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}

{: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 @@