feat: add user centric features

This commit is contained in:
gosangam
2026-05-30 23:44:08 +05:30
parent 7bd2952caa
commit dcdd2383c6
63 changed files with 16783 additions and 1735 deletions
@@ -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,
);
}
+63 -79
View File
@@ -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>,
+4 -1
View File
@@ -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?.();
},
},
{