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
All checks were successful
Deploy production / deploy (push) Successful in 1m11s
This commit is contained in:
165
src/lib/books/WorkActions.svelte
Normal file
165
src/lib/books/WorkActions.svelte
Normal 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>
|
||||
@@ -59,6 +59,7 @@ export type BookCard = {
|
||||
// Owner only (empty / false for everyone else):
|
||||
formats: string[];
|
||||
hasFile: boolean;
|
||||
archived: boolean;
|
||||
};
|
||||
|
||||
export type LibraryStats = {
|
||||
|
||||
Reference in New Issue
Block a user