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
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:
33
ui/src/routes/api/library/bulk-remove/+server.ts
Normal file
33
ui/src/routes/api/library/bulk-remove/+server.ts
Normal 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 });
|
||||
};
|
||||
44
ui/src/routes/api/library/bulk-shelf/+server.ts
Normal file
44
ui/src/routes/api/library/bulk-shelf/+server.ts
Normal 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 });
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user