307 lines
10 KiB
TypeScript
307 lines
10 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 } 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<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 {
|
|
// 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),
|
|
});
|
|
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,
|
|
);
|
|
}
|