Skip to content

Commit

Permalink
feat: UI supports adding, editing, and deleting external identifiers
Browse files Browse the repository at this point in the history
Previously, identifiers like ISBN, Amazon ID, etc. were read-only. Now, you can create, edit, and delete those IDs.
  • Loading branch information
phildenhoff committed Dec 17, 2024
1 parent 61c22f9 commit becd282
Show file tree
Hide file tree
Showing 5 changed files with 155 additions and 21 deletions.
3 changes: 2 additions & 1 deletion src-tauri/libcalibre/src/api/books.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,12 @@ impl BooksHandler {
fn create_book_identifier(&mut self, update: UpsertBookIdentifier) -> Result<i32, ()> {
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)
Expand Down
100 changes: 81 additions & 19 deletions src/components/pages/EditBook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,29 +14,30 @@ 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";

interface BookPageProps {
book: LibraryBook;
allAuthorList: LibraryAuthor[];
onSave: (bookUpdate: BookUpdate) => Promise<void>;
onDeleteIdentifier: (bookId: string, identifierId: number) => Promise<void>;
onUpsertIdentifier: (
bookId: string,
identifierId: number | null,
label: string,
value: string,
) => Promise<void>;
}

export const BookPage = ({ book, allAuthorList, onSave }: BookPageProps) => {
return (
<BookPagePure book={book} allAuthorList={allAuthorList} onSave={onSave} />
);
};

interface BookPagePureProps {
book: LibraryBook;
allAuthorList: LibraryAuthor[];
onSave: (bookUpdate: BookUpdate) => Promise<void>;
}

const BookPagePure = ({ book, allAuthorList, onSave }: BookPagePureProps) => {
export const BookPage = ({
book,
allAuthorList,
onSave,
onUpsertIdentifier,
onDeleteIdentifier,
}: BookPageProps) => {
return (
<Stack h={"100%"}>
<Title size="md">
Expand All @@ -45,7 +46,13 @@ const BookPagePure = ({ book, allAuthorList, onSave }: BookPagePureProps) => {
</Text>{" "}
{book.title}
</Title>
<EditBookForm book={book} allAuthorList={allAuthorList} onSave={onSave} />
<EditBookForm
book={book}
allAuthorList={allAuthorList}
onSave={onSave}
onDeleteIdentifier={onDeleteIdentifier}
onUpsertIdentifier={onUpsertIdentifier}
/>
</Stack>
);
};
Expand Down Expand Up @@ -99,10 +106,19 @@ const EditBookForm = ({
book,
allAuthorList,
onSave,
onUpsertIdentifier,
onDeleteIdentifier,
}: {
book: LibraryBook;
allAuthorList: LibraryAuthor[];
onSave: (update: BookUpdate) => Promise<void>;
onDeleteIdentifier: (bookId: string, identifierId: number) => Promise<void>;
onUpsertIdentifier: (
bookId: string,
identifierId: number | null,
label: string,
value: string,
) => Promise<void>;
}) => {
const initialValues = useMemo(() => {
return formValuesFromBook(book);
Expand All @@ -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(() => {
Expand Down Expand Up @@ -217,16 +234,61 @@ const EditBookForm = ({
{form.values.identifierList.length > 0 && (
<Group flex={1}>
<Fieldset legend="Identifiers">
{form.values.identifierList.map(({ label, value }) => (
<Group key={`${label}-${value}`} flex={1} align="center">
{form.values.identifierList.map(({ label, id }, index) => (
<Group key={id} flex={1} align="center">
<TextInput
flex={"15ch"}
label={label.toUpperCase()}
value={value}
disabled
{...form.getInputProps(`identifierList.${index}.value`)}
onBlur={(event) => {
onUpsertIdentifier(
book.id,
id,
label,
event.target.value,
).catch(console.error);
}}
/>
<ActionIcon
variant="outline"
color="red"
onClick={() => {
onDeleteIdentifier(book.id, id).catch(console.error);
}}
mt={LABEL_OFFSET_MARGIN}
>
×
</ActionIcon>
</Group>
))}
<hr style={{ color: "lightgrey" }} />
<Group>
<TextInput
label="Identifier label"
placeholder="ISBN"
value={newBookIdentifierLabel}
onChange={(event) =>
setNewBookIdentifierLabel(event.target.value)
}
/>
<Button
onClick={() => {
onUpsertIdentifier(
book.id,
null,
newBookIdentifierLabel,
"",
)
.then(() => setNewBookIdentifierLabel(""))
.catch(console.error);
}}
variant="outline"
color="blue"
mt={LABEL_OFFSET_MARGIN}
>
Add identifier
</Button>
</Group>
</Fieldset>
</Group>
)}
Expand Down
7 changes: 7 additions & 0 deletions src/lib/services/library/_internal/_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ export interface Library {
},
): Promise<void>;
updateBook(bookId: string, updates: BookUpdate): Promise<void>;
deleteBookIdentifier(bookId: string, identifierId: number): Promise<void>;
upsertBookIdentifier(
bookId: string,
identifierId: number | null,
label: string,
value: string,
): Promise<void>;

/**
* Returns the path to the cover image for the book with the given ID.
Expand Down
22 changes: 22 additions & 0 deletions src/lib/services/library/_internal/adapters/calibre.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand Down Expand Up @@ -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) => {
Expand Down
44 changes: 43 additions & 1 deletion src/routes/books.$bookId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,56 @@ 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 <div>Loading...</div>;
}
if (!book) {
return <div>Book not found</div>;
}

return <BookPage book={book} allAuthorList={allAuthorList} onSave={onSave} />;
return (
<BookPage
book={book}
allAuthorList={allAuthorList}
onSave={onSave}
onUpsertIdentifier={onUpsertIdentifier}
onDeleteIdentifier={onDeleteIdentifier}
/>
);
};

export const Route = createFileRoute("/books/$bookId")({
Expand Down

0 comments on commit becd282

Please sign in to comment.