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,200 @@
<script lang="ts">
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
import * as Tooltip from '$lib/components/ui/tooltip/index';
import { BookOpenCheck, Download, Trash2, SquareCheckBig, X, Album, PlusIcon } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
import { getLibraryState } from '$lib/state/library.svelte';
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
import { invalidate } from '$app/navigation';
import ShelfCreateDialog from '../forms/shelf-create-dialog.svelte';
const libraryState = getLibraryState();
const bookshelfState = getBookshelfState();
const selectionState = getBookSelectionState();
const bookOps = getBookOperationsState();
const collectionState = getBookCollectionState();
let selectedBooks = $derived(selectionState.getSelectedBooks())
let createShelfDialogOpen = $state(false)
</script>
<!-- Mark selected as finished button -->
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
onclick={async () => {
await bookOps.markBooksAsComplete(selectionState.getSelectedIds());
selectionState.deselectAll();
}}
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
>
<BookOpenCheck />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Mark as finished</p>
</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: 'ghost',
size: 'icon'
})}
>
<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={selectedBooks.every(
(book) => book.lists.findIndex((sh) => sh.id === shelf.id) !== -1
)}
onclick={async () => {
// Check if all books are in the shelf
const allBooksInShelf = selectedBooks.every((book) =>
book.lists.find((sh) => sh.id === shelf.id)
);
if (allBooksInShelf) {
// Remove all books from the shelf
const bookIds = selectedBooks.map((book) => book.id);
await bookshelfState.removeBooksFromShelf(shelf.id, bookIds);
selectedBooks.forEach((book) => {
book.lists = book.lists.filter((sh) => sh.id !== shelf.id);
});
} else {
// Add all books to the shelf
const bookIds = selectedBooks.map((book) => book.id);
await bookshelfState.addBooksToShelf(shelf.id, bookIds);
selectedBooks.forEach((book) => {
if (!book.lists.find((sh) => sh.id === shelf.id)) {
book.lists.push(shelf);
}
});
}
selectionState.deselectAll();
}}
>
{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/Remove from shelf</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<!-- Download selected button -->
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
onclick={() => {
bookOps.downloadBooks(selectionState.getSelectedIds());
}}
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
>
<Download />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Download selected</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<!-- Delete selected button -->
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
onclick={() => {
bookOps.deleteDialogTitle = `Delete ${selectionState.getSelectedIds().length} books?`;
bookOps.deleteFn = async (deleteFiles: boolean) => {
await bookOps.deleteBooks(selectionState.getSelectedIds(), deleteFiles);
libraryState.activeLibrary!.total! -= selectedBooks.length
bookshelfState.deletedBooks(selectedBooks)
selectionState.deselectAll();
};
bookOps.deleteDialogOpen = true;
}}
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
>
<Trash2 />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Delete selected</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<!-- Select all button -->
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
onclick={() => {
selectionState.selectAll(collectionState.books);
}}
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
>
<SquareCheckBig />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Select all</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<!-- Deselect all button -->
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
onclick={() => {
selectionState.deselectAll();
}}
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
>
<X />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Deselect all</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<ShelfCreateDialog
bind:open={createShelfDialogOpen}
onSubmit={async (name: string) => {
const bookshelf = await bookshelfState.addBookshelf(name, libraryState.activeLibrary!.id, selectionState.getSelectedIds())
selectedBooks.forEach(book => book.lists.push(bookshelf))
selectionState.deselectAll()
createShelfDialogOpen = false
}}
/>
@@ -0,0 +1,154 @@
<script lang="ts">
import * as Empty from '$lib/components/ui/empty/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Spinner } from '$lib/components/ui/spinner/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
import { SearchX, FolderX, FunnelX, Upload } from '@lucide/svelte';
import BookGrid from './book-grid.svelte';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import FilterSidebar from '$lib/components/view/filter-sidebar.svelte';
import FilterButton from './filter-button.svelte';
import SortButton from './sort-button.svelte';
import ViewToggle from './view-toggle.svelte';
import BookTable from './book-table.svelte';
import BatchOperationsToolbar from './batch-operations-toolbar.svelte';
import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
const selectionState = getBookSelectionState();
const bookCollection = getBookCollectionState();
const bookOps = getBookOperationsState();
let view = $state('grid');
let sentinel = $state<HTMLElement>();
let scrollContainer = $state<HTMLElement | null>(null);
$effect(() => {
if (!sentinel || !bookCollection.moreBooks) return;
const observer = new IntersectionObserver(
async (entries) => {
if (entries[0].isIntersecting && !bookCollection.loading) {
await bookCollection.loadMoreBooks();
}
},
{
root: scrollContainer,
rootMargin: '0px 0px 200px 0px'
}
);
observer.observe(sentinel);
return () => {
observer.disconnect();
};
});
// TODO: Set initial sidebar state to the previous state (saved in cookies)
// If there is is no previous state, it is opened if there are active filters o.w closed
let initialFilterSidebarOpen = bookCollection.hasActiveFilters;
</script>
<div class="flex h-full w-full flex-col">
<!-- Main Content Area -->
<div class="flex h-0 w-full flex-1">
<!-- Sidebar Layout -->
<Sidebar.Provider
contextKey="FILTER_SIDEBAR"
open={initialFilterSidebarOpen}
class="flex h-full w-full"
>
<!-- Sidebar Area -->
<Sidebar.Inset class="flex min-w-0 flex-1">
<div class="top-0 z-1 mb-4 flex h-12 w-full rounded-lg border bg-sidebar px-5">
<div class="flex w-full items-center py-2">
{#if !selectionState.selectionModeActive}
<ViewToggle bind:view />
<div class="ml-auto">
<SortButton />
<FilterButton />
</div>
{:else}
<p class="ml-2 font-medium">
Selected {selectionState.numSelected()} book{selectionState.numSelected() > 1
? 's'
: ''}
</p>
<div class="ml-auto">
<BatchOperationsToolbar />
</div>
{/if}
</div>
</div>
{#if bookCollection.loading && bookCollection.books.length === 0}
<div class="mb-[25vh] flex h-full w-full items-center justify-center">
<Spinner />
</div>
{:else if bookCollection.books.length > 0}
<ScrollArea bind:viewportRef={scrollContainer} orientation="both" class=" h-[calc(100vh-12vh)] w-full pr-5 pl-5">
{#if view === 'grid'}
<BookGrid books={bookCollection.books} />
{:else}
<BookTable books={bookCollection.books} />
{/if}
{#if bookCollection.moreBooks}
<div bind:this={sentinel}>
{#if bookCollection.loading}
<Spinner />
{/if}
</div>
{/if}
</ScrollArea>
{:else if bookCollection.books.length === 0 && !bookCollection.hasActiveFilters}
<!-- Show a CTA to upload books if the library is empty -->
<Empty.Root class="mb-[20vh]">
<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>
{:else if bookCollection.books.length === 0 && bookCollection.hasActiveFilters}
<!-- Show no results if the given filters result in no books -->
<Empty.Root class="mb-[20vh]">
<Empty.Header>
<Empty.Media variant="icon">
<SearchX />
</Empty.Media>
<Empty.Title>No Results</Empty.Title>
<Empty.Description>
No books exist with the applied filters. Adjust your filters.
</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button variant="outline" onclick={() => bookCollection.clearFilters()}>
<FunnelX />
Clear Filters
</Button>
</Empty.Content>
</Empty.Root>
{/if}
</Sidebar.Inset>
<!-- Filter Sidebar (right-side) -->
<FilterSidebar class="m-2 pb-5 h-full pt-20" />
</Sidebar.Provider>
</div>
</div>
@@ -0,0 +1,135 @@
<script lang="ts">
import * as Accordion from '$lib/components/ui/accordion/index';
import * as Tooltip from '$lib/components/ui/tooltip/index';
import * as Table from '$lib/components/ui/table/index';
// import * as AlertDialog from '$lib/components/ui/alert-dialog/index';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import type { Book } from '$lib/schema/index';
import { Badge } from '$lib/components/ui/badge/index';
import { formatFileSize, getFileType } from '$lib/utils';
import { BookOpenText, Download } from '@lucide/svelte';
let { book }: { book: Book } = $props();
let files = $state(book.files);
</script>
<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="">{file.filename}</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>
<Tooltip.Trigger
class="{buttonVariants({
variant: 'default',
size: 'icon'
})} scale-90"
onclick={() => handleRead(file)}
>
<BookOpenText />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<p>Read</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/if}
<!-- Download button -->
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger
class="{buttonVariants({ variant: 'default', size: 'icon' })} scale-90"
onclick={() => handleDownload(file)}
>
<Download />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<p>Download</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<!-- Delete button -->
<AlertDialog.Root bind:open={fileDeleteDialogOpen}>
<AlertDialog.Trigger>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger
class="{buttonVariants({
variant: 'destructive',
size: 'icon'
})} scale-90"
onclick={() => {
fileToDelete = file.id;
}}
>
<Trash2 />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<p>Delete</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</AlertDialog.Trigger>
<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>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
onclick={() => {
handleDeleteFile(fileToDelete);
fileDeleteDialogOpen = false;
fileToDelete = undefined;
}}
class="text-destructive-foreground bg-destructive hover:bg-destructive/90"
>Delete</AlertDialog.Action
>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
@@ -0,0 +1,102 @@
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Circle, Pencil, EllipsisVertical } from '@lucide/svelte';
import type { Book } from '$lib/schema';
import BookThumbnail from './book-thumbnail.svelte';
import { goto } from '$app/navigation';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
import { getLibraryState } from '$lib/state/library.svelte';
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
let { books }: { books: Book[] } = $props();
const libraryState = getLibraryState();
const selectedState = getBookSelectionState();
const bookOps = getBookOperationsState();
const bookshelfState = getBookshelfState();
</script>
<div class="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4 p-2">
{#each books as book (book.id)}
<div class="group relative">
<div
class="absolute top-2 right-2 z-50 {selectedState.isSelected(book.id)
? 'opacity-100'
: 'opacity-0'} transition-opacity group-hover:opacity-100"
>
<Circle
onclick={() => {
selectedState.toggleSelection(book);
}}
class="{selectedState.isSelected(book.id)
? 'scale-110 text-yellow-300'
: 'scale-75 text-white'} transition-all hover:scale-110 hover:cursor-pointer hover:text-yellow-300"
/>
</div>
{#if selectedState.isSelected(book.id)}
<div
class="absolute top-2 right-2 z-40 {selectedState.isSelected(book.id)
? 'opacity-100'
: 'opacity-0'} transition-opacity group-hover:opacity-100"
>
<Circle
class="yellow-300 scale-50 fill-yellow-300 text-yellow-300 transition-all hover:scale-75 hover:cursor-pointer hover:text-yellow-300"
/>
</div>
{/if}
<BookThumbnail {book} />
{#if !selectedState.selectionModeActive}
<div
class="absolute bottom-20 left-1 z-50 inline-flex items-center opacity-0 transition-opacity group-hover:opacity-100"
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<EllipsisVertical
class="scale-75 text-white transition-all hover:scale-110 hover:cursor-pointer hover:text-yellow-300"
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Group>
<DropdownMenu.Item
onclick={async () => {
if (!book.progress?.completed) await bookOps.markBooksAsComplete([book.id]);
else await bookOps.markBooksAsIncomplete([book.id]);
}}
>{!book?.progress?.completed
? 'Mark as finished'
: 'Mark as unfinished'}</DropdownMenu.Item
>
<DropdownMenu.Item
onclick={() => {
bookOps.deleteDialogTitle = `Delete "${book.title}"?`;
bookOps.deleteFn = async (deleteFiles: boolean) => {
await bookOps.deleteBooks([book.id], deleteFiles);
libraryState.activeLibrary!.total!--
bookshelfState.deletedBooks([book])
};
bookOps.deleteDialogOpen = true;
}}
>
Delete
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
<div
class="absolute right-2 bottom-20 z-50 inline-flex items-center opacity-0 transition-opacity group-hover:opacity-100"
>
<Pencil
class="scale-75 text-white transition-all hover:scale-110 hover:cursor-pointer hover:text-yellow-300"
onclick={() => {
bookOps.bookToEdit = book;
bookOps.editDialogOpen = true;
}}
/>
</div>
{/if}
</div>
{/each}
</div>
@@ -0,0 +1,9 @@
<script>
let { src, fallback = '/images/default_cover.jpg', class: className = '' } = $props();
async function handleError(e) {
e.target.src = fallback;
}
</script>
<img {src} onerror={handleError} class={className} alt="book cover" />
@@ -0,0 +1,100 @@
<script lang="ts">
import BookThumbnail from './book-thumbnail.svelte';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
let { title, books } = $props();
let scrollContainer = $state<HTMLElement>();
let needsScroll = $state(false);
let canScrollLeft = $state(false);
let canScrollRight = $state(false);
// Initialize after DOM is ready and handle resize
$effect(() => {
if (!scrollContainer) return;
// Create resize observer
const resizeObserver = new ResizeObserver(() => {
// Add a small delay to ensure accurate measurements
setTimeout(updateScrollState, 0);
});
// Start observing
resizeObserver.observe(scrollContainer);
// Initial check with delay
setTimeout(updateScrollState, 0);
// Cleanup
return () => {
resizeObserver.disconnect();
};
});
const scrollLeft = () => {
scrollContainer.scrollBy({ left: -scrollContainer.clientWidth, behavior: 'smooth' });
// Update state after scroll
setTimeout(updateScrollState, 100);
};
const scrollRight = () => {
scrollContainer.scrollBy({ left: scrollContainer.clientWidth, behavior: 'smooth' });
// Update state after scroll
setTimeout(updateScrollState, 100);
};
// Update all scroll-related states
function updateScrollState() {
if (!scrollContainer) return;
const scrollWidth = Math.ceil(scrollContainer.scrollWidth);
const clientWidth = Math.ceil(scrollContainer.clientWidth);
const scrollLeft = Math.ceil(scrollContainer.scrollLeft);
needsScroll = scrollWidth > clientWidth;
canScrollLeft = scrollLeft > 0;
canScrollRight = scrollWidth - (scrollLeft + clientWidth) > 1; // Add small buffer for rounding
}
</script>
{#if books.length > 0}
<div class="flex w-full flex-col">
<div class="flex items-center">
<h1 class="ml-4 text-xl font-semibold">{title}</h1>
{#if needsScroll}
<div class="ml-auto">
<button
type="button"
disabled={!canScrollLeft}
onclick={scrollLeft}
class={`${canScrollLeft ? 'text-primary/70 hover:text-primary' : 'text-primary/30'}`}
aria-label="Scroll Left"
>
<ChevronLeft size="20" strokeWidth="3" />
</button>
<button
type="button"
disabled={!canScrollRight}
onclick={scrollRight}
class={`${canScrollRight ? 'text-primary/70 hover:text-primary' : 'text-primary/30'}`}
aria-label="Scroll Right"
>
<ChevronRight size="20" strokeWidth="3" />
</button>
</div>
{/if}
</div>
<div
bind:this={scrollContainer}
onscroll={updateScrollState}
class="scrollbar-none flex w-full gap-4 overflow-x-auto p-4"
>
{#each books as book (book.id)}
<div class="w-40 flex-shrink-0">
<BookThumbnail {book} />
</div>
{/each}
</div>
</div>
{/if}
@@ -0,0 +1,66 @@
<script>
import * as Table from '$lib/components/ui/table/index';
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
import { Checkbox } from '$lib/components/ui/checkbox/index';
import BookImage from './book-image.svelte';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import { getLibraryState } from '$lib/state/library.svelte';
let { books } = $props();
const selectedState = getBookSelectionState();
const libraryState = getLibraryState();
</script>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head></Table.Head>
<Table.Head class="max-w-24 min-w-16">Cover</Table.Head>
<Table.Head>Title</Table.Head>
<Table.Head class="max-w-24 min-w-16">Authors</Table.Head>
<Table.Head>Publisher</Table.Head>
<Table.Head>Published Date</Table.Head>
<Table.Head>Pages</Table.Head>
<Table.Head>Tags</Table.Head>
<Table.Head>Identifiers</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each books as book (book.id)}
<Table.Row>
<Table.Cell
><Checkbox
checked={selectedState.isSelected(book.id)}
onCheckedChange={() => selectedState.toggleSelection(book)}
/></Table.Cell
>
<Table.Cell>
<BookImage src="/api/{book.cover_image}" />
</Table.Cell>
<Table.Cell>{book.title}</Table.Cell>
<Table.Cell>
{#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}
</Table.Cell>
<Table.Cell>{book.publisher}</Table.Cell>
<Table.Cell>{book.published_date}</Table.Cell>
<Table.Cell>{book.pages}</Table.Cell>
<Table.Cell>
{#each book.tags as tag}
{tag.name}
{/each}
</Table.Cell>
<Table.Cell>
{#each Object.entries(book.identifiers) as [name, value] (value)}
<span class="cs-list">{name}</span> &thinsp;
{/each}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
@@ -0,0 +1,65 @@
<script lang="ts">
import BookImage from './book-image.svelte';
import { Progress } from '$lib/components/ui/progress/index';
import { getLibraryState } from '$lib/state/library.svelte';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import type { Book } from '$lib/schema';
let { book, class: className = '', ...rest }: { book: Book, class?: string} = $props();
const selectionState = getBookSelectionState();
const libraryState = getLibraryState();
let darkened = $derived(selectionState.selectionModeActive);
let selected = $derived(selectionState.isSelected(book.id));
function handleClick(e: MouseEvent) {
if (darkened) {
e.preventDefault();
selectionState.toggleSelection(book);
}
}
</script>
<div class="flex w-full flex-shrink-0 flex-col gap-1 {className}">
<!-- Book Cover -->
<a
href="/book/{book.id}"
class="group relative aspect-9/12 w-full overflow-hidden rounded shadow-lg drop-shadow-lg transition-all duration-200 {selected
? 'ring-2 ring-yellow-300'
: ''}"
onclick={handleClick}
>
<BookImage
src="/api/{book.cover_image}"
class="h-full w-full rounded object-cover transition-all duration-200 group-hover:brightness-50 {selected ||
darkened
? 'brightness-50'
: ''}"
/>
</a>
{#if book.progress?.progress && !selected && !darkened}
<Progress
value={book.progress.progress}
max={1}
class="mt-[-8px] h-1 rounded {book.progress.completed
? '[&>div]:bg-green-600'
: '[&>div]:bg-yellow-500'}"
/>
{/if}
<!-- Book Title -->
<a href="/book/{book.id}" class="text-base-content mt-1 line-clamp-2 w-full text-sm hover:underline"
>{book.title}</a
>
<!-- Authors list -->
<p class="line-clamp-1 w-full text-xs text-muted-foreground">
{#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}
</p>
</div>
@@ -0,0 +1,30 @@
<script lang="ts">
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
import * as Tooltip from '$lib/components/ui/tooltip/index';
import { Funnel } from '@lucide/svelte';
import { useSidebar } from '$lib/components/ui/sidebar/index';
import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
const sidebar = useSidebar('FILTER_SIDEBAR');
const bookCollection = getBookCollectionState();
</script>
<!-- Filter button -->
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger
onclick={sidebar.toggle}
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} relative"
>
<Funnel />
{#if bookCollection.hasActiveFilters}
<div class="absolute top-[5px] right-[5px] h-1.5 w-1.5 rounded-full bg-yellow-500"></div>
{/if}
</Tooltip.Trigger>
<Tooltip.Content>
<p>Filter</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
@@ -0,0 +1,46 @@
<script lang="ts">
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import type { ComponentProps } from 'svelte';
import { ChevronsRight, FunnelX } from '@lucide/svelte';
import Filters from './filters.svelte';
import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
let { ...restProps }: ComponentProps<typeof Sidebar.Root> = $props();
const sidebar = Sidebar.useSidebar('FILTER_SIDEBAR');
const bookCollection = getBookCollectionState();
</script>
<Sidebar.Root
side="right"
collapsible="offcanvas"
variant="floating"
contextKey="FILTER_SIDEBAR"
{...restProps}
>
<Sidebar.Header class="h-16 items-center justify-center border-sidebar-border">
Filter Menu
</Sidebar.Header>
<Sidebar.Content class="overflow-hidden">
<Sidebar.Separator class="mx-0" />
<Filters />
</Sidebar.Content>
<Sidebar.Footer>
<Sidebar.Menu>
<Sidebar.MenuItem onclick={() => bookCollection.clearFilters()}>
<Sidebar.MenuButton>
<FunnelX />
<span>Clear Filters</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem onclick={sidebar.toggle}>
<Sidebar.MenuButton>
<ChevronsRight />
<span>Hide Menu</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Footer>
</Sidebar.Root>
@@ -0,0 +1,54 @@
<script lang="ts">
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import CheckIcon from '@lucide/svelte/icons/check';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
const bookCollection = getBookCollectionState();
</script>
{#each bookCollection.filterOptions as filter, index (filter.name)}
<Sidebar.Group class="py-0">
<Collapsible.Root open={index === 0} class="group/collapsible ">
<Sidebar.GroupLabel
class="group/label w-full text-sm text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
{#snippet child({ props })}
<Collapsible.Trigger {...props}>
{filter.name}
{#if bookCollection.filters[filter.value].length !== 0}
<div class="ml-2 h-[6px] w-[6px] rounded-full bg-yellow-500"></div>
{/if}
<ChevronRightIcon
class="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90"
/>
</Collapsible.Trigger>
{/snippet}
</Sidebar.GroupLabel>
<Collapsible.Content class="h-full max-h-128 overflow-y-auto">
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each filter.items as item, index (item.id)}
<Sidebar.MenuItem
onclick={() => bookCollection.toggleFilter(filter.value, item.id.toString())}
>
<Sidebar.MenuButton>
<div
data-active={bookCollection.isFilterSelected(filter.value, item.id.toString())}
class="group/calendar-item flex aspect-square size-4 shrink-0 items-center justify-center rounded-xs border border-sidebar-border text-sidebar-primary-foreground data-[active=true]:border-sidebar-primary data-[active=true]:bg-sidebar-primary"
>
<CheckIcon class="hidden size-3 group-data-[active=true]/calendar-item:block" />
</div>
{item?.name || item?.title}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Collapsible.Content>
</Collapsible.Root>
</Sidebar.Group>
<Sidebar.Separator class="mx-0" />
{/each}
@@ -0,0 +1,56 @@
<script lang="ts">
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
import * as Tooltip from '$lib/components/ui/tooltip/index';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
import { ArrowUpDown, ChevronUp, ChevronDown } from '@lucide/svelte';
import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
const bookCollection = getBookCollectionState();
</script>
<!-- Sort button -->
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger>
{#snippet child({ props })}
<DropdownMenu.Root>
<DropdownMenu.Trigger
{...props}
class="{buttonVariants({ variant: 'ghost', size: 'icon' })} relative"
>
{#if bookCollection.hasActiveSort}
<div
class="absolute top-[5px] right-[5px] h-1.5 w-1.5 rounded-full bg-yellow-500"
></div>
{/if}
<ArrowUpDown />
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-40">
<DropdownMenu.Group>
{#each bookCollection.sortOptions as sortProp}
<DropdownMenu.Item
onSelect={() => bookCollection.updateSort(sortProp.value)}
class={bookCollection.orderBy === sortProp.value ? 'bg-muted' : ''}
>
<div class="flex w-full items-center">
{sortProp.name}
{#if bookCollection.orderBy === sortProp.value}
{#if bookCollection.sortOrder === 'asc'}
<ChevronUp class="ml-auto" />
{:else}
<ChevronDown class="ml-auto" />
{/if}
{/if}
</div>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>Sort</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
@@ -0,0 +1,40 @@
<script>
import * as Tooltip from '$lib/components/ui/tooltip/index';
import * as ToggleGroup from '$lib/components/ui/toggle-group/index';
import { List, LayoutGrid } from '@lucide/svelte';
let { view = $bindable(), class: className = '' } = $props();
</script>
<ToggleGroup.Root type="single" bind:value={view} class={className}>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<ToggleGroup.Item value="grid" aria-label="Toggle grid view" {...props}>
<LayoutGrid class="size-4" />
</ToggleGroup.Item>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>Grid view</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<ToggleGroup.Item value="list" aria-label="Toggle list view" {...props}>
<List class="size-4" />
</ToggleGroup.Item>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>List view</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</ToggleGroup.Root>