Introduce local fallback for book creation and improve error handling

- Added support for creating books locally when the cloud limit is reached.
- Enhanced error handling in `AddNewBookForm` with `ApiError` and fallback logic for local book creation.
- Implemented `BookTypeLimit` to manage type-specific book limits with visual indicators in `BookList`.
- Refactored `TombstoneRecord` to standardize naming conventions for better API compatibility.
- Updated `useSyncSeries` and `useSyncBooks` to handle empty tombstones gracefully.
- Updated locales with new translations for fallback and error messaging.
This commit is contained in:
natreex
2026-03-31 09:18:11 -04:00
parent acacd95f38
commit b9bc024e91
10 changed files with 191 additions and 47 deletions

View File

@@ -1,12 +1,12 @@
'use client'
import React, {ChangeEvent, Dispatch, SetStateAction, useContext, useState} from "react";
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
import {apiPost} from "@/lib/api/client";
import {ApiError, apiPost} from "@/lib/api/client";
import {isDesktop} from '@/lib/configs';
import * as tauri from '@/lib/tauri';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
import {Book, BookOpen, Calendar, FileText, Info, Pencil} from "lucide-react";
import {AlertTriangle, Book, BookOpen, Calendar, FileText, HardDrive, Info, Pencil} from "lucide-react";
import SelectBox, {SelectBoxProps} from "@/components/form/SelectBox";
import {bookTypes} from "@/lib/constants/book";
import InputField from "@/components/form/InputField";
@@ -32,7 +32,7 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {session, setSession}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {setServerSyncedBooks}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext)
const {setServerSyncedBooks, setLocalOnlyBooks}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext)
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const [title, setTitle] = useState<string>('');
const [subtitle, setSubtitle] = useState<string>('');
@@ -43,6 +43,7 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
const [isAddingBook, setIsAddingBook] = useState<boolean>(false);
const [bookTypeHint, setBookTypeHint] = useState<boolean>(false);
const [showLocalFallback, setShowLocalFallback] = useState<boolean>(false);
const token: string = session?.accessToken ?? '';
@@ -154,9 +155,61 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
aiGuideLine: null
};
setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] => [...prev, book])
setIsAddingBook(false);
setCloseForm(false)
} catch (e: unknown) {
const apiError = e as ApiError;
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('addNewBookForm.error.addingBook'));
}
if (apiError?.statusCode === 409 && isDesktop) {
setShowLocalFallback(true);
}
setIsAddingBook(false);
}
}
async function handleCreateLocalBook(): Promise<void> {
setIsAddingBook(true);
setShowLocalFallback(false);
try {
const bookId: string = await tauri.createBook({
title: title,
subTitle: subtitle,
type: selectedBookType,
summary: summary,
desiredReleaseDate: publicationDate,
desiredWordCount: wordCount,
});
if (!bookId) {
errorMessage(t('addNewBookForm.error.addingBook'));
setIsAddingBook(false);
return;
}
const book: SyncedBook = {
id: bookId,
type: selectedBookType,
title: title,
subTitle: subtitle,
seriesId: null,
lastUpdate: new Date().getTime() / 1000,
chapters: [],
characters: [],
locations: [],
worlds: [],
incidents: [],
plotPoints: [],
issues: [],
actSummaries: [],
guideLine: null,
aiGuideLine: null
};
setLocalOnlyBooks((prev: SyncedBook[]): SyncedBook[] => [...prev, book]);
setIsAddingBook(false);
setCloseForm(false);
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
@@ -166,7 +219,7 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
setIsAddingBook(false);
}
}
function maxWordsCountHint(): MinMax {
switch (selectedBookType) {
case 'short':
@@ -212,16 +265,35 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
footer={
<>
<Button variant="secondary" onClick={() => setCloseForm(false)}>{t("common.cancel")}</Button>
<Button
variant="primary"
onClick={handleAddBook}
isLoading={isAddingBook}
loadingText={t("addNewBookForm.adding")}
icon={Book}
>{t("addNewBookForm.add")}</Button>
{showLocalFallback ? (
<Button
variant="primary"
onClick={handleCreateLocalBook}
isLoading={isAddingBook}
loadingText={t("addNewBookForm.adding")}
icon={HardDrive}
>{t("addNewBookForm.error.saveLocally")}</Button>
) : (
<Button
variant="primary"
onClick={handleAddBook}
isLoading={isAddingBook}
loadingText={t("addNewBookForm.adding")}
icon={Book}
>{t("addNewBookForm.add")}</Button>
)}
</>
}
>
{showLocalFallback && (
<div className="flex items-start gap-3 bg-warning/10 border border-warning/30 rounded-xl p-4 mb-4">
<AlertTriangle strokeWidth={1.75} className="w-5 h-5 text-warning shrink-0 mt-0.5"/>
<div>
<p className="text-text-primary font-medium text-sm">{t("addNewBookForm.error.limitReached")}</p>
<p className="text-muted text-sm mt-1">{t("addNewBookForm.error.localFallbackDescription")}</p>
</div>
</div>
)}
<InputField icon={BookOpen} fieldName={t("addNewBookForm.type")} input={
<SelectBox
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setSelectedBookType(e.target.value)}