diff --git a/ui/src/routes/api/library/bulk-remove/+server.ts b/ui/src/routes/api/library/bulk-remove/+server.ts new file mode 100644 index 0000000..f8ddda2 --- /dev/null +++ b/ui/src/routes/api/library/bulk-remove/+server.ts @@ -0,0 +1,33 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { unsaveBook } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * POST /api/library/bulk-remove + * Body: { slugs: string[] } + * Removes multiple books from the user's library at once. + */ +export const POST: RequestHandler = async ({ request, locals }) => { + const body = await request.json().catch(() => null); + const slugs: unknown = body?.slugs; + if (!Array.isArray(slugs) || slugs.length === 0) { + error(400, 'slugs must be a non-empty array'); + } + const validSlugs = (slugs as unknown[]).filter((s): s is string => typeof s === 'string'); + if (validSlugs.length === 0) error(400, 'no valid slugs provided'); + + const results = await Promise.allSettled( + validSlugs.map((slug) => unsaveBook(locals.sessionId, slug, locals.user?.id)) + ); + + const failed = results + .map((r, i) => (r.status === 'rejected' ? validSlugs[i] : null)) + .filter(Boolean); + + if (failed.length > 0) { + log.error('library', 'bulk-remove partial failure', { failed }); + } + + return json({ ok: true, removed: validSlugs.length - failed.length, failed }); +}; diff --git a/ui/src/routes/api/library/bulk-shelf/+server.ts b/ui/src/routes/api/library/bulk-shelf/+server.ts new file mode 100644 index 0000000..48c36c0 --- /dev/null +++ b/ui/src/routes/api/library/bulk-shelf/+server.ts @@ -0,0 +1,44 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { updateBookShelf } from '$lib/server/pocketbase'; +import type { ShelfName } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +const VALID_SHELVES: ShelfName[] = ['', 'plan_to_read', 'completed', 'dropped']; + +/** + * POST /api/library/bulk-shelf + * Body: { slugs: string[], shelf: ShelfName } + * Moves multiple books to the given shelf at once. + */ +export const POST: RequestHandler = async ({ request, locals }) => { + const body = await request.json().catch(() => null); + const slugs: unknown = body?.slugs; + const shelf: unknown = body?.shelf; + + if (!Array.isArray(slugs) || slugs.length === 0) { + error(400, 'slugs must be a non-empty array'); + } + if (typeof shelf !== 'string' || !VALID_SHELVES.includes(shelf as ShelfName)) { + error(400, 'invalid shelf value'); + } + + const validSlugs = (slugs as unknown[]).filter((s): s is string => typeof s === 'string'); + if (validSlugs.length === 0) error(400, 'no valid slugs provided'); + + const results = await Promise.allSettled( + validSlugs.map((slug) => + updateBookShelf(locals.sessionId, slug, shelf as ShelfName, locals.user?.id) + ) + ); + + const failed = results + .map((r, i) => (r.status === 'rejected' ? validSlugs[i] : null)) + .filter(Boolean); + + if (failed.length > 0) { + log.error('library', 'bulk-shelf partial failure', { failed, shelf }); + } + + return json({ ok: true, updated: validSlugs.length - failed.length, failed }); +}; diff --git a/ui/src/routes/books/+page.svelte b/ui/src/routes/books/+page.svelte index 948386b..b0a6d5c 100644 --- a/ui/src/routes/books/+page.svelte +++ b/ui/src/routes/books/+page.svelte @@ -1,4 +1,5 @@ {m.books_page_title()} -
-

{m.books_heading()}

-

- {m.books_count({ n: String(data.books?.length ?? 0), s: (data.books?.length ?? 0) !== 1 ? 's' : '' })} -

+
+
+

{m.books_heading()}

+

+ {m.books_count({ n: String(data.books?.length ?? 0), s: (data.books?.length ?? 0) !== 1 ? 's' : '' })} +

+
+ {#if selectMode} + + {/if}
{#if !data.books?.length} @@ -63,22 +188,34 @@

{:else} - -
- {#each (['all', '', 'plan_to_read', 'completed', 'dropped'] as const) as shelf} - {#if shelfCounts[shelf] > 0 || shelf === 'all'} - - {/if} - {/each} + +
+ {#if selectMode} + + {selectedCount} selected + {:else} + {#each (['all', '', 'plan_to_read', 'completed', 'dropped'] as const) as shelf} + {#if shelfCounts[shelf] > 0 || shelf === 'all'} + + {/if} + {/each} + {/if}
@@ -86,18 +223,41 @@ {@const lastChapter = data.progressMap[book.slug]} {@const genres = parseGenres(book.genres)} {@const bookShelf = shelfMap[book.slug] ?? ''} + {@const isSelected = selected.has(book.slug)} + onCardClick(e, book.slug)} + onpointerdown={() => onPointerDown(book.slug)} + onpointerup={onPointerUp} + onpointercancel={onPointerCancel} + draggable="false" + class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) border transition-colors select-none + {isSelected + ? 'border-(--color-brand) ring-2 ring-(--color-brand)/40' + : 'border-(--color-border) hover:bg-(--color-surface-3) hover:border-zinc-500'}" > + + {#if selectMode} +
+ {#if isSelected} + + + + {/if} +
+ {/if} +
{#if book.cover} {book.title} {:else}
@@ -148,3 +308,73 @@ {/each}
{/if} + + +{#if selectMode} +
+ + {selectedCount} selected + + + +
+ + + {#if shelfPickerOpen} +
+ {#each ([['', 'Reading'], ['plan_to_read', 'Plan to Read'], ['completed', 'Completed'], ['dropped', 'Dropped']] as const) as [val, label]} + + {/each} +
+ {/if} +
+ + + +
+ + +
+{/if} diff --git a/ui/src/routes/catalogue/+page.svelte b/ui/src/routes/catalogue/+page.svelte index 23f9277..5691e2d 100644 --- a/ui/src/routes/catalogue/+page.svelte +++ b/ui/src/routes/catalogue/+page.svelte @@ -591,7 +591,7 @@ @@ -694,7 +694,7 @@