Initial commit
This commit is contained in:
37
frontend/src/routes/(root)/(library)/+layout.svelte
Normal file
37
frontend/src/routes/(root)/(library)/+layout.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import BookDelete from '$lib/components/forms/book-delete.svelte';
|
||||
import { BookEdit } from '$lib/components/forms/edit-book';
|
||||
import { getBookOperationsState, setBookOperationsState } from '$lib/state/bookOperations.svelte';
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
import { setBookSelectionState } from '$lib/state/bookSelection.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
const libraryState = getLibraryState();
|
||||
const bookOps = getBookOperationsState();
|
||||
const bookSelection = setBookSelectionState();
|
||||
|
||||
// Deselect all when active library changes
|
||||
$effect(() => {
|
||||
libraryState.activeLibrary; // Track this
|
||||
bookSelection.deselectAll();
|
||||
});
|
||||
|
||||
// Exit selection mode the ESC key
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && bookSelection.selectionModeActive) bookSelection.deselectAll();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
|
||||
{@render children?.()}
|
||||
|
||||
<BookDelete
|
||||
bind:open={bookOps.deleteDialogOpen}
|
||||
deleteFn={bookOps.deleteFn}
|
||||
title={bookOps.deleteDialogTitle}
|
||||
/>
|
||||
|
||||
<BookEdit book={bookOps.bookToEdit} bind:open={bookOps.editDialogOpen} />
|
||||
@@ -0,0 +1,9 @@
|
||||
import { getBook } from '$lib/api';
|
||||
|
||||
export async function load({ params, depends }) {
|
||||
depends('app:books');
|
||||
|
||||
return {
|
||||
book: await getBook(params.bookId)
|
||||
};
|
||||
}
|
||||
527
frontend/src/routes/(root)/(library)/book/[bookId]/+page.svelte
Normal file
527
frontend/src/routes/(root)/(library)/book/[bookId]/+page.svelte
Normal file
@@ -0,0 +1,527 @@
|
||||
<script lang="ts">
|
||||
import { Badge, badgeVariants } from '$lib/components/ui/badge/index.js';
|
||||
import { CollapsibleText } from '$lib/components/ui/collapsible-text/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import BookImage from '$lib/components/view/book-image.svelte';
|
||||
import type { BookFile } from '$lib/schema';
|
||||
import {
|
||||
Album,
|
||||
BookOpenCheck,
|
||||
BookOpenText,
|
||||
CalendarDays,
|
||||
Download,
|
||||
Link,
|
||||
NotebookPen,
|
||||
NotebookText,
|
||||
Pencil,
|
||||
PlusIcon,
|
||||
Tags,
|
||||
Trash2
|
||||
} from '@lucide/svelte';
|
||||
import { formatFileSize, getFileType } from '$lib/utils.js';
|
||||
import { getBookOperationsState } from '$lib/state/bookOperations.svelte.js';
|
||||
import { getBookshelfState } from '$lib/state/bookshelf.svelte.js';
|
||||
import { getLibraryState } from '$lib/state/library.svelte.js';
|
||||
import { Progress } from '$lib/components/ui/progress/index.js';
|
||||
import * as Accordion from '$lib/components/ui/accordion/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
import ShelfCreateDialog from '$lib/components/forms/shelf-create-dialog.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let book = $state(data.book);
|
||||
|
||||
$effect(() => {
|
||||
book = data.book;
|
||||
});
|
||||
|
||||
const bookOps = getBookOperationsState();
|
||||
const libraryState = getLibraryState();
|
||||
const bookshelfState = getBookshelfState();
|
||||
|
||||
let deleteFiles = $state(true);
|
||||
let fileDeleteDialogOpen = $state(false);
|
||||
let fileToDelete = $state<number>();
|
||||
let createShelfDialogOpen = $state(false)
|
||||
|
||||
function openBookInReader(file: BookFile) {
|
||||
if (getFileType(file.filename) === 'EPUB')
|
||||
window.open(`/book/${book.id}/read/epub/${file.id}`, '_blank', 'noopener,noreferrer');
|
||||
|
||||
if (getFileType(file.filename) === 'PDF')
|
||||
window.open(`/book/${book.id}/read/pdf/${file.id}`, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-4 flex flex-1 flex-col items-center">
|
||||
<div class="ml-8 grid max-w-[900px] grid-cols-[minmax(250px,1fr)_2.5fr] gap-8">
|
||||
<!-- Cover image and action buttons -->
|
||||
<aside class="flex flex-col gap-4">
|
||||
<a href="/book/{book.id}" class="w-full rounded shadow-lg drop-shadow-lg">
|
||||
<BookImage
|
||||
src="/api/{book.cover_image}"
|
||||
class="h-full w-full overflow-hidden rounded object-cover"
|
||||
/>
|
||||
|
||||
{#if book?.progress?.progress}
|
||||
<Progress
|
||||
value={book?.progress.progress}
|
||||
max={1}
|
||||
color={book?.progress.completed ? 'bg-green-600' : 'bg-yellow-500'}
|
||||
class="mt-[-8px] h-2 rounded {book?.progress.completed
|
||||
? '[&>div]:bg-green-600'
|
||||
: '[&>div]:bg-yellow-500'}"
|
||||
/>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<div class="grid grid-cols-[2fr_1fr] gap-2">
|
||||
<!-- If there are multiple files to choose from -->
|
||||
{#if book.files.length > 1}
|
||||
<!-- Read button -->
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class="{buttonVariants({ variant: 'accent' })} drop-shadow-lg">
|
||||
<BookOpenText />
|
||||
Read
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading>
|
||||
<DropdownMenu.Separator />
|
||||
{#each book.files as file}
|
||||
<DropdownMenu.Item onclick={() => openBookInReader(file)}
|
||||
>{getFileType(file.filename)}</DropdownMenu.Item
|
||||
>
|
||||
{/each}
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<!-- Download Button -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
{...props}
|
||||
class="w-full {buttonVariants({
|
||||
variant: 'outline',
|
||||
size: 'icon'
|
||||
})} drop-shadow-lg"
|
||||
>
|
||||
<Download />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading>
|
||||
<DropdownMenu.Separator />
|
||||
{#each book.files as file}
|
||||
<!-- Download a single file -->
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
bookOps.downloadBookFile(book.id, file.id, file.filename)}
|
||||
>{getFileType(file.filename)}</DropdownMenu.Item
|
||||
>
|
||||
{/each}
|
||||
<!-- Download all files as zip -->
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onclick={() => {
|
||||
bookOps.downloadBooks([book.id], `${book.title}.zip`);
|
||||
}}
|
||||
>
|
||||
All (ZIP)
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>Download</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
{:else}
|
||||
<!-- There is only a single file, render simple buttons -->
|
||||
|
||||
<!-- Read button -->
|
||||
<Button
|
||||
class="w-full {buttonVariants({ variant: 'accent' })} drop-shadow-lg"
|
||||
onclick={() => openBookInReader(book.files[0])}
|
||||
>
|
||||
<BookOpenText class="mr-2 size-4" />
|
||||
Read
|
||||
</Button>
|
||||
|
||||
<!-- Download button -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
class="{buttonVariants({ variant: 'outline', size: 'icon' })} w-full drop-shadow-lg"
|
||||
onclick={() =>
|
||||
bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
|
||||
>
|
||||
<Download />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>Download</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[1fr_1fr_1fr_1fr] gap-2">
|
||||
<!-- Mark as finished button -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger
|
||||
class=" {buttonVariants({
|
||||
variant: 'outline',
|
||||
size: 'icon'
|
||||
})} w-full drop-shadow-lg {book?.progress?.completed === true
|
||||
? 'text-green-500'
|
||||
: ''}"
|
||||
onclick={async () => {
|
||||
if (book?.progress?.completed === true)
|
||||
await bookOps.markBooksAsIncomplete([book.id]);
|
||||
else await bookOps.markBooksAsComplete([book.id]);
|
||||
}}
|
||||
>
|
||||
<BookOpenCheck />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
{#if !book?.progress?.completed}
|
||||
<p>Mark as finished</p>
|
||||
{:else}
|
||||
<p>Mark as not finished</p>
|
||||
{/if}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<!-- Add to shelf button -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
{...props}
|
||||
class="{buttonVariants({
|
||||
variant: 'outline',
|
||||
size: 'icon'
|
||||
})} w-full drop-shadow-lg"
|
||||
>
|
||||
<Album />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupHeading>Shelves</DropdownMenu.GroupHeading>
|
||||
<DropdownMenu.Separator />
|
||||
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf}
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={book.lists.findIndex((sh) => sh.id === shelf.id) !== -1}
|
||||
onclick={async () => {
|
||||
if (book.lists.find((sh) => sh.id === shelf.id)) {
|
||||
await bookshelfState.removeBooksFromShelf(shelf.id, [book.id]);
|
||||
book.lists = book.lists.filter((sh) => sh.id !== shelf.id);
|
||||
} else {
|
||||
await bookshelfState.addBooksToShelf(shelf.id, [book.id]);
|
||||
book.lists.push(shelf);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{shelf.title}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
{/each}
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onclick={() => createShelfDialogOpen = true}
|
||||
class="text-muted-foreground ">
|
||||
<PlusIcon class="size-4" />
|
||||
New Shelf
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>Add to shelf</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<!-- Edit button -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
onclick={() => {
|
||||
bookOps.bookToEdit = book;
|
||||
bookOps.editDialogOpen = true;
|
||||
}}
|
||||
class="{buttonVariants({ variant: 'outline', size: 'icon' })} w-full drop-shadow-lg"
|
||||
>
|
||||
<Pencil />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>Edit</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<!-- Delete button -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
class="{buttonVariants({ variant: 'outline', size: 'icon' })} w-full drop-shadow-lg"
|
||||
onclick={() => {
|
||||
bookOps.deleteDialogTitle = `Delete book?`;
|
||||
bookOps.deleteFn = async (deleteFiles: boolean) => {
|
||||
await bookOps.deleteBooks([book.id], deleteFiles, false);
|
||||
libraryState.activeLibrary!.total!--
|
||||
bookshelfState.deletedBooks([book])
|
||||
await history.back();
|
||||
};
|
||||
bookOps.deleteDialogOpen = true;
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>Delete</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex flex-col gap-1">
|
||||
<!-- Title, subtitle, and authors -->
|
||||
<h1 class="text-2xl font-semibold">{book.title}</h1>
|
||||
|
||||
{#if book?.subtitle}
|
||||
<h2 class="text-lg">{book.subtitle}</h2>
|
||||
{/if}
|
||||
|
||||
{#if book.series}
|
||||
<h2 class="text-base text-muted-foreground">
|
||||
{book.series.title}
|
||||
{book.series_position ? `#${book.series_position}` : ''}
|
||||
</h2>
|
||||
{/if}
|
||||
|
||||
{#if book.authors && book.authors.length > 0}
|
||||
<h2 class="line-clamp-1 w-full text-lg">
|
||||
By
|
||||
{#each book.authors as author}
|
||||
<a
|
||||
href="/library/{libraryState.activeLibrary!.id}/view?authors={author.id}"
|
||||
class="cs-list hover:underline">{author.name}</a
|
||||
>  
|
||||
{/each}
|
||||
</h2>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex flex-col gap-4 text-sm">
|
||||
<!-- Tags -->
|
||||
{#if book?.tags.length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
<Tags size="20" />
|
||||
<span class="mr-2 text-sm">Tags: </span>
|
||||
{#each book.tags as tag (tag.id)}
|
||||
<a href="/tag/{tag.id}" class={badgeVariants({ variant: 'default' })}>{tag.name}</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Identifiers -->
|
||||
{#if book.identifiers && Object.keys(book.identifiers).length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
<Link size="18" />
|
||||
<span class="mr-2">Identifiers: </span>
|
||||
{#each Object.entries(book.identifiers) as [name, value] (name)}
|
||||
<Badge>{name}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Publisher -->
|
||||
{#if book.publisher}
|
||||
<div class="flex items-center gap-2">
|
||||
<NotebookPen size="18" />
|
||||
<span class="mr-2">Publisher: </span>
|
||||
<span>{book.publisher.name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Published date -->
|
||||
{#if book.published_date}
|
||||
<div class="flex items-center gap-2">
|
||||
<CalendarDays size="18" />
|
||||
<span class="mr-2">Date published: </span>
|
||||
<span class="text-sm">{book.published_date}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Page count -->
|
||||
{#if book.pages}
|
||||
<div class="flex items-center gap-2">
|
||||
<NotebookText size="18" />
|
||||
<span class="mr-2">Pages: </span>
|
||||
<span>{book.pages}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Description -->
|
||||
{#if book.description}
|
||||
<CollapsibleText text={book.description} maxLength={500} />
|
||||
{/if}
|
||||
|
||||
<!-- Book files -->
|
||||
<Accordion.Root
|
||||
type="single"
|
||||
class="mt-4 w-full rounded-lg bg-muted px-4 shadow-lg drop-shadow "
|
||||
>
|
||||
<!-- <Separator></Separator> -->
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Trigger>
|
||||
<div class="ml-2 flex gap-4">
|
||||
Library Files
|
||||
<Badge>{book.files.length}</Badge>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[300px]">Filename</Table.Head>
|
||||
<Table.Head>Size</Table.Head>
|
||||
<Table.Head>File type</Table.Head>
|
||||
<Table.Head class="text-right">Actions</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each book.files as file (file.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="max-w-[300px] min-w-0 overflow-hidden">
|
||||
<div class="wrap-break-word whitespace-normal">{file.filename}</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{formatFileSize(file.size)}</Table.Cell>
|
||||
<Table.Cell>{getFileType(file.filename)}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<div class="flex justify-end gap-1">
|
||||
<!-- Read button -->
|
||||
{#if getFileType(file.filename) == 'EPUB' || getFileType(file.filename) == 'PDF'}
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
class="{buttonVariants({
|
||||
variant: 'default',
|
||||
size: 'icon'
|
||||
})} scale-90"
|
||||
>
|
||||
<BookOpenText />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>Read</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
{/if}
|
||||
|
||||
<!-- Download button -->
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
class="{buttonVariants({
|
||||
variant: 'default',
|
||||
size: 'icon'
|
||||
})} scale-90"
|
||||
onclick={async () => {
|
||||
await bookOps.downloadBookFile(book.id, file.id, file.filename);
|
||||
}}
|
||||
>
|
||||
<Download />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<p>Download</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<!-- Delete button -->
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
onclick={() => {
|
||||
fileToDelete = file.id;
|
||||
fileDeleteDialogOpen = true;
|
||||
}}
|
||||
class="{buttonVariants({
|
||||
variant: 'destructive',
|
||||
size: 'icon'
|
||||
})} scale-90"
|
||||
>
|
||||
<Trash2 />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">Delete File</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog.Root bind:open={fileDeleteDialogOpen}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Are you absolutely sure?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This action cannot be undone. This will permanently delete the record from the database and
|
||||
the file from the filesystem.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox bind:checked={deleteFiles} />
|
||||
<Field.Label class="font-normal">Delete files from the filesystem</Field.Label>
|
||||
</div>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
onclick={async () => {
|
||||
await bookOps.deleteBookFiles(book.id, [fileToDelete!], deleteFiles);
|
||||
fileDeleteDialogOpen = false;
|
||||
}}
|
||||
class={buttonVariants({ variant: 'destructive' })}>Delete</AlertDialog.Action
|
||||
>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<ShelfCreateDialog
|
||||
bind:open={createShelfDialogOpen}
|
||||
onSubmit={async (name: string) => {
|
||||
const bookshelf = await bookshelfState.addBookshelf(name, libraryState.activeLibrary!.id, [book.id])
|
||||
book.lists.push(bookshelf)
|
||||
createShelfDialogOpen = false
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import ChapterSidebar from '$lib/components/reader/chapter-sidebar.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { page } from '$app/state';
|
||||
import EpubReader from '$lib/components/reader/epub-reader.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let fileId = page.params.fileId;
|
||||
let bookId = page.params.bookId;
|
||||
|
||||
// Make sure this endpoint returns the complete EPUB file
|
||||
let bookUrl = `/api/books/download/${bookId}/${fileId}`;
|
||||
</script>
|
||||
|
||||
<div class="h-screen w-screen">
|
||||
<EpubReader
|
||||
{bookUrl}
|
||||
{bookId}
|
||||
initialProgress={data.bookProgress?.progress}
|
||||
initialEpubLoc={data.bookProgress?.epub_loc}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
export async function load({ fetch, params, parent }) {
|
||||
try {
|
||||
const response = await fetch(`/api/books/${params.bookId}`);
|
||||
const result = await response.json();
|
||||
let bookProgress = result.progress;
|
||||
|
||||
return {
|
||||
bookProgress
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching book: ', error);
|
||||
return {
|
||||
status: error.status || 500,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
|
||||
let fileId = page.params.fileId;
|
||||
let bookId = page.params.bookId;
|
||||
|
||||
// Make sure this endpoint returns the complete PDF file
|
||||
let url = `${page.url.origin}/api/books/download/${bookId}/${fileId}`;
|
||||
|
||||
let iframeEl = $state<HTMLIFrameElement>();
|
||||
|
||||
$effect(() => {
|
||||
if (iframeEl && url) {
|
||||
// Use relative path to viewer.html with the PDF file URL as a parameter
|
||||
iframeEl.src = `/pdfjs/web/viewer.html?file=${encodeURIComponent(url)}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="pdf-container">
|
||||
<iframe bind:this={iframeEl} title="PDF Viewer" class="pdf-iframe"></iframe>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Make the container take up the full viewport height */
|
||||
.pdf-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Make the iframe fill its container */
|
||||
.pdf-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import * as Empty from '$lib/components/ui/empty/index.js'
|
||||
import { Button } from '$lib/components/ui/button/index.js'
|
||||
import BookList from '$lib/components/view/book-list.svelte';
|
||||
import { FolderX, Upload } from '@lucide/svelte';
|
||||
import { getBookOperationsState } from '$lib/state/bookOperations.svelte.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let bookOps = getBookOperationsState()
|
||||
|
||||
</script>
|
||||
|
||||
<ScrollArea class="h-full w-full">
|
||||
<div class="mr-2 flex flex-col gap-8 p-4">
|
||||
<BookList books={data.continueReading} title="Continue Reading" />
|
||||
<BookList books={data.recentlyAdded} title="Recently Added" />
|
||||
<BookList books={data.discover} title="Discover" />
|
||||
<BookList books={data.readAgain} title="Read Again" />
|
||||
</div>
|
||||
|
||||
{#if data.recentlyAdded.length === 0 }
|
||||
<div class="flex flex-col items-center justify-center w-full h-full">
|
||||
<!-- Show a CTA to upload books if the library is empty -->
|
||||
<Empty.Root class="mb-[12vh]">
|
||||
<Empty.Header>
|
||||
<Empty.Media variant="icon">
|
||||
<FolderX />
|
||||
</Empty.Media>
|
||||
<Empty.Title>Library Empty</Empty.Title>
|
||||
<Empty.Description>
|
||||
This library doesn't have any books yet. Get started by uploading your first books.
|
||||
</Empty.Description>
|
||||
</Empty.Header>
|
||||
<Empty.Content>
|
||||
<Button onclick={async () => (bookOps.uploadDialogOpen = true)}>
|
||||
<Upload />
|
||||
Upload Books
|
||||
</Button>
|
||||
</Empty.Content>
|
||||
</Empty.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</ScrollArea>
|
||||
@@ -0,0 +1,40 @@
|
||||
export async function load({ params, fetch }) {
|
||||
try {
|
||||
const continueReadingResponse = await fetch(
|
||||
`/api/books?libraries=${params.libraryId}&progress=in_progress&orderBy=last_accessed&sortOrder=desc`
|
||||
);
|
||||
let results = await continueReadingResponse.json();
|
||||
let continueReading = results['items'];
|
||||
|
||||
const discoverResponse = await fetch(
|
||||
`/api/books?libraries=${params.libraryId}&progress=unread&sortOrder=random`
|
||||
);
|
||||
results = await discoverResponse.json();
|
||||
let discover = results['items'];
|
||||
|
||||
const readAgainResponse = await fetch(
|
||||
`/api/books?libraries=${params.libraryId}&progress=read&sortOrder=random`
|
||||
);
|
||||
results = await readAgainResponse.json();
|
||||
let readAgain = results['items'];
|
||||
|
||||
const recentlyAddedResponse = await fetch(
|
||||
`/api/books?libraries=${params.libraryId}&orderBy=create_at&sortOrder=desc&progress=unread`
|
||||
);
|
||||
results = await recentlyAddedResponse.json();
|
||||
let recentlyAdded = results['items'];
|
||||
|
||||
return {
|
||||
continueReading,
|
||||
discover,
|
||||
readAgain,
|
||||
recentlyAdded
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching books from library: ', error);
|
||||
return {
|
||||
status: error.status || 500,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="[--header-height:calc(--spacing(16))]">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,60 @@
|
||||
import { listAuthors } from '$lib/api/author.remote.js';
|
||||
import { listBooks } from '$lib/api/book.remote.js';
|
||||
import { listBookshelves } from '$lib/api/bookshelf.remote.js';
|
||||
import { listPublishers } from '$lib/api/publisher.remote.js';
|
||||
import { listTags } from '$lib/api/tag.remote.js';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export async function load({ params, url, depends }) {
|
||||
depends('app:books');
|
||||
|
||||
// Ensure the default parameters exist
|
||||
const searchParams = url.searchParams;
|
||||
|
||||
const defaults = {
|
||||
orderBy: 'title',
|
||||
sortOrder: 'asc'
|
||||
};
|
||||
|
||||
let needsRedirect = false;
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
|
||||
// Check and set defaults only if missing
|
||||
for (const [key, value] of Object.entries(defaults)) {
|
||||
if (!searchParams.has(key)) {
|
||||
newParams.set(key, value);
|
||||
needsRedirect = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsRedirect) {
|
||||
throw redirect(302, `${url.pathname}?${newParams.toString()}`);
|
||||
}
|
||||
|
||||
// Convert searchParams to object, preserving arrays
|
||||
const paramsObject: Record<string, string | string[]> = {};
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
const allValues = searchParams.getAll(key);
|
||||
paramsObject[key] = allValues.length > 1 ? allValues : value;
|
||||
}
|
||||
|
||||
const [books, authors, publishers, tags, bookshelves] = await Promise.all([
|
||||
listBooks({
|
||||
libraries: [params.libraryId],
|
||||
...paramsObject,
|
||||
pageSize: 50
|
||||
}),
|
||||
listAuthors({ libraries: [params.libraryId], pageSize: 500 }),
|
||||
listPublishers({ libraries: [params.libraryId], pageSize: 500 }),
|
||||
listTags({ libraries: [params.libraryId], pageSize: 500 }),
|
||||
listBookshelves({ libraries: [params.libraryId], pageSize: 500 })
|
||||
]);
|
||||
|
||||
return {
|
||||
books,
|
||||
authors,
|
||||
publishers,
|
||||
tags,
|
||||
bookshelves
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import BookBrowser from '$lib/components/view/book-browser.svelte';
|
||||
import type { Bookshelf, Publisher, Tag, Book, Author } from '$lib/schema';
|
||||
import type { PaginatedResponse } from '$lib/schema/common';
|
||||
import { setBookCollectionState } from '$lib/state/bookCollection.svelte';
|
||||
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let {
|
||||
data
|
||||
}: {
|
||||
data: {
|
||||
books: PaginatedResponse<Book>;
|
||||
bookshelves: PaginatedResponse<Bookshelf>;
|
||||
authors: PaginatedResponse<Author>;
|
||||
publishers: PaginatedResponse<Publisher>;
|
||||
tags: PaginatedResponse<Tag>;
|
||||
};
|
||||
} = $props();
|
||||
|
||||
const { books, ...filterData } = data;
|
||||
|
||||
const bookOps = getBookOperationsState();
|
||||
const bookCollection = setBookCollectionState(bookOps, books, filterData);
|
||||
|
||||
let skip = $state(true);
|
||||
|
||||
// Update book state on data changes
|
||||
$effect(() => {
|
||||
const { books, ...filterData } = data;
|
||||
|
||||
// Skip effect on first run
|
||||
if (skip) {
|
||||
skip = false;
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
bookOps.libraryId = page.params.libraryId!;
|
||||
bookCollection.resetState(books, filterData);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full w-full items-center">
|
||||
<BookBrowser />
|
||||
</div>
|
||||
9
frontend/src/routes/(root)/+layout.server.ts
Normal file
9
frontend/src/routes/(root)/+layout.server.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { listLibraries } from '$lib/api/library.remote.js';
|
||||
|
||||
export const load = async ({ depends }) => {
|
||||
depends('app:libraries');
|
||||
|
||||
return {
|
||||
libraries: await listLibraries({ pageSize: 100 })
|
||||
};
|
||||
};
|
||||
39
frontend/src/routes/(root)/+layout.svelte
Normal file
39
frontend/src/routes/(root)/+layout.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { listBookshelves } from '$lib/api';
|
||||
import AppSidebar from '$lib/components/layout/app-sidebar.svelte';
|
||||
import SiteHeader from '$lib/components/layout/site-header.svelte';
|
||||
import { Loading } from '$lib/components/ui/command';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import type { Library, PaginatedResponse } from '$lib/schema';
|
||||
import { setBookOperationsState } from '$lib/state/bookOperations.svelte';
|
||||
import { setBookshelfState } from '$lib/state/bookshelf.svelte';
|
||||
import { setLibraryState } from '$lib/state/library.svelte.js';
|
||||
|
||||
import { ModeWatcher } from 'mode-watcher';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Toaster } from 'svelte-sonner';
|
||||
|
||||
let { data, children }: { data: { libraries: PaginatedResponse<Library> }; children: Snippet } =
|
||||
$props();
|
||||
|
||||
const libraryState = setLibraryState(data.libraries.items);
|
||||
const bookshelfState = setBookshelfState();
|
||||
const bookOps = setBookOperationsState(libraryState.activeLibrary!.id);
|
||||
</script>
|
||||
|
||||
<Toaster />
|
||||
<ModeWatcher />
|
||||
|
||||
<div class="[--header-height:calc(--spacing(14))]">
|
||||
<Sidebar.Provider class="flex flex-col">
|
||||
<div class="flex h-screen">
|
||||
<AppSidebar />
|
||||
<Sidebar.Inset class="flex min-w-0 flex-1 flex-col overflow-x-hidden">
|
||||
<SiteHeader />
|
||||
<div class="mt-4 flex-1 overflow-hidden p-4 pt-0">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</Sidebar.Inset>
|
||||
</div>
|
||||
</Sidebar.Provider>
|
||||
</div>
|
||||
8
frontend/src/routes/(root)/+page.server.ts
Normal file
8
frontend/src/routes/(root)/+page.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export async function load({ parent }) {
|
||||
const { libraries } = await parent();
|
||||
|
||||
// Immediately redirect to the library page
|
||||
redirect(303, `/library/${libraries.items[0]?.id}`);
|
||||
}
|
||||
51
frontend/src/routes/(root)/settings/+layout.svelte
Normal file
51
frontend/src/routes/(root)/settings/+layout.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import { page } from '$app/state';
|
||||
import Separator from '$lib/components/ui/separator/separator.svelte';
|
||||
let { children } = $props();
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: 'Account',
|
||||
url: '/settings/account'
|
||||
},
|
||||
{
|
||||
title: 'Libraries',
|
||||
url: '/settings/libraries'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="flex h-[calc(100vh-var(--header-height)-2rem)] flex-col">
|
||||
<h1 class="text-xl font-bold">Settings</h1>
|
||||
<Separator class="my-4" />
|
||||
<div class="flex flex-1 gap-6 overflow-hidden">
|
||||
<div class="flex gap-8">
|
||||
<Sidebar.Provider class="shrink-0">
|
||||
<Sidebar.Inset>
|
||||
<Sidebar.Content>
|
||||
<Sidebar.Group class="flex flex-col gap-1">
|
||||
{#each items as item}
|
||||
<Sidebar.MenuItem class="w-48">
|
||||
<Sidebar.MenuButton
|
||||
class="rounded hover:bg-muted {page.url.pathname.endsWith(item.url)
|
||||
? 'bg-muted'
|
||||
: ''}"
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<a href={item.url} {...props}>
|
||||
<span class="text-base">{item.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
</Sidebar.Group>
|
||||
</Sidebar.Content>
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
</div>
|
||||
<Separator orientation="vertical" class="self-stretch" />
|
||||
<div class="mx-2 mt-2 flex-1 overflow-auto">{@render children()}</div>
|
||||
</div>
|
||||
</div>
|
||||
8
frontend/src/routes/(root)/settings/+page.ts
Normal file
8
frontend/src/routes/(root)/settings/+page.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export async function load({ parent }) {
|
||||
const { libraries } = await parent();
|
||||
|
||||
// Immediately redirect to the account settings
|
||||
redirect(303, `/settings/account`);
|
||||
}
|
||||
23
frontend/src/routes/(root)/settings/account/+page.svelte
Normal file
23
frontend/src/routes/(root)/settings/account/+page.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { logout } from '$lib/api';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { LogOut } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
<h1 class="text-lg font-semibold">Account Settings</h1>
|
||||
<p class="text-sm text-muted-foreground">Manage your account</p>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<Button
|
||||
onclick={async () => {
|
||||
await logout();
|
||||
goto('/login');
|
||||
}}
|
||||
variant="outline"
|
||||
class="w-32"
|
||||
>
|
||||
<LogOut />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
43
frontend/src/routes/(root)/settings/libraries/+page.svelte
Normal file
43
frontend/src/routes/(root)/settings/libraries/+page.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
import { EllipsisVertical, Plus } from '@lucide/svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
|
||||
const libraryState = getLibraryState();
|
||||
</script>
|
||||
|
||||
<h1 class="text-lg font-semibold">Library Settings</h1>
|
||||
<p class="text-sm text-muted-foreground">Manage your libraries</p>
|
||||
|
||||
<Card.Root class="mt-6">
|
||||
<Card.Content>
|
||||
<Card.Header class="mb-2 flex items-center">
|
||||
<Card.Title>Libraries</Card.Title>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="ml-auto"
|
||||
onclick={() => libraryState.openLibraryCreateDialog()}
|
||||
>
|
||||
<Plus />
|
||||
Add Library
|
||||
</Button>
|
||||
</Card.Header>
|
||||
<Table.Root>
|
||||
<Table.Body>
|
||||
{#each libraryState.libraries as library}
|
||||
<Table.Row class="h-16">
|
||||
<Table.Cell class="w-16 text-center text-lg font-semibold">{library.name[0]}</Table.Cell
|
||||
>
|
||||
<Table.Cell class="font-medium"
|
||||
><a href={`/library/${library.id}`} class="hover:underline">{library.name}</a
|
||||
></Table.Cell
|
||||
>
|
||||
<Table.Cell class="w-16 text-center"><EllipsisVertical class="scale-75" /></Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
17
frontend/src/routes/+layout.svelte
Normal file
17
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex h-screen min-h-screen flex-col">
|
||||
<!-- Main Content Area -->
|
||||
<main class="flex-1">
|
||||
{@render children?.()}
|
||||
</main>
|
||||
</div>
|
||||
148
frontend/src/routes/api/[...path]/+server.ts
Normal file
148
frontend/src/routes/api/[...path]/+server.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
// src/routes/api/[...path]/+server.ts
|
||||
import { BACKEND_API_URL } from '$lib/server/config';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
// import type { RequestHandler } from './$types';
|
||||
|
||||
// Helper function to handle both JSON and file responses
|
||||
async function handleResponse(response: Response) {
|
||||
if (!response.ok) {
|
||||
throw error(response.status, await response.text());
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
// Handle JSON response
|
||||
const data = await response.json();
|
||||
return json(data);
|
||||
} else {
|
||||
// Handle file or other non-JSON response
|
||||
const responseBody = await response.arrayBuffer();
|
||||
|
||||
return new Response(responseBody, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': response.headers.get('Content-Disposition') || '',
|
||||
'Content-Length': response.headers.get('Content-Length') || ''
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Shared function to prepare the request with authentication
|
||||
function prepareRequest(locals: App.Locals, request: Request) {
|
||||
const token = locals.authToken || 'server-default-token';
|
||||
|
||||
const headers = new Headers({
|
||||
Authorization: `Bearer ${token}`
|
||||
});
|
||||
|
||||
// Forward important headers from the original request
|
||||
const contentType = request.headers.get('Content-Type');
|
||||
if (contentType) headers.set('Content-Type', contentType);
|
||||
|
||||
const accept = request.headers.get('Accept');
|
||||
if (accept) headers.set('Accept', accept);
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
// GET handler
|
||||
export const GET: RequestHandler = async ({ params, locals, fetch, request, url }) => {
|
||||
try {
|
||||
const path = params.path;
|
||||
const queryString = url.search;
|
||||
const backendUrl = `${BACKEND_API_URL}/${path}${queryString}`;
|
||||
const headers = prepareRequest(locals, request);
|
||||
|
||||
const response = await fetch(backendUrl, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
return handleResponse(response);
|
||||
} catch (e) {
|
||||
console.error('API proxy error (GET):', e);
|
||||
throw error(500, 'Failed to fetch from API');
|
||||
}
|
||||
};
|
||||
|
||||
// POST handler
|
||||
export const POST: RequestHandler = async ({ params, locals, fetch, request, url }) => {
|
||||
try {
|
||||
const path = params.path;
|
||||
const queryString = url.search;
|
||||
const backendUrl = `${BACKEND_API_URL}/${path}${queryString}`;
|
||||
const headers = prepareRequest(locals, request);
|
||||
|
||||
// Get the request body
|
||||
const body = await request.arrayBuffer();
|
||||
|
||||
const response = await fetch(backendUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body
|
||||
});
|
||||
|
||||
return handleResponse(response);
|
||||
} catch (e) {
|
||||
console.error('API proxy error (POST):', e);
|
||||
throw error(500, 'Failed to post to API');
|
||||
}
|
||||
};
|
||||
|
||||
// PATCH handler
|
||||
export const PATCH: RequestHandler = async ({ params, locals, fetch, request, url }) => {
|
||||
try {
|
||||
const path = params.path;
|
||||
const queryString = url.search;
|
||||
const backendUrl = `${BACKEND_API_URL}/${path}${queryString}`;
|
||||
const headers = prepareRequest(locals, request);
|
||||
|
||||
// Get the request body
|
||||
const body = await request.arrayBuffer();
|
||||
|
||||
const response = await fetch(backendUrl, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body
|
||||
});
|
||||
|
||||
return handleResponse(response);
|
||||
} catch (e) {
|
||||
console.error('API proxy error (PATCH):', e);
|
||||
throw error(500, 'Failed to update API resource');
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE handler
|
||||
export const DELETE: RequestHandler = async ({ params, locals, fetch, request, url }) => {
|
||||
try {
|
||||
const path = params.path;
|
||||
const queryString = url.search;
|
||||
const backendUrl = `${BACKEND_API_URL}/${path}${queryString}`;
|
||||
const headers = prepareRequest(locals, request);
|
||||
|
||||
// DELETE may or may not have a body
|
||||
let options: RequestInit = {
|
||||
method: 'DELETE',
|
||||
headers
|
||||
};
|
||||
|
||||
// Add body if the request has one
|
||||
if (
|
||||
request.headers.get('Content-Length') &&
|
||||
parseInt(request.headers.get('Content-Length') || '0') > 0
|
||||
) {
|
||||
options.body = await request.arrayBuffer();
|
||||
}
|
||||
|
||||
const response = await fetch(backendUrl, options);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error('API proxy error (DELETE):', e);
|
||||
throw error(500, 'Failed to delete API resource');
|
||||
}
|
||||
};
|
||||
31
frontend/src/routes/login/+layout@.svelte
Normal file
31
frontend/src/routes/login/+layout@.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import '../../app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { Toaster } from 'svelte-sonner';
|
||||
import { ModeWatcher } from 'mode-watcher';
|
||||
import ThemeToggle from '$lib/components/layout/theme-toggle.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
<Toaster />
|
||||
<ModeWatcher />
|
||||
|
||||
<div class="flex h-screen min-h-screen flex-col">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4 pt-4 pl-6">
|
||||
<p class="text-3xl">📚</p>
|
||||
<a href="/" class="text-2xl font-semibold">chitai</a>
|
||||
|
||||
<ThemeToggle class="mr-4 ml-auto" />
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="flex-1">
|
||||
{@render children?.()}
|
||||
</main>
|
||||
</div>
|
||||
33
frontend/src/routes/login/+page.svelte
Normal file
33
frontend/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { replaceState } from '$app/navigation';
|
||||
import LoginForm from '$lib/components/forms/login-form.svelte';
|
||||
import SignupForm from '$lib/components/forms/signup-form.svelte';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
let tabValue = $state('login');
|
||||
</script>
|
||||
|
||||
<div class="flex h-full items-center justify-center pt-4 md:pb-42">
|
||||
<div class="mx-4 w-full max-w-sm flex-col gap-6">
|
||||
<Tabs.Root bind:value={tabValue}>
|
||||
<Tabs.List class="grid w-full grid-cols-2">
|
||||
<Tabs.Trigger value="login">Login</Tabs.Trigger>
|
||||
<Tabs.Trigger value="signup">Sign Up</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<div class="min-h-[480px]">
|
||||
<Tabs.Content value="login">
|
||||
{#if tabValue === 'login'}
|
||||
<LoginForm />
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="signup">
|
||||
{#if tabValue === 'signup'}
|
||||
<SignupForm bind:tabValue />
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user