Initial commit
This commit is contained in:
@@ -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
|
||||
>  
|
||||
{/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>  
|
||||
{/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
|
||||
>  
|
||||
{/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>
|
||||
Reference in New Issue
Block a user