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

View 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} />

View File

@@ -0,0 +1,9 @@
import { getBook } from '$lib/api';
export async function load({ params, depends }) {
depends('app:books');
return {
book: await getBook(params.bookId)
};
}

View 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
> &thinsp;
{/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
}}
/>

View File

@@ -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?.()}

View File

@@ -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>

View File

@@ -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
};
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
};
}
}

View File

@@ -0,0 +1,7 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="[--header-height:calc(--spacing(16))]">
{@render children?.()}
</div>

View File

@@ -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
};
}

View File

@@ -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>

View 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 })
};
};

View 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>

View 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}`);
}

View 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>

View 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`);
}

View 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>

View 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>

View 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>

View 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');
}
};

View 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>

View 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>