Initial commit
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
|
||||
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
title = 'Delete book?',
|
||||
deleteFn
|
||||
}: {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
deleteFn: (deleteFiles: boolean) => {};
|
||||
} = $props();
|
||||
|
||||
const selectedState = getBookSelectionState();
|
||||
const bookOps = getBookOperationsState();
|
||||
|
||||
let deleteFiles = $state(false);
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
This will delete the book(s) from the database, and optionally delete the files from the
|
||||
filesystem.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<Field.Set>
|
||||
<Field.Group>
|
||||
<!-- Delete files checkbox -->
|
||||
<Field.Field>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox bind:checked={deleteFiles} />
|
||||
<Field.Label class="font-normal">Delete files from the filesystem</Field.Label>
|
||||
</div>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<!-- Buttons -->
|
||||
<Dialog.Footer class="ml-auto flex">
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
|
||||
<Button
|
||||
onclick={async () => {
|
||||
open = false;
|
||||
await deleteFn(deleteFiles);
|
||||
}}
|
||||
variant="destructive">Delete</Button
|
||||
>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,161 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
|
||||
import {
|
||||
displaySize,
|
||||
FileDropZone,
|
||||
type FileDropZoneProps
|
||||
} from '$lib/components/ui/file-drop-zone';
|
||||
import { X } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import { Switch } from '$lib/components/ui/switch/index';
|
||||
|
||||
import { tick } from 'svelte';
|
||||
import { Spinner } from '$lib/components/ui/spinner/index';
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
import { uploadBooks } from '$lib/api';
|
||||
import type { Book, PaginatedResponse } from '$lib/schema';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let { open = $bindable() }: { open?: boolean } = $props();
|
||||
|
||||
let libraryState = getLibraryState();
|
||||
|
||||
$effect(() => {
|
||||
uploadBooks.fields.library_id.set(libraryState.activeLibrary!.id);
|
||||
});
|
||||
|
||||
let files = $derived(uploadBooks.fields.files.value() ?? []);
|
||||
|
||||
let autoUploadOnDrop = $state(true);
|
||||
let navigateOnUpload = $state(true);
|
||||
let formEl = $state<HTMLFormElement>();
|
||||
|
||||
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
|
||||
uploadBooks.fields.files.set([...Array.from(files), ...uploadedFiles]);
|
||||
if (autoUploadOnDrop && files.length > 0) {
|
||||
await tick();
|
||||
formEl?.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
|
||||
toast.error(`${file.name} failed to upload!`, { description: reason });
|
||||
};
|
||||
|
||||
function navigateToBooks(books: PaginatedResponse<Book>) {
|
||||
open = false;
|
||||
let libraryId = books.items[0].library_id;
|
||||
libraryState.setActive(libraryId);
|
||||
if (books.items.length === 1) {
|
||||
goto(`/book/${books.items[0].id}`);
|
||||
} else {
|
||||
goto(`/library/${libraryId}/view?orderBy=created_at&sortOrder=desc`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content>
|
||||
{#if uploadBooks.pending}
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<span class="text-lg font-semibold"
|
||||
>Uploading {uploadBooks.fields.files.value().length} files...</span
|
||||
>
|
||||
<Spinner class="scale-150" />
|
||||
</div>
|
||||
{:else}
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Upload Books</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form
|
||||
{...uploadBooks.enhance(async ({ submit, form }) => {
|
||||
try {
|
||||
await submit();
|
||||
|
||||
// Check if there are any validation issues
|
||||
const issues = uploadBooks.fields.allIssues();
|
||||
if (issues && issues.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update library book count
|
||||
const count = uploadBooks.result.total
|
||||
libraryState.libraries.find(lib => uploadBooks.fields.library_id.value() == lib.id.toString())!.total += count
|
||||
|
||||
// Reset the files field
|
||||
uploadBooks.fields.files.set([]);
|
||||
toast.success('Books successfully uploaded!');
|
||||
|
||||
if (navigateOnUpload) {
|
||||
navigateToBooks(uploadBooks.result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to upload book: ', error);
|
||||
toast.error('Failed to upload books');
|
||||
}
|
||||
})}
|
||||
bind:this={formEl}
|
||||
enctype="multipart/form-data"
|
||||
class="flex w-full flex-col gap-2 p-4"
|
||||
>
|
||||
<!-- Library select field -->
|
||||
<Field.Label for="library_id">Select Library</Field.Label>
|
||||
<NativeSelect.Root {...uploadBooks.fields.library_id.as('select')} class="w-36">
|
||||
{#each libraryState.libraries as library}
|
||||
<NativeSelect.Option value={library.id}>
|
||||
{library.name}
|
||||
</NativeSelect.Option>
|
||||
{/each}
|
||||
</NativeSelect.Root>
|
||||
|
||||
<FileDropZone
|
||||
{onUpload}
|
||||
{onFileRejected}
|
||||
directory={true}
|
||||
accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook"
|
||||
sublabel="Only PDF, EPUB, and MOBI files supported"
|
||||
/>
|
||||
<input class="hidden" {...uploadBooks.fields.files.as('file multiple')} />
|
||||
<div class="flex max-h-[300px] flex-col gap-2 overflow-y-auto">
|
||||
{#each files as file, idx}
|
||||
<div class="flex place-items-center justify-between gap-2">
|
||||
<div class="flex flex-col">
|
||||
<span>{file.name}</span>
|
||||
<span class="text-xs text-muted-foreground">{displaySize(file.size)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onclick={() => {
|
||||
uploadBooks.fields.files.set([
|
||||
...Array.from(files).slice(0, idx),
|
||||
...Array.from(files).slice(idx + 1)
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-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">Upload</Button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch bind:checked={navigateOnUpload} />
|
||||
<Field.Label for="navigate-to-book">Navigate to book on upload</Field.Label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import { type Book } from '$lib/schema';
|
||||
import EditCover from './edit-cover.svelte';
|
||||
import EditFiles from './edit-files.svelte';
|
||||
import EditMetadata from './edit-metadata.svelte';
|
||||
|
||||
let { book, open = $bindable() }: { book?: Book; open: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
{#if book}
|
||||
<Dialog.Content class="sm:max-w-xl">
|
||||
<Tabs.Root value="metadata" class="h-[500px] max-w-xl py-4 md:h-[700px]">
|
||||
<Tabs.List class="grid w-full grid-cols-3">
|
||||
<Tabs.Trigger value="metadata">Metadata</Tabs.Trigger>
|
||||
<Tabs.Trigger value="cover">Cover</Tabs.Trigger>
|
||||
<Tabs.Trigger value="files">Files</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<!-- Metadata form -->
|
||||
<Tabs.Content value="metadata" class="h-full overflow-y-auto pb-1">
|
||||
<EditMetadata {book} {open} />
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Cover form -->
|
||||
<Tabs.Content value="cover">
|
||||
<EditCover {book} {open} />
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Add files form -->
|
||||
<Tabs.Content value="files">
|
||||
<EditFiles {book} />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</Dialog.Content>
|
||||
{/if}
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
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 { updateBookCover } from '$lib/api';
|
||||
import type { Book } from '$lib/schema';
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { X } from '@lucide/svelte';
|
||||
|
||||
let { book, open = $bindable() }: { book: Book; open: boolean } = $props();
|
||||
|
||||
let formEl = $state<HTMLFormElement>();
|
||||
let coverImagePreview = $state(`/api/${book.cover_image}`);
|
||||
let autoUploadOnDrop = $state(true);
|
||||
|
||||
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
|
||||
updateBookCover.fields.file.set(uploadedFiles[0]);
|
||||
updateCoverPreview();
|
||||
if (autoUploadOnDrop && updateBookCover.fields.file.value()) {
|
||||
await tick();
|
||||
formEl?.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
function updateCoverPreview() {
|
||||
const file = updateBookCover.fields.file.value();
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
coverImagePreview = reader.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
|
||||
toast.error(`${file.name} failed to upload!`, { description: reason });
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
book;
|
||||
untrack(() => {
|
||||
updateBookCover.fields.book_id.set(book.id);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<form
|
||||
bind:this={formEl}
|
||||
{...updateBookCover.enhance(async ({ submit, form }) => {
|
||||
try {
|
||||
await submit();
|
||||
form.reset();
|
||||
open = false;
|
||||
toast.success('Updated book cover!');
|
||||
} catch (error) {
|
||||
console.error('Failed to update book cover: ', error);
|
||||
toast.error('Failed to update cover.');
|
||||
}
|
||||
})}
|
||||
enctype="multipart/form-data"
|
||||
class="grid grid-cols-[1fr_2fr] gap-4 p-6"
|
||||
>
|
||||
<input class="hidden" {...updateBookCover.fields.book_id.as('text')} />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<BookImage src={coverImagePreview} class="w-64 rounded" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<FileDropZone
|
||||
{onUpload}
|
||||
{onFileRejected}
|
||||
accept=".jpeg,.jpg,.png,.webp,image/*"
|
||||
label="Only JPEG, PNG, and WEBP images supported"
|
||||
maxFiles={1}
|
||||
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>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { uploadBookFiles } from '$lib/api';
|
||||
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';
|
||||
|
||||
let { book }: { book: Book } = $props();
|
||||
|
||||
let files = $derived(uploadBookFiles.fields.files.value() ?? []);
|
||||
|
||||
let autoUploadOnDrop = $state(true);
|
||||
let formEl = $state<HTMLFormElement>();
|
||||
|
||||
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
|
||||
uploadBookFiles.fields.files.set([...Array.from(files), ...uploadedFiles]);
|
||||
if (autoUploadOnDrop && files.length > 0) {
|
||||
await tick();
|
||||
formEl?.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
|
||||
toast.error(`${file.name} failed to upload!`, { description: reason });
|
||||
};
|
||||
</script>
|
||||
|
||||
<form
|
||||
{...uploadBookFiles.enhance(async ({ submit, form }) => {
|
||||
try {
|
||||
await submit();
|
||||
|
||||
// Check if there are any validation issues
|
||||
const issues = uploadBookFiles.fields.allIssues();
|
||||
if (issues && issues.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset the files field
|
||||
uploadBookFiles.fields.files.set([]);
|
||||
toast.success('Files successfully added!');
|
||||
} catch (error) {
|
||||
console.error('Failed to upload files: ', error);
|
||||
toast.error('Failed to upload files');
|
||||
}
|
||||
})}
|
||||
bind:this={formEl}
|
||||
enctype="multipart/form-data"
|
||||
class="flex w-full flex-col gap-2 p-4"
|
||||
>
|
||||
<input {...uploadBookFiles.fields.book_id.as('hidden', book.id)} />
|
||||
|
||||
<FileDropZone
|
||||
{onUpload}
|
||||
{onFileRejected}
|
||||
accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook"
|
||||
sublabel="Only PDF, EPUB, and MOBI files supported"
|
||||
/>
|
||||
<input class="hidden" {...uploadBookFiles.fields.files.as('file multiple')} />
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each files as file, idx}
|
||||
<div class="flex place-items-center justify-between gap-2">
|
||||
<div class="flex flex-col">
|
||||
<span>{file.name}</span>
|
||||
<span class="text-xs text-muted-foreground">{displaySize(file.size)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onclick={() => {
|
||||
uploadBookFiles.fields.files.set([
|
||||
...Array.from(files).slice(0, idx),
|
||||
...Array.from(files).slice(idx + 1)
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</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">Upload</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,282 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
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 { updateBookMetadata } from '$lib/api';
|
||||
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();
|
||||
|
||||
let authors = $state(book.authors.map((author) => author.name) || []);
|
||||
let tags = $state(book.tags.map((tag) => tag.name) || []);
|
||||
let identifierKeys = $state(Object.keys(book.identifiers));
|
||||
let identifierValues = $state(Object.values(book.identifiers));
|
||||
|
||||
function handleAddIdentifier() {
|
||||
// Add empty strings to both arrays
|
||||
identifierKeys = [...identifierKeys, ''];
|
||||
identifierValues = [...identifierValues, ''];
|
||||
}
|
||||
|
||||
function handleRemoveIdentifier(index: number) {
|
||||
identifierKeys = identifierKeys.filter((_, i) => i !== index);
|
||||
identifierValues = identifierValues.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
const validateTagsInput: TagsInputProps['validate'] = (val, tags) => {
|
||||
const transformed = val.trim();
|
||||
|
||||
// disallow empties
|
||||
if (transformed.length === 0) return undefined;
|
||||
|
||||
// disallow duplicates
|
||||
if (tags.find((t) => transformed === t.toLowerCase())) return undefined;
|
||||
|
||||
return transformed;
|
||||
};
|
||||
|
||||
// Pre-populate forms
|
||||
$effect(() => {
|
||||
book;
|
||||
untrack(() => {
|
||||
updateBookMetadata.fields.set({
|
||||
book_id: book.id.toString(),
|
||||
title: book.title,
|
||||
subtitle: book.subtitle || undefined,
|
||||
authors: authors,
|
||||
tags: tags,
|
||||
identifiers: JSON.stringify(
|
||||
Object.fromEntries(identifierKeys.map((key, i) => [key, identifierValues[i]]))
|
||||
),
|
||||
description: book.description || undefined,
|
||||
publisher: book.publisher?.name || undefined,
|
||||
published_date: book.published_date || undefined,
|
||||
series: book?.series?.title || undefined,
|
||||
series_position: book?.series_position || undefined,
|
||||
pages: book?.pages || undefined,
|
||||
language: book?.language || undefined,
|
||||
edition: book.edition || undefined
|
||||
});
|
||||
|
||||
identifierKeys = Object.keys(book.identifiers);
|
||||
identifierValues = Object.values(book.identifiers);
|
||||
});
|
||||
});
|
||||
|
||||
// Keep form data in sync
|
||||
$effect(() => {
|
||||
updateBookMetadata.fields.authors.set(authors);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
updateBookMetadata.fields.tags.set(tags);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
updateBookMetadata.fields.identifiers.set(
|
||||
JSON.stringify(Object.fromEntries(identifierKeys.map((key, i) => [key, identifierValues[i]])))
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Root class="w-full ">
|
||||
<form
|
||||
{...updateBookMetadata.enhance(async ({ submit, form }) => {
|
||||
try {
|
||||
await submit();
|
||||
|
||||
// Check if there are any validation issues
|
||||
const issues = updateBookMetadata.fields.allIssues();
|
||||
if (issues && issues.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
open = false;
|
||||
book = book;
|
||||
toast.success('Updated book metadata!');
|
||||
} catch (error) {
|
||||
console.error('Error occurred updating book metadata: ', error);
|
||||
toast.error('Failed to update book metadata.');
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Card.Content>
|
||||
<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 -->
|
||||
<Field.Field>
|
||||
<Field.Label for="title">Title</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.title.as('text')} />
|
||||
{#each updateBookMetadata.fields.title.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Subtitle field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="subtitle">Subtitle</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.subtitle.as('text')} />
|
||||
{#each updateBookMetadata.fields.subtitle.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<div class="grid grid-cols-[3fr_1fr] gap-2">
|
||||
<!-- Series field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="series">Series</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.series.as('text')} />
|
||||
{#each updateBookMetadata.fields.series.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Series position field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="series_position">Series position</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.series_position.as('text')} />
|
||||
{#each updateBookMetadata.fields.series_position.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
</div>
|
||||
|
||||
<!-- Authors field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="authors">Authors</Field.Label>
|
||||
<TagsInput
|
||||
bind:value={authors}
|
||||
validate={validateTagsInput}
|
||||
placeholder="Add an author"
|
||||
class="min-h-10 p-2 text-sm"
|
||||
/>
|
||||
{#each authors as author}
|
||||
<input class="hidden" {...updateBookMetadata.fields.authors.as('checkbox', author)} />
|
||||
{/each}
|
||||
{#each updateBookMetadata.fields.authors.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Tags field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="tags">Tags</Field.Label>
|
||||
<TagsInput
|
||||
bind:value={tags}
|
||||
validate={validateTagsInput}
|
||||
placeholder="Add a tag"
|
||||
class="min-h-10 p-2 text-sm"
|
||||
/>
|
||||
{#each tags as tag}
|
||||
<input class="hidden" {...updateBookMetadata.fields.tags.as('checkbox', tag)} />
|
||||
{/each}
|
||||
{#each updateBookMetadata.fields.tags.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Description field -->
|
||||
<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.Label for="publisher">Publisher</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.publisher.as('text')} />
|
||||
{#each updateBookMetadata.fields.publisher.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Published date field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="published_date">Date published</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.published_date.as('date')} />
|
||||
{#each updateBookMetadata.fields.published_date.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[2fr_1fr_1fr] gap-2">
|
||||
<!-- Pages field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="pages">Pages</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.pages.as('number')} />
|
||||
{#each updateBookMetadata.fields.pages.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</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>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
</Card.Content>
|
||||
|
||||
<!-- Submit button -->
|
||||
<Card.Footer class="flex-col gap-2 pt-6">
|
||||
<Button type="submit" class="w-full">Save</Button>
|
||||
</Card.Footer>
|
||||
</form>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,3 @@
|
||||
import EditBook from './edit-book.svelte';
|
||||
|
||||
export { EditBook as BookEdit };
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { createLibrary } from '$lib/api/library.remote';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { goto, invalidate, invalidateAll } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
|
||||
let { open = $bindable(false) } = $props();
|
||||
|
||||
const libraryState = getLibraryState();
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Create a new Library</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form
|
||||
{...createLibrary.enhance(async ({ form, submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
|
||||
// Check if there are any validation issues
|
||||
const issues = createLibrary.fields.allIssues();
|
||||
if (issues && issues.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const libraryName = createLibrary.fields.name.value();
|
||||
|
||||
form.reset();
|
||||
open = false;
|
||||
toast.success(`Library '${libraryName}' created.`);
|
||||
|
||||
libraryState.addLibrary(createLibrary.result);
|
||||
} catch (error) {
|
||||
console.error('Failed to create library: ', error);
|
||||
toast.error('Failed to create library');
|
||||
}
|
||||
})}
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<Field.Set>
|
||||
<Field.Group>
|
||||
<!-- Library name -->
|
||||
<Field.Field>
|
||||
<Field.Label for="name">Library name</Field.Label>
|
||||
<Input {...createLibrary.fields.name.as('text')} />
|
||||
{#each createLibrary.fields.name.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Description -->
|
||||
<Field.Field>
|
||||
<Field.Label for="description">Description</Field.Label>
|
||||
<Textarea {...createLibrary.fields.description.as('text')} />
|
||||
{#each createLibrary.fields.description.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Root Path -->
|
||||
<Field.Field>
|
||||
<Field.Label for="root_path">Root Path</Field.Label>
|
||||
<Input {...createLibrary.fields.root_path.as('text')} />
|
||||
{#each createLibrary.fields.root_path.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
<Field.Description class="text-xs">
|
||||
Path the books in this library will be stored
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
|
||||
<!-- Path Template -->
|
||||
<Field.Field>
|
||||
<Field.Label for="path_template">Path Template</Field.Label>
|
||||
<Input {...createLibrary.fields.path_template.as('text')} />
|
||||
{#each createLibrary.fields.path_template.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
<Field.Description class="text-xs">
|
||||
The directory structure of your library
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Button type="submit" class="ml-auto w-24">Create</Button>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { login } from '$lib/api/auth.remote';
|
||||
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
</script>
|
||||
|
||||
<Card.Root class="w-full max-w-sm">
|
||||
<Card.Header>
|
||||
<Card.Title>Login to your account</Card.Title>
|
||||
<Card.Description>Enter your email below to login to your account</Card.Description>
|
||||
</Card.Header>
|
||||
|
||||
<form {...login}>
|
||||
<Card.Content>
|
||||
<Field.Set>
|
||||
<Field.Group>
|
||||
<!-- Email field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="email">Email</Field.Label>
|
||||
<Input {...login.fields.email.as('email')} />
|
||||
{#each login.fields.email.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Password field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="password">Password</Field.Label>
|
||||
<Input {...login.fields.password.as('password')} />
|
||||
{#each login.fields.password.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
</Card.Content>
|
||||
|
||||
<!-- Submit button -->
|
||||
<Card.Footer class="flex-col gap-2 pt-6">
|
||||
<Button type="submit" class="w-full">Login</Button>
|
||||
</Card.Footer>
|
||||
</form>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { Input } from "$lib/components/ui/input/index.js";
|
||||
import { Label } from "$lib/components/ui/label/index.js";
|
||||
|
||||
let { open = $bindable(), onSubmit }: { open?: boolean, onSubmit: (name: string) => Promise<undefined> } = $props()
|
||||
let shelfName = $state('')
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Create bookshelf</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<Label>Name</Label>
|
||||
<Input bind:value={shelfName}/>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<Dialog.Footer class="ml-auto flex">
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
|
||||
<Button
|
||||
onclick={async () => {
|
||||
await onSubmit(shelfName);
|
||||
}}
|
||||
variant="default">Create</Button
|
||||
>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { signup, login } from '$lib/api/auth.remote';
|
||||
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let { tabValue = $bindable() }: { tabValue: string } = $props();
|
||||
</script>
|
||||
|
||||
<Card.Root class="w-full max-w-sm">
|
||||
<Card.Header>
|
||||
<Card.Title>Sign up for an account</Card.Title>
|
||||
<Card.Description>Enter your email below to register for an account</Card.Description>
|
||||
</Card.Header>
|
||||
|
||||
<form
|
||||
{...signup.enhance(async ({ submit, form }) => {
|
||||
try {
|
||||
await submit();
|
||||
|
||||
// Check if there are any validation issues
|
||||
const issues = signup.fields.allIssues();
|
||||
if (issues && issues.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Move to login tab on success
|
||||
// TODO: Fix previous errors showing on login form
|
||||
form.reset();
|
||||
toast.success('Successfully registered!');
|
||||
login.fields.set({ email: '', password: '' });
|
||||
login.validate();
|
||||
tabValue = 'login';
|
||||
} catch (error) {
|
||||
console.error('Unknown error occurred: ', error);
|
||||
toast.error('Registration failed.');
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Card.Content>
|
||||
<Field.Set>
|
||||
<Field.Group>
|
||||
<!-- Email field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="email">Email</Field.Label>
|
||||
<Input {...signup.fields.email.as('email')} />
|
||||
{#each signup.fields.email.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Password field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="password">Password</Field.Label>
|
||||
<Input {...signup.fields.password.as('password')} />
|
||||
{#each signup.fields.password.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Confirm password field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="confirmPassword">Confirm Password</Field.Label>
|
||||
<Input {...signup.fields.confirmPassword.as('password')} />
|
||||
{#each signup.fields.confirmPassword.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
</Card.Content>
|
||||
|
||||
<!-- Submit button -->
|
||||
<Card.Footer class="flex-col gap-2 pt-6">
|
||||
<Button type="submit" class="w-full">Sign Up</Button>
|
||||
</Card.Footer>
|
||||
</form>
|
||||
</Card.Root>
|
||||
Reference in New Issue
Block a user