"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 { toast } from "sonner"; 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"; import { compressImage } from "@/lib/compress-image"; import { useSharePost } from "@/components/social/share-post-provider"; const MAX_IMAGES = 5; const PRESS_IMAGE_TYPES = "image/png,image/jpeg,image/webp,image/gif"; // Lazy-init singleton for the success chime. The user clicked "Post" // to get here, so we're inside an active user-gesture context — modern // browsers (incl. iOS Safari) honour audio playback on that activation. // Anything that goes wrong (autoplay policy, decode failure, the file // not yet cached on first run) falls through silently; the visible // toast already communicates success, the sound is icing. let successAudio: HTMLAudioElement | null = null; function playSuccessSound() { if (typeof window === "undefined") return; try { if (!successAudio) { successAudio = new Audio("/audio/success.mp3"); successAudio.preload = "auto"; } // Reset so rapid posts get a fresh chime instead of being // ignored as "already playing". successAudio.currentTime = 0; void successAudio.play().catch(() => { // Silent — see comment above. }); } catch { // Audio constructor unavailable or quota'd — silent. } } export function ComposePost({ open, onClose, selectedText, isMobile, onPosted, }: { open: boolean; onClose: () => void; selectedText: string; isMobile: boolean; onPosted?: () => void; }) { const router = useRouter(); const { openShareSheet } = useSharePost(); const fileInput = useRef(null); const [images, setImages] = useState<{ url: string; uploading: boolean }[]>( [], ); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); async function onFiles(e: React.ChangeEvent) { 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 { // Compress in the browser before the presigned PUT — keeps // R2 storage bounded, makes share pre-fetch fast, strips // EXIF as a side effect. Falls back to the original file // on any decode failure. const compressed = await compressImage(file); const url = await posts.uploadImage(compressed); 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 , 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 : ""; const newPost = 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(); playSuccessSound(); // Success feedback + offer to externally share. The BFF's Create // response doesn't include author info (no JOIN with users), but // SharePostSheet falls back to the authed user's handle when // post.author_handle is missing — perfect for a freshly-created // post since the creator IS the auth user. toast.success("Posted to your feed", { description: "Want to share it to other apps?", action: { label: "Share", onClick: () => openShareSheet(newPost), }, duration: 5000, }); } 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, ); }