reading: owner archive / restore / delete / merge for books (⋯ menu on cards, rows and detail pages), archived filter, tombstones so merged ids redirect and the ingest never recreates deleted works, books-quarantine.py for deleted files
All checks were successful
Deploy production / deploy (push) Successful in 1m11s

This commit is contained in:
2026-10-25 14:22:40 +00:00
parent 8a8ed1642e
commit 27987fb8e7
17 changed files with 27812 additions and 28 deletions

View File

@@ -0,0 +1,165 @@
<script lang="ts">
// Owner-only ⋯ menu for a work: archive / restore, merge into another work (detail page) and
// delete (with a confirm dialog). The server re-checks ownership on every action.
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js';
import * as AlertDialog from '#lib/components/ui/alert-dialog/index.js';
import * as Dialog from '#lib/components/ui/dialog/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import Ellipsis from 'lucide-svelte/icons/ellipsis';
import Archive from 'lucide-svelte/icons/archive';
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
import Merge from 'lucide-svelte/icons/merge';
import Trash2 from 'lucide-svelte/icons/trash-2';
type Candidate = { id: number; title: string; subtitle: string | null; authors: string[] };
let {
id,
title,
archived,
label = 'book',
candidates,
class: className = ''
}: {
id: number;
title: string;
archived: boolean;
label?: string;
candidates?: Candidate[];
class?: string;
} = $props();
let archiveForm = $state<HTMLFormElement>();
let confirmDelete = $state(false);
let mergeOpen = $state(false);
let busy = $state(false);
let into = $state('');
const submit: SubmitFunction = () => {
busy = true;
return async ({ update }) => {
await update();
busy = false;
confirmDelete = false;
mergeOpen = false;
};
};
</script>
<form
method="POST"
action={archived ? '?/restore' : '?/archive'}
use:enhance={submit}
bind:this={archiveForm}
hidden
>
<input type="hidden" name="id" value={id} />
</form>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class={['work-actions', className]}
aria-label="Actions for {title}"
disabled={busy}
>
<Ellipsis size={16} aria-hidden="true" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="min-w-44">
<DropdownMenu.Item onSelect={() => archiveForm?.requestSubmit()}>
{#if archived}<ArchiveRestore /> restore{:else}<Archive /> archive{/if}
</DropdownMenu.Item>
{#if candidates}
<DropdownMenu.Item onSelect={() => (mergeOpen = true)}
><Merge /> merge into…</DropdownMenu.Item
>
{/if}
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onSelect={() => (confirmDelete = true)}
><Trash2 /> delete…</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
<AlertDialog.Root bind:open={confirmDelete}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete “{title}”?</AlertDialog.Title>
<AlertDialog.Description>
This removes the {label} with its progress, reading history, notes and highlights for good. Files
stay on disk and can be moved to quarantine. Archive it instead to just hide it.
</AlertDialog.Description>
</AlertDialog.Header>
<form method="POST" action="?/delete" use:enhance={submit}>
<input type="hidden" name="id" value={id} />
<AlertDialog.Footer>
<AlertDialog.Cancel type="button">Keep it</AlertDialog.Cancel>
<Button type="submit" variant="destructive" disabled={busy}>Delete</Button>
</AlertDialog.Footer>
</form>
</AlertDialog.Content>
</AlertDialog.Root>
{#if candidates}
<Dialog.Root bind:open={mergeOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Merge into another {label}</Dialog.Title>
<Dialog.Description>
Files, progress, notes, highlights and reading history move to the {label} you pick; empty details
there are filled in from this one. Then this one is removed and its link redirects.
</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/merge" use:enhance={submit} class="grid gap-3">
{#if candidates.length}
<NativeSelect name="into" bind:value={into} required class="w-full">
<option value="" disabled>Pick a likely duplicate…</option>
{#each candidates as c (c.id)}
<option value={String(c.id)}
>#{c.id} · {c.title}{c.subtitle ? `: ${c.subtitle}` : ''}{c.authors.length
? ` (${c.authors.join(', ')})`
: ''}</option
>
{/each}
</NativeSelect>
{:else}
<input
name="into"
bind:value={into}
required
inputmode="numeric"
placeholder="id or link, e.g. /trackers/books/289"
class="rounded-md border border-[var(--site-border)] bg-transparent px-3 py-2 text-sm"
/>
{/if}
<Dialog.Footer>
<Button type="submit" disabled={busy || !into}>Merge</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
{/if}
<style>
:global(.work-actions) {
display: inline-grid;
place-items: center;
width: 1.9rem;
height: 1.9rem;
border-radius: 999px;
color: var(--site-muted);
border: 1px solid var(--site-border);
background: color-mix(in oklab, var(--site-bg) 80%, transparent);
backdrop-filter: blur(6px);
transition:
color 0.15s,
background 0.15s,
opacity 0.15s;
}
:global(.work-actions:hover),
:global(.work-actions[data-state='open']) {
color: var(--site-text);
background: var(--site-hover);
}
</style>

View File

@@ -59,6 +59,7 @@ export type BookCard = {
// Owner only (empty / false for everyone else):
formats: string[];
hasFile: boolean;
archived: boolean;
};
export type LibraryStats = {