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,229 @@
import { getContext, setContext } from 'svelte';
import { goto, pushState, replaceState } from '$app/navigation';
import type { Book, DynamicFilterData, FilterOption, PaginatedResponse } from '$lib/schema';
import { page } from '$app/state';
import { BookOperationsState } from './bookOperations.svelte';
export class BookCollectionState {
public sortOrder = $state<string>('');
public orderBy = $state<string>('');
public filters = $state<Record<string, string[]>>({});
readonly hasActiveSort = $derived(this.orderBy !== 'title' || this.sortOrder !== 'asc');
readonly hasActiveFilters = $derived(Object.values(this.filters).some((arr) => arr.length > 0));
public books = $state<Book[]>([]);
public moreBooks = $state(false);
private currentBookPage = $state(1);
public loading = $state(false);
public filterOptions: FilterOption[] = $state([]);
private ops: BookOperationsState;
readonly sortOptions = [
{
value: 'title',
name: 'Title'
},
{
value: 'created_at',
name: 'Date Added'
},
{
value: 'published_date',
name: 'Date Published'
},
{
value: 'pages',
name: 'Pages'
},
{
value: 'last_accessed',
name: 'Last Accessed'
}
];
constructor(
ops: BookOperationsState,
books: PaginatedResponse<Book>,
filterData: DynamicFilterData
) {
this.ops = ops;
this.books = books.items;
this.moreBooks = books.total > books.items.length;
this.filterOptions = this.buildFilterOptions(filterData);
// Determine sort order and direction based on page data
this.orderBy = page.url.searchParams.get('orderBy') || 'title';
this.sortOrder = page.url.searchParams.get('sortOrder') || 'asc';
// Construct initial filters based on the page data
this.filters = {
authors: page.url.searchParams.getAll('authors'),
publishers: page.url.searchParams.getAll('publishers'),
progress: page.url.searchParams.getAll('progress'),
tags: page.url.searchParams.getAll('tags'),
shelves: page.url.searchParams.getAll('shelves')
};
}
updateSort(sortValue: string) {
if (sortValue === this.orderBy) {
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
} else {
this.orderBy = sortValue;
}
this.updateSearchParams();
}
toggleFilter(filter: string, value: string) {
if (!this.filters[filter]) {
this.filters[filter] = [];
}
const index = this.filters[filter].indexOf(value);
if (index > -1) {
this.filters[filter].splice(index, 1);
} else {
this.filters[filter].push(value);
}
this.updateSearchParams();
}
updateSearchParams() {
// Update URL
const url = new URL(window.location.href);
Object.entries(this.filters).forEach(([filter, values]) => {
url.searchParams.delete(filter);
values.forEach((val) => url.searchParams.append(filter, val.toString()));
});
url.searchParams.set('orderBy', this.orderBy);
url.searchParams.set('sortOrder', this.sortOrder);
// pushState(url.toString(), {})
goto(url.toString());
this.loadNewBooks();
}
async loadNewBooks() {
this.loading = true;
const result = await this.ops.listBooks({
currentPage: 1,
pageSize: 50,
sortOrder: this.sortOrder,
orderBy: this.orderBy,
...this.filters
});
this.books = [...result.items];
this.currentBookPage = 1;
this.loading = false;
}
async loadMoreBooks() {
const result = await this.ops.listBooks({
currentPage: this.currentBookPage + 1,
pageSize: 50,
sortOrder: this.sortOrder,
orderBy: this.orderBy,
...this.filters
});
this.books = [...this.books, ...result.items];
this.moreBooks = result.total > this.books.length;
this.currentBookPage++;
}
updateBooks(books: PaginatedResponse<Book>) {
this.books = books.items;
this.moreBooks = books.total > books.items.length;
}
resetState(books: PaginatedResponse<Book>, filterData: DynamicFilterData) {
this.books = books.items;
this.moreBooks = books.total > books.items.length;
this.filterOptions = this.buildFilterOptions(filterData);
const urlParams = new URLSearchParams(window.location.search);
// Re-read sort and filter state from URL
this.orderBy = urlParams.get('orderBy') || 'title';
this.sortOrder = urlParams.get('sortOrder') || 'asc';
this.filters = {
authors: urlParams.getAll('authors'),
publishers: urlParams.getAll('publishers'),
progress: urlParams.getAll('progress'),
tags: urlParams.getAll('tags'),
shelves: urlParams.getAll('shelves')
};
}
buildFilterOptions(data: DynamicFilterData): FilterOption[] {
return [
{
value: 'shelves',
name: 'Bookshelves',
items: data.bookshelves.items
},
{
value: 'authors',
name: 'Authors',
items: data.authors.items
},
{
value: 'tags',
name: 'Tags',
items: data.tags.items
},
{
value: 'publishers',
name: 'Publishers',
items: data.publishers.items
},
{
value: 'progress',
name: 'Progress',
items: [
{ id: 'read', name: 'Read' },
{ id: 'in_progress', name: 'In Progress' },
{ id: 'unread', name: 'Not Started' }
]
}
];
}
updateFilterOptions(
filterOptions: { value: string; name: string; items: { name: string; id: number | string }[] }[]
) {
this.filterOptions = filterOptions;
}
clearFilters() {
Object.values(this.filters).forEach((val) => (val.length = 0));
this.updateSearchParams();
}
isFilterSelected(filter: string, value: string) {
return this.filters[filter]?.includes(value) || false;
}
}
const BOOK_COLLECTION_KEY = Symbol('BOOK_COLLECTION');
export function setBookCollectionState(
ops: BookOperationsState,
books: PaginatedResponse<Book>,
filterData: DynamicFilterData
) {
return setContext(BOOK_COLLECTION_KEY, new BookCollectionState(ops, books, filterData));
}
export function getBookCollectionState() {
return getContext<ReturnType<typeof setBookCollectionState>>(BOOK_COLLECTION_KEY);
}
@@ -0,0 +1,178 @@
import { invalidate } from '$app/navigation';
import { page } from '$app/state';
import {
deleteBookFiles,
deleteBooks,
listBooks,
updateBookProgress
} from '$lib/api';
import {
type Book,
type UpdateBookProgress,
type BookQuery,
type PaginatedResponse,
} from '$lib/schema';
import { getContext, setContext } from 'svelte';
import { toast } from 'svelte-sonner';
export class BookOperationsState {
public libraryId = $state<string>('');
// Delete dialog related state
deleteDialogOpen = $state(false);
deleteDialogTitle = $state('Delete books?');
deleteFn = $state((deleteFiles: boolean) => {});
// Edit dialog related state
editDialogOpen = $state(false);
bookToEdit = $state<Book>();
// Upload dialog related state
uploadDialogOpen = $state(false);
constructor(libraryId: string | number) {
this.libraryId = libraryId.toString();
}
async listBooks(queryData: BookQuery): Promise<PaginatedResponse<Book>> {
const cleanParams = Object.fromEntries(
Object.entries(queryData).filter(([_, value]) => {
// Remove empty values
if (value === undefined || value === null) return false;
if (Array.isArray(value) && value.length === 0) return false;
if (value === '') return false;
return true;
})
);
return await listBooks({
// cacheTimestamp: this.cacheTimestamp,
libraries: [this.libraryId],
...cleanParams
});
}
async deleteBooks(bookIds: string[] | number[], deleteFiles: boolean, reload: boolean = true) {
try {
await deleteBooks({
book_ids: bookIds,
delete_files: deleteFiles,
library_id: this.libraryId
});
if (bookIds.length > 1) toast.success(`Deleted ${bookIds.length} books!`);
else toast.success(`Book deleted!`);
this.deleteDialogOpen = false;
// Reload the page after deleting a book to get fresh data
if (reload) await this.reload();
} catch (error) {
toast.error('Failed to delete.');
console.error('Failed to delete book(s): ', error);
}
}
async deleteBookFiles(bookId: number, fileIds: number[], deleteFiles: boolean) {
try {
await deleteBookFiles({
book_id: bookId,
file_ids: fileIds,
delete_files: deleteFiles
});
await invalidate('app:books');
if (fileIds.length > 1) toast.success(`Deleted ${fileIds.length} files!`);
else toast.success(`Deleted file!`);
} catch (error) {
toast.error('Failed to delete file(s)');
console.error('Failed to delete book files: ', error);
}
}
async updateBookProgress(bookIds: string[] | number[], progressRecord: UpdateBookProgress) {
await updateBookProgress({
book_ids: bookIds,
...progressRecord
});
}
async markBooksAsComplete(bookIds: string[] | number[]) {
try {
await this.updateBookProgress(bookIds, {
progress: 1,
completed: true
});
if (bookIds.length > 1) toast.success('Book marked as complete!');
else toast.success(`${bookIds.length} books marked as complete!`);
// Get fresh data
await this.reload();
} catch (error) {
toast.error('Failed to mark books as complete.');
console.error('Mark as complete operation failed: ', error);
}
}
async markBooksAsIncomplete(bookIds: number[]) {
try {
await this.updateBookProgress(bookIds, {
progress: 0,
completed: false
});
if (bookIds.length > 1) toast.success('Book progress reset!');
else toast.success(`Reset progress for ${bookIds.length} books!`);
// get fresh data
await this.reload();
} catch (error) {
toast.error('Failed to reset progress.');
console.error('Mark as incomplete operation failed: ', error);
}
}
async downloadBooks(bookIds: string[] | number[], filename?: string) {
// Construct the download URL
const downloadUrl = new URL(`/api/books/download`, page.url.origin);
downloadUrl.searchParams.set('library_id', this.libraryId);
bookIds.forEach((id) => {
downloadUrl.searchParams.set('book_ids', id.toString());
});
this.download(downloadUrl, filename || 'download.zip');
}
async downloadBookFile(bookId: number, fileId: number, filename: string) {
// Construct the download URL
const downloadUrl = new URL(`/api/books/download/${bookId}/${fileId}`, page.url.origin);
this.download(downloadUrl, filename);
}
private download(downloadUrl: URL, filename: string) {
// Create a download anchor element, click it, then remove it
const link = document.createElement('a');
link.download = filename;
link.href = downloadUrl.href;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
private async reload() {
await invalidate('app:books');
}
}
const BOOK_OPS_KEY = Symbol('BOOK_OPERATIONS');
export function setBookOperationsState(libraryId: string | number) {
return setContext(BOOK_OPS_KEY, new BookOperationsState(libraryId));
}
export function getBookOperationsState() {
return getContext<ReturnType<typeof setBookOperationsState>>(BOOK_OPS_KEY);
}
@@ -0,0 +1,55 @@
import type { Book } from '$lib/schema';
import { getContext, setContext } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
export class BookSelectionState {
private selectedBooks = new SvelteMap<string, Book>();
readonly selectionModeActive = $derived(this.selectedBooks.size !== 0);
toggleSelection(book: Book) {
const bookId = book.id.toString()
if (this.selectedBooks.has(bookId))
this.selectedBooks.delete(bookId);
else
this.selectedBooks.set(bookId, book);
}
isSelected(id: number | string): boolean {
return this.selectedBooks.has(id.toString());
}
getSelectedBooks(): Book[] {
return Array.from(this.selectedBooks.values())
}
getSelectedIds(): string[] {
return Array.from(this.selectedBooks.keys())
}
numSelected() {
return this.selectedBooks.size;
}
selectAll(books: Book[]) {
books.forEach((book) => {
const bookId = book.id.toString()
if (!this.selectedBooks.has(bookId))
this.selectedBooks.set(bookId, book)
});
}
deselectAll() {
this.selectedBooks.clear();
}
}
const BOOK_SELECTION_KEY = Symbol('BOOK_SELECTION');
export function setBookSelectionState() {
return setContext(BOOK_SELECTION_KEY, new BookSelectionState());
}
export function getBookSelectionState() {
return getContext<ReturnType<typeof setBookSelectionState>>(BOOK_SELECTION_KEY);
}
+151
View File
@@ -0,0 +1,151 @@
import { goto, invalidate } from '$app/navigation';
import { addBooksToShelf, createBookshelf, listBookshelves, removeBooksFromShelf } from '$lib/api';
import type { Book, Bookshelf } from '$lib/schema';
import { getContext, setContext } from 'svelte';
import { toast } from 'svelte-sonner';
import { SvelteMap } from 'svelte/reactivity';
export class BookshelfState {
readonly libraryBookshelves = new SvelteMap<string, Bookshelf[]>();
getBookshelves(libraryId: string | number): Bookshelf[] | undefined {
const id = libraryId.toString();
if (!this.libraryBookshelves.has(id)) {
this.fetchBookshelves(id).then(shelves => {
this.libraryBookshelves.set(id, shelves);
});
}
return this.libraryBookshelves.get(id);
}
async fetchBookshelves(libraryId: string) {
try {
let paginatedBookshelves = await listBookshelves({
libraries: [libraryId]
});
return paginatedBookshelves.items;
} catch (error) {
console.error('Failed to retrieve bookshelves: ', error);
}
}
async addBookshelf(name: string, libraryId?: string | number, booksToAdd?: string[] | number[]) {
try {
let bookshelf = await createBookshelf({
title: name,
library_id: libraryId,
book_ids: booksToAdd
})
if (bookshelf.library_id) {
const currentShelves = this.libraryBookshelves.get(bookshelf.library_id.toString()) || [];
this.libraryBookshelves.set(bookshelf.library_id.toString(), [...currentShelves, bookshelf]);
}
invalidate('app:books')
if (booksToAdd?.length)
toast.success(`Added ${booksToAdd.length} books to '${name}'`)
else
toast.success(`Created shelf '${name}'`)
return bookshelf
} catch (error) {
toast.error(`Failed to create bookshelf '${name}'`)
console.error(`Failed to create bookshelf: `, error)
}
}
async addBooksToShelf(shelfId: number | string, bookIds: number[]) {
try {
const shelf = await addBooksToShelf({
shelf_id: shelfId,
book_ids: bookIds
});
this.updateShelf(shelf)
if (bookIds.length === 1) toast.success(`Added book to shelf!`);
else toast.success(`Added ${bookIds.length} books to shelf!`);
} catch (error) {
console.error('Failed to add book(s) to shelf: ', error);
toast.error('Failed to add book(s) to shelf.');
}
}
async removeBooksFromShelf(shelfId: number | string, bookIds: number[]) {
try {
const shelf = await removeBooksFromShelf({
shelf_id: shelfId,
book_ids: bookIds
});
this.updateShelf(shelf)
if (bookIds.length === 1) toast.success(`Removed book from shelf.`);
else toast.success(`Removed ${bookIds.length} books from shelf!`);
} catch (error) {
console.error('Failed to remove book(s) from shelf: ', error);
toast.error('Failed to remove book(s) from shelf.');
}
}
async updateShelf(shelf: Bookshelf) {
if (!shelf.library_id) return;
const bookshelves = this.libraryBookshelves.get(shelf.library_id.toString())
if (!bookshelves) return;
const index = bookshelves.findIndex(bookshelf => bookshelf.id === shelf.id)
if (index !== -1) {
this.libraryBookshelves.set(
shelf.library_id.toString(),
[...bookshelves.slice(0, index), shelf, ...bookshelves.slice(index + 1)]
);
}
}
deletedBooks(books: Book[]) {
// Assume all books are in the same library
const libraryId = books[0].library_id.toString()
const bookshelves = this.libraryBookshelves.get(libraryId);
if (!bookshelves) return;
// Create a Set of shelf IDs that need updating for efficient lookup
const shelfIdsToUpdate = new Set<number>();
books.forEach(book => {
book.lists.forEach(shelf => {
shelfIdsToUpdate.add(shelf.id);
});
});
const updatedBookshelves = bookshelves.map(shelf => {
if (shelfIdsToUpdate.has(shelf.id)) {
// Count how many times this shelf appears across all deleted books
let decrementBy = 0;
books.forEach(book => {
if (book.lists.some(s => s.id === shelf.id)) {
decrementBy++;
}
});
return { ...shelf, total: shelf.total - decrementBy };
}
return shelf;
});
this.libraryBookshelves.set(libraryId, updatedBookshelves);
}
}
const BOOKSHELF_KEY = Symbol('BOOKSHELF');
export function setBookshelfState() {
return setContext(BOOKSHELF_KEY, new BookshelfState());
}
export function getBookshelfState() {
return getContext<ReturnType<typeof setBookshelfState>>(BOOKSHELF_KEY);
}
+83
View File
@@ -0,0 +1,83 @@
import { browser } from '$app/environment';
import { goto, invalidate } from '$app/navigation';
import { page } from '$app/state';
import { deleteLibrary } from '$lib/api';
import type { Library } from '$lib/schema';
import { getContext, setContext } from 'svelte';
import { toast } from 'svelte-sonner';
export class LibraryState {
libraries = $state<Library[]>([]);
activeLibrary = $state<Library | undefined>(undefined);
createDialogOpen = $state(false);
constructor(libraries: Library[]) {
this.libraries = libraries;
const activeLibrary = this.libraries.find((lib) => lib.id.toString() === page.params.libraryId);
let previousLibrary: Library | undefined;
if (browser) {
previousLibrary = this.libraries.find(
(lib) => lib.id.toString() === localStorage.getItem('previousLibraryId')
);
}
this.activeLibrary = activeLibrary || previousLibrary || libraries[0];
}
async setActive(libraryId: number) {
this.activeLibrary = this.libraries.find((lib) => lib.id === libraryId) || this.libraries[0];
if (browser) {
localStorage.setItem('previousLibraryId', this.activeLibrary.id.toString());
await goto(`/library/${libraryId}/view`, { invalidate: ['app:libraries'] });
}
}
updateLibraries(libraries: Library[]) {
this.libraries = libraries;
// Update the current library as it may have been removed
if (!this.libraries.find((lib) => this.activeLibrary?.id === lib.id))
this.activeLibrary = libraries[0] || undefined;
}
async deleteLibrary(libraryId: number) {
// Store library with its original index
const libIndex = this.libraries.findIndex((lib) => lib.id === libraryId);
const deletionSnapshot = { library: this.libraries[libIndex], index: libIndex };
// Optimistically delete the library
this.libraries = this.libraries.filter((lib) => lib.id !== libraryId);
try {
// Delete library via API
await deleteLibrary(libraryId);
toast.success(`Deleted library '${deletionSnapshot.library}'`);
} catch (err) {
// Delete failed; restore library at its original position
this.libraries.splice(deletionSnapshot.index, 0, deletionSnapshot.library);
toast.error('Failed to delete library');
console.error('Failed to delete library: ', err);
}
}
addLibrary(library: Library) {
this.libraries.push(library);
}
openLibraryCreateDialog() {
this.createDialogOpen = true;
}
}
const LIBRARY_KEY = Symbol('LIBRARY');
export function setLibraryState(libraries: Library[]) {
return setContext(LIBRARY_KEY, new LibraryState(libraries));
}
export function getLibraryState() {
return getContext<ReturnType<typeof setLibraryState>>(LIBRARY_KEY);
}