feat: add user centric features
This commit is contained in:
@@ -1,24 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { PSEUDO_COLLECTIONS } from "./shared";
|
||||
import { FolderPlus } from "lucide-react";
|
||||
/**
|
||||
* CollectionsDrawer — "Save to Collection" picker, reused in two flows:
|
||||
*
|
||||
* • Selection toolbar — caller passes `selectedText`. On row click we
|
||||
* POST a new public post first, then add it to the chosen collection.
|
||||
*
|
||||
* • Post card footer — caller passes `existingPostId` (the post is
|
||||
* already saved). We skip the create step and only call addItem.
|
||||
*
|
||||
* Exactly one of `selectedText` or `existingPostId` is meaningful per
|
||||
* mount. Per the product spec: collections always hold posts, every
|
||||
* post is public on the user's feed, and bookmarks are the private save.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { FolderPlus, Check, Plus, Loader2 } from "lucide-react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import {
|
||||
collections,
|
||||
posts as postsApi,
|
||||
SocialError,
|
||||
type Collection,
|
||||
} from "@/lib/social";
|
||||
import { buildHighlightUrl } from "@/lib/text-highlight";
|
||||
import { getSourceReference } from "@/lib/source-reference";
|
||||
|
||||
type Status = "idle" | "saving" | "saved" | "auth" | "limit" | "error";
|
||||
|
||||
export function CollectionsDrawer({
|
||||
open,
|
||||
onClose,
|
||||
isMobile,
|
||||
selectedText,
|
||||
existingPostId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
isMobile: boolean;
|
||||
/** Provided by the selection toolbar — the drawer will create a fresh
|
||||
* post from this text before adding it to a collection. */
|
||||
selectedText?: string;
|
||||
/** Provided by post-card footers — the drawer skips post creation and
|
||||
* attaches the existing post id to the chosen collection. */
|
||||
existingPostId?: string;
|
||||
}) {
|
||||
const [list, setList] = useState<Collection[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// For the existing-post mode: which collections already contain the
|
||||
// post, so we can disable those rows. Empty for the toolbar mode
|
||||
// (a brand-new post can't belong to anything yet).
|
||||
const [alreadyIn, setAlreadyIn] = useState<Set<string>>(new Set());
|
||||
|
||||
// Per-row save status — keyed by collection id so each row can show its
|
||||
// own "saving / saved" state without blocking the others.
|
||||
const [rowStatus, setRowStatus] = useState<Record<string, Status>>({});
|
||||
const [rowMsg, setRowMsg] = useState<Record<string, string>>({});
|
||||
|
||||
// Inline "new collection" form state.
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [newIsPublic, setNewIsPublic] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Fetch the user's collections in parallel with the post→collection
|
||||
// membership lookup so the rows render with the disabled state set
|
||||
// on first paint (no flicker from "Save" → "Saved").
|
||||
const tasks: Promise<unknown>[] = [
|
||||
collections
|
||||
.mine()
|
||||
.then((rows) => setList(rows ?? []))
|
||||
.catch((err) => {
|
||||
if (err instanceof SocialError && err.status === 401) {
|
||||
setError("Log in to use collections.");
|
||||
} else {
|
||||
setError(
|
||||
err instanceof SocialError ? err.message : "Could not load collections.",
|
||||
);
|
||||
}
|
||||
}),
|
||||
];
|
||||
if (existingPostId) {
|
||||
tasks.push(
|
||||
collections
|
||||
.containingPost(existingPostId)
|
||||
.then((ids) => setAlreadyIn(new Set(ids)))
|
||||
.catch(() => {
|
||||
/* Non-fatal — drawer still works without the disabled hints. */
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setAlreadyIn(new Set());
|
||||
}
|
||||
Promise.all(tasks).finally(() => setLoading(false));
|
||||
}, [open, existingPostId]);
|
||||
|
||||
// Reset transient UI state when drawer closes.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setRowStatus({});
|
||||
setRowMsg({});
|
||||
setShowCreate(false);
|
||||
setNewTitle("");
|
||||
setAlreadyIn(new Set());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function saveTo(collectionID: string) {
|
||||
setRowStatus((s) => ({ ...s, [collectionID]: "saving" }));
|
||||
setRowMsg((m) => ({ ...m, [collectionID]: "" }));
|
||||
try {
|
||||
// Two paths:
|
||||
// - existingPostId provided → just attach.
|
||||
// - selectedText provided → create a post first, then attach.
|
||||
let postID = existingPostId ?? null;
|
||||
if (!postID) {
|
||||
const sourceURL = buildHighlightUrl(selectedText ?? "");
|
||||
const ref = getSourceReference();
|
||||
const sourceTitle = ref
|
||||
? ref.detail
|
||||
? `${ref.title} — ${ref.detail}`
|
||||
: ref.title
|
||||
: typeof document !== "undefined"
|
||||
? document.title
|
||||
: "";
|
||||
const post = await postsApi.create({
|
||||
caption: selectedText ?? "",
|
||||
source_url: sourceURL,
|
||||
source_title: sourceTitle,
|
||||
});
|
||||
postID = post.id;
|
||||
}
|
||||
await collections.addItem(collectionID, postID);
|
||||
setRowStatus((s) => ({ ...s, [collectionID]: "saved" }));
|
||||
// Bump the local item_count so the user sees the change without a refetch.
|
||||
setList((rows) =>
|
||||
rows.map((c) =>
|
||||
c.id === collectionID ? { ...c, item_count: c.item_count + 1 } : c,
|
||||
),
|
||||
);
|
||||
setTimeout(onClose, 800);
|
||||
} catch (err) {
|
||||
if (err instanceof SocialError) {
|
||||
const tone: Status =
|
||||
err.status === 401 ? "auth" : err.status === 402 ? "limit" : "error";
|
||||
setRowStatus((s) => ({ ...s, [collectionID]: tone }));
|
||||
setRowMsg((m) => ({ ...m, [collectionID]: err.message }));
|
||||
} else {
|
||||
setRowStatus((s) => ({ ...s, [collectionID]: "error" }));
|
||||
setRowMsg((m) => ({ ...m, [collectionID]: "Save failed." }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createCollection() {
|
||||
const title = newTitle.trim();
|
||||
if (!title) return;
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const c = await collections.create({ title, is_public: newIsPublic });
|
||||
setList((rows) => [c, ...rows]);
|
||||
setNewTitle("");
|
||||
setShowCreate(false);
|
||||
// Auto-save the quote into the new collection so the user gets to the
|
||||
// payoff in one step.
|
||||
void saveTo(c.id);
|
||||
} catch (err) {
|
||||
if (err instanceof SocialError && err.status === 402) {
|
||||
setError(err.message);
|
||||
} else if (err instanceof SocialError && err.status === 401) {
|
||||
setError("Log in to create a collection.");
|
||||
} else {
|
||||
setError(
|
||||
err instanceof SocialError ? err.message : "Could not create collection.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const direction = isMobile ? "bottom" : "right";
|
||||
|
||||
return (
|
||||
@@ -32,47 +204,125 @@ export function CollectionsDrawer({
|
||||
className="bg-black dark:bg-[#1a1a1a] border-white/10"
|
||||
>
|
||||
<DrawerHeader className="text-left border-b border-white/10 pb-4">
|
||||
<DrawerTitle className="text-white">Save to Collection</DrawerTitle>
|
||||
<DrawerTitle className="text-white">Save to collection</DrawerTitle>
|
||||
{selectedText ? (
|
||||
<p className="text-xs text-white/50 mt-1 line-clamp-2">
|
||||
“{selectedText.slice(0, 140)}
|
||||
{selectedText.length > 140 ? "…" : ""}”
|
||||
</p>
|
||||
) : null}
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="overflow-y-auto flex-1 p-4 space-y-1">
|
||||
{PSEUDO_COLLECTIONS.map((col) => (
|
||||
<button
|
||||
key={col.id}
|
||||
onClick={() => {
|
||||
// TODO: Implement save to collection
|
||||
onClose();
|
||||
}}
|
||||
className="flex items-center justify-between gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-9 rounded-lg bg-white/10">
|
||||
<FolderPlus className="size-4 text-white/50" />
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-white/60">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-sm text-amber-400 px-3 py-2">{error}</p>
|
||||
) : null}
|
||||
|
||||
{!loading && !error && list.length === 0 ? (
|
||||
<p className="text-sm text-white/50 px-3 py-2">
|
||||
No collections yet — create one below.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{list.map((c) => {
|
||||
const st = rowStatus[c.id] ?? "idle";
|
||||
// A row is "already saved" if the BFF told us this post is
|
||||
// already in this collection. We treat that the same as
|
||||
// st === "saved" (button locked, check mark shown), just
|
||||
// with a different sub-line for the user.
|
||||
const alreadySaved = alreadyIn.has(c.id);
|
||||
const locked = alreadySaved || st === "saving" || st === "saved";
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => saveTo(c.id)}
|
||||
disabled={locked}
|
||||
aria-disabled={locked}
|
||||
className="flex items-center justify-between gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors disabled:hover:bg-transparent disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex items-center justify-center size-9 rounded-lg bg-white/10 shrink-0">
|
||||
<FolderPlus className="size-4 text-white/60" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-white/90 truncate">
|
||||
{c.title}
|
||||
</p>
|
||||
<p className="text-xs text-white/50 truncate">
|
||||
{alreadySaved
|
||||
? "Already in this collection"
|
||||
: `${c.is_public ? "Public" : "Private"} · ${c.item_count} item${
|
||||
c.item_count === 1 ? "" : "s"
|
||||
}${rowMsg[c.id] ? ` · ${rowMsg[c.id]}` : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-white/90">
|
||||
{col.name}
|
||||
<span className="shrink-0 text-xs text-white/50">
|
||||
{st === "saving" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : alreadySaved || st === "saved" ? (
|
||||
<Check className="size-4 text-emerald-400" />
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Inline "new collection" panel */}
|
||||
{!loading && !error && !showCreate ? (
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors mt-2 border border-dashed border-white/20"
|
||||
>
|
||||
<div className="flex items-center justify-center size-9 rounded-lg bg-amber-400/15">
|
||||
<Plus className="size-4 text-amber-400" />
|
||||
</div>
|
||||
<span className="text-xs text-white/50 tabular-nums">
|
||||
{col.count}
|
||||
<span className="text-sm font-medium text-amber-400">
|
||||
New collection
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
) : null}
|
||||
|
||||
{/* Create new collection */}
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement create collection
|
||||
onClose();
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors mt-2 border border-dashed border-white/20"
|
||||
>
|
||||
<div className="flex items-center justify-center size-9 rounded-lg bg-amber-400/15">
|
||||
<FolderPlus className="size-4 text-amber-400" />
|
||||
{showCreate ? (
|
||||
<div className="mt-2 rounded-lg border border-white/10 p-3 space-y-3">
|
||||
<input
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder="Collection title"
|
||||
maxLength={120}
|
||||
className="w-full bg-white/5 text-white text-sm rounded-md px-3 py-2 placeholder:text-white/30 outline-none focus:bg-white/10"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs text-white/60">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newIsPublic}
|
||||
onChange={(e) => setNewIsPublic(e.target.checked)}
|
||||
/>
|
||||
Public
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="px-3 py-1.5 rounded-md hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={createCollection}
|
||||
disabled={creating || !newTitle.trim()}
|
||||
className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground disabled:opacity-60"
|
||||
>
|
||||
{creating ? "Creating…" : "Create & save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-amber-400">
|
||||
New Collection
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
/**
|
||||
* MorePopover — generic secondary-action menu for the selection toolbar.
|
||||
*
|
||||
* The parent (`TextSelectionToolbar`) owns all side effects and builds
|
||||
* the appropriate `items` array based on auth state. Items can be marked
|
||||
* `locked` to render a small Lock badge — used for the public flow
|
||||
* where every More action sits behind the auth wall.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCopyFeedback, type Position } from "./shared";
|
||||
import { Bookmark, FolderPlus, AtSign } from "lucide-react";
|
||||
import { Lock } from "lucide-react";
|
||||
import { type Position } from "./shared";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -12,35 +21,37 @@ import {
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
|
||||
export type MoreItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
/** Show a lock badge — indicates the action is behind the auth wall. */
|
||||
locked?: boolean;
|
||||
};
|
||||
|
||||
export function MorePopover({
|
||||
open,
|
||||
position,
|
||||
onClose,
|
||||
onBookmark,
|
||||
onSaveToCollection,
|
||||
onMention,
|
||||
items,
|
||||
isMobile,
|
||||
selectedText,
|
||||
}: {
|
||||
open: boolean;
|
||||
position: Position;
|
||||
onClose: () => void;
|
||||
onBookmark: () => void;
|
||||
onSaveToCollection: () => void;
|
||||
onMention: () => void;
|
||||
items: MoreItem[];
|
||||
isMobile: boolean;
|
||||
selectedText: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { copied, copy } = useCopyFeedback();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || isMobile) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
}
|
||||
// Delay so the click that opened us doesn't immediately fire it.
|
||||
const timer = setTimeout(() => {
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
}, 10);
|
||||
@@ -52,30 +63,6 @@ export function MorePopover({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const actions = [
|
||||
{
|
||||
key: "bookmark",
|
||||
label: "Add Bookmark",
|
||||
description: "Save your place",
|
||||
icon: <Bookmark className="size-5" />,
|
||||
onClick: onBookmark,
|
||||
},
|
||||
{
|
||||
key: "collection",
|
||||
label: "Save to Collection",
|
||||
description: "Organize into a collection",
|
||||
icon: <FolderPlus className="size-5" />,
|
||||
onClick: onSaveToCollection,
|
||||
},
|
||||
{
|
||||
key: "mention",
|
||||
label: "Mention",
|
||||
description: "Tag someone with this quote",
|
||||
icon: <AtSign className="size-5" />,
|
||||
onClick: onMention,
|
||||
},
|
||||
];
|
||||
|
||||
// Mobile: bottom drawer
|
||||
if (isMobile) {
|
||||
return (
|
||||
@@ -85,27 +72,29 @@ export function MorePopover({
|
||||
<DrawerTitle className="text-white">More Options</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="p-4 space-y-1">
|
||||
{actions.map((action) => (
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={action.key}
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick();
|
||||
if (action.key !== "copy-link") {
|
||||
onClose();
|
||||
}
|
||||
item.onClick();
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-center size-10 rounded-lg bg-white/10 text-white/60">
|
||||
{action.icon}
|
||||
{item.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/90">
|
||||
{action.label}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white/90 truncate">
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="text-xs text-white/50 truncate">
|
||||
{item.description}
|
||||
</p>
|
||||
<p className="text-xs text-white/50">{action.description}</p>
|
||||
</div>
|
||||
{item.locked ? (
|
||||
<Lock className="size-3.5 text-white/40 shrink-0" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -121,54 +110,49 @@ export function MorePopover({
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: floating popover
|
||||
const popoverW = 220;
|
||||
const popoverH = actions.length * 44 + 8;
|
||||
// Desktop: floating popover anchored to `position`, flipping when it
|
||||
// would overflow the viewport.
|
||||
const popoverW = 260;
|
||||
const popoverH = items.length * 56 + 8;
|
||||
const pad = 8;
|
||||
|
||||
let popLeft = position.x;
|
||||
let popTop = position.y + 8;
|
||||
|
||||
if (popLeft + popoverW > window.innerWidth - pad) {
|
||||
popLeft = window.innerWidth - popoverW - pad;
|
||||
}
|
||||
if (popLeft < pad) {
|
||||
popLeft = pad;
|
||||
}
|
||||
if (popLeft < pad) popLeft = pad;
|
||||
if (popTop + popoverH > window.innerHeight - pad) {
|
||||
popTop = position.y - popoverH - 8;
|
||||
}
|
||||
if (popTop < pad) {
|
||||
popTop = pad;
|
||||
}
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
zIndex: 90,
|
||||
left: popLeft,
|
||||
top: popTop,
|
||||
};
|
||||
if (popTop < pad) popTop = pad;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
style={style}
|
||||
className="w-55 rounded-lg border border-white/10 bg-black shadow-2xl py-1.5 animate-in fade-in-0 zoom-in-95 dark:bg-[#1a1a1a] dark:border-white/15"
|
||||
style={{ position: "fixed", zIndex: 90, left: popLeft, top: popTop }}
|
||||
className="w-64 rounded-lg border border-white/10 bg-black shadow-2xl py-1.5 animate-in fade-in-0 zoom-in-95 dark:bg-[#1a1a1a] dark:border-white/15"
|
||||
>
|
||||
{actions.map((action) => (
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={action.key}
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick();
|
||||
if (action.key !== "copy-link") {
|
||||
onClose();
|
||||
}
|
||||
item.onClick();
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-white/90 hover:bg-white/10 transition-colors text-left"
|
||||
className="flex items-start gap-3 w-full px-3 py-2.5 text-left hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<span className="text-white/50">{action.icon}</span>
|
||||
{action.label}
|
||||
<span className="text-white/60 mt-0.5">{item.icon}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm text-white/90 truncate">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="block text-xs text-white/50 truncate">
|
||||
{item.description}
|
||||
</span>
|
||||
</span>
|
||||
{item.locked ? (
|
||||
<Lock className="size-3.5 text-white/40 shrink-0 mt-1" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
|
||||
@@ -18,11 +18,14 @@ export function ShareModal({
|
||||
onClose,
|
||||
selectedText,
|
||||
isMobile,
|
||||
onShareToFeed,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
selectedText: string;
|
||||
isMobile: boolean;
|
||||
/** Called when the user picks "Share to feed" — parent opens the compose modal. */
|
||||
onShareToFeed?: () => void;
|
||||
}) {
|
||||
const { copied, copy } = useCopyFeedback();
|
||||
const [generating, setGenerating] = useState(false);
|
||||
@@ -45,8 +48,8 @@ export function ShareModal({
|
||||
icon: <MessageSquare className="size-4" />,
|
||||
description: "Post this quote to your feed",
|
||||
onClick: () => {
|
||||
// TODO: Implement share to feed
|
||||
onClose();
|
||||
onShareToFeed?.();
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user