fix(sessions): remove IP from device fingerprint to prevent duplicate sessions on network change
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 1m43s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m40s
Release / Docker / runner (push) Successful in 4m16s
Release / Upload source maps (push) Successful in 1m41s
Release / Docker / ui (push) Successful in 2m47s
Release / Gitea Release (push) Successful in 40s

- deviceFingerprint now hashes only User-Agent (not UA+IP) so switching
  networks (VPN, mobile data, wifi) no longer creates a new session row
- On re-login with same device, also refresh the stored IP field so the
  sessions page shows the current network address
- feat(library): bulk remove and bulk shelf-change actions on /books
  Long-press any card to enter selection mode; sticky action bar with
  Move to shelf dropdown and Remove button; POST /api/library/bulk-remove
  and POST /api/library/bulk-shelf endpoints
- fix(catalogue): make Scrape button visible with solid amber-500 fill
  and dark text instead of low-opacity ghost style that blended into card
This commit is contained in:
Admin
2026-04-05 23:12:31 +05:00
parent 150eb2a2af
commit 59794e3694
4 changed files with 333 additions and 26 deletions

View File

@@ -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 });
};

View File

@@ -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 });
};

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
@@ -38,19 +39,143 @@
'': data.books.filter((b) => (shelfMap[b.slug] ?? '') === '').length,
plan_to_read: data.books.filter((b) => shelfMap[b.slug] === 'plan_to_read').length,
completed: data.books.filter((b) => shelfMap[b.slug] === 'completed').length,
dropped: data.books.filter((b) => shelfMap[b.slug] === 'dropped').length,
dropped: data.books.filter((b) => shelfMap[b.slug] === 'dropped').length
});
// ── Selection / bulk-action state ─────────────────────────────────────────
let selectMode = $state(false);
let selected = $state<Set<string>>(new Set());
let busy = $state(false);
let shelfPickerOpen = $state(false);
const selectedCount = $derived(selected.size);
const allVisibleSelected = $derived(
filteredBooks.length > 0 && filteredBooks.every((b) => selected.has(b.slug))
);
function enterSelectMode(slug: string) {
selectMode = true;
selected = new Set([slug]);
}
function exitSelectMode() {
selectMode = false;
selected = new Set();
shelfPickerOpen = false;
}
function toggleSelect(slug: string) {
const next = new Set(selected);
if (next.has(slug)) next.delete(slug);
else next.add(slug);
selected = next;
if (next.size === 0) exitSelectMode();
}
function toggleSelectAll() {
if (allVisibleSelected) {
selected = new Set();
exitSelectMode();
} else {
selected = new Set(filteredBooks.map((b) => b.slug));
}
}
// Long-press support (pointer events, works on desktop + mobile)
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let longPressFired = false;
function onPointerDown(slug: string) {
if (selectMode) return;
longPressFired = false;
longPressTimer = setTimeout(() => {
longPressFired = true;
enterSelectMode(slug);
}, 500);
}
function onPointerUp() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function onPointerCancel() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
// Prevent navigation click if long-press just fired
function onCardClick(e: MouseEvent, slug: string) {
if (selectMode) {
e.preventDefault();
toggleSelect(slug);
return;
}
if (longPressFired) {
e.preventDefault();
longPressFired = false;
}
}
// ── Bulk actions ──────────────────────────────────────────────────────────
async function bulkRemove() {
if (busy || selected.size === 0) return;
busy = true;
try {
await fetch('/api/library/bulk-remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slugs: [...selected] })
});
await invalidateAll();
} finally {
busy = false;
exitSelectMode();
}
}
async function bulkMoveShelf(shelf: Shelf) {
if (busy || selected.size === 0) return;
busy = true;
shelfPickerOpen = false;
try {
await fetch('/api/library/bulk-shelf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slugs: [...selected], shelf })
});
await invalidateAll();
} finally {
busy = false;
exitSelectMode();
}
}
</script>
<svelte:head>
<title>{m.books_page_title()}</title>
</svelte:head>
<div class="mb-6">
<h1 class="text-2xl font-bold text-(--color-text)">{m.books_heading()}</h1>
<p class="text-(--color-muted) text-sm mt-1">
{m.books_count({ n: String(data.books?.length ?? 0), s: (data.books?.length ?? 0) !== 1 ? 's' : '' })}
</p>
<div class="mb-6 flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-(--color-text)">{m.books_heading()}</h1>
<p class="text-(--color-muted) text-sm mt-1">
{m.books_count({ n: String(data.books?.length ?? 0), s: (data.books?.length ?? 0) !== 1 ? 's' : '' })}
</p>
</div>
{#if selectMode}
<button
type="button"
onclick={exitSelectMode}
class="text-sm text-(--color-muted) hover:text-(--color-text) transition-colors pt-1"
>
Cancel
</button>
{/if}
</div>
{#if !data.books?.length}
@@ -63,22 +188,34 @@
</p>
</div>
{:else}
<!-- Shelf tabs -->
<div class="flex gap-1 flex-wrap mb-4">
{#each (['all', '', 'plan_to_read', 'completed', 'dropped'] as const) as shelf}
{#if shelfCounts[shelf] > 0 || shelf === 'all'}
<button
type="button"
onclick={() => (activeShelf = shelf)}
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors
{activeShelf === shelf
? 'bg-(--color-brand) text-(--color-surface)'
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) border border-(--color-border)'}"
>
{shelfLabels[shelf]}{shelfCounts[shelf] !== data.books.length || shelf === 'all' ? ` (${shelfCounts[shelf]})` : ''}
</button>
{/if}
{/each}
<!-- Shelf tabs + select-all row -->
<div class="flex items-center gap-2 mb-4 flex-wrap">
{#if selectMode}
<button
type="button"
onclick={toggleSelectAll}
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors
bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) border border-(--color-border)"
>
{allVisibleSelected ? 'Deselect all' : 'Select all'}
</button>
<span class="text-sm text-(--color-muted)">{selectedCount} selected</span>
{:else}
{#each (['all', '', 'plan_to_read', 'completed', 'dropped'] as const) as shelf}
{#if shelfCounts[shelf] > 0 || shelf === 'all'}
<button
type="button"
onclick={() => (activeShelf = shelf)}
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors
{activeShelf === shelf
? 'bg-(--color-brand) text-(--color-surface)'
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) border border-(--color-border)'}"
>
{shelfLabels[shelf]}{shelfCounts[shelf] !== data.books.length || shelf === 'all' ? ` (${shelfCounts[shelf]})` : ''}
</button>
{/if}
{/each}
{/if}
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
@@ -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)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
onclick={(e) => 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'}"
>
<!-- Selection overlay -->
{#if selectMode}
<div class="absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors
{isSelected ? 'bg-(--color-brand) border-(--color-brand)' : 'bg-black/40 border-white/60'}">
{#if isSelected}
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
{/if}
</div>
{/if}
<!-- Cover image -->
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
{#if book.cover}
<img
src={book.cover}
alt={book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 {selectMode ? 'pointer-events-none' : ''}"
loading="lazy"
draggable="false"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
@@ -148,3 +308,73 @@
{/each}
</div>
{/if}
<!-- Bulk action bar (sticky bottom, shown in selection mode) -->
{#if selectMode}
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface-2) border-t border-(--color-border) px-4 py-3 flex items-center gap-3 shadow-lg">
<span class="text-sm text-(--color-muted) mr-auto">
{selectedCount} selected
</span>
<!-- Move to shelf picker -->
<div class="relative">
<button
type="button"
disabled={busy || selectedCount === 0}
onclick={() => (shelfPickerOpen = !shelfPickerOpen)}
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
bg-(--color-surface-3) text-(--color-text) border border-(--color-border)
hover:border-zinc-500 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
Move to shelf
<svg class="w-3.5 h-3.5 transition-transform {shelfPickerOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if shelfPickerOpen}
<div class="absolute bottom-full mb-2 right-0 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden min-w-[160px]">
{#each ([['', 'Reading'], ['plan_to_read', 'Plan to Read'], ['completed', 'Completed'], ['dropped', 'Dropped']] as const) as [val, label]}
<button
type="button"
onclick={() => bulkMoveShelf(val as Shelf)}
class="w-full text-left px-4 py-2.5 text-sm text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
>
{label}
</button>
{/each}
</div>
{/if}
</div>
<!-- Remove button -->
<button
type="button"
disabled={busy || selectedCount === 0}
onclick={bulkRemove}
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
bg-red-500/10 text-red-400 border border-red-500/30
hover:bg-red-500/20 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{#if busy}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{/if}
Remove
</button>
</div>
<!-- Spacer so last row of cards isn't hidden behind the action bar -->
<div class="h-20"></div>
{/if}

View File

@@ -591,7 +591,7 @@
<button
onclick={(e) => { e.preventDefault(); scrapeNovel(novel); }}
disabled={scraping[novel.slug]}
class="w-full text-xs px-2 py-1 rounded bg-amber-500/20 text-(--color-brand-dim) hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30"
class="w-full text-xs px-2 py-1 rounded bg-amber-500 text-zinc-900 font-semibold hover:bg-amber-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{scraping[novel.slug] ? m.catalogue_scraping_novel() : m.catalogue_scrape_novel_button()}
</button>
@@ -694,7 +694,7 @@
<button
onclick={() => scrapeNovel(novel)}
disabled={scraping[novel.slug]}
class="text-xs px-2.5 py-1 rounded bg-amber-500/20 text-(--color-brand-dim) hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30 whitespace-nowrap"
class="text-xs px-2.5 py-1 rounded bg-amber-500 text-zinc-900 font-semibold hover:bg-amber-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
{scraping[novel.slug] ? m.catalogue_scraping_novel() : m.catalogue_scrape_novel_button()}
</button>