158 lines
5.5 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|