116 lines
4.2 KiB
TypeScript
116 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { Trash2, ExternalLink } from "lucide-react";
|
|
import { bookmarks, SocialError, type Bookmark } from "@/lib/social";
|
|
import { useAuth } from "@/components/auth/auth-provider";
|
|
import { appendHighlight, hasHighlight } from "@/lib/text-highlight";
|
|
import { displaySourceLabel } from "@/lib/source-reference";
|
|
|
|
export default function MyBookmarksPage() {
|
|
const { user } = useAuth();
|
|
const [list, setList] = useState<Bookmark[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
bookmarks
|
|
.list()
|
|
.then((r) => setList(r ?? []))
|
|
.catch((err) =>
|
|
setError(
|
|
err instanceof SocialError ? err.message : "Could not load bookmarks.",
|
|
),
|
|
)
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
async function remove(id: string) {
|
|
const prev = list;
|
|
setList((rows) => rows.filter((b) => b.id !== id));
|
|
try {
|
|
await bookmarks.delete(id);
|
|
} catch (err) {
|
|
setList(prev);
|
|
setError(err instanceof SocialError ? err.message : "Could not delete.");
|
|
}
|
|
}
|
|
|
|
// Effective cap from the server (plan default merged with any admin
|
|
// override). -1 would mean unlimited (bookmarks aren't, today).
|
|
const limit = user?.limits?.bookmarks ?? 1;
|
|
|
|
if (loading) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<div className="rounded-lg bg-card/60 ring-1 ring-foreground/8 px-4 py-3 text-sm flex items-center justify-between">
|
|
<span className="text-muted-foreground">
|
|
Bookmarks used:{" "}
|
|
<span className="text-foreground tabular-nums">
|
|
{list.length} / {limit}
|
|
</span>
|
|
</span>
|
|
{user?.plan !== "premium" && list.length >= limit ? (
|
|
<span className="text-xs text-amber-600 dark:text-amber-400">
|
|
Upgrade to premium for 5 bookmarks
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
|
|
|
{list.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
No bookmarks yet. Select text anywhere and use “Add bookmark” in the
|
|
more menu.
|
|
</p>
|
|
) : (
|
|
<ul className="space-y-3">
|
|
{list.map((b) => {
|
|
// If the stored URL doesn't already carry a `?hl=` (old data, or
|
|
// a bookmark saved without a selection), synthesise one from the
|
|
// excerpt so clicking still scrolls to + highlights the passage.
|
|
const href =
|
|
hasHighlight(b.source_url) || !b.excerpt
|
|
? b.source_url
|
|
: appendHighlight(b.source_url, b.excerpt);
|
|
return (
|
|
<li
|
|
key={b.id}
|
|
className="rounded-xl bg-card ring-1 ring-foreground/10 p-4 flex items-start gap-3"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<Link
|
|
href={href}
|
|
className="font-[family-name:var(--font-title)] text-base text-foreground hover:underline inline-flex items-center gap-1"
|
|
>
|
|
<ExternalLink className="size-3.5" />
|
|
{displaySourceLabel(b.source_url, b.source_title)}
|
|
</Link>
|
|
{b.excerpt ? (
|
|
<p className="mt-1 text-sm text-muted-foreground italic line-clamp-2">
|
|
“{b.excerpt}”
|
|
</p>
|
|
) : null}
|
|
<p className="text-xs text-muted-foreground mt-2">
|
|
Saved {new Date(b.created_at).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => remove(b.id)}
|
|
aria-label="Delete bookmark"
|
|
className="size-9 inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|