diff --git a/components/book/AddNewBookForm.tsx b/components/book/AddNewBookForm.tsx index 3680292..d7d0f44 100644 --- a/components/book/AddNewBookForm.tsx +++ b/components/book/AddNewBookForm.tsx @@ -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(LangContext); const {session, setSession}: SessionContextProps = useContext(SessionContext); const {errorMessage}: AlertContextProps = useContext(AlertContext); - const {setServerSyncedBooks}: BooksSyncContextProps = useContext(BooksSyncContext) + const {setServerSyncedBooks, setLocalOnlyBooks}: BooksSyncContextProps = useContext(BooksSyncContext) const {isCurrentlyOffline}: OfflineContextType = useContext(OfflineContext); const [title, setTitle] = useState(''); const [subtitle, setSubtitle] = useState(''); @@ -43,6 +43,7 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch< const [isAddingBook, setIsAddingBook] = useState(false); const [bookTypeHint, setBookTypeHint] = useState(false); + const [showLocalFallback, setShowLocalFallback] = useState(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 { + 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={ <> - + {showLocalFallback ? ( + + ) : ( + + )} } > + {showLocalFallback && ( +
+ +
+

{t("addNewBookForm.error.limitReached")}

+

{t("addNewBookForm.error.localFallbackDescription")}

+
+
+ )} ): void => setSelectedBookType(e.target.value)} diff --git a/components/book/BookList.tsx b/components/book/BookList.tsx index c0630c6..7b6fda1 100644 --- a/components/book/BookList.tsx +++ b/components/book/BookList.tsx @@ -9,7 +9,7 @@ import {useRouter} from "@/lib/navigation"; import {Book, ChevronLeft, ChevronRight, Download, Settings, Trash2} from 'lucide-react'; import Badge from "@/components/ui/Badge"; 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 BookCard from "@/components/book/BookCard"; import BookCardSkeleton from "@/components/book/BookCardSkeleton"; @@ -46,6 +46,7 @@ export default function BookList() { const [groupedItems, setGroupedItems] = useState>({}); const [isLoadingBooks, setIsLoadingBooks] = useState(true); const [showSeriesSettingId, setShowSeriesSettingId] = useState(null); + const [bookLimits, setBookLimits] = useState | null>(null); const carouselRefs = useRef>({}); const [bookGuide, setBookGuide] = useState(false); @@ -148,14 +149,58 @@ export default function BookList() { try { let booksResponse: BookProps[]; let seriesResponse: SeriesListItemProps[]; + if (isDesktop && isCurrentlyOffline()) { - booksResponse = await tauri.getBooks() as BookProps[]; - seriesResponse = await tauri.getSeriesList() as SeriesListItemProps[]; - } else { - [booksResponse, seriesResponse] = await Promise.all([ - apiGet('books', accessToken, lang), - apiGet('series/list', accessToken, lang) + if (!offlineMode.isDatabaseInitialized) { + setIsLoadingBooks(false); + return; + } + const [localBooks, localSeries] = await Promise.all([ + tauri.getBooks() as Promise, + tauri.getSeriesList() as Promise ]); + booksResponse = localBooks.map((b: BookProps): BookProps => ({...b, localBook: true})); + seriesResponse = localSeries; + } else { + const [onlineBooks, localBooks, onlineSeries, localSeries, limitsResponse] = await Promise.all([ + apiGet('books', accessToken, lang), + isDesktop && offlineMode.isDatabaseInitialized + ? tauri.getBooks() as Promise + : Promise.resolve([]), + apiGet('series/list', accessToken, lang), + isDesktop && offlineMode.isDatabaseInitialized + ? tauri.getSeriesList() as Promise + : Promise.resolve([]), + apiGet | null>('books/limits', accessToken, lang) + ]); + setBookLimits(limitsResponse); + + // Merge livres : serveur + locaux uniques + const onlineBookIds: Set = 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 = 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 = 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 = 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) { @@ -170,11 +215,11 @@ export default function BookList() { }); // 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 : ''; return { bookId: book.bookId, - type: getBookTypeLabel(book.type), + type: book.type || book.bookType || '', title: book.title, subTitle: book.subTitle, summary: book.summary, @@ -191,36 +236,35 @@ export default function BookList() { const processedSeriesIds: Set = new Set(); transformedBooks.forEach((book: BookProps): void => { - const categoryLabel: string = t(book.type); - if (!itemsByCategory[categoryLabel]) { - itemsByCategory[categoryLabel] = []; + if (!itemsByCategory[book.type]) { + itemsByCategory[book.type] = []; } - + const seriesInfo: SeriesListItemProps | undefined = bookToSeriesMap.get(book.bookId); - + if (seriesInfo && !processedSeriesIds.has(seriesInfo.id)) { // Ce livre fait partie d'une série non encore traitée processedSeriesIds.add(seriesInfo.id); - + // Récupérer tous les livres de cette série dans cette catégorie const seriesBooks: BookProps[] = transformedBooks.filter( (bookItem: BookProps): boolean => seriesInfo.bookIds.includes(bookItem.bookId) ); - + const seriesCard: SeriesCardProps = { id: seriesInfo.id, name: seriesInfo.name, coverImage: seriesInfo.coverImage, books: seriesBooks }; - - itemsByCategory[categoryLabel].push({ + + itemsByCategory[book.type].push({ type: 'series', series: seriesCard }); } else if (!seriesInfo) { // Livre individuel (pas dans une série) - itemsByCategory[categoryLabel].push({ + itemsByCategory[book.type].push({ type: 'book', book: book }); @@ -384,17 +428,25 @@ export default function BookList() {

{t("bookList.booksAreMirrors")}

- {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 (

- {category} + {categoryLabel}

- - {getTotalItemsCount(items)} {t("bookList.works")} + + {typeLimit !== undefined + ? `${typeLimit.current}/${typeLimit.max}` + : itemCount + } {t("bookList.works")}
@@ -455,7 +507,8 @@ export default function BookList() {
- ))} + ); + })} ) : (
diff --git a/hooks/useSyncBooks.ts b/hooks/useSyncBooks.ts index ae98d13..dc0a638 100644 --- a/hooks/useSyncBooks.ts +++ b/hooks/useSyncBooks.ts @@ -216,7 +216,7 @@ export default function useSyncBooks() { serverBooksResponse = serverResponse.books; - await tauri.applyBookTombstones(serverResponse.tombstones as tauri.TombstoneRecord[]); + await tauri.applyBookTombstones((serverResponse.tombstones ?? []) as tauri.TombstoneRecord[]); } else { // No local DB but online - just get server books without tombstones const serverResponse: SyncedBooksResponse = await apiPost( diff --git a/hooks/useSyncSeries.ts b/hooks/useSyncSeries.ts index a0c820a..e7a1343 100644 --- a/hooks/useSyncSeries.ts +++ b/hooks/useSyncSeries.ts @@ -302,7 +302,7 @@ export default function useSyncSeries() { serverSeriesResponse = serverResponse.series; - await tauri.applySeriesTombstones(serverResponse.tombstones as tauri.TombstoneRecord[]); + await tauri.applySeriesTombstones((serverResponse.tombstones ?? []) as tauri.TombstoneRecord[]); } else { // No local DB but online - just get server series without tombstones const serverResponse: SyncedSeriesResponse = await apiPost( diff --git a/lib/api/client.ts b/lib/api/client.ts index ccfa887..c173e9a 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -13,10 +13,20 @@ interface ApiRequestConfig { 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 { if (axios.isAxiosError(error)) { 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) { throw new Error(error.message); } diff --git a/lib/locales/en.json b/lib/locales/en.json index 2a8e6e1..0cc0425 100644 --- a/lib/locales/en.json +++ b/lib/locales/en.json @@ -718,7 +718,10 @@ "titleTooShort": "Title is too short. Minimum 2 characters required.", "titleTooLong": "Title is too long. Maximum 50 characters allowed.", "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": { "title": "Type of work", diff --git a/lib/locales/fr.json b/lib/locales/fr.json index ed3a94d..ce20954 100644 --- a/lib/locales/fr.json +++ b/lib/locales/fr.json @@ -717,7 +717,10 @@ "titleTooShort": "Le titre est trop court. Minimum 2 caractères requis", "titleTooLong": "Le titre est trop long. Maximum 50 caractères autorisés", "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": { "title": "Type d'oeuvre", diff --git a/lib/tauri.ts b/lib/tauri.ts index 30932a7..27f116c 100644 --- a/lib/tauri.ts +++ b/lib/tauri.ts @@ -44,10 +44,10 @@ export interface SyncCheckResult { } export interface TombstoneRecord { - tableName: string; - entityId: string; - bookId: string | null; - deletedAt: number; + table_name: string; + entity_id: string; + book_id: string | null; + deleted_at: number; } // ─── User & Auth ─────────────────────────────────────────── diff --git a/lib/types/book.ts b/lib/types/book.ts index 163f454..162c79a 100644 --- a/lib/types/book.ts +++ b/lib/types/book.ts @@ -106,3 +106,8 @@ export interface BookTags { objects: Tag[]; worldElements: Tag[]; } + +export interface BookTypeLimit { + current: number; + max: number; +} diff --git a/src-tauri/src/domains/tombstone/commands.rs b/src-tauri/src/domains/tombstone/commands.rs index 8450a29..3b82633 100644 --- a/src-tauri/src/domains/tombstone/commands.rs +++ b/src-tauri/src/domains/tombstone/commands.rs @@ -40,7 +40,6 @@ fn get_session(session: &State) -> Result<(String, Lang), AppError } #[derive(Serialize)] -#[serde(rename_all = "camelCase")] pub struct TombstoneRecord { pub table_name: String, pub entity_id: String, @@ -63,7 +62,6 @@ pub fn get_tombstones_since(since: i64, db: State, session: State