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' 'use client'
import React, {ChangeEvent, Dispatch, SetStateAction, useContext, useState} from "react"; import React, {ChangeEvent, Dispatch, SetStateAction, useContext, useState} from "react";
import {AlertContext, AlertContextProps} from "@/context/AlertContext"; 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 {isDesktop} from '@/lib/configs';
import * as tauri from '@/lib/tauri'; import * as tauri from '@/lib/tauri';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {SessionContext, SessionContextProps} from "@/context/SessionContext"; 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 SelectBox, {SelectBoxProps} from "@/components/form/SelectBox";
import {bookTypes} from "@/lib/constants/book"; import {bookTypes} from "@/lib/constants/book";
import InputField from "@/components/form/InputField"; import InputField from "@/components/form/InputField";
@@ -32,7 +32,7 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
const {lang}: LangContextProps = useContext<LangContextProps>(LangContext); const {lang}: LangContextProps = useContext<LangContextProps>(LangContext);
const {session, setSession}: SessionContextProps = useContext<SessionContextProps>(SessionContext); const {session, setSession}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {errorMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext); 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 {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const [title, setTitle] = useState<string>(''); const [title, setTitle] = useState<string>('');
const [subtitle, setSubtitle] = 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 [isAddingBook, setIsAddingBook] = useState<boolean>(false);
const [bookTypeHint, setBookTypeHint] = useState<boolean>(false); const [bookTypeHint, setBookTypeHint] = useState<boolean>(false);
const [showLocalFallback, setShowLocalFallback] = useState<boolean>(false);
const token: string = session?.accessToken ?? ''; const token: string = session?.accessToken ?? '';
@@ -154,9 +155,61 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
aiGuideLine: null aiGuideLine: null
}; };
setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] => [...prev, book]) setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] => [...prev, book])
setIsAddingBook(false); setIsAddingBook(false);
setCloseForm(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) { } catch (e: unknown) {
if (e instanceof Error) { if (e instanceof Error) {
errorMessage(e.message); errorMessage(e.message);
@@ -166,7 +219,7 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
setIsAddingBook(false); setIsAddingBook(false);
} }
} }
function maxWordsCountHint(): MinMax { function maxWordsCountHint(): MinMax {
switch (selectedBookType) { switch (selectedBookType) {
case 'short': case 'short':
@@ -212,16 +265,35 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
footer={ footer={
<> <>
<Button variant="secondary" onClick={() => setCloseForm(false)}>{t("common.cancel")}</Button> <Button variant="secondary" onClick={() => setCloseForm(false)}>{t("common.cancel")}</Button>
<Button {showLocalFallback ? (
variant="primary" <Button
onClick={handleAddBook} variant="primary"
isLoading={isAddingBook} onClick={handleCreateLocalBook}
loadingText={t("addNewBookForm.adding")} isLoading={isAddingBook}
icon={Book} loadingText={t("addNewBookForm.adding")}
>{t("addNewBookForm.add")}</Button> 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={ <InputField icon={BookOpen} fieldName={t("addNewBookForm.type")} input={
<SelectBox <SelectBox
onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setSelectedBookType(e.target.value)} onChangeCallBack={(e: ChangeEvent<HTMLSelectElement>): void => setSelectedBookType(e.target.value)}

View File

@@ -9,7 +9,7 @@ import {useRouter} from "@/lib/navigation";
import {Book, ChevronLeft, ChevronRight, Download, Settings, Trash2} from 'lucide-react'; import {Book, ChevronLeft, ChevronRight, Download, Settings, Trash2} from 'lucide-react';
import Badge from "@/components/ui/Badge"; import Badge from "@/components/ui/Badge";
import {SessionContext, SessionContextProps} from "@/context/SessionContext"; import {SessionContext, SessionContextProps} from "@/context/SessionContext";
import {BookProps} from "@/lib/types/book"; import {BookProps, BookTypeLimit} from "@/lib/types/book";
import {getBookTypeLabel} from "@/lib/utils/book"; import {getBookTypeLabel} from "@/lib/utils/book";
import BookCard from "@/components/book/BookCard"; import BookCard from "@/components/book/BookCard";
import BookCardSkeleton from "@/components/book/BookCardSkeleton"; import BookCardSkeleton from "@/components/book/BookCardSkeleton";
@@ -46,6 +46,7 @@ export default function BookList() {
const [groupedItems, setGroupedItems] = useState<Record<string, CategoryItem[]>>({}); const [groupedItems, setGroupedItems] = useState<Record<string, CategoryItem[]>>({});
const [isLoadingBooks, setIsLoadingBooks] = useState<boolean>(true); const [isLoadingBooks, setIsLoadingBooks] = useState<boolean>(true);
const [showSeriesSettingId, setShowSeriesSettingId] = useState<string | null>(null); const [showSeriesSettingId, setShowSeriesSettingId] = useState<string | null>(null);
const [bookLimits, setBookLimits] = useState<Record<string, BookTypeLimit> | null>(null);
const carouselRefs = useRef<Record<string, HTMLDivElement | null>>({}); const carouselRefs = useRef<Record<string, HTMLDivElement | null>>({});
const [bookGuide, setBookGuide] = useState<boolean>(false); const [bookGuide, setBookGuide] = useState<boolean>(false);
@@ -148,14 +149,58 @@ export default function BookList() {
try { try {
let booksResponse: BookProps[]; let booksResponse: BookProps[];
let seriesResponse: SeriesListItemProps[]; let seriesResponse: SeriesListItemProps[];
if (isDesktop && isCurrentlyOffline()) { if (isDesktop && isCurrentlyOffline()) {
booksResponse = await tauri.getBooks() as BookProps[]; if (!offlineMode.isDatabaseInitialized) {
seriesResponse = await tauri.getSeriesList() as SeriesListItemProps[]; setIsLoadingBooks(false);
} else { return;
[booksResponse, seriesResponse] = await Promise.all([ }
apiGet<BookProps[]>('books', accessToken, lang), const [localBooks, localSeries] = await Promise.all([
apiGet<SeriesListItemProps[]>('series/list', accessToken, lang) tauri.getBooks() as Promise<BookProps[]>,
tauri.getSeriesList() as Promise<SeriesListItemProps[]>
]); ]);
booksResponse = localBooks.map((b: BookProps): BookProps => ({...b, localBook: true}));
seriesResponse = localSeries;
} else {
const [onlineBooks, localBooks, onlineSeries, localSeries, limitsResponse] = await Promise.all([
apiGet<BookProps[]>('books', accessToken, lang),
isDesktop && offlineMode.isDatabaseInitialized
? tauri.getBooks() as Promise<BookProps[]>
: Promise.resolve([]),
apiGet<SeriesListItemProps[]>('series/list', accessToken, lang),
isDesktop && offlineMode.isDatabaseInitialized
? tauri.getSeriesList() as Promise<SeriesListItemProps[]>
: Promise.resolve([]),
apiGet<Record<string, BookTypeLimit> | null>('books/limits', accessToken, lang)
]);
setBookLimits(limitsResponse);
// Merge livres : serveur + locaux uniques
const onlineBookIds: Set<string> = new Set(onlineBooks.map((b: BookProps): string => b.bookId));
const uniqueLocalBooks: BookProps[] = localBooks
.filter((b: BookProps): boolean => !onlineBookIds.has(b.bookId))
.map((b: BookProps): BookProps => ({...b, localBook: true}));
booksResponse = [...onlineBooks, ...uniqueLocalBooks];
// Merge séries : serveur + bookIds locaux manquants + séries locales uniques
const localSeriesMap: Map<string, SeriesListItemProps> = new Map(
localSeries.map((s: SeriesListItemProps): [string, SeriesListItemProps] => [s.id, s])
);
const mergedOnlineSeries: SeriesListItemProps[] = onlineSeries.map((serverSeries: SeriesListItemProps): SeriesListItemProps => {
const localVersion: SeriesListItemProps | undefined = localSeriesMap.get(serverSeries.id);
if (localVersion) {
const serverBookIds: Set<string> = new Set(serverSeries.bookIds);
const localOnlyBookIds: string[] = localVersion.bookIds.filter((id: string): boolean => !serverBookIds.has(id));
return {
...serverSeries,
bookIds: [...serverSeries.bookIds, ...localOnlyBookIds]
};
}
return serverSeries;
});
const onlineSeriesIds: Set<string> = new Set(onlineSeries.map((s: SeriesListItemProps): string => s.id));
const uniqueLocalSeries: SeriesListItemProps[] = localSeries.filter((s: SeriesListItemProps): boolean => !onlineSeriesIds.has(s.id));
seriesResponse = [...mergedOnlineSeries, ...uniqueLocalSeries];
} }
if (booksResponse) { if (booksResponse) {
@@ -170,11 +215,11 @@ export default function BookList() {
}); });
// Transformer les livres avec leur image // Transformer les livres avec leur image
const transformedBooks: BookProps[] = booksResponse.map((book: BookProps): BookProps => { const transformedBooks: BookProps[] = booksResponse.map((book: BookProps & { bookType?: string }): BookProps => {
const imageDataUrl: string = book.coverImage ? 'data:image/jpeg;base64,' + book.coverImage : ''; const imageDataUrl: string = book.coverImage ? 'data:image/jpeg;base64,' + book.coverImage : '';
return { return {
bookId: book.bookId, bookId: book.bookId,
type: getBookTypeLabel(book.type), type: book.type || book.bookType || '',
title: book.title, title: book.title,
subTitle: book.subTitle, subTitle: book.subTitle,
summary: book.summary, summary: book.summary,
@@ -191,36 +236,35 @@ export default function BookList() {
const processedSeriesIds: Set<string> = new Set(); const processedSeriesIds: Set<string> = new Set();
transformedBooks.forEach((book: BookProps): void => { transformedBooks.forEach((book: BookProps): void => {
const categoryLabel: string = t(book.type); if (!itemsByCategory[book.type]) {
if (!itemsByCategory[categoryLabel]) { itemsByCategory[book.type] = [];
itemsByCategory[categoryLabel] = [];
} }
const seriesInfo: SeriesListItemProps | undefined = bookToSeriesMap.get(book.bookId); const seriesInfo: SeriesListItemProps | undefined = bookToSeriesMap.get(book.bookId);
if (seriesInfo && !processedSeriesIds.has(seriesInfo.id)) { if (seriesInfo && !processedSeriesIds.has(seriesInfo.id)) {
// Ce livre fait partie d'une série non encore traitée // Ce livre fait partie d'une série non encore traitée
processedSeriesIds.add(seriesInfo.id); processedSeriesIds.add(seriesInfo.id);
// Récupérer tous les livres de cette série dans cette catégorie // Récupérer tous les livres de cette série dans cette catégorie
const seriesBooks: BookProps[] = transformedBooks.filter( const seriesBooks: BookProps[] = transformedBooks.filter(
(bookItem: BookProps): boolean => seriesInfo.bookIds.includes(bookItem.bookId) (bookItem: BookProps): boolean => seriesInfo.bookIds.includes(bookItem.bookId)
); );
const seriesCard: SeriesCardProps = { const seriesCard: SeriesCardProps = {
id: seriesInfo.id, id: seriesInfo.id,
name: seriesInfo.name, name: seriesInfo.name,
coverImage: seriesInfo.coverImage, coverImage: seriesInfo.coverImage,
books: seriesBooks books: seriesBooks
}; };
itemsByCategory[categoryLabel].push({ itemsByCategory[book.type].push({
type: 'series', type: 'series',
series: seriesCard series: seriesCard
}); });
} else if (!seriesInfo) { } else if (!seriesInfo) {
// Livre individuel (pas dans une série) // Livre individuel (pas dans une série)
itemsByCategory[categoryLabel].push({ itemsByCategory[book.type].push({
type: 'book', type: 'book',
book: book book: book
}); });
@@ -384,17 +428,25 @@ export default function BookList() {
<p className="text-muted italic text-lg">{t("bookList.booksAreMirrors")}</p> <p className="text-muted italic text-lg">{t("bookList.booksAreMirrors")}</p>
</div> </div>
{Object.entries(filteredItems).map(([category, items]: [string, CategoryItem[]], index: number) => ( {Object.entries(filteredItems).map(([category, items]: [string, CategoryItem[]], index: number) => {
const itemCount: number = getTotalItemsCount(items);
const typeLimit: BookTypeLimit | undefined = bookLimits?.[category] ?? undefined;
const isLimitReached: boolean = typeLimit !== undefined && typeLimit.current >= typeLimit.max;
const categoryLabel: string = t(getBookTypeLabel(category));
return (
<div key={category} {...(index === 0 && {'data-guide': 'book-category'})} <div key={category} {...(index === 0 && {'data-guide': 'book-category'})}
className="w-full mb-10"> className="w-full mb-10">
<div <div
className="flex justify-between items-center w-full max-w-5xl mx-auto mb-6 px-6"> className="flex justify-between items-center w-full max-w-5xl mx-auto mb-6 px-6">
<h2 className="text-3xl text-text-primary capitalize font-['ADLaM_Display'] flex items-center gap-3"> <h2 className="text-3xl text-text-primary capitalize font-['ADLaM_Display'] flex items-center gap-3">
<span className="w-1 h-8 bg-primary rounded-full"></span> <span className="w-1 h-8 bg-primary rounded-full"></span>
{category} {categoryLabel}
</h2> </h2>
<Badge variant="muted" size="md"> <Badge variant={isLimitReached ? "error" : "muted"} size="md">
{getTotalItemsCount(items)} {t("bookList.works")} {typeLimit !== undefined
? `${typeLimit.current}/${typeLimit.max}`
: itemCount
} {t("bookList.works")}
</Badge> </Badge>
</div> </div>
@@ -455,7 +507,8 @@ export default function BookList() {
</div> </div>
</div> </div>
</div> </div>
))} );
})}
</> </>
) : ( ) : (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">

View File

@@ -216,7 +216,7 @@ export default function useSyncBooks() {
serverBooksResponse = serverResponse.books; serverBooksResponse = serverResponse.books;
await tauri.applyBookTombstones(serverResponse.tombstones as tauri.TombstoneRecord[]); await tauri.applyBookTombstones((serverResponse.tombstones ?? []) as tauri.TombstoneRecord[]);
} else { } else {
// No local DB but online - just get server books without tombstones // No local DB but online - just get server books without tombstones
const serverResponse: SyncedBooksResponse = await apiPost<SyncedBooksResponse>( const serverResponse: SyncedBooksResponse = await apiPost<SyncedBooksResponse>(

View File

@@ -302,7 +302,7 @@ export default function useSyncSeries() {
serverSeriesResponse = serverResponse.series; serverSeriesResponse = serverResponse.series;
await tauri.applySeriesTombstones(serverResponse.tombstones as tauri.TombstoneRecord[]); await tauri.applySeriesTombstones((serverResponse.tombstones ?? []) as tauri.TombstoneRecord[]);
} else { } else {
// No local DB but online - just get server series without tombstones // No local DB but online - just get server series without tombstones
const serverResponse: SyncedSeriesResponse = await apiPost<SyncedSeriesResponse>( const serverResponse: SyncedSeriesResponse = await apiPost<SyncedSeriesResponse>(

View File

@@ -13,10 +13,20 @@ interface ApiRequestConfig {
contentType?: ContentType; contentType?: ContentType;
} }
export class ApiError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
this.name = 'ApiError';
}
}
function handleApiError(error: unknown): never { function handleApiError(error: unknown): never {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const serverMessage: string = error.response?.data?.message || error.response?.data || error.message; const serverMessage: string = error.response?.data?.message || error.response?.data || error.message;
throw new Error(serverMessage); const statusCode: number = error.response?.status ?? 500;
throw new ApiError(serverMessage, statusCode);
} else if (error instanceof Error) { } else if (error instanceof Error) {
throw new Error(error.message); throw new Error(error.message);
} }

View File

@@ -718,7 +718,10 @@
"titleTooShort": "Title is too short. Minimum 2 characters required.", "titleTooShort": "Title is too short. Minimum 2 characters required.",
"titleTooLong": "Title is too long. Maximum 50 characters allowed.", "titleTooLong": "Title is too long. Maximum 50 characters allowed.",
"typeMissing": "Select a genre.", "typeMissing": "Select a genre.",
"addingBook": "An error occurred while adding the book." "addingBook": "An error occurred while adding the book.",
"limitReached": "You have reached the book limit for this type on the cloud.",
"saveLocally": "Save locally",
"localFallbackDescription": "You can still save this book locally on your device."
}, },
"bookTypeHint": { "bookTypeHint": {
"title": "Type of work", "title": "Type of work",

View File

@@ -717,7 +717,10 @@
"titleTooShort": "Le titre est trop court. Minimum 2 caractères requis", "titleTooShort": "Le titre est trop court. Minimum 2 caractères requis",
"titleTooLong": "Le titre est trop long. Maximum 50 caractères autorisés", "titleTooLong": "Le titre est trop long. Maximum 50 caractères autorisés",
"typeMissing": "Sélectionner un genre.", "typeMissing": "Sélectionner un genre.",
"addingBook": "Une erreur est survenue lors de l'ajout du livre." "addingBook": "Une erreur est survenue lors de l'ajout du livre.",
"limitReached": "Vous avez atteint la limite de livres pour ce type sur le cloud.",
"saveLocally": "Sauvegarder localement",
"localFallbackDescription": "Vous pouvez tout de même enregistrer ce livre localement sur votre appareil."
}, },
"bookTypeHint": { "bookTypeHint": {
"title": "Type d'oeuvre", "title": "Type d'oeuvre",

View File

@@ -44,10 +44,10 @@ export interface SyncCheckResult {
} }
export interface TombstoneRecord { export interface TombstoneRecord {
tableName: string; table_name: string;
entityId: string; entity_id: string;
bookId: string | null; book_id: string | null;
deletedAt: number; deleted_at: number;
} }
// ─── User & Auth ─────────────────────────────────────────── // ─── User & Auth ───────────────────────────────────────────

View File

@@ -106,3 +106,8 @@ export interface BookTags {
objects: Tag[]; objects: Tag[];
worldElements: Tag[]; worldElements: Tag[];
} }
export interface BookTypeLimit {
current: number;
max: number;
}

View File

@@ -40,7 +40,6 @@ fn get_session(session: &State<SessionState>) -> Result<(String, Lang), AppError
} }
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TombstoneRecord { pub struct TombstoneRecord {
pub table_name: String, pub table_name: String,
pub entity_id: String, pub entity_id: String,
@@ -63,7 +62,6 @@ pub fn get_tombstones_since(since: i64, db: State<DbManager>, session: State<Ses
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TombstoneInput { pub struct TombstoneInput {
pub table_name: String, pub table_name: String,
pub entity_id: String, pub entity_id: String,