diff --git a/apps/backoffice/app/libraries/[id]/books/page.tsx b/apps/backoffice/app/libraries/[id]/books/page.tsx new file mode 100644 index 0000000..6181a60 --- /dev/null +++ b/apps/backoffice/app/libraries/[id]/books/page.tsx @@ -0,0 +1,95 @@ +import { fetchLibraries, fetchBooks, getBookCoverUrl, LibraryDto, BookDto } from "../../../../lib/api"; +import { BooksGrid, EmptyState } from "../../../components/BookCard"; +import { Card, Badge, Button, CursorPagination } from "../../../components/ui"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +export const dynamic = "force-dynamic"; + +export default async function LibraryBooksPage({ + params, + searchParams +}: { + params: Promise<{ id: string }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const { id } = await params; + const searchParamsAwaited = await searchParams; + const cursor = typeof searchParamsAwaited.cursor === "string" ? searchParamsAwaited.cursor : undefined; + const series = typeof searchParamsAwaited.series === "string" ? searchParamsAwaited.series : undefined; + const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20; + + const [library, booksPage] = await Promise.all([ + fetchLibraries().then(libs => libs.find(l => l.id === id)), + fetchBooks(id, series, cursor, limit).catch(() => ({ + items: [] as BookDto[], + next_cursor: null + })) + ]); + + if (!library) { + notFound(); + } + + const books = booksPage.items.map(book => ({ + ...book, + coverUrl: getBookCoverUrl(book.id) + })); + const nextCursor = booksPage.next_cursor; + + const seriesDisplayName = series === "unclassified" ? "Unclassified" : series; + const hasNextPage = !!nextCursor; + const hasPrevPage = !!cursor; + + return ( + <> +
+ ← Back to libraries +
+ +

+ + {library.name} +

+ + +
+ {library.root_path} + | + {library.book_count} book{library.book_count !== 1 ? 's' : ''} + | + + {library.enabled ? "Enabled" : "Disabled"} + +
+
+ +
+

+ {series ? `Books in "${seriesDisplayName}"` : "All Books"} +

+ {series && ( + + View all + + )} +
+ + {books.length > 0 ? ( + <> + + + + + ) : ( + + )} + + ); +} diff --git a/apps/backoffice/app/libraries/[id]/series/page.tsx b/apps/backoffice/app/libraries/[id]/series/page.tsx new file mode 100644 index 0000000..d3a178d --- /dev/null +++ b/apps/backoffice/app/libraries/[id]/series/page.tsx @@ -0,0 +1,87 @@ +import { fetchLibraries, fetchSeries, getBookCoverUrl, LibraryDto, SeriesDto } from "../../../../lib/api"; +import Image from "next/image"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { Card, Badge } from "../../../components/ui"; + +export const dynamic = "force-dynamic"; + +export default async function LibrarySeriesPage({ + params +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + const [library, series] = await Promise.all([ + fetchLibraries().then(libs => libs.find(l => l.id === id)), + fetchSeries(id).catch(() => [] as SeriesDto[]) + ]); + + if (!library) { + notFound(); + } + + return ( + <> +
+ ← Back to libraries +
+ +

+ + {library.name} +

+ + +
+ {library.root_path} + | + {library.book_count} book{library.book_count !== 1 ? 's' : ''} + | + + {library.enabled ? "Enabled" : "Disabled"} + +
+
+ +

Series ({series.length})

+ + {series.length > 0 ? ( +
+ {series.map((s) => ( + +
+
+ {`Cover +
+
+

+ {s.name === "unclassified" ? "Unclassified" : s.name} +

+

+ {s.book_count} book{s.book_count !== 1 ? 's' : ''} +

+
+
+ + ))} +
+ ) : ( +
+

No series found in this library

+
+ )} + + ); +} diff --git a/apps/backoffice/app/libraries/page.tsx b/apps/backoffice/app/libraries/page.tsx new file mode 100644 index 0000000..d9f65d0 --- /dev/null +++ b/apps/backoffice/app/libraries/page.tsx @@ -0,0 +1,181 @@ +import { revalidatePath } from "next/cache"; +import Link from "next/link"; +import { listFolders, createLibrary, deleteLibrary, fetchLibraries, fetchSeries, scanLibrary, LibraryDto, FolderItem } from "../../lib/api"; +import { LibraryActions } from "../components/LibraryActions"; +import { Card, CardHeader, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "../components/ui"; + +export const dynamic = "force-dynamic"; + +function formatNextScan(nextScanAt: string | null): string { + if (!nextScanAt) return "-"; + const date = new Date(nextScanAt); + const now = new Date(); + const diff = date.getTime() - now.getTime(); + + if (diff < 0) return "Due now"; + if (diff < 60000) return "< 1 min"; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`; + return `${Math.floor(diff / 86400000)}d`; +} + +export default async function LibrariesPage() { + const [libraries, folders] = await Promise.all([ + fetchLibraries().catch(() => [] as LibraryDto[]), + listFolders().catch(() => [] as FolderItem[]) + ]); + + const seriesCounts = await Promise.all( + libraries.map(async (lib) => { + try { + const series = await fetchSeries(lib.id); + return { id: lib.id, count: series.length }; + } catch { + return { id: lib.id, count: 0 }; + } + }) + ); + + const seriesCountMap = new Map(seriesCounts.map(s => [s.id, s.count])); + + async function addLibrary(formData: FormData) { + "use server"; + const name = formData.get("name") as string; + const rootPath = formData.get("root_path") as string; + if (name && rootPath) { + await createLibrary(name, rootPath); + revalidatePath("/libraries"); + } + } + + async function removeLibrary(formData: FormData) { + "use server"; + const id = formData.get("id") as string; + await deleteLibrary(id); + revalidatePath("/libraries"); + } + + async function scanLibraryAction(formData: FormData) { + "use server"; + const id = formData.get("id") as string; + await scanLibrary(id); + revalidatePath("/libraries"); + revalidatePath("/jobs"); + } + + async function scanLibraryFullAction(formData: FormData) { + "use server"; + const id = formData.get("id") as string; + await scanLibrary(id, true); + revalidatePath("/libraries"); + revalidatePath("/jobs"); + } + + return ( + <> +

+ + Libraries +

+ + {/* Add Library Form */} + + +
+ + + + + + + + {folders.map((folder) => ( + + ))} + + + + +
+
+ + {/* Libraries Grid */} +
+ {libraries.map((lib) => { + const seriesCount = seriesCountMap.get(lib.id) || 0; + return ( + + {/* Header with settings */} +
+
+

{lib.name}

+ {!lib.enabled && Disabled} +
+ +
+ + {/* Path */} + {lib.root_path} + + {/* Stats */} +
+ + {lib.book_count} + Books + + + {seriesCount} + Series + +
+ + {/* Status */} +
+ + {lib.monitor_enabled ? '●' : '○'} {lib.monitor_enabled ? 'Auto' : 'Manual'} + + {lib.watcher_enabled && ( + + )} + {lib.monitor_enabled && lib.next_scan_at && ( + + Next: {formatNextScan(lib.next_scan_at)} + + )} +
+ + {/* Actions */} +
+
+ + +
+
+ + +
+
+ + +
+
+
+ ); + })} +
+ + ); +}