Files
2026-06-10 20:02:23 +05:30

357 lines
12 KiB
TypeScript

"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, type UserSummary } from "@/lib/social";
import { MentionAutocomplete } from "@/components/social/mention-autocomplete";
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<HTMLInputElement>(null);
const [images, setImages] = useState<{ url: string; uploading: boolean }[]>(
[],
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Users tagged on this post (separate from the locked caption).
const [mentions, setMentions] = useState<UserSummary[]>([]);
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 {
// 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 <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
: "";
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),
mention_user_ids: mentions
.map((m) => m.id)
.filter((id): id is string => !!id),
});
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>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Mention people
</p>
{mentions.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{mentions.map((u) => (
<span
key={u.id ?? u.handle}
className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-sm text-foreground"
>
@{u.handle}
<button
type="button"
onClick={() =>
setMentions((prev) => prev.filter((m) => m.id !== u.id))
}
aria-label={`Remove @${u.handle}`}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
</span>
))}
</div>
) : null}
<div className="mt-2">
<MentionAutocomplete
excludeIds={mentions
.map((m) => m.id)
.filter((id): id is string => !!id)}
onSelect={(u) =>
setMentions((prev) =>
prev.some((m) => m.id === u.id) ? prev : [...prev, u],
)
}
/>
</div>
</div>
<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}
repositionInputs={false}
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="max-h-[75vh] overflow-y-auto 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 flex max-h-[85vh] w-full max-w-md -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden 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="overflow-y-auto p-5">{body}</div>
</div>
</>,
document.body,
);
}