diff --git a/bun.lock b/bun.lock index be58893..6409ad1 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "trackverse", @@ -67,6 +68,7 @@ "srvx": "^0.12.4", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.2.4", + "takumi-js": "^2.5.4", "tippy.js": "^6.3.7", "vaul": "^1.1.2", "zod": "^4.3.6", @@ -479,6 +481,28 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.2.4", "", { "dependencies": { "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "tailwindcss": "4.2.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw=="], + "@takumi-rs/core": ["@takumi-rs/core@2.5.4", "", { "dependencies": { "@takumi-rs/helpers": "2.5.4" }, "optionalDependencies": { "@takumi-rs/core-darwin-arm64": "2.5.4", "@takumi-rs/core-darwin-x64": "2.5.4", "@takumi-rs/core-linux-arm64-gnu": "2.5.4", "@takumi-rs/core-linux-arm64-musl": "2.5.4", "@takumi-rs/core-linux-x64-gnu": "2.5.4", "@takumi-rs/core-linux-x64-musl": "2.5.4", "@takumi-rs/core-win32-arm64-msvc": "2.5.4", "@takumi-rs/core-win32-x64-msvc": "2.5.4" }, "peerDependencies": { "csstype": "*" }, "optionalPeers": ["csstype"] }, "sha512-RfYVPihlYaa951bW2g7/lkK8uTQ3wxq1/yvY41K2CY98UZ8xChAcriJsF1Qxg1nAagmbLKuExJs1DSLR/+g9ow=="], + + "@takumi-rs/core-darwin-arm64": ["@takumi-rs/core-darwin-arm64@2.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gbIY8SBxWK2fg13OtXj6V4ONLN683tTR08Llhr/UNqGdMn5yLw26/inn9j4sQV52QCWzczUYMwSX7aE2CaTbcw=="], + + "@takumi-rs/core-darwin-x64": ["@takumi-rs/core-darwin-x64@2.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yQzah3bIo4SxFeQa+O/CBpMNc/FgbaDqMlzXA5/axRhypIXmdTaK9lrPqipROgPXRkPddeMevV1WoEHslaMCfg=="], + + "@takumi-rs/core-linux-arm64-gnu": ["@takumi-rs/core-linux-arm64-gnu@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-eFtuebrAm+6W7BTn/revOgIJCp14n+Fomg5DgAvyiTa1fz9Cz4R/0Nlm9OBP/bXP+S2TTqTBVsqvivGjrLJCZg=="], + + "@takumi-rs/core-linux-arm64-musl": ["@takumi-rs/core-linux-arm64-musl@2.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9JQEtQWOGkUYX7xPev6+TVZVNHpZlr0JOxyCCsaOKvwhyI2KLugWM8C+3CITRtqa0WfJOqJsM43UP3cFofFbvA=="], + + "@takumi-rs/core-linux-x64-gnu": ["@takumi-rs/core-linux-x64-gnu@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-KJp3tkS7BkwQ3WiKWse0uFjx/fKtsNayq9/sLo/8PurxZqjZAmIDMg4M9UUkWQ428uCTi+UERPNPlZQ/0XLrZQ=="], + + "@takumi-rs/core-linux-x64-musl": ["@takumi-rs/core-linux-x64-musl@2.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-WRQSnGu91GqGlA64aCHi5MepROm5pnADo6x8o3AakNxLXu0R6Hb1Tj1qJLp4nSylImzZueyR7PUNhNr3xdEBpg=="], + + "@takumi-rs/core-win32-arm64-msvc": ["@takumi-rs/core-win32-arm64-msvc@2.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-BxtY3t4bk98mG4uS3Tao1p6U7zbKayH+KGgN7gYLkGTn3cKQtDxP/rwaebdOnnRycIDo310mzhJ5640JhDJBGQ=="], + + "@takumi-rs/core-win32-x64-msvc": ["@takumi-rs/core-win32-x64-msvc@2.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-n3yNothzsg6+3CBOB53AKKWn9pa/z1X1TrFOM7tdBSvnnEjXdoeowx6IsNVBv/OunwWjw+ah2f9ldkry+8Q2hg=="], + + "@takumi-rs/helpers": ["@takumi-rs/helpers@2.5.4", "", { "peerDependencies": { "preact": "^10.0.0", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["preact", "react"] }, "sha512-/bNTK2nRSmaugF4m2TDb1SLiEAN7PcORwqqa1RIfahMAqxZoNzMw2krCuNexeDRPGBNJKNCmlOB6F1heBXqHTw=="], + + "@takumi-rs/wasm": ["@takumi-rs/wasm@2.5.4", "", { "dependencies": { "@takumi-rs/helpers": "2.5.4" }, "peerDependencies": { "csstype": "*" }, "optionalPeers": ["csstype"] }, "sha512-Uae6J+SglHS2sl0hqXLGuw5nUEy/scyYOJzbGFfN79EGm+Oy5f5SkizlNKo+6H0HzWoXCNdWCvXq/Yl8stRylA=="], + "@tanstack/devtools": ["@tanstack/devtools@0.11.2", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.6", "@tanstack/devtools-event-bus": "0.4.1", "@tanstack/devtools-ui": "0.5.1", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" }, "bin": { "intent": "bin/intent.js" } }, "sha512-K8+tsBx+ptTLqqd4dOF10B6laj1g+XYImqYZL9n0jBINGaT+sOf17PKV9pbBt8kdbZeIGsHaJ5OZWCyZoHqN4A=="], "@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.6", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1" } }, "sha512-f85ZJXJnDIFOoykG/BFIixuAevJovCvJF391LPs6YjBAPhGYC50NWlx1y4iF/UmK5/cCMx+/JqI5SBOz7FanQQ=="], @@ -1233,6 +1257,8 @@ "tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="], + "takumi-js": ["takumi-js@2.5.4", "", { "dependencies": { "@takumi-rs/core": "2.5.4", "@takumi-rs/helpers": "2.5.4", "@takumi-rs/wasm": "2.5.4" } }, "sha512-GdzRCezKVelmjfh/J5vsEtYUeEgoKMRtwe57f7Qd4XrPdMCkYvWl8ThuHZ67J76bMqGpHUEDZATkgu8RYbTwSg=="], + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], "tar": ["tar@7.5.6", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA=="], diff --git a/package.json b/package.json index 58b37e5..70ca634 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "srvx": "^0.12.4", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.2.4", + "takumi-js": "^2.5.4", "tippy.js": "^6.3.7", "vaul": "^1.1.2", "zod": "^4.3.6" diff --git a/src/components/pages/details/backfill-episodes-dialog.tsx b/src/components/pages/details/backfill-episodes-dialog.tsx new file mode 100644 index 0000000..ccb9ea0 --- /dev/null +++ b/src/components/pages/details/backfill-episodes-dialog.tsx @@ -0,0 +1,75 @@ +import { Icon } from "@iconify/react"; +import { t } from "i18next"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; + +interface BackfillEpisodesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + episodeCount: number; + isLoading?: boolean; + onConfirm: (remember: boolean) => void; + onDecline: (remember: boolean) => void; +} + +export function BackfillEpisodesDialog({ + open, + onOpenChange, + episodeCount, + isLoading = false, + onConfirm, + onDecline, +}: BackfillEpisodesDialogProps) { + const [remember, setRemember] = useState(false); + + const handleOpenChange = (next: boolean) => { + if (!next) setRemember(false); + onOpenChange(next); + }; + + return ( + + + + + + {t("library:backfillTitle")} + + {t("library:backfillDescription", { count: episodeCount })} + + +
+ setRemember(checked === true)} + disabled={isLoading} + /> + +
+ + + + + +
+
+ ); +} diff --git a/src/components/shared/modals/book.tsx b/src/components/shared/modals/book.tsx index 2b27128..2d199cf 100644 --- a/src/components/shared/modals/book.tsx +++ b/src/components/shared/modals/book.tsx @@ -1,251 +1,701 @@ +import { zodResolver } from "@hookform/resolvers/zod"; import { Icon } from "@iconify/react"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { format } from "date-fns"; -import { useState } from "react"; +import type { TFunction } from "i18next"; +import { useEffect, useMemo, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { api, apiEndpoints } from "@/lib/api.ts"; +import { toast } from "sonner"; +import z from "zod"; +import { type ApiTypes, api, apiEndpoints } from "@/lib/api.ts"; import { useSession } from "@/lib/auth.ts"; import { Button } from "../../ui/button"; import { Calendar } from "../../ui/calendar"; import { Checkbox } from "../../ui/checkbox"; -import { Field, FieldLabel } from "../../ui/field"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog"; +import { Field, FieldError, FieldLabel } from "../../ui/field"; import { Input } from "../../ui/input"; import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "../../ui/input-group"; import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover"; +import { RatingGroupAdvanced } from "../../ui/rating-group-advanced"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "../../ui/select"; import { Textarea } from "../../ui/textarea"; +type ProgressStatus = "Planning" | "Reading" | "Completed" | "Paused" | "Dropped"; + +const STATUS_OPTIONS = ["planning", "reading", "completed", "rereading", "dropped", "paused"] as const; + +const STATUS_TO_ENUM: Record = { + planning: "Planning", + reading: "Reading", + completed: "Completed", + rereading: "Reading", + dropped: "Dropped", + paused: "Paused", +}; + +const ENUM_TO_STATUS: Record = { + Planning: "planning", + Reading: "reading", + Completed: "completed", + Paused: "paused", + Dropped: "dropped", +}; + +const REVIEW_STATUSES = ["completed", "dropped", "rereading"]; + +const SUMMARY_MAX_LENGTH = 500; +const REVIEW_NOTES_MAX_LENGTH = 1000; + +function createProgressSchema() { + return z.object({ + status: z.string(), + pagesRead: z.string(), + readCount: z.string(), + startDate: z.date().optional(), + finishDate: z.date().optional(), + }); +} + +type ProgressFormData = z.infer>; + +function createReviewSchema(t: TFunction) { + return z.object({ + overall: z.string(), + characters: z.string(), + language: z.string(), + theme: z.string(), + summary: z.string().trim().max(SUMMARY_MAX_LENGTH, t("feed:reviewSummaryMax")), + notes: z.string().trim().max(REVIEW_NOTES_MAX_LENGTH, t("feed:reviewNotesMax")), + recommended: z.boolean(), + }); +} + +type ReviewFormData = z.infer>; + +interface BookProgressData { + id: string; + status: ProgressStatus; + chaptersRead: number | null; + readCount: number | null; + startedAt: string | null; + completedAt: string | null; +} + interface BookModalProps { - mediaData?: unknown; - onStatusChange?: (status: string) => void; - onSaveSuccess?: (status: string) => void; + bookId?: string; + totalPages?: number | null; + onClose?: () => void; } -export function BookModal({ mediaData: _, onStatusChange, onSaveSuccess }: BookModalProps) { - const [startDate, setStartDate] = useState(); - const [finishDate, setFinishDate] = useState(); - const [customLists, setCustomLists] = useState(["2026", "Favorites", "Read Later"]); - const [selectedStatus, setSelectedStatus] = useState(null); +export function BookModal({ bookId, totalPages, onClose }: BookModalProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const session = useSession(); + const userId = session?.data?.user?.id; + const enabled = !!userId && !!bookId; + const [newListInput, setNewListInput] = useState(null); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); - const session = useSession(); - const createListMutation = useMutation({ - mutationFn: async (name: string) => { - await api.post(apiEndpoints.list, { - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, userId: session?.data?.user?.id, type: "Book" }), + const progressSchema = useMemo(() => createProgressSchema(), []); + + const progressForm = useForm({ + resolver: zodResolver(progressSchema), + defaultValues: { + status: "", + pagesRead: "", + readCount: "", + startDate: undefined, + finishDate: undefined, + }, + }); + + const progressStatus = progressForm.watch("status"); + + const reviewSchema = useMemo(() => createReviewSchema(t), [t]); + + const reviewForm = useForm({ + resolver: zodResolver(reviewSchema), + defaultValues: { + overall: "0", + characters: "0", + language: "0", + theme: "0", + summary: "", + notes: "", + recommended: false, + }, + }); + + const summary = reviewForm.watch("summary") ?? ""; + const reviewNotes = reviewForm.watch("notes") ?? ""; + + const progressQuery = useQuery({ + queryKey: ["bookProgress", bookId, userId], + queryFn: () => + api + .get(apiEndpoints.getBookProgress(userId as string, bookId as string)) + .then(({ data }) => data.bookProgresses.items[0] ?? null), + enabled, + }); + + const reviewQuery = useQuery({ + queryKey: ["bookReview", bookId, userId], + queryFn: () => + api + .get(`${apiEndpoints.bookReview}/?bookId=${bookId}&userId=${userId}`) + .then(({ data }) => data.bookReviews?.items[0] ?? null), + enabled, + }); + + const listsQuery = useQuery({ + queryKey: ["bookLists", userId], + queryFn: () => + api + .get(apiEndpoints.getListsByUserId(userId as string), { + params: { type: "Book", itemsPerPage: 50 }, + }) + .then(({ data }) => data.lists.items), + enabled: !!userId, + }); + + const lists = listsQuery.data ?? []; + + const listStatusQuery = useQuery({ + queryKey: ["bookListStatus", bookId, userId], + queryFn: () => + api + .get(apiEndpoints.getListStatus, { + params: { type: "Book", bookId }, + }) + .then(({ data }) => data.listIds), + enabled, + }); + + const listIds = listStatusQuery.data ?? []; + + const isInList = (listId: string) => listIds.includes(listId); + + useEffect(() => { + const progress = progressQuery.data; + if (!progress) return; + + progressForm.reset({ + status: ENUM_TO_STATUS[progress.status] ?? "", + pagesRead: progress.chaptersRead != null ? String(progress.chaptersRead) : "", + readCount: progress.readCount != null ? String(progress.readCount) : "", + startDate: progress.startedAt ? new Date(progress.startedAt) : undefined, + finishDate: progress.completedAt ? new Date(progress.completedAt) : undefined, + }); + }, [progressQuery.data, progressForm.reset]); + + useEffect(() => { + const review = reviewQuery.data; + if (!review) return; + + reviewForm.reset({ + overall: String(Number(review.overall)), + characters: review.characters != null ? String(Number(review.characters)) : "0", + language: review.language != null ? String(Number(review.language)) : "0", + theme: review.theme != null ? String(Number(review.theme)) : "0", + summary: review.summary ?? "", + notes: review.notes ?? "", + recommended: !!review.recommended, + }); + }, [reviewQuery.data, reviewForm.reset]); + + const saveProgressMutation = useMutation({ + mutationFn: (data: ProgressFormData) => { + const status = STATUS_TO_ENUM[data.status]; + + return api.post(apiEndpoints.bookProgress, { + bookId, + status, + chaptersRead: data.pagesRead.trim() === "" ? undefined : Number(data.pagesRead), + readCount: data.readCount.trim() === "" ? undefined : Number(data.readCount), + startedAt: data.startDate ?? undefined, + completedAt: status === "Completed" ? (data.finishDate ?? new Date()) : data.finishDate, }); }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["bookProgress", bookId, userId] }); + queryClient.invalidateQueries({ queryKey: ["book"] }); + }, }); - const handleAddList = () => setNewListInput(""); + const saveReviewMutation = useMutation({ + mutationFn: (data: ReviewFormData) => { + const body = { + bookId, + overall: Number(data.overall), + characters: Number(data.characters) || undefined, + language: Number(data.language) || undefined, + theme: Number(data.theme) || undefined, + summary: data.summary.trim() || undefined, + notes: data.notes.trim() || undefined, + recommended: data.recommended, + }; + + const existing = reviewQuery.data; + + return existing + ? api.patch(`${apiEndpoints.bookReview}/${existing.id}`, body) + : api.post(apiEndpoints.bookReview, body); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["bookReview", bookId, userId] }); + queryClient.invalidateQueries({ queryKey: ["bookReviews", bookId] }); + queryClient.invalidateQueries({ queryKey: ["book"] }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async () => { + if (reviewQuery.data) { + await api.delete(`${apiEndpoints.bookReview}/${reviewQuery.data.id}`); + } + if (progressQuery.data) { + await api.delete(`${apiEndpoints.bookProgress}/${progressQuery.data.id}`); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["bookProgress", bookId, userId] }); + queryClient.invalidateQueries({ queryKey: ["bookReview", bookId, userId] }); + queryClient.invalidateQueries({ queryKey: ["bookReviews", bookId] }); + queryClient.invalidateQueries({ queryKey: ["book"] }); + setConfirmDeleteOpen(false); + onClose?.(); + }, + onError: () => toast.error(t("api:INTERNAL_SERVER_ERROR")), + }); + + const toggleListMutation = useMutation({ + mutationFn: ({ listId, isMember }: { listId: string; isMember: boolean }) => { + const body = { type: "Book", listId, userId, bookId }; + + return isMember + ? api.delete(apiEndpoints.listItem(listId), { data: body }) + : api.post(apiEndpoints.listItem(listId), body); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["bookListStatus", bookId, userId] }); + queryClient.invalidateQueries({ queryKey: ["bookContainingLists", bookId] }); + }, + onError: () => toast.error(t("api:INTERNAL_SERVER_ERROR")), + }); + + const createListMutation = useMutation({ + mutationFn: (name: string) => api.post(apiEndpoints.list, { name, userId, type: "Book" }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["bookLists", userId] }), + onError: () => toast.error(t("api:INTERNAL_SERVER_ERROR")), + }); const handleNewListBlur = () => { - if (newListInput?.trim()) { - const trimmed = newListInput.trim(); - setCustomLists((prev) => [...prev, trimmed]); + const trimmed = newListInput?.trim(); + if (trimmed) { createListMutation.mutate(trimmed); } setNewListInput(null); }; - const { t } = useTranslation(); - - const toggleCustomList = (list: string) => { - setCustomLists((prev) => (prev.includes(list) ? prev.filter((l) => l !== list) : [...prev, list])); - }; const handleSave = async () => { - if (selectedStatus) { - onSaveSuccess?.(selectedStatus); + const progress = progressForm.getValues(); + + if (!bookId || !progress.status) { + onClose?.(); + return; + } + + try { + if (!(await progressForm.trigger())) return; + + await saveProgressMutation.mutateAsync(progress); + + const review = reviewForm.getValues(); + + if (REVIEW_STATUSES.includes(progress.status) && Number(review.overall) > 0) { + if (!(await reviewForm.trigger())) return; + + await saveReviewMutation.mutateAsync(review); + } + + onClose?.(); + } catch { + toast.error(t("api:INTERNAL_SERVER_ERROR")); } }; + const isSaving = saveProgressMutation.isPending || saveReviewMutation.isPending; + return (
-
-
-

- - {t("common:progress")} -

-
- - - {t("library:status")} - - - +
+

+ + {t("common:progress")} +

+
+ + + {t("library:status")} + + ( + + )} + /> + - - - {t("library:page_other")} - - - + + + {t("library:page_other")} + + + + {!!totalPages && ( - /349 + /{totalPages} - - + )} + + - - - {t("feed:totalRereads")} - - - -
+ + + {t("feed:totalRereads")} + + +
-
-
-

- - {t("feed:timeline")} -

-
- - - {t("feed:startDate")} - - - - - - - - - - +
+

+ + {t("feed:timeline")} +

+
+ + + {t("feed:startDate")} + + ( + + + + + + + + + )} + /> + + + + + {t("feed:finishDate")} + + ( + + + + + + + + + )} + /> + +
+
- - - {t("feed:finishDate")} +
+
+

{t("feed:customLists")}

+ +
+
+ {lists.map((list) => ( + + toggleListMutation.mutate({ listId: list.id, isMember: isInList(list.id) })} + /> + + {list.name} - - - - - - - - -
+ ))} + {newListInput !== null && ( + + + setNewListInput(e.target.value)} + onBlur={handleNewListBlur} + onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()} + placeholder={t("feed:newList")} + className="h-6 text-sm bg-background px-2 py-0" + aria-label={t("feed:newList")} + /> + + )} + {lists.length === 0 && newListInput === null && ( +

{t("library:noLists")}

+ )}
+
-
-
-

{t("feed:notes")}

-