Files
book-comic-library/frontend/src/components/Bookmarks.tsx
T

158 lines
5.5 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Pencil, Plus, X } from "lucide-react";
import { useState } from "react";
import { api } from "../api/client";
import type { Book } from "../api/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "./Toaster";
type Locator = Record<string, unknown>;
/** 书签面板内容(供 ReaderNav 的「书签」选项卡承载);跳转后由调用方负责关抽屉. */
export function BookmarksTab({
book,
capture,
seek,
}: {
book: Book;
capture: () => { locator: Locator; percent: number };
seek: (locator: Locator) => void;
}) {
const qc = useQueryClient();
const [note, setNote] = useState("");
const [editId, setEditId] = useState<number | null>(null);
const [editVal, setEditVal] = useState("");
const q = useQuery({ queryKey: ["bookmarks", book.id], queryFn: () => api.listBookmarks(book.id) });
const items = q.data ?? [];
const invalidate = () => qc.invalidateQueries({ queryKey: ["bookmarks", book.id] });
const fail = (e: unknown) => toast("err", e instanceof Error ? e.message : "书签操作失败");
const add = useMutation({
mutationFn: () => {
const { locator, percent } = capture();
return api.createBookmark(book.id, locator, percent, note.trim());
},
onSuccess: () => {
setNote("");
invalidate();
toast("ok", "已加书签");
},
onError: fail,
});
const patch = useMutation({
mutationFn: (v: { id: number; note: string }) => api.patchBookmark(v.id, v.note),
onSuccess: () => {
setEditId(null);
invalidate();
},
onError: fail,
});
const del = useMutation({
mutationFn: (id: number) => api.deleteBookmark(id),
onSuccess: invalidate,
onError: fail,
});
return (
<>
<div className="mb-2 flex items-center gap-1.5">
<Input
className="min-w-0 flex-1"
placeholder="备注(可空)"
maxLength={500}
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !add.isPending) add.mutate();
}}
/>
<Button
variant="ghost"
size="icon-xs"
className="shrink-0"
aria-label="加书签"
disabled={add.isPending}
onClick={() => add.mutate()}
>
<Plus size={14} />
</Button>
</div>
<ul className="space-y-0.5">
{q.isPending && <li className="p-2 text-xs text-muted-foreground">加载中…</li>}
{!q.isPending && items.length === 0 && (
<li className="p-2 text-xs text-muted-foreground">还没有书签,读到想记的位置点「加」</li>
)}
{items.map((b) => (
<li key={b.id}>
{editId === b.id ? (
<div className="flex items-center gap-1">
<Input
className="min-w-0 flex-1"
maxLength={500}
value={editVal}
onChange={(e) => setEditVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !patch.isPending) patch.mutate({ id: b.id, note: editVal.trim() });
}}
/>
<Button
variant="ghost"
size="icon-xs"
className="shrink-0"
aria-label="保存备注"
disabled={patch.isPending}
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
>
<Check size={14} />
</Button>
<Button
variant="ghost"
size="icon-xs"
className="shrink-0"
aria-label="取消编辑"
onClick={() => setEditId(null)}
>
<X size={14} />
</Button>
</div>
) : (
<div className="group flex items-center">
<button
className="min-w-0 flex-1 truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
title={b.note || undefined}
onClick={() => seek(b.locator)}
>
<span className="tabular-nums opacity-70">{Math.round(b.percent * 100)}%</span>{" "}
{b.note || <span className="opacity-50">{b.created_at.slice(0, 10)}</span>}
</button>
<Button
variant="ghost"
size="icon-xs"
className="m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="改备注"
onClick={() => {
setEditId(b.id);
setEditVal(b.note);
}}
>
<Pencil size={14} />
</Button>
<Button
variant="ghost"
size="icon-xs"
className="m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="删除书签"
onClick={() => del.mutate(b.id)}
>
<X size={14} />
</Button>
</div>
)}
</li>
))}
</ul>
</>
);
}