From becd282d937d7056d22727b6fe9de3b87444f918 Mon Sep 17 00:00:00 2001 From: Phil Denhoff Date: Mon, 14 Oct 2024 18:40:54 -0700 Subject: [PATCH] feat: UI supports adding, editing, and deleting external identifiers Previously, identifiers like ISBN, Amazon ID, etc. were read-only. Now, you can create, edit, and delete those IDs. --- src-tauri/libcalibre/src/api/books.rs | 3 +- src/components/pages/EditBook.tsx | 100 ++++++++++++++---- src/lib/services/library/_internal/_types.ts | 7 ++ .../library/_internal/adapters/calibre.ts | 22 ++++ src/routes/books.$bookId.tsx | 44 +++++++- 5 files changed, 155 insertions(+), 21 deletions(-) diff --git a/src-tauri/libcalibre/src/api/books.rs b/src-tauri/libcalibre/src/api/books.rs index a39c792..4a673e3 100644 --- a/src-tauri/libcalibre/src/api/books.rs +++ b/src-tauri/libcalibre/src/api/books.rs @@ -155,11 +155,12 @@ impl BooksHandler { fn create_book_identifier(&mut self, update: UpsertBookIdentifier) -> Result { use crate::schema::identifiers::dsl::*; let mut connection = self.client.lock().unwrap(); + let lowercased_label = update.label.to_lowercase(); diesel::insert_into(identifiers) .values(( book.eq(update.book_id), - type_.eq(update.label), + type_.eq(lowercased_label), val.eq(update.value), )) .returning(id) diff --git a/src/components/pages/EditBook.tsx b/src/components/pages/EditBook.tsx index a78b815..34f69ed 100644 --- a/src/components/pages/EditBook.tsx +++ b/src/components/pages/EditBook.tsx @@ -14,7 +14,7 @@ import { Title, } from "@mantine/core"; import { Form, useForm } from "@mantine/form"; -import { type HTMLProps, useEffect, useMemo } from "react"; +import { type HTMLProps, useEffect, useMemo, useState } from "react"; import { BookCover } from "../atoms/BookCover"; import { MultiSelectCreatable } from "../atoms/Multiselect"; @@ -22,21 +22,22 @@ interface BookPageProps { book: LibraryBook; allAuthorList: LibraryAuthor[]; onSave: (bookUpdate: BookUpdate) => Promise; + onDeleteIdentifier: (bookId: string, identifierId: number) => Promise; + onUpsertIdentifier: ( + bookId: string, + identifierId: number | null, + label: string, + value: string, + ) => Promise; } -export const BookPage = ({ book, allAuthorList, onSave }: BookPageProps) => { - return ( - - ); -}; - -interface BookPagePureProps { - book: LibraryBook; - allAuthorList: LibraryAuthor[]; - onSave: (bookUpdate: BookUpdate) => Promise; -} - -const BookPagePure = ({ book, allAuthorList, onSave }: BookPagePureProps) => { +export const BookPage = ({ + book, + allAuthorList, + onSave, + onUpsertIdentifier, + onDeleteIdentifier, +}: BookPageProps) => { return ( @@ -45,7 +46,13 @@ const BookPagePure = ({ book, allAuthorList, onSave }: BookPagePureProps) => { </Text>{" "} – {book.title} - + ); }; @@ -99,10 +106,19 @@ const EditBookForm = ({ book, allAuthorList, onSave, + onUpsertIdentifier, + onDeleteIdentifier, }: { book: LibraryBook; allAuthorList: LibraryAuthor[]; onSave: (update: BookUpdate) => Promise; + onDeleteIdentifier: (bookId: string, identifierId: number) => Promise; + onUpsertIdentifier: ( + bookId: string, + identifierId: number | null, + label: string, + value: string, + ) => Promise; }) => { const initialValues = useMemo(() => { return formValuesFromBook(book); @@ -114,6 +130,7 @@ const EditBookForm = ({ () => allAuthorList.map((author) => author.name), [allAuthorList], ); + const [newBookIdentifierLabel, setNewBookIdentifierLabel] = useState(""); // biome-ignore lint/correctness/useExhaustiveDependencies: Re-rendering when Form is updated causes infinite loops. useEffect(() => { @@ -217,16 +234,61 @@ const EditBookForm = ({ {form.values.identifierList.length > 0 && (
- {form.values.identifierList.map(({ label, value }) => ( - + {form.values.identifierList.map(({ label, id }, index) => ( + { + onUpsertIdentifier( + book.id, + id, + label, + event.target.value, + ).catch(console.error); + }} /> + { + onDeleteIdentifier(book.id, id).catch(console.error); + }} + mt={LABEL_OFFSET_MARGIN} + > + × + ))} +
+ + + setNewBookIdentifierLabel(event.target.value) + } + /> + +
)} diff --git a/src/lib/services/library/_internal/_types.ts b/src/lib/services/library/_internal/_types.ts index 2cac3a9..689ff12 100644 --- a/src/lib/services/library/_internal/_types.ts +++ b/src/lib/services/library/_internal/_types.ts @@ -28,6 +28,13 @@ export interface Library { }, ): Promise; updateBook(bookId: string, updates: BookUpdate): Promise; + deleteBookIdentifier(bookId: string, identifierId: number): Promise; + upsertBookIdentifier( + bookId: string, + identifierId: number | null, + label: string, + value: string, + ): Promise; /** * Returns the path to the cover image for the book with the given ID. diff --git a/src/lib/services/library/_internal/adapters/calibre.ts b/src/lib/services/library/_internal/adapters/calibre.ts index 7639649..47fb3e1 100644 --- a/src/lib/services/library/_internal/adapters/calibre.ts +++ b/src/lib/services/library/_internal/adapters/calibre.ts @@ -64,6 +64,22 @@ const genLocalCalibreClient = async ( updateBook: async (bookId, updates) => { await commands.clbCmdUpdateBook(options.libraryPath, bookId, updates); }, + upsertBookIdentifier: async (bookId, identifierId, label, value) => { + await commands.clbCmdUpsertBookIdentifier( + options.libraryPath, + bookId, + label, + value, + identifierId, + ); + }, + deleteBookIdentifier: async (bookId, identifierId) => { + await commands.clbCmdDeleteBookIdentifier( + options.libraryPath, + bookId, + identifierId, + ); + }, getCoverPathForBook: (bookId) => { return bookCoverCache.get(bookId)?.localPath; }, @@ -134,6 +150,12 @@ const genRemoteCalibreClient = async ( updateBook: () => { throw new Error("Not implemented"); }, + upsertBookIdentifier: () => { + throw new Error("Not implemented"); + }, + deleteBookIdentifier: () => { + throw new Error("Not implemented"); + }, // The interface requires we accept param. // eslint-disable-next-line @typescript-eslint/no-unused-vars getCoverPathForBook: (_bookId) => { diff --git a/src/routes/books.$bookId.tsx b/src/routes/books.$bookId.tsx index 2d902b9..c688eca 100644 --- a/src/routes/books.$bookId.tsx +++ b/src/routes/books.$bookId.tsx @@ -71,6 +71,40 @@ const EditBookRoute = () => { [library, bookId, eventEmitter], ); + const onUpsertIdentifier = useCallback( + async ( + bookId: string, + identifierId: number | null, + label: string, + value: string, + ) => { + await library?.upsertBookIdentifier(bookId, identifierId, label, value); + + if (bookId) { + eventEmitter?.emit(LibraryEventNames.LIBRARY_BOOK_UPDATED, { + book: bookId, + }); + } + + return Promise.resolve(); + }, + [library, eventEmitter], + ); + const onDeleteIdentifier = useCallback( + async (bookId: string, identifierId: number) => { + await library?.deleteBookIdentifier(bookId, identifierId); + + if (bookId) { + eventEmitter?.emit(LibraryEventNames.LIBRARY_BOOK_UPDATED, { + book: bookId, + }); + } + + return Promise.resolve(); + }, + [library, eventEmitter], + ); + if (state !== LibraryState.ready) { return
Loading...
; } @@ -78,7 +112,15 @@ const EditBookRoute = () => { return
Book not found
; } - return ; + return ( + + ); }; export const Route = createFileRoute("/books/$bookId")({