Initial commit

This commit is contained in:
hiperman
2025-12-04 00:33:37 -05:00
commit 7ca0a21283
798 changed files with 190424 additions and 0 deletions
@@ -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>