feat: add user centric features
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user