feat: add user centric features
This commit is contained in:
@@ -1,8 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Share2, Copy, MoreHorizontal, Check } from "lucide-react";
|
||||
import { formatCopyText } from "@/lib/source-reference";
|
||||
import {
|
||||
AtSign,
|
||||
Bookmark as BookmarkIcon,
|
||||
Copy,
|
||||
FolderPlus,
|
||||
Link as LinkIcon,
|
||||
MoreHorizontal,
|
||||
Send,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatCopyText, getSourceReference } from "@/lib/source-reference";
|
||||
import {
|
||||
buildHighlightUrl,
|
||||
buildShortHighlightUrl,
|
||||
} from "@/lib/text-highlight";
|
||||
import { bookmarks, SocialError } from "@/lib/social";
|
||||
import {
|
||||
KautilyaIcon,
|
||||
useMediaQuery,
|
||||
@@ -11,9 +26,11 @@ import {
|
||||
type ToolbarAction,
|
||||
} from "./selection-toolbar/shared";
|
||||
import { FloatingToolbar } from "./selection-toolbar/floating-toolbar";
|
||||
import { ShareModal } from "./selection-toolbar/share-modal";
|
||||
import { CollectionsDrawer } from "./selection-toolbar/collections-drawer";
|
||||
import { MorePopover } from "./selection-toolbar/more-popover";
|
||||
import { MorePopover, type MoreItem } from "./selection-toolbar/more-popover";
|
||||
import { ComposePost } from "./selection-toolbar/compose-post";
|
||||
import { useAuth } from "./auth/auth-provider";
|
||||
import { useAuthGate } from "./auth/auth-gate";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Component
|
||||
@@ -24,10 +41,45 @@ export function TextSelectionToolbar() {
|
||||
const [toolbarPos, setToolbarPos] = useState<Position | null>(null);
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
const [morePos, setMorePos] = useState<Position>({ x: 0, y: 0 });
|
||||
const [showShare, setShowShare] = useState(false);
|
||||
const [showCollections, setShowCollections] = useState(false);
|
||||
const { copied, copy } = useCopyFeedback();
|
||||
const [showCompose, setShowCompose] = useState(false);
|
||||
const { copy } = useCopyFeedback();
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const { requireAuth } = useAuthGate();
|
||||
const { user } = useAuth();
|
||||
|
||||
// Cached bookmark count → drives the "Bookmark" / "Collect" swap.
|
||||
// We fetch on auth state change and react to the toolbar's own
|
||||
// `spsays:bookmarks-changed` event so the label flips immediately
|
||||
// after saving the user's last allowed bookmark.
|
||||
const [bookmarkCount, setBookmarkCount] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setBookmarkCount(0);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const refetch = () => {
|
||||
bookmarks
|
||||
.list()
|
||||
.then((rows) => {
|
||||
if (!cancelled) setBookmarkCount(rows?.length ?? 0);
|
||||
})
|
||||
.catch(() => {
|
||||
/* non-essential — swallow */
|
||||
});
|
||||
};
|
||||
refetch();
|
||||
const onChanged = () => refetch();
|
||||
window.addEventListener("spsays:bookmarks-changed", onChanged);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("spsays:bookmarks-changed", onChanged);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const bookmarkLimit = user?.plan === "premium" ? 5 : 1;
|
||||
const limitReached = !!user && bookmarkCount >= bookmarkLimit;
|
||||
|
||||
// Track whether the user is interacting with our toolbar/popovers
|
||||
const interactingRef = useRef(false);
|
||||
@@ -66,7 +118,12 @@ export function TextSelectionToolbar() {
|
||||
useEffect(() => {
|
||||
function handleSelectionChange() {
|
||||
// Don't dismiss while interacting with toolbar menus
|
||||
if (interactingRef.current || showMore || showShare || showCollections) {
|
||||
if (
|
||||
interactingRef.current ||
|
||||
showMore ||
|
||||
showCollections ||
|
||||
showCompose
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -186,7 +243,7 @@ export function TextSelectionToolbar() {
|
||||
document.removeEventListener("touchstart", onTouchStart);
|
||||
document.removeEventListener("keyup", onKeyUp);
|
||||
};
|
||||
}, [clearSelection, showMore, showShare, showCollections]);
|
||||
}, [clearSelection, showMore, showCollections, showCompose]);
|
||||
|
||||
// Intercept native Ctrl+C / Cmd+C to append source reference
|
||||
useEffect(() => {
|
||||
@@ -230,7 +287,7 @@ export function TextSelectionToolbar() {
|
||||
|
||||
const target = e.target as Element;
|
||||
if (target.closest("[data-selection-toolbar]")) return;
|
||||
if (showMore || showShare || showCollections) return;
|
||||
if (showMore || showCollections || showCompose) return;
|
||||
|
||||
const sel = window.getSelection();
|
||||
const text = sel?.toString().trim() ?? "";
|
||||
@@ -241,7 +298,7 @@ export function TextSelectionToolbar() {
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||
}, [clearSelection, showMore, showShare, showCollections]);
|
||||
}, [clearSelection, showMore, showCollections, showCompose]);
|
||||
|
||||
// Don't render on RTF viewer or if nothing is selected
|
||||
if (
|
||||
@@ -253,60 +310,245 @@ export function TextSelectionToolbar() {
|
||||
|
||||
if (!toolbarPos || !selectedText) return null;
|
||||
|
||||
// --- Toolbar actions ---
|
||||
// ─── Action handlers ──────────────────────────────────────────────────
|
||||
|
||||
const toolbarActions: ToolbarAction[] = [
|
||||
function openCollectionsDrawer() {
|
||||
if (isMobile) saveAndClearSelection();
|
||||
else interactingRef.current = true;
|
||||
setShowCollections(true);
|
||||
}
|
||||
|
||||
async function onPost() {
|
||||
if (!(await requireAuth())) return;
|
||||
if (isMobile) saveAndClearSelection();
|
||||
else interactingRef.current = true;
|
||||
setShowCompose(true);
|
||||
}
|
||||
|
||||
async function onBookmarkOrCollect() {
|
||||
// Fast path: already at cap → reuse this click to open Save to
|
||||
// Collection. The label is "Collect" in this state so the user knows.
|
||||
if (limitReached) {
|
||||
openCollectionsDrawer();
|
||||
return;
|
||||
}
|
||||
if (!(await requireAuth())) return;
|
||||
try {
|
||||
const sourceURL = buildHighlightUrl(selectedText);
|
||||
const ref = getSourceReference();
|
||||
const sourceTitle = ref
|
||||
? ref.detail
|
||||
? `${ref.title} — ${ref.detail}`
|
||||
: ref.title
|
||||
: typeof document !== "undefined"
|
||||
? document.title
|
||||
: "";
|
||||
await bookmarks.create({
|
||||
source_url: sourceURL,
|
||||
source_title: sourceTitle,
|
||||
excerpt: selectedText,
|
||||
});
|
||||
toast.success("Bookmark saved", { duration: 2000 });
|
||||
window.dispatchEvent(new CustomEvent("spsays:bookmarks-changed"));
|
||||
clearSelection();
|
||||
} catch (err) {
|
||||
if (err instanceof SocialError && err.status === 402) {
|
||||
toast.error("Bookmark limit reached", {
|
||||
description:
|
||||
"Free plan allows 1 bookmark. We'll save this to a collection instead.",
|
||||
duration: 6000,
|
||||
});
|
||||
openCollectionsDrawer();
|
||||
} else if (err instanceof SocialError) {
|
||||
toast.error(err.message);
|
||||
} else {
|
||||
toast.error("Could not save bookmark.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onCopyText() {
|
||||
await copy(formatCopyText(selectedText));
|
||||
toast.success("Copied to clipboard", { duration: 1500 });
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
async function onCopyLink() {
|
||||
const url = await buildShortHighlightUrl(selectedText);
|
||||
await copy(url);
|
||||
toast.success("Link copied", { duration: 1500 });
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
async function onSaveToCollection() {
|
||||
setShowMore(false);
|
||||
if (!(await requireAuth())) return;
|
||||
openCollectionsDrawer();
|
||||
}
|
||||
|
||||
// ─── Toolbar actions ──────────────────────────────────────────────────
|
||||
//
|
||||
// Authed users get action-oriented top-level buttons (Post / Bookmark)
|
||||
// and More holds copy/share. Public users get the universally-usable
|
||||
// copy/share at the top and every account-requiring action sits behind
|
||||
// a lock in More.
|
||||
|
||||
const isAuthed = !!user;
|
||||
|
||||
const askAction: ToolbarAction = {
|
||||
key: "ask",
|
||||
label: "Ask Kautilya",
|
||||
icon: <KautilyaIcon className="size-3.5" />,
|
||||
onClick: () => {
|
||||
// TODO: Implement Ask Kautilya
|
||||
clearSelection();
|
||||
},
|
||||
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
|
||||
};
|
||||
|
||||
const moreAction: ToolbarAction = {
|
||||
key: "more",
|
||||
label: "More",
|
||||
icon: <MoreHorizontal className="size-3.5" />,
|
||||
onClick: () => {
|
||||
if (isMobile) saveAndClearSelection();
|
||||
else interactingRef.current = true;
|
||||
setMorePos({
|
||||
x: toolbarPos.x + 60,
|
||||
y: toolbarPos.y,
|
||||
});
|
||||
setShowMore(true);
|
||||
},
|
||||
};
|
||||
|
||||
const authedToolbarActions: ToolbarAction[] = [
|
||||
askAction,
|
||||
{
|
||||
key: "ask",
|
||||
label: "Ask Kautilya",
|
||||
icon: <KautilyaIcon className="size-3.5" />,
|
||||
onClick: () => {
|
||||
// TODO: Implement Ask Kautilya
|
||||
clearSelection();
|
||||
},
|
||||
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
|
||||
key: "post",
|
||||
label: "Post",
|
||||
icon: <Send className="size-3.5" />,
|
||||
onClick: onPost,
|
||||
},
|
||||
{
|
||||
key: "bookmark",
|
||||
label: limitReached ? "Collect" : "Bookmark",
|
||||
icon: limitReached ? (
|
||||
<FolderPlus className="size-3.5" />
|
||||
) : (
|
||||
<BookmarkIcon className="size-3.5" />
|
||||
),
|
||||
onClick: onBookmarkOrCollect,
|
||||
},
|
||||
moreAction,
|
||||
];
|
||||
|
||||
const publicToolbarActions: ToolbarAction[] = [
|
||||
askAction,
|
||||
{
|
||||
key: "copy",
|
||||
label: "Copy",
|
||||
icon: <Copy className="size-3.5" />,
|
||||
onClick: onCopyText,
|
||||
},
|
||||
{
|
||||
key: "share",
|
||||
label: "Share",
|
||||
icon: <Share2 className="size-3.5" />,
|
||||
onClick: () => {
|
||||
if (isMobile) saveAndClearSelection();
|
||||
else interactingRef.current = true;
|
||||
setShowShare(true);
|
||||
onClick: onCopyLink,
|
||||
},
|
||||
moreAction,
|
||||
];
|
||||
|
||||
const toolbarActions = isAuthed ? authedToolbarActions : publicToolbarActions;
|
||||
|
||||
const authedMoreItems: MoreItem[] = [
|
||||
{
|
||||
key: "copy-text",
|
||||
label: "Copy Text",
|
||||
description: "Passage with source citation",
|
||||
icon: <Copy className="size-5" />,
|
||||
onClick: async () => {
|
||||
setShowMore(false);
|
||||
await onCopyText();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "copy",
|
||||
label: copied ? "Copied!" : "Copy",
|
||||
icon: copied ? (
|
||||
<Check className="size-3.5" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
),
|
||||
onClick: () => {
|
||||
copy(formatCopyText(selectedText));
|
||||
key: "copy-link",
|
||||
label: "Copy Link",
|
||||
description: "Short URL that highlights this passage",
|
||||
icon: <LinkIcon className="size-5" />,
|
||||
onClick: async () => {
|
||||
setShowMore(false);
|
||||
await onCopyLink();
|
||||
},
|
||||
className: copied
|
||||
? "text-green-400 bg-green-400/15"
|
||||
: "text-white/70 hover:bg-white/10 hover:text-white",
|
||||
},
|
||||
{
|
||||
key: "more",
|
||||
label: "More",
|
||||
icon: <MoreHorizontal className="size-3.5" />,
|
||||
key: "collection",
|
||||
label: "Save to Collection",
|
||||
description: "Organise 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: () => {
|
||||
if (isMobile) saveAndClearSelection();
|
||||
else interactingRef.current = true;
|
||||
setMorePos({
|
||||
x: toolbarPos.x + 60,
|
||||
y: toolbarPos.y,
|
||||
});
|
||||
setShowMore(true);
|
||||
setShowMore(false);
|
||||
if (isMobile) restoreSelection();
|
||||
else interactingRef.current = false;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const publicMoreItems: MoreItem[] = [
|
||||
{
|
||||
key: "share-as-post",
|
||||
label: "Share as Post",
|
||||
description: "Publish this passage to your feed",
|
||||
icon: <Send className="size-5" />,
|
||||
locked: true,
|
||||
onClick: async () => {
|
||||
setShowMore(false);
|
||||
await onPost();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "save-to-collection-public",
|
||||
label: "Save to Collection",
|
||||
description: "Organise into a collection",
|
||||
icon: <FolderPlus className="size-5" />,
|
||||
locked: true,
|
||||
onClick: onSaveToCollection,
|
||||
},
|
||||
{
|
||||
key: "bookmark-public",
|
||||
label: "Bookmark",
|
||||
description: "Save your place — always private",
|
||||
icon: <BookmarkIcon className="size-5" />,
|
||||
locked: true,
|
||||
onClick: async () => {
|
||||
setShowMore(false);
|
||||
await onBookmarkOrCollect();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "mention-public",
|
||||
label: "Mention",
|
||||
description: "Tag someone with this quote",
|
||||
icon: <AtSign className="size-5" />,
|
||||
locked: true,
|
||||
onClick: async () => {
|
||||
setShowMore(false);
|
||||
if (!(await requireAuth())) return;
|
||||
// TODO: Implement Mention
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const moreItems = isAuthed ? authedMoreItems : publicMoreItems;
|
||||
|
||||
// --- Render ---
|
||||
|
||||
return (
|
||||
@@ -321,34 +563,18 @@ export function TextSelectionToolbar() {
|
||||
open={showMore}
|
||||
position={morePos}
|
||||
isMobile={isMobile}
|
||||
selectedText={selectedText}
|
||||
items={moreItems}
|
||||
onClose={() => {
|
||||
setShowMore(false);
|
||||
if (isMobile) restoreSelection();
|
||||
else interactingRef.current = false;
|
||||
}}
|
||||
onBookmark={() => {
|
||||
setShowMore(false);
|
||||
if (isMobile) restoreSelection();
|
||||
else interactingRef.current = false;
|
||||
}}
|
||||
onSaveToCollection={() => {
|
||||
setShowMore(false);
|
||||
if (isMobile) saveAndClearSelection();
|
||||
else interactingRef.current = true;
|
||||
setShowCollections(true);
|
||||
}}
|
||||
onMention={() => {
|
||||
setShowMore(false);
|
||||
if (isMobile) restoreSelection();
|
||||
else interactingRef.current = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
<ShareModal
|
||||
open={showShare}
|
||||
<ComposePost
|
||||
open={showCompose}
|
||||
onClose={() => {
|
||||
setShowShare(false);
|
||||
setShowCompose(false);
|
||||
if (isMobile) restoreSelection();
|
||||
else interactingRef.current = false;
|
||||
}}
|
||||
@@ -364,6 +590,7 @@ export function TextSelectionToolbar() {
|
||||
else interactingRef.current = false;
|
||||
}}
|
||||
isMobile={isMobile}
|
||||
selectedText={selectedText}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user