feat: add user centric features

This commit is contained in:
gosangam
2026-05-30 23:44:08 +05:30
parent 7bd2952caa
commit dcdd2383c6
63 changed files with 16783 additions and 1735 deletions
@@ -0,0 +1,258 @@
"use client";
/**
* ComposePost — drawer (mobile) / modal (desktop) for "Share to Feed".
*
* The caption is locked to the exact selected text per spec:
* "Each post must have only the selected text as its caption with a link
* to the original text URL from where it was copied."
*
* Users can attach up to 5 images. Each image is uploaded to R2 via the
* BFF presign endpoint before submit, so submit only sends already-stored
* public URLs (no multipart upload through the BFF).
*/
import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { X, ImagePlus, Trash2, Send } from "lucide-react";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { posts, SocialError } from "@/lib/social";
import { buildHighlightUrl } from "@/lib/text-highlight";
import { getSourceReference } from "@/lib/source-reference";
const MAX_IMAGES = 5;
const PRESS_IMAGE_TYPES = "image/png,image/jpeg,image/webp,image/gif";
export function ComposePost({
open,
onClose,
selectedText,
isMobile,
onPosted,
}: {
open: boolean;
onClose: () => void;
selectedText: string;
isMobile: boolean;
onPosted?: () => void;
}) {
const router = useRouter();
const fileInput = useRef<HTMLInputElement>(null);
const [images, setImages] = useState<{ url: string; uploading: boolean }[]>(
[],
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onFiles(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
if (fileInput.current) fileInput.current.value = "";
if (files.length === 0) return;
const slotsLeft = MAX_IMAGES - images.length;
if (slotsLeft <= 0) {
setError(`A post can hold at most ${MAX_IMAGES} images.`);
return;
}
setError(null);
const picked = files.slice(0, slotsLeft);
// Optimistically show "uploading" tiles; replace with real URLs as each
// upload settles. We track each upload by its index so ordering stays stable.
const baseIndex = images.length;
setImages((prev) => [
...prev,
...picked.map(() => ({ url: "", uploading: true })),
]);
await Promise.all(
picked.map(async (file, i) => {
try {
const url = await posts.uploadImage(file);
setImages((prev) => {
const next = [...prev];
next[baseIndex + i] = { url, uploading: false };
return next;
});
} catch (err) {
setImages((prev) => prev.filter((_, idx) => idx !== baseIndex + i));
setError(
err instanceof SocialError ? err.message : "Image upload failed.",
);
}
}),
);
}
function removeImage(idx: number) {
setImages((prev) => prev.filter((_, i) => i !== idx));
}
async function submit() {
setError(null);
setSubmitting(true);
try {
const sourceURL = buildHighlightUrl(selectedText);
// Prefer the structured reference (e.g. "Bhagavad-gītā As It Is —
// Chapter 1, Verse 1") over the raw <title>, which often carries
// site branding ("— Prabhupāda Says") and isn't a clean citation.
const ref = getSourceReference();
const sourceTitle = ref
? ref.detail
? `${ref.title} — ${ref.detail}`
: ref.title
: typeof document !== "undefined"
? document.title
: "";
await posts.create({
caption: selectedText,
source_url: sourceURL,
source_title: sourceTitle,
image_urls: images
.filter((img) => !img.uploading && img.url)
.map((img) => img.url),
});
onPosted?.();
onClose();
router.refresh();
} catch (err) {
if (err instanceof SocialError && err.status === 401) {
setError("Please log in to post.");
} else {
setError(err instanceof SocialError ? err.message : "Post failed.");
}
} finally {
setSubmitting(false);
}
}
const body = (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-muted/40 p-4">
<p className="text-[0.7rem] uppercase tracking-[0.18em] text-muted-foreground">
Caption (locked)
</p>
<p className="mt-2 font-[family-name:var(--font-title)] text-base leading-relaxed text-foreground">
“{selectedText}”
</p>
</div>
<div>
<div className="flex items-center justify-between">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Images ({images.length}/{MAX_IMAGES})
</p>
<button
type="button"
onClick={() => fileInput.current?.click()}
disabled={images.length >= MAX_IMAGES || submitting}
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline disabled:opacity-50"
>
<ImagePlus className="size-4" />
Add
</button>
<input
ref={fileInput}
type="file"
multiple
accept={PRESS_IMAGE_TYPES}
hidden
onChange={onFiles}
/>
</div>
{images.length > 0 ? (
<div className="mt-3 grid grid-cols-3 sm:grid-cols-4 gap-2">
{images.map((img, i) => (
<div
key={i}
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10"
>
{img.uploading ? (
<span className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground">
Uploading…
</span>
) : (
<>
<Image
src={img.url}
alt=""
fill
sizes="120px"
className="object-cover"
unoptimized
/>
<button
type="button"
onClick={() => removeImage(i)}
className="absolute top-1 right-1 size-6 rounded-full bg-black/70 text-white inline-flex items-center justify-center hover:bg-black/85"
aria-label="Remove image"
>
<Trash2 className="size-3.5" />
</button>
</>
)}
</div>
))}
</div>
) : null}
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<button
type="button"
onClick={submit}
disabled={
submitting || images.some((i) => i.uploading) || !selectedText
}
className="w-full h-11 inline-flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground text-base font-medium disabled:opacity-60"
>
<Send className="size-4" />
{submitting ? "Posting…" : "Post to feed"}
</button>
</div>
);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={(o) => !o && onClose()}>
<DrawerContent className="bg-card border-border">
<DrawerHeader className="text-left border-b border-border pb-4">
<DrawerTitle>Share to feed</DrawerTitle>
</DrawerHeader>
<div className="p-4 pb-8">{body}</div>
</DrawerContent>
</Drawer>
);
}
if (!open) return null;
return createPortal(
<>
<div
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm"
onClick={onClose}
/>
<div className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border bg-card shadow-xl">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h2 className="font-[family-name:var(--font-title)] font-semibold text-lg">
Share to feed
</h2>
<button
onClick={onClose}
className="size-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center"
aria-label="Close"
>
<X className="size-4" />
</button>
</div>
<div className="p-5">{body}</div>
</div>
</>,
document.body,
);
}