feat: rebuild the edit dialog around a cover and files rail
Drops the three tabs for a wide dialog: cover and files in a fixed rail, all fourteen metadata fields grouped beside them, Save in the footer. Adds file management, which the Files tab never had — it could only upload, never list or remove. Files now show size and format with download and delete, the latter asking whether to remove it from disk too.
This commit is contained in:
@@ -1,18 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import { type Book } from '$lib/schema';
|
import { type Book } from '$lib/schema';
|
||||||
import EditCover from './edit-cover.svelte';
|
import EditCover from './edit-cover.svelte';
|
||||||
import EditFiles from './edit-files.svelte';
|
import EditFiles from './edit-files.svelte';
|
||||||
import EditMetadata from './edit-metadata.svelte';
|
import EditMetadata from './edit-metadata.svelte';
|
||||||
|
|
||||||
let { book, open = $bindable() }: { book?: Book; open: boolean } = $props();
|
let { book, open = $bindable() }: { book?: Book; open: boolean } = $props();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The metadata form lives in a child, but Save belongs in the dialog footer —
|
||||||
|
* a submit button reaches it by id rather than the footer having to duplicate
|
||||||
|
* the submit logic.
|
||||||
|
*/
|
||||||
|
const METADATA_FORM_ID = 'edit-book-metadata';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
This dialog is mounted once, in (root)/(library)/+layout.svelte, and `book`
|
This dialog is mounted once, in (root)/(library)/+layout.svelte, and `book`
|
||||||
changes underneath it as different books are edited. bookToEdit is never
|
changes underneath it as different books are edited. bookToEdit is never
|
||||||
cleared, so {#if book} stays true and the tab forms never unmount — they seed
|
cleared, so {#if book} stays true and the forms never unmount — they seed
|
||||||
local state from `book` on mount, so without this key you would open Edit on a
|
local state from `book` on mount, so without this key you would open Edit on a
|
||||||
second book and see the first book's authors, tags and cover, then save them
|
second book and see the first book's authors, tags and cover, then save them
|
||||||
onto the wrong record. Keying on the id remounts the forms per book.
|
onto the wrong record. Keying on the id remounts the forms per book.
|
||||||
@@ -20,29 +27,41 @@
|
|||||||
<Dialog.Root bind:open>
|
<Dialog.Root bind:open>
|
||||||
{#if book}
|
{#if book}
|
||||||
{#key book.id}
|
{#key book.id}
|
||||||
<Dialog.Content class="sm:max-w-xl">
|
<Dialog.Content
|
||||||
<Tabs.Root value="metadata" class="h-[500px] max-w-xl py-4 md:h-[700px]">
|
class="flex h-[85vh] max-w-[min(72rem,95vw)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(72rem,95vw)]"
|
||||||
<Tabs.List class="grid w-full grid-cols-3">
|
>
|
||||||
<Tabs.Trigger value="metadata">Metadata</Tabs.Trigger>
|
<Dialog.Header class="shrink-0 space-y-0 border-b px-5 py-3 text-left">
|
||||||
<Tabs.Trigger value="cover">Cover</Tabs.Trigger>
|
<Dialog.Title class="truncate font-serif text-base font-normal">{book.title}</Dialog.Title
|
||||||
<Tabs.Trigger value="files">Files</Tabs.Trigger>
|
>
|
||||||
</Tabs.List>
|
<Dialog.Description class="truncate text-xs">
|
||||||
|
{book.authors.map((author) => author.name).join(', ') || 'Unknown author'}
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
<!-- Metadata form -->
|
<!-- Rail and form scroll independently so the footer never moves -->
|
||||||
<Tabs.Content value="metadata" class="h-full overflow-y-auto pb-1">
|
<div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[16rem_1fr]">
|
||||||
<EditMetadata {book} {open} />
|
<aside
|
||||||
</Tabs.Content>
|
class="flex min-w-0 flex-col gap-5 overflow-y-auto border-b bg-sidebar p-4 md:border-r md:border-b-0"
|
||||||
|
>
|
||||||
<!-- Cover form -->
|
<EditCover {book} />
|
||||||
<Tabs.Content value="cover">
|
|
||||||
<EditCover {book} {open} />
|
|
||||||
</Tabs.Content>
|
|
||||||
|
|
||||||
<!-- Add files form -->
|
|
||||||
<Tabs.Content value="files">
|
|
||||||
<EditFiles {book} />
|
<EditFiles {book} />
|
||||||
</Tabs.Content>
|
</aside>
|
||||||
</Tabs.Root>
|
|
||||||
|
<div class="min-h-0 overflow-y-auto p-5">
|
||||||
|
<EditMetadata {book} bind:open formId={METADATA_FORM_ID} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Footer class="shrink-0 items-center gap-2 border-t px-5 py-3 sm:justify-between">
|
||||||
|
<!-- Cover and file changes hit the server as they happen, while the
|
||||||
|
fields wait for Save. Saying so is the cheapest way to stop
|
||||||
|
Cancel reading as "undo everything". -->
|
||||||
|
<p class="text-xs text-muted-foreground">Cover and file changes apply immediately</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
|
||||||
|
<Button type="submit" form={METADATA_FORM_ID}>Save changes</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Footer>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,34 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import { tick, untrack } from 'svelte';
|
||||||
FileDropZone,
|
|
||||||
type FileDropZoneProps,
|
|
||||||
displaySize
|
|
||||||
} from '$lib/components/ui/file-drop-zone/index.js';
|
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
|
||||||
import * as Field from '$lib/components/ui/field/index.js';
|
|
||||||
import { Switch } from '$lib/components/ui/switch/index';
|
|
||||||
import BookImage from '$lib/components/view/book-image.svelte';
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
import { updateBookCover } from '$lib/api';
|
import { updateBookCover } from '$lib/api';
|
||||||
import type { Book } from '$lib/schema';
|
import type { Book } from '$lib/schema';
|
||||||
import { tick, untrack } from 'svelte';
|
import BookImage from '$lib/components/view/book-image.svelte';
|
||||||
import { X } from '@lucide/svelte';
|
import { FileDropZone, type FileDropZoneProps } from '$lib/components/ui/file-drop-zone/index.js';
|
||||||
|
|
||||||
let { book, open = $bindable() }: { book: Book; open: boolean } = $props();
|
let { book }: { book: Book } = $props();
|
||||||
|
|
||||||
let formEl = $state<HTMLFormElement>();
|
let formEl = $state<HTMLFormElement>();
|
||||||
// Seeded once per mount — see the key in edit-book.svelte
|
// Seeded once per mount — see the key in edit-book.svelte
|
||||||
let coverImagePreview = $state(untrack(() => `/api/${book.cover_image}`));
|
let coverImagePreview = $state<string>(untrack(() => `/api/${book.cover_image}`));
|
||||||
let autoUploadOnDrop = $state(true);
|
|
||||||
|
|
||||||
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
|
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
|
||||||
updateBookCover.fields.file.set(uploadedFiles[0]);
|
updateBookCover.fields.file.set(uploadedFiles[0]);
|
||||||
updateCoverPreview();
|
updateCoverPreview();
|
||||||
if (autoUploadOnDrop && updateBookCover.fields.file.value()) {
|
|
||||||
await tick();
|
await tick();
|
||||||
formEl?.requestSubmit();
|
formEl?.requestSubmit();
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateCoverPreview() {
|
function updateCoverPreview() {
|
||||||
@@ -36,14 +25,14 @@
|
|||||||
if (file && file.type.startsWith('image/')) {
|
if (file && file.type.startsWith('image/')) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onloadend = () => {
|
reader.onloadend = () => {
|
||||||
coverImagePreview = reader.result;
|
coverImagePreview = reader.result as string;
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
|
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
|
||||||
toast.error(`${file.name} failed to upload!`, { description: reason });
|
toast.error(`${file.name} was not used`, { description: reason });
|
||||||
};
|
};
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -54,65 +43,40 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<section class="flex min-w-0 flex-col gap-2">
|
||||||
|
<h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Cover</h3>
|
||||||
|
|
||||||
|
<BookImage src={coverImagePreview} class="w-full rounded-md border" />
|
||||||
|
|
||||||
<form
|
<form
|
||||||
bind:this={formEl}
|
bind:this={formEl}
|
||||||
{...updateBookCover.enhance(async ({ submit, form }) => {
|
{...updateBookCover.enhance(async ({ submit, form }) => {
|
||||||
try {
|
try {
|
||||||
await submit();
|
await submit();
|
||||||
form.reset();
|
form.reset();
|
||||||
open = false;
|
// Deliberately does not close the dialog. The cover is one panel of a
|
||||||
toast.success('Updated book cover!');
|
// larger form now, and closing here would throw away metadata edits
|
||||||
|
// the reader has not saved yet.
|
||||||
|
toast.success('Cover updated');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update book cover: ', error);
|
console.error('Failed to update book cover: ', error);
|
||||||
toast.error('Failed to update cover.');
|
toast.error('Failed to update the cover');
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
enctype="multipart/form-data"
|
enctype="multipart/form-data"
|
||||||
class="grid grid-cols-[1fr_2fr] gap-4 p-6"
|
class="flex flex-col gap-2"
|
||||||
>
|
>
|
||||||
<input class="hidden" {...updateBookCover.fields.book_id.as('text')} />
|
<input class="hidden" {...updateBookCover.fields.book_id.as('text')} />
|
||||||
|
<input class="hidden" {...updateBookCover.fields.file.as('file')} />
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<BookImage src={coverImagePreview} class="w-64 rounded" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<FileDropZone
|
<FileDropZone
|
||||||
{onUpload}
|
{onUpload}
|
||||||
{onFileRejected}
|
{onFileRejected}
|
||||||
accept=".jpeg,.jpg,.png,.webp,image/*"
|
accept=".jpeg,.jpg,.png,.webp,image/*"
|
||||||
label="Only JPEG, PNG, and WEBP images supported"
|
label="Replace cover"
|
||||||
|
sublabel="JPEG, PNG or WEBP"
|
||||||
maxFiles={1}
|
maxFiles={1}
|
||||||
fileCount={updateBookCover.fields.file.value() ? 1 : 0}
|
fileCount={updateBookCover.fields.file.value() ? 1 : 0}
|
||||||
/>
|
/>
|
||||||
<input class="hidden" {...updateBookCover.fields.file.as('file')} />
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
{#if updateBookCover.fields.file.value()}
|
|
||||||
<div class="flex place-items-center justify-between gap-2">
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span>{updateBookCover.fields.file.value().name}</span>
|
|
||||||
<span class="text-xs text-muted-foreground"
|
|
||||||
>{displaySize(updateBookCover.fields.file.value().size)}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onclick={() => {
|
|
||||||
updateBookCover.fields.file.set(undefined);
|
|
||||||
coverImagePreview = `/api/${book.cover_image}`;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-row items-center space-x-2">
|
|
||||||
<Switch bind:checked={autoUploadOnDrop} />
|
|
||||||
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label>
|
|
||||||
<Button type="submit" class="ml-auto w-fit" disabled={!updateBookCover.fields.file.value()}>Upload</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
</section>
|
||||||
|
|||||||
@@ -1,98 +1,196 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { uploadBookFiles } from '$lib/api';
|
import { tick, untrack } from 'svelte';
|
||||||
import {
|
|
||||||
displaySize,
|
|
||||||
FileDropZone,
|
|
||||||
type FileDropZoneProps
|
|
||||||
} from '$lib/components/ui/file-drop-zone';
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import * as Field from '$lib/components/ui/field/index.js';
|
|
||||||
|
|
||||||
import { Switch } from '$lib/components/ui/switch/index';
|
|
||||||
import { X } from '@lucide/svelte';
|
|
||||||
|
|
||||||
import type { Book } from '$lib/schema';
|
|
||||||
import { tick } from 'svelte';
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { Download, Trash2 } from '@lucide/svelte';
|
||||||
|
|
||||||
|
import { uploadBookFiles } from '$lib/api';
|
||||||
|
import type { Book, BookFile } from '$lib/schema';
|
||||||
|
import { formatFileSize, getFileType } from '$lib/utils';
|
||||||
|
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
|
||||||
|
|
||||||
|
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||||
|
import * as Field from '$lib/components/ui/field/index.js';
|
||||||
|
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||||
|
import { FileDropZone, type FileDropZoneProps } from '$lib/components/ui/file-drop-zone';
|
||||||
|
import { Spinner } from '$lib/components/ui/spinner/index';
|
||||||
|
|
||||||
let { book }: { book: Book } = $props();
|
let { book }: { book: Book } = $props();
|
||||||
|
|
||||||
let files = $derived(uploadBookFiles.fields.files.value() ?? []);
|
const bookOps = getBookOperationsState();
|
||||||
|
|
||||||
let autoUploadOnDrop = $state(true);
|
/**
|
||||||
|
* The book's files, owned locally.
|
||||||
|
*
|
||||||
|
* `book` is a snapshot handed down from bookOperations rather than a live
|
||||||
|
* query, so invalidating after a delete does not reach it. Keeping the list
|
||||||
|
* here lets the rail reflect an add or a remove straight away. Seeded once per
|
||||||
|
* mount — edit-book.svelte keys the dialog on book.id.
|
||||||
|
*/
|
||||||
|
let files = $state<BookFile[]>(untrack(() => [...book.files]));
|
||||||
|
|
||||||
|
// The field's value is a sparse-ish list until the form settles, so narrow it
|
||||||
|
// before rendering rather than asserting at each use.
|
||||||
|
let pending = $derived(
|
||||||
|
(uploadBookFiles.fields.files.value() ?? []).filter((file): file is File => Boolean(file))
|
||||||
|
);
|
||||||
|
|
||||||
|
let fileToDelete = $state<BookFile>();
|
||||||
|
let deleteFromDisk = $state(true);
|
||||||
|
let confirmOpen = $state(false);
|
||||||
let formEl = $state<HTMLFormElement>();
|
let formEl = $state<HTMLFormElement>();
|
||||||
|
|
||||||
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
|
const onUpload: FileDropZoneProps['onUpload'] = async (uploaded) => {
|
||||||
uploadBookFiles.fields.files.set([...Array.from(files), ...uploadedFiles]);
|
uploadBookFiles.fields.files.set([...Array.from(pending), ...uploaded]);
|
||||||
if (autoUploadOnDrop && files.length > 0) {
|
|
||||||
await tick();
|
await tick();
|
||||||
formEl?.requestSubmit();
|
formEl?.requestSubmit();
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
|
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
|
||||||
toast.error(`${file.name} failed to upload!`, { description: reason });
|
toast.error(`${file.name} was not added`, { description: reason });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function confirmDelete(file: BookFile) {
|
||||||
|
fileToDelete = file;
|
||||||
|
confirmOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFile() {
|
||||||
|
if (!fileToDelete) return;
|
||||||
|
|
||||||
|
const target = fileToDelete;
|
||||||
|
confirmOpen = false;
|
||||||
|
|
||||||
|
// Drop it from the list first: the request invalidates the books query, but
|
||||||
|
// this dialog holds its own copy of the book and would not see that.
|
||||||
|
files = files.filter((file) => file.id !== target.id);
|
||||||
|
|
||||||
|
await bookOps.deleteBookFiles(book.id, [target.id], deleteFromDisk);
|
||||||
|
fileToDelete = undefined;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<section class="flex min-w-0 flex-col gap-2">
|
||||||
|
<h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Files</h3>
|
||||||
|
|
||||||
|
{#if files.length === 0}
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
No files yet. Add one below so this book can be read or downloaded.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ul class="flex flex-col gap-1.5">
|
||||||
|
{#each files as file (file.id)}
|
||||||
|
<li class="flex items-center gap-2 rounded-md border bg-background p-2">
|
||||||
|
<span
|
||||||
|
class="rounded-sm bg-accent px-1.5 py-0.5 font-mono text-[9px] font-semibold text-accent-foreground"
|
||||||
|
>
|
||||||
|
{getFileType(file.filename)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate text-xs" title={file.filename}>{file.filename}</span>
|
||||||
|
<span class="block font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="size-7 shrink-0"
|
||||||
|
title="Download {file.filename}"
|
||||||
|
onclick={() => bookOps.downloadBookFile(book.id, file.id, file.filename)}
|
||||||
|
>
|
||||||
|
<Download class="size-3.5" />
|
||||||
|
<span class="sr-only">Download {file.filename}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="size-7 shrink-0 text-destructive hover:text-destructive"
|
||||||
|
title="Remove {file.filename}"
|
||||||
|
onclick={() => confirmDelete(file)}
|
||||||
|
>
|
||||||
|
<Trash2 class="size-3.5" />
|
||||||
|
<span class="sr-only">Remove {file.filename}</span>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#each pending as file (file.name)}
|
||||||
|
<li
|
||||||
|
class="flex items-center gap-2 rounded-md border border-dashed bg-background p-2 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Spinner class="size-3.5 shrink-0" />
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate text-xs">{file.name}</span>
|
||||||
|
<span class="block font-mono text-[10px] tabular-nums">Uploading…</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
{...uploadBookFiles.enhance(async ({ submit, form }) => {
|
bind:this={formEl}
|
||||||
|
{...uploadBookFiles.enhance(async ({ submit }) => {
|
||||||
try {
|
try {
|
||||||
await submit();
|
await submit();
|
||||||
|
|
||||||
// Check if there are any validation issues
|
|
||||||
const issues = uploadBookFiles.fields.allIssues();
|
const issues = uploadBookFiles.fields.allIssues();
|
||||||
if (issues && issues.length > 0) {
|
if (issues && issues.length > 0) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the files field
|
// The endpoint answers with the updated book, so the new files come
|
||||||
|
// back with their ids rather than having to be guessed at.
|
||||||
|
files = uploadBookFiles.result?.files ?? files;
|
||||||
uploadBookFiles.fields.files.set([]);
|
uploadBookFiles.fields.files.set([]);
|
||||||
toast.success('Files successfully added!');
|
toast.success('Files added');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to upload files: ', error);
|
console.error('Failed to add files: ', error);
|
||||||
toast.error('Failed to upload files');
|
toast.error('Failed to add files');
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
bind:this={formEl}
|
|
||||||
enctype="multipart/form-data"
|
enctype="multipart/form-data"
|
||||||
class="flex w-full flex-col gap-2 p-4"
|
class="flex flex-col gap-2"
|
||||||
>
|
>
|
||||||
<input {...uploadBookFiles.fields.book_id.as('hidden', book.id)} />
|
<input {...uploadBookFiles.fields.book_id.as('hidden', book.id)} />
|
||||||
|
<input class="hidden" {...uploadBookFiles.fields.files.as('file multiple')} />
|
||||||
|
|
||||||
<FileDropZone
|
<FileDropZone
|
||||||
{onUpload}
|
{onUpload}
|
||||||
{onFileRejected}
|
{onFileRejected}
|
||||||
accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook"
|
accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook"
|
||||||
sublabel="Only PDF, EPUB, and MOBI files supported"
|
label="Add a file"
|
||||||
|
sublabel="EPUB, PDF or MOBI"
|
||||||
/>
|
/>
|
||||||
<input class="hidden" {...uploadBookFiles.fields.files.as('file multiple')} />
|
</form>
|
||||||
<div class="flex flex-col gap-2">
|
</section>
|
||||||
{#each files as file, idx}
|
|
||||||
<div class="flex place-items-center justify-between gap-2">
|
<AlertDialog.Root bind:open={confirmOpen}>
|
||||||
<div class="flex flex-col">
|
<AlertDialog.Content>
|
||||||
<span>{file.name}</span>
|
<AlertDialog.Header>
|
||||||
<span class="text-xs text-muted-foreground">{displaySize(file.size)}</span>
|
<AlertDialog.Title>Remove {fileToDelete?.filename}?</AlertDialog.Title>
|
||||||
</div>
|
<AlertDialog.Description>
|
||||||
<Button
|
This cannot be undone. The other files on this book are not affected.
|
||||||
variant="outline"
|
</AlertDialog.Description>
|
||||||
size="icon"
|
</AlertDialog.Header>
|
||||||
onclick={() => {
|
|
||||||
uploadBookFiles.fields.files.set([
|
<!-- The API takes these as separate outcomes: drop the record, or drop the
|
||||||
...Array.from(files).slice(0, idx),
|
record and the file on disk. Leaving it implicit would mean deleting
|
||||||
...Array.from(files).slice(idx + 1)
|
someone's only copy without saying so. -->
|
||||||
]);
|
<div class="flex items-center gap-2">
|
||||||
}}
|
<Checkbox id="delete-from-disk" bind:checked={deleteFromDisk} />
|
||||||
>
|
<Field.Label for="delete-from-disk" class="font-normal">
|
||||||
<X />
|
Also delete the file from the filesystem
|
||||||
</Button>
|
</Field.Label>
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-row items-center space-x-2">
|
<AlertDialog.Footer>
|
||||||
<Switch bind:checked={autoUploadOnDrop} />
|
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||||
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label>
|
<AlertDialog.Action class={buttonVariants({ variant: 'destructive' })} onclick={removeFile}>
|
||||||
<Button type="submit" class="ml-auto w-fit">Upload</Button>
|
Remove
|
||||||
</div>
|
</AlertDialog.Action>
|
||||||
</form>
|
</AlertDialog.Footer>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Root>
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import { untrack } from 'svelte';
|
||||||
import * as Field from '$lib/components/ui/field/index.js';
|
|
||||||
import { TagsInput, type TagsInputProps } from '$lib/components/ui/tags-input/index.js';
|
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
|
||||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { Minus, Plus } from '@lucide/svelte';
|
||||||
|
|
||||||
import { updateBookMetadata } from '$lib/api';
|
import { updateBookMetadata } from '$lib/api';
|
||||||
import type { Book } from '$lib/schema';
|
import type { Book } from '$lib/schema';
|
||||||
import { untrack } from 'svelte';
|
|
||||||
import { Minus, Plus } from '@lucide/svelte';
|
|
||||||
|
|
||||||
let { book, open = $bindable() }: { book: Book; open: boolean } = $props();
|
import * as Field from '$lib/components/ui/field/index.js';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
import { TagsInput, type TagsInputProps } from '$lib/components/ui/tags-input/index.js';
|
||||||
|
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
book,
|
||||||
|
open = $bindable(),
|
||||||
|
/** Lets the dialog footer own the submit button via the `form` attribute. */
|
||||||
|
formId
|
||||||
|
}: { book: Book; open: boolean; formId: string } = $props();
|
||||||
|
|
||||||
// Seeded once per mount; edit-book.svelte keys this form on book.id so a
|
// Seeded once per mount; edit-book.svelte keys this form on book.id so a
|
||||||
// different book gets a fresh form rather than the previous book's values.
|
// different book gets a fresh form rather than the previous book's values.
|
||||||
@@ -22,7 +27,6 @@
|
|||||||
let identifierValues = $state(untrack(() => Object.values(book.identifiers)));
|
let identifierValues = $state(untrack(() => Object.values(book.identifiers)));
|
||||||
|
|
||||||
function handleAddIdentifier() {
|
function handleAddIdentifier() {
|
||||||
// Add empty strings to both arrays
|
|
||||||
identifierKeys = [...identifierKeys, ''];
|
identifierKeys = [...identifierKeys, ''];
|
||||||
identifierValues = [...identifierValues, ''];
|
identifierValues = [...identifierValues, ''];
|
||||||
}
|
}
|
||||||
@@ -88,9 +92,17 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root class="w-full ">
|
{#snippet groupHeading(label: string)}
|
||||||
|
<h3
|
||||||
|
class="col-span-full mt-2 border-b pb-1 font-mono text-[10px] tracking-widest text-primary uppercase first:mt-0"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</h3>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
{...updateBookMetadata.enhance(async ({ submit, form }) => {
|
id={formId}
|
||||||
|
{...updateBookMetadata.enhance(async ({ submit }) => {
|
||||||
try {
|
try {
|
||||||
await submit();
|
await submit();
|
||||||
|
|
||||||
@@ -101,28 +113,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
open = false;
|
open = false;
|
||||||
book = book;
|
|
||||||
toast.success('Updated book metadata!');
|
toast.success('Updated book metadata!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error occurred updating book metadata: ', error);
|
console.error('Error occurred updating book metadata: ', error);
|
||||||
toast.error('Failed to update book metadata.');
|
toast.error('Failed to update book metadata.');
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
class="grid grid-cols-1 items-start gap-x-4 gap-y-3 sm:grid-cols-2"
|
||||||
>
|
>
|
||||||
<Card.Content>
|
<input class="hidden" {...updateBookMetadata.fields.book_id.as('text')} />
|
||||||
<Field.Set>
|
|
||||||
<Field.Group class="flex flex-col">
|
|
||||||
<!-- Book ID field -->
|
|
||||||
<Field.Field class="hidden">
|
|
||||||
<Field.Label for="book_id">Book ID</Field.Label>
|
|
||||||
<Input {...updateBookMetadata.fields.book_id.as('text')} />
|
|
||||||
{#each updateBookMetadata.fields.book_id.issues() ?? [] as issue}
|
|
||||||
<Field.Error>{issue.message}</Field.Error>
|
|
||||||
{/each}
|
|
||||||
</Field.Field>
|
|
||||||
|
|
||||||
<!-- Title field -->
|
{@render groupHeading('Identity')}
|
||||||
<Field.Field>
|
|
||||||
|
<Field.Field class="col-span-full">
|
||||||
<Field.Label for="title">Title</Field.Label>
|
<Field.Label for="title">Title</Field.Label>
|
||||||
<Input {...updateBookMetadata.fields.title.as('text')} />
|
<Input {...updateBookMetadata.fields.title.as('text')} />
|
||||||
{#each updateBookMetadata.fields.title.issues() ?? [] as issue}
|
{#each updateBookMetadata.fields.title.issues() ?? [] as issue}
|
||||||
@@ -130,7 +133,6 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
<!-- Subtitle field -->
|
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="subtitle">Subtitle</Field.Label>
|
<Field.Label for="subtitle">Subtitle</Field.Label>
|
||||||
<Input {...updateBookMetadata.fields.subtitle.as('text')} />
|
<Input {...updateBookMetadata.fields.subtitle.as('text')} />
|
||||||
@@ -139,8 +141,14 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
<div class="grid grid-cols-[3fr_1fr] gap-2">
|
<Field.Field>
|
||||||
<!-- Series field -->
|
<Field.Label for="edition">Edition</Field.Label>
|
||||||
|
<Input {...updateBookMetadata.fields.edition.as('number')} />
|
||||||
|
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue}
|
||||||
|
<Field.Error>{issue.message}</Field.Error>
|
||||||
|
{/each}
|
||||||
|
</Field.Field>
|
||||||
|
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="series">Series</Field.Label>
|
<Field.Label for="series">Series</Field.Label>
|
||||||
<Input {...updateBookMetadata.fields.series.as('text')} />
|
<Input {...updateBookMetadata.fields.series.as('text')} />
|
||||||
@@ -149,18 +157,27 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
<!-- Series position field -->
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="series_position">Series position</Field.Label>
|
<Field.Label for="series_position">No.</Field.Label>
|
||||||
<Input {...updateBookMetadata.fields.series_position.as('text')} />
|
<Input {...updateBookMetadata.fields.series_position.as('text')} />
|
||||||
{#each updateBookMetadata.fields.series_position.issues() ?? [] as issue}
|
{#each updateBookMetadata.fields.series_position.issues() ?? [] as issue}
|
||||||
<Field.Error>{issue.message}</Field.Error>
|
<Field.Error>{issue.message}</Field.Error>
|
||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for="language">Language</Field.Label>
|
||||||
|
<Input {...updateBookMetadata.fields.language.as('text')} />
|
||||||
|
{#each updateBookMetadata.fields.language.issues() ?? [] as issue}
|
||||||
|
<Field.Error>{issue.message}</Field.Error>
|
||||||
|
{/each}
|
||||||
|
</Field.Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Authors field -->
|
{@render groupHeading('People and subjects')}
|
||||||
<Field.Field>
|
|
||||||
|
<Field.Field class="col-span-full">
|
||||||
<Field.Label for="authors">Authors</Field.Label>
|
<Field.Label for="authors">Authors</Field.Label>
|
||||||
<TagsInput
|
<TagsInput
|
||||||
bind:value={authors}
|
bind:value={authors}
|
||||||
@@ -176,8 +193,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
<!-- Tags field -->
|
<Field.Field class="col-span-full">
|
||||||
<Field.Field>
|
|
||||||
<Field.Label for="tags">Tags</Field.Label>
|
<Field.Label for="tags">Tags</Field.Label>
|
||||||
<TagsInput
|
<TagsInput
|
||||||
bind:value={tags}
|
bind:value={tags}
|
||||||
@@ -193,39 +209,8 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
<!-- Description field -->
|
{@render groupHeading('Publication')}
|
||||||
<Field.Field>
|
|
||||||
<Field.Label for="description">Description</Field.Label>
|
|
||||||
<Textarea {...updateBookMetadata.fields.description.as('text')} />
|
|
||||||
{#each updateBookMetadata.fields.description.issues() ?? [] as issue}
|
|
||||||
<Field.Error>{issue.message}</Field.Error>
|
|
||||||
{/each}
|
|
||||||
</Field.Field>
|
|
||||||
|
|
||||||
<!-- Identifier fields -->
|
|
||||||
<Field.Field>
|
|
||||||
<Field.Label for="identifiers">Identifiers</Field.Label>
|
|
||||||
<div class="grid grid-cols-[5fr_10fr_0.5fr] gap-2">
|
|
||||||
{#each identifierKeys as _, idx}
|
|
||||||
<Input bind:value={identifierKeys[idx]} placeholder="Identifier..." />
|
|
||||||
<Input bind:value={identifierValues[idx]} placeholder="Value..." />
|
|
||||||
|
|
||||||
<Button variant="outline" size="icon" onclick={() => handleRemoveIdentifier(idx)}>
|
|
||||||
<Minus />
|
|
||||||
</Button>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<Button variant="outline" onclick={() => handleAddIdentifier()}>
|
|
||||||
<Plus />
|
|
||||||
Add Identifier
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<input {...updateBookMetadata.fields.identifiers.as('text')} class="hidden" />
|
|
||||||
</div>
|
|
||||||
</Field.Field>
|
|
||||||
|
|
||||||
<!-- Publisher field -->
|
|
||||||
<div class="grid grid-cols-[2fr_1fr] gap-2">
|
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="publisher">Publisher</Field.Label>
|
<Field.Label for="publisher">Publisher</Field.Label>
|
||||||
<Input {...updateBookMetadata.fields.publisher.as('text')} />
|
<Input {...updateBookMetadata.fields.publisher.as('text')} />
|
||||||
@@ -234,18 +219,15 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
<!-- Published date field -->
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="published_date">Date published</Field.Label>
|
<Field.Label for="published_date">Published</Field.Label>
|
||||||
<Input {...updateBookMetadata.fields.published_date.as('date')} />
|
<Input {...updateBookMetadata.fields.published_date.as('date')} />
|
||||||
{#each updateBookMetadata.fields.published_date.issues() ?? [] as issue}
|
{#each updateBookMetadata.fields.published_date.issues() ?? [] as issue}
|
||||||
<Field.Error>{issue.message}</Field.Error>
|
<Field.Error>{issue.message}</Field.Error>
|
||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-[2fr_1fr_1fr] gap-2">
|
|
||||||
<!-- Pages field -->
|
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="pages">Pages</Field.Label>
|
<Field.Label for="pages">Pages</Field.Label>
|
||||||
<Input {...updateBookMetadata.fields.pages.as('number')} />
|
<Input {...updateBookMetadata.fields.pages.as('number')} />
|
||||||
@@ -253,32 +235,44 @@
|
|||||||
<Field.Error>{issue.message}</Field.Error>
|
<Field.Error>{issue.message}</Field.Error>
|
||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
<!-- Language field -->
|
|
||||||
<Field.Field>
|
|
||||||
<Field.Label for="language">Language</Field.Label>
|
|
||||||
<Input {...updateBookMetadata.fields.language.as('text')} />
|
|
||||||
{#each updateBookMetadata.fields.language.issues() ?? [] as issue}
|
|
||||||
<Field.Error>{issue.message}</Field.Error>
|
|
||||||
{/each}
|
|
||||||
</Field.Field>
|
|
||||||
|
|
||||||
<!-- Edition field -->
|
|
||||||
<Field.Field>
|
|
||||||
<Field.Label for="edition">Edition</Field.Label>
|
|
||||||
<Input {...updateBookMetadata.fields.edition.as('number')} />
|
|
||||||
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue}
|
|
||||||
<Field.Error>{issue.message}</Field.Error>
|
|
||||||
{/each}
|
|
||||||
</Field.Field>
|
|
||||||
</div>
|
</div>
|
||||||
</Field.Group>
|
|
||||||
</Field.Set>
|
|
||||||
</Card.Content>
|
|
||||||
|
|
||||||
<!-- Submit button -->
|
<Field.Field class="col-span-full">
|
||||||
<Card.Footer class="flex-col gap-2 pt-6">
|
<Field.Label for="identifiers">Identifiers</Field.Label>
|
||||||
<Button type="submit" class="w-full">Save</Button>
|
<div class="flex flex-col gap-2">
|
||||||
</Card.Footer>
|
{#each identifierKeys as _, idx}
|
||||||
|
<div class="grid grid-cols-[1fr_1.6fr_auto] gap-2">
|
||||||
|
<Input bind:value={identifierKeys[idx]} placeholder="ISBN, DOI…" />
|
||||||
|
<Input bind:value={identifierValues[idx]} placeholder="Value" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
title="Remove identifier"
|
||||||
|
onclick={() => handleRemoveIdentifier(idx)}
|
||||||
|
>
|
||||||
|
<Minus />
|
||||||
|
<span class="sr-only">Remove identifier</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<Button type="button" variant="outline" class="w-fit" onclick={handleAddIdentifier}>
|
||||||
|
<Plus />
|
||||||
|
Add identifier
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<input {...updateBookMetadata.fields.identifiers.as('text')} class="hidden" />
|
||||||
|
</div>
|
||||||
|
</Field.Field>
|
||||||
|
|
||||||
|
{@render groupHeading('Description')}
|
||||||
|
|
||||||
|
<Field.Field class="col-span-full">
|
||||||
|
<Field.Label for="description" class="sr-only">Description</Field.Label>
|
||||||
|
<Textarea {...updateBookMetadata.fields.description.as('text')} rows={6} />
|
||||||
|
{#each updateBookMetadata.fields.description.issues() ?? [] as issue}
|
||||||
|
<Field.Error>{issue.message}</Field.Error>
|
||||||
|
{/each}
|
||||||
|
</Field.Field>
|
||||||
</form>
|
</form>
|
||||||
</Card.Root>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user