diff --git a/app/globals.css b/app/globals.css index 3bd0318..8b09ce4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -251,10 +251,6 @@ body { border: none !important; } -.setting-container { - height: calc(100vh - 10rem); -} - .composer-panel-h { height: calc(100vh - 8rem); } diff --git a/app/page.tsx b/app/page.tsx index ff5f7f6..462c74c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -34,10 +34,32 @@ import OfflineContext, {OfflineMode} from "@/context/OfflineContext"; import OfflinePinSetup from "@/components/offline/OfflinePinSetup"; import OfflinePinVerify from "@/components/offline/OfflinePinVerify"; import {SyncedBook, BookSyncCompare, compareBookSyncs} from "@/lib/models/SyncedBook"; +import {SyncedSeries, SeriesSyncCompare, compareSeriesSyncs} from "@/lib/models/SyncedSeries"; import {BooksSyncContext} from "@/context/BooksSyncContext"; +import {SeriesSyncContext} from "@/context/SeriesSyncContext"; import useSyncBooks from "@/hooks/useSyncBooks"; +import useSyncSeries from "@/hooks/useSyncSeries"; import {LocalSyncQueueContext, LocalSyncOperation} from "@/context/SyncQueueContext"; +interface RemovedItemRecord { + removal_id: string; + table_name: string; + entity_id: string; + book_id: string | null; + user_id: string; + deleted_at: number; +} + +interface SyncedBooksResponse { + books: SyncedBook[]; + tombstones: RemovedItemRecord[]; +} + +interface SyncedSeriesResponse { + series: SyncedSeries[]; + tombstones: RemovedItemRecord[]; +} + const messagesMap = { fr: frMessages, en: enMessages @@ -45,22 +67,59 @@ const messagesMap = { function AutoSyncOnReconnect() { const {offlineMode} = useContext(OfflineContext); - const {syncAllToServer, refreshBooks, booksToSyncToServer} = useSyncBooks(); + const {syncAllToServer: syncAllBooksToServer, refreshBooks, booksToSyncToServer} = useSyncBooks(); + const {syncAllToServer: syncAllSeriesToServer, refreshSeries, seriesToSyncToServer} = useSyncSeries(); const [pendingSync, setPendingSync] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + + const saveLastOnlineTimestamp = (): void => { + const timestamp: number = Math.floor(Date.now() / 1000); + localStorage.setItem('lastOnlineTimestamp', timestamp.toString()); + }; useEffect((): void => { if (!offlineMode.isOffline) { setPendingSync(true); - refreshBooks(); + setIsRefreshing(true); + Promise.all([refreshBooks(), refreshSeries()]).then(() => { + setIsRefreshing(false); + }); } }, [offlineMode.isOffline]); useEffect((): void => { - if (pendingSync && booksToSyncToServer.length > 0) { - syncAllToServer(); + if (pendingSync && !isRefreshing) { + const syncPromises: Promise[] = []; + + if (booksToSyncToServer.length > 0) { + syncPromises.push(syncAllBooksToServer()); + } + if (seriesToSyncToServer.length > 0) { + syncPromises.push(syncAllSeriesToServer()); + } + + if (syncPromises.length > 0) { + Promise.all(syncPromises).then(() => { + saveLastOnlineTimestamp(); + }); + } else { + saveLastOnlineTimestamp(); + } + setPendingSync(false); } - }, [booksToSyncToServer, pendingSync]); + }, [booksToSyncToServer, seriesToSyncToServer, pendingSync, isRefreshing]); + + // Update lastOnlineTimestamp every 5 minutes while online + useEffect((): (() => void) | void => { + if (!offlineMode.isOffline) { + const intervalId: NodeJS.Timeout = setInterval((): void => { + saveLastOnlineTimestamp(); + }, 5 * 60 * 1000); // 5 minutes + + return (): void => clearInterval(intervalId); + } + }, [offlineMode.isOffline]); return null; } @@ -94,6 +153,13 @@ function ScribeContent() { const [serverOnlyBooks, setServerOnlyBooks] = useState([]); const [localOnlyBooks, setLocalOnlyBooks] = useState([]); + const [serverSyncedSeries, setServerSyncedSeries] = useState([]); + const [localSyncedSeries, setLocalSyncedSeries] = useState([]); + const [seriesSyncDiffsFromServer, setSeriesSyncDiffsFromServer] = useState([]); + const [seriesSyncDiffsToServer, setSeriesSyncDiffsToServer] = useState([]); + const [serverOnlySeries, setServerOnlySeries] = useState([]); + const [localOnlySeries, setLocalOnlySeries] = useState([]); + const [currentCredits, setCurrentCredits] = useState(160); const [amountSpent, setAmountSpent] = useState(session.user?.aiUsage || 0); @@ -218,7 +284,8 @@ function ScribeContent() { useEffect((): void => { if (session.isConnected) { - getBooks().then() + refreshBooks().then() + refreshSeries().then() setIsTermsAccepted(session.user?.termsAccepted ?? false); setHomeStepsGuide(User.guideTourDone(session.user?.guideTour ?? [], 'home-basic')); setIsLoading(false); @@ -230,7 +297,7 @@ function ScribeContent() { if (currentBook) { getLastChapter().then(); } else { - getBooks().then(); + refreshBooks().then(); } } }, [currentBook]); @@ -269,17 +336,58 @@ function ScribeContent() { setLocalOnlyBooks(localSyncedBooks.filter((localBook: SyncedBook):boolean => !serverSyncedBooks.find((serverBook: SyncedBook):boolean => serverBook.id === localBook.id))) }, [localSyncedBooks, serverSyncedBooks]); - - async function getBooks(): Promise { + useEffect((): void => { + const diffsFromServer: SeriesSyncCompare[] = []; + const diffsToServer: SeriesSyncCompare[] = []; + + serverSyncedSeries.forEach((serverSeries: SyncedSeries): void => { + const localSeries: SyncedSeries | undefined = localSyncedSeries.find((series: SyncedSeries): boolean => series.id === serverSeries.id); + if (!localSeries) { + return; + } + + const diff: SeriesSyncCompare | null = compareSeriesSyncs(serverSeries, localSeries); + if (diff) { + diffsFromServer.push(diff); + } + }); + + localSyncedSeries.forEach((localSeries: SyncedSeries): void => { + const serverSeries: SyncedSeries | undefined = serverSyncedSeries.find((series: SyncedSeries): boolean => series.id === localSeries.id); + if (!serverSeries) { + return; + } + + const diff: SeriesSyncCompare | null = compareSeriesSyncs(localSeries, serverSeries); + if (diff) { + diffsToServer.push(diff); + } + }); + + setSeriesSyncDiffsFromServer(diffsFromServer); + setSeriesSyncDiffsToServer(diffsToServer); + setServerOnlySeries(serverSyncedSeries.filter((serverSeries: SyncedSeries): boolean => !localSyncedSeries.find((localSeries: SyncedSeries): boolean => localSeries.id === serverSeries.id))); + setLocalOnlySeries(localSyncedSeries.filter((localSeries: SyncedSeries): boolean => !serverSyncedSeries.find((serverSeries: SyncedSeries): boolean => serverSeries.id === localSeries.id))); + }, [localSyncedSeries, serverSyncedSeries]); + + async function refreshBooks(): Promise { try { let localBooksResponse: SyncedBook[] = []; let serverBooksResponse: SyncedBook[] = []; - if (!isCurrentlyOffline()){ + if (!isCurrentlyOffline()) { if (offlineMode.isDatabaseInitialized) { localBooksResponse = await window.electron.invoke('db:books:synced'); + const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp'); + const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0; + const localTombstones: RemovedItemRecord[] = await window.electron.invoke('db:tombstones:since', lastOnlineTimestamp); + const serverResponse: SyncedBooksResponse = await System.authPostToServer('books/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale); + serverBooksResponse = serverResponse.books; + await window.electron.invoke('db:tombstones:apply:books', serverResponse.tombstones); + } else { + const serverResponse: SyncedBooksResponse = await System.authPostToServer('books/synced', { lastOnlineTimestamp: 0, tombstones: [] }, session.accessToken, locale); + serverBooksResponse = serverResponse.books; } - serverBooksResponse = await System.authGetQueryToServer('books/synced', session.accessToken, locale); } else { if (offlineMode.isDatabaseInitialized) { localBooksResponse = await window.electron.invoke('db:books:synced'); @@ -296,7 +404,43 @@ function ScribeContent() { } } } - + + async function refreshSeries(): Promise { + try { + let localSeriesResponse: SyncedSeries[] = []; + let serverSeriesResponse: SyncedSeries[] = []; + + if (!isCurrentlyOffline()) { + if (offlineMode.isDatabaseInitialized) { + localSeriesResponse = await window.electron.invoke('db:series:synced'); + const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp'); + const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0; + const localTombstones: RemovedItemRecord[] = await window.electron.invoke('db:tombstones:since', lastOnlineTimestamp); + const serverResponse: SyncedSeriesResponse = await System.authPostToServer('series/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale); + serverSeriesResponse = serverResponse.series; + await window.electron.invoke('db:tombstones:apply:series', serverResponse.tombstones); + } else { + const serverResponse: SyncedSeriesResponse = await System.authPostToServer('series/synced', { lastOnlineTimestamp: 0, tombstones: [] }, session.accessToken, locale); + serverSeriesResponse = serverResponse.series; + } + } else { + if (offlineMode.isDatabaseInitialized) { + localSeriesResponse = await window.electron.invoke('db:series:synced'); + } + } + + setServerSyncedSeries(serverSeriesResponse); + setLocalSyncedSeries(localSeriesResponse); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("homePage.errors.fetchSeriesError")); + } + } + } + + useEffect(():void => { async function checkPinSetup() { if (session.isConnected && window.electron) { @@ -601,7 +745,8 @@ function ScribeContent() { addToQueue: addToLocalSyncQueue, isProcessing: isQueueProcessing, }}> - + + @@ -650,6 +795,7 @@ function ScribeContent() { + diff --git a/components/Modal.tsx b/components/Modal.tsx index 04039f8..65c08d9 100644 --- a/components/Modal.tsx +++ b/components/Modal.tsx @@ -1,4 +1,4 @@ -import {ReactNode, useEffect, useState} from 'react'; +import React, {ReactNode, useEffect, useState} from 'react'; import {createPortal} from 'react-dom'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import {faX} from "@fortawesome/free-solid-svg-icons"; @@ -12,6 +12,7 @@ interface ModalProps { confirmText?: string; cancelText?: string; enableFooter?: boolean; + enableOverflow?: boolean; } export default function Modal( @@ -24,6 +25,7 @@ export default function Modal( confirmText = 'Confirm', cancelText = 'Cancel', enableFooter = true, + enableOverflow = true, }: ModalProps) { const [mounted, setMounted] = useState(false); @@ -59,7 +61,7 @@ export default function Modal( -
+
{children}
{ diff --git a/components/ScribeControllerBar.tsx b/components/ScribeControllerBar.tsx index 8ef5e63..519a81b 100644 --- a/components/ScribeControllerBar.tsx +++ b/components/ScribeControllerBar.tsx @@ -1,4 +1,4 @@ -import {useContext, useState} from "react"; +import React, {useContext, useState} from "react"; import {ChapterProps, chapterVersions} from "@/lib/models/Chapter"; import {ChapterContext} from "@/context/ChapterContext"; import {BookContext} from "@/context/BookContext"; @@ -10,7 +10,6 @@ import {SelectBoxProps} from "@/shared/interface"; import {AlertContext} from "@/context/AlertContext"; import {SessionContext} from "@/context/SessionContext"; import Book, {BookProps} from "@/lib/models/Book"; -import Modal from "@/components/Modal"; import BookSetting from "@/components/book/settings/BookSetting"; import SelectBox from "@/components/form/SelectBox"; import {useTranslations} from "next-intl"; @@ -28,7 +27,7 @@ export default function ScribeControllerBar() { const t = useTranslations(); const {lang, setLang} = useContext(LangContext); const {isCurrentlyOffline} = useContext(OfflineContext) - const {serverOnlyBooks,localOnlyBooks} = useContext(BooksSyncContext); + const {serverSyncedBooks, serverOnlyBooks, localOnlyBooks} = useContext(BooksSyncContext); const isGPTEnabled: boolean = !isCurrentlyOffline() && QuillSense.isOpenAIEnabled(session); const isGemini: boolean = !isCurrentlyOffline() && QuillSense.isOpenAIEnabled(session); @@ -79,6 +78,7 @@ export default function ScribeControllerBar() { totalWordCount: response.desiredWordCount, quillsenseEnabled: response.quillsenseEnabled, tools: response?.tools, + seriesId: response.seriesId, }); } catch (e: unknown) { if (e instanceof Error) { @@ -174,17 +174,7 @@ export default function ScribeControllerBar() {
- { - showSettingPanel && - setShowSettingPanel(false)} - onConfirm={() => { - }} - children={} - enableFooter={false} - /> - } + {showSettingPanel && setShowSettingPanel(false)}/>} ) } \ No newline at end of file diff --git a/components/SettingsPanel.tsx b/components/SettingsPanel.tsx new file mode 100644 index 0000000..4dcc8d2 --- /dev/null +++ b/components/SettingsPanel.tsx @@ -0,0 +1,50 @@ +'use client' +import {ReactNode, useEffect, useState} from "react"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faX} from "@fortawesome/free-solid-svg-icons"; +import {createPortal} from "react-dom"; + +interface SettingsPanelProps { + title: string; + sidebar: ReactNode; + children: ReactNode; + onClose: () => void; +} + +export default function SettingsPanel({title, sidebar, children, onClose}: SettingsPanelProps) { + const [mounted, setMounted] = useState(false); + + useEffect((): void => { + setMounted(true); + }, []); + + if (!mounted) return null; + + return createPortal( +
+
+ +
+

{title}

+ +
+ +
+
+ {sidebar} +
+
+ {children} +
+
+ +
+
, + document.body + ); +} diff --git a/components/StaticAlert.tsx b/components/StaticAlert.tsx index 2cdd2f1..0e91b92 100644 --- a/components/StaticAlert.tsx +++ b/components/StaticAlert.tsx @@ -75,7 +75,9 @@ export default function StaticAlert( />
-
{message}
+
+ {typeof message === 'string' ? message : String(message ?? 'Une erreur est survenue')} +
+ )} + {currentStatus === 'server-only' && ( + + )} + {currentStatus === 'to-sync-from-server' && ( + + )} + {currentStatus === 'to-sync-to-server' && ( + + )} + + ); +} diff --git a/components/book/AddNewBookForm.tsx b/components/book/AddNewBookForm.tsx index 332fb83..e496558 100644 --- a/components/book/AddNewBookForm.tsx +++ b/components/book/AddNewBookForm.tsx @@ -170,7 +170,10 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch< actSummaries: [], guideLine: null, aiGuideLine: null, - bookTools: null + bookTools: null, + seriesId: null, + spells: [], + spellTags: [] }]); } else { @@ -190,7 +193,10 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch< actSummaries: [], guideLine: null, aiGuideLine: null, - bookTools: null + bookTools: null, + seriesId: null, + spells: [], + spellTags: [] }]); } diff --git a/components/book/BookCard.tsx b/components/book/BookCard.tsx index 864456e..b503659 100644 --- a/components/book/BookCard.tsx +++ b/components/book/BookCard.tsx @@ -18,8 +18,8 @@ export default function BookCard({book, onClickCallback, index, syncStatus}: Boo const t = useTranslations(); return (
-
+ className="group bg-tertiary/90 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 h-full border border-secondary/50 hover:border-primary/50 flex flex-col"> +
), }, - ] - + ]; + useEffect((): void => { - if (groupedBooks && Object.keys(groupedBooks).length > 0 && User.guideTourDone(session.user?.guideTour || [], 'new-first-book')) { + if (groupedItems && Object.keys(groupedItems).length > 0 && User.guideTourDone(session.user?.guideTour || [], 'new-first-book')) { setBookGuide(true); } - }, [groupedBooks]); - + }, [groupedItems]); + useEffect((): void => { - const shouldFetchBooks:boolean|"" = + const shouldFetch: boolean | "" = (session.isConnected || accessToken) && (!isCurrentlyOffline() || offlineMode.isDatabaseInitialized); - if (shouldFetchBooks) { - getBooks().then(); + if (shouldFetch) { + loadBooksAndSeries().then(); } }, [ session.isConnected, @@ -103,9 +128,10 @@ export default function BookList() { booksToSyncFromServer, booksToSyncToServer, serverOnlyBooks, - localOnlyBooks + localOnlyBooks, + serverSyncedBooks ]); - + async function handleFirstBookGuide(): Promise { try { if (!isCurrentlyOffline()) { @@ -135,60 +161,172 @@ export default function BookList() { } } } - - async function getBooks(): Promise { + + async function loadBooksAndSeries(): Promise { setIsLoadingBooks(true); try { - let bookResponse: (BookProps & { itIsLocal: boolean })[] = []; + let booksResponse: (BookProps & { itIsLocal?: boolean })[] = []; + let seriesResponse: SeriesListItemProps[] = []; + + // ═══════════════════════════════════════════════════════════════ + // PARTIE 1 : FETCH DES DONNÉES (dual logic) + // ═══════════════════════════════════════════════════════════════ + if (!isCurrentlyOffline()) { - const [onlineBooks, localBooks]: [BookProps[], BookProps[]] = await Promise.all([ + // ONLINE : fetch serveur + local en parallèle + const [onlineBooks, localBooks, onlineSeries, localSeries] = await Promise.all([ System.authGetQueryToServer('books', accessToken, lang), offlineMode.isDatabaseInitialized ? window.electron.invoke('db:book:books') + : Promise.resolve([]), + System.authGetQueryToServer('series/list', accessToken, lang), + offlineMode.isDatabaseInitialized + ? window.electron.invoke('db:series:list') : Promise.resolve([]) ]); - const onlineBookIds: Set = new Set(onlineBooks.map((book: BookProps): string => book.bookId)); - const uniqueLocalBooks: BookProps[] = localBooks.filter((book: BookProps): boolean => !onlineBookIds.has(book.bookId)); - bookResponse = [ - ...onlineBooks.map((book: BookProps): BookProps & { itIsLocal: boolean } => ({ ...book, itIsLocal: false })), - ...uniqueLocalBooks.map((book: BookProps): BookProps & { itIsLocal: boolean } => ({ ...book, itIsLocal: true })) + + // Merge des livres (serveur + locaux uniques) + const onlineBookIds = new Set(onlineBooks.map(b => b.bookId)); + const uniqueLocalBooks = localBooks.filter(b => !onlineBookIds.has(b.bookId)); + booksResponse = [ + ...onlineBooks.map(b => ({...b, itIsLocal: false})), + ...uniqueLocalBooks.map(b => ({...b, itIsLocal: true})) ]; + + // Merge des séries (serveur + locales uniques) + // Pour les séries synced, on merge les bookIds (serveur + local-only) + const localSeriesMap = new Map(localSeries.map(s => [s.id, s])); + const mergedOnlineSeries = onlineSeries.map(serverSeries => { + const localVersion = localSeriesMap.get(serverSeries.id); + if (localVersion) { + // Merger les bookIds : serveur + ceux du local qui ne sont pas sur le serveur + const serverBookIds = new Set(serverSeries.bookIds); + const localOnlyBookIds = localVersion.bookIds.filter(id => !serverBookIds.has(id)); + return { + ...serverSeries, + bookIds: [...serverSeries.bookIds, ...localOnlyBookIds] + }; + } + return serverSeries; + }); + const onlineSeriesIds = new Set(onlineSeries.map(s => s.id)); + const uniqueLocalSeries = localSeries.filter(s => !onlineSeriesIds.has(s.id)); + seriesResponse = [...mergedOnlineSeries, ...uniqueLocalSeries]; + } else { + // OFFLINE : local seulement if (!offlineMode.isDatabaseInitialized) { setIsLoadingBooks(false); return; } - const localBooks: BookProps[] = await window.electron.invoke('db:book:books'); - bookResponse = localBooks.map((book: BookProps): BookProps & { itIsLocal: boolean } => ({ ...book, itIsLocal: true })); + const [localBooks, localSeries] = await Promise.all([ + window.electron.invoke('db:book:books'), + window.electron.invoke('db:series:list') + ]); + booksResponse = localBooks.map(b => ({...b, itIsLocal: true})); + seriesResponse = localSeries; } - console.log(bookResponse); - if (bookResponse) { - const booksByType: Record = bookResponse.reduce((groups: Record, book: BookProps): Record => { - const imageDataUrl: string = book.coverImage ? 'data:image/jpeg;base64,' + book.coverImage : ''; - const categoryLabel: string = Book.getBookTypeLabel(book.type); - const transformedBook: BookProps = { - bookId: book.bookId, - type: categoryLabel, - title: book.title, - subTitle: book.subTitle, - summary: book.summary, - serie: book.serie, - publicationDate: book.publicationDate, - desiredWordCount: book.desiredWordCount, - totalWordCount: 0, - coverImage: imageDataUrl, + + // ═══════════════════════════════════════════════════════════════ + // PARTIE 2 : CRÉATION DU MAPPING BOOK → SERIES + // ═══════════════════════════════════════════════════════════════ + + const bookToSeriesMap: Map = new Map(); + seriesResponse.forEach((series: SeriesListItemProps): void => { + series.bookIds.forEach((bookId: string): void => { + bookToSeriesMap.set(bookId, series); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // PARTIE 3 : TRANSFORMATION DES LIVRES + // ═══════════════════════════════════════════════════════════════ + + const transformedBooks: (BookProps & { itIsLocal?: boolean })[] = booksResponse.map(book => { + const imageDataUrl = book.coverImage ? 'data:image/jpeg;base64,' + book.coverImage : ''; + return { + bookId: book.bookId, + type: Book.getBookTypeLabel(book.type), + title: book.title, + subTitle: book.subTitle, + summary: book.summary, + serie: book.serie, + publicationDate: book.publicationDate, + desiredWordCount: book.desiredWordCount, + totalWordCount: 0, + coverImage: imageDataUrl, + itIsLocal: book.itIsLocal + }; + }); + + // ═══════════════════════════════════════════════════════════════ + // PARTIE 4 : GROUPEMENT PAR CATÉGORIE AVEC SÉRIES + // ═══════════════════════════════════════════════════════════════ + + const itemsByCategory: Record = {}; + const processedSeriesIds: Set = new Set(); + + transformedBooks.forEach((book): void => { + const categoryLabel: string = t(book.type); + if (!itemsByCategory[categoryLabel]) { + itemsByCategory[categoryLabel] = []; + } + + const seriesInfo = bookToSeriesMap.get(book.bookId); + + if (seriesInfo && !processedSeriesIds.has(seriesInfo.id)) { + // 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 + const seriesBooks: BookProps[] = transformedBooks.filter( + b => seriesInfo.bookIds.includes(b.bookId) + ); + + const seriesCard: SeriesCardProps = { + id: seriesInfo.id, + name: seriesInfo.name, + coverImage: seriesInfo.coverImage, + books: seriesBooks }; - if (!groups[t(categoryLabel)]) { - groups[t(categoryLabel)] = []; + + itemsByCategory[categoryLabel].push({ + type: 'series', + series: seriesCard + }); + } else if (!seriesInfo) { + // Livre individuel (pas dans une série) + itemsByCategory[categoryLabel].push({ + type: 'book', + book: book + }); + } + }); + + // Ajouter les séries vides (orphelines) + seriesResponse.forEach((series): void => { + if (series.bookIds.length === 0) { + const emptySeriesCategory = t('bookList.emptySeries'); + if (!itemsByCategory[emptySeriesCategory]) { + itemsByCategory[emptySeriesCategory] = []; } - groups[t(categoryLabel)].push(transformedBook); - return groups; - }, {}); - setGroupedBooks(booksByType); - } - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(e.message); + itemsByCategory[emptySeriesCategory].push({ + type: 'series', + series: { + id: series.id, + name: series.name, + coverImage: series.coverImage, + books: [] + } + }); + } + }); + + setGroupedItems(itemsByCategory); + + } catch (error: unknown) { + if (error instanceof Error) { + errorMessage(error.message); } else { errorMessage(t("bookList.errorBooksFetch")); } @@ -196,90 +334,128 @@ export default function BookList() { setIsLoadingBooks(false); } } - - const filteredGroupedBooks: Record = Object.entries(groupedBooks).reduce( - (acc: Record, [category, books]: [string, BookProps[]]): Record => { - const filteredBooks: BookProps[] = books.filter((book: BookProps): boolean => - book.title.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - if (filteredBooks.length > 0) { - acc[category] = filteredBooks; + + function getFilteredGroupedItems(): Record { + if (!searchQuery) { + return groupedItems; + } + + const filtered: Record = {}; + + Object.entries(groupedItems).forEach(([category, items]) => { + const filteredItems = items.filter((item): boolean => { + if (item.type === 'book' && item.book) { + return item.book.title.toLowerCase().includes(searchQuery.toLowerCase()); + } + if (item.type === 'series' && item.series) { + const matchesSeriesName = item.series.name.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesBookTitle = item.series.books.some( + book => book.title.toLowerCase().includes(searchQuery.toLowerCase()) + ); + return matchesSeriesName || matchesBookTitle; + } + return false; + }); + + if (filteredItems.length > 0) { + filtered[category] = filteredItems; } - return acc; - }, - {} - ); - - function detectBookSyncStatus(bookId: string):SyncType { - if (serverOnlyBooks.find((book: SyncedBook):boolean => book.id === bookId)) { - return 'server-only'; - } - if (localOnlyBooks.find((book: SyncedBook):boolean => book.id === bookId)) { - return 'local-only'; - } - if (booksToSyncFromServer.find((book: BookSyncCompare):boolean => book.id === bookId)) { - return 'to-sync-from-server'; - } - if (booksToSyncToServer.find((book: BookSyncCompare):boolean => book.id === bookId)) { - return 'to-sync-to-server'; - } + }); + + return filtered; + } + + function getTotalItemsCount(items: CategoryItem[]): number { + return items.reduce((count, item) => { + if (item.type === 'book') return count + 1; + if (item.type === 'series' && item.series) return count + item.series.books.length; + return count; + }, 0); + } + + function detectBookSyncStatus(bookId: string): SyncType { + if (serverOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId)) return 'server-only'; + if (localOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId)) return 'local-only'; + if (booksToSyncFromServer.find((book: BookSyncCompare): boolean => book.id === bookId)) return 'to-sync-from-server'; + if (booksToSyncToServer.find((book: BookSyncCompare): boolean => book.id === bookId)) return 'to-sync-to-server'; return 'synced'; } - - async function getBook(bookId: string): Promise { + + function detectSeriesSyncStatus(seriesId: string): SeriesSyncType { + if (serverOnlySeries.find((series: SyncedSeries): boolean => series.id === seriesId)) return 'server-only'; + if (localOnlySeries.find((series: SyncedSeries): boolean => series.id === seriesId)) return 'local-only'; + if (seriesToSyncFromServer.find((series: SeriesSyncCompare): boolean => series.id === seriesId)) return 'to-sync-from-server'; + if (seriesToSyncToServer.find((series: SeriesSyncCompare): boolean => series.id === seriesId)) return 'to-sync-to-server'; + return 'synced'; + } + + async function handleBookClick(bookId: string): Promise { try { let localBookOnly: boolean = false; - let bookResponse: BookProps|null = null; - if (isCurrentlyOffline()){ + let bookResponse: BookProps | null = null; + + // DUAL LOGIC + if (isCurrentlyOffline()) { if (!offlineMode.isDatabaseInitialized) { errorMessage(t("bookList.errorBookDetails")); return; } - bookResponse = await window.electron.invoke('db:book:bookBasicInformation', bookId) - if (bookResponse) { - localBookOnly = true; - } + bookResponse = await window.electron.invoke('db:book:bookBasicInformation', bookId); + if (bookResponse) localBookOnly = true; } else { - const isOfflineBook: SyncedBook | undefined = localOnlyBooks.find((book: SyncedBook):boolean => book.id === bookId); + const isOfflineBook = localOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId); if (isOfflineBook) { - bookResponse = await window.electron.invoke('db:book:bookBasicInformation', bookId) + bookResponse = await window.electron.invoke('db:book:bookBasicInformation', bookId); localBookOnly = true; } if (!bookResponse) { - bookResponse = await System.authGetQueryToServer(`book/basic-information`, accessToken, lang, {id: bookId}); + bookResponse = await System.authGetQueryToServer( + 'book/basic-information', accessToken, lang, {id: bookId} + ); } } + if (!bookResponse) { errorMessage(t("bookList.errorBookDetails")); return; } + if (setBook) { setBook({ bookId: bookId, - title: bookResponse?.title || '', - subTitle: bookResponse?.subTitle || '', - summary: bookResponse?.summary || '', - type: bookResponse?.type || '', - serie: bookResponse?.serie, - publicationDate: bookResponse?.publicationDate || '', - desiredWordCount: bookResponse?.desiredWordCount || 0, + title: bookResponse.title || '', + subTitle: bookResponse.subTitle || '', + summary: bookResponse.summary || '', + type: bookResponse.type || '', + serie: bookResponse.serie, + seriesId: bookResponse.seriesId, + publicationDate: bookResponse.publicationDate || '', + desiredWordCount: bookResponse.desiredWordCount || 0, totalWordCount: 0, localBook: localBookOnly, - coverImage: bookResponse?.coverImage ? 'data:image/jpeg;base64,' + bookResponse.coverImage : '', - quillsenseEnabled: bookResponse?.quillsenseEnabled, - tools: bookResponse?.tools, + coverImage: bookResponse.coverImage ? 'data:image/jpeg;base64,' + bookResponse.coverImage : '', + quillsenseEnabled: bookResponse.quillsenseEnabled, + tools: bookResponse.tools, }); } - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(e.message); + } catch (error: unknown) { + if (error instanceof Error) { + errorMessage(error.message); } else { errorMessage(t("bookList.errorUnknown")); } } } - + + function handleSeriesSettingsClick(seriesId: string): void { + const isLocal: boolean = isCurrentlyOffline() || + Boolean(localOnlySeries.find((s: SyncedSeries): boolean => s.id === seriesId)); + setIsLocalSeries(isLocal); + setShowSeriesSettingId(seriesId); + } + + const filteredItems = getFilteredGroupedItems(); + return (
@@ -302,7 +478,7 @@ export default function BookList() {
- +
{Array.from({length: 6}).map((_, id: number) => (
- ) : Object.entries(filteredGroupedBooks).length > 0 ? ( + ) : Object.entries(filteredItems).length > 0 ? ( <>

{t("bookList.library")}

{t("bookList.booksAreMirrors")}

- - {Object.entries(filteredGroupedBooks).map(([category, books], index) => ( -
( +

- {category} + {category}

{books.length} {t("bookList.works")} + className="text-muted text-lg font-medium bg-secondary/30 px-4 py-1.5 rounded-full"> + {getTotalItemsCount(items)} {t("bookList.works")} +
- -
- { - books.map((book: BookProps, idx) => ( -
- + {items.map((item, idx) => { + if (item.type === 'book' && item.book) { + return ( +
+ +
+ ); + } + if (item.type === 'series' && item.series) { + return ( + -
- )) - } + ); + } + return null; + })}
))} @@ -354,8 +548,9 @@ export default function BookList() { ) : (
-
- +
+

{t("bookList.welcomeWritingWorkshop")}

@@ -369,6 +564,16 @@ export default function BookList() { bookGuide && setBookGuide(false)}/> } + {showSeriesSettingId && ( + { + setShowSeriesSettingId(null); + setIsLocalSeries(false); + }} + /> + )}

); -} \ No newline at end of file +} diff --git a/components/book/settings/BookSetting.tsx b/components/book/settings/BookSetting.tsx index 74db62d..e8e3104 100644 --- a/components/book/settings/BookSetting.tsx +++ b/components/book/settings/BookSetting.tsx @@ -1,18 +1,25 @@ +'use client' import {useState} from "react"; import BookSettingSidebar from "@/components/book/settings/BookSettingSidebar"; import BookSettingOption from "@/components/book/settings/BookSettingOption"; +import SettingsPanel from "@/components/SettingsPanel"; +import {useTranslations} from "next-intl"; -export default function BookSetting() { - const [currentSetting, setCurrentSetting] = useState('basic-information') - return ( -
-
- -
-
- -
-
- ) +interface BookSettingProps { + onClose: () => void; +} + +export default function BookSetting({onClose}: BookSettingProps) { + const t = useTranslations(); + const [currentSetting, setCurrentSetting] = useState('basic-information'); + + return ( + } + onClose={onClose} + > + + + ); } diff --git a/components/book/settings/BookSettingOption.tsx b/components/book/settings/BookSettingOption.tsx index 36971a7..490de95 100644 --- a/components/book/settings/BookSettingOption.tsx +++ b/components/book/settings/BookSettingOption.tsx @@ -1,48 +1,63 @@ -import BasicInformationSetting from "./BasicInformationSetting"; -import GuideLineSetting from "./guide-line/GuideLineSetting"; -import StorySetting from "./story/StorySetting"; -import WorldSetting from "@/components/book/settings/world/WorldSetting"; -import {faPen, faSave} from "@fortawesome/free-solid-svg-icons"; -import {RefObject, useRef} from "react"; -import PanelHeader from "@/components/PanelHeader"; -import LocationComponent from "@/components/book/settings/locations/LocationComponent"; -import CharacterComponent from "@/components/book/settings/characters/CharacterComponent"; -import SpellComponent from "@/components/book/settings/spells/SpellComponent"; -import QuillSenseSetting from "@/components/book/settings/quillsense/QuillSenseSetting"; -import {useTranslations} from "next-intl"; // Ajouté pour la traduction +'use client' +import React, {lazy, Suspense, useRef} from 'react'; +import {faPen, faSave} from '@fortawesome/free-solid-svg-icons'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faSpinner} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; +import PanelHeader from '@/components/PanelHeader'; -export default function BookSettingOption( - { - setting, - }: { - setting: string; - }) { - const t = useTranslations(); // Ajouté pour la traduction - - const basicInfoRef: RefObject<{ handleSave: () => Promise } | null> = useRef<{ - handleSave: () => Promise - }>(null); - const guideLineRef: RefObject<{ handleSave: () => Promise } | null> = useRef<{ - handleSave: () => Promise - }>(null); - const storyRef: RefObject<{ handleSave: () => Promise } | null> = useRef<{ - handleSave: () => Promise - }>(null); - const worldRef: RefObject<{ handleSave: () => Promise } | null> = useRef<{ - handleSave: () => Promise - }>(null); - const locationRef: RefObject<{ handleSave: () => Promise } | null> = useRef<{ - handleSave: () => Promise - }>(null); - const characterRef: RefObject<{ handleSave: () => Promise } | null> = useRef<{ - handleSave: () => Promise - }>(null); - const spellRef: RefObject<{ handleSave: () => Promise } | null> = useRef<{ - handleSave: () => Promise - }>(null); - const quillSenseRef: RefObject<{ handleSave: () => Promise } | null> = useRef<{ - handleSave: () => Promise - }>(null); +// Lazy loaded components - avec ref (anciens) +const BasicInformationSetting = lazy(function () { + return import('./BasicInformationSetting'); +}); +const GuideLineSetting = lazy(function () { + return import('./guide-line/GuideLineSetting'); +}); +const StorySetting = lazy(function () { + return import('./story/StorySetting'); +}); +const QuillSenseSetting = lazy(function () { + return import('./quillsense/QuillSenseSetting'); +}); + +// Lazy loaded components - sans ref (nouveaux avec leur propre header) +const WorldSettings = lazy(function () { + return import('./world/settings/WorldSettings'); +}); +const LocationSettings = lazy(function () { + return import('./locations/settings/LocationSettings'); +}); +const CharacterSettings = lazy(function () { + return import('./characters/settings/CharacterSettings'); +}); +const SpellSettings = lazy(function () { + return import('./spells/settings/SpellSettings'); +}); + +function LoadingSpinner(): React.JSX.Element { + return ( +
+ +
+ ); +} + +interface BookSettingOptionProps { + setting: string; +} + +interface SettingRef { + handleSave: () => Promise; +} + +// Settings qui gèrent leur propre save (pas de bouton save parent) +const selfManagedSettings: string[] = ['characters', 'spells', 'world', 'worlds', 'locations']; + +export default function BookSettingOption({setting}: BookSettingOptionProps): React.JSX.Element { + const t = useTranslations(); + const settingRef = useRef(null); + + const showSaveButton: boolean = !selfManagedSettings.includes(setting); function renderTitle(): string { switch (setting) { @@ -52,6 +67,8 @@ export default function BookSettingOption( return t("bookSettingOption.guideLine"); case 'story': return t("bookSettingOption.storyPlan"); + case 'quillsense': + return t("bookSettingOption.quillsense"); case 'world': return t("bookSettingOption.manageWorlds"); case 'locations': @@ -60,81 +77,55 @@ export default function BookSettingOption( return t("bookSettingOption.characters"); case 'spells': return t("bookSettingOption.spells"); - case 'objects': - return t("bookSettingOption.objectsList"); - case 'goals': - return t("bookSettingOption.bookGoals"); - case 'quillsense': - return t("bookSettingOption.quillsense"); default: return ""; } } - + async function handleSaveClick(): Promise { - switch (setting) { - case 'basic-information': - basicInfoRef.current?.handleSave(); - break; - case 'guide-line': - guideLineRef.current?.handleSave(); - break; - case 'story': - storyRef.current?.handleSave(); - break; - case 'world': - worldRef.current?.handleSave(); - break; - case 'locations': - locationRef.current?.handleSave(); - break; - case 'characters': - characterRef.current?.handleSave(); - break; - case 'spells': - spellRef.current?.handleSave(); - break; - case 'quillsense': - quillSenseRef.current?.handleSave(); - break; - default: - break; + if (settingRef.current?.handleSave) { + await settingRef.current.handleSave(); } } - + return ( -
- -
- { - setting === 'basic-information' ? ( - - ) : setting === 'guide-line' ? ( - - ) : setting === 'story' ? ( - - ) : setting === 'world' ? ( - - ) : setting === 'locations' ? ( - - ) : setting === 'characters' ? ( - - ) : setting === 'spells' ? ( - - ) : setting === 'quillsense' ? ( - - ) :
{t("bookSettingOption.notAvailable")}
- } +
+
+ +
+
+ }> + {setting === 'basic-information' && } + {setting === 'guide-line' && } + {setting === 'story' && } + {setting === 'quillsense' && } + {(setting === 'world' || setting === 'worlds') && ( + + )} + {setting === 'locations' && ( + + )} + {setting === 'characters' && ( + + )} + {setting === 'spells' && ( + + )} + {!['basic-information', 'guide-line', 'story', 'world', 'worlds', 'locations', 'characters', 'spells', 'quillsense'].includes(setting) && ( +
+ {t("bookSettingOption.notAvailable")} +
+ )} +
- ) -} \ No newline at end of file + ); +} diff --git a/components/book/settings/BookSettingSidebar.tsx b/components/book/settings/BookSettingSidebar.tsx index e45c176..823ea49 100644 --- a/components/book/settings/BookSettingSidebar.tsx +++ b/components/book/settings/BookSettingSidebar.tsx @@ -11,9 +11,10 @@ import { faUser, faWandMagicSparkles } from "@fortawesome/free-solid-svg-icons"; -import {Dispatch, SetStateAction} from "react"; +import {Dispatch, SetStateAction, useContext} from "react"; import {IconDefinition} from "@fortawesome/fontawesome-svg-core"; import {useTranslations} from "next-intl"; +import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; interface BookSettingOption { id: string; @@ -30,7 +31,8 @@ export default function BookSettingSidebar( setSelectedSetting: Dispatch> }) { const t = useTranslations(); - + const {isCurrentlyOffline} = useContext(OfflineContext); + const settings: BookSettingOption[] = [ { id: 'basic-information', @@ -84,11 +86,16 @@ export default function BookSettingSidebar( // }, ] + // Filter out QuillSense when offline (requires server connection) + const availableSettings: BookSettingOption[] = isCurrentlyOffline() + ? settings.filter((s: BookSettingOption) => s.id !== 'quillsense') + : settings; + return (
- ) -} \ No newline at end of file diff --git a/components/book/settings/characters/editor/CharacterEditor.tsx b/components/book/settings/characters/editor/CharacterEditor.tsx new file mode 100644 index 0000000..97b453f --- /dev/null +++ b/components/book/settings/characters/editor/CharacterEditor.tsx @@ -0,0 +1,206 @@ +'use client'; +import React, {useCallback, useContext, useMemo, useState} from 'react'; +import {useCharacters, UseCharactersConfig} from '@/hooks/settings/useCharacters'; +import {useTranslations} from 'next-intl'; +import {CharacterProps} from '@/lib/models/Character'; +import {SeriesCharacterProps} from '@/lib/models/Series'; +import {BookContext} from '@/context/BookContext'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons'; + +import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader'; +import InputField from '@/components/form/InputField'; +import ToggleSwitch from '@/components/form/ToggleSwitch'; +import SeriesImportSelector from '@/components/form/SeriesImportSelector'; +import AlertBox from '@/components/AlertBox'; + +import CharacterEditorList from './CharacterEditorList'; +import CharacterEditorDetail from './CharacterEditorDetail'; +import CharacterEditorEdit from './CharacterEditorEdit'; + +/** + * CharacterEditor - Orchestrateur pour ComposerRightBar + * Mêmes fonctionnalités que CharacterSettings, layout condensé + */ +export default function CharacterEditor(): React.JSX.Element { + const t = useTranslations(); + const {book} = useContext(BookContext); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const config: UseCharactersConfig = useMemo(function (): UseCharactersConfig { + return { + entityType: 'book', + entityId: book?.bookId || '', + }; + }, [book?.bookId]); + + const { + characters, + seriesCharacters, + selectedCharacter, + toolEnabled, + isLoading, + bookSeriesId, + viewMode, + saveCharacter, + deleteCharacter, + updateCharacterField, + addAttribute, + removeAttribute, + toggleTool, + importFromSeries, + exportToSeries, + refreshSeriesCharacters, + setSelectedCharacter, + enterDetailMode, + enterEditMode, + exitEditMode, + backToList, + addNewCharacter, + } = useCharacters(config); + + const availableSeriesCharacters = useMemo(function (): SeriesCharacterProps[] { + return seriesCharacters.filter(function (sc: SeriesCharacterProps): boolean { + return !characters.some(function (c: CharacterProps): boolean { + return c.seriesCharacterId === sc.id; + }); + }); + }, [seriesCharacters, characters]); + + const handleCharacterChange = useCallback(function (key: keyof CharacterProps, value: string | number | null): void { + updateCharacterField(key, value); + }, [updateCharacterField]); + + async function handleSave(): Promise { + await exitEditMode(true); + } + + function handleCancel(): void { + exitEditMode(false); + } + + async function handleDelete(): Promise { + if (selectedCharacter?.id) { + await deleteCharacter(selectedCharacter.id); + setShowDeleteConfirm(false); + backToList(); + } + } + + function getSeriesCharacterForSelected(): SeriesCharacterProps | null { + if (!selectedCharacter?.seriesCharacterId) return null; + return seriesCharacters.find(function (sc: SeriesCharacterProps): boolean { + return sc.id === selectedCharacter.seriesCharacterId; + }) || null; + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + const isNew: boolean = selectedCharacter?.id === null; + const canExport: boolean = Boolean(bookSeriesId && selectedCharacter?.id && !selectedCharacter.seriesCharacterId); + + return ( +
+ + +
+ {viewMode === 'list' && ( +
+ {/* Toggle tool */} +
+ + } + /> +
+ + {toolEnabled && ( + <> + {/* Import from series */} + {bookSeriesId && availableSeriesCharacters.length > 0 && ( + + )} + + + + )} +
+ )} + + {viewMode === 'detail' && selectedCharacter && ( +
+ +
+ )} + + {viewMode === 'edit' && selectedCharacter && ( +
+ +
+ )} +
+ + {showDeleteConfirm && selectedCharacter?.id && ( + + )} +
+ ); +} diff --git a/components/book/settings/characters/editor/CharacterEditorDetail.tsx b/components/book/settings/characters/editor/CharacterEditorDetail.tsx new file mode 100644 index 0000000..23cf46a --- /dev/null +++ b/components/book/settings/characters/editor/CharacterEditorDetail.tsx @@ -0,0 +1,121 @@ +'use client'; +import React, {useContext, useEffect} from 'react'; +import { + Attribute, + CharacterAttribute, + characterCategories, + CharacterProps +} from '@/lib/models/Character'; +import {SeriesCharacterProps} from '@/lib/models/Series'; +import {useTranslations} from 'next-intl'; +import {SessionContext} from '@/context/SessionContext'; +import {AlertContext} from '@/context/AlertContext'; +import {LangContext} from '@/context/LangContext'; +import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; +import {BookContext} from '@/context/BookContext'; +import System from '@/lib/models/System'; + +type AttributeResponse = { type: string; values: Attribute[] }[]; + +interface CharacterEditorDetailProps { + character: CharacterProps; + seriesCharacter?: SeriesCharacterProps | null; + onLoadAttributes?: (attributes: CharacterAttribute) => void; +} + +/** + * CharacterEditorDetail - Version sidebar lecture seule + * Layout linéaire simple, juste les infos essentielles empilées + * PAS de CollapsableArea, PAS de grids + */ +export default function CharacterEditorDetail({ + character, + seriesCharacter, + onLoadAttributes, +}: CharacterEditorDetailProps): React.JSX.Element { + const t = useTranslations(); + const {lang} = useContext(LangContext); + const {session} = useContext(SessionContext); + const {errorMessage} = useContext(AlertContext); + const {isCurrentlyOffline} = useContext(OfflineContext); + const {book} = useContext(BookContext); + + useEffect(function (): void { + if (character?.id !== null) { + getAttributes().then(); + } + }, [character?.id]); + + async function getAttributes(): Promise { + try { + let response: AttributeResponse; + if (isCurrentlyOffline()) { + response = await window.electron.invoke('db:character:attributes', {characterId: character?.id}); + } else if (book?.localBook) { + response = await window.electron.invoke('db:character:attributes', {characterId: character?.id}); + } else { + response = await System.authGetQueryToServer( + 'character/attribute', + session.accessToken, + lang, + {characterId: character?.id} + ); + } + if (response && onLoadAttributes) { + const attributes: CharacterAttribute = {}; + response.forEach(function (item: { type: string; values: Attribute[] }): void { + attributes[item.type] = item.values; + }); + onLoadAttributes(attributes); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + } + + function renderField(label: string, value: string | number | null | undefined): React.JSX.Element | null { + if (!value) return null; + return ( +
+ {label} +

{value}

+
+ ); + } + + function getCategoryLabel(category: string | null | undefined): string { + if (!category) return ''; + const found = characterCategories.find(function (c): boolean { return c.value === category; }); + return found ? t(found.label) : category; + } + + return ( +
+ {/* Image du personnage - version compacte */} + {character.image && ( +
+
+ {character.name} +
+
+ )} + +

+ {character.name} {character.lastName} +

+ + {renderField(t('characterDetail.role'), getCategoryLabel(character.category))} + {renderField(t('characterDetail.title'), character.title)} + {renderField(t('characterDetail.gender'), character.gender)} + {renderField(t('characterDetail.age'), character.age)} + {renderField(t('characterDetail.biography'), character.biography)} + {renderField(t('characterDetail.roleFull'), character.role)} +
+ ); +} diff --git a/components/book/settings/characters/editor/CharacterEditorEdit.tsx b/components/book/settings/characters/editor/CharacterEditorEdit.tsx new file mode 100644 index 0000000..3558040 --- /dev/null +++ b/components/book/settings/characters/editor/CharacterEditorEdit.tsx @@ -0,0 +1,395 @@ +'use client'; +import React, {useContext, useEffect, useMemo, useState} from 'react'; +import { + advancedCharacterElements, + Attribute, + basicCharacterElements, + CharacterAttribute, + characterCategories, + CharacterElement, + CharacterProps, + characterStatus +} from '@/lib/models/Character'; +import {SeriesCharacterProps} from '@/lib/models/Series'; +import InputField from '@/components/form/InputField'; +import TextInput from '@/components/form/TextInput'; +import TexteAreaInput from '@/components/form/TexteAreaInput'; +import NumberInput from '@/components/form/NumberInput'; +import SelectBox from '@/components/form/SelectBox'; +import CharacterSectionElement from '@/components/book/settings/characters/CharacterSectionElement'; +import SyncFieldWrapper from '@/components/form/SyncFieldWrapper'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faSliders} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; +import {SessionContext} from '@/context/SessionContext'; +import {AlertContext} from '@/context/AlertContext'; +import {LangContext} from '@/context/LangContext'; +import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; +import {BookContext} from '@/context/BookContext'; +import System from '@/lib/models/System'; +import {Dispatch, SetStateAction} from 'react'; + +type AttributeResponse = { type: string; values: Attribute[] }[]; + +interface CharacterEditorEditProps { + character: CharacterProps; + setCharacter: Dispatch>; + onCharacterChange: (key: keyof CharacterProps, value: string | number | null) => void; + onAddAttribute: (section: keyof CharacterProps, attr: Attribute) => Promise; + onRemoveAttribute: (section: keyof CharacterProps, idx: number, id: string) => Promise; + seriesCharacter?: SeriesCharacterProps | null; + onSyncComplete?: () => void; +} + +/** + * CharacterEditorEdit - Version sidebar édition + * Mêmes fonctionnalités que CharacterSettingsEdit, layout linéaire + */ +export default function CharacterEditorEdit({ + character, + setCharacter, + onCharacterChange, + onAddAttribute, + onRemoveAttribute, + seriesCharacter, + onSyncComplete, +}: CharacterEditorEditProps): React.JSX.Element { + const t = useTranslations(); + const {lang} = useContext(LangContext); + const {session} = useContext(SessionContext); + const {errorMessage} = useContext(AlertContext); + const {isCurrentlyOffline} = useContext(OfflineContext); + const {book} = useContext(BookContext); + const [showAdvanced, setShowAdvanced] = useState(false); + + // Traduire les données des SelectBox + const translatedCharacterCategories = useMemo(() => + characterCategories.map((item) => ({ + ...item, + label: t(item.label) + })), [t]); + + const translatedCharacterStatus = useMemo(() => + characterStatus.map((item) => ({ + ...item, + label: t(item.label) + })), [t]); + + useEffect(function (): void { + if (character?.id !== null) { + getAttributes().then(); + } + }, [character?.id]); + + async function getAttributes(): Promise { + try { + let response: AttributeResponse; + if (isCurrentlyOffline()) { + response = await window.electron.invoke('db:character:attributes', {characterId: character?.id}); + } else if (book?.localBook) { + response = await window.electron.invoke('db:character:attributes', {characterId: character?.id}); + } else { + response = await System.authGetQueryToServer( + 'character/attribute', + session.accessToken, + lang, + {characterId: character?.id} + ); + } + if (response) { + const attributes: CharacterAttribute = {}; + response.forEach(function (item: { type: string; values: Attribute[] }): void { + attributes[item.type] = item.values; + }); + setCharacter(function (prev: CharacterProps | null): CharacterProps | null { + if (!prev) return null; + return { + ...prev, + physical: attributes.physical ?? [], + psychological: attributes.psychological ?? [], + relations: attributes.relations ?? [], + skills: attributes.skills ?? [], + weaknesses: attributes.weaknesses ?? [], + strengths: attributes.strengths ?? [], + goals: attributes.goals ?? [], + motivations: attributes.motivations ?? [], + arc: attributes.arc ?? [], + secrets: attributes.secrets ?? [], + fears: attributes.fears ?? [], + flaws: attributes.flaws ?? [], + beliefs: attributes.beliefs ?? [], + conflicts: attributes.conflicts ?? [], + quotes: attributes.quotes ?? [], + distinguishingMarks: attributes.distinguishingMarks ?? [], + items: attributes.items ?? [], + affiliations: attributes.affiliations ?? [], + }; + }); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + } + + return ( +
+ {/* Informations de base */} +
+

{t('characterDetail.basicInfo')}

+
+ + ): void { + onCharacterChange('name', e.target.value); + }} + placeholder={t('characterDetail.namePlaceholder')} + /> + + } + /> + + + ): void { + onCharacterChange('lastName', e.target.value); + }} + placeholder={t('characterDetail.lastNamePlaceholder')} + /> + + } + /> + + ): void { + setCharacter(function (prev: CharacterProps | null): CharacterProps | null { + return prev ? {...prev, category: e.target.value as CharacterProps['category']} : prev; + }); + }} + data={translatedCharacterCategories} + /> + } + /> + + ): void { + onCharacterChange('gender', e.target.value); + }} + placeholder={t('characterDetail.genderPlaceholder')} + /> + } + /> + + + } + /> +
+
+ + {/* Histoire */} +
+

{t('characterDetail.historySection')}

+
+ + ): void { + onCharacterChange('biography', e.target.value); + }} + placeholder={t('characterDetail.biographyPlaceholder')} + /> + + } + /> + + + ): void { + onCharacterChange('role', e.target.value); + }} + placeholder={t('characterDetail.roleFullPlaceholder')} + /> + + } + /> +
+
+ + {/* Attributs de base */} + {basicCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element { + return ( + + ); + })} + + {/* Toggle Mode Avancé */} +
+
+ + {t('characterDetail.advancedMode')} +
+ +
+ + {/* Sections avancées */} + {showAdvanced && ( + <> +
+

{t('characterDetail.identitySection')}

+
+ ): void { + onCharacterChange('species', e.target.value); + }} + placeholder={t('characterDetail.speciesPlaceholder')} + /> + } + /> + + ): void { + setCharacter(function (prev: CharacterProps | null): CharacterProps | null { + return prev ? {...prev, status: e.target.value as CharacterProps['status']} : prev; + }); + }} + data={translatedCharacterStatus} + /> + } + /> +
+
+ +
+

{t('characterDetail.authorSection')}

+ ): void { + onCharacterChange('notes', e.target.value); + }} + placeholder={t('characterDetail.notesPlaceholder')} + /> + } + /> +
+ + {advancedCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element { + return ( + + ); + })} + + )} +
+ ); +} diff --git a/components/book/settings/characters/editor/CharacterEditorList.tsx b/components/book/settings/characters/editor/CharacterEditorList.tsx new file mode 100644 index 0000000..011344f --- /dev/null +++ b/components/book/settings/characters/editor/CharacterEditorList.tsx @@ -0,0 +1,116 @@ +'use client'; +import React, {useState} from 'react'; +import {CharacterProps} from '@/lib/models/Character'; +import InputField from '@/components/form/InputField'; +import TextInput from '@/components/form/TextInput'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faChevronRight, faPlus, faUser} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; + +interface CharacterEditorListProps { + characters: CharacterProps[]; + onCharacterClick: (character: CharacterProps) => void; + onAddCharacter: () => void; +} + +/** + * CharacterEditorList - Liste des personnages pour ComposerRightBar + * Version compacte sans groupage par catégorie + * PAS de scroll interne (géré par parent ComposerRightBar) + */ +export default function CharacterEditorList({ + characters, + onCharacterClick, + onAddCharacter, +}: CharacterEditorListProps): React.JSX.Element { + const t = useTranslations(); + const [searchQuery, setSearchQuery] = useState(''); + + function getFilteredCharacters(): CharacterProps[] { + return characters.filter(function (char: CharacterProps): boolean { + return char.name.toLowerCase().includes(searchQuery.toLowerCase()) || + (char.lastName?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false); + }); + } + + const filteredCharacters: CharacterProps[] = getFilteredCharacters(); + + return ( +
+
+ ): void { + setSearchQuery(e.target.value); + }} + placeholder={t('characterList.search')} + /> + } + actionIcon={faPlus} + actionLabel={t('characterList.add')} + addButtonCallBack={async function (): Promise { + onAddCharacter(); + }} + /> +
+ +
+ {filteredCharacters.length === 0 ? ( +
+
+ +
+

+ {t('characterList.noCharacters')} +

+

+ {t('characterList.noCharactersDescription')} +

+
+ ) : ( + filteredCharacters.map(function (char: CharacterProps): React.JSX.Element { + return ( +
+
+ {char.image ? ( + {char.name} + ) : ( +
+ {char.name?.charAt(0)?.toUpperCase() || '?'} +
+ )} +
+ +
+
+ {char.name || t('characterList.unknown')} +
+
+ {char.title || char.role || t('characterList.noRole')} +
+
+ +
+ +
+
+ ); + }) + )} +
+
+ ); +} diff --git a/components/book/settings/characters/settings/CharacterSettings.tsx b/components/book/settings/characters/settings/CharacterSettings.tsx new file mode 100644 index 0000000..a3884fb --- /dev/null +++ b/components/book/settings/characters/settings/CharacterSettings.tsx @@ -0,0 +1,230 @@ +'use client'; +import React, {useCallback, useContext, useMemo, useState} from 'react'; +import {useCharacters, UseCharactersConfig} from '@/hooks/settings/useCharacters'; +import {useTranslations} from 'next-intl'; +import {CharacterProps} from '@/lib/models/Character'; +import {SeriesCharacterProps} from '@/lib/models/Series'; +import {BookContext} from '@/context/BookContext'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons'; + +import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader'; +import InputField from '@/components/form/InputField'; +import ToggleSwitch from '@/components/form/ToggleSwitch'; +import SeriesImportSelector from '@/components/form/SeriesImportSelector'; +import AlertBox from '@/components/AlertBox'; + +import CharacterSettingsList from './CharacterSettingsList'; +import CharacterSettingsDetail from './CharacterSettingsDetail'; +import CharacterSettingsEdit from './CharacterSettingsEdit'; + +interface CharacterSettingsProps { + entityType?: 'book' | 'series'; + entityId?: string; + showToggle?: boolean; +} + +/** + * CharacterSettings - Orchestrateur pour BookSetting/SerieSetting + * Gère le viewMode (list/detail/edit) et coordonne les sous-composants + * Inclut: toggle tool, import from series, header avec actions + */ +export default function CharacterSettings({ + entityType = 'book', + entityId, + showToggle = true, +}: CharacterSettingsProps): React.JSX.Element { + const t = useTranslations(); + const {book} = useContext(BookContext); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const resolvedEntityId: string = entityId || book?.bookId || ''; + + const config: UseCharactersConfig = useMemo(function (): UseCharactersConfig { + return { + entityType, + entityId: resolvedEntityId, + }; + }, [entityType, resolvedEntityId]); + + const { + characters, + seriesCharacters, + selectedCharacter, + toolEnabled, + isLoading, + isSeriesMode, + bookSeriesId, + viewMode, + saveCharacter, + deleteCharacter, + updateCharacterField, + addAttribute, + removeAttribute, + toggleTool, + importFromSeries, + exportToSeries, + refreshSeriesCharacters, + setSelectedCharacter, + enterDetailMode, + enterEditMode, + exitEditMode, + backToList, + addNewCharacter, + } = useCharacters(config); + + const availableSeriesCharacters = useMemo(function (): SeriesCharacterProps[] { + return seriesCharacters.filter(function (sc: SeriesCharacterProps): boolean { + return !characters.some(function (c: CharacterProps): boolean { + return c.seriesCharacterId === sc.id; + }); + }); + }, [seriesCharacters, characters]); + + const handleCharacterChange = useCallback(function (key: keyof CharacterProps, value: string | number | null): void { + updateCharacterField(key, value); + }, [updateCharacterField]); + + async function handleSave(): Promise { + await exitEditMode(true); + } + + function handleCancel(): void { + exitEditMode(false); + } + + async function handleDelete(): Promise { + if (selectedCharacter?.id) { + await deleteCharacter(selectedCharacter.id); + setShowDeleteConfirm(false); + backToList(); + } + } + + function getSeriesCharacterForSelected(): SeriesCharacterProps | null { + if (!selectedCharacter?.seriesCharacterId) return null; + return seriesCharacters.find(function (sc: SeriesCharacterProps): boolean { + return sc.id === selectedCharacter.seriesCharacterId; + }) || null; + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + const isNew: boolean = selectedCharacter?.id === null; + const canExport: boolean = Boolean(bookSeriesId && selectedCharacter?.id && !selectedCharacter.seriesCharacterId); + + return ( +
+ {/* Header - uniquement pour detail/edit */} + + + {/* Contenu principal */} +
+ {viewMode === 'list' && ( +
+ {/* Toggle tool */} + {showToggle && !isSeriesMode && ( +
+ + } + /> +

+ {t('characterComponent.enableToolDescription')} +

+
+ )} + + {/* Contenu si outil activé */} + {(toolEnabled || isSeriesMode) && ( + <> + {/* Import from series */} + {!isSeriesMode && bookSeriesId && availableSeriesCharacters.length > 0 && ( + + )} + + {/* Liste des personnages */} + + + )} +
+ )} + + {viewMode === 'detail' && selectedCharacter && ( +
+ +
+ )} + + {viewMode === 'edit' && selectedCharacter && ( +
+ +
+ )} +
+ + {/* Modal de confirmation de suppression */} + {showDeleteConfirm && selectedCharacter?.id && ( + + )} +
+ ); +} diff --git a/components/book/settings/characters/settings/CharacterSettingsDetail.tsx b/components/book/settings/characters/settings/CharacterSettingsDetail.tsx new file mode 100644 index 0000000..164f740 --- /dev/null +++ b/components/book/settings/characters/settings/CharacterSettingsDetail.tsx @@ -0,0 +1,326 @@ +'use client'; +import React, {useContext, useEffect, useState} from 'react'; +import { + advancedCharacterElements, + Attribute, + basicCharacterElements, + CharacterAttribute, + characterCategories, + CharacterElement, + CharacterProps, + characterStatus +} from '@/lib/models/Character'; +import {SeriesCharacterProps} from '@/lib/models/Series'; +import CollapsableArea from '@/components/CollapsableArea'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import { + faBook, + faCommentDots, + faGlobe, + faSliders, + faStickyNote, + faUser, + faVenusMars, + faCakeCandles, + faTag, + faCrown, + faQuoteLeft, + faFlag, + faHouse, + faSkull, + faDna, + faPalette, + faNoteSticky +} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; +import {SessionContext} from '@/context/SessionContext'; +import {AlertContext} from '@/context/AlertContext'; +import {LangContext} from '@/context/LangContext'; +import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; +import {BookContext} from '@/context/BookContext'; +import System from '@/lib/models/System'; + +type AttributeResponse = { type: string; values: Attribute[] }[]; + +interface CharacterSettingsDetailProps { + character: CharacterProps; + seriesCharacter?: SeriesCharacterProps | null; + onLoadAttributes?: (attributes: CharacterAttribute) => void; +} + +export default function CharacterSettingsDetail({ + character, + seriesCharacter, + onLoadAttributes, +}: CharacterSettingsDetailProps): React.JSX.Element { + const t = useTranslations(); + const {lang} = useContext(LangContext); + const {session} = useContext(SessionContext); + const {errorMessage} = useContext(AlertContext); + const {isCurrentlyOffline} = useContext(OfflineContext); + const {book} = useContext(BookContext); + const [showAdvanced, setShowAdvanced] = useState(false); + + useEffect(function (): void { + if (character?.id !== null) { + getAttributes().then(); + } + }, [character?.id]); + + async function getAttributes(): Promise { + try { + let response: AttributeResponse; + if (isCurrentlyOffline()) { + response = await window.electron.invoke('db:character:attributes', {characterId: character?.id}); + } else if (book?.localBook) { + response = await window.electron.invoke('db:character:attributes', {characterId: character?.id}); + } else { + response = await System.authGetQueryToServer( + 'character/attribute', + session.accessToken, + lang, + {characterId: character?.id} + ); + } + if (response && onLoadAttributes) { + const attributes: CharacterAttribute = {}; + response.forEach(function (item: { type: string; values: Attribute[] }): void { + attributes[item.type] = item.values; + }); + onLoadAttributes(attributes); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + } + + function getCategoryLabel(): string { + const cat = characterCategories.find(c => c.value === character.category); + return cat ? t(cat.label) : character.category || '—'; + } + + function getStatusLabel(): string { + const stat = characterStatus.find(s => s.value === character.status); + return stat ? t(stat.label) : character.status || '—'; + } + + function renderAttributeSection(element: CharacterElement): React.JSX.Element | null { + const attributes: Attribute[] = character[element.section] as Attribute[] || []; + if (attributes.length === 0) return null; + + return ( + +
+ {attributes.map(function (attr: Attribute, index: number): React.JSX.Element { + return ( + + {attr.name} + + ); + })} +
+
+ ); + } + + return ( +
+ {/* Hero Section - Image + Infos principales */} +
+ {/* Image */} +
+ {character.image ? ( +
+ {character.name} +
+ ) : ( +
+ +
+ )} +
+ + {/* Infos principales */} +
+

+ {character.name} {character.lastName} +

+ {character.nickname && ( +

« {character.nickname} »

+ )} + {character.title && ( +

{character.title}

+ )} + + {/* Badges */} +
+ + + {getCategoryLabel()} + + {character.gender && ( + + + {character.gender} + + )} + {character.age && ( + + + {character.age} {t('characterDetail.yearsOld')} + + )} +
+
+
+ + {/* Histoire & Biographie */} + +
+
+

{t('characterDetail.biography')}

+

+ {character.biography || '—'} +

+
+
+

{t('characterDetail.history')}

+

+ {character.history || '—'} +

+
+
+

{t('characterDetail.roleFull')}

+

+ {character.role || '—'} +

+
+
+
+ + {/* Attributs de base */} + {basicCharacterElements.map(renderAttributeSection)} + + {/* Toggle Mode Avancé */} +
+
+ + {t('characterDetail.advancedMode')} +
+ +
+ + {/* Sections avancées */} + {showAdvanced && ( + <> + {/* Identité étendue */} + +
+
+
+ + {t('characterDetail.species')} +
+

+ {character.species || '—'} +

+
+
+
+ + {t('characterDetail.nationality')} +
+

+ {character.nationality || '—'} +

+
+
+
+ + {t('characterDetail.status')} +
+

+ {getStatusLabel()} +

+
+
+
+ + {t('characterDetail.residence')} +
+

+ {character.residence || '—'} +

+
+
+
+ + {/* Voix du personnage */} + +
+
+

{t('characterDetail.speechPattern')}

+

+ {character.speechPattern || '—'} +

+
+
+ +

{t('characterDetail.catchphrase')}

+

+ {character.catchphrase ? `« ${character.catchphrase} »` : '—'} +

+
+
+
+ + {/* Notes de l'auteur */} + +
+
+
+ + {t('characterDetail.notes')} +
+

+ {character.notes || '—'} +

+
+
+
+ + {t('characterDetail.colorLabel')} +
+ {character.color ? ( +
+
+ {character.color} +
+ ) : ( +

+ )} +
+
+ + + {/* Attributs avancés */} + {advancedCharacterElements.map(renderAttributeSection)} + + )} +
+ ); +} diff --git a/components/book/settings/characters/settings/CharacterSettingsEdit.tsx b/components/book/settings/characters/settings/CharacterSettingsEdit.tsx new file mode 100644 index 0000000..3fd7674 --- /dev/null +++ b/components/book/settings/characters/settings/CharacterSettingsEdit.tsx @@ -0,0 +1,684 @@ +'use client'; +import React, {useContext, useEffect, useMemo, useState} from 'react'; +import { + advancedCharacterElements, + Attribute, + basicCharacterElements, + CharacterAttribute, + characterCategories, + CharacterElement, + CharacterProps, + characterStatus +} from '@/lib/models/Character'; +import {SeriesCharacterProps} from '@/lib/models/Series'; +import CollapsableArea from '@/components/CollapsableArea'; +import InputField from '@/components/form/InputField'; +import TextInput from '@/components/form/TextInput'; +import TexteAreaInput from '@/components/form/TexteAreaInput'; +import NumberInput from '@/components/form/NumberInput'; +import SelectBox from '@/components/form/SelectBox'; +import CharacterSectionElement from '@/components/book/settings/characters/CharacterSectionElement'; +import SyncFieldWrapper from '@/components/form/SyncFieldWrapper'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import { + faBook, + faCommentDots, + faGlobe, + faScroll, + faSliders, + faStickyNote, + faUser +} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; +import {SessionContext} from '@/context/SessionContext'; +import {AlertContext} from '@/context/AlertContext'; +import {LangContext} from '@/context/LangContext'; +import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; +import {BookContext} from '@/context/BookContext'; +import System from '@/lib/models/System'; +import {Dispatch, SetStateAction} from 'react'; + +type AttributeResponse = { type: string; values: Attribute[] }[]; + +interface CharacterSettingsEditProps { + character: CharacterProps; + setCharacter: Dispatch>; + onCharacterChange: (key: keyof CharacterProps, value: string | number | null) => void; + onAddAttribute: (section: keyof CharacterProps, attr: Attribute) => Promise; + onRemoveAttribute: (section: keyof CharacterProps, idx: number, id: string) => Promise; + seriesCharacter?: SeriesCharacterProps | null; + onSyncComplete?: () => void; +} + +/** + * CharacterSettingsEdit - Vue édition des détails d'un personnage + * Pour BookSetting/SerieSetting - Tous les champs éditables avec SyncFieldWrapper + * PAS de scroll interne (géré par parent) + */ +export default function CharacterSettingsEdit({ + character, + setCharacter, + onCharacterChange, + onAddAttribute, + onRemoveAttribute, + seriesCharacter, + onSyncComplete, +}: CharacterSettingsEditProps): React.JSX.Element { + const t = useTranslations(); + const {lang} = useContext(LangContext); + const {session} = useContext(SessionContext); + const {errorMessage} = useContext(AlertContext); + const {isCurrentlyOffline} = useContext(OfflineContext); + const {book} = useContext(BookContext); + const [showAdvanced, setShowAdvanced] = useState(false); + + // Traduire les données des SelectBox + const translatedCharacterCategories = useMemo(() => + characterCategories.map((item) => ({ + ...item, + label: t(item.label) + })), [t]); + + const translatedCharacterStatus = useMemo(() => + characterStatus.map((item) => ({ + ...item, + label: t(item.label) + })), [t]); + + useEffect(function (): void { + if (character?.id !== null) { + getAttributes().then(); + } + }, [character?.id]); + + async function getAttributes(): Promise { + try { + let response: AttributeResponse; + if (isCurrentlyOffline()) { + response = await window.electron.invoke('db:character:attributes', {characterId: character?.id}); + } else if (book?.localBook) { + response = await window.electron.invoke('db:character:attributes', {characterId: character?.id}); + } else { + response = await System.authGetQueryToServer( + 'character/attribute', + session.accessToken, + lang, + {characterId: character?.id} + ); + } + if (response) { + const attributes: CharacterAttribute = {}; + response.forEach(function (item: { type: string; values: Attribute[] }): void { + attributes[item.type] = item.values; + }); + setCharacter(function (prev: CharacterProps | null): CharacterProps | null { + if (!prev) return null; + return { + ...prev, + physical: attributes.physical ?? [], + psychological: attributes.psychological ?? [], + relations: attributes.relations ?? [], + skills: attributes.skills ?? [], + weaknesses: attributes.weaknesses ?? [], + strengths: attributes.strengths ?? [], + goals: attributes.goals ?? [], + motivations: attributes.motivations ?? [], + arc: attributes.arc ?? [], + secrets: attributes.secrets ?? [], + fears: attributes.fears ?? [], + flaws: attributes.flaws ?? [], + beliefs: attributes.beliefs ?? [], + conflicts: attributes.conflicts ?? [], + quotes: attributes.quotes ?? [], + distinguishingMarks: attributes.distinguishingMarks ?? [], + items: attributes.items ?? [], + affiliations: attributes.affiliations ?? [], + }; + }); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + } + + return ( +
+ {/* Informations de base */} + +
+ + ): void { + onCharacterChange('name', e.target.value); + }} + placeholder={t('characterDetail.namePlaceholder')} + /> + + } + /> + + + ): void { + onCharacterChange('lastName', e.target.value); + }} + placeholder={t('characterDetail.lastNamePlaceholder')} + /> + + } + /> + + + ): void { + onCharacterChange('nickname', e.target.value); + }} + placeholder={t('characterDetail.nicknamePlaceholder')} + /> + + } + /> + + + ): void { + setCharacter(function (prev: CharacterProps | null): CharacterProps | null { + return prev ? {...prev, category: e.target.value as CharacterProps['category']} : prev; + }); + }} + data={translatedCharacterCategories} + /> + + } + /> + + + ): void { + onCharacterChange('title', e.target.value); + }} + placeholder={t('characterDetail.titlePlaceholder')} + /> + + } + /> + + + ): void { + onCharacterChange('gender', e.target.value); + }} + placeholder={t('characterDetail.genderPlaceholder')} + /> + + } + /> + + + + + } + /> +
+
+ + {/* Histoire */} + +
+ + ): void { + onCharacterChange('biography', e.target.value); + }} + placeholder={t('characterDetail.biographyPlaceholder')} + /> + + } + /> + + + ): void { + onCharacterChange('history', e.target.value); + }} + placeholder={t('characterDetail.historyPlaceholder')} + /> + + } + /> + + + ): void { + onCharacterChange('role', e.target.value); + }} + placeholder={t('characterDetail.roleFullPlaceholder')} + /> + + } + /> +
+
+ + {/* Attributs de base */} + {basicCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element { + return ( + + ); + })} + + {/* Toggle Mode Avancé */} +
+
+ + {t('characterDetail.advancedMode')} +
+ +
+ + {/* Sections avancées */} + {showAdvanced && ( + <> + {/* Identité étendue */} + +
+ + ): void { + onCharacterChange('species', e.target.value); + }} + placeholder={t('characterDetail.speciesPlaceholder')} + /> + + } + /> + + + ): void { + onCharacterChange('nationality', e.target.value); + }} + placeholder={t('characterDetail.nationalityPlaceholder')} + /> + + } + /> + + + ): void { + setCharacter(function (prev: CharacterProps | null): CharacterProps | null { + return prev ? {...prev, status: e.target.value as CharacterProps['status']} : prev; + }); + }} + data={translatedCharacterStatus} + /> + + } + /> + + + ): void { + onCharacterChange('residence', e.target.value); + }} + placeholder={t('characterDetail.residencePlaceholder')} + /> + + } + /> +
+
+ + {/* Voix du personnage */} + +
+ + ): void { + onCharacterChange('speechPattern', e.target.value); + }} + placeholder={t('characterDetail.speechPatternPlaceholder')} + /> + + } + /> + + + ): void { + onCharacterChange('catchphrase', e.target.value); + }} + placeholder={t('characterDetail.catchphrasePlaceholder')} + /> + + } + /> +
+
+ + {/* Notes de l'auteur */} + +
+ + ): void { + onCharacterChange('notes', e.target.value); + }} + placeholder={t('characterDetail.notesPlaceholder')} + /> + + } + /> + + + ): void { + onCharacterChange('color', e.target.value); + }} + placeholder={t('characterDetail.colorPlaceholder')} + /> + + } + /> +
+
+ + {/* Attributs avancés */} + {advancedCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element { + return ( + + ); + })} + + )} +
+ ); +} diff --git a/components/book/settings/characters/settings/CharacterSettingsList.tsx b/components/book/settings/characters/settings/CharacterSettingsList.tsx new file mode 100644 index 0000000..f1512a0 --- /dev/null +++ b/components/book/settings/characters/settings/CharacterSettingsList.tsx @@ -0,0 +1,151 @@ +'use client'; +import React, {useState} from 'react'; +import {characterCategories, CharacterProps} from '@/lib/models/Character'; +import InputField from '@/components/form/InputField'; +import TextInput from '@/components/form/TextInput'; +import CollapsableArea from '@/components/CollapsableArea'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faChevronRight, faPlus, faUser} from '@fortawesome/free-solid-svg-icons'; +import {SelectBoxProps} from '@/shared/interface'; +import {useTranslations} from 'next-intl'; + +interface CharacterSettingsListProps { + characters: CharacterProps[]; + onCharacterClick: (character: CharacterProps) => void; + onAddCharacter: () => void; +} + +/** + * CharacterSettingsList - Liste des personnages pour BookSetting/SerieSetting + * Version complète avec groupage par catégorie et sections collapsibles + * PAS de scroll interne (géré par parent SettingsContainer) + */ +export default function CharacterSettingsList({ + characters, + onCharacterClick, + onAddCharacter, +}: CharacterSettingsListProps): React.JSX.Element { + const t = useTranslations(); + const [searchQuery, setSearchQuery] = useState(''); + + function getFilteredCharacters(): CharacterProps[] { + return characters.filter(function (char: CharacterProps): boolean { + return char.name.toLowerCase().includes(searchQuery.toLowerCase()) || + (char.lastName?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false); + }); + } + + const filteredCharacters: CharacterProps[] = getFilteredCharacters(); + + return ( +
+
+ ): void { + setSearchQuery(e.target.value); + }} + placeholder={t('characterList.search')} + /> + } + actionIcon={faPlus} + actionLabel={t('characterList.add')} + addButtonCallBack={async function (): Promise { + onAddCharacter(); + }} + /> +
+ +
+ {characterCategories.map(function (category: SelectBoxProps): React.JSX.Element | null { + const categoryCharacters: CharacterProps[] = filteredCharacters.filter( + function (char: CharacterProps): boolean { + return char.category === category.value; + } + ); + + if (categoryCharacters.length === 0) { + return null; + } + + return ( + +
+ {categoryCharacters.map(function (char: CharacterProps): React.JSX.Element { + return ( +
+
+ {char.image ? ( + {char.name} + ) : ( +
+ {char.name?.charAt(0)?.toUpperCase() || '?'} +
+ )} +
+ +
+
+ {char.name || t('characterList.unknown')} +
+
+ {char.lastName || t('characterList.noLastName')} +
+
+ +
+
+ {char.title || t('characterList.noTitle')} +
+
+ {char.role || t('characterList.noRole')} +
+
+ +
+ +
+
+ ); + })} +
+
+ ); + })} + + {filteredCharacters.length === 0 && ( +
+
+ +
+

+ {t('characterList.noCharacters')} +

+

+ {t('characterList.noCharactersDescription')} +

+
+ )} +
+
+ ); +} diff --git a/components/book/settings/locations/LocationComponent.tsx b/components/book/settings/locations/LocationComponent.tsx index 2251eac..fa0a39e 100644 --- a/components/book/settings/locations/LocationComponent.tsx +++ b/components/book/settings/locations/LocationComponent.tsx @@ -1,7 +1,7 @@ 'use client' -import {faMapMarkerAlt, faPlus, faToggleOn, faTrash} from '@fortawesome/free-solid-svg-icons'; +import {faMapMarkerAlt, faPlus, faShare, faToggleOn, faTrash} from '@fortawesome/free-solid-svg-icons'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; -import {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react'; +import React, {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react'; import {SessionContext} from "@/context/SessionContext"; import {AlertContext} from "@/context/AlertContext"; import {BookContext} from "@/context/BookContext"; @@ -15,7 +15,12 @@ import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext"; import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext"; import {SyncedBook} from "@/lib/models/SyncedBook"; +import {SeriesContext, SeriesContextProps} from "@/context/SeriesContext"; +import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext"; +import {SyncedSeries} from "@/lib/models/SyncedSeries"; import ToggleSwitch from "@/components/form/ToggleSwitch"; +import {SeriesLocationElement, SeriesLocationItem, SeriesLocationSubElement} from "@/lib/models/Series"; +import SeriesImportSelector from "@/components/form/SeriesImportSelector"; interface SubElement { id: string; @@ -34,6 +39,7 @@ interface LocationProps { id: string; name: string; elements: Element[]; + seriesLocationId?: string | null; } interface LocationListResponse { @@ -41,7 +47,14 @@ interface LocationListResponse { enabled: boolean; } -export function LocationComponent({showToggle = true}: {showToggle?: boolean}, ref: any) { +interface LocationComponentProps { + showToggle?: boolean; + entityType?: 'book' | 'series'; + entityId?: string; +} + +export function LocationComponent(props: LocationComponentProps, ref: React.Ref<{ handleSave: () => Promise }>) { + const {showToggle = true, entityType = 'book', entityId} = props; const t = useTranslations(); const {lang} = useContext(LangContext); const {isCurrentlyOffline} = useContext(OfflineContext); @@ -50,44 +63,77 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r const {session} = useContext(SessionContext); const {successMessage, errorMessage} = useContext(AlertContext); const {book, setBook} = useContext(BookContext); + const {seriesId, localSeries} = useContext(SeriesContext); + const {localSyncedSeries} = useContext(SeriesSyncContext); - const bookId: string | undefined = book?.bookId; + const currentEntityId: string = entityId || book?.bookId || ''; + const isSeriesMode: boolean = entityType === 'series'; const token: string = session.accessToken; const [sections, setSections] = useState([]); + const [seriesLocations, setSeriesLocations] = useState([]); const [newSectionName, setNewSectionName] = useState(''); const [newElementNames, setNewElementNames] = useState<{ [key: string]: string }>({}); const [newSubElementNames, setNewSubElementNames] = useState<{ [key: string]: string }>({}); - const [toolEnabled, setToolEnabled] = useState(book?.tools?.locations ?? false); - + const [toolEnabled, setToolEnabled] = useState(isSeriesMode ? true : (book?.tools?.locations ?? false)); + const bookSeriesId: string | null = book?.seriesId || null; + useImperativeHandle(ref, function () { return { handleSave: handleSave, }; }); - + useEffect((): void => { - getAllLocations().then(); - }, []); + if (currentEntityId) { + getAllLocations().then(); + } + }, [currentEntityId]); + + useEffect((): void => { + if (bookSeriesId && !isSeriesMode) { + getSeriesLocations().then(); + } + }, [bookSeriesId]); + + async function getSeriesLocations(): Promise { + if (!bookSeriesId) return; + try { + const response: SeriesLocationItem[] = await System.authGetQueryToServer( + 'series/location/list', + token, + lang, + {seriesid: bookSeriesId} + ); + if (response) { + setSeriesLocations(response); + } + } catch (e: unknown) { + if (e instanceof Error) { + console.error('Error loading series locations:', e.message); + } + } + } async function handleToggleTool(enabled: boolean): Promise { + if (isSeriesMode) return; try { let response: boolean; if (isCurrentlyOffline() || book?.localBook) { response = await window.electron.invoke('db:book:tool:update', { - bookId: bookId, + bookId: currentEntityId, toolName: 'locations', enabled: enabled }); } else { response = await System.authPatchToServer('book/tool-setting', { - bookId: bookId, + bookId: currentEntityId, toolName: 'locations', enabled: enabled }, token, lang); - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { addToQueue('db:book:tool:update', { - bookId: bookId, + bookId: currentEntityId, toolName: 'locations', enabled: enabled }); @@ -95,12 +141,14 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r } if (response && setBook && book) { setToolEnabled(enabled); - setBook({...book, tools: { - characters: book.tools?.characters ?? false, - worlds: book.tools?.worlds ?? false, - spells: book.tools?.spells ?? false, - locations: enabled - }}); + setBook({ + ...book, tools: { + characters: book.tools?.characters ?? false, + worlds: book.tools?.worlds ?? false, + spells: book.tools?.spells ?? false, + locations: enabled + } + }); } } catch (e: unknown) { if (e instanceof Error) { @@ -111,28 +159,61 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r async function getAllLocations(): Promise { try { - let response: LocationListResponse; - if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:location:all', {bookid: bookId}); - } else { - if (book?.localBook) { - response = await window.electron.invoke('db:location:all', {bookid: bookId}); + if (isSeriesMode) { + let response: SeriesLocationItem[]; + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke('db:series:location:list', {seriesId: currentEntityId}); } else { - response = await System.authGetQueryToServer(`location/all`, token, lang, { - bookid: bookId, - }); + response = await System.authGetQueryToServer( + 'series/location/list', + token, + lang, + {seriesid: currentEntityId} + ); } - } - if (response) { - setSections(response.locations); - setToolEnabled(response.enabled); - if (setBook && book) { - setBook({...book, tools: { - characters: book.tools?.characters ?? false, - worlds: book.tools?.worlds ?? false, - spells: book.tools?.spells ?? false, - locations: response.enabled - }}); + if (response) { + const mappedLocations: LocationProps[] = response.map((loc: SeriesLocationItem): LocationProps => ({ + id: loc.id, + name: loc.name, + elements: loc.elements.map((elem: SeriesLocationElement) => ({ + id: elem.id, + name: elem.name, + description: elem.description, + subElements: elem.subElements.map((sub: SeriesLocationSubElement) => ({ + id: sub.id, + name: sub.name, + description: sub.description, + })), + })), + })); + setSections(mappedLocations); + } + } else { + let response: LocationListResponse; + if (isCurrentlyOffline()) { + response = await window.electron.invoke('db:location:all', {bookid: currentEntityId}); + } else { + if (book?.localBook) { + response = await window.electron.invoke('db:location:all', {bookid: currentEntityId}); + } else { + response = await System.authGetQueryToServer(`location/all`, token, lang, { + bookid: currentEntityId, + }); + } + } + if (response) { + setSections(response.locations); + setToolEnabled(response.enabled); + if (setBook && book) { + setBook({ + ...book, tools: { + characters: book.tools?.characters ?? false, + worlds: book.tools?.worlds ?? false, + spells: book.tools?.spells ?? false, + locations: response.enabled + } + }); + } } } } catch (e: unknown) { @@ -143,7 +224,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r } } } - + async function handleAddSection(): Promise { if (!newSectionName.trim()) { errorMessage(t('locationComponent.errorSectionNameEmpty')) @@ -151,20 +232,42 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r } try { let sectionId: string; - if (isCurrentlyOffline() || book?.localBook) { + if (isSeriesMode) { + const addData = { + seriesId: currentEntityId, + name: newSectionName, + }; + if (isCurrentlyOffline() || localSeries) { + sectionId = await window.electron.invoke('db:series:location:section:add', addData); + } else { + sectionId = await System.authPostToServer( + 'series/location/section/add', + addData, + token, + lang + ); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { + addToQueue('db:series:location:section:add', addData); + } + } + if (!sectionId) { + errorMessage(t('locationComponent.errorUnknownAddSection')); + return; + } + } else if (isCurrentlyOffline() || book?.localBook) { sectionId = await window.electron.invoke('db:location:section:add', { - bookId: bookId, + bookId: currentEntityId, locationName: newSectionName, }); } else { sectionId = await System.authPostToServer(`location/section/add`, { - bookId: bookId, + bookId: currentEntityId, locationName: newSectionName, }, token, lang); - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { addToQueue('db:location:section:add', { - bookId: bookId, + bookId: currentEntityId, sectionId, locationName: newSectionName, }); @@ -189,7 +292,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r } } } - + async function handleAddElement(sectionId: string): Promise { if (!newElementNames[sectionId]?.trim()) { errorMessage(t('locationComponent.errorElementNameEmpty')) @@ -197,23 +300,45 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r } try { let elementId: string; - if (isCurrentlyOffline() || book?.localBook) { + if (isSeriesMode) { + const addData = { + locationId: sectionId, + name: newElementNames[sectionId], + }; + if (isCurrentlyOffline() || localSeries) { + elementId = await window.electron.invoke('db:series:location:element:add', addData); + } else { + elementId = await System.authPostToServer( + 'series/location/element/add', + addData, + token, + lang + ); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { + addToQueue('db:series:location:element:add', addData); + } + } + if (!elementId) { + errorMessage(t('locationComponent.errorUnknownAddElement')); + return; + } + } else if (isCurrentlyOffline() || book?.localBook) { elementId = await window.electron.invoke('db:location:element:add', { - bookId: bookId, + bookId: currentEntityId, locationId: sectionId, elementName: newElementNames[sectionId], }); } else { elementId = await System.authPostToServer(`location/element/add`, { - bookId: bookId, + bookId: currentEntityId, locationId: sectionId, elementName: newElementNames[sectionId], }, token, lang); - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { addToQueue('db:location:element:add', { - bookId: bookId, + bookId: currentEntityId, locationId: sectionId, elementId, elementName: newElementNames[sectionId], @@ -244,7 +369,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r } } } - + function handleElementChange( sectionId: string, elementIndex: number, @@ -259,7 +384,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r updatedSections[sectionIndex].elements[elementIndex][field] = value; setSections(updatedSections); } - + async function handleAddSubElement( sectionId: string, elementIndex: number, @@ -274,7 +399,29 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r try { let subElementId: string; const elementId = sections[sectionIndex].elements[elementIndex].id; - if (isCurrentlyOffline() || book?.localBook) { + if (isSeriesMode) { + const addData = { + elementId: elementId, + name: newSubElementNames[elementIndex], + }; + if (isCurrentlyOffline() || localSeries) { + subElementId = await window.electron.invoke('db:series:location:subelement:add', addData); + } else { + subElementId = await System.authPostToServer( + 'series/location/sub-element/add', + addData, + token, + lang + ); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { + addToQueue('db:series:location:subelement:add', addData); + } + } + if (!subElementId) { + errorMessage(t('locationComponent.errorUnknownAddSubElement')); + return; + } + } else if (isCurrentlyOffline() || book?.localBook) { subElementId = await window.electron.invoke('db:location:subelement:add', { elementId: elementId, subElementName: newSubElementNames[elementIndex], @@ -285,7 +432,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r subElementName: newSubElementNames[elementIndex], }, token, lang); - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { addToQueue('db:location:subelement:add', { elementId: elementId, subElementId, @@ -330,7 +477,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r ][field] = value; setSections(updatedSections); } - + async function handleRemoveElement( sectionId: string, elementIndex: number, @@ -339,7 +486,17 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r let response: boolean; const elementId = sections.find((section: LocationProps): boolean => section.id === sectionId) ?.elements[elementIndex].id; - if (isCurrentlyOffline() || book?.localBook) { + if (isSeriesMode) { + const deleteData = {elementId: elementId}; + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke('db:series:location:element:delete', deleteData); + } else { + response = await System.authDeleteToServer('series/location/element/delete', deleteData, token, lang); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { + addToQueue('db:series:location:element:delete', deleteData); + } + } + } else if (isCurrentlyOffline() || book?.localBook) { response = await window.electron.invoke('db:location:element:delete', { elementId: elementId, }); @@ -348,7 +505,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r elementId: elementId, }, token, lang); - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { addToQueue('db:location:element:delete', { elementId: elementId, }); @@ -379,7 +536,17 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r try { let response: boolean; const subElementId = sections.find((section: LocationProps): boolean => section.id === sectionId)?.elements[elementIndex].subElements[subElementIndex].id; - if (isCurrentlyOffline() || book?.localBook) { + if (isSeriesMode) { + const deleteData = {subElementId: subElementId}; + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke('db:series:location:subelement:delete', deleteData); + } else { + response = await System.authDeleteToServer('series/location/sub-element/delete', deleteData, token, lang); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { + addToQueue('db:series:location:subelement:delete', deleteData); + } + } + } else if (isCurrentlyOffline() || book?.localBook) { response = await window.electron.invoke('db:location:subelement:delete', { subElementId: subElementId, }); @@ -388,7 +555,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r subElementId: subElementId, }, token, lang); - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { addToQueue('db:location:subelement:delete', { subElementId: subElementId, }); @@ -414,7 +581,17 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r async function handleRemoveSection(sectionId: string): Promise { try { let response: boolean; - if (isCurrentlyOffline() || book?.localBook) { + if (isSeriesMode) { + const deleteData = {locationId: sectionId}; + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke('db:series:location:delete', deleteData); + } else { + response = await System.authDeleteToServer('series/location/delete', deleteData, token, lang); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { + addToQueue('db:series:location:delete', deleteData); + } + } + } else if (isCurrentlyOffline() || book?.localBook) { response = await window.electron.invoke('db:location:delete', { locationId: sectionId, }); @@ -423,7 +600,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r locationId: sectionId, }, token, lang); - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { addToQueue('db:location:delete', { locationId: sectionId, }); @@ -456,7 +633,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r locations: sections, }, token, lang); - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { addToQueue('db:location:update', { locations: sections, }); @@ -476,9 +653,107 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r } } + async function handleExportToSeries(section: LocationProps): Promise { + if (!bookSeriesId) return; + + try { + const seriesLocationId: string = await System.authPostToServer('series/location/section/add', { + seriesId: bookSeriesId, + name: section.name, + }, token, lang); + + if (seriesLocationId) { + const updateResponse: boolean = await System.authPostToServer('location/section/update', { + sectionId: section.id, + sectionName: section.name, + seriesLocationId: seriesLocationId, + }, token, lang); + + if (updateResponse) { + setSections(sections.map((s: LocationProps): LocationProps => + s.id === section.id ? {...s, seriesLocationId: seriesLocationId} : s + )); + await getSeriesLocations(); + successMessage(t("locationComponent.exportSuccess")); + } + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + } + + async function handleImportFromSeries(seriesLocationId: string): Promise { + const seriesLocation: SeriesLocationItem | undefined = seriesLocations.find((location: SeriesLocationItem): boolean => location.id === seriesLocationId); + if (!seriesLocation) return; + + try { + const sectionId: string = await System.authPostToServer('location/section/add', { + bookId: currentEntityId, + locationName: seriesLocation.name, + seriesLocationId: seriesLocationId, + }, token, lang); + + if (!sectionId) { + errorMessage(t('locationComponent.importError')); + return; + } + + const importedElements: Element[] = []; + + for (const seriesElement of seriesLocation.elements) { + const elementId: string = await System.authPostToServer('location/element/add', { + bookId: currentEntityId, + locationId: sectionId, + elementName: seriesElement.name, + }, token, lang); + + if (!elementId) continue; + + const importedSubElements: SubElement[] = []; + + for (const seriesSubElement of seriesElement.subElements) { + const subElementId: string = await System.authPostToServer('location/sub-element/add', { + elementId: elementId, + subElementName: seriesSubElement.name, + }, token, lang); + + if (subElementId) { + importedSubElements.push({ + id: subElementId, + name: seriesSubElement.name, + description: seriesSubElement.description, + }); + } + } + + importedElements.push({ + id: elementId, + name: seriesElement.name, + description: seriesElement.description, + subElements: importedSubElements, + }); + } + + const newLocation: LocationProps = { + id: sectionId, + name: seriesLocation.name, + elements: importedElements, + seriesLocationId: seriesLocationId, + }; + setSections([...sections, newLocation]); + successMessage(t('locationComponent.importSuccess')); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + } + return (
- {showToggle && ( + {showToggle && !isSeriesMode && (
)} - {toolEnabled && ( + {(toolEnabled || isSeriesMode) && ( <> + {!isSeriesMode && bookSeriesId && + seriesLocations.filter((seriesLocation: SeriesLocationItem): boolean => !sections.some((section: LocationProps): boolean => section.seriesLocationId === seriesLocation.id)).length > 0 && ( + !sections.some((section: LocationProps): boolean => section.seriesLocationId === seriesLocation.id)) + .map((seriesLocation: SeriesLocationItem) => ({id: seriesLocation.id, name: seriesLocation.name}))} + onImport={handleImportFromSeries} + placeholder={t("seriesImport.selectElement")} + label={t("seriesImport.importFromSeries")} + /> + )}
{section.elements.length || 0} - +
+ {!isSeriesMode && bookSeriesId && !section.seriesLocationId && ( + + )} + +
{section.elements.length > 0 ? ( @@ -638,4 +935,4 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r ); } -export default forwardRef(LocationComponent); \ No newline at end of file +export default forwardRef(LocationComponent); diff --git a/components/book/settings/locations/editor/LocationEditor.tsx b/components/book/settings/locations/editor/LocationEditor.tsx new file mode 100644 index 0000000..f286521 --- /dev/null +++ b/components/book/settings/locations/editor/LocationEditor.tsx @@ -0,0 +1,242 @@ +'use client'; +import React, {useCallback, useContext, useMemo, useState} from 'react'; +import {useLocations, UseLocationsConfig, LocationProps, Element, SubElement} from '@/hooks/settings/useLocations'; +import {useTranslations} from 'next-intl'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faSpinner, faPlus, faToggleOn} from '@fortawesome/free-solid-svg-icons'; +import {BookContext} from '@/context/BookContext'; +import {SeriesLocationItem} from '@/lib/models/Series'; +import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader'; +import AlertBox from '@/components/AlertBox'; +import InputField from '@/components/form/InputField'; +import TextInput from '@/components/form/TextInput'; +import ToggleSwitch from '@/components/form/ToggleSwitch'; +import SeriesImportSelector from '@/components/form/SeriesImportSelector'; + +import LocationEditorList from './LocationEditorList'; +import LocationEditorDetail from './LocationEditorDetail'; +import LocationEditorEdit from './LocationEditorEdit'; + +/** + * LocationEditor - Orchestrateur pour ComposerRightBar + * Mêmes fonctionnalités que LocationSettings, layout condensé + * Inclut: toggle tool, import from series, export to series + */ +export default function LocationEditor(): React.JSX.Element { + const t = useTranslations(); + const {book} = useContext(BookContext); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [showAddForm, setShowAddForm] = useState(false); + + const config: UseLocationsConfig = useMemo(function (): UseLocationsConfig { + return { + entityType: 'book', + entityId: book?.bookId || '', + }; + }, [book?.bookId]); + + const { + sections, + seriesLocations, + toolEnabled, + isLoading, + bookSeriesId, + newSectionName, + newElementNames, + newSubElementNames, + viewMode, + selectedSectionIndex, + addSection, + addElement, + addSubElement, + removeSection, + removeElement, + removeSubElement, + updateElement, + updateSubElement, + saveLocations, + toggleTool, + importFromSeries, + exportToSeries, + setNewSectionName, + setNewElementNames, + setNewSubElementNames, + enterDetailMode, + enterEditMode, + exitEditMode, + backToList, + } = useLocations(config); + + const availableSeriesLocations = useMemo(function (): SeriesLocationItem[] { + return seriesLocations.filter(function (sl: SeriesLocationItem): boolean { + return !sections.some(function (s: LocationProps): boolean { + return s.seriesLocationId === sl.id; + }); + }); + }, [seriesLocations, sections]); + + // Wrapper pour convertir LocationProps en index + const handleSectionClick = useCallback(function (section: LocationProps, index: number): void { + enterDetailMode(index); + }, [enterDetailMode]); + + // Gestion de l'ajout + async function handleAddSection(): Promise { + if (newSectionName.trim()) { + await addSection(); + setShowAddForm(false); + } else { + setShowAddForm(true); + } + } + + async function handleSave(): Promise { + await exitEditMode(true); + } + + function handleCancel(): void { + exitEditMode(false); + } + + async function handleDelete(): Promise { + if (selectedSectionIndex >= 0 && sections[selectedSectionIndex]) { + await removeSection(sections[selectedSectionIndex].id); + setShowDeleteConfirm(false); + backToList(); + } + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + const selectedSection: LocationProps | undefined = sections[selectedSectionIndex]; + const canExport: boolean = Boolean(bookSeriesId && selectedSection && !selectedSection.seriesLocationId); + + return ( +
+ { return exportToSeries(selectedSection!); } : undefined} + showExport={canExport} + showDelete={Boolean(selectedSection)} + /> + +
+ {viewMode === 'list' && ( +
+ {/* Toggle tool */} +
+ + } + /> +
+ + {toolEnabled && ( + <> + {/* Import from series */} + {bookSeriesId && availableSeriesLocations.length > 0 && ( + + )} + + {showAddForm && ( +
+ ): void { + setNewSectionName(e.target.value); + }} + placeholder={t('locationComponent.newSectionPlaceholder')} + /> + } + actionIcon={faPlus} + actionLabel={t('locationComponent.addSectionLabel')} + addButtonCallBack={async function (): Promise { + await addSection(); + setShowAddForm(false); + }} + /> +
+ )} + + + + )} +
+ )} + + {viewMode === 'detail' && selectedSection && ( +
+ +
+ )} + + {viewMode === 'edit' && selectedSection && ( +
+ +
+ )} +
+ + {showDeleteConfirm && selectedSection && ( + + )} +
+ ); +} diff --git a/components/book/settings/locations/editor/LocationEditorDetail.tsx b/components/book/settings/locations/editor/LocationEditorDetail.tsx new file mode 100644 index 0000000..b1701b7 --- /dev/null +++ b/components/book/settings/locations/editor/LocationEditorDetail.tsx @@ -0,0 +1,65 @@ +'use client'; +import React from 'react'; +import {LocationProps, Element, SubElement} from '@/hooks/settings/useLocations'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faLocationDot, faMapPin} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; + +interface LocationEditorDetailProps { + section: LocationProps; +} + +/** + * LocationEditorDetail - Version sidebar lecture seule + * Layout linéaire simple, juste les infos essentielles empilées + * PAS de CollapsableArea, PAS de grids + */ +export default function LocationEditorDetail({ + section, +}: LocationEditorDetailProps): React.JSX.Element { + const t = useTranslations(); + + return ( +
+

{section.name}

+ + {section.elements.length === 0 ? ( +

{t('locationComponent.noElementAvailable')}

+ ) : ( +
+ {section.elements.map(function (element: Element): React.JSX.Element { + return ( +
+
+ + {element.name} +
+ {element.description && ( +

{element.description}

+ )} + + {element.subElements.length > 0 && ( +
+ {element.subElements.map(function (subElement: SubElement): React.JSX.Element { + return ( +
+ +
+ {subElement.name} + {subElement.description && ( +

{subElement.description}

+ )} +
+
+ ); + })} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/components/book/settings/locations/editor/LocationEditorEdit.tsx b/components/book/settings/locations/editor/LocationEditorEdit.tsx new file mode 100644 index 0000000..c9e5f7f --- /dev/null +++ b/components/book/settings/locations/editor/LocationEditorEdit.tsx @@ -0,0 +1,151 @@ +'use client'; +import React, {ChangeEvent} from 'react'; +import {LocationProps, Element, SubElement} from '@/hooks/settings/useLocations'; +import InputField from '@/components/form/InputField'; +import TextInput from '@/components/form/TextInput'; +import TexteAreaInput from '@/components/form/TexteAreaInput'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faMapPin, faPlus} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; + +interface LocationEditorEditProps { + section: LocationProps; + newElementNames: { [key: string]: string }; + newSubElementNames: { [key: string]: string }; + onAddElement: (sectionId: string) => Promise; + onAddSubElement: (sectionId: string, elementIndex: number) => Promise; + onRemoveElement: (sectionId: string, elementIndex: number) => Promise; + onRemoveSubElement: (sectionId: string, elementIndex: number, subElementIndex: number) => Promise; + onUpdateElement: (sectionId: string, elementIndex: number, field: keyof Element, value: string) => void; + onUpdateSubElement: (sectionId: string, elementIndex: number, subElementIndex: number, field: keyof SubElement, value: string) => void; + onNewElementNameChange: (sectionId: string, name: string) => void; + onNewSubElementNameChange: (elementIndex: number, name: string) => void; +} + +/** + * LocationEditorEdit - Version sidebar édition + * Layout linéaire simple, champs empilés verticalement + * PAS de CollapsableArea, PAS de grids + */ +export default function LocationEditorEdit({ + section, + newElementNames, + newSubElementNames, + onAddElement, + onAddSubElement, + onRemoveElement, + onRemoveSubElement, + onUpdateElement, + onUpdateSubElement, + onNewElementNameChange, + onNewSubElementNameChange, +}: LocationEditorEditProps): React.JSX.Element { + const t = useTranslations(); + + return ( +
+

{section.name}

+ + {/* Éléments existants */} + {section.elements.map(function (element: Element, elementIndex: number): React.JSX.Element { + return ( +
+
+ + {t('locationComponent.element')} +
+ + ): void { + onUpdateElement(section.id, elementIndex, 'name', e.target.value); + }} + placeholder={t('locationComponent.elementNamePlaceholder')} + /> + } + removeButtonCallBack={function (): Promise { + return onRemoveElement(section.id, elementIndex); + }} + /> + +
+ ): void { + onUpdateElement(section.id, elementIndex, 'description', e.target.value); + }} + placeholder={t('locationComponent.elementDescriptionPlaceholder')} + /> +
+ + {/* Sous-éléments */} + {element.subElements.length > 0 && ( +
+ {element.subElements.map(function (subElement: SubElement, subElementIndex: number): React.JSX.Element { + return ( +
+ ): void { + onUpdateSubElement(section.id, elementIndex, subElementIndex, 'name', e.target.value); + }} + placeholder={t('locationComponent.subElementNamePlaceholder')} + /> + } + removeButtonCallBack={function (): Promise { + return onRemoveSubElement(section.id, elementIndex, subElementIndex); + }} + /> +
+ ); + })} +
+ )} + + {/* Ajouter sous-élément */} +
+ ): void { + onNewSubElementNameChange(elementIndex, e.target.value); + }} + placeholder={t('locationComponent.newSubElementPlaceholder')} + /> + } + actionIcon={faPlus} + actionLabel={t('locationComponent.addSubElement')} + addButtonCallBack={function (): Promise { + return onAddSubElement(section.id, elementIndex); + }} + /> +
+
+ ); + })} + + {/* Ajouter élément */} + ): void { + onNewElementNameChange(section.id, e.target.value); + }} + placeholder={t('locationComponent.newElementPlaceholder')} + /> + } + actionIcon={faPlus} + addButtonCallBack={function (): Promise { + return onAddElement(section.id); + }} + /> +
+ ); +} diff --git a/components/book/settings/locations/editor/LocationEditorList.tsx b/components/book/settings/locations/editor/LocationEditorList.tsx new file mode 100644 index 0000000..88d2f80 --- /dev/null +++ b/components/book/settings/locations/editor/LocationEditorList.tsx @@ -0,0 +1,113 @@ +'use client'; +import React, {useState} from 'react'; +import {LocationProps, Element} from '@/hooks/settings/useLocations'; +import InputField from '@/components/form/InputField'; +import TextInput from '@/components/form/TextInput'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faChevronRight, faMapMarkerAlt, faPlus} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; + +interface LocationEditorListProps { + sections: LocationProps[]; + onSectionClick: (section: LocationProps, index: number) => void; + onAddSection: () => void; +} + +/** + * LocationEditorList - Liste des sections pour ComposerRightBar + * Version compacte avec liste cliquable (même pattern que CharacterEditorList) + * PAS de scroll interne (géré par parent ComposerRightBar) + */ +export default function LocationEditorList({ + sections, + onSectionClick, + onAddSection, +}: LocationEditorListProps): React.JSX.Element { + const t = useTranslations(); + const [searchQuery, setSearchQuery] = useState(''); + + function getFilteredSections(): LocationProps[] { + return sections.filter(function (section: LocationProps): boolean { + return section.name.toLowerCase().includes(searchQuery.toLowerCase()); + }); + } + + function countTotalElements(section: LocationProps): number { + let count: number = section.elements.length; + section.elements.forEach(function (element: Element): void { + count += element.subElements.length; + }); + return count; + } + + const filteredSections: LocationProps[] = getFilteredSections(); + + return ( +
+
+ ): void { + setSearchQuery(e.target.value); + }} + placeholder={t('locationComponent.search')} + /> + } + actionIcon={faPlus} + actionLabel={t('locationComponent.addSectionLabel')} + addButtonCallBack={async function (): Promise { + onAddSection(); + }} + /> +
+ +
+ {filteredSections.length === 0 ? ( +
+
+ +
+

+ {t('locationComponent.noSectionAvailable')} +

+

+ {t('locationComponent.noSectionDescription')} +

+
+ ) : ( + filteredSections.map(function (section: LocationProps, index: number): React.JSX.Element { + return ( +
+
+ +
+ +
+
+ {section.name} +
+
+ {t('locationComponent.elementsCount', {count: countTotalElements(section)})} +
+
+ +
+ +
+
+ ); + }) + )} +
+
+ ); +} diff --git a/components/book/settings/locations/settings/LocationSettings.tsx b/components/book/settings/locations/settings/LocationSettings.tsx new file mode 100644 index 0000000..dcddaf4 --- /dev/null +++ b/components/book/settings/locations/settings/LocationSettings.tsx @@ -0,0 +1,228 @@ +'use client'; +import React, {useContext, useMemo, useState} from 'react'; +import {useLocations, UseLocationsConfig, LocationProps, Element, SubElement} from '@/hooks/settings/useLocations'; +import {useTranslations} from 'next-intl'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons'; +import {BookContext} from '@/context/BookContext'; +import {SeriesLocationItem} from '@/lib/models/Series'; +import InputField from '@/components/form/InputField'; +import ToggleSwitch from '@/components/form/ToggleSwitch'; +import SeriesImportSelector from '@/components/form/SeriesImportSelector'; +import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader'; +import AlertBox from '@/components/AlertBox'; + +import LocationSettingsList from './LocationSettingsList'; +import LocationSettingsDetail from './LocationSettingsDetail'; +import LocationSettingsEdit from './LocationSettingsEdit'; + +interface LocationSettingsProps { + entityType?: 'book' | 'series'; + entityId?: string; + showToggle?: boolean; +} + +/** + * LocationSettings - Orchestrateur pour BookSetting/SerieSetting + * Gère le viewMode (list/detail/edit) et coordonne les sous-composants + * Inclut: toggle tool, import from series, export to series + */ +export default function LocationSettings({ + entityType = 'book', + entityId, + showToggle = true, +}: LocationSettingsProps): React.JSX.Element { + const t = useTranslations(); + const {book} = useContext(BookContext); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const resolvedEntityId: string = entityId || book?.bookId || ''; + + const config: UseLocationsConfig = useMemo(function (): UseLocationsConfig { + return { + entityType, + entityId: resolvedEntityId, + }; + }, [entityType, resolvedEntityId]); + + const { + sections, + seriesLocations, + toolEnabled, + isLoading, + isSeriesMode, + bookSeriesId, + newSectionName, + newElementNames, + newSubElementNames, + viewMode, + selectedSectionIndex, + addSection, + addElement, + addSubElement, + removeSection, + removeElement, + removeSubElement, + updateElement, + updateSubElement, + saveLocations, + toggleTool, + importFromSeries, + exportToSeries, + setNewSectionName, + setNewElementNames, + setNewSubElementNames, + enterDetailMode, + enterEditMode, + exitEditMode, + backToList, + } = useLocations(config); + + const availableSeriesLocations = useMemo(function (): SeriesLocationItem[] { + return seriesLocations.filter(function (sl: SeriesLocationItem): boolean { + return !sections.some(function (s: LocationProps): boolean { + return s.seriesLocationId === sl.id; + }); + }); + }, [seriesLocations, sections]); + + async function handleSave(): Promise { + await exitEditMode(true); + } + + function handleCancel(): void { + exitEditMode(false); + } + + async function handleDelete(): Promise { + if (selectedSectionIndex >= 0 && sections[selectedSectionIndex]) { + await removeSection(sections[selectedSectionIndex].id); + setShowDeleteConfirm(false); + backToList(); + } + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + const selectedSection: LocationProps | undefined = sections[selectedSectionIndex]; + const canExport: boolean = Boolean(bookSeriesId && selectedSection && !selectedSection.seriesLocationId); + + return ( +
+ {/* Header - uniquement pour detail/edit */} + { return exportToSeries(selectedSection!); } : undefined} + showExport={canExport} + showDelete={Boolean(selectedSection)} + /> + + {/* Contenu principal */} +
+ {viewMode === 'list' && ( +
+ {/* Toggle tool */} + {showToggle && !isSeriesMode && ( +
+ + } + /> +

+ {t('locationComponent.enableToolDescription')} +

+
+ )} + + {/* Contenu si outil activé */} + {(toolEnabled || isSeriesMode) && ( + <> + {/* Import from series */} + {!isSeriesMode && bookSeriesId && availableSeriesLocations.length > 0 && ( + + )} + + {/* Liste des sections */} + + + )} +
+ )} + + {viewMode === 'detail' && selectedSection && ( +
+ +
+ )} + + {viewMode === 'edit' && selectedSection && ( +
+ +
+ )} +
+ + {/* Modal de confirmation de suppression */} + {showDeleteConfirm && selectedSection && ( + + )} +
+ ); +} diff --git a/components/book/settings/locations/settings/LocationSettingsDetail.tsx b/components/book/settings/locations/settings/LocationSettingsDetail.tsx new file mode 100644 index 0000000..6af7b12 --- /dev/null +++ b/components/book/settings/locations/settings/LocationSettingsDetail.tsx @@ -0,0 +1,89 @@ +'use client'; +import React from 'react'; +import {LocationProps, Element, SubElement} from '@/hooks/settings/useLocations'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faMapMarkerAlt, faMapPin, faLocationDot, faChevronRight} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; + +interface LocationSettingsDetailProps { + section: LocationProps; +} + +export default function LocationSettingsDetail({ + section, +}: LocationSettingsDetailProps): React.JSX.Element { + const t = useTranslations(); + + return ( +
+ {/* Hero Section */} +
+
+
+ +
+
+

{section.name}

+

+ {t("locationComponent.elementsCount", {count: section.elements.length})} +

+
+
+
+ + {/* Éléments en grille */} + {section.elements.length === 0 ? ( +
+ +

{t("locationComponent.noElementAvailable")}

+
+ ) : ( +
+ {section.elements.map(function (element: Element): React.JSX.Element { + return ( +
+ {/* Element header */} +
+
+ +
+

{element.name}

+
+ + {/* Description */} +

+ {element.description || '—'} +

+ + {/* Sub-elements */} + {element.subElements.length > 0 && ( +
+

+ + {t("locationComponent.subElementsHeading")} ({element.subElements.length}) +

+
+ {element.subElements.map(function (subElement: SubElement): React.JSX.Element { + return ( +
+ +
+

{subElement.name}

+ {subElement.description && ( +

{subElement.description}

+ )} +
+
+ ); + })} +
+
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/components/book/settings/locations/settings/LocationSettingsEdit.tsx b/components/book/settings/locations/settings/LocationSettingsEdit.tsx new file mode 100644 index 0000000..5d56d1c --- /dev/null +++ b/components/book/settings/locations/settings/LocationSettingsEdit.tsx @@ -0,0 +1,169 @@ +'use client'; +import React, {ChangeEvent} from 'react'; +import {LocationProps, Element, SubElement} from '@/hooks/settings/useLocations'; +import InputField from '@/components/form/InputField'; +import TextInput from '@/components/form/TextInput'; +import TexteAreaInput from '@/components/form/TexteAreaInput'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faMapMarkerAlt, faPlus, faTrash} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; + +interface LocationSettingsEditProps { + section: LocationProps; + newElementNames: { [key: string]: string }; + newSubElementNames: { [key: string]: string }; + onAddElement: (sectionId: string) => Promise; + onAddSubElement: (sectionId: string, elementIndex: number) => Promise; + onRemoveElement: (sectionId: string, elementIndex: number) => Promise; + onRemoveSubElement: (sectionId: string, elementIndex: number, subElementIndex: number) => Promise; + onUpdateElement: (sectionId: string, elementIndex: number, field: keyof Element, value: string) => void; + onUpdateSubElement: (sectionId: string, elementIndex: number, subElementIndex: number, field: keyof SubElement, value: string) => void; + onNewElementNameChange: (sectionId: string, name: string) => void; + onNewSubElementNameChange: (elementIndex: number, name: string) => void; +} + +/** + * LocationSettingsEdit - Vue édition pour BookSetting/SerieSetting + * Permet d'éditer les éléments et sous-éléments + * PAS de scroll interne (géré par parent) + */ +export default function LocationSettingsEdit({ + section, + newElementNames, + newSubElementNames, + onAddElement, + onAddSubElement, + onRemoveElement, + onRemoveSubElement, + onUpdateElement, + onUpdateSubElement, + onNewElementNameChange, + onNewSubElementNameChange, +}: LocationSettingsEditProps): React.JSX.Element { + const t = useTranslations(); + + return ( +
+ {/* Header de la section */} +
+
+ +
+
+

{section.name}

+
+
+ + {/* Éléments existants */} + {section.elements.map(function (element: Element, elementIndex: number): React.JSX.Element { + return ( +
+
+ ): void { + onUpdateElement(section.id, elementIndex, 'name', e.target.value); + }} + placeholder={t("locationComponent.elementNamePlaceholder")} + /> + } + removeButtonCallBack={function (): Promise { + return onRemoveElement(section.id, elementIndex); + }} + /> +
+ + ): void { + onUpdateElement(section.id, elementIndex, 'description', e.target.value); + }} + placeholder={t("locationComponent.elementDescriptionPlaceholder")} + /> + + {/* Sous-éléments */} +
+ {element.subElements.length > 0 && ( +

+ {t("locationComponent.subElementsHeading")} +

+ )} + + {element.subElements.map(function (subElement: SubElement, subElementIndex: number): React.JSX.Element { + return ( +
+
+ ): void { + onUpdateSubElement(section.id, elementIndex, subElementIndex, 'name', e.target.value); + }} + placeholder={t("locationComponent.subElementNamePlaceholder")} + /> + } + removeButtonCallBack={function (): Promise { + return onRemoveSubElement(section.id, elementIndex, subElementIndex); + }} + /> +
+ ): void { + onUpdateSubElement(section.id, elementIndex, subElementIndex, 'description', e.target.value); + }} + placeholder={t("locationComponent.subElementDescriptionPlaceholder")} + /> +
+ ); + })} + + {/* Ajouter sous-élément */} + ): void { + onNewSubElementNameChange(elementIndex, e.target.value); + }} + placeholder={t("locationComponent.newSubElementPlaceholder")} + /> + } + actionIcon={faPlus} + actionLabel={t("locationComponent.addSubElement")} + addButtonCallBack={function (): Promise { + return onAddSubElement(section.id, elementIndex); + }} + /> +
+
+ ); + })} + + {/* Ajouter élément */} +
+ ): void { + onNewElementNameChange(section.id, e.target.value); + }} + placeholder={t("locationComponent.newElementPlaceholder")} + /> + } + actionIcon={faPlus} + actionLabel={t("locationComponent.addElement")} + addButtonCallBack={function (): Promise { + return onAddElement(section.id); + }} + /> +
+
+ ); +} diff --git a/components/book/settings/locations/settings/LocationSettingsList.tsx b/components/book/settings/locations/settings/LocationSettingsList.tsx new file mode 100644 index 0000000..9bed495 --- /dev/null +++ b/components/book/settings/locations/settings/LocationSettingsList.tsx @@ -0,0 +1,106 @@ +'use client'; +import React, {ChangeEvent} from 'react'; +import {LocationProps, Element} from '@/hooks/settings/useLocations'; +import InputField from '@/components/form/InputField'; +import TextInput from '@/components/form/TextInput'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faChevronRight, faMapMarkerAlt, faPlus} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; + +interface LocationSettingsListProps { + sections: LocationProps[]; + newSectionName: string; + onSectionClick: (sectionIndex: number) => void; + onAddSection: () => Promise; + onNewSectionNameChange: (name: string) => void; +} + +/** + * LocationSettingsList - Liste des sections de lieux pour BookSetting/SerieSetting + * Inclut recherche et bouton d'ajout + * PAS de scroll interne (géré par parent) + */ +export default function LocationSettingsList({ + sections, + newSectionName, + onSectionClick, + onAddSection, + onNewSectionNameChange, +}: LocationSettingsListProps): React.JSX.Element { + const t = useTranslations(); + + function countTotalElements(section: LocationProps): number { + let count: number = section.elements.length; + section.elements.forEach(function (element: Element): void { + count += element.subElements.length; + }); + return count; + } + + return ( +
+
+ ): void { + onNewSectionNameChange(e.target.value); + }} + placeholder={t("locationComponent.newSectionPlaceholder")} + /> + } + actionIcon={faPlus} + actionLabel={t("locationComponent.addSectionLabel")} + addButtonCallBack={onAddSection} + /> +
+ +
+ {sections.length === 0 ? ( +
+
+ +
+

+ {t("locationComponent.noSectionAvailable")} +

+

+ {t("locationComponent.noSectionDescription")} +

+
+ ) : ( + sections.map(function (section: LocationProps, index: number): React.JSX.Element { + return ( +
+
+ +
+ +
+
+ {section.name} +
+
+ {t("locationComponent.elementsCount", {count: countTotalElements(section)})} +
+
+ +
+ +
+
+ ); + }) + )} +
+
+ ); +} diff --git a/components/book/settings/spells/SpellComponent.tsx b/components/book/settings/spells/SpellComponent.tsx deleted file mode 100644 index 58265ce..0000000 --- a/components/book/settings/spells/SpellComponent.tsx +++ /dev/null @@ -1,563 +0,0 @@ -'use client'; -import React, {forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react'; -import { - initialSpellState, - SpellEditState, - SpellListItem, - SpellListResponse, - SpellProps, - SpellPropsPost, - SpellTagProps -} from "@/lib/models/Spell"; -import {SessionContext} from "@/context/SessionContext"; -import {AlertContext} from "@/context/AlertContext"; -import {BookContext} from "@/context/BookContext"; -import {LangContext, LangContextProps} from "@/context/LangContext"; -import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; -import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext"; -import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext"; -import {SyncedBook} from "@/lib/models/SyncedBook"; -import System from '@/lib/models/System'; -import {useTranslations} from "next-intl"; -import ToggleSwitch from "@/components/form/ToggleSwitch"; -import InputField from "@/components/form/InputField"; -import {faToggleOn} from "@fortawesome/free-solid-svg-icons"; -import SpellList from "@/components/book/settings/spells/SpellList"; -import SpellDetail from "@/components/book/settings/spells/SpellDetail"; -import SpellTagManager from "@/components/book/settings/spells/SpellTagManager"; - -interface SpellComponentProps { - showToggle?: boolean; -} - -export function SpellComponent(props: SpellComponentProps, ref: React.Ref<{ handleSave: () => Promise }>) { - const {showToggle = true} = props; - const t = useTranslations(); - const {lang} = useContext(LangContext); - const {isCurrentlyOffline} = useContext(OfflineContext); - const {addToQueue} = useContext(LocalSyncQueueContext); - const {localSyncedBooks} = useContext(BooksSyncContext); - const {session} = useContext(SessionContext); - const {book, setBook} = useContext(BookContext); - const {errorMessage, successMessage} = useContext(AlertContext); - - const bookId: string | undefined = book?.bookId; - const token: string = session.accessToken; - - const [spells, setSpells] = useState([]); - const [tags, setTags] = useState([]); - const [selectedSpell, setSelectedSpell] = useState(null); - const [toolEnabled, setToolEnabled] = useState(book?.tools?.spells ?? false); - const [showTagManager, setShowTagManager] = useState(false); - - useImperativeHandle(ref, function () { - return { - handleSave: handleSaveSpell, - }; - }); - - useEffect((): void => { - getSpells().then(); - }, []); - - async function handleToggleTool(enabled: boolean): Promise { - try { - let response: boolean; - if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:book:tool:update', { - bookId: bookId, - toolName: 'spells', - enabled: enabled - }); - } else { - response = await System.authPatchToServer('book/tool-setting', { - bookId: bookId, - toolName: 'spells', - enabled: enabled - }, token, lang); - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:book:tool:update', { - bookId: bookId, - toolName: 'spells', - enabled: enabled - }); - } - } - if (response && setBook && book) { - setToolEnabled(enabled); - setBook({ - ...book, tools: { - characters: book.tools?.characters ?? false, - worlds: book.tools?.worlds ?? false, - locations: book.tools?.locations ?? false, - spells: enabled - } - }); - } - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(e.message); - } - } - } - - async function getSpells(): Promise { - try { - let response: SpellListResponse; - if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:spell:list', {bookid: bookId}); - } else { - if (book?.localBook) { - response = await window.electron.invoke('db:spell:list', {bookid: bookId}); - } else { - response = await System.authGetQueryToServer('spell/list', token, lang, { - bookid: bookId, - }); - } - } - if (response) { - setSpells(response.spells); - setTags(response.tags); - setToolEnabled(response.enabled); - if (setBook && book) { - setBook({ - ...book, tools: { - characters: book.tools?.characters ?? false, - worlds: book.tools?.worlds ?? false, - locations: book.tools?.locations ?? false, - spells: response.enabled - } - }); - } - } - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(e.message); - } else { - errorMessage(t("common.unknownError")); - } - } - } - - async function handleSpellClick(spell: SpellListItem): Promise { - // Convertir les tags de SpellTagProps[] vers string[] (IDs) - const tagIds: string[] = spell.tags.map((tag: SpellTagProps): string => tag.id); - - // D'abord afficher avec les données de la liste - setSelectedSpell({ - id: spell.id, - name: spell.name, - description: spell.description, - appearance: '', - tags: tagIds, - powerLevel: null, - components: null, - limitations: null, - notes: null, - }); - try { - let response: SpellProps; - if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:spell:detail', {spellid: spell.id}); - } else { - if (book?.localBook) { - response = await window.electron.invoke('db:spell:detail', {spellid: spell.id}); - } else { - response = await System.authGetQueryToServer('spell/detail', token, lang, { - spellid: spell.id, - }); - } - } - if (response) { - setSelectedSpell((prev: SpellEditState | null): SpellEditState | null => { - if (!prev) return null; - return { - ...prev, - appearance: response.appearance, - powerLevel: response.powerLevel, - components: response.components, - limitations: response.limitations, - notes: response.notes, - // Garder les tags de la liste, pas ceux de l'API - }; - }); - } - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(e.message); - } - } - } - - function handleAddSpell(): void { - setSelectedSpell({...initialSpellState}); - } - - function handleSpellChange(key: keyof SpellEditState, value: string | string[] | null): void { - if (selectedSpell) { - setSelectedSpell({...selectedSpell, [key]: value}); - } - } - - async function handleSaveSpell(): Promise { - if (selectedSpell) { - if (selectedSpell.id === null) { - await addNewSpell(selectedSpell); - } else { - await updateSpell(selectedSpell); - } - } - } - - async function addNewSpell(spell: SpellEditState): Promise { - if (!spell.name) { - errorMessage(t("spellComponent.errorNameRequired")); - return; - } - if (!spell.description) { - errorMessage(t("spellComponent.errorDescriptionRequired")); - return; - } - if (!spell.appearance) { - errorMessage(t("spellComponent.errorAppearanceRequired")); - return; - } - try { - const spellPost: SpellPropsPost = { - name: spell.name, - description: spell.description, - appearance: spell.appearance, - tags: spell.tags, - powerLevel: spell.powerLevel, - components: spell.components, - limitations: spell.limitations, - notes: spell.notes, - }; - let spellId: string; - if (isCurrentlyOffline() || book?.localBook) { - spellId = await window.electron.invoke('db:spell:create', { - bookId: bookId, - spell: spellPost, - }); - } else { - const createdSpell: SpellProps = await System.authPostToServer('spell/add', { - bookId: bookId, - spell: spellPost, - }, token, lang); - if (!createdSpell || !createdSpell.id) { - errorMessage(t("spellComponent.errorAddSpell")); - return; - } - spellId = createdSpell.id; - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:spell:create', { - bookId: bookId, - spell: {...spellPost, id: spellId}, - }); - } - } - if (!spellId) { - errorMessage(t("spellComponent.errorAddSpell")); - return; - } - // Ajouter à la liste avec les tags résolus - const resolvedTags: SpellTagProps[] = tags.filter((tag: SpellTagProps) => spell.tags.includes(tag.id)); - const newSpellListItem: SpellListItem = { - id: spellId, - name: spell.name, - description: spell.description.length > 150 - ? spell.description.substring(0, 150) + '...' - : spell.description, - tags: resolvedTags, - }; - setSpells([...spells, newSpellListItem]); - setSelectedSpell(null); - successMessage(t("spellComponent.successAdd")); - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(e.message); - } else { - errorMessage(t("common.unknownError")); - } - } - } - - async function updateSpell(spell: SpellEditState): Promise { - if (!spell.id) return; - if (!spell.name) { - errorMessage(t("spellComponent.errorNameRequired")); - return; - } - if (!spell.description) { - errorMessage(t("spellComponent.errorDescriptionRequired")); - return; - } - if (!spell.appearance) { - errorMessage(t("spellComponent.errorAppearanceRequired")); - return; - } - try { - const spellPost: SpellPropsPost = { - name: spell.name, - description: spell.description, - appearance: spell.appearance, - tags: spell.tags, - powerLevel: spell.powerLevel, - components: spell.components, - limitations: spell.limitations, - notes: spell.notes, - }; - let response: boolean; - if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:spell:update', { - spellId: spell.id, - spell: spellPost, - }); - } else { - response = await System.authPutToServer('spell/update', { - spellId: spell.id, - spell: spellPost, - }, token, lang); - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:spell:update', { - spellId: spell.id, - spell: spellPost, - }); - } - } - if (!response) { - errorMessage(t("spellComponent.errorUpdateSpell")); - return; - } - // Mettre à jour la liste avec les tags résolus - const resolvedTags: SpellTagProps[] = tags.filter((tag: SpellTagProps) => spell.tags.includes(tag.id)); - setSpells(spells.map((s: SpellListItem): SpellListItem => - s.id === spell.id ? { - id: spell.id, - name: spell.name, - description: spell.description.length > 150 - ? spell.description.substring(0, 150) + '...' - : spell.description, - tags: resolvedTags, - } : s - )); - setSelectedSpell(null); - successMessage(t("spellComponent.successUpdate")); - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(e.message); - } else { - errorMessage(t("common.unknownError")); - } - } - } - - async function handleDeleteSpell(spellId: string): Promise { - try { - let response: boolean; - if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:spell:delete', { - spellId: spellId, - }); - } else { - response = await System.authDeleteToServer('spell/delete', { - spellId: spellId, - }, token, lang); - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:spell:delete', { - spellId: spellId, - }); - } - } - if (!response) { - errorMessage(t("spellComponent.errorDeleteSpell")); - return; - } - setSpells(spells.filter((s: SpellListItem) => s.id !== spellId)); - setSelectedSpell(null); - successMessage(t("spellComponent.successDelete")); - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(e.message); - } else { - errorMessage(t("common.unknownError")); - } - } - } - - function handleManageTags(): void { - setShowTagManager(true); - } - - function handleBackFromTagManager(): void { - setShowTagManager(false); - } - - async function handleCreateTag(name: string, color: string): Promise { - try { - let tagId: string; - if (isCurrentlyOffline() || book?.localBook) { - tagId = await window.electron.invoke('db:spell:tag:create', { - bookId: bookId, - name: name, - color: color, - }); - } else { - const newTag: SpellTagProps = await System.authPostToServer('spell/tag/add', { - bookId: bookId, - name: name, - color: color, - }, token, lang); - if (!newTag || !newTag.id) { - return null; - } - tagId = newTag.id; - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:spell:tag:create', { - bookId: bookId, - name: name, - color: color, - tagId: tagId, - }); - } - } - if (tagId) { - const createdTag: SpellTagProps = {id: tagId, name: name, color: color}; - setTags([...tags, createdTag]); - return createdTag; - } - return null; - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(e.message); - } - return null; - } - } - - async function handleUpdateTag(tagId: string, name: string, color: string): Promise { - try { - let response: boolean; - if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:spell:tag:update', { - tagId: tagId, - name: name, - color: color, - }); - } else { - response = await System.authPutToServer('spell/tag/update', { - tagId: tagId, - name: name, - color: color, - }, token, lang); - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:spell:tag:update', { - tagId: tagId, - name: name, - color: color, - }); - } - } - if (response) { - setTags(tags.map((tag: SpellTagProps): SpellTagProps => - tag.id === tagId ? {id: tagId, name: name, color: color} : tag - )); - return true; - } - return false; - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(e.message); - } - return false; - } - } - - async function handleDeleteTag(tagId: string): Promise { - try { - let response: boolean; - if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:spell:tag:delete', { - tagId: tagId, - bookId: bookId, - }); - } else { - response = await System.authDeleteToServer('spell/tag/delete', { - tagId: tagId, - bookId: bookId, - }, token, lang); - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:spell:tag:delete', { - tagId: tagId, - bookId: bookId, - }); - } - } - if (response) { - setTags(tags.filter((tag: SpellTagProps): boolean => tag.id !== tagId)); - return true; - } - return false; - } catch (e: unknown) { - if (e instanceof Error) { - errorMessage(e.message); - } - return false; - } - } - - return ( -
- {showToggle && ( -
- => handleToggleTool(checked)} - /> - } - /> -

- {t('spellComponent.enableToolDescription')} -

-
- )} - {toolEnabled && ( - <> - {showTagManager ? ( - - ) : selectedSpell ? ( - - ) : ( - - )} - - )} -
- ); -} - -export default forwardRef(SpellComponent); diff --git a/components/book/settings/spells/SpellDetail.tsx b/components/book/settings/spells/SpellDetail.tsx deleted file mode 100644 index f77fefb..0000000 --- a/components/book/settings/spells/SpellDetail.tsx +++ /dev/null @@ -1,310 +0,0 @@ -'use client'; -import React, {Dispatch, SetStateAction, useState} from 'react'; -import {defaultTagColors, SpellEditState, spellPowerLevels, SpellTagProps} from "@/lib/models/Spell"; -import {SelectBoxProps} from "@/shared/interface"; -import CollapsableArea from "@/components/CollapsableArea"; -import InputField from "@/components/form/InputField"; -import TextInput from "@/components/form/TextInput"; -import TexteAreaInput from "@/components/form/TexteAreaInput"; -import SelectBox from "@/components/form/SelectBox"; -import SpellTagChip from "@/components/book/settings/spells/SpellTagChip"; -import DeleteButton from "@/components/form/DeleteButton"; -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import { - faArrowLeft, - faBolt, - faBook, - faEye, - faHatWizard, - faPlus, - faPuzzlePiece, - faSave, - faStickyNote, - faTags, - faTriangleExclamation -} from "@fortawesome/free-solid-svg-icons"; -import {useTranslations} from "next-intl"; - -interface SpellDetailProps { - selectedSpell: SpellEditState; - setSelectedSpell: Dispatch>; - availableTags: SpellTagProps[]; - handleSpellChange: (key: keyof SpellEditState, value: string | string[] | null) => void; - handleSaveSpell: () => Promise; - handleDeleteSpell: (spellId: string) => Promise; - handleCreateTagInline: (name: string, color: string) => Promise; -} - -export default function SpellDetail( - { - selectedSpell, - setSelectedSpell, - availableTags, - handleSpellChange, - handleSaveSpell, - handleDeleteSpell, - handleCreateTagInline, - }: SpellDetailProps) { - const t = useTranslations(); - - const [tagSearchQuery, setTagSearchQuery] = useState(''); - const [showTagDropdown, setShowTagDropdown] = useState(false); - const [isCreatingTag, setIsCreatingTag] = useState(false); - const [newTagColor, setNewTagColor] = useState(defaultTagColors[0]); - - function handleAddTag(tagId: string): void { - if (!selectedSpell.tags.includes(tagId)) { - handleSpellChange('tags', [...selectedSpell.tags, tagId]); - } - setTagSearchQuery(''); - setShowTagDropdown(false); - } - - function handleRemoveTag(tagId: string): void { - handleSpellChange('tags', selectedSpell.tags.filter((id: string) => id !== tagId)); - } - - function getFilteredAvailableTags(): SpellTagProps[] { - return availableTags.filter((tag: SpellTagProps) => { - const notAlreadyAdded = !selectedSpell.tags.includes(tag.id); - const matchesSearch = tag.name.toLowerCase().includes(tagSearchQuery.toLowerCase()); - return notAlreadyAdded && matchesSearch; - }); - } - - function getSelectedTags(): SpellTagProps[] { - return availableTags.filter((tag: SpellTagProps) => selectedSpell.tags.includes(tag.id)); - } - - async function handleCreateTag(): Promise { - if (!tagSearchQuery.trim()) { - return; - } - const newTag: SpellTagProps | null = await handleCreateTagInline(tagSearchQuery.trim(), newTagColor); - if (newTag) { - handleAddTag(newTag.id); - setIsCreatingTag(false); - setNewTagColor(defaultTagColors[0]); - } - } - - function getLocalizedPowerLevels(): SelectBoxProps[] { - return spellPowerLevels.map((level: SelectBoxProps): SelectBoxProps => ({ - value: level.value, - label: t(level.label), - })); - } - - const filteredTags = getFilteredAvailableTags(); - const selectedTags = getSelectedTags(); - const showCreateOption = tagSearchQuery.trim() && !availableTags.some((tag: SpellTagProps) => tag.name.toLowerCase() === tagSearchQuery.toLowerCase()); - - - return ( -
-
- - - {selectedSpell.name || t("spellDetail.newSpell")} - -
- {selectedSpell.id && ( - => handleDeleteSpell(selectedSpell.id as string)} - confirmTitle={t("spellDetail.deleteTitle")} - confirmMessage={t("spellDetail.deleteMessage", {name: selectedSpell.name})} - confirmButtonText={t("common.delete")} - cancelButtonText={t("common.cancel")} - /> - )} - -
-
- -
- -
- handleSpellChange('name', e.target.value)} - placeholder={t("spellDetail.namePlaceholder")} - /> - } - /> - - handleSpellChange('description', e.target.value)} - placeholder={t("spellDetail.descriptionPlaceholder")} - /> - } - /> - - handleSpellChange('appearance', e.target.value)} - placeholder={t("spellDetail.appearancePlaceholder")} - /> - } - /> -
-
- - -
- {selectedTags.length > 0 && ( -
- {selectedTags.map((tag: SpellTagProps) => ( - handleRemoveTag(tag.id)} - /> - ))} -
- )} - -
- { - setTagSearchQuery(e.target.value); - setShowTagDropdown(true); - }} - placeholder={t("spellDetail.addTag")} - onFocus={() => setShowTagDropdown(true)} - /> - - {showTagDropdown && (tagSearchQuery || filteredTags.length > 0) && ( -
- {filteredTags.map((tag: SpellTagProps) => ( - - ))} - - {showCreateOption && !isCreatingTag && ( - - )} - - {isCreatingTag && ( -
-

- {t("spellDetail.createTag", {name: tagSearchQuery})} -

-
- {defaultTagColors.map((color: string) => ( -
-
- - -
-
- )} -
- )} -
-
-
- - -
- handleSpellChange('powerLevel', e.target.value === 'none' ? null : e.target.value)} - data={getLocalizedPowerLevels()} - /> -
-
- - -
- handleSpellChange('components', e.target.value || null)} - placeholder={t("spellDetail.componentsPlaceholder")} - /> -
-
- - -
- handleSpellChange('limitations', e.target.value || null)} - placeholder={t("spellDetail.limitationsPlaceholder")} - /> -
-
- - -
- handleSpellChange('notes', e.target.value || null)} - placeholder={t("spellDetail.notesPlaceholder")} - /> -
-
-
-
- ); -} diff --git a/components/book/settings/spells/SpellList.tsx b/components/book/settings/spells/SpellList.tsx deleted file mode 100644 index 871a1fd..0000000 --- a/components/book/settings/spells/SpellList.tsx +++ /dev/null @@ -1,141 +0,0 @@ -'use client'; -import React, {useState} from 'react'; -import {SpellListItem, SpellTagProps} from "@/lib/models/Spell"; -import InputField from "@/components/form/InputField"; -import TextInput from "@/components/form/TextInput"; -import SpellTagChip from "@/components/book/settings/spells/SpellTagChip"; -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faChevronRight, faCog, faHatWizard, faPlus} from "@fortawesome/free-solid-svg-icons"; -import {useTranslations} from "next-intl"; - -interface SpellListProps { - spells: SpellListItem[]; - tags: SpellTagProps[]; - handleSpellClick: (spell: SpellListItem) => void; - handleAddSpell: () => void; - handleManageTags: () => void; -} - -export default function SpellList( - { - spells, - tags, - handleSpellClick, - handleAddSpell, - handleManageTags, - }: SpellListProps) { - const t = useTranslations(); - const [searchQuery, setSearchQuery] = useState(''); - const [filterTag, setFilterTag] = useState('all'); - - function getFilteredSpells(): SpellListItem[] { - return spells.filter((spell: SpellListItem) => { - const matchesSearch = spell.name.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesTag = filterTag === 'all' || spell.tags.some((tag: SpellTagProps) => tag.id === filterTag); - return matchesSearch && matchesTag; - }); - } - - const filteredSpells = getFilteredSpells(); - - return ( -
-
- setSearchQuery(e.target.value)} - placeholder={t("spellList.search")} - /> - } - actionIcon={faPlus} - actionLabel={t("spellList.add")} - addButtonCallBack={async () => handleAddSpell()} - /> - -
-
- -
- -
-
- -
- {filteredSpells.length === 0 ? ( -
-
- -
-

{t("spellList.noSpells")}

-

{t("spellList.noSpellsDescription")}

-
- ) : ( -
- {filteredSpells.map((spell: SpellListItem) => ( -
handleSpellClick(spell)} - className="group flex items-center p-4 bg-secondary/30 rounded-xl border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md hover:scale-102 transition-all duration-200 hover:border-primary/50" - > -
- -
- -
-
- {spell.name} -
-
- {spell.description} -
- {spell.tags.length > 0 && ( -
- {spell.tags.slice(0, 3).map((tag: SpellTagProps) => ( - - ))} - {spell.tags.length > 3 && ( - - +{spell.tags.length - 3} - - )} -
- )} -
- -
- -
-
- ))} -
- )} -
-
- ); -} diff --git a/components/book/settings/spells/editor/SpellEditor.tsx b/components/book/settings/spells/editor/SpellEditor.tsx new file mode 100644 index 0000000..0d05e9e --- /dev/null +++ b/components/book/settings/spells/editor/SpellEditor.tsx @@ -0,0 +1,217 @@ +'use client'; +import React, {useCallback, useContext, useMemo, useState} from 'react'; +import {useSpells, UseSpellsConfig} from '@/hooks/settings/useSpells'; +import {useTranslations} from 'next-intl'; +import {SpellEditState, SpellListItem} from '@/lib/models/Spell'; +import {SeriesSpellListItem} from '@/lib/models/Series'; +import {BookContext} from '@/context/BookContext'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons'; + +import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader'; +import InputField from '@/components/form/InputField'; +import ToggleSwitch from '@/components/form/ToggleSwitch'; +import SeriesImportSelector from '@/components/form/SeriesImportSelector'; +import AlertBox from '@/components/AlertBox'; +import SpellTagManager from '@/components/book/settings/spells/SpellTagManager'; + +import SpellEditorList from './SpellEditorList'; +import SpellEditorDetail from './SpellEditorDetail'; +import SpellEditorEdit from './SpellEditorEdit'; + +/** + * SpellEditor - Orchestrateur pour ComposerRightBar + * Mêmes fonctionnalités que SpellSettings, layout condensé + */ +export default function SpellEditor(): React.JSX.Element { + const t = useTranslations(); + const {book} = useContext(BookContext); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const config: UseSpellsConfig = useMemo(function (): UseSpellsConfig { + return { + entityType: 'book', + entityId: book?.bookId || '', + }; + }, [book?.bookId]); + + const { + spells, + seriesSpells, + tags, + selectedSpell, + selectedSeriesSpell, + toolEnabled, + isLoading, + bookSeriesId, + showTagManager, + viewMode, + saveSpell, + deleteSpell, + updateSpellField, + toggleTool, + importFromSeries, + exportToSeries, + setSelectedSpell, + setShowTagManager, + enterDetailMode, + enterEditMode, + exitEditMode, + backToList, + addNewSpell, + createTag, + updateTag, + deleteTag, + handleSyncComplete, + } = useSpells(config); + + const availableSeriesSpells = useMemo(function (): SeriesSpellListItem[] { + return seriesSpells.filter(function (ss: SeriesSpellListItem): boolean { + return !spells.some(function (s: SpellListItem): boolean { + return s.seriesSpellId === ss.id; + }); + }); + }, [seriesSpells, spells]); + + const handleSpellChange = useCallback(function (key: keyof SpellEditState, value: string | string[] | null): void { + updateSpellField(key, value); + }, [updateSpellField]); + + async function handleSave(): Promise { + await exitEditMode(true); + } + + function handleCancel(): void { + exitEditMode(false); + } + + async function handleDelete(): Promise { + if (selectedSpell?.id) { + await deleteSpell(selectedSpell.id); + setShowDeleteConfirm(false); + backToList(); + } + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + const isNew: boolean = selectedSpell?.id === null; + const canExport: boolean = Boolean(bookSeriesId && selectedSpell?.id && !selectedSpell.seriesSpellId); + + return ( +
+ + +
+ {viewMode === 'list' && ( +
+ {/* Toggle tool */} +
+ + } + /> +
+ + {toolEnabled && ( + <> + {/* Import from series */} + {bookSeriesId && availableSeriesSpells.length > 0 && ( + + )} + + + + )} +
+ )} + + {viewMode === 'detail' && selectedSpell && ( +
+ +
+ )} + + {viewMode === 'edit' && selectedSpell && ( +
+ +
+ )} +
+ + {showDeleteConfirm && selectedSpell?.id && ( + + )} + + {showTagManager && ( + + )} +
+ ); +} diff --git a/components/book/settings/spells/editor/SpellEditorDetail.tsx b/components/book/settings/spells/editor/SpellEditorDetail.tsx new file mode 100644 index 0000000..bc930b5 --- /dev/null +++ b/components/book/settings/spells/editor/SpellEditorDetail.tsx @@ -0,0 +1,79 @@ +'use client'; +import React from 'react'; +import {SpellEditState, spellPowerLevels, SpellTagProps} from '@/lib/models/Spell'; +import {SeriesSpellDetailResponse} from '@/lib/models/Series'; +import {SelectBoxProps} from '@/shared/interface'; +import SpellTagChip from '@/components/book/settings/spells/SpellTagChip'; +import {useTranslations} from 'next-intl'; + +interface SpellEditorDetailProps { + spell: SpellEditState; + availableTags: SpellTagProps[]; + seriesSpell?: SeriesSpellDetailResponse | null; +} + +/** + * SpellEditorDetail - Version sidebar lecture seule + * Layout linéaire simple, juste les infos essentielles empilées + * PAS de CollapsableArea, PAS de grids + */ +export default function SpellEditorDetail({ + spell, + availableTags, + seriesSpell, +}: SpellEditorDetailProps): React.JSX.Element { + const t = useTranslations(); + + function getSelectedTags(): SpellTagProps[] { + return availableTags.filter(function (tag: SpellTagProps): boolean { + return spell.tags.includes(tag.id); + }); + } + + function getLocalizedPowerLevel(): string { + if (!spell.powerLevel || spell.powerLevel === 'none') { + return ''; + } + const level: SelectBoxProps | undefined = spellPowerLevels.find(function (l: SelectBoxProps): boolean { + return l.value === spell.powerLevel; + }); + return level ? t(level.label) : spell.powerLevel; + } + + function renderField(label: string, value: string | null | undefined): React.JSX.Element | null { + if (!value) return null; + return ( +
+ {label} +

{value}

+
+ ); + } + + const selectedTags: SpellTagProps[] = getSelectedTags(); + const powerLevelText: string = getLocalizedPowerLevel(); + + return ( +
+

{spell.name}

+ + {renderField(t('spellDetail.description'), spell.description)} + {renderField(t('spellDetail.appearance'), spell.appearance)} + {powerLevelText && renderField(t('spellDetail.powerLevel'), powerLevelText)} + {renderField(t('spellDetail.components'), spell.components)} + {renderField(t('spellDetail.limitations'), spell.limitations)} + {renderField(t('spellDetail.notes'), spell.notes)} + + {selectedTags.length > 0 && ( +
+ {t('spellDetail.tags')} +
+ {selectedTags.map(function (tag: SpellTagProps): React.JSX.Element { + return ; + })} +
+
+ )} +
+ ); +} diff --git a/components/book/settings/spells/editor/SpellEditorEdit.tsx b/components/book/settings/spells/editor/SpellEditorEdit.tsx new file mode 100644 index 0000000..ad4d0ee --- /dev/null +++ b/components/book/settings/spells/editor/SpellEditorEdit.tsx @@ -0,0 +1,368 @@ +'use client'; +import React, {ChangeEvent, useState} from 'react'; +import {defaultTagColors, SpellEditState, spellPowerLevels, SpellTagProps} from '@/lib/models/Spell'; +import {SeriesSpellDetailResponse} from '@/lib/models/Series'; +import {SelectBoxProps} from '@/shared/interface'; +import InputField from '@/components/form/InputField'; +import TextInput from '@/components/form/TextInput'; +import TexteAreaInput from '@/components/form/TexteAreaInput'; +import SelectBox from '@/components/form/SelectBox'; +import SpellTagChip from '@/components/book/settings/spells/SpellTagChip'; +import SyncFieldWrapper from '@/components/form/SyncFieldWrapper'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faPlus} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; + +interface SpellEditorEditProps { + spell: SpellEditState; + availableTags: SpellTagProps[]; + onSpellChange: (key: keyof SpellEditState, value: string | string[] | null) => void; + onCreateTag: (name: string, color: string) => Promise; + seriesSpell?: SeriesSpellDetailResponse | null; + onSyncComplete?: () => void; +} + +/** + * SpellEditorEdit - Version sidebar édition + * Mêmes fonctionnalités que SpellSettingsEdit, layout linéaire + * Gestion des tags, SyncFieldWrapper, tous les champs + */ +export default function SpellEditorEdit({ + spell, + availableTags, + onSpellChange, + onCreateTag, + seriesSpell, + onSyncComplete, +}: SpellEditorEditProps): React.JSX.Element { + const t = useTranslations(); + + const [tagSearchQuery, setTagSearchQuery] = useState(''); + const [isCreatingTag, setIsCreatingTag] = useState(false); + const [newTagColor, setNewTagColor] = useState(defaultTagColors[0]); + + function handleAddTag(tagId: string): void { + if (!spell.tags.includes(tagId)) { + onSpellChange('tags', [...spell.tags, tagId]); + } + setTagSearchQuery(''); + } + + function handleRemoveTag(tagId: string): void { + onSpellChange('tags', spell.tags.filter(function (id: string): boolean { + return id !== tagId; + })); + } + + function getFilteredAvailableTags(): SpellTagProps[] { + return availableTags.filter(function (tag: SpellTagProps): boolean { + const notAlreadyAdded: boolean = !spell.tags.includes(tag.id); + const matchesSearch: boolean = tag.name.toLowerCase().includes(tagSearchQuery.toLowerCase()); + return notAlreadyAdded && matchesSearch; + }); + } + + function getSelectedTags(): SpellTagProps[] { + return availableTags.filter(function (tag: SpellTagProps): boolean { + return spell.tags.includes(tag.id); + }); + } + + async function handleCreateTag(): Promise { + if (!tagSearchQuery.trim()) return; + const newTag: SpellTagProps | null = await onCreateTag(tagSearchQuery.trim(), newTagColor); + if (newTag) { + handleAddTag(newTag.id); + setIsCreatingTag(false); + setNewTagColor(defaultTagColors[0]); + } + } + + function getLocalizedPowerLevels(): SelectBoxProps[] { + return spellPowerLevels.map(function (level: SelectBoxProps): SelectBoxProps { + return { + value: level.value, + label: t(level.label), + }; + }); + } + + const filteredTags: SpellTagProps[] = getFilteredAvailableTags(); + const selectedTags: SpellTagProps[] = getSelectedTags(); + const showCreateOption: boolean = Boolean( + tagSearchQuery.trim() && + !availableTags.some(function (tag: SpellTagProps): boolean { + return tag.name.toLowerCase() === tagSearchQuery.toLowerCase(); + }) + ); + + return ( +
+ {/* Informations de base */} +
+

{t('spellDetail.basicInfo')}

+
+ + ): void { + onSpellChange('name', e.target.value); + }} + placeholder={t('spellDetail.namePlaceholder')} + /> + + } + /> + + + ): void { + onSpellChange('description', e.target.value); + }} + placeholder={t('spellDetail.descriptionPlaceholder')} + /> + + } + /> + + + ): void { + onSpellChange('appearance', e.target.value); + }} + placeholder={t('spellDetail.appearancePlaceholder')} + /> + + } + /> +
+
+ + {/* Tags */} +
+

{t('spellDetail.tags')}

+
+ {selectedTags.length > 0 && ( +
+ {selectedTags.map(function (tag: SpellTagProps): React.JSX.Element { + return ( + + ); + })} +
+ )} + + ): void { + setTagSearchQuery(e.target.value); + }} + placeholder={t('spellDetail.addTag')} + /> + + {filteredTags.length > 0 && ( +
+ {filteredTags.map(function (tag: SpellTagProps): React.JSX.Element { + return ( + + ); + })} +
+ )} + + {showCreateOption && !isCreatingTag && ( + + )} + + {isCreatingTag && ( +
+

+ {t('spellDetail.createTag', {name: tagSearchQuery})} +

+
+ {defaultTagColors.map(function (color: string): React.JSX.Element { + return ( +
+
+ + +
+
+ )} +
+
+ + {/* Niveau de puissance */} +
+

{t('spellDetail.powerLevel')}

+ + ): void { + onSpellChange('powerLevel', e.target.value === 'none' ? null : e.target.value); + }} + data={getLocalizedPowerLevels()} + /> + +
+ + {/* Composants */} +
+

{t('spellDetail.components')}

+ + ): void { + onSpellChange('components', e.target.value || null); + }} + placeholder={t('spellDetail.componentsPlaceholder')} + /> + +
+ + {/* Limitations */} +
+

{t('spellDetail.limitations')}

+ + ): void { + onSpellChange('limitations', e.target.value || null); + }} + placeholder={t('spellDetail.limitationsPlaceholder')} + /> + +
+ + {/* Notes */} +
+

{t('spellDetail.notes')}

+ + ): void { + onSpellChange('notes', e.target.value || null); + }} + placeholder={t('spellDetail.notesPlaceholder')} + /> + +
+
+ ); +} diff --git a/components/book/settings/spells/editor/SpellEditorList.tsx b/components/book/settings/spells/editor/SpellEditorList.tsx new file mode 100644 index 0000000..56ba6bf --- /dev/null +++ b/components/book/settings/spells/editor/SpellEditorList.tsx @@ -0,0 +1,163 @@ +'use client'; +import React, {useState} from 'react'; +import {SpellListItem, SpellTagProps} from '@/lib/models/Spell'; +import InputField from '@/components/form/InputField'; +import TextInput from '@/components/form/TextInput'; +import SpellTagChip from '@/components/book/settings/spells/SpellTagChip'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faChevronRight, faHatWizard, faPlus, faTags} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; + +interface SpellEditorListProps { + spells: SpellListItem[]; + tags: SpellTagProps[]; + onSpellClick: (spell: SpellListItem) => void; + onAddSpell: () => void; + onManageTags: () => void; +} + +/** + * SpellEditorList - Liste compacte pour ComposerRightBar + * Mêmes fonctionnalités que SpellSettingsList, layout condensé + */ +export default function SpellEditorList({ + spells, + tags, + onSpellClick, + onAddSpell, + onManageTags, +}: SpellEditorListProps): React.JSX.Element { + const t = useTranslations(); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedTagId, setSelectedTagId] = useState(null); + + function getFilteredSpells(): SpellListItem[] { + return spells.filter(function (spell: SpellListItem): boolean { + const matchesSearch: boolean = spell.name.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesTag: boolean = selectedTagId === null || spell.tags.some(function (tag: SpellTagProps): boolean { + return tag.id === selectedTagId; + }); + return matchesSearch && matchesTag; + }); + } + + const filteredSpells: SpellListItem[] = getFilteredSpells(); + + return ( +
+
+ ): void { + setSearchQuery(e.target.value); + }} + placeholder={t('spellList.search')} + /> + } + actionIcon={faPlus} + actionLabel={t('spellList.add')} + addButtonCallBack={async function (): Promise { + onAddSpell(); + }} + /> +
+ + {/* Tag filter + manage button */} +
+ + {tags.slice(0, 4).map(function (tag: SpellTagProps): React.JSX.Element { + return ( + + ); + })} + +
+ +
+ {filteredSpells.length === 0 ? ( +
+
+ +
+

+ {t('spellList.noSpells')} +

+

+ {t('spellList.noSpellsDescription')} +

+
+ ) : ( + filteredSpells.map(function (spell: SpellListItem): React.JSX.Element { + return ( +
+
+ +
+ +
+
+ {spell.name} +
+
+ {spell.description} +
+ {spell.tags.length > 0 && ( +
+ {spell.tags.slice(0, 2).map(function (tag: SpellTagProps): React.JSX.Element { + return ; + })} + {spell.tags.length > 2 && ( + + +{spell.tags.length - 2} + + )} +
+ )} +
+ +
+ +
+
+ ); + }) + )} +
+
+ ); +} diff --git a/components/book/settings/spells/settings/SpellSettings.tsx b/components/book/settings/spells/settings/SpellSettings.tsx new file mode 100644 index 0000000..d0c7203 --- /dev/null +++ b/components/book/settings/spells/settings/SpellSettings.tsx @@ -0,0 +1,243 @@ +'use client'; +import React, {useCallback, useContext, useMemo, useState} from 'react'; +import {useSpells, UseSpellsConfig} from '@/hooks/settings/useSpells'; +import {useTranslations} from 'next-intl'; +import {SpellEditState, SpellListItem, SpellTagProps} from '@/lib/models/Spell'; +import {SeriesSpellListItem} from '@/lib/models/Series'; +import {BookContext} from '@/context/BookContext'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons'; + +import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader'; +import InputField from '@/components/form/InputField'; +import ToggleSwitch from '@/components/form/ToggleSwitch'; +import SeriesImportSelector from '@/components/form/SeriesImportSelector'; +import AlertBox from '@/components/AlertBox'; +import SpellTagManager from '@/components/book/settings/spells/SpellTagManager'; + +import SpellSettingsList from './SpellSettingsList'; +import SpellSettingsDetail from './SpellSettingsDetail'; +import SpellSettingsEdit from './SpellSettingsEdit'; + +interface SpellSettingsProps { + entityType?: 'book' | 'series'; + entityId?: string; + showToggle?: boolean; +} + +/** + * SpellSettings - Orchestrateur pour BookSetting/SerieSetting + * Gère le viewMode (list/detail/edit) et coordonne les sous-composants + * Inclut: toggle tool, import from series, tag manager + */ +export default function SpellSettings({ + entityType = 'book', + entityId, + showToggle = true, +}: SpellSettingsProps): React.JSX.Element { + const t = useTranslations(); + const {book} = useContext(BookContext); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const resolvedEntityId: string = entityId || book?.bookId || ''; + + const config: UseSpellsConfig = useMemo(function (): UseSpellsConfig { + return { + entityType, + entityId: resolvedEntityId, + }; + }, [entityType, resolvedEntityId]); + + const { + spells, + seriesSpells, + tags, + selectedSpell, + selectedSeriesSpell, + toolEnabled, + isLoading, + isSeriesMode, + bookSeriesId, + showTagManager, + viewMode, + saveSpell, + deleteSpell, + updateSpellField, + toggleTool, + importFromSeries, + exportToSeries, + refreshSeriesSpells, + setSelectedSpell, + setShowTagManager, + enterDetailMode, + enterEditMode, + exitEditMode, + backToList, + addNewSpell, + createTag, + updateTag, + deleteTag, + handleSyncComplete, + } = useSpells(config); + + const availableSeriesSpells = useMemo(function (): SeriesSpellListItem[] { + return seriesSpells.filter(function (ss: SeriesSpellListItem): boolean { + return !spells.some(function (s: SpellListItem): boolean { + return s.seriesSpellId === ss.id; + }); + }); + }, [seriesSpells, spells]); + + const handleSpellChange = useCallback(function (key: keyof SpellEditState, value: string | string[] | null): void { + updateSpellField(key, value); + }, [updateSpellField]); + + async function handleSave(): Promise { + await exitEditMode(true); + } + + function handleCancel(): void { + exitEditMode(false); + } + + async function handleDelete(): Promise { + if (selectedSpell?.id) { + await deleteSpell(selectedSpell.id); + setShowDeleteConfirm(false); + backToList(); + } + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + const isNew: boolean = selectedSpell?.id === null; + const canExport: boolean = Boolean(bookSeriesId && selectedSpell?.id && !selectedSpell.seriesSpellId); + + return ( +
+ {/* Header - uniquement pour detail/edit */} + + + {/* Contenu principal */} +
+ {viewMode === 'list' && ( +
+ {/* Toggle tool */} + {showToggle && !isSeriesMode && ( +
+ + } + /> +

+ {t('spellComponent.enableToolDescription')} +

+
+ )} + + {/* Contenu si outil activé */} + {(toolEnabled || isSeriesMode) && ( + <> + {/* Import from series */} + {!isSeriesMode && bookSeriesId && availableSeriesSpells.length > 0 && ( + + )} + + {/* Liste des sorts */} + + + )} +
+ )} + + {viewMode === 'detail' && selectedSpell && ( +
+ +
+ )} + + {viewMode === 'edit' && selectedSpell && ( +
+ +
+ )} +
+ + {/* Modal de confirmation de suppression */} + {showDeleteConfirm && selectedSpell?.id && ( + + )} + + {/* Tag Manager Modal */} + {showTagManager && ( + + )} +
+ ); +} diff --git a/components/book/settings/spells/settings/SpellSettingsDetail.tsx b/components/book/settings/spells/settings/SpellSettingsDetail.tsx new file mode 100644 index 0000000..8a5e55c --- /dev/null +++ b/components/book/settings/spells/settings/SpellSettingsDetail.tsx @@ -0,0 +1,150 @@ +'use client'; +import React from 'react'; +import {SpellEditState, spellPowerLevels, SpellTagProps} from '@/lib/models/Spell'; +import {SeriesSpellDetailResponse} from '@/lib/models/Series'; +import {SelectBoxProps} from '@/shared/interface'; +import SpellTagChip from '@/components/book/settings/spells/SpellTagChip'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import { + faBolt, + faEye, + faHatWizard, + faPuzzlePiece, + faStickyNote, + faTags, + faTriangleExclamation, + faWandMagicSparkles +} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; + +interface SpellSettingsDetailProps { + spell: SpellEditState; + availableTags: SpellTagProps[]; + seriesSpell?: SeriesSpellDetailResponse | null; +} + +export default function SpellSettingsDetail({ + spell, + availableTags, + seriesSpell, +}: SpellSettingsDetailProps): React.JSX.Element { + const t = useTranslations(); + + function getSelectedTags(): SpellTagProps[] { + return availableTags.filter(function (tag: SpellTagProps): boolean { + return spell.tags.includes(tag.id); + }); + } + + function getLocalizedPowerLevel(): string { + if (!spell.powerLevel || spell.powerLevel === 'none') { + return t('spellPowerLevels.none'); + } + const level: SelectBoxProps | undefined = spellPowerLevels.find(function (l: SelectBoxProps): boolean { + return l.value === spell.powerLevel; + }); + return level ? t(level.label) : spell.powerLevel; + } + + function getPowerLevelColor(): string { + switch (spell.powerLevel) { + case 'weak': return 'bg-green-500/20 text-green-400 border-green-500/30'; + case 'moderate': return 'bg-blue-500/20 text-blue-400 border-blue-500/30'; + case 'strong': return 'bg-orange-500/20 text-orange-400 border-orange-500/30'; + case 'legendary': return 'bg-purple-500/20 text-purple-400 border-purple-500/30'; + default: return 'bg-secondary/50 text-text-secondary border-secondary/50'; + } + } + + const selectedTags: SpellTagProps[] = getSelectedTags(); + + return ( +
+ {/* Hero Section */} +
+
+
+ +
+
+

{spell.name || '—'}

+ + {/* Power Level Badge */} +
+ + + {getLocalizedPowerLevel()} + +
+ + {/* Tags */} + {selectedTags.length > 0 && ( +
+ {selectedTags.map(function (tag: SpellTagProps): React.JSX.Element { + return ; + })} +
+ )} +
+
+
+ + {/* Description & Appearance - Side by side */} +
+
+
+ +

{t('spellDetail.description')}

+
+

+ {spell.description || '—'} +

+
+ +
+
+ +

{t('spellDetail.appearance')}

+
+

+ {spell.appearance || '—'} +

+
+
+ + {/* Components & Limitations - Side by side */} +
+
+
+ +

{t('spellDetail.components')}

+
+

+ {spell.components || '—'} +

+
+ +
+
+ +

{t('spellDetail.limitations')}

+
+

+ {spell.limitations || '—'} +

+
+
+ + {/* Notes - Full width */} +
+
+ +

{t('spellDetail.notes')}

+
+

+ {spell.notes || '—'} +

+
+
+ ); +} diff --git a/components/book/settings/spells/settings/SpellSettingsEdit.tsx b/components/book/settings/spells/settings/SpellSettingsEdit.tsx new file mode 100644 index 0000000..e9eaa78 --- /dev/null +++ b/components/book/settings/spells/settings/SpellSettingsEdit.tsx @@ -0,0 +1,384 @@ +'use client'; +import React, {useState} from 'react'; +import {defaultTagColors, SpellEditState, spellPowerLevels, SpellTagProps} from '@/lib/models/Spell'; +import {SeriesSpellDetailResponse} from '@/lib/models/Series'; +import {SelectBoxProps} from '@/shared/interface'; +import CollapsableArea from '@/components/CollapsableArea'; +import InputField from '@/components/form/InputField'; +import TextInput from '@/components/form/TextInput'; +import TexteAreaInput from '@/components/form/TexteAreaInput'; +import SelectBox from '@/components/form/SelectBox'; +import SpellTagChip from '@/components/book/settings/spells/SpellTagChip'; +import SyncFieldWrapper from '@/components/form/SyncFieldWrapper'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import { + faBolt, + faBook, + faEye, + faHatWizard, + faPlus, + faPuzzlePiece, + faStickyNote, + faTags, + faTriangleExclamation +} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; + +interface SpellSettingsEditProps { + spell: SpellEditState; + availableTags: SpellTagProps[]; + onSpellChange: (key: keyof SpellEditState, value: string | string[] | null) => void; + onCreateTag: (name: string, color: string) => Promise; + seriesSpell?: SeriesSpellDetailResponse | null; + onSyncComplete?: () => void; +} + +/** + * SpellSettingsEdit - Vue édition pour BookSetting/SerieSetting + * Tous les champs avec SyncFieldWrapper + * Gestion des tags inline + * PAS de scroll interne (géré par parent) + */ +export default function SpellSettingsEdit({ + spell, + availableTags, + onSpellChange, + onCreateTag, + seriesSpell, + onSyncComplete, +}: SpellSettingsEditProps): React.JSX.Element { + const t = useTranslations(); + + const [tagSearchQuery, setTagSearchQuery] = useState(''); + const [isCreatingTag, setIsCreatingTag] = useState(false); + const [newTagColor, setNewTagColor] = useState(defaultTagColors[0]); + + function handleAddTag(tagId: string): void { + if (!spell.tags.includes(tagId)) { + onSpellChange('tags', [...spell.tags, tagId]); + } + setTagSearchQuery(''); + } + + function handleRemoveTag(tagId: string): void { + onSpellChange('tags', spell.tags.filter(function (id: string): boolean { + return id !== tagId; + })); + } + + function getFilteredAvailableTags(): SpellTagProps[] { + return availableTags.filter(function (tag: SpellTagProps): boolean { + const notAlreadyAdded: boolean = !spell.tags.includes(tag.id); + const matchesSearch: boolean = tag.name.toLowerCase().includes(tagSearchQuery.toLowerCase()); + return notAlreadyAdded && matchesSearch; + }); + } + + function getSelectedTags(): SpellTagProps[] { + return availableTags.filter(function (tag: SpellTagProps): boolean { + return spell.tags.includes(tag.id); + }); + } + + async function handleCreateTag(): Promise { + if (!tagSearchQuery.trim()) return; + const newTag: SpellTagProps | null = await onCreateTag(tagSearchQuery.trim(), newTagColor); + if (newTag) { + handleAddTag(newTag.id); + setIsCreatingTag(false); + setNewTagColor(defaultTagColors[0]); + } + } + + function getLocalizedPowerLevels(): SelectBoxProps[] { + return spellPowerLevels.map(function (level: SelectBoxProps): SelectBoxProps { + return { + value: level.value, + label: t(level.label), + }; + }); + } + + const filteredTags: SpellTagProps[] = getFilteredAvailableTags(); + const selectedTags: SpellTagProps[] = getSelectedTags(); + const showCreateOption: boolean = Boolean( + tagSearchQuery.trim() && + !availableTags.some(function (tag: SpellTagProps): boolean { + return tag.name.toLowerCase() === tagSearchQuery.toLowerCase(); + }) + ); + + return ( +
+ {/* Informations de base */} + +
+ + ): void { + onSpellChange('name', e.target.value); + }} + placeholder={t('spellDetail.namePlaceholder')} + /> + + } + /> + + + ): void { + onSpellChange('description', e.target.value); + }} + placeholder={t('spellDetail.descriptionPlaceholder')} + /> + + } + /> + + + ): void { + onSpellChange('appearance', e.target.value); + }} + placeholder={t('spellDetail.appearancePlaceholder')} + /> + + } + /> +
+
+ + {/* Tags */} + +
+ {selectedTags.length > 0 && ( +
+ {selectedTags.map(function (tag: SpellTagProps): React.JSX.Element { + return ( + + ); + })} +
+ )} + + ): void { + setTagSearchQuery(e.target.value); + }} + placeholder={t('spellDetail.addTag')} + /> + + {filteredTags.length > 0 && ( +
+ {filteredTags.map(function (tag: SpellTagProps): React.JSX.Element { + return ( + + ); + })} +
+ )} + + {showCreateOption && !isCreatingTag && ( + + )} + + {isCreatingTag && ( +
+

+ {t('spellDetail.createTag', {name: tagSearchQuery})} +

+
+ {defaultTagColors.map(function (color: string): React.JSX.Element { + return ( +
+
+ + +
+
+ )} +
+
+ + {/* Niveau de puissance */} + +
+ + ): void { + onSpellChange('powerLevel', e.target.value === 'none' ? null : e.target.value); + }} + data={getLocalizedPowerLevels()} + /> + +
+
+ + {/* Composants */} + +
+ + ): void { + onSpellChange('components', e.target.value || null); + }} + placeholder={t('spellDetail.componentsPlaceholder')} + /> + +
+
+ + {/* Limitations */} + +
+ + ): void { + onSpellChange('limitations', e.target.value || null); + }} + placeholder={t('spellDetail.limitationsPlaceholder')} + /> + +
+
+ + {/* Notes */} + +
+ + ): void { + onSpellChange('notes', e.target.value || null); + }} + placeholder={t('spellDetail.notesPlaceholder')} + /> + +
+
+
+ ); +} diff --git a/components/book/settings/spells/settings/SpellSettingsList.tsx b/components/book/settings/spells/settings/SpellSettingsList.tsx new file mode 100644 index 0000000..ad4fedb --- /dev/null +++ b/components/book/settings/spells/settings/SpellSettingsList.tsx @@ -0,0 +1,159 @@ +'use client'; +import React, {useState} from 'react'; +import {SpellListItem, SpellTagProps} from '@/lib/models/Spell'; +import InputField from '@/components/form/InputField'; +import TextInput from '@/components/form/TextInput'; +import SpellTagChip from '@/components/book/settings/spells/SpellTagChip'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faChevronRight, faCog, faHatWizard, faPlus} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; + +interface SpellSettingsListProps { + spells: SpellListItem[]; + tags: SpellTagProps[]; + onSpellClick: (spell: SpellListItem) => void; + onAddSpell: () => void; + onManageTags: () => void; +} + +/** + * SpellSettingsList - Liste des sorts pour BookSetting/SerieSetting + * Inclut recherche, filtre par tag, et gestion des tags + * PAS de scroll interne (géré par parent) + */ +export default function SpellSettingsList({ + spells, + tags, + onSpellClick, + onAddSpell, + onManageTags, +}: SpellSettingsListProps): React.JSX.Element { + const t = useTranslations(); + const [searchQuery, setSearchQuery] = useState(''); + const [filterTag, setFilterTag] = useState('all'); + + function getFilteredSpells(): SpellListItem[] { + return spells.filter(function (spell: SpellListItem): boolean { + const matchesSearch: boolean = spell.name.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesTag: boolean = filterTag === 'all' || spell.tags.some(function (tag: SpellTagProps): boolean { + return tag.id === filterTag; + }); + return matchesSearch && matchesTag; + }); + } + + const filteredSpells: SpellListItem[] = getFilteredSpells(); + + return ( +
+
+ ): void { + setSearchQuery(e.target.value); + }} + placeholder={t('spellList.search')} + /> + } + actionIcon={faPlus} + actionLabel={t('spellList.add')} + addButtonCallBack={async function (): Promise { + onAddSpell(); + }} + /> + +
+
+ +
+ +
+
+ +
+ {filteredSpells.length === 0 ? ( +
+
+ +
+

+ {t('spellList.noSpells')} +

+

+ {t('spellList.noSpellsDescription')} +

+
+ ) : ( +
+ {filteredSpells.map(function (spell: SpellListItem): React.JSX.Element { + return ( +
+
+ +
+ +
+
+ {spell.name} +
+
+ {spell.description} +
+ {spell.tags.length > 0 && ( +
+ {spell.tags.slice(0, 3).map(function (tag: SpellTagProps): React.JSX.Element { + return ; + })} + {spell.tags.length > 3 && ( + + +{spell.tags.length - 3} + + )} +
+ )} +
+ +
+ +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/components/book/settings/story/MainChapter.tsx b/components/book/settings/story/MainChapter.tsx index 31ec772..d2063b7 100644 --- a/components/book/settings/story/MainChapter.tsx +++ b/components/book/settings/story/MainChapter.tsx @@ -86,9 +86,11 @@ export default function MainChapter({chapters, setChapters}: MainChapterProps) { try { setDeleteConfirmMessage(false); let response: boolean; + const deletedAt: number = System.timeStampInSeconds(); const deleteData = { bookId, chapterId: chapterIdToRemove, + deletedAt, }; if (isCurrentlyOffline() || book?.localBook) { response = await window.electron.invoke('db:chapter:remove', deleteData); diff --git a/components/book/settings/world/WorldElement.tsx b/components/book/settings/world/WorldElement.tsx index 8df74c9..b2a9405 100644 --- a/components/book/settings/world/WorldElement.tsx +++ b/components/book/settings/world/WorldElement.tsx @@ -16,12 +16,33 @@ import {BookContext} from "@/context/BookContext"; import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext"; import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext"; import {SyncedBook} from "@/lib/models/SyncedBook"; +import {SeriesContext, SeriesContextProps} from "@/context/SeriesContext"; +import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext"; +import {SyncedSeries} from "@/lib/models/SyncedSeries"; interface WorldElementInputProps { sectionLabel: string; sectionType: string; } +function getElementTypeNumber(sectionType: string): number { + const typeMap: { [key: string]: number } = { + 'laws': 0, + 'biomes': 1, + 'issues': 2, + 'customs': 3, + 'kingdoms': 4, + 'climate': 5, + 'resources': 6, + 'wildlife': 7, + 'arts': 8, + 'ethnicGroups': 9, + 'socialClasses': 10, + 'importantCharacters': 11, + }; + return typeMap[sectionType] ?? 0; +} + export default function WorldElementComponent({sectionLabel, sectionType}: WorldElementInputProps) { const t = useTranslations(); const {lang} = useContext(LangContext); @@ -29,9 +50,11 @@ export default function WorldElementComponent({sectionLabel, sectionType}: World const {addToQueue} = useContext(LocalSyncQueueContext); const {localSyncedBooks} = useContext(BooksSyncContext); const {book} = useContext(BookContext); - const {worlds, setWorlds, selectedWorldIndex} = useContext(WorldContext); - const {errorMessage, successMessage} = useContext(AlertContext); + const {worlds, setWorlds, selectedWorldIndex, isSeriesMode} = useContext(WorldContext); + const {errorMessage} = useContext(AlertContext); const {session} = useContext(SessionContext); + const {seriesId, localSeries} = useContext(SeriesContext); + const {localSyncedSeries} = useContext(SeriesSyncContext); const [newElementName, setNewElementName] = useState(''); @@ -42,7 +65,17 @@ export default function WorldElementComponent({sectionLabel, sectionType}: World try { let response: boolean; const elementId = (worlds[selectedWorldIndex][section] as WorldElement[])[index].id; - if (isCurrentlyOffline() || book?.localBook) { + if (isSeriesMode) { + const deleteData = {elementId: elementId}; + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke('db:series:world:element:delete', deleteData); + } else { + response = await System.authDeleteToServer('series/world/element/delete', deleteData, session.accessToken, lang); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { + addToQueue('db:series:world:element:delete', deleteData); + } + } + } else if (isCurrentlyOffline() || book?.localBook) { response = await window.electron.invoke('db:book:world:element:remove', { elementId: elementId, }); @@ -82,7 +115,30 @@ export default function WorldElementComponent({sectionLabel, sectionType}: World } try { let elementId: string; - if (isCurrentlyOffline() || book?.localBook) { + if (isSeriesMode) { + const addData = { + worldId: worlds[selectedWorldIndex].id, + elementType: getElementTypeNumber(section as string), + name: newElementName, + }; + if (isCurrentlyOffline() || localSeries) { + elementId = await window.electron.invoke('db:series:world:element:add', addData); + } else { + elementId = await System.authPostToServer( + 'series/world/element/add', + addData, + session.accessToken, + lang + ); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { + addToQueue('db:series:world:element:add', addData); + } + } + if (!elementId) { + errorMessage(t("worldSetting.unknownError")) + return; + } + } else if (isCurrentlyOffline() || book?.localBook) { elementId = await window.electron.invoke('db:book:world:element:add', { elementType: section, worldId: worlds[selectedWorldIndex].id, diff --git a/components/book/settings/world/WorldSetting.tsx b/components/book/settings/world/WorldSetting.tsx index 8504b70..ae9cae9 100644 --- a/components/book/settings/world/WorldSetting.tsx +++ b/components/book/settings/world/WorldSetting.tsx @@ -1,13 +1,13 @@ 'use client' -import {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react'; +import React, {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react'; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faPlus, faToggleOn, IconDefinition} from "@fortawesome/free-solid-svg-icons"; +import {faPlus, faShare, faToggleOn, IconDefinition} from "@fortawesome/free-solid-svg-icons"; import {WorldContext} from '@/context/WorldContext'; import {BookContext} from "@/context/BookContext"; import {AlertContext} from "@/context/AlertContext"; import {SelectBoxProps} from "@/shared/interface"; import System from "@/lib/models/System"; -import {elementSections, WorldProps, WorldListResponse} from "@/lib/models/World"; +import {elementSections, WorldListResponse, WorldProps} from "@/lib/models/World"; import {SessionContext} from "@/context/SessionContext"; import InputField from "@/components/form/InputField"; import TextInput from '@/components/form/TextInput'; @@ -21,6 +21,9 @@ import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQ import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext"; import {SyncedBook} from "@/lib/models/SyncedBook"; import ToggleSwitch from "@/components/form/ToggleSwitch"; +import {SeriesWorldProps, SeriesWorldListItem} from "@/lib/models/Series"; +import SeriesImportSelector from "@/components/form/SeriesImportSelector"; +import SyncFieldWrapper from "@/components/form/SyncFieldWrapper"; export interface ElementSection { title: string; @@ -28,7 +31,14 @@ export interface ElementSection { icon: IconDefinition; } -export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: any) { +interface WorldSettingProps { + showToggle?: boolean; + entityType?: 'book' | 'series'; + entityId?: string; +} + +export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSave: () => Promise }>) { + const {showToggle = true, entityType = 'book', entityId} = props; const t = useTranslations(); const {lang} = useContext(LangContext); const {isCurrentlyOffline} = useContext(OfflineContext); @@ -37,43 +47,72 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a const {errorMessage, successMessage} = useContext(AlertContext); const {session} = useContext(SessionContext); const {book, setBook} = useContext(BookContext); - const bookId: string = book?.bookId ? book.bookId.toString() : ''; + + const currentEntityId: string = entityId || book?.bookId || ''; + const isSeriesMode: boolean = entityType === 'series'; const [worlds, setWorlds] = useState([]); + const [seriesWorlds, setSeriesWorlds] = useState([]); const [newWorldName, setNewWorldName] = useState(''); const [selectedWorldIndex, setSelectedWorldIndex] = useState(0); const [worldsSelector, setWorldsSelector] = useState([]); const [showAddNewWorld, setShowAddNewWorld] = useState(false); - const [toolEnabled, setToolEnabled] = useState(book?.tools?.worlds ?? false); - + const [toolEnabled, setToolEnabled] = useState(isSeriesMode ? true : (book?.tools?.worlds ?? false)); + const bookSeriesId: string | null = book?.seriesId || null; + useImperativeHandle(ref, function () { return { handleSave: handleUpdateWorld, }; }); - + useEffect((): void => { - getWorlds().then(); - }, []); + if (currentEntityId) { + getWorlds().then(); + } + }, [currentEntityId]); + + useEffect((): void => { + if (bookSeriesId && !isSeriesMode && !isCurrentlyOffline()) { + getSeriesWorlds().then(); + } + }, [bookSeriesId]); + + async function getSeriesWorlds(): Promise { + if (!bookSeriesId || isCurrentlyOffline()) return; + try { + const response: SeriesWorldProps[] = await System.authGetQueryToServer('series/world/list', session.accessToken, lang, { + seriesid: bookSeriesId + }); + if (response) { + setSeriesWorlds(response); + } + } catch (e: unknown) { + if (e instanceof Error) { + console.error('Error loading series worlds:', e.message); + } + } + } async function handleToggleTool(enabled: boolean): Promise { + if (isSeriesMode) return; try { let response: boolean; if (isCurrentlyOffline() || book?.localBook) { response = await window.electron.invoke('db:book:tool:update', { - bookId: bookId, + bookId: currentEntityId, toolName: 'worlds', enabled: enabled }); } else { response = await System.authPatchToServer('book/tool-setting', { - bookId: bookId, + bookId: currentEntityId, toolName: 'worlds', enabled: enabled }, session.accessToken, lang); - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { addToQueue('db:book:tool:update', { - bookId: bookId, + bookId: currentEntityId, toolName: 'worlds', enabled: enabled }); @@ -81,11 +120,14 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a } if (response && setBook && book) { setToolEnabled(enabled); - setBook({...book, tools: { - characters: book.tools?.characters ?? false, - worlds: enabled, - locations: book.tools?.locations ?? false - }}); + setBook({ + ...book, tools: { + characters: book.tools?.characters ?? false, + worlds: enabled, + locations: book.tools?.locations ?? false, + spells: book.tools?.spells ?? false + } + }); } } catch (e: unknown) { if (e instanceof Error) { @@ -94,37 +136,73 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a } } - async function getWorlds() { + async function getWorlds(): Promise { try { - let response: WorldListResponse; - if (isCurrentlyOffline()) { - response = await window.electron.invoke('db:book:worlds:get', {bookid: bookId}); + if (isSeriesMode) { + // Series mode: server only (series are not local) + const response: SeriesWorldProps[] = await System.authGetQueryToServer('series/world/list', session.accessToken, lang, { + seriesid: currentEntityId + }); + if (response) { + const mappedWorlds: WorldProps[] = response.map((world: SeriesWorldProps): WorldProps => ({ + id: world.id, + name: world.name, + history: world.history || '', + politics: world.politics || '', + economy: world.economy || '', + religion: world.religion || '', + languages: world.languages || '', + laws: world.laws || [], + biomes: world.biomes || [], + issues: world.issues || [], + customs: world.customs || [], + kingdoms: world.kingdoms || [], + climate: world.climate || [], + resources: world.resources || [], + wildlife: world.wildlife || [], + arts: world.arts || [], + ethnicGroups: world.ethnicGroups || [], + socialClasses: world.socialClasses || [], + importantCharacters: world.importantCharacters || [], + })); + setWorlds(mappedWorlds); + const formattedWorlds: SelectBoxProps[] = response.map((world: SeriesWorldProps): SelectBoxProps => ({ + label: world.name, + value: world.id, + })); + setWorldsSelector(formattedWorlds); + } } else { - if (book?.localBook) { - response = await window.electron.invoke('db:book:worlds:get', {bookid: bookId}); + // Book mode: dual offline/online logic + let response: WorldListResponse; + if (isCurrentlyOffline() || book?.localBook) { + response = await window.electron.invoke('db:book:worlds:get', {bookid: currentEntityId}); } else { - response = await System.authGetQueryToServer(`book/worlds`, session.accessToken, lang, { - bookid: bookId, + response = await System.authGetQueryToServer('book/worlds', session.accessToken, lang, { + bookid: currentEntityId, }); } - } - if (response) { - setWorlds(response.worlds); - setToolEnabled(response.enabled); - if (setBook && book) { - setBook({...book, tools: { - characters: book.tools?.characters ?? false, - worlds: response.enabled, - locations: book.tools?.locations ?? false - }}); + if (response) { + setWorlds(response.worlds); + setToolEnabled(response.enabled); + if (setBook && book) { + setBook({ + ...book, tools: { + characters: book.tools?.characters ?? false, + worlds: response.enabled, + locations: book.tools?.locations ?? false, + spells: book.tools?.spells ?? false + } + }); + } + const formattedWorlds: SelectBoxProps[] = response.worlds.map( + (world: WorldProps): SelectBoxProps => ({ + label: world.name, + value: world.id.toString(), + }), + ); + setWorldsSelector(formattedWorlds); } - const formattedWorlds: SelectBoxProps[] = response.worlds.map( - (world: WorldProps): SelectBoxProps => ({ - label: world.name, - value: world.id.toString(), - }), - ); - setWorldsSelector(formattedWorlds); } } catch (e: unknown) { if (e instanceof Error) { @@ -134,38 +212,57 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a } } } - + async function handleAddNewWorld(): Promise { if (newWorldName.trim() === '') { errorMessage(t("worldSetting.newWorldNameError")); return; } try { - let worldId: string; - if (isCurrentlyOffline() || book?.localBook) { - worldId = await window.electron.invoke('db:book:world:add', { + let newWorldId: string; + if (isSeriesMode) { + // Series mode: server only + newWorldId = await System.authPostToServer( + 'series/world/add', + { + seriesId: currentEntityId, + name: newWorldName, + }, + session.accessToken, + lang + ); + if (!newWorldId) { + errorMessage(t("worldSetting.addWorldError")); + return; + } + } else if (isCurrentlyOffline() || book?.localBook) { + // Book mode: offline/local + newWorldId = await window.electron.invoke('db:book:world:add', { worldName: newWorldName, - bookId: bookId, + bookId: currentEntityId, }); + if (!newWorldId) { + errorMessage(t("worldSetting.addWorldError")); + return; + } } else { - worldId = await System.authPostToServer('book/world/add', { + // Book mode: online + newWorldId = await System.authPostToServer('book/world/add', { worldName: newWorldName, - bookId: bookId, + bookId: currentEntityId, }, session.accessToken, lang); - - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { + if (!newWorldId) { + errorMessage(t("worldSetting.addWorldError")); + return; + } + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { addToQueue('db:book:world:add', { worldName: newWorldName, - worldId, - bookId: bookId, + worldId: newWorldId, + bookId: currentEntityId, }); } } - if (!worldId) { - errorMessage(t("worldSetting.addWorldError")); - return; - } - const newWorldId: string = worldId; const newWorld: WorldProps = { id: newWorldId, name: newWorldName, @@ -202,23 +299,44 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a } } } - - async function handleUpdateWorld(): Promise { - try { - let response: boolean; - const worldData = { - world: worlds[selectedWorldIndex], - bookId: bookId, - }; - if (isCurrentlyOffline() || book?.localBook) { - response = await window.electron.invoke('db:book:world:update', worldData); - } else { - response = await System.authPatchToServer('book/world/update', worldData, session.accessToken, lang); - if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { - addToQueue('db:book:world:update', worldData); + async function handleUpdateWorld(): Promise { + if (worlds.length === 0) return; + try { + const currentWorld: WorldProps = worlds[selectedWorldIndex]; + let response: boolean; + + if (isSeriesMode) { + // Series mode: server only + response = await System.authPatchToServer('series/world/update', { + worldId: currentWorld.id, + name: currentWorld.name, + history: currentWorld.history, + politics: currentWorld.politics, + economy: currentWorld.economy, + religion: currentWorld.religion, + languages: currentWorld.languages, + }, session.accessToken, lang); + } else if (isCurrentlyOffline() || book?.localBook) { + // Book mode: offline/local + response = await window.electron.invoke('db:book:world:update', { + world: currentWorld, + bookId: currentEntityId, + }); + } else { + // Book mode: online + response = await System.authPatchToServer('book/world/update', { + world: currentWorld, + bookId: currentEntityId, + }, session.accessToken, lang); + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { + addToQueue('db:book:world:update', { + world: currentWorld, + bookId: currentEntityId, + }); } } + if (!response) { errorMessage(t("worldSetting.updateWorldError")); return; @@ -232,16 +350,130 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a } } } - + + function handleWorldSelect(worldId: string): void { + const index: number = worlds.findIndex((world: WorldProps): boolean => world.id === worldId); + if (index !== -1) { + setSelectedWorldIndex(index); + } + } + function handleInputChange(value: string, field: keyof WorldProps) { const updatedWorlds = [...worlds] as WorldProps[]; (updatedWorlds[selectedWorldIndex][field] as string) = value; setWorlds(updatedWorlds); } - + + async function handleExportToSeries(): Promise { + if (isCurrentlyOffline()) return; + const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex]; + if (!selectedWorld || !bookSeriesId) return; + + try { + const seriesWorldId: string = await System.authPostToServer('series/world/add', { + seriesId: bookSeriesId, + world: { + name: selectedWorld.name, + history: selectedWorld.history || null, + politics: selectedWorld.politics || null, + economy: selectedWorld.economy || null, + religion: selectedWorld.religion || null, + languages: selectedWorld.languages || null, + } + }, session.accessToken, lang); + + if (seriesWorldId) { + const updateResponse: boolean = await System.authPostToServer('book/world/update', { + world: { + ...selectedWorld, + seriesWorldId: seriesWorldId + }, + }, session.accessToken, lang); + + if (updateResponse) { + const updatedWorlds: WorldProps[] = [...worlds]; + updatedWorlds[selectedWorldIndex] = {...selectedWorld, seriesWorldId: seriesWorldId}; + setWorlds(updatedWorlds); + await getSeriesWorlds(); + successMessage(t("worldSetting.exportSuccess")); + } + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + } + + async function handleImportFromSeries(seriesWorldId: string): Promise { + if (isCurrentlyOffline()) return; + const seriesWorld: SeriesWorldListItem | undefined = seriesWorlds.find((w) => w.id === seriesWorldId); + if (!seriesWorld) return; + + try { + const worldId: string = await System.authPostToServer('book/world/add', { + worldName: seriesWorld.name, + bookId: currentEntityId, + seriesWorldId: seriesWorldId, + }, session.accessToken, lang); + + if (!worldId) { + errorMessage(t("worldSetting.importError")); + return; + } + + // Sync to local if book is synced + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { + addToQueue('db:book:world:add', { + worldName: seriesWorld.name, + worldId: worldId, + bookId: currentEntityId, + }); + } + + const newWorld: WorldProps = { + id: worldId, + name: seriesWorld.name, + history: seriesWorld.history || '', + politics: seriesWorld.politics || '', + economy: seriesWorld.economy || '', + religion: seriesWorld.religion || '', + languages: seriesWorld.languages || '', + laws: [], + biomes: [], + issues: [], + customs: [], + kingdoms: [], + climate: [], + resources: [], + wildlife: [], + arts: [], + ethnicGroups: [], + socialClasses: [], + importantCharacters: [], + seriesWorldId: seriesWorldId, + }; + setWorlds([...worlds, newWorld]); + setWorldsSelector([ + ...worldsSelector, + {label: seriesWorld.name, value: worldId}, + ]); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + } + + function getSeriesWorldForCurrentWorld(): SeriesWorldProps | null { + const currentWorld: WorldProps = worlds[selectedWorldIndex]; + if (!currentWorld?.seriesWorldId) return null; + return seriesWorlds.find((world: SeriesWorldListItem): boolean => world.id === currentWorld.seriesWorldId) || null; + } + return (
- {showToggle && ( + {showToggle && !isSeriesMode && (
)} - {toolEnabled && ( + {(toolEnabled || isSeriesMode) && ( <> -
+ {!isSeriesMode && bookSeriesId && !isCurrentlyOffline() && + seriesWorlds.filter((seriesWorld: SeriesWorldProps): boolean => !worlds.some((world: WorldProps): boolean => world.seriesWorldId === seriesWorld.id)).length > 0 && ( + !worlds.some((world: WorldProps): boolean => world.seriesWorldId === seriesWorld.id)) + .map((seriesWorld: SeriesWorldProps) => ({id: seriesWorld.id, name: seriesWorld.name}))} + onImport={handleImportFromSeries} + placeholder={t("seriesImport.selectElement")} + label={t("seriesImport.importFromSeries")} + /> + )} +
{ - const worldId = e.target.value; - const index = worlds.findIndex(world => world.id.toString() === worldId); - if (index !== -1) { - setSelectedWorldIndex(index); - } - }} + onChangeCallBack={(e) => handleWorldSelect(e.target.value)} data={worldsSelector.length > 0 ? worldsSelector : [{ label: t("worldSetting.noWorldAvailable"), value: '0' @@ -300,37 +538,78 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a addButtonCallBack={handleAddNewWorld} /> )} + {!isSeriesMode && bookSeriesId && !isCurrentlyOffline() && worlds[selectedWorldIndex] && !worlds[selectedWorldIndex].seriesWorldId && ( + + )}
{worlds.length > 0 && worlds[selectedWorldIndex] ? ( - +
) => { - const updatedWorlds: WorldProps[] = [...worlds]; - updatedWorlds[selectedWorldIndex].name = e.target.value - setWorlds(updatedWorlds); + { + const seriesWorld = getSeriesWorldForCurrentWorld(); + if (seriesWorld) { + const updatedWorlds: WorldProps[] = [...worlds]; + updatedWorlds[selectedWorldIndex].name = seriesWorld.name; + setWorlds(updatedWorlds); + } }} - placeholder={t("worldSetting.worldNamePlaceholder")} - /> + onSyncComplete={getSeriesWorlds} + > + ) => { + const updatedWorlds: WorldProps[] = [...worlds]; + updatedWorlds[selectedWorldIndex].name = e.target.value + setWorlds(updatedWorlds); + }} + placeholder={t("worldSetting.worldNamePlaceholder")} + /> + } />
handleInputChange(e.target.value, 'history')} - placeholder={t("worldSetting.worldHistoryPlaceholder")} - /> + { + const seriesWorld = getSeriesWorldForCurrentWorld(); + if (seriesWorld) handleInputChange(seriesWorld.history || '', 'history'); + }} + onSyncComplete={getSeriesWorlds} + > + handleInputChange(e.target.value, 'history')} + placeholder={t("worldSetting.worldHistoryPlaceholder")} + /> + } />
@@ -341,21 +620,49 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a handleInputChange(e.target.value, 'politics')} - placeholder={t("worldSetting.politicsPlaceholder")} - /> + { + const seriesWorld = getSeriesWorldForCurrentWorld(); + if (seriesWorld) handleInputChange(seriesWorld.politics || '', 'politics'); + }} + onSyncComplete={getSeriesWorlds} + > + handleInputChange(e.target.value, 'politics')} + placeholder={t("worldSetting.politicsPlaceholder")} + /> + } /> handleInputChange(e.target.value, 'economy')} - placeholder={t("worldSetting.economyPlaceholder")} - /> + { + const seriesWorld = getSeriesWorldForCurrentWorld(); + if (seriesWorld) handleInputChange(seriesWorld.economy || '', 'economy'); + }} + onSyncComplete={getSeriesWorlds} + > + handleInputChange(e.target.value, 'economy')} + placeholder={t("worldSetting.economyPlaceholder")} + /> + } />
@@ -367,21 +674,49 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a handleInputChange(e.target.value, 'religion')} - placeholder={t("worldSetting.religionPlaceholder")} - /> + { + const seriesWorld = getSeriesWorldForCurrentWorld(); + if (seriesWorld) handleInputChange(seriesWorld.religion || '', 'religion'); + }} + onSyncComplete={getSeriesWorlds} + > + handleInputChange(e.target.value, 'religion')} + placeholder={t("worldSetting.religionPlaceholder")} + /> + } /> handleInputChange(e.target.value, 'languages')} - placeholder={t("worldSetting.languagesPlaceholder")} - /> + { + const seriesWorld = getSeriesWorldForCurrentWorld(); + if (seriesWorld) handleInputChange(seriesWorld.languages || '', 'languages'); + }} + onSyncComplete={getSeriesWorlds} + > + handleInputChange(e.target.value, 'languages')} + placeholder={t("worldSetting.languagesPlaceholder")} + /> + } />
@@ -417,4 +752,4 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a ); } -export default forwardRef(WorldSetting); \ No newline at end of file +export default forwardRef(WorldSetting); diff --git a/components/book/settings/world/editor/WorldEditor.tsx b/components/book/settings/world/editor/WorldEditor.tsx new file mode 100644 index 0000000..d3aa1c4 --- /dev/null +++ b/components/book/settings/world/editor/WorldEditor.tsx @@ -0,0 +1,226 @@ +'use client'; +import React, {useCallback, useContext, useMemo, useState} from 'react'; +import {useWorlds, UseWorldsConfig} from '@/hooks/settings/useWorlds'; +import {useTranslations} from 'next-intl'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faPlus, faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons'; +import {BookContext} from '@/context/BookContext'; +import {WorldProps} from '@/lib/models/World'; +import {SeriesWorldProps} from '@/lib/models/Series'; +import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader'; +import AlertBox from '@/components/AlertBox'; +import InputField from '@/components/form/InputField'; +import TextInput from '@/components/form/TextInput'; +import ToggleSwitch from '@/components/form/ToggleSwitch'; +import SeriesImportSelector from '@/components/form/SeriesImportSelector'; + +import WorldEditorList from './WorldEditorList'; +import WorldEditorDetail from './WorldEditorDetail'; +import WorldEditorEdit from './WorldEditorEdit'; + +/** + * WorldEditor - Orchestrateur pour ComposerRightBar + * Mêmes fonctionnalités que WorldSettings, layout condensé + * Inclut: toggle tool, import from series, export to series + */ +export default function WorldEditor(): React.JSX.Element { + const t = useTranslations(); + const {book} = useContext(BookContext); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [showAddForm, setShowAddForm] = useState(false); + + const config: UseWorldsConfig = useMemo(function (): UseWorldsConfig { + return { + entityType: 'book', + entityId: book?.bookId || '', + }; + }, [book?.bookId]); + + const { + worlds, + seriesWorlds, + selectedWorldIndex, + toolEnabled, + isLoading, + bookSeriesId, + newWorldName, + viewMode, + saveWorld, + updateWorldField, + addNewWorld, + toggleTool, + importFromSeries, + exportToSeries, + refreshSeriesWorlds, + setNewWorldName, + setWorlds, + getSeriesWorldForCurrentWorld, + enterDetailMode, + enterEditMode, + exitEditMode, + backToList, + } = useWorlds(config); + + const availableSeriesWorlds = useMemo(function (): SeriesWorldProps[] { + return seriesWorlds.filter(function (sw: SeriesWorldProps): boolean { + return !worlds.some(function (w: WorldProps): boolean { + return w.seriesWorldId === sw.id; + }); + }); + }, [seriesWorlds, worlds]); + + const handleWorldFieldChange = useCallback(function (field: keyof WorldProps, value: string): void { + updateWorldField(field, value); + }, [updateWorldField]); + + // Wrapper pour convertir WorldProps en worldId + const handleWorldClick = useCallback(function (world: WorldProps): void { + enterDetailMode(world.id); + }, [enterDetailMode]); + + // Gestion de l'ajout + async function handleAddWorld(): Promise { + if (newWorldName.trim()) { + await addNewWorld(); + setShowAddForm(false); + } else { + setShowAddForm(true); + } + } + + async function handleSave(): Promise { + await exitEditMode(true); + } + + function handleCancel(): void { + exitEditMode(false); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex]; + const canExport: boolean = Boolean(bookSeriesId && selectedWorld && !selectedWorld.seriesWorldId); + + return ( +
+ + +
+ {viewMode === 'list' && ( +
+ {/* Toggle tool */} +
+ + } + /> +
+ + {toolEnabled && ( + <> + {/* Import from series */} + {bookSeriesId && availableSeriesWorlds.length > 0 && ( + + )} + + {showAddForm && ( +
+ ): void { + setNewWorldName(e.target.value); + }} + placeholder={t('worldSetting.newWorldPlaceholder')} + /> + } + actionIcon={faPlus} + actionLabel={t('worldSetting.createWorldLabel')} + addButtonCallBack={async function (): Promise { + await addNewWorld(); + setShowAddForm(false); + }} + /> +
+ )} + + + + )} +
+ )} + + {viewMode === 'detail' && selectedWorld && ( +
+ +
+ )} + + {viewMode === 'edit' && selectedWorld && ( +
+ +
+ )} +
+ + {showDeleteConfirm && selectedWorld && ( + { setShowDeleteConfirm(false); }} + onCancel={function (): void { setShowDeleteConfirm(false); }} + /> + )} +
+ ); +} diff --git a/components/book/settings/world/editor/WorldEditorDetail.tsx b/components/book/settings/world/editor/WorldEditorDetail.tsx new file mode 100644 index 0000000..82c9694 --- /dev/null +++ b/components/book/settings/world/editor/WorldEditorDetail.tsx @@ -0,0 +1,87 @@ +'use client'; +import React from 'react'; +import {WorldProps, elementSections, ElementSection, WorldElement} from '@/lib/models/World'; +import {SeriesWorldProps} from '@/lib/models/Series'; +import {useTranslations} from 'next-intl'; + +interface WorldEditorDetailProps { + world: WorldProps; + seriesWorld?: SeriesWorldProps | null; +} + +/** + * WorldEditorDetail - Version sidebar lecture seule + * Mêmes fonctionnalités que WorldSettingsDetail, layout linéaire + */ +export default function WorldEditorDetail({ + world, + seriesWorld, +}: WorldEditorDetailProps): React.JSX.Element { + const t = useTranslations(); + + function renderField(label: string, value: string | null | undefined): React.JSX.Element | null { + if (!value) return null; + return ( +
+ {label} +

{value}

+
+ ); + } + + function renderElementSection(section: ElementSection): React.JSX.Element | null { + const elements: WorldElement[] = world[section.section] as WorldElement[]; + if (!elements || elements.length === 0) return null; + + return ( +
+

{section.title}

+
+ {elements.map(function (element: WorldElement): React.JSX.Element { + return ( +
+

{element.name}

+ {element.description && ( +

{element.description}

+ )} +
+ ); + })} +
+
+ ); + } + + return ( +
+ {/* Informations de base */} +
+

{world.name}

+ {renderField(t('worldSetting.worldHistory'), world.history)} +
+ + {/* Politique et économie */} + {(world.politics || world.economy) && ( +
+

{t('worldSetting.politicsEconomy')}

+ {renderField(t('worldSetting.politics'), world.politics)} + {renderField(t('worldSetting.economy'), world.economy)} +
+ )} + + {/* Religion et langues */} + {(world.religion || world.languages) && ( +
+

{t('worldSetting.cultureLanguages')}

+ {renderField(t('worldSetting.religion'), world.religion)} + {renderField(t('worldSetting.languages'), world.languages)} +
+ )} + + {/* Sections d'éléments */} + {elementSections.map(function (section: ElementSection): React.JSX.Element | null { + return renderElementSection(section); + })} +
+ ); +} diff --git a/components/book/settings/world/editor/WorldEditorEdit.tsx b/components/book/settings/world/editor/WorldEditorEdit.tsx new file mode 100644 index 0000000..5f07894 --- /dev/null +++ b/components/book/settings/world/editor/WorldEditorEdit.tsx @@ -0,0 +1,237 @@ +'use client'; +import React, {ChangeEvent, Dispatch, SetStateAction} from 'react'; +import {WorldProps, elementSections, ElementSection} from '@/lib/models/World'; +import {SeriesWorldProps} from '@/lib/models/Series'; +import {WorldContext} from '@/context/WorldContext'; +import InputField from '@/components/form/InputField'; +import TextInput from '@/components/form/TextInput'; +import TexteAreaInput from '@/components/form/TexteAreaInput'; +import SyncFieldWrapper from '@/components/form/SyncFieldWrapper'; +import WorldElementComponent from '@/components/book/settings/world/WorldElement'; +import {useTranslations} from 'next-intl'; + +interface WorldEditorEditProps { + world: WorldProps; + worlds: WorldProps[]; + selectedWorldIndex: number; + setWorlds: Dispatch>; + onWorldFieldChange: (field: keyof WorldProps, value: string) => void; + seriesWorld?: SeriesWorldProps | null; + onSyncComplete?: () => void; +} + +/** + * WorldEditorEdit - Version sidebar édition + * Mêmes fonctionnalités que WorldSettingsEdit, layout linéaire + * SyncFieldWrapper pour tous les champs + */ +export default function WorldEditorEdit({ + world, + worlds, + selectedWorldIndex, + setWorlds, + onWorldFieldChange, + seriesWorld, + onSyncComplete, +}: WorldEditorEditProps): React.JSX.Element { + const t = useTranslations(); + + return ( + +
+ {/* Informations de base */} +
+

{t('worldSetting.basicInfo')}

+
+ + ): void { + const updatedWorlds: WorldProps[] = [...worlds]; + updatedWorlds[selectedWorldIndex].name = e.target.value; + setWorlds(updatedWorlds); + }} + placeholder={t("worldSetting.worldNamePlaceholder")} + /> + + } + /> + + + ): void { + onWorldFieldChange('history', e.target.value); + }} + placeholder={t("worldSetting.worldHistoryPlaceholder")} + /> + + } + /> +
+
+ + {/* Politique et économie */} +
+

{t('worldSetting.politicsEconomy')}

+
+ + ): void { + onWorldFieldChange('politics', e.target.value); + }} + placeholder={t("worldSetting.politicsPlaceholder")} + /> + + } + /> + + + ): void { + onWorldFieldChange('economy', e.target.value); + }} + placeholder={t("worldSetting.economyPlaceholder")} + /> + + } + /> +
+
+ + {/* Religion et langues */} +
+

{t('worldSetting.cultureLanguages')}

+
+ + ): void { + onWorldFieldChange('religion', e.target.value); + }} + placeholder={t("worldSetting.religionPlaceholder")} + /> + + } + /> + + + ): void { + onWorldFieldChange('languages', e.target.value); + }} + placeholder={t("worldSetting.languagesPlaceholder")} + /> + + } + /> +
+
+ + {/* Sections d'éléments */} + {elementSections.map(function (section: ElementSection): React.JSX.Element { + return ( +
+

{section.title}

+ +
+ ); + })} +
+
+ ); +} diff --git a/components/book/settings/world/editor/WorldEditorList.tsx b/components/book/settings/world/editor/WorldEditorList.tsx new file mode 100644 index 0000000..c8b3dad --- /dev/null +++ b/components/book/settings/world/editor/WorldEditorList.tsx @@ -0,0 +1,107 @@ +'use client'; +import React, {useState} from 'react'; +import {WorldProps} from '@/lib/models/World'; +import InputField from '@/components/form/InputField'; +import TextInput from '@/components/form/TextInput'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faChevronRight, faGlobe, faPlus} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; + +interface WorldEditorListProps { + worlds: WorldProps[]; + onWorldClick: (world: WorldProps) => void; + onAddWorld: () => void; +} + +/** + * WorldEditorList - Liste des mondes pour ComposerRightBar + * Version compacte avec liste cliquable (même pattern que CharacterEditorList) + * PAS de scroll interne (géré par parent ComposerRightBar) + */ +export default function WorldEditorList({ + worlds, + onWorldClick, + onAddWorld, +}: WorldEditorListProps): React.JSX.Element { + const t = useTranslations(); + const [searchQuery, setSearchQuery] = useState(''); + + function getFilteredWorlds(): WorldProps[] { + return worlds.filter(function (world: WorldProps): boolean { + return world.name.toLowerCase().includes(searchQuery.toLowerCase()); + }); + } + + const filteredWorlds: WorldProps[] = getFilteredWorlds(); + + return ( +
+
+ ): void { + setSearchQuery(e.target.value); + }} + placeholder={t('worldSetting.search')} + /> + } + actionIcon={faPlus} + actionLabel={t('worldSetting.addWorldLabel')} + addButtonCallBack={async function (): Promise { + onAddWorld(); + }} + /> +
+ +
+ {filteredWorlds.length === 0 ? ( +
+
+ +
+

+ {t('worldSetting.noWorldAvailable')} +

+

+ {t('worldSetting.noWorldDescription')} +

+
+ ) : ( + filteredWorlds.map(function (world: WorldProps): React.JSX.Element { + return ( +
+
+ +
+ +
+
+ {world.name} +
+ {world.history && ( +
+ {world.history.substring(0, 50)}{world.history.length > 50 ? '...' : ''} +
+ )} +
+ +
+ +
+
+ ); + }) + )} +
+
+ ); +} diff --git a/components/book/settings/world/settings/WorldSettings.tsx b/components/book/settings/world/settings/WorldSettings.tsx new file mode 100644 index 0000000..5d34953 --- /dev/null +++ b/components/book/settings/world/settings/WorldSettings.tsx @@ -0,0 +1,200 @@ +'use client'; +import React, {useCallback, useContext, useMemo, useState} from 'react'; +import {useWorlds, UseWorldsConfig} from '@/hooks/settings/useWorlds'; +import {useTranslations} from 'next-intl'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons'; +import {BookContext} from '@/context/BookContext'; +import {WorldProps} from '@/lib/models/World'; +import {SeriesWorldProps} from '@/lib/models/Series'; +import InputField from '@/components/form/InputField'; +import ToggleSwitch from '@/components/form/ToggleSwitch'; +import SeriesImportSelector from '@/components/form/SeriesImportSelector'; +import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader'; +import AlertBox from '@/components/AlertBox'; + +import WorldSettingsList from './WorldSettingsList'; +import WorldSettingsDetail from './WorldSettingsDetail'; +import WorldSettingsEdit from './WorldSettingsEdit'; + +interface WorldSettingsProps { + entityType?: 'book' | 'series'; + entityId?: string; + showToggle?: boolean; +} + +/** + * WorldSettings - Orchestrateur pour BookSetting/SerieSetting + * Gère le viewMode (list/detail/edit) et coordonne les sous-composants + * Inclut: toggle tool, import from series, export to series + */ +export default function WorldSettings({ + entityType = 'book', + entityId, + showToggle = true, +}: WorldSettingsProps): React.JSX.Element { + const t = useTranslations(); + const {book} = useContext(BookContext); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const resolvedEntityId: string = entityId || book?.bookId || ''; + + const config: UseWorldsConfig = useMemo(function (): UseWorldsConfig { + return { + entityType, + entityId: resolvedEntityId, + }; + }, [entityType, resolvedEntityId]); + + const { + worlds, + seriesWorlds, + selectedWorldIndex, + toolEnabled, + isLoading, + isSeriesMode, + bookSeriesId, + newWorldName, + viewMode, + addNewWorld, + saveWorld, + updateWorldField, + toggleTool, + importFromSeries, + exportToSeries, + refreshSeriesWorlds, + setNewWorldName, + setWorlds, + getSeriesWorldForCurrentWorld, + enterDetailMode, + enterEditMode, + exitEditMode, + backToList, + } = useWorlds(config); + + const availableSeriesWorlds = useMemo(function (): SeriesWorldProps[] { + return seriesWorlds.filter(function (sw: SeriesWorldProps): boolean { + return !worlds.some(function (w: WorldProps): boolean { + return w.seriesWorldId === sw.id; + }); + }); + }, [seriesWorlds, worlds]); + + const handleWorldFieldChange = useCallback(function (field: keyof WorldProps, value: string): void { + updateWorldField(field, value); + }, [updateWorldField]); + + async function handleSave(): Promise { + await exitEditMode(true); + } + + function handleCancel(): void { + exitEditMode(false); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex]; + const canExport: boolean = Boolean(bookSeriesId && selectedWorld && !selectedWorld.seriesWorldId); + + return ( +
+ {/* Header - uniquement pour detail/edit */} + + + {/* Contenu principal */} +
+ {viewMode === 'list' && ( +
+ {/* Toggle tool */} + {showToggle && !isSeriesMode && ( +
+ + } + /> +

+ {t('worldSetting.enableToolDescription')} +

+
+ )} + + {/* Contenu si outil activé */} + {(toolEnabled || isSeriesMode) && ( + <> + {/* Import from series */} + {!isSeriesMode && bookSeriesId && availableSeriesWorlds.length > 0 && ( + + )} + + {/* Liste des mondes */} + + + )} +
+ )} + + {viewMode === 'detail' && selectedWorld && ( +
+ +
+ )} + + {viewMode === 'edit' && selectedWorld && ( +
+ +
+ )} +
+
+ ); +} diff --git a/components/book/settings/world/settings/WorldSettingsDetail.tsx b/components/book/settings/world/settings/WorldSettingsDetail.tsx new file mode 100644 index 0000000..cbec2f1 --- /dev/null +++ b/components/book/settings/world/settings/WorldSettingsDetail.tsx @@ -0,0 +1,128 @@ +'use client'; +import React from 'react'; +import {WorldProps, elementSections, ElementSection, WorldElement} from '@/lib/models/World'; +import {SeriesWorldProps} from '@/lib/models/Series'; +import CollapsableArea from '@/components/CollapsableArea'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import { + faGlobe, + faLandmark, + faBook, + faCoins, + faChurch, + faLanguage, + faScroll +} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; + +interface WorldSettingsDetailProps { + world: WorldProps; + seriesWorld?: SeriesWorldProps | null; +} + +export default function WorldSettingsDetail({ + world, + seriesWorld, +}: WorldSettingsDetailProps): React.JSX.Element { + const t = useTranslations(); + + function renderElementSection(section: ElementSection): React.JSX.Element | null { + const elements: WorldElement[] = world[section.section] as WorldElement[]; + if (!elements || elements.length === 0) return null; + + return ( + +
+ {elements.map(function (element: WorldElement): React.JSX.Element { + return ( +
+

{element.name}

+ {element.description && ( +

{element.description}

+ )} +
+ ); + })} +
+
+ ); + } + + return ( +
+ {/* Hero Section */} +
+
+
+ +
+
+

{world.name || '—'}

+
+
+
+ + {/* Histoire du monde - Full width */} +
+
+ +

{t('worldSetting.worldHistory')}

+
+

+ {world.history || '—'} +

+
+ + {/* Politique & Économie - Side by side */} +
+
+
+ +

{t('worldSetting.politics')}

+
+

+ {world.politics || '—'} +

+
+ +
+
+ +

{t('worldSetting.economy')}

+
+

+ {world.economy || '—'} +

+
+
+ + {/* Religion & Langues - Side by side */} +
+
+
+ +

{t('worldSetting.religion')}

+
+

+ {world.religion || '—'} +

+
+ +
+
+ +

{t('worldSetting.languages')}

+
+

+ {world.languages || '—'} +

+
+
+ + {/* Sections d'éléments - Grille de cards */} + {elementSections.map(function (section: ElementSection): React.JSX.Element | null { + return renderElementSection(section); + })} +
+ ); +} diff --git a/components/book/settings/world/settings/WorldSettingsEdit.tsx b/components/book/settings/world/settings/WorldSettingsEdit.tsx new file mode 100644 index 0000000..066040f --- /dev/null +++ b/components/book/settings/world/settings/WorldSettingsEdit.tsx @@ -0,0 +1,240 @@ +'use client'; +import React, {ChangeEvent, Dispatch, SetStateAction} from 'react'; +import {WorldProps, elementSections, ElementSection} from '@/lib/models/World'; +import {SeriesWorldProps} from '@/lib/models/Series'; +import {WorldContext} from '@/context/WorldContext'; +import CollapsableArea from '@/components/CollapsableArea'; +import InputField from '@/components/form/InputField'; +import TextInput from '@/components/form/TextInput'; +import TexteAreaInput from '@/components/form/TexteAreaInput'; +import SyncFieldWrapper from '@/components/form/SyncFieldWrapper'; +import WorldElementComponent from '@/components/book/settings/world/WorldElement'; +import {faBook, faGlobe, faLandmark} from '@fortawesome/free-solid-svg-icons'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {useTranslations} from 'next-intl'; + +interface WorldSettingsEditProps { + world: WorldProps; + worlds: WorldProps[]; + selectedWorldIndex: number; + setWorlds: Dispatch>; + onWorldFieldChange: (field: keyof WorldProps, value: string) => void; + seriesWorld?: SeriesWorldProps | null; + isSeriesMode: boolean; + onSyncComplete?: () => void; +} + +/** + * WorldSettingsEdit - Vue édition pour BookSetting/SerieSetting + * Tous les champs avec SyncFieldWrapper + * PAS de scroll interne (géré par parent) + */ +export default function WorldSettingsEdit({ + world, + worlds, + selectedWorldIndex, + setWorlds, + onWorldFieldChange, + seriesWorld, + isSeriesMode, + onSyncComplete, +}: WorldSettingsEditProps): React.JSX.Element { + const t = useTranslations(); + + return ( + +
+ {/* Informations de base */} + +
+ + ): void { + const updatedWorlds: WorldProps[] = [...worlds]; + updatedWorlds[selectedWorldIndex].name = e.target.value; + setWorlds(updatedWorlds); + }} + placeholder={t("worldSetting.worldNamePlaceholder")} + /> + + } + /> + + + ): void { + onWorldFieldChange('history', e.target.value); + }} + placeholder={t("worldSetting.worldHistoryPlaceholder")} + /> + + } + /> +
+
+ + {/* Politique et économie */} + +
+ + ): void { + onWorldFieldChange('politics', e.target.value); + }} + placeholder={t("worldSetting.politicsPlaceholder")} + /> + + } + /> + + + ): void { + onWorldFieldChange('economy', e.target.value); + }} + placeholder={t("worldSetting.economyPlaceholder")} + /> + + } + /> +
+
+ + {/* Religion et langues */} + +
+ + ): void { + onWorldFieldChange('religion', e.target.value); + }} + placeholder={t("worldSetting.religionPlaceholder")} + /> + + } + /> + + + ): void { + onWorldFieldChange('languages', e.target.value); + }} + placeholder={t("worldSetting.languagesPlaceholder")} + /> + + } + /> +
+
+ + {/* Sections d'éléments */} + {elementSections.map(function (section: ElementSection): React.JSX.Element { + return ( + +
+ +
+
+ ); + })} +
+
+ ); +} diff --git a/components/book/settings/world/settings/WorldSettingsList.tsx b/components/book/settings/world/settings/WorldSettingsList.tsx new file mode 100644 index 0000000..d5de93f --- /dev/null +++ b/components/book/settings/world/settings/WorldSettingsList.tsx @@ -0,0 +1,127 @@ +'use client'; +import React, {ChangeEvent, useState} from 'react'; +import {WorldProps} from '@/lib/models/World'; +import InputField from '@/components/form/InputField'; +import TextInput from '@/components/form/TextInput'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faChevronRight, faGlobe, faPlus} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; + +interface WorldSettingsListProps { + worlds: WorldProps[]; + onWorldClick: (worldId: string) => void; + onAddWorld: () => Promise; + newWorldName: string; + onNewWorldNameChange: (name: string) => void; +} + +/** + * WorldSettingsList - Liste des mondes pour BookSetting/SerieSetting + * Liste cliquable avec recherche et formulaire d'ajout + * PAS de SelectBox, même pattern que CharacterSettingsList + * PAS de scroll interne (géré par parent) + */ +export default function WorldSettingsList({ + worlds, + onWorldClick, + onAddWorld, + newWorldName, + onNewWorldNameChange, +}: WorldSettingsListProps): React.JSX.Element { + const t = useTranslations(); + const [searchQuery, setSearchQuery] = useState(''); + + function getFilteredWorlds(): WorldProps[] { + return worlds.filter(function (world: WorldProps): boolean { + return world.name.toLowerCase().includes(searchQuery.toLowerCase()); + }); + } + + const filteredWorlds: WorldProps[] = getFilteredWorlds(); + + return ( +
+ {/* Recherche et ajout */} +
+
+ ): void { + setSearchQuery(e.target.value); + }} + placeholder={t('worldSetting.search')} + /> + } + /> + + ): void { + onNewWorldNameChange(e.target.value); + }} + placeholder={t('worldSetting.newWorldPlaceholder')} + /> + } + actionIcon={faPlus} + actionLabel={t('worldSetting.createWorldLabel')} + addButtonCallBack={onAddWorld} + /> +
+
+ + {/* Liste des mondes cliquables */} +
+ {filteredWorlds.length === 0 ? ( +
+
+ +
+

+ {t('worldSetting.noWorldAvailable')} +

+

+ {t('worldSetting.noWorldDescription')} +

+
+ ) : ( + filteredWorlds.map(function (world: WorldProps): React.JSX.Element { + return ( +
+
+ +
+ +
+
+ {world.name} +
+ {world.history && ( +
+ {world.history.substring(0, 60)}{world.history.length > 60 ? '...' : ''} +
+ )} +
+ +
+ +
+
+ ); + }) + )} +
+
+ ); +} diff --git a/components/form/NumberInput.tsx b/components/form/NumberInput.tsx index 664cd28..dd0bfcb 100644 --- a/components/form/NumberInput.tsx +++ b/components/form/NumberInput.tsx @@ -1,8 +1,9 @@ -import {ChangeEvent, Dispatch} from "react"; +import React, {ChangeEvent, Dispatch} from "react"; interface NumberInputProps { - value: number; - setValue: Dispatch>; + value: number | null; + setValue?: Dispatch>; + onValueChange?: (value: number | null) => void; placeholder?: string; readOnly?: boolean; disabled?: boolean; @@ -12,22 +13,34 @@ export default function NumberInput( { value, setValue, + onValueChange, placeholder, readOnly = false, disabled = false }: NumberInputProps ) { - function handleChange(e: ChangeEvent) { - const newValue: number = parseInt(e.target.value); + function handleChange(e: ChangeEvent): void { + const inputValue: string = e.target.value; + if (inputValue === '') { + if (onValueChange) { + onValueChange(null); + } + return; + } + const newValue: number = parseInt(inputValue, 10); if (!isNaN(newValue)) { - setValue(newValue); + if (onValueChange) { + onValueChange(newValue); + } else if (setValue) { + setValue(newValue); + } } } return ( Promise; + placeholder: string; + label?: string; +} + +export default function SeriesImportSelector({ + availableItems, + onImport, + placeholder, + label +}: SeriesImportSelectorProps) { + const t = useTranslations(); + + const [selectedId, setSelectedId] = useState(''); + const [isImporting, setIsImporting] = useState(false); + + async function handleImport(): Promise { + if (!selectedId || isImporting) return; + + setIsImporting(true); + try { + await onImport(selectedId); + setSelectedId(''); + } finally { + setIsImporting(false); + } + } + + if (availableItems.length === 0) { + return null; + } + + const selectData = availableItems.map((item) => ({ + label: item.name, + value: item.id + })); + + return ( +
+ {label && ( +

+ + {label} +

+ )} +
+
+ setSelectedId(e.target.value)} + data={selectData} + defaultValue={selectedId} + placeholder={placeholder} + /> +
+ +
+
+ ); +} diff --git a/components/form/SyncFieldWrapper.tsx b/components/form/SyncFieldWrapper.tsx new file mode 100644 index 0000000..64b9ae1 --- /dev/null +++ b/components/form/SyncFieldWrapper.tsx @@ -0,0 +1,152 @@ +'use client' +import React, {ReactNode, useContext, useState} from 'react'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faArrowDown, faArrowUp, faSpinner} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; +import {SessionContext} from '@/context/SessionContext'; +import {AlertContext} from '@/context/AlertContext'; +import {LangContext, LangContextProps} from '@/context/LangContext'; +import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; +import {LocalSyncQueueContext, LocalSyncQueueContextProps} from '@/context/SyncQueueContext'; +import {BooksSyncContext, BooksSyncContextProps} from '@/context/BooksSyncContext'; +import {BookContext} from '@/context/BookContext'; +import {SyncedBook} from '@/lib/models/SyncedBook'; +import System from '@/lib/models/System'; + +export type SyncElementType = 'character' | 'world' | 'location' | 'spell'; + +interface SyncFieldWrapperProps { + children: ReactNode; + seriesElementId: string | null | undefined; + seriesValue: string; + currentValue: string; + bookElementId: string; + field: string; + elementType: SyncElementType; + onDownload: () => void; + onSyncComplete?: () => void; +} + +interface SeriesSyncUploadResponse { + success: boolean; + updatedCount: number; +} + +export default function SyncFieldWrapper({ + children, + seriesElementId, + seriesValue, + currentValue, + bookElementId, + field, + elementType, + onDownload, + onSyncComplete +}: SyncFieldWrapperProps) { + const t = useTranslations(); + const {session} = useContext(SessionContext); + const {errorMessage, successMessage} = useContext(AlertContext); + const {lang} = useContext(LangContext); + const {isCurrentlyOffline} = useContext(OfflineContext); + const {addToQueue} = useContext(LocalSyncQueueContext); + const {localSyncedBooks} = useContext(BooksSyncContext); + const {book} = useContext(BookContext); + + const [isUploading, setIsUploading] = useState(false); + + const isLinkedToSeries: boolean = !!seriesElementId; + const hasSeriesDiff: boolean = isLinkedToSeries && seriesValue !== currentValue; + + async function handleUpload(): Promise { + if (!seriesElementId || isUploading) return; + + setIsUploading(true); + try { + const requestData = { + type: elementType, + bookElementId: bookElementId, + field: field, + value: currentValue + }; + + let response: SeriesSyncUploadResponse; + + if (isCurrentlyOffline() || book?.localBook) { + // Offline OU livre local → IPC + response = await window.electron.invoke('db:series:sync:upload', requestData); + } else { + // Online + livre serveur → Server + response = await System.authPostToServer( + 'series/propagate', + requestData, + session.accessToken, + lang + ); + + // Si le livre a une copie locale → addToQueue pour sync + if (book?.bookId && localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === book.bookId)) { + addToQueue('db:series:sync:upload', requestData); + } + } + + if (response.success) { + successMessage(t('syncField.uploadSuccess', {count: response.updatedCount})); + if (onSyncComplete) { + onSyncComplete(); + } + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } finally { + setIsUploading(false); + } + } + + function handleDownload(): void { + onDownload(); + } + + if (!isLinkedToSeries) { + return <>{children}; + } + + return ( +
+ + +
+ {children} +
+ + +
+ ); +} diff --git a/components/leftbar/ScribeLeftBar.tsx b/components/leftbar/ScribeLeftBar.tsx index 7d031b4..09b71df 100644 --- a/components/leftbar/ScribeLeftBar.tsx +++ b/components/leftbar/ScribeLeftBar.tsx @@ -1,11 +1,12 @@ import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faBookMedical, faBookOpen, faFeather} from "@fortawesome/free-solid-svg-icons"; -import {useContext, useEffect, useState} from "react"; +import {faBookMedical, faBookOpen, faFeather, faLayerGroup} from "@fortawesome/free-solid-svg-icons"; +import React, {useContext, useEffect, useState} from "react"; import {BookContext} from "@/context/BookContext"; import ScribeChapterComponent from "@/components/leftbar/ScribeChapterComponent"; import PanelHeader from "@/components/PanelHeader"; import {PanelComponent} from "@/lib/models/Editor"; import AddNewBookForm from "@/components/book/AddNewBookForm"; +import AddNewSeriesForm from "@/components/series/AddNewSeriesForm"; import ShortStoryGenerator from "@/components/ShortStoryGenerator"; import {SessionContext} from "@/context/SessionContext"; import {useTranslations} from "next-intl"; @@ -21,6 +22,7 @@ export default function ScribeLeftBar() { const [currentPanel, setCurrentPanel] = useState(); const [showAddNewBook, setShowAddNewBook] = useState(false); + const [showAddNewSeries, setShowAddNewSeries] = useState(false); const [showGenerateShortModal, setShowGenerateShortModal] = useState(false) const {isCurrentlyOffline} = useContext(OfflineContext) @@ -61,6 +63,12 @@ export default function ScribeLeftBar() { icon: faFeather, badge: t("scribeLeftBar.homeComponents.generateStory.badge"), description: t("scribeLeftBar.homeComponents.generateStory.description") + }, { + id: 3, + title: t("scribeLeftBar.homeComponents.addSeries.title"), + icon: faLayerGroup, + badge: t("scribeLeftBar.homeComponents.addSeries.badge"), + description: t("scribeLeftBar.homeComponents.addSeries.description") }, ] @@ -86,7 +94,7 @@ export default function ScribeLeftBar() { return (
- {book ? editorComponents.map((component:PanelComponent) => ( + {book ? editorComponents.map(component => ( )) : ( homeComponents - .filter((component: PanelComponent):boolean => { - return !(isCurrentlyOffline() && component.id === 2); + .filter((component: PanelComponent): boolean => { + // Hide generate story (id: 2) in offline mode (requires AI server) + // Series (id: 3) now has dual logic and works offline + if (isCurrentlyOffline() && component.id === 2) { + return false; + } + return true; }) .map((component: PanelComponent) => ( )) )} @@ -141,6 +154,10 @@ export default function ScribeLeftBar() { showAddNewBook && } + { + showAddNewSeries && + + } { showGenerateShortModal && setShowGenerateShortModal(false)}/> diff --git a/components/rightbar/ComposerRightBar.tsx b/components/rightbar/ComposerRightBar.tsx index f17f4c2..24875be 100644 --- a/components/rightbar/ComposerRightBar.tsx +++ b/components/rightbar/ComposerRightBar.tsx @@ -1,54 +1,87 @@ +'use client' import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faFeather, faGlobe, faHatWizard, faInfoCircle, faMapMarkerAlt, faUsers} from "@fortawesome/free-solid-svg-icons"; -import {RefObject, useContext, useEffect, useRef, useState} from "react"; +import { + faFeather, + faGlobe, + faHatWizard, + faInfoCircle, + faMapMarkerAlt, + faUsers +} from "@fortawesome/free-solid-svg-icons"; +import React, {lazy, Suspense, useContext, useEffect, useState} from "react"; import {BookContext} from "@/context/BookContext"; import {PanelComponent} from "@/lib/models/Editor"; import PanelHeader from "@/components/PanelHeader"; import AboutEditors from "@/components/rightbar/AboutERitors"; import {faDiscord, faFacebook} from "@fortawesome/free-brands-svg-icons"; -import WorldSetting from "@/components/book/settings/world/WorldSetting"; -import LocationComponent from "@/components/book/settings/locations/LocationComponent"; -import CharacterComponent from "@/components/book/settings/characters/CharacterComponent"; -import SpellComponent from "@/components/book/settings/spells/SpellComponent"; import QuillSense from "@/components/quillsense/QuillSenseComponent"; import {useTranslations} from "next-intl"; +import {faSpinner} from "@fortawesome/free-solid-svg-icons"; import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; -export default function ComposerRightBar() { +// Lazy loaded Editor components +const WorldEditor = lazy(function () { + return import('@/components/book/settings/world/editor/WorldEditor'); +}); +const LocationEditor = lazy(function () { + return import('@/components/book/settings/locations/editor/LocationEditor'); +}); +const CharacterEditor = lazy(function () { + return import('@/components/book/settings/characters/editor/CharacterEditor'); +}); +const SpellEditor = lazy(function () { + return import('@/components/book/settings/spells/editor/SpellEditor'); +}); + +function LoadingSpinner(): React.JSX.Element { + return ( +
+ +
+ ); +} + +interface PanelContentProps { + currentPanelId: number | undefined; +} + +function PanelContent({currentPanelId}: PanelContentProps): React.JSX.Element { + return ( + }> + {currentPanelId === 1 && } + {currentPanelId === 2 && } + {currentPanelId === 3 && } + {currentPanelId === 4 && } + {currentPanelId === 5 && } + + ); +} + +interface PanelHeaderProps { + currentPanel: PanelComponent | undefined; + onClose: () => Promise; +} + +function EditorPanelHeader({currentPanel, onClose}: PanelHeaderProps): React.JSX.Element { + return ( + + ); +} + +export default function ComposerRightBar(): React.JSX.Element { const {book} = useContext(BookContext); - const t = useTranslations(); - - const {isCurrentlyOffline} = useContext(OfflineContext) - + const {isCurrentlyOffline} = useContext(OfflineContext); + const [panelHidden, setPanelHidden] = useState(false); - const [currentPanel, setCurrentPanel] = useState() - + const [currentPanel, setCurrentPanel] = useState(); const [showAbout, setShowAbout] = useState(false); - - const worldRef: RefObject<{ handleSave: () => Promise } | null> = useRef<{ handleSave: () => Promise }>(null); - const locationRef: RefObject<{ handleSave: () => Promise } | null> = useRef<{ handleSave: () => Promise }>(null); - const characterRef: RefObject<{ handleSave: () => Promise } | null> = useRef<{ handleSave: () => Promise }>(null); - const spellRef: RefObject<{ handleSave: () => Promise } | null> = useRef<{ handleSave: () => Promise }>(null); - - async function handleSaveClick(): Promise { - switch (currentPanel?.id) { - case 2: - worldRef.current?.handleSave(); - break; - case 3: - locationRef.current?.handleSave(); - break; - case 4: - characterRef.current?.handleSave(); - break; - case 5: - spellRef.current?.handleSave(); - break; - default: - break; - } - } function togglePanel(component: PanelComponent): void { if (panelHidden) { @@ -60,15 +93,7 @@ export default function ComposerRightBar() { setPanelHidden(true); } } - - useEffect(():void => { - if (!book){ - setCurrentPanel(undefined); - setPanelHidden(false); - return; - } - }, [book]); - + const editorComponents: PanelComponent[] = [ { id: 1, @@ -105,15 +130,8 @@ export default function ComposerRightBar() { badge: t("composerRightBar.editorComponents.spells.badge"), icon: faHatWizard }, - /*{ - id: 6, - title: t("composerRightBar.editorComponents.items.title"), - description: t("composerRightBar.editorComponents.items.description"), - badge: t("composerRightBar.editorComponents.items.badge"), - icon: faCube, - }*/ - ] - + ]; + const homeComponents: PanelComponent[] = [ { id: 1, @@ -121,7 +139,9 @@ export default function ComposerRightBar() { description: t("composerRightBar.homeComponents.about.description"), badge: t("composerRightBar.homeComponents.about.badge"), icon: faInfoCircle, - action: () => setShowAbout(true) + action: function (): void { + setShowAbout(true); + } }, { id: 2, @@ -129,7 +149,9 @@ export default function ComposerRightBar() { description: t("composerRightBar.homeComponents.facebook.description"), badge: t("composerRightBar.homeComponents.facebook.badge"), icon: faFacebook, - action: ():Promise => window.electron.openExternal('https://www.facebook.com/profile.php?id=61562628720878') + action: function (): Promise { + return window.electron.openExternal('https://www.facebook.com/profile.php?id=61562628720878'); + } }, { id: 3, @@ -137,124 +159,115 @@ export default function ComposerRightBar() { description: t("composerRightBar.homeComponents.discord.description"), badge: t("composerRightBar.homeComponents.discord.badge"), icon: faDiscord, - action: () => window.electron.openExternal('https://discord.gg/CHXRPvmaXm') + action: function (): Promise { + return window.electron.openExternal('https://discord.gg/CHXRPvmaXm'); + } } - ] - + ]; + function disabled(componentId: number): boolean { - switch (componentId) { - case 1: - return book === null; - default: - return book === null; - } + return book === null; } - + + useEffect(function (): void { + if (!book) { + setCurrentPanel(undefined); + setPanelHidden(false); + } + }, [book]); + + async function handleClosePanel(): Promise { + setPanelHidden(!panelHidden); + } + return (
{panelHidden && (
- setPanelHidden(!panelHidden)} +
- {currentPanel?.id === 1 && ( - - )} - {currentPanel?.id === 2 && ( - - )} - {currentPanel?.id === 3 && ( - - )} - {currentPanel?.id === 4 && ( - - )} - {currentPanel?.id === 5 && ( - - )} +
)}
- {book ? editorComponents - .filter((component: PanelComponent):boolean => { - // Filter QuillSense if offline, local book, or quillsenseEnabled is false - if (component.id === 1) { - if (isCurrentlyOffline() || book?.localBook) { - return false; - } - if (book?.quillsenseEnabled === false) { - return false; - } - } - // Filter Worlds if tools.worlds is disabled - if (component.id === 2 && !book?.tools?.worlds) { + {book ? editorComponents.filter(function (component: PanelComponent): boolean { + // Masquer QuillSense (id=1) si offline, livre local ou desactive pour ce livre + if (component.id === 1) { + if (isCurrentlyOffline() || book?.localBook) { return false; } - // Filter Locations if tools.locations is disabled - if (component.id === 3 && !book?.tools?.locations) { + if (book.quillsenseEnabled === false) { return false; } - // Filter Characters if tools.characters is disabled - if (component.id === 4 && !book?.tools?.characters) { - return false; - } - // Filter Spells if tools.spells is disabled - if (component.id === 5 && !book?.tools?.spells) { - return false; - } - return true; - }) - .map((component: PanelComponent) => ( - - )) : homeComponents.map((component: PanelComponent) => ( - - ))} + } + // Masquer Worlds (id=2) si desactive pour ce livre + if (component.id === 2 && !book?.tools?.worlds) { + return false; + } + // Masquer Locations (id=3) si desactive pour ce livre + if (component.id === 3 && !book?.tools?.locations) { + return false; + } + // Masquer Characters (id=4) si desactive pour ce livre + if (component.id === 4 && !book?.tools?.characters) { + return false; + } + // Masquer Spells (id=5) si desactive pour ce livre + if (component.id === 5 && !book?.tools?.spells) { + return false; + } + return true; + }).map(function (component: PanelComponent) { + return ( + + ); + }) : homeComponents.map(function (component: PanelComponent) { + return ( + + ); + })}
- { - showAbout && setShowAbout(false)}/> - } + {showAbout && }
- ) -} \ No newline at end of file + ); +} diff --git a/components/series/AddNewSeriesForm.tsx b/components/series/AddNewSeriesForm.tsx new file mode 100644 index 0000000..617d9a2 --- /dev/null +++ b/components/series/AddNewSeriesForm.tsx @@ -0,0 +1,258 @@ +'use client'; +import React, {ChangeEvent, Dispatch, SetStateAction, useContext, useEffect, useRef, useState} from "react"; +import {AlertContext} from "@/context/AlertContext"; +import System from "@/lib/models/System"; +import {SessionContext} from "@/context/SessionContext"; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faBook, faCheck, faLayerGroup, faPencilAlt, faX} from "@fortawesome/free-solid-svg-icons"; +import InputField from "@/components/form/InputField"; +import TextInput from "@/components/form/TextInput"; +import TexteAreaInput from "@/components/form/TexteAreaInput"; +import CancelButton from "@/components/form/CancelButton"; +import SubmitButtonWLoading from "@/components/form/SubmitButtonWLoading"; +import {useTranslations} from "next-intl"; +import {LangContext, LangContextProps} from "@/context/LangContext"; +import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext"; +import {SyncedBook} from "@/lib/models/SyncedBook"; +import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; +import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext"; +import {SyncedSeries, SyncedSeriesBook} from "@/lib/models/SyncedSeries"; + +interface AddNewSeriesFormProps { + setCloseForm: Dispatch>; + onSeriesCreated?: (seriesId: string, name: string) => void; +} + +export default function AddNewSeriesForm({setCloseForm, onSeriesCreated}: AddNewSeriesFormProps) { + const t = useTranslations(); + const {lang} = useContext(LangContext); + const {session} = useContext(SessionContext); + const {errorMessage, successMessage} = useContext(AlertContext); + const {serverSyncedBooks, setServerSyncedBooks, localSyncedBooks, setLocalSyncedBooks} = useContext(BooksSyncContext); + const {isCurrentlyOffline} = useContext(OfflineContext); + const {serverSyncedSeries, localSyncedSeries} = useContext(SeriesSyncContext); + + // Get all bookIds already in a series + const booksAlreadyInSeries: Set = new Set( + (isCurrentlyOffline() ? localSyncedSeries : serverSyncedSeries) + .flatMap((series: SyncedSeries): string[] => + series.books.map((book: SyncedSeriesBook): string => book.bookId) + ) + ); + const modalRef: React.RefObject = useRef(null); + + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [selectedBookIds, setSelectedBookIds] = useState([]); + const [isAddingSeries, setIsAddingSeries] = useState(false); + + const token: string = session?.accessToken ?? ''; + + useEffect((): () => void => { + document.body.style.overflow = 'hidden'; + return (): void => { + document.body.style.overflow = 'auto'; + }; + }, []); + + function toggleBookSelection(bookId: string): void { + setSelectedBookIds((prev: string[]): string[] => { + if (prev.includes(bookId)) { + return prev.filter((id: string): boolean => id !== bookId); + } + return [...prev, bookId]; + }); + } + + async function handleAddSeries(): Promise { + if (!name) { + errorMessage(t('addNewSeriesForm.error.nameMissing')); + return; + } + if (name.length < 2) { + errorMessage(t('addNewSeriesForm.error.nameTooShort')); + return; + } + if (name.length > 100) { + errorMessage(t('addNewSeriesForm.error.nameTooLong')); + return; + } + + setIsAddingSeries(true); + try { + const createData = { + name: name, + description: description || null, + bookIds: selectedBookIds, + }; + let response: string; + + if (isCurrentlyOffline()) { + response = await window.electron.invoke('db:series:create', createData); + } else { + response = await System.authPostToServer( + 'series/add', + createData, + token, + lang + ); + } + + if (!response) { + errorMessage(t('addNewSeriesForm.error.addingSeries')); + setIsAddingSeries(false); + return; + } + successMessage(t('addNewSeriesForm.success')); + + if (selectedBookIds.length > 0) { + if (isCurrentlyOffline()) { + setLocalSyncedBooks((prev: SyncedBook[]): SyncedBook[] => + prev.map((book: SyncedBook): SyncedBook => + selectedBookIds.includes(book.id) + ? {...book, seriesId: response} + : book + ) + ); + } else { + setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] => + prev.map((book: SyncedBook): SyncedBook => + selectedBookIds.includes(book.id) + ? {...book, seriesId: response} + : book + ) + ); + } + } + + if (onSeriesCreated) { + onSeriesCreated(response, name); + } + setIsAddingSeries(false); + setCloseForm(false); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('addNewSeriesForm.error.addingSeries')); + } + setIsAddingSeries(false); + } + } + + return ( +
+
+
+

+ + {t("addNewSeriesForm.title")} +

+ +
+ +
+
+ ): void => setName(e.target.value)} + placeholder={t("addNewSeriesForm.namePlaceholder")} + /> + }/> + + ): void => setDescription(e.target.value)} + placeholder={t("addNewSeriesForm.descriptionPlaceholder")} + /> + } + /> + +
+
+ + {t("addNewSeriesForm.selectBooks")} + {selectedBookIds.length > 0 && ( + + ({selectedBookIds.length} {t("addNewSeriesForm.selected")}) + + )} +
+ + {(isCurrentlyOffline() ? localSyncedBooks : serverSyncedBooks) + .filter((book: SyncedBook): boolean => !booksAlreadyInSeries.has(book.id)).length === 0 ? ( +
+ +

{t("addNewSeriesForm.noBooks")}

+
+ ) : ( +
+ {(isCurrentlyOffline() ? localSyncedBooks : serverSyncedBooks) + .filter((book: SyncedBook): boolean => !booksAlreadyInSeries.has(book.id)) + .map((book: SyncedBook) => { + const isSelected: boolean = selectedBookIds.includes(book.id); + return ( + + ); + })} +
+ )} +
+
+
+ +
+
+
+ setCloseForm(false)}/> + +
+
+
+
+ ); +} diff --git a/components/series/SeriesCard.tsx b/components/series/SeriesCard.tsx new file mode 100644 index 0000000..4405cce --- /dev/null +++ b/components/series/SeriesCard.tsx @@ -0,0 +1,112 @@ +'use client'; +import React, {useState} from "react"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faCog, faLayerGroup} from "@fortawesome/free-solid-svg-icons"; +import {BookProps} from "@/lib/models/Book"; +import BookCard from "@/components/book/BookCard"; +import {useTranslations} from "next-intl"; +import {SyncType} from "@/context/BooksSyncContext"; +import {SeriesSyncType} from "@/context/SeriesSyncContext"; +import SyncSeries from "@/components/SyncSeries"; + +export interface SeriesCardProps { + id: string; + name: string; + coverImage: string | null; + books: BookProps[]; +} + +interface SeriesCardComponentProps { + series: SeriesCardProps; + onBookClick: (bookId: string) => Promise; + onSettingsClick: (seriesId: string) => void; + getSyncStatus?: (bookId: string) => SyncType; + seriesSyncStatus?: SeriesSyncType; +} + +export default function SeriesCard({series, onBookClick, onSettingsClick, getSyncStatus, seriesSyncStatus = 'synced'}: SeriesCardComponentProps) { + const t = useTranslations(); + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+
setIsExpanded(!isExpanded)} + className={`group bg-tertiary/90 backdrop-blur-sm shadow-lg hover:shadow-2xl transition-all duration-300 border-2 border-primary/50 hover:border-primary flex flex-col cursor-pointer flex-shrink-0 w-64 sm:w-52 md:w-48 lg:w-56 xl:w-64 ${isExpanded ? 'rounded-l-2xl border-r-0' : 'rounded-2xl'}`} + > +
+ {series.coverImage ? ( + {series.name} + ) : ( +
+
+ +
+ )} +
+ +
+ +
+ {series.books.length} +
+
+
+ +
+
+

+ {series.name} +

+
+
+

+ {series.books.length} {series.books.length > 1 ? t("seriesCard.books") : t("seriesCard.book")} +

+
+
+
+
+ + {t("seriesCard.series")} + +
+
+
+ +
+ {series.books.map((book: BookProps, idx: number) => ( +
+ +
+ ))} + + {/* Bouton Settings */} +
+ +
+
+ + {/* Bordure de fin */} +
+
+ ); +} diff --git a/components/series/SeriesSetting.tsx b/components/series/SeriesSetting.tsx new file mode 100644 index 0000000..204e1c5 --- /dev/null +++ b/components/series/SeriesSetting.tsx @@ -0,0 +1,37 @@ +'use client' +import {useState} from "react"; +import SeriesSettingSidebar from "@/components/series/SeriesSettingSidebar"; +import SeriesSettingOption from "@/components/series/SeriesSettingOption"; +import {SeriesContext} from "@/context/SeriesContext"; +import {useTranslations} from "next-intl"; +import SettingsPanel from "@/components/SettingsPanel"; + +interface SeriesSettingProps { + seriesId: string; + localSeries: boolean; + onClose: () => void; +} + +export default function SeriesSetting({seriesId, localSeries, onClose}: SeriesSettingProps) { + const t = useTranslations(); + const [currentSetting, setCurrentSetting] = useState('basic-information'); + + return ( + + + } + onClose={onClose} + > + + + + ); +} diff --git a/components/series/SeriesSettingOption.tsx b/components/series/SeriesSettingOption.tsx new file mode 100644 index 0000000..8435777 --- /dev/null +++ b/components/series/SeriesSettingOption.tsx @@ -0,0 +1,121 @@ +'use client' +import React, {lazy, Suspense, useContext, useRef} from 'react'; +import {faPen, faSave} from '@fortawesome/free-solid-svg-icons'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faSpinner} from '@fortawesome/free-solid-svg-icons'; +import {useTranslations} from 'next-intl'; +import PanelHeader from '@/components/PanelHeader'; +import {SeriesContext} from '@/context/SeriesContext'; + +// Lazy loaded components - avec ref (anciens) +const BasicSeriesInformation = lazy(function () { + return import('./settings/BasicSeriesInformation'); +}); +const SeriesBooksManager = lazy(function () { + return import('./settings/SeriesBooksManager'); +}); + +// Lazy loaded components - sans ref (nouveaux avec leur propre header) +const WorldSettings = lazy(function () { + return import('@/components/book/settings/world/settings/WorldSettings'); +}); +const LocationSettings = lazy(function () { + return import('@/components/book/settings/locations/settings/LocationSettings'); +}); +const CharacterSettings = lazy(function () { + return import('@/components/book/settings/characters/settings/CharacterSettings'); +}); +const SpellSettings = lazy(function () { + return import('@/components/book/settings/spells/settings/SpellSettings'); +}); + +function LoadingSpinner(): React.JSX.Element { + return ( +
+ +
+ ); +} + +interface SeriesSettingOptionProps { + setting: string; +} + +interface SettingRef { + handleSave: () => Promise; +} + +// Settings qui gèrent leur propre save (pas de bouton save parent) +const selfManagedSettings: string[] = ['characters', 'spells', 'worlds', 'locations']; + +export default function SeriesSettingOption({setting}: SeriesSettingOptionProps): React.JSX.Element { + const t = useTranslations(); + const {seriesId} = useContext(SeriesContext); + const settingRef = useRef(null); + + const showSaveButton: boolean = !selfManagedSettings.includes(setting); + + function renderTitle(): string { + switch (setting) { + case 'basic-information': + return t("seriesSettingOption.basicInformation"); + case 'books': + return t("seriesSettingOption.books"); + case 'characters': + return t("seriesSettingOption.characters"); + case 'worlds': + return t("seriesSettingOption.worlds"); + case 'locations': + return t("seriesSettingOption.locations"); + case 'spells': + return t("seriesSettingOption.spells"); + default: + return ""; + } + } + + async function handleSaveClick(): Promise { + if (settingRef.current?.handleSave) { + await settingRef.current.handleSave(); + } + } + + return ( +
+
+ +
+
+ }> + {setting === 'basic-information' && } + {setting === 'books' && } + {setting === 'worlds' && ( + + )} + {setting === 'locations' && ( + + )} + {setting === 'characters' && ( + + )} + {setting === 'spells' && ( + + )} + {!['basic-information', 'books', 'worlds', 'locations', 'characters', 'spells'].includes(setting) && ( +
+ {t("bookSettingOption.notAvailable")} +
+ )} +
+
+
+ ); +} diff --git a/components/series/SeriesSettingSidebar.tsx b/components/series/SeriesSettingSidebar.tsx new file mode 100644 index 0000000..759d5d2 --- /dev/null +++ b/components/series/SeriesSettingSidebar.tsx @@ -0,0 +1,177 @@ +'use client' +import Link from "next/link"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import { + faBook, + faGlobe, + faHatWizard, + faMapMarkedAlt, + faPencilAlt, + faTrash, + faUser +} from "@fortawesome/free-solid-svg-icons"; +import React, {Dispatch, SetStateAction, useContext, useState} from "react"; +import {IconDefinition} from "@fortawesome/fontawesome-svg-core"; +import {useTranslations} from "next-intl"; +import AlertBox from "@/components/AlertBox"; +import {SessionContext} from "@/context/SessionContext"; +import {LangContext, LangContextProps} from "@/context/LangContext"; +import {AlertContext} from "@/context/AlertContext"; +import System from "@/lib/models/System"; +import {SeriesContext, SeriesContextProps} from "@/context/SeriesContext"; +import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; +import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext"; +import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext"; +import {SyncedSeries} from "@/lib/models/SyncedSeries"; + +interface SeriesSettingOption { + id: string; + name: string; + icon: IconDefinition; +} + +interface SeriesSettingSidebarProps { + selectedSetting: string; + setSelectedSetting: Dispatch>; + seriesId: string; + onClose: () => void; +} + +export default function SeriesSettingSidebar( + { + selectedSetting, + setSelectedSetting, + seriesId, + onClose + }: SeriesSettingSidebarProps) { + const t = useTranslations(); + const {session} = useContext(SessionContext); + const {lang} = useContext(LangContext); + const {errorMessage, successMessage} = useContext(AlertContext); + const userToken: string = session?.accessToken ? session?.accessToken : ''; + const {localSeries} = useContext(SeriesContext); + const {isCurrentlyOffline} = useContext(OfflineContext); + const {localSyncedSeries} = useContext(SeriesSyncContext); + const {addToQueue} = useContext(LocalSyncQueueContext); + + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + async function handleDeleteSeries(): Promise { + try { + const deleteData = {seriesId: seriesId}; + let success: boolean; + + if (isCurrentlyOffline() || localSeries) { + success = await window.electron.invoke('db:series:delete', deleteData); + } else { + success = await System.authDeleteToServer( + 'series/delete', + deleteData, + userToken, + lang + ); + + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { + addToQueue('db:series:delete', deleteData); + } + } + + if (success) { + successMessage(t('seriesSetting.deleteSuccess')); + onClose(); + window.location.reload(); + } + } catch (error: unknown) { + if (error instanceof Error) { + errorMessage(error.message); + } else { + errorMessage(t('seriesSetting.deleteError')); + } + } + } + + async function handleDeleteConfirm(): Promise { + await handleDeleteSeries(); + setShowDeleteConfirm(false); + } + + const settings: SeriesSettingOption[] = [ + { + id: 'basic-information', + name: 'seriesSetting.basicInformation', + icon: faPencilAlt + }, + { + id: 'books', + name: 'seriesSetting.books', + icon: faBook + }, + { + id: 'characters', + name: 'seriesSetting.characters', + icon: faUser + }, + { + id: 'worlds', + name: 'seriesSetting.worlds', + icon: faGlobe + }, + { + id: 'locations', + name: 'seriesSetting.locations', + icon: faMapMarkedAlt + }, + { + id: 'spells', + name: 'seriesSetting.spells', + icon: faHatWizard + } + ]; + + return ( +
+ + +
+ +
+ + {showDeleteConfirm && ( + setShowDeleteConfirm(false)} + confirmText={t('common.delete')} + cancelText={t('common.cancel')} + /> + )} +
+ ); +} diff --git a/components/series/settings/BasicSeriesInformation.tsx b/components/series/settings/BasicSeriesInformation.tsx new file mode 100644 index 0000000..f0d66ff --- /dev/null +++ b/components/series/settings/BasicSeriesInformation.tsx @@ -0,0 +1,151 @@ +'use client' +import {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from "react"; +import System from "@/lib/models/System"; +import {AlertContext} from "@/context/AlertContext"; +import {SessionContext} from "@/context/SessionContext"; +import TextInput from "@/components/form/TextInput"; +import TexteAreaInput from "@/components/form/TexteAreaInput"; +import InputField from "@/components/form/InputField"; +import {useTranslations} from "next-intl"; +import {LangContext, LangContextProps} from "@/context/LangContext"; +import {SeriesContext, SeriesContextProps} from "@/context/SeriesContext"; +import {SeriesDetailResponse, SeriesUpdateResponse} from "@/lib/models/Series"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faSpinner} from "@fortawesome/free-solid-svg-icons"; +import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; +import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext"; +import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext"; +import {SyncedSeries} from "@/lib/models/SyncedSeries"; + +function BasicSeriesInformation(props: object, ref: React.Ref<{ handleSave: () => Promise }>) { + const t = useTranslations(); + const {lang} = useContext(LangContext); + + const {session} = useContext(SessionContext); + const {seriesId, localSeries} = useContext(SeriesContext); + const userToken: string = session?.accessToken ? session?.accessToken : ''; + const {errorMessage, successMessage} = useContext(AlertContext); + const {isCurrentlyOffline} = useContext(OfflineContext); + const {addToQueue} = useContext(LocalSyncQueueContext); + const {localSyncedSeries} = useContext(SeriesSyncContext); + + const [isLoading, setIsLoading] = useState(true); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + + useEffect(function () { + if (seriesId) { + loadSeriesData(); + } + }, [seriesId]); + + async function loadSeriesData(): Promise { + setIsLoading(true); + try { + let response: SeriesDetailResponse; + + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke('db:series:detail', {seriesId}); + } else { + response = await System.authGetQueryToServer( + 'series/detail', + userToken, + lang, + {seriesid: seriesId} + ); + } + + if (response) { + setName(response.name); + setDescription(response.description || ''); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('seriesBasicInformation.error.unknown')); + } + } finally { + setIsLoading(false); + } + } + + useImperativeHandle(ref, function () { + return { + handleSave: handleSave + }; + }); + + async function handleSave(): Promise { + if (!name) { + errorMessage(t('seriesBasicInformation.error.nameRequired')); + return; + } + try { + const updateData = { + seriesId: seriesId, + name: name, + description: description + }; + let success: boolean; + + if (isCurrentlyOffline() || localSeries) { + success = await window.electron.invoke('db:series:update', updateData); + } else { + const response: SeriesUpdateResponse = await System.authPutToServer( + 'series/update', + updateData, + userToken, + lang + ); + success = response.success; + + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { + addToQueue('db:series:update', updateData); + } + } + + if (!success) { + errorMessage(t('seriesBasicInformation.error.update')); + return; + } + successMessage(t('seriesBasicInformation.success.update')); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('seriesBasicInformation.error.unknown')); + } + } + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ ) => setName(e.target.value)} + placeholder={t('seriesBasicInformation.fields.namePlaceholder')} + />}/> +
+ +
+ ) => setDescription(e.target.value)} + placeholder={t('seriesBasicInformation.fields.descriptionPlaceholder')} + />}/> +
+
+ ); +} + +export default forwardRef(BasicSeriesInformation); diff --git a/components/series/settings/SeriesBooksManager.tsx b/components/series/settings/SeriesBooksManager.tsx new file mode 100644 index 0000000..e69f413 --- /dev/null +++ b/components/series/settings/SeriesBooksManager.tsx @@ -0,0 +1,372 @@ +'use client' +import {forwardRef, useContext, useEffect, useImperativeHandle, useState} from "react"; +import System from "@/lib/models/System"; +import {AlertContext} from "@/context/AlertContext"; +import {SessionContext} from "@/context/SessionContext"; +import {useTranslations} from "next-intl"; +import {LangContext, LangContextProps} from "@/context/LangContext"; +import {SeriesContext, SeriesContextProps} from "@/context/SeriesContext"; +import {SeriesBookProps} from "@/lib/models/Series"; +import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext"; +import {SyncedBook} from "@/lib/models/SyncedBook"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faArrowDown, faArrowUp, faBook, faPlus, faSpinner, faTrash} from "@fortawesome/free-solid-svg-icons"; +import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; +import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext"; +import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext"; +import {SyncedSeries, SyncedSeriesBook} from "@/lib/models/SyncedSeries"; + +function SeriesBooksManager(props: object, ref: React.Ref<{ handleSave: () => Promise }>) { + const t = useTranslations(); + const {lang} = useContext(LangContext); + + const {session} = useContext(SessionContext); + const {seriesId, localSeries} = useContext(SeriesContext); + const {serverSyncedBooks, setServerSyncedBooks, localSyncedBooks} = useContext(BooksSyncContext); + const userToken: string = session?.accessToken ? session?.accessToken : ''; + const {errorMessage, successMessage} = useContext(AlertContext); + const {isCurrentlyOffline} = useContext(OfflineContext); + const {addToQueue} = useContext(LocalSyncQueueContext); + const {localSyncedSeries, serverSyncedSeries} = useContext(SeriesSyncContext); + + const [isLoading, setIsLoading] = useState(true); + const [seriesBooks, setSeriesBooks] = useState([]); + const [selectedBookToAdd, setSelectedBookToAdd] = useState(''); + const [availableBooks, setAvailableBooks] = useState([]); + + useEffect(function () { + if (seriesId) { + loadSeriesBooks(); + } + }, [seriesId]); + + useEffect(function () { + const booksInThisSeries: string[] = seriesBooks.map((book: SeriesBookProps) => book.bookId); + let allBooks: SyncedBook[]; + let allSeries: SyncedSeries[]; + + if (isCurrentlyOffline() || localSeries) { + allBooks = localSyncedBooks; + allSeries = localSyncedSeries; + } else { + allBooks = serverSyncedBooks; + allSeries = serverSyncedSeries; + } + + // Get all bookIds in OTHER series (not this one) + const booksInOtherSeries: Set = new Set( + allSeries + .filter((series: SyncedSeries): boolean => series.id !== seriesId) + .flatMap((series: SyncedSeries): string[] => + series.books.map((book: SyncedSeriesBook): string => book.bookId) + ) + ); + + // Filter out books already in this series AND books already in another series + const filteredBooks: SyncedBook[] = allBooks.filter( + (book: SyncedBook) => !booksInThisSeries.includes(book.id) && !booksInOtherSeries.has(book.id) + ); + setAvailableBooks(filteredBooks); + }, [seriesBooks, serverSyncedBooks, localSyncedBooks, serverSyncedSeries, localSyncedSeries, isCurrentlyOffline, localSeries, seriesId]); + + async function loadSeriesBooks(): Promise { + setIsLoading(true); + try { + let response: SeriesBookProps[]; + + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke('db:series:books', {seriesId}); + } else { + response = await System.authGetQueryToServer( + 'series/book/list', + userToken, + lang, + {seriesid: seriesId} + ); + } + + if (response) { + setSeriesBooks(response); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('seriesBooks.error.unknown')); + } + } finally { + setIsLoading(false); + } + } + + useImperativeHandle(ref, function () { + return { + handleSave: handleSave + }; + }); + + async function handleSave(): Promise { + successMessage(t('seriesBooks.success.saved')); + } + + async function handleAddBook(): Promise { + if (!selectedBookToAdd) { + errorMessage(t('seriesBooks.error.selectBook')); + return; + } + + try { + const addData = { + seriesId: seriesId, + bookId: selectedBookToAdd + }; + let response: boolean; + + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke('db:series:book:add', addData); + } else { + response = await System.authPostToServer( + 'series/book/add', + addData, + userToken, + lang + ); + + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { + addToQueue('db:series:book:add', addData); + } + } + + if (response) { + const allBooks: SyncedBook[] = isCurrentlyOffline() || localSeries ? localSyncedBooks : serverSyncedBooks; + const addedBook: SyncedBook | undefined = allBooks.find( + (book: SyncedBook) => book.id === selectedBookToAdd + ); + if (addedBook) { + const newSeriesBook: SeriesBookProps = { + bookId: addedBook.id, + title: addedBook.title, + order: seriesBooks.length + 1, + coverImage: null + }; + setSeriesBooks([...seriesBooks, newSeriesBook]); + + setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] => + prev.map((book: SyncedBook): SyncedBook => + book.id === selectedBookToAdd + ? {...book, seriesId: seriesId} + : book + ) + ); + } + setSelectedBookToAdd(''); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('seriesBooks.error.unknown')); + } + } + } + + async function handleRemoveBook(bookId: string): Promise { + try { + const removeData = { + seriesId: seriesId, + bookId: bookId + }; + let response: boolean; + + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke('db:series:book:remove', removeData); + } else { + response = await System.authDeleteToServer( + 'series/book/remove', + removeData, + userToken, + lang + ); + + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { + addToQueue('db:series:book:remove', removeData); + } + } + + if (response) { + const updatedBooks: SeriesBookProps[] = seriesBooks + .filter((book: SeriesBookProps) => book.bookId !== bookId) + .map((book: SeriesBookProps, index: number) => ({ + ...book, + order: index + 1 + })); + setSeriesBooks(updatedBooks); + + setServerSyncedBooks((prev: SyncedBook[]): SyncedBook[] => + prev.map((book: SyncedBook): SyncedBook => + book.id === bookId + ? {...book, seriesId: null} + : book + ) + ); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('seriesBooks.error.unknown')); + } + } + } + + async function handleMoveBook(bookId: string, direction: 'up' | 'down'): Promise { + const currentIndex: number = seriesBooks.findIndex((book: SeriesBookProps) => book.bookId === bookId); + if (currentIndex === -1) return; + + const newIndex: number = direction === 'up' ? currentIndex - 1 : currentIndex + 1; + if (newIndex < 0 || newIndex >= seriesBooks.length) return; + + const reorderedBooks: SeriesBookProps[] = [...seriesBooks]; + const [movedBook] = reorderedBooks.splice(currentIndex, 1); + reorderedBooks.splice(newIndex, 0, movedBook); + + const updatedBooks: SeriesBookProps[] = reorderedBooks.map((book: SeriesBookProps, index: number) => ({ + ...book, + order: index + 1 + })); + + try { + const reorderData = { + seriesId: seriesId, + booksOrder: updatedBooks.map((book: SeriesBookProps) => ({ + bookId: book.bookId, + order: book.order + })) + }; + let response: boolean; + + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke('db:series:book:reorder', reorderData); + } else { + response = await System.authPutToServer( + 'series/book/reorder', + reorderData, + userToken, + lang + ); + + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { + addToQueue('db:series:book:reorder', reorderData); + } + } + + if (response) { + setSeriesBooks(updatedBooks); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('seriesBooks.error.unknown')); + } + } + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ {t('seriesBooks.addBook')} +

+
+ + +
+
+ +
+

+ {t('seriesBooks.booksInSeries')} ({seriesBooks.length}) +

+ + {seriesBooks.length === 0 ? ( +
+ +

{t('seriesBooks.noBooks')}

+
+ ) : ( +
+ {seriesBooks + .sort((a: SeriesBookProps, b: SeriesBookProps) => a.order - b.order) + .map((book: SeriesBookProps, index: number) => ( +
+
+ + {book.order} + + {book.title} +
+
+ + + +
+
+ ))} +
+ )} +
+
+ ); +} + +export default forwardRef(SeriesBooksManager); diff --git a/context/AlertProvider.tsx b/context/AlertProvider.tsx index acc8792..2782b29 100644 --- a/context/AlertProvider.tsx +++ b/context/AlertProvider.tsx @@ -1,7 +1,7 @@ 'use client'; import type {Context, Dispatch, JSX, ReactNode, SetStateAction} from 'react'; -import {createContext, useCallback, useState} from 'react'; +import React, {createContext, useCallback, useState} from 'react'; import AlertStack from '@/components/AlertStack'; import {cleanErrorMessage} from '@/lib/errors'; @@ -38,10 +38,11 @@ export const AlertContext: Context = createContext>] = useState([]); - const addAlert: (type: AlertType, message: string) => void = useCallback((type: AlertType, message: string): void => { + const addAlert: (type: AlertType, message: string) => void = useCallback((type: AlertType, message: unknown): void => { + const safeMessage: string = typeof message === 'string' ? message : String(message ?? 'Une erreur est survenue'); const id: string = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; - const newAlert: Alert = {id, type, message}; - + const newAlert: Alert = {id, type, message: safeMessage}; + setAlerts((prev: Alert[]): Alert[] => [...prev, newAlert]); }, []); @@ -54,7 +55,7 @@ export function AlertProvider({children}: AlertProviderProps): JSX.Element { }, [addAlert]); const errorMessage: (message: string) => void = useCallback((message: string): void => { - addAlert('error', cleanErrorMessage(message)); + addAlert('error', message); }, [addAlert]); const infoMessage: (message: string) => void = useCallback((message: string): void => { diff --git a/context/BooksSyncContext.ts b/context/BooksSyncContext.ts index 3ef19fa..1898d8a 100644 --- a/context/BooksSyncContext.ts +++ b/context/BooksSyncContext.ts @@ -12,6 +12,8 @@ export interface BooksSyncContextProps { setLocalSyncedBooks:Dispatch>; setServerOnlyBooks:Dispatch>; setLocalOnlyBooks:Dispatch>; + setBooksToSyncFromServer:Dispatch>; + setBooksToSyncToServer:Dispatch>; serverOnlyBooks:SyncedBook[]; localOnlyBooks:SyncedBook[]; } @@ -25,6 +27,8 @@ export const BooksSyncContext:Context = createContext {}, setServerOnlyBooks:():void => {}, setLocalOnlyBooks:():void => {}, + setBooksToSyncFromServer:():void => {}, + setBooksToSyncToServer:():void => {}, serverOnlyBooks:[], localOnlyBooks:[] }) \ No newline at end of file diff --git a/context/SeriesContext.ts b/context/SeriesContext.ts new file mode 100644 index 0000000..1ec743e --- /dev/null +++ b/context/SeriesContext.ts @@ -0,0 +1,11 @@ +import {Context, createContext} from "react"; + +export interface SeriesContextProps { + seriesId: string; + localSeries: boolean; +} + +export const SeriesContext: Context = createContext({ + seriesId: '', + localSeries: false +}); diff --git a/context/SeriesSyncContext.ts b/context/SeriesSyncContext.ts new file mode 100644 index 0000000..4681742 --- /dev/null +++ b/context/SeriesSyncContext.ts @@ -0,0 +1,42 @@ +import { SeriesSyncCompare, SyncedSeries } from "@/lib/models/SyncedSeries"; +import { Context, createContext, Dispatch, SetStateAction } from "react"; + +/** + * Sync status types for series synchronization. + * - 'server-only': Series exists only on server, needs download + * - 'local-only': Series exists only locally, needs upload + * - 'to-sync-from-server': Series has newer data on server + * - 'to-sync-to-server': Series has newer data locally + * - 'synced': Series is synchronized between server and local + */ +export type SeriesSyncType = 'server-only' | 'local-only' | 'to-sync-from-server' | 'to-sync-to-server' | 'synced'; + +export interface SeriesSyncContextProps { + serverSyncedSeries: SyncedSeries[]; + localSyncedSeries: SyncedSeries[]; + seriesToSyncFromServer: SeriesSyncCompare[]; + seriesToSyncToServer: SeriesSyncCompare[]; + setServerSyncedSeries: Dispatch>; + setLocalSyncedSeries: Dispatch>; + setServerOnlySeries: Dispatch>; + setLocalOnlySeries: Dispatch>; + setSeriesToSyncFromServer: Dispatch>; + setSeriesToSyncToServer: Dispatch>; + serverOnlySeries: SyncedSeries[]; + localOnlySeries: SyncedSeries[]; +} + +export const SeriesSyncContext: Context = createContext({ + serverSyncedSeries: [], + localSyncedSeries: [], + seriesToSyncFromServer: [], + seriesToSyncToServer: [], + setServerSyncedSeries: (): void => {}, + setLocalSyncedSeries: (): void => {}, + setServerOnlySeries: (): void => {}, + setLocalOnlySeries: (): void => {}, + setSeriesToSyncFromServer: (): void => {}, + setSeriesToSyncToServer: (): void => {}, + serverOnlySeries: [], + localOnlySeries: [] +}); diff --git a/context/WorldContext.ts b/context/WorldContext.ts index 7e91021..d0314ff 100755 --- a/context/WorldContext.ts +++ b/context/WorldContext.ts @@ -5,6 +5,7 @@ export interface WorldContextProps { worlds: WorldProps[]; setWorlds: Dispatch>; selectedWorldIndex: number; + isSeriesMode?: boolean; } export const WorldContext = createContext({} as WorldContextProps); diff --git a/electron/database/models/Book.ts b/electron/database/models/Book.ts index 47c1f59..d96d0dc 100644 --- a/electron/database/models/Book.ts +++ b/electron/database/models/Book.ts @@ -36,6 +36,7 @@ import { SyncedActSummary } from "./Act.js"; import { SyncedAIGuideLine, SyncedGuideLine } from "./GuideLine.js"; import Cover from "./Cover.js"; import UserRepo from "../repositories/user.repository.js"; +import RemovedItem from "./RemovedItem.js"; export interface SyncedBookTools { lastUpdate: number; @@ -369,6 +370,7 @@ export interface SyncedSeriesSpellTag { export interface SyncedSeries { id: string; name: string; + description: string | null; lastUpdate: number; books: SyncedSeriesBook[]; characters: SyncedSeriesCharacter[]; @@ -500,6 +502,8 @@ export default class Book { const book: Book = new Book(bookId); book.getBookInfos(userId); const bookTools: BookToolsTable | null = BookRepo.fetchBookTools(userId, bookId, lang); + // Récupérer le seriesId depuis series_books + const seriesId: string | null = BookRepo.fetchBookSeriesId(bookId, lang); return { bookId: book.getId(), type: book.getType(), @@ -508,6 +512,7 @@ export default class Book { subTitle: book.getSubTitle(), summary: book.getSummary(), serieId: book.getSerieId(), + seriesId: seriesId, desiredReleaseDate: book.getDesiredReleaseDate(), desiredWordCount: book.getDesiredWordCount(), wordCount: book.getWordCount(), @@ -547,11 +552,16 @@ export default class Book { * Removes a book from the database. * @param userId - The unique identifier of the user * @param bookId - The unique identifier of the book to remove + * @param deletedAt - The timestamp of deletion (from UI via System.timeStampInSeconds()) * @param lang - The language for error messages ('fr' or 'en') * @returns True if the book was removed, false otherwise */ - public static removeBook(userId: string, bookId: string, lang: 'fr' | 'en' = 'fr'): boolean { - return BookRepo.deleteBook(userId, bookId, lang); + public static removeBook(userId: string, bookId: string, deletedAt: number, lang: 'fr' | 'en' = 'fr'): boolean { + const deleted: boolean = BookRepo.deleteBook(userId, bookId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, bookId, 'erit_books', bookId, deletedAt, lang); + } + return deleted; } public static updateBookToolSetting(userId: string, bookId: string, toolName: 'characters' | 'worlds' | 'locations' | 'spells', enabled: boolean, lang: 'fr' | 'en' = 'fr'): boolean { diff --git a/electron/database/models/Chapter.ts b/electron/database/models/Chapter.ts index cf794cb..ae6d218 100644 --- a/electron/database/models/Chapter.ts +++ b/electron/database/models/Chapter.ts @@ -13,6 +13,7 @@ import ChapterContentRepository, { CompanionContentQueryResult, ContentQueryResult } from "../repositories/chaptercontent.repository.js"; +import RemovedItem from "./RemovedItem.js"; export interface ChapterContent { version: number; @@ -262,12 +263,18 @@ export default class Chapter { /** * Removes a chapter from the database. * @param userId - The unique identifier of the user + * @param bookId - The unique identifier of the book * @param chapterId - The unique identifier of the chapter to remove + * @param deletedAt - The timestamp of deletion (from UI via System.timeStampInSeconds()) * @param lang - The language for error messages ('fr' or 'en') * @returns True if the chapter was removed successfully, false otherwise */ - public static removeChapter(userId: string, chapterId: string, lang: 'fr' | 'en' = 'fr'): boolean { - return ChapterRepo.deleteChapter(userId, chapterId, lang); + public static removeChapter(userId: string, bookId: string, chapterId: string, deletedAt: number, lang: 'fr' | 'en' = 'fr'): boolean { + const deleted: boolean = ChapterRepo.deleteChapter(userId, chapterId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, bookId, 'book_chapters', chapterId, deletedAt, lang); + } + return deleted; } /** @@ -437,12 +444,18 @@ export default class Chapter { /** * Removes chapter information by its identifier. * @param userId - The unique identifier of the user + * @param bookId - The unique identifier of the book * @param chapterInfoId - The unique identifier of the chapter information to remove + * @param deletedAt - The timestamp of deletion (from UI via System.timeStampInSeconds()) * @param lang - The language for error messages ('fr' or 'en') * @returns True if the chapter information was removed successfully, false otherwise */ - static removeChapterInformation(userId: string, chapterInfoId: string, lang: 'fr' | 'en' = 'fr'): boolean { - return ChapterRepo.deleteChapterInformation(userId, chapterInfoId, lang); + static removeChapterInformation(userId: string, bookId: string, chapterInfoId: string, deletedAt: number, lang: 'fr' | 'en' = 'fr'): boolean { + const deleted: boolean = ChapterRepo.deleteChapterInformation(userId, chapterInfoId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, bookId, 'book_chapter_infos', chapterInfoId, deletedAt, lang); + } + return deleted; } /** diff --git a/electron/database/models/Character.ts b/electron/database/models/Character.ts index 1cbda1a..ad0bf63 100644 --- a/electron/database/models/Character.ts +++ b/electron/database/models/Character.ts @@ -6,6 +6,7 @@ import CharacterRepo, { import BookRepo, {BookToolsTable} from "../repositories/book.repository.js"; import System from "../System.js"; import {getUserEncryptionKey} from "../keyManager.js"; +import RemovedItem from "./RemovedItem.js"; export type CharacterCategory = 'Main' | 'Secondary' | 'Recurring'; @@ -290,23 +291,35 @@ export default class Character { /** * Deletes an attribute from a character. * @param userId - The unique identifier of the user + * @param bookId - The unique identifier of the book * @param attributeId - The unique identifier of the attribute to delete + * @param deletedAt - The timestamp of deletion * @param lang - The language code for localization (defaults to 'fr') * @returns True if the deletion was successful, false otherwise */ - static deleteAttribute(userId: string, attributeId: string, lang: 'fr' | 'en' = 'fr'): boolean { - return CharacterRepo.deleteAttribute(userId, attributeId, lang); + static deleteAttribute(userId: string, bookId: string, attributeId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { + const deleted: boolean = CharacterRepo.deleteAttribute(userId, attributeId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, bookId, 'book_characters_attributes', attributeId, deletedAt, lang); + } + return deleted; } /** * Deletes a character and all its related data. * @param userId - The unique identifier of the user + * @param bookId - The unique identifier of the book * @param characterId - The unique identifier of the character to delete + * @param deletedAt - The timestamp of deletion * @param lang - The language code for localization (defaults to 'fr') * @returns True if the deletion was successful */ - static deleteCharacter(userId: string, characterId: string, lang: 'fr' | 'en' = 'fr'): boolean { - return CharacterRepo.deleteCharacter(userId, characterId, lang); + static deleteCharacter(userId: string, bookId: string, characterId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { + const deleted: boolean = CharacterRepo.deleteCharacter(userId, characterId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, bookId, 'book_characters', characterId, deletedAt, lang); + } + return deleted; } /** diff --git a/electron/database/models/Download.ts b/electron/database/models/Download.ts index c2be45a..bf4bd38 100644 --- a/electron/database/models/Download.ts +++ b/electron/database/models/Download.ts @@ -235,9 +235,9 @@ export default class Download { const spellsInserted: boolean = data.spells.every((spell: BookSpellsTable): boolean => { const encryptedName: string = System.encryptDataWithUserKey(spell.name, userEncryptionKey); - const encryptedDescription: string = System.encryptDataWithUserKey(spell.description, userEncryptionKey); - const encryptedAppearance: string = System.encryptDataWithUserKey(spell.appearance, userEncryptionKey); - const encryptedTags: string = System.encryptDataWithUserKey(spell.tags, userEncryptionKey); + const encryptedDescription: string | null = spell.description ? System.encryptDataWithUserKey(spell.description, userEncryptionKey) : null; + const encryptedAppearance: string | null = spell.appearance ? System.encryptDataWithUserKey(spell.appearance, userEncryptionKey) : null; + const encryptedTags: string | null = spell.tags ? System.encryptDataWithUserKey(spell.tags, userEncryptionKey) : null; const encryptedPowerLevel: string | null = spell.power_level ? System.encryptDataWithUserKey(spell.power_level, userEncryptionKey) : null; const encryptedComponents: string | null = spell.components ? System.encryptDataWithUserKey(spell.components, userEncryptionKey) : null; const encryptedLimitations: string | null = spell.limitations ? System.encryptDataWithUserKey(spell.limitations, userEncryptionKey) : null; diff --git a/electron/database/models/Incident.ts b/electron/database/models/Incident.ts index 4db0be8..23ba62c 100644 --- a/electron/database/models/Incident.ts +++ b/electron/database/models/Incident.ts @@ -2,6 +2,7 @@ import { getUserEncryptionKey } from "../keyManager.js"; import System from "../System.js"; import { ActChapter } from "./Act.js"; import IncidentRepository, { IncidentQuery } from "../repositories/incident.repository.js"; +import RemovedItem from "./RemovedItem.js"; export interface IncidentStory { incidentTitle: string; @@ -91,6 +92,7 @@ export default class Incident { * @param userId - The unique identifier of the user * @param bookId - The unique identifier of the book * @param incidentId - The unique identifier of the incident to remove + * @param deletedAt - The timestamp of deletion * @param lang - The language for error messages (defaults to 'fr') * @returns True if the incident was successfully deleted, false otherwise */ @@ -98,8 +100,13 @@ export default class Incident { userId: string, bookId: string, incidentId: string, + deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr' ): boolean { - return IncidentRepository.deleteIncident(userId, bookId, incidentId, lang); + const deleted: boolean = IncidentRepository.deleteIncident(userId, bookId, incidentId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, bookId, 'book_incidents', incidentId, deletedAt, lang); + } + return deleted; } } diff --git a/electron/database/models/Issue.ts b/electron/database/models/Issue.ts index ae05c93..e8dbc31 100644 --- a/electron/database/models/Issue.ts +++ b/electron/database/models/Issue.ts @@ -1,6 +1,7 @@ import { getUserEncryptionKey } from "../keyManager.js"; import System from "../System.js"; import IssueRepository, { IssueQuery } from "../repositories/issue.repository.js"; +import RemovedItem from "./RemovedItem.js"; /** * Represents a synced issue with its metadata. @@ -84,15 +85,23 @@ export default class Issue { * Removes an issue from the database. * * @param userId - The unique identifier of the user. + * @param bookId - The unique identifier of the book. * @param issueId - The unique identifier of the issue to remove. + * @param deletedAt - The timestamp of deletion. * @param lang - The language for error messages ('fr' or 'en'). Defaults to 'fr'. * @returns True if the issue was successfully removed, false otherwise. */ public static removeIssue( userId: string, + bookId: string, issueId: string, + deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr' ): boolean { - return IssueRepository.deleteIssue(userId, issueId, lang); + const deleted: boolean = IssueRepository.deleteIssue(userId, issueId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, bookId, 'book_issues', issueId, deletedAt, lang); + } + return deleted; } } diff --git a/electron/database/models/Location.ts b/electron/database/models/Location.ts index 6d6c4aa..8e000d3 100644 --- a/electron/database/models/Location.ts +++ b/electron/database/models/Location.ts @@ -6,6 +6,7 @@ import LocationRepo, { import System from "../System.js"; import {getUserEncryptionKey} from "../keyManager.js"; import BookRepo, {BookToolsTable} from "../repositories/book.repository.js"; +import RemovedItem from "./RemovedItem.js"; export interface SubElement { id: string; @@ -229,34 +230,52 @@ export default class Location { /** * Deletes a location section and all its associated elements and sub-elements. * @param userId - The user's unique identifier. + * @param bookId - The book's unique identifier. * @param locationId - The location's unique identifier to delete. + * @param deletedAt - The timestamp of deletion. * @param lang - The language for error messages ('fr' or 'en'). Defaults to 'fr'. * @returns The result of the delete operation. */ - static deleteLocationSection(userId: string, locationId: string, lang: 'fr' | 'en' = 'fr'): boolean { - return LocationRepo.deleteLocationSection(userId, locationId, lang); + static deleteLocationSection(userId: string, bookId: string, locationId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { + const deleted: boolean = LocationRepo.deleteLocationSection(userId, locationId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, bookId, 'book_location', locationId, deletedAt, lang); + } + return deleted; } /** * Deletes a location element and all its associated sub-elements. * @param userId - The user's unique identifier. + * @param bookId - The book's unique identifier. * @param elementId - The element's unique identifier to delete. + * @param deletedAt - The timestamp of deletion. * @param lang - The language for error messages ('fr' or 'en'). Defaults to 'fr'. * @returns The result of the delete operation. */ - static deleteLocationElement(userId: string, elementId: string, lang: 'fr' | 'en' = 'fr'): boolean { - return LocationRepo.deleteLocationElement(userId, elementId, lang); + static deleteLocationElement(userId: string, bookId: string, elementId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { + const deleted: boolean = LocationRepo.deleteLocationElement(userId, elementId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, bookId, 'location_element', elementId, deletedAt, lang); + } + return deleted; } /** * Deletes a location sub-element. * @param userId - The user's unique identifier. + * @param bookId - The book's unique identifier. * @param subElementId - The sub-element's unique identifier to delete. + * @param deletedAt - The timestamp of deletion. * @param lang - The language for error messages ('fr' or 'en'). Defaults to 'fr'. * @returns The result of the delete operation. */ - static deleteLocationSubElement(userId: string, subElementId: string, lang: 'fr' | 'en' = 'fr'): boolean { - return LocationRepo.deleteLocationSubElement(userId, subElementId, lang); + static deleteLocationSubElement(userId: string, bookId: string, subElementId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { + const deleted: boolean = LocationRepo.deleteLocationSubElement(userId, subElementId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, bookId, 'location_sub_element', subElementId, deletedAt, lang); + } + return deleted; } /** diff --git a/electron/database/models/PlotPoint.ts b/electron/database/models/PlotPoint.ts index f76a567..b8876c0 100644 --- a/electron/database/models/PlotPoint.ts +++ b/electron/database/models/PlotPoint.ts @@ -2,6 +2,7 @@ import { getUserEncryptionKey } from "../keyManager.js"; import System from "../System.js"; import { ActChapter } from "./Act.js"; import PlotPointRepository, { PlotPointQuery } from "../repositories/plotpoint.repository.js"; +import RemovedItem from "./RemovedItem.js"; export interface PlotPointStory { plotTitle: string; @@ -96,11 +97,17 @@ export default class PlotPoint { /** * Removes a plot point from the database. * @param userId - The unique identifier of the user + * @param bookId - The unique identifier of the book * @param plotId - The unique identifier of the plot point to remove + * @param deletedAt - The timestamp of deletion * @param lang - The language for error messages ('fr' or 'en'), defaults to 'fr' * @returns True if the plot point was successfully deleted, false otherwise */ - static removePlotPoint(userId: string, plotId: string, lang: 'fr' | 'en' = 'fr'): boolean { - return PlotPointRepository.deletePlotPoint(userId, plotId, lang); + static removePlotPoint(userId: string, bookId: string, plotId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { + const deleted: boolean = PlotPointRepository.deletePlotPoint(userId, plotId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, bookId, 'book_plot_points', plotId, deletedAt, lang); + } + return deleted; } } diff --git a/electron/database/models/RemovedItem.ts b/electron/database/models/RemovedItem.ts new file mode 100644 index 0000000..0048f31 --- /dev/null +++ b/electron/database/models/RemovedItem.ts @@ -0,0 +1,41 @@ +import System from '../System.js'; +import RemovedItemsRepository from '../repositories/removed-items.repository.js'; + +/** + * Model class for tracking deleted items for sync purposes. + * Provides the main entry point for recording deletions. + */ +export default class RemovedItem { + /** + * Records a deleted item for sync tracking. + * Must be called BEFORE the actual deletion from the source table. + * + * @param userId - The unique identifier of the user. + * @param bookId - The book ID (null for series items). + * @param tableName - The name of the table from which the item is deleted. + * @param entityId - The UUID of the deleted entity. + * @param deletedAt - The timestamp of deletion (from UI via System.timeStampInSeconds()). + * @param lang - The language for error messages ('fr' or 'en'). Defaults to 'fr'. + * @returns True if the record was inserted successfully. + */ + public static deleteTracker( + userId: string, + bookId: string | null, + tableName: string, + entityId: string, + deletedAt: number, + lang: 'fr' | 'en' = 'fr' + ): boolean { + const removalId: string = System.createUniqueId(); + + return RemovedItemsRepository.insert( + removalId, + tableName, + entityId, + bookId, + userId, + deletedAt, + lang + ); + } +} diff --git a/electron/database/models/Series.ts b/electron/database/models/Series.ts index 113ed5f..831e6a4 100644 --- a/electron/database/models/Series.ts +++ b/electron/database/models/Series.ts @@ -1,6 +1,7 @@ import { getUserEncryptionKey } from "../keyManager.js"; import System from "../System.js"; import SeriesRepo, { SeriesBookResult, SeriesListItem, SeriesResult } from "../repositories/series.repo.js"; +import RemovedItem from "./RemovedItem.js"; export interface SeriesProps { id: string; @@ -148,16 +149,21 @@ export default class Series { * Deletes a series. * @param userId - The unique identifier of the user * @param seriesId - The unique identifier of the series + * @param deletedAt - The timestamp of deletion * @param lang - The language for error messages ('fr' or 'en') * @returns True if the deletion was successful */ - public static deleteSeries(userId: string, seriesId: string, lang: 'fr' | 'en' = 'fr'): boolean { + public static deleteSeries(userId: string, seriesId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { const exists: boolean = SeriesRepo.isSeriesExist(userId, seriesId, lang); if (!exists) { throw new Error(lang === 'fr' ? 'Série non trouvée.' : 'Series not found.'); } - return SeriesRepo.deleteSeries(userId, seriesId, lang); + const deleted: boolean = SeriesRepo.deleteSeries(userId, seriesId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, null, 'book_series', seriesId, deletedAt, lang); + } + return deleted; } /** @@ -183,16 +189,21 @@ export default class Series { * @param userId - The unique identifier of the user * @param seriesId - The unique identifier of the series * @param bookId - The unique identifier of the book + * @param deletedAt - The timestamp of deletion * @param lang - The language for error messages ('fr' or 'en') * @returns True if the removal was successful */ - public static removeBookFromSeries(userId: string, seriesId: string, bookId: string, lang: 'fr' | 'en' = 'fr'): boolean { + public static removeBookFromSeries(userId: string, seriesId: string, bookId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { const exists: boolean = SeriesRepo.isSeriesExist(userId, seriesId, lang); if (!exists) { throw new Error(lang === 'fr' ? 'Série non trouvée.' : 'Series not found.'); } - return SeriesRepo.removeBookFromSeries(seriesId, bookId, lang); + const deleted: boolean = SeriesRepo.removeBookFromSeries(seriesId, bookId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, null, 'series_books', `${seriesId}_${bookId}`, deletedAt, lang); + } + return deleted; } /** diff --git a/electron/database/models/SeriesCharacter.ts b/electron/database/models/SeriesCharacter.ts index aaf043b..41a23da 100644 --- a/electron/database/models/SeriesCharacter.ts +++ b/electron/database/models/SeriesCharacter.ts @@ -1,6 +1,7 @@ import { getUserEncryptionKey } from "../keyManager.js"; import System from "../System.js"; import SeriesCharacterRepo, { SeriesCharacterAttributeResult, SeriesCharacterResult } from "../repositories/series-character.repo.js"; +import RemovedItem from "./RemovedItem.js"; export type CharacterCategory = 'Main' | 'Secondary' | 'Recurring'; @@ -213,15 +214,20 @@ export default class SeriesCharacter { * Deletes a character from a series. * @param userId - The unique identifier of the user * @param characterId - The unique identifier of the character + * @param deletedAt - The timestamp of deletion * @param lang - The language for error messages ('fr' or 'en') * @returns True if the deletion was successful */ - public static deleteCharacter(userId: string, characterId: string, lang: 'fr' | 'en' = 'fr'): boolean { + public static deleteCharacter(userId: string, characterId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { const exists: boolean = SeriesCharacterRepo.isCharacterExist(userId, characterId, lang); if (!exists) { throw new Error(lang === 'fr' ? 'Personnage non trouvé.' : 'Character not found.'); } - return SeriesCharacterRepo.deleteCharacter(userId, characterId, lang); + const deleted: boolean = SeriesCharacterRepo.deleteCharacter(userId, characterId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, null, 'series_characters', characterId, deletedAt, lang); + } + return deleted; } /** @@ -248,11 +254,16 @@ export default class SeriesCharacter { * Deletes an attribute from a character. * @param userId - The unique identifier of the user * @param attributeId - The unique identifier of the attribute + * @param deletedAt - The timestamp of deletion * @param lang - The language for error messages ('fr' or 'en') * @returns True if the deletion was successful */ - public static deleteAttribute(userId: string, attributeId: string, lang: 'fr' | 'en' = 'fr'): boolean { - return SeriesCharacterRepo.deleteAttribute(userId, attributeId, lang); + public static deleteAttribute(userId: string, attributeId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { + const deleted: boolean = SeriesCharacterRepo.deleteAttribute(userId, attributeId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, null, 'series_characters_attributes', attributeId, deletedAt, lang); + } + return deleted; } /** diff --git a/electron/database/models/SeriesLocation.ts b/electron/database/models/SeriesLocation.ts index 5ca35c8..6cfa437 100644 --- a/electron/database/models/SeriesLocation.ts +++ b/electron/database/models/SeriesLocation.ts @@ -1,6 +1,7 @@ import { getUserEncryptionKey } from "../keyManager.js"; import System from "../System.js"; import SeriesLocationRepo, { SeriesLocationResult, SeriesLocationElementResult, SeriesLocationSubElementResult } from "../repositories/series-location.repo.js"; +import RemovedItem from "./RemovedItem.js"; export interface SeriesLocationSubElementProps { id: string; @@ -123,32 +124,47 @@ export default class SeriesLocation { * Deletes a location section. * @param userId - The unique identifier of the user * @param locationId - The unique identifier of the location + * @param deletedAt - The timestamp of deletion * @param lang - The language for error messages ('fr' or 'en') * @returns True if successful */ - public static deleteLocation(userId: string, locationId: string, lang: 'fr' | 'en' = 'fr'): boolean { - return SeriesLocationRepo.deleteLocation(userId, locationId, lang); + public static deleteLocation(userId: string, locationId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { + const deleted: boolean = SeriesLocationRepo.deleteLocation(userId, locationId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, null, 'series_locations', locationId, deletedAt, lang); + } + return deleted; } /** * Deletes an element. * @param userId - The unique identifier of the user * @param elementId - The unique identifier of the element + * @param deletedAt - The timestamp of deletion * @param lang - The language for error messages ('fr' or 'en') * @returns True if successful */ - public static deleteElement(userId: string, elementId: string, lang: 'fr' | 'en' = 'fr'): boolean { - return SeriesLocationRepo.deleteElement(userId, elementId, lang); + public static deleteElement(userId: string, elementId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { + const deleted: boolean = SeriesLocationRepo.deleteElement(userId, elementId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, null, 'series_location_elements', elementId, deletedAt, lang); + } + return deleted; } /** * Deletes a sub-element. * @param userId - The unique identifier of the user * @param subElementId - The unique identifier of the sub-element + * @param deletedAt - The timestamp of deletion * @param lang - The language for error messages ('fr' or 'en') * @returns True if successful */ - public static deleteSubElement(userId: string, subElementId: string, lang: 'fr' | 'en' = 'fr'): boolean { - return SeriesLocationRepo.deleteSubElement(userId, subElementId, lang); + public static deleteSubElement(userId: string, subElementId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { + const deleted: boolean = SeriesLocationRepo.deleteSubElement(userId, subElementId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, null, 'series_location_sub_elements', subElementId, deletedAt, lang); + } + return deleted; } } diff --git a/electron/database/models/SeriesSpell.ts b/electron/database/models/SeriesSpell.ts index c386034..e3dbec9 100644 --- a/electron/database/models/SeriesSpell.ts +++ b/electron/database/models/SeriesSpell.ts @@ -1,6 +1,7 @@ import { getUserEncryptionKey } from "../keyManager.js"; import System from "../System.js"; import SeriesSpellRepo, { SeriesSpellResult, SeriesSpellTagResult } from "../repositories/series-spell.repo.js"; +import RemovedItem from "./RemovedItem.js"; export interface SeriesSpellTagProps { id: string; @@ -155,11 +156,16 @@ export default class SeriesSpell { * Deletes a spell. * @param userId - The unique identifier of the user * @param spellId - The unique identifier of the spell + * @param deletedAt - The timestamp of deletion * @param lang - The language for error messages ('fr' or 'en') * @returns True if successful */ - public static deleteSpell(userId: string, spellId: string, lang: 'fr' | 'en' = 'fr'): boolean { - return SeriesSpellRepo.deleteSpell(userId, spellId, lang); + public static deleteSpell(userId: string, spellId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { + const deleted: boolean = SeriesSpellRepo.deleteSpell(userId, spellId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, null, 'series_spells', spellId, deletedAt, lang); + } + return deleted; } /** @@ -202,10 +208,15 @@ export default class SeriesSpell { * Deletes a tag. * @param userId - The unique identifier of the user * @param tagId - The unique identifier of the tag + * @param deletedAt - The timestamp of deletion * @param lang - The language for error messages ('fr' or 'en') * @returns True if successful */ - public static deleteTag(userId: string, tagId: string, lang: 'fr' | 'en' = 'fr'): boolean { - return SeriesSpellRepo.deleteTag(userId, tagId, lang); + public static deleteTag(userId: string, tagId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { + const deleted: boolean = SeriesSpellRepo.deleteTag(userId, tagId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, null, 'series_spell_tags', tagId, deletedAt, lang); + } + return deleted; } } diff --git a/electron/database/models/SeriesSync.ts b/electron/database/models/SeriesSync.ts index 5d294e8..c5da45c 100644 --- a/electron/database/models/SeriesSync.ts +++ b/electron/database/models/SeriesSync.ts @@ -1,6 +1,28 @@ import { getUserEncryptionKey } from "../keyManager.js"; import System from "../System.js"; import SeriesSyncRepo, { SyncElementType } from "../repositories/series-sync.repo.js"; +import Sync from "./Sync.js"; +import { + CompleteSeries, + SyncedSeries, + SeriesTable, + SeriesBooksTable, + SeriesCharactersTable, + SeriesCharacterAttributesTable, + SeriesWorldsTable, + SeriesWorldElementsTable, + SeriesLocationsTable, + SeriesLocationElementsTable, + SeriesLocationSubElementsTable, + SeriesSpellsTable, + SeriesSpellTagsTable +} from "./Book.js"; +import SeriesRepo from "../repositories/series.repo.js"; +import BookRepo from "../repositories/book.repository.js"; +import SeriesCharacterRepo from "../repositories/series-character.repo.js"; +import SeriesWorldRepo from "../repositories/series-world.repo.js"; +import SeriesLocationRepo from "../repositories/series-location.repo.js"; +import SeriesSpellRepo from "../repositories/series-spell.repo.js"; export interface SeriesSyncUploadPayload { type: SyncElementType; @@ -14,44 +36,55 @@ export interface SeriesSyncResult { updatedCount: number; } +export type { CompleteSeries, SyncedSeries }; + +/** + * Handles series synchronization operations. + * Manages field propagation from book elements to series elements, + * and provides methods for complete series upload/download synchronization. + */ export default class SeriesSync { /** * Uploads a field value from a book element to its linked series element, - * and propagates the change to all other book elements linked to the same series element. + * then propagates the change to all other book elements linked to the same series element. * @param userId - The unique identifier of the user - * @param payload - The upload payload containing type, bookElementId, field, and value + * @param payload - Contains type, bookElementId, field, and value * @param lang - The language for error messages ('fr' or 'en') - * @returns The upload response + * @returns Result containing success status and count of updated book elements */ - public static uploadFieldToSeries(userId: string, payload: SeriesSyncUploadPayload, lang: 'fr' | 'en' = 'fr'): SeriesSyncResult { + static uploadFieldToSeries(userId: string, payload: SeriesSyncUploadPayload, lang: 'fr' | 'en'): SeriesSyncResult { const { type, bookElementId, field, value } = payload; + // 1. Get the series element ID linked to the book element const seriesElementId: string | null = this.getSeriesLink(userId, type, bookElementId, lang); if (!seriesElementId) { - throw new Error(lang === 'fr' ? `Cet élément n'est pas lié à une série.` : `This element is not linked to a series.`); + return { success: false, updatedCount: 0 }; } - const userKey: string = getUserEncryptionKey(userId); - const encryptedValue: string = value ? System.encryptDataWithUserKey(value, userKey) : ''; + // 2. Encrypt the value + const userEncryptionKey: string = getUserEncryptionKey(userId); + const encryptedValue: string = System.encryptDataWithUserKey(value, userEncryptionKey); - const dbField: string = this.mapFieldToDbColumn(type, field); + // 3. Map the frontend field name to the database column name + const dbColumn: string = this.mapFieldToDbColumn(type, field); - this.updateSeriesElement(userId, type, seriesElementId, dbField, encryptedValue, lang); - const updatedCount: number = this.updateLinkedBookElements(userId, type, seriesElementId, dbField, encryptedValue, lang); + // 4. Update the series element + const seriesUpdated: boolean = this.updateSeriesElement(userId, type, seriesElementId, dbColumn, encryptedValue, lang); + if (!seriesUpdated) { + return { success: false, updatedCount: 0 }; + } - return { - success: true, - updatedCount: updatedCount + 1 - }; + // 5. Map the series field to the book field (may be different for some types) + const bookField: string = this.mapSeriesFieldToBookField(type, dbColumn); + + // 6. Update all linked book elements + const updatedCount: number = this.updateLinkedBookElements(userId, type, seriesElementId, bookField, encryptedValue, lang); + + return { success: true, updatedCount }; } /** * Gets the series element ID linked to a book element. - * @param userId - The unique identifier of the user - * @param type - The type of element (character, world, location, spell) - * @param bookElementId - The unique identifier of the book element - * @param lang - The language for error messages ('fr' or 'en') - * @returns The series element ID or null if not linked */ private static getSeriesLink(userId: string, type: SyncElementType, bookElementId: string, lang: 'fr' | 'en'): string | null { switch (type) { @@ -63,88 +96,47 @@ export default class SeriesSync { return SeriesSyncRepo.getLocationSeriesLink(userId, bookElementId, lang); case 'spell': return SeriesSyncRepo.getSpellSeriesLink(userId, bookElementId, lang); + default: + return null; } } /** * Maps frontend field names to database column names. - * @param type - The type of element (character, world, location, spell) - * @param field - The frontend field name to map - * @returns The corresponding database column name */ private static mapFieldToDbColumn(type: SyncElementType, field: string): string { - const characterFieldMap: Record = { - 'name': 'first_name', - 'firstName': 'first_name', - 'lastName': 'last_name', - 'nickname': 'nickname', - 'age': 'age', - 'gender': 'gender', - 'species': 'species', - 'nationality': 'nationality', - 'status': 'status', - 'title': 'title', - 'category': 'category', - 'role': 'role', - 'biography': 'biography', - 'history': 'history', - 'speechPattern': 'speech_pattern', - 'catchphrase': 'catchphrase', - 'residence': 'residence', - 'notes': 'notes', - 'color': 'color' + // Most fields have the same name, but some need mapping + const fieldMappings: Record> = { + character: { + firstName: 'first_name', + lastName: 'last_name', + speechPattern: 'speech_pattern' + }, + world: {}, + location: { + name: 'name', + locName: 'loc_name' + }, + spell: { + powerLevel: 'power_level' + } }; - const worldFieldMap: Record = { - 'name': 'name', - 'history': 'history', - 'politics': 'politics', - 'economy': 'economy', - 'religion': 'religion', - 'languages': 'languages' - }; - - const locationFieldMap: Record = { - 'name': 'name', - 'loc_name': 'loc_name' - }; - - const spellFieldMap: Record = { - 'name': 'name', - 'description': 'description', - 'type': 'type', - 'level': 'level', - 'range': 'range', - 'duration': 'duration', - 'cost': 'cost', - 'effect': 'effect', - 'components': 'components', - 'notes': 'notes' - }; - - switch (type) { - case 'character': - return characterFieldMap[field] || field; - case 'world': - return worldFieldMap[field] || field; - case 'location': - return locationFieldMap[field] || field; - case 'spell': - return spellFieldMap[field] || field; - } + const typeMapping = fieldMappings[type] || {}; + return typeMapping[field] || field; } /** - * Updates the series element field. - * @param userId - The unique identifier of the user - * @param type - The type of element (character, world, location, spell) - * @param seriesElementId - The unique identifier of the series element - * @param field - The database column name to update - * @param encryptedValue - The encrypted value to set - * @param lang - The language for error messages ('fr' or 'en') - * @returns True if updated successfully + * Updates a field in the series element. */ - private static updateSeriesElement(userId: string, type: SyncElementType, seriesElementId: string, field: string, encryptedValue: string, lang: 'fr' | 'en'): boolean { + private static updateSeriesElement( + userId: string, + type: SyncElementType, + seriesElementId: string, + field: string, + encryptedValue: string, + lang: 'fr' | 'en' + ): boolean { switch (type) { case 'character': return SeriesSyncRepo.updateSeriesCharacterField(userId, seriesElementId, field, encryptedValue, lang); @@ -154,46 +146,878 @@ export default class SeriesSync { return SeriesSyncRepo.updateSeriesLocationField(userId, seriesElementId, field, encryptedValue, lang); case 'spell': return SeriesSyncRepo.updateSeriesSpellField(userId, seriesElementId, field, encryptedValue, lang); + default: + return false; } } /** - * Updates all book elements linked to the series element. - * @param userId - The unique identifier of the user - * @param type - The type of element (character, world, location, spell) - * @param seriesElementId - The unique identifier of the series element - * @param field - The database column name to update - * @param encryptedValue - The encrypted value to set - * @param lang - The language for error messages ('fr' or 'en') - * @returns The number of book elements updated + * Updates all book elements linked to a series element. */ - private static updateLinkedBookElements(userId: string, type: SyncElementType, seriesElementId: string, field: string, encryptedValue: string, lang: 'fr' | 'en'): number { - const bookField: string = this.mapSeriesFieldToBookField(type, field); - + private static updateLinkedBookElements( + userId: string, + type: SyncElementType, + seriesElementId: string, + field: string, + encryptedValue: string, + lang: 'fr' | 'en' + ): number { switch (type) { case 'character': - return SeriesSyncRepo.updateLinkedBookCharactersField(userId, seriesElementId, bookField, encryptedValue, lang); + return SeriesSyncRepo.updateLinkedBookCharactersField(userId, seriesElementId, field, encryptedValue, lang); case 'world': - return SeriesSyncRepo.updateLinkedBookWorldsField(userId, seriesElementId, bookField, encryptedValue, lang); + return SeriesSyncRepo.updateLinkedBookWorldsField(userId, seriesElementId, field, encryptedValue, lang); case 'location': - return SeriesSyncRepo.updateLinkedBookLocationsField(userId, seriesElementId, bookField, encryptedValue, lang); + return SeriesSyncRepo.updateLinkedBookLocationsField(userId, seriesElementId, field, encryptedValue, lang); case 'spell': - return SeriesSyncRepo.updateLinkedBookSpellsField(userId, seriesElementId, bookField, encryptedValue, lang); + return SeriesSyncRepo.updateLinkedBookSpellsField(userId, seriesElementId, field, encryptedValue, lang); + default: + return 0; } } /** - * Maps series table field names to book table field names (if different). - * @param type - The type of element (character, world, location, spell) - * @param seriesField - The series table field name - * @returns The corresponding book table field name + * Maps series field names to book field names (they may differ). */ private static mapSeriesFieldToBookField(type: SyncElementType, seriesField: string): string { - if (type === 'location') { - if (seriesField === 'name') { - return 'loc_name'; + const fieldMappings: Record> = { + location: { + name: 'loc_name' + } + }; + + const typeMapping = fieldMappings[type] || {}; + return typeMapping[seriesField] || seriesField; + } + + // ===== SYNC METHODS ===== + + /** + * Gets all synced series for a user. + * Delegates to Sync.getSyncedSeries which already implements this functionality. + */ + static getSyncedSeries(userId: string, lang: 'fr' | 'en'): SyncedSeries[] { + return Sync.getSyncedSeries(userId, lang); + } + + /** + * Gets a complete series with all data decrypted for upload to server. + * @param userId - The unique identifier of the user + * @param seriesId - The unique identifier of the series + * @param lang - The language for error messages ('fr' or 'en') + * @returns Complete series object with all decrypted data + */ + static getCompleteSeriesForUpload(userId: string, seriesId: string, lang: 'fr' | 'en'): CompleteSeries { + const userEncryptionKey: string = getUserEncryptionKey(userId); + + // Fetch all series data - use table fetch methods that return arrays + const seriesData = SeriesRepo.fetchSeriesTableForSync(userId, seriesId, lang); + const seriesBooksData = SeriesRepo.fetchSeriesBooksTable(seriesId, lang); + const charactersData = SeriesCharacterRepo.fetchSeriesCharactersTable(userId, seriesId, lang); + const characterAttributesData = SeriesCharacterRepo.fetchSeriesCharacterAttributesBySeriesId(userId, seriesId, lang); + const worldsData = SeriesWorldRepo.fetchSeriesWorldsTable(userId, seriesId, lang); + const worldElementsData = SeriesWorldRepo.fetchSeriesWorldElementsBySeriesId(userId, seriesId, lang); + const locationsData = SeriesLocationRepo.fetchSeriesLocationsTable(userId, seriesId, lang); + const locationElementsData = SeriesLocationRepo.fetchSeriesLocationElementsBySeriesId(userId, seriesId, lang); + const locationSubElementsData = SeriesLocationRepo.fetchSeriesLocationSubElementsBySeriesId(userId, seriesId, lang); + const spellsData = SeriesSpellRepo.fetchSeriesSpellsTable(userId, seriesId, lang); + const spellTagsData = SeriesSpellRepo.fetchSeriesSpellTagsTable(userId, seriesId, lang); + + // Decrypt series + const series: SeriesTable[] = seriesData.map((s: SeriesTable): SeriesTable => ({ + ...s, + name: System.decryptDataWithUserKey(s.name, userEncryptionKey), + description: s.description ? System.decryptDataWithUserKey(s.description, userEncryptionKey) : null, + cover_image: s.cover_image ? System.decryptDataWithUserKey(s.cover_image, userEncryptionKey) : null + })); + + // Decrypt characters + const seriesCharacters: SeriesCharactersTable[] = charactersData.map((c): SeriesCharactersTable => { + const decryptedAge: string | null = c.age ? System.decryptDataWithUserKey(c.age, userEncryptionKey) : null; + return { + character_id: c.character_id as string, + series_id: c.series_id as string, + user_id: c.user_id as string, + first_name: System.decryptDataWithUserKey(c.first_name as string, userEncryptionKey), + last_name: c.last_name ? System.decryptDataWithUserKey(c.last_name as string, userEncryptionKey) : null, + nickname: c.nickname ? System.decryptDataWithUserKey(c.nickname as string, userEncryptionKey) : null, + age: decryptedAge ? parseInt(decryptedAge, 10) : null, + gender: c.gender ? System.decryptDataWithUserKey(c.gender as string, userEncryptionKey) : null, + species: c.species ? System.decryptDataWithUserKey(c.species as string, userEncryptionKey) : null, + nationality: c.nationality ? System.decryptDataWithUserKey(c.nationality as string, userEncryptionKey) : null, + status: c.status ? System.decryptDataWithUserKey(c.status as string, userEncryptionKey) : null, + title: c.title ? System.decryptDataWithUserKey(c.title as string, userEncryptionKey) : null, + category: System.decryptDataWithUserKey(c.category as string, userEncryptionKey), + image: c.image ? System.decryptDataWithUserKey(c.image as string, userEncryptionKey) : null, + role: c.role ? System.decryptDataWithUserKey(c.role as string, userEncryptionKey) : null, + biography: c.biography ? System.decryptDataWithUserKey(c.biography as string, userEncryptionKey) : null, + history: c.history ? System.decryptDataWithUserKey(c.history as string, userEncryptionKey) : null, + speech_pattern: c.speech_pattern ? System.decryptDataWithUserKey(c.speech_pattern as string, userEncryptionKey) : null, + catchphrase: c.catchphrase ? System.decryptDataWithUserKey(c.catchphrase as string, userEncryptionKey) : null, + residence: c.residence ? System.decryptDataWithUserKey(c.residence as string, userEncryptionKey) : null, + notes: c.notes ? System.decryptDataWithUserKey(c.notes as string, userEncryptionKey) : null, + color: c.color ? System.decryptDataWithUserKey(c.color as string, userEncryptionKey) : null, + last_update: c.last_update as number + }; + }); + + // Decrypt character attributes + const seriesCharacterAttributes: SeriesCharacterAttributesTable[] = characterAttributesData.map((a: SeriesCharacterAttributesTable): SeriesCharacterAttributesTable => ({ + ...a, + attribute_name: System.decryptDataWithUserKey(a.attribute_name, userEncryptionKey), + attribute_value: System.decryptDataWithUserKey(a.attribute_value, userEncryptionKey) + })); + + // Decrypt worlds + const seriesWorlds: SeriesWorldsTable[] = worldsData.map((w: SeriesWorldsTable): SeriesWorldsTable => ({ + ...w, + name: System.decryptDataWithUserKey(w.name, userEncryptionKey), + history: w.history ? System.decryptDataWithUserKey(w.history, userEncryptionKey) : null, + politics: w.politics ? System.decryptDataWithUserKey(w.politics, userEncryptionKey) : null, + economy: w.economy ? System.decryptDataWithUserKey(w.economy, userEncryptionKey) : null, + religion: w.religion ? System.decryptDataWithUserKey(w.religion, userEncryptionKey) : null, + languages: w.languages ? System.decryptDataWithUserKey(w.languages, userEncryptionKey) : null + })); + + // Decrypt world elements + const seriesWorldElements: SeriesWorldElementsTable[] = worldElementsData.map((e: SeriesWorldElementsTable): SeriesWorldElementsTable => ({ + ...e, + name: System.decryptDataWithUserKey(e.name, userEncryptionKey), + description: e.description ? System.decryptDataWithUserKey(e.description, userEncryptionKey) : null + })); + + // Decrypt locations + const seriesLocations: SeriesLocationsTable[] = locationsData.map((l: SeriesLocationsTable): SeriesLocationsTable => ({ + ...l, + loc_name: System.decryptDataWithUserKey(l.loc_name, userEncryptionKey) + })); + + // Decrypt location elements + const seriesLocationElements: SeriesLocationElementsTable[] = locationElementsData.map((e: SeriesLocationElementsTable): SeriesLocationElementsTable => ({ + ...e, + element_name: System.decryptDataWithUserKey(e.element_name, userEncryptionKey), + element_description: e.element_description ? System.decryptDataWithUserKey(e.element_description, userEncryptionKey) : null + })); + + // Decrypt location sub-elements + const seriesLocationSubElements: SeriesLocationSubElementsTable[] = locationSubElementsData.map((se: SeriesLocationSubElementsTable): SeriesLocationSubElementsTable => ({ + ...se, + sub_elem_name: System.decryptDataWithUserKey(se.sub_elem_name, userEncryptionKey), + sub_elem_description: se.sub_elem_description ? System.decryptDataWithUserKey(se.sub_elem_description, userEncryptionKey) : null + })); + + // Decrypt spells + const seriesSpells: SeriesSpellsTable[] = spellsData.map((s): SeriesSpellsTable => ({ + spell_id: s.spell_id as string, + series_id: s.series_id as string, + user_id: s.user_id as string, + name: System.decryptDataWithUserKey(s.name as string, userEncryptionKey), + name_hash: s.name_hash as string, + description: s.description ? System.decryptDataWithUserKey(s.description as string, userEncryptionKey) : '', + appearance: s.appearance ? System.decryptDataWithUserKey(s.appearance as string, userEncryptionKey) : '', + tags: s.tags ? System.decryptDataWithUserKey(s.tags as string, userEncryptionKey) : '', + power_level: s.power_level ? System.decryptDataWithUserKey(s.power_level as string, userEncryptionKey) : null, + components: s.components ? System.decryptDataWithUserKey(s.components as string, userEncryptionKey) : null, + limitations: s.limitations ? System.decryptDataWithUserKey(s.limitations as string, userEncryptionKey) : null, + notes: s.notes ? System.decryptDataWithUserKey(s.notes as string, userEncryptionKey) : null, + last_update: s.last_update as number + })); + + // Decrypt spell tags + const seriesSpellTags: SeriesSpellTagsTable[] = spellTagsData.map((t: SeriesSpellTagsTable): SeriesSpellTagsTable => ({ + ...t, + name: System.decryptDataWithUserKey(t.name, userEncryptionKey) + })); + + return { + series, + seriesBooks: seriesBooksData, + seriesCharacters, + seriesCharacterAttributes, + seriesWorlds, + seriesWorldElements, + seriesLocations, + seriesLocationElements, + seriesLocationSubElements, + seriesSpells, + seriesSpellTags + }; + } + + /** + * Saves a complete series downloaded from the server to the local database. + * Encrypts all data before storing. + * @param userId - The unique identifier of the user + * @param completeSeries - The complete series data from the server + * @param lang - The language for error messages ('fr' or 'en') + * @returns True if save was successful, false otherwise + */ + static saveCompleteSeries(userId: string, completeSeries: CompleteSeries, lang: 'fr' | 'en'): boolean { + const userEncryptionKey: string = getUserEncryptionKey(userId); + + // Save series + for (const series of completeSeries.series) { + const encryptedName: string = System.encryptDataWithUserKey(series.name, userEncryptionKey); + const encryptedDescription: string | null = series.description ? System.encryptDataWithUserKey(series.description, userEncryptionKey) : null; + const encryptedCoverImage: string | null = series.cover_image ? System.encryptDataWithUserKey(series.cover_image, userEncryptionKey) : null; + + const success: boolean = SeriesRepo.insertSyncSeries( + series.series_id, + userId, + encryptedName, + series.hashed_name, + encryptedDescription, + encryptedCoverImage, + series.last_update, + lang + ); + if (!success) return false; + } + + // Save series books (only if the book exists locally) + for (const seriesBook of completeSeries.seriesBooks) { + const bookExists: boolean = BookRepo.isBookExist(userId, seriesBook.book_id, lang); + if (!bookExists) continue; + + const success: boolean = SeriesRepo.insertSyncSeriesBook( + seriesBook.series_id, + seriesBook.book_id, + seriesBook.book_order, + seriesBook.last_update, + lang + ); + if (!success) return false; + } + + // Save characters + for (const character of completeSeries.seriesCharacters) { + const encFirstName: string = System.encryptDataWithUserKey(character.first_name, userEncryptionKey); + const encLastName: string | null = character.last_name ? System.encryptDataWithUserKey(character.last_name, userEncryptionKey) : null; + const encNickname: string | null = character.nickname ? System.encryptDataWithUserKey(character.nickname, userEncryptionKey) : null; + const encAge: string | null = character.age !== null ? System.encryptDataWithUserKey(String(character.age), userEncryptionKey) : null; + const encGender: string | null = character.gender ? System.encryptDataWithUserKey(character.gender, userEncryptionKey) : null; + const encSpecies: string | null = character.species ? System.encryptDataWithUserKey(character.species, userEncryptionKey) : null; + const encNationality: string | null = character.nationality ? System.encryptDataWithUserKey(character.nationality, userEncryptionKey) : null; + const encStatus: string | null = character.status ? System.encryptDataWithUserKey(character.status, userEncryptionKey) : null; + const encTitle: string | null = character.title ? System.encryptDataWithUserKey(character.title, userEncryptionKey) : null; + const encCategory: string = System.encryptDataWithUserKey(character.category, userEncryptionKey); + const encImage: string | null = character.image ? System.encryptDataWithUserKey(character.image, userEncryptionKey) : null; + const encRole: string | null = character.role ? System.encryptDataWithUserKey(character.role, userEncryptionKey) : null; + const encBiography: string | null = character.biography ? System.encryptDataWithUserKey(character.biography, userEncryptionKey) : null; + const encHistory: string | null = character.history ? System.encryptDataWithUserKey(character.history, userEncryptionKey) : null; + const encSpeechPattern: string | null = character.speech_pattern ? System.encryptDataWithUserKey(character.speech_pattern, userEncryptionKey) : null; + const encCatchphrase: string | null = character.catchphrase ? System.encryptDataWithUserKey(character.catchphrase, userEncryptionKey) : null; + const encResidence: string | null = character.residence ? System.encryptDataWithUserKey(character.residence, userEncryptionKey) : null; + const encNotes: string | null = character.notes ? System.encryptDataWithUserKey(character.notes, userEncryptionKey) : null; + const encColor: string | null = character.color ? System.encryptDataWithUserKey(character.color, userEncryptionKey) : null; + + const success: boolean = SeriesCharacterRepo.insertSyncSeriesCharacter( + character.character_id, + character.series_id, + userId, + encFirstName, + encLastName, + encNickname, + encAge, + encGender, + encSpecies, + encNationality, + encStatus, + encCategory, + encTitle, + encImage, + encRole, + encBiography, + encHistory, + encSpeechPattern, + encCatchphrase, + encResidence, + encNotes, + encColor, + character.last_update, + lang + ); + if (!success) return false; + } + + // Save character attributes + for (const attr of completeSeries.seriesCharacterAttributes) { + const encryptedName: string = System.encryptDataWithUserKey(attr.attribute_name, userEncryptionKey); + const encryptedValue: string = System.encryptDataWithUserKey(attr.attribute_value, userEncryptionKey); + + const success: boolean = SeriesCharacterRepo.insertSyncSeriesCharacterAttribute( + attr.attr_id, + attr.character_id, + userId, + encryptedName, + encryptedValue, + attr.last_update, + lang + ); + if (!success) return false; + } + + // Save worlds + for (const world of completeSeries.seriesWorlds) { + const encryptedName: string = System.encryptDataWithUserKey(world.name, userEncryptionKey); + const encryptedHistory: string | null = world.history ? System.encryptDataWithUserKey(world.history, userEncryptionKey) : null; + const encryptedPolitics: string | null = world.politics ? System.encryptDataWithUserKey(world.politics, userEncryptionKey) : null; + const encryptedEconomy: string | null = world.economy ? System.encryptDataWithUserKey(world.economy, userEncryptionKey) : null; + const encryptedReligion: string | null = world.religion ? System.encryptDataWithUserKey(world.religion, userEncryptionKey) : null; + const encryptedLanguages: string | null = world.languages ? System.encryptDataWithUserKey(world.languages, userEncryptionKey) : null; + + const success: boolean = SeriesWorldRepo.insertSyncSeriesWorld( + world.world_id, + world.series_id, + userId, + encryptedName, + world.hashed_name, + encryptedHistory, + encryptedPolitics, + encryptedEconomy, + encryptedReligion, + encryptedLanguages, + world.last_update, + lang + ); + if (!success) return false; + } + + // Save world elements + for (const element of completeSeries.seriesWorldElements) { + const encryptedName: string = System.encryptDataWithUserKey(element.name, userEncryptionKey); + const encryptedDescription: string | null = element.description ? System.encryptDataWithUserKey(element.description, userEncryptionKey) : null; + + const success: boolean = SeriesWorldRepo.insertSyncSeriesWorldElement( + element.element_id, + element.world_id, + userId, + element.element_type, + encryptedName, + element.original_name, + encryptedDescription, + element.last_update, + lang + ); + if (!success) return false; + } + + // Save locations + for (const location of completeSeries.seriesLocations) { + const encryptedName: string = System.encryptDataWithUserKey(location.loc_name, userEncryptionKey); + + const success: boolean = SeriesLocationRepo.insertSyncSeriesLocation( + location.loc_id, + location.series_id, + userId, + encryptedName, + location.loc_original_name, + location.last_update, + lang + ); + if (!success) return false; + } + + // Save location elements + for (const element of completeSeries.seriesLocationElements) { + const encryptedName: string = System.encryptDataWithUserKey(element.element_name, userEncryptionKey); + const encryptedDescription: string | null = element.element_description ? System.encryptDataWithUserKey(element.element_description, userEncryptionKey) : null; + + const success: boolean = SeriesLocationRepo.insertSyncSeriesLocationElement( + element.element_id, + element.location_id, + userId, + encryptedName, + element.original_name, + encryptedDescription, + element.last_update, + lang + ); + if (!success) return false; + } + + // Save location sub-elements + for (const subElement of completeSeries.seriesLocationSubElements) { + const encryptedName: string = System.encryptDataWithUserKey(subElement.sub_elem_name, userEncryptionKey); + const encryptedDescription: string | null = subElement.sub_elem_description ? System.encryptDataWithUserKey(subElement.sub_elem_description, userEncryptionKey) : null; + + const success: boolean = SeriesLocationRepo.insertSyncSeriesLocationSubElement( + subElement.sub_element_id, + subElement.element_id, + userId, + encryptedName, + subElement.original_name, + encryptedDescription, + subElement.last_update, + lang + ); + if (!success) return false; + } + + // Save spells + for (const spell of completeSeries.seriesSpells) { + const encryptedName: string = System.encryptDataWithUserKey(spell.name, userEncryptionKey); + const encryptedDescription: string = System.encryptDataWithUserKey(spell.description, userEncryptionKey); + const encryptedAppearance: string = System.encryptDataWithUserKey(spell.appearance, userEncryptionKey); + const encryptedTags: string = System.encryptDataWithUserKey(spell.tags, userEncryptionKey); + const encryptedPowerLevel: string | null = spell.power_level ? System.encryptDataWithUserKey(spell.power_level, userEncryptionKey) : null; + const encryptedComponents: string | null = spell.components ? System.encryptDataWithUserKey(spell.components, userEncryptionKey) : null; + const encryptedLimitations: string | null = spell.limitations ? System.encryptDataWithUserKey(spell.limitations, userEncryptionKey) : null; + const encryptedNotes: string | null = spell.notes ? System.encryptDataWithUserKey(spell.notes, userEncryptionKey) : null; + + const success: boolean = SeriesSpellRepo.insertSyncSeriesSpell( + spell.spell_id, + spell.series_id, + userId, + encryptedName, + spell.name_hash, + encryptedDescription, + encryptedAppearance, + encryptedTags, + encryptedPowerLevel, + encryptedComponents, + encryptedLimitations, + encryptedNotes, + spell.last_update, + lang + ); + if (!success) return false; + } + + // Save spell tags + for (const tag of completeSeries.seriesSpellTags) { + const encryptedName: string = System.encryptDataWithUserKey(tag.name, userEncryptionKey); + + const success: boolean = SeriesSpellRepo.insertSyncSeriesSpellTag( + tag.tag_id, + tag.series_id, + userId, + encryptedName, + tag.hashed_name, + tag.color, + tag.last_update, + lang + ); + if (!success) return false; + } + + return true; + } + + /** + * Synchronizes a series from server to client, updating existing records or inserting new ones. + * @param userId - The unique identifier of the user + * @param completeSeries - The complete series data from the server + * @param lang - The language for error messages ('fr' or 'en') + * @returns True if sync was successful, false otherwise + */ + static syncSeriesFromServerToClient(userId: string, completeSeries: CompleteSeries, lang: 'fr' | 'en'): boolean { + const userEncryptionKey: string = getUserEncryptionKey(userId); + + // Sync series + for (const series of completeSeries.series) { + const encryptedName: string = System.encryptDataWithUserKey(series.name, userEncryptionKey); + const encryptedDescription: string | null = series.description ? System.encryptDataWithUserKey(series.description, userEncryptionKey) : null; + const encryptedCoverImage: string | null = series.cover_image ? System.encryptDataWithUserKey(series.cover_image, userEncryptionKey) : null; + + const exists: boolean = SeriesRepo.seriesExists(userId, series.series_id, lang); + if (exists) { + const success: boolean = SeriesRepo.updateSyncSeries( + userId, + series.series_id, + encryptedName, + series.hashed_name, + encryptedDescription, + encryptedCoverImage, + series.last_update, + lang + ); + if (!success) return false; + } else { + const success: boolean = SeriesRepo.insertSyncSeries( + series.series_id, + userId, + encryptedName, + series.hashed_name, + encryptedDescription, + encryptedCoverImage, + series.last_update, + lang + ); + if (!success) return false; } } - return seriesField; + + // Sync series books (only if the book exists locally) + for (const seriesBook of completeSeries.seriesBooks) { + const bookExists: boolean = BookRepo.isBookExist(userId, seriesBook.book_id, lang); + if (!bookExists) continue; + + const success: boolean = SeriesRepo.insertSyncSeriesBook( + seriesBook.series_id, + seriesBook.book_id, + seriesBook.book_order, + seriesBook.last_update, + lang + ); + if (!success) return false; + } + + // Sync characters + for (const character of completeSeries.seriesCharacters) { + const encFirstName: string = System.encryptDataWithUserKey(character.first_name, userEncryptionKey); + const encLastName: string | null = character.last_name ? System.encryptDataWithUserKey(character.last_name, userEncryptionKey) : null; + const encNickname: string | null = character.nickname ? System.encryptDataWithUserKey(character.nickname, userEncryptionKey) : null; + const encAge: string | null = character.age !== null ? System.encryptDataWithUserKey(String(character.age), userEncryptionKey) : null; + const encGender: string | null = character.gender ? System.encryptDataWithUserKey(character.gender, userEncryptionKey) : null; + const encSpecies: string | null = character.species ? System.encryptDataWithUserKey(character.species, userEncryptionKey) : null; + const encNationality: string | null = character.nationality ? System.encryptDataWithUserKey(character.nationality, userEncryptionKey) : null; + const encStatus: string | null = character.status ? System.encryptDataWithUserKey(character.status, userEncryptionKey) : null; + const encTitle: string | null = character.title ? System.encryptDataWithUserKey(character.title, userEncryptionKey) : null; + const encCategory: string = System.encryptDataWithUserKey(character.category, userEncryptionKey); + const encImage: string | null = character.image ? System.encryptDataWithUserKey(character.image, userEncryptionKey) : null; + const encRole: string | null = character.role ? System.encryptDataWithUserKey(character.role, userEncryptionKey) : null; + const encBiography: string | null = character.biography ? System.encryptDataWithUserKey(character.biography, userEncryptionKey) : null; + const encHistory: string | null = character.history ? System.encryptDataWithUserKey(character.history, userEncryptionKey) : null; + const encSpeechPattern: string | null = character.speech_pattern ? System.encryptDataWithUserKey(character.speech_pattern, userEncryptionKey) : null; + const encCatchphrase: string | null = character.catchphrase ? System.encryptDataWithUserKey(character.catchphrase, userEncryptionKey) : null; + const encResidence: string | null = character.residence ? System.encryptDataWithUserKey(character.residence, userEncryptionKey) : null; + const encNotes: string | null = character.notes ? System.encryptDataWithUserKey(character.notes, userEncryptionKey) : null; + const encColor: string | null = character.color ? System.encryptDataWithUserKey(character.color, userEncryptionKey) : null; + + const exists: boolean = SeriesCharacterRepo.seriesCharacterExists(userId, character.character_id, lang); + if (exists) { + const success: boolean = SeriesCharacterRepo.updateSyncSeriesCharacter( + userId, + character.character_id, + encFirstName, + encLastName, + encNickname, + encAge, + encGender, + encSpecies, + encNationality, + encStatus, + encCategory, + encTitle, + encImage, + encRole, + encBiography, + encHistory, + encSpeechPattern, + encCatchphrase, + encResidence, + encNotes, + encColor, + character.last_update, + lang + ); + if (!success) return false; + } else { + const success: boolean = SeriesCharacterRepo.insertSyncSeriesCharacter( + character.character_id, + character.series_id, + userId, + encFirstName, + encLastName, + encNickname, + encAge, + encGender, + encSpecies, + encNationality, + encStatus, + encCategory, + encTitle, + encImage, + encRole, + encBiography, + encHistory, + encSpeechPattern, + encCatchphrase, + encResidence, + encNotes, + encColor, + character.last_update, + lang + ); + if (!success) return false; + } + } + + // Sync character attributes + for (const attr of completeSeries.seriesCharacterAttributes) { + const encryptedName: string = System.encryptDataWithUserKey(attr.attribute_name, userEncryptionKey); + const encryptedValue: string = System.encryptDataWithUserKey(attr.attribute_value, userEncryptionKey); + + const exists: boolean = SeriesCharacterRepo.seriesCharacterAttributeExists(userId, attr.attr_id, lang); + if (exists) { + const success: boolean = SeriesCharacterRepo.updateSyncSeriesCharacterAttribute( + userId, + attr.attr_id, + encryptedName, + encryptedValue, + attr.last_update, + lang + ); + if (!success) return false; + } else { + const success: boolean = SeriesCharacterRepo.insertSyncSeriesCharacterAttribute( + attr.attr_id, + attr.character_id, + userId, + encryptedName, + encryptedValue, + attr.last_update, + lang + ); + if (!success) return false; + } + } + + // Sync worlds + for (const world of completeSeries.seriesWorlds) { + const encryptedName: string = System.encryptDataWithUserKey(world.name, userEncryptionKey); + const encryptedHistory: string | null = world.history ? System.encryptDataWithUserKey(world.history, userEncryptionKey) : null; + const encryptedPolitics: string | null = world.politics ? System.encryptDataWithUserKey(world.politics, userEncryptionKey) : null; + const encryptedEconomy: string | null = world.economy ? System.encryptDataWithUserKey(world.economy, userEncryptionKey) : null; + const encryptedReligion: string | null = world.religion ? System.encryptDataWithUserKey(world.religion, userEncryptionKey) : null; + const encryptedLanguages: string | null = world.languages ? System.encryptDataWithUserKey(world.languages, userEncryptionKey) : null; + + const exists: boolean = SeriesWorldRepo.seriesWorldExists(userId, world.world_id, lang); + if (exists) { + const success: boolean = SeriesWorldRepo.updateSyncSeriesWorld( + world.world_id, + userId, + encryptedName, + encryptedHistory, + encryptedPolitics, + encryptedEconomy, + encryptedReligion, + encryptedLanguages, + world.last_update, + lang + ); + if (!success) return false; + } else { + const success: boolean = SeriesWorldRepo.insertSyncSeriesWorld( + world.world_id, + world.series_id, + userId, + encryptedName, + world.hashed_name, + encryptedHistory, + encryptedPolitics, + encryptedEconomy, + encryptedReligion, + encryptedLanguages, + world.last_update, + lang + ); + if (!success) return false; + } + } + + // Sync world elements + for (const element of completeSeries.seriesWorldElements) { + const encryptedName: string = System.encryptDataWithUserKey(element.name, userEncryptionKey); + const encryptedDescription: string | null = element.description ? System.encryptDataWithUserKey(element.description, userEncryptionKey) : null; + + const exists: boolean = SeriesWorldRepo.seriesWorldElementExists(userId, element.element_id, lang); + if (exists) { + const success: boolean = SeriesWorldRepo.updateSyncSeriesWorldElement( + element.element_id, + userId, + encryptedName, + encryptedDescription, + element.last_update, + lang + ); + if (!success) return false; + } else { + const success: boolean = SeriesWorldRepo.insertSyncSeriesWorldElement( + element.element_id, + element.world_id, + userId, + element.element_type, + encryptedName, + element.original_name, + encryptedDescription, + element.last_update, + lang + ); + if (!success) return false; + } + } + + // Sync locations + for (const location of completeSeries.seriesLocations) { + const encryptedName: string = System.encryptDataWithUserKey(location.loc_name, userEncryptionKey); + + const exists: boolean = SeriesLocationRepo.seriesLocationExists(userId, location.loc_id, lang); + if (exists) { + const success: boolean = SeriesLocationRepo.updateSyncSeriesLocation( + location.loc_id, + userId, + encryptedName, + location.last_update, + lang + ); + if (!success) return false; + } else { + const success: boolean = SeriesLocationRepo.insertSyncSeriesLocation( + location.loc_id, + location.series_id, + userId, + encryptedName, + location.loc_original_name, + location.last_update, + lang + ); + if (!success) return false; + } + } + + // Sync location elements + for (const element of completeSeries.seriesLocationElements) { + const encryptedName: string = System.encryptDataWithUserKey(element.element_name, userEncryptionKey); + const encryptedDescription: string | null = element.element_description ? System.encryptDataWithUserKey(element.element_description, userEncryptionKey) : null; + + const exists: boolean = SeriesLocationRepo.seriesLocationElementExists(userId, element.element_id, lang); + if (exists) { + const success: boolean = SeriesLocationRepo.updateSyncSeriesLocationElement( + element.element_id, + userId, + encryptedName, + encryptedDescription, + element.last_update, + lang + ); + if (!success) return false; + } else { + const success: boolean = SeriesLocationRepo.insertSyncSeriesLocationElement( + element.element_id, + element.location_id, + userId, + encryptedName, + element.original_name, + encryptedDescription, + element.last_update, + lang + ); + if (!success) return false; + } + } + + // Sync location sub-elements + for (const subElement of completeSeries.seriesLocationSubElements) { + const encryptedName: string = System.encryptDataWithUserKey(subElement.sub_elem_name, userEncryptionKey); + const encryptedDescription: string | null = subElement.sub_elem_description ? System.encryptDataWithUserKey(subElement.sub_elem_description, userEncryptionKey) : null; + + const exists: boolean = SeriesLocationRepo.seriesLocationSubElementExists(userId, subElement.sub_element_id, lang); + if (exists) { + const success: boolean = SeriesLocationRepo.updateSyncSeriesLocationSubElement( + subElement.sub_element_id, + userId, + encryptedName, + encryptedDescription, + subElement.last_update, + lang + ); + if (!success) return false; + } else { + const success: boolean = SeriesLocationRepo.insertSyncSeriesLocationSubElement( + subElement.sub_element_id, + subElement.element_id, + userId, + encryptedName, + subElement.original_name, + encryptedDescription, + subElement.last_update, + lang + ); + if (!success) return false; + } + } + + // Sync spells + for (const spell of completeSeries.seriesSpells) { + const encryptedName: string = System.encryptDataWithUserKey(spell.name, userEncryptionKey); + const encryptedDescription: string = System.encryptDataWithUserKey(spell.description, userEncryptionKey); + const encryptedAppearance: string = System.encryptDataWithUserKey(spell.appearance, userEncryptionKey); + const encryptedTags: string = System.encryptDataWithUserKey(spell.tags, userEncryptionKey); + const encryptedPowerLevel: string | null = spell.power_level ? System.encryptDataWithUserKey(spell.power_level, userEncryptionKey) : null; + const encryptedComponents: string | null = spell.components ? System.encryptDataWithUserKey(spell.components, userEncryptionKey) : null; + const encryptedLimitations: string | null = spell.limitations ? System.encryptDataWithUserKey(spell.limitations, userEncryptionKey) : null; + const encryptedNotes: string | null = spell.notes ? System.encryptDataWithUserKey(spell.notes, userEncryptionKey) : null; + + const exists: boolean = SeriesSpellRepo.seriesSpellExists(userId, spell.spell_id, lang); + if (exists) { + const success: boolean = SeriesSpellRepo.updateSyncSeriesSpell( + spell.spell_id, + userId, + encryptedName, + encryptedDescription, + encryptedAppearance, + encryptedTags, + encryptedPowerLevel, + encryptedComponents, + encryptedLimitations, + encryptedNotes, + spell.last_update, + lang + ); + if (!success) return false; + } else { + const success: boolean = SeriesSpellRepo.insertSyncSeriesSpell( + spell.spell_id, + spell.series_id, + userId, + encryptedName, + spell.name_hash, + encryptedDescription, + encryptedAppearance, + encryptedTags, + encryptedPowerLevel, + encryptedComponents, + encryptedLimitations, + encryptedNotes, + spell.last_update, + lang + ); + if (!success) return false; + } + } + + // Sync spell tags + for (const tag of completeSeries.seriesSpellTags) { + const encryptedName: string = System.encryptDataWithUserKey(tag.name, userEncryptionKey); + + const exists: boolean = SeriesSpellRepo.seriesSpellTagExists(userId, tag.tag_id, lang); + if (exists) { + const success: boolean = SeriesSpellRepo.updateSyncSeriesSpellTag( + tag.tag_id, + userId, + encryptedName, + tag.color, + tag.last_update, + lang + ); + if (!success) return false; + } else { + const success: boolean = SeriesSpellRepo.insertSyncSeriesSpellTag( + tag.tag_id, + tag.series_id, + userId, + encryptedName, + tag.hashed_name, + tag.color, + tag.last_update, + lang + ); + if (!success) return false; + } + } + + return true; } } diff --git a/electron/database/models/SeriesWorld.ts b/electron/database/models/SeriesWorld.ts index 86864f8..ffbeaa2 100644 --- a/electron/database/models/SeriesWorld.ts +++ b/electron/database/models/SeriesWorld.ts @@ -1,6 +1,7 @@ import { getUserEncryptionKey } from "../keyManager.js"; import System from "../System.js"; import SeriesWorldRepo, { SeriesWorldResult } from "../repositories/series-world.repo.js"; +import RemovedItem from "./RemovedItem.js"; export interface SeriesWorldElementProps { id: string; @@ -181,10 +182,15 @@ export default class SeriesWorld { * Deletes an element from a world. * @param userId - The unique identifier of the user * @param elementId - The unique identifier of the element + * @param deletedAt - The timestamp of deletion * @param lang - The language for error messages ('fr' or 'en') * @returns True if successful */ - public static deleteElement(userId: string, elementId: string, lang: 'fr' | 'en' = 'fr'): boolean { - return SeriesWorldRepo.deleteElement(userId, elementId, lang); + public static deleteElement(userId: string, elementId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { + const deleted: boolean = SeriesWorldRepo.deleteElement(userId, elementId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, null, 'series_world_elements', elementId, deletedAt, lang); + } + return deleted; } } diff --git a/electron/database/models/Spell.ts b/electron/database/models/Spell.ts index dd95cb7..20c90ad 100644 --- a/electron/database/models/Spell.ts +++ b/electron/database/models/Spell.ts @@ -3,6 +3,7 @@ import SpellTagRepo, { SpellTagResult } from '../repositories/spelltag.repo.js'; import BookRepo, { BookToolsTable } from '../repositories/book.repository.js'; import System from '../System.js'; import { getUserEncryptionKey } from '../keyManager.js'; +import RemovedItem from './RemovedItem.js'; export interface SpellTagProps { id: string; @@ -118,16 +119,16 @@ export default class Spell { * @param lang - The language for error messages ('fr' or 'en') * @returns True if the deletion was successful */ - static deleteSpellTag(userId: string, tagId: string, bookId: string, lang: 'fr' | 'en' = 'fr'): boolean { + static deleteSpellTag(userId: string, bookId: string, tagId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { const userKey: string = getUserEncryptionKey(userId); const spells: SpellResult[] = SpellRepo.fetchSpells(userId, bookId, lang); for (const spell of spells) { - const decryptedTags: string = System.decryptDataWithUserKey(spell.tags, userKey); + const decryptedTags: string | null = spell.tags ? System.decryptDataWithUserKey(spell.tags, userKey) : null; let tagsArray: string[] = []; try { - tagsArray = JSON.parse(decryptedTags) as string[]; + tagsArray = decryptedTags ? JSON.parse(decryptedTags) as string[] : []; } catch { tagsArray = []; } @@ -140,7 +141,11 @@ export default class Spell { } // Then delete the tag - return SpellTagRepo.deleteSpellTag(userId, tagId, lang); + const deleted: boolean = SpellTagRepo.deleteSpellTag(userId, tagId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, bookId, 'book_spell_tags', tagId, deletedAt, lang); + } + return deleted; } /** @@ -172,12 +177,12 @@ export default class Spell { const spells: SpellListItem[] = spellResults.map((spell: SpellResult): SpellListItem => { const decryptedName: string = System.decryptDataWithUserKey(spell.name, userKey); - const decryptedDescription: string = System.decryptDataWithUserKey(spell.description, userKey); - const decryptedTags: string = System.decryptDataWithUserKey(spell.tags, userKey); + const decryptedDescription: string | null = spell.description ? System.decryptDataWithUserKey(spell.description, userKey) : null; + const decryptedTags: string | null = spell.tags ? System.decryptDataWithUserKey(spell.tags, userKey) : null; let tagIds: string[]; try { - tagIds = JSON.parse(decryptedTags) as string[]; + tagIds = decryptedTags ? JSON.parse(decryptedTags) as string[] : []; } catch { tagIds = []; } @@ -186,9 +191,9 @@ export default class Spell { .map((tagId: string): SpellTagProps | undefined => tagMap.get(tagId)) .filter((tag: SpellTagProps | undefined): tag is SpellTagProps => tag !== undefined); - const truncatedDescription: string = decryptedDescription.length > 150 - ? decryptedDescription.substring(0, 150) + '...' - : decryptedDescription; + const truncatedDescription: string = decryptedDescription + ? (decryptedDescription.length > 150 ? decryptedDescription.substring(0, 150) + '...' : decryptedDescription) + : ''; return { id: spell.spell_id, @@ -222,13 +227,13 @@ export default class Spell { } const decryptedName: string = System.decryptDataWithUserKey(spell.name, userKey); - const decryptedDescription: string = System.decryptDataWithUserKey(spell.description, userKey); - const decryptedAppearance: string = System.decryptDataWithUserKey(spell.appearance, userKey); - const decryptedTags: string = System.decryptDataWithUserKey(spell.tags, userKey); + const decryptedDescription: string | null = spell.description ? System.decryptDataWithUserKey(spell.description, userKey) : null; + const decryptedAppearance: string | null = spell.appearance ? System.decryptDataWithUserKey(spell.appearance, userKey) : null; + const decryptedTags: string | null = spell.tags ? System.decryptDataWithUserKey(spell.tags, userKey) : null; let tagIds: string[]; try { - tagIds = JSON.parse(decryptedTags) as string[]; + tagIds = decryptedTags ? JSON.parse(decryptedTags) as string[] : []; } catch { tagIds = []; } @@ -236,8 +241,8 @@ export default class Spell { return { id: spell.spell_id, name: decryptedName, - description: decryptedDescription, - appearance: decryptedAppearance, + description: decryptedDescription || '', + appearance: decryptedAppearance || '', tags: tagIds, powerLevel: spell.power_level ? System.decryptDataWithUserKey(spell.power_level, userKey) : null, components: spell.components ? System.decryptDataWithUserKey(spell.components, userKey) : null, @@ -356,11 +361,17 @@ export default class Spell { /** * Deletes a spell. * @param userId - The unique identifier of the user + * @param bookId - The unique identifier of the book * @param spellId - The unique identifier of the spell + * @param deletedAt - The timestamp of deletion * @param lang - The language for error messages ('fr' or 'en') * @returns True if the deletion was successful */ - static deleteSpell(userId: string, spellId: string, lang: 'fr' | 'en' = 'fr'): boolean { - return SpellRepo.deleteSpell(userId, spellId, lang); + static deleteSpell(userId: string, bookId: string, spellId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { + const deleted: boolean = SpellRepo.deleteSpell(userId, spellId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, bookId, 'book_spells', spellId, deletedAt, lang); + } + return deleted; } } diff --git a/electron/database/models/Sync.ts b/electron/database/models/Sync.ts index 78de3a6..4159d8a 100644 --- a/electron/database/models/Sync.ts +++ b/electron/database/models/Sync.ts @@ -57,6 +57,40 @@ import { SyncedPlotPoint } from "./PlotPoint.js"; import { SyncedIssue } from "./Issue.js"; import { SyncedActSummary } from "./Act.js"; import { SyncedAIGuideLine, SyncedGuideLine } from "./GuideLine.js"; +import { + SyncedSeries, + SyncedSeriesBook, + SyncedSeriesCharacter, + SyncedSeriesCharacterAttribute, + SyncedSeriesWorld, + SyncedSeriesWorldElement, + SyncedSeriesLocation, + SyncedSeriesLocationElement, + SyncedSeriesLocationSubElement, + SyncedSeriesSpell, + SyncedSeriesSpellTag +} from "./Book.js"; +import SeriesRepo, { + SyncedSeriesResult, + SyncedSeriesBookResult +} from "../repositories/series.repo.js"; +import SeriesCharacterRepo, { + SyncedSeriesCharacterResult, + SyncedSeriesCharacterAttributeResult +} from "../repositories/series-character.repo.js"; +import SeriesWorldRepo, { + SyncedSeriesWorldResult, + SyncedSeriesWorldElementResult +} from "../repositories/series-world.repo.js"; +import SeriesLocationRepo, { + SyncedSeriesLocationResult, + SyncedSeriesLocationElementResult, + SyncedSeriesLocationSubElementResult +} from "../repositories/series-location.repo.js"; +import SeriesSpellRepo, { + SyncedSeriesSpellResult, + SyncedSeriesSpellTagResult +} from "../repositories/series-spell.repo.js"; /** * Handles synchronization operations between local database and remote server. @@ -375,9 +409,9 @@ export default class Sync { decryptedSpells.push({ ...spellRecord, name: System.decryptDataWithUserKey(spellRecord.name, userEncryptionKey), - description: System.decryptDataWithUserKey(spellRecord.description, userEncryptionKey), - appearance: System.decryptDataWithUserKey(spellRecord.appearance, userEncryptionKey), - tags: System.decryptDataWithUserKey(spellRecord.tags, userEncryptionKey), + description: spellRecord.description ? System.decryptDataWithUserKey(spellRecord.description, userEncryptionKey) : null, + appearance: spellRecord.appearance ? System.decryptDataWithUserKey(spellRecord.appearance, userEncryptionKey) : null, + tags: spellRecord.tags ? System.decryptDataWithUserKey(spellRecord.tags, userEncryptionKey) : null, power_level: spellRecord.power_level ? System.decryptDataWithUserKey(spellRecord.power_level, userEncryptionKey) : null, components: spellRecord.components ? System.decryptDataWithUserKey(spellRecord.components, userEncryptionKey) : null, limitations: spellRecord.limitations ? System.decryptDataWithUserKey(spellRecord.limitations, userEncryptionKey) : null, @@ -823,9 +857,9 @@ export default class Sync { for (const serverSpell of completeBook.spells) { const spellExists: boolean = SpellRepo.isSpellExist(userId, serverSpell.spell_id, lang); const encryptedName: string = System.encryptDataWithUserKey(serverSpell.name, userEncryptionKey); - const encryptedDescription: string = System.encryptDataWithUserKey(serverSpell.description, userEncryptionKey); - const encryptedAppearance: string = System.encryptDataWithUserKey(serverSpell.appearance, userEncryptionKey); - const encryptedTags: string = System.encryptDataWithUserKey(serverSpell.tags, userEncryptionKey); + const encryptedDescription: string | null = serverSpell.description ? System.encryptDataWithUserKey(serverSpell.description, userEncryptionKey) : null; + const encryptedAppearance: string | null = serverSpell.appearance ? System.encryptDataWithUserKey(serverSpell.appearance, userEncryptionKey) : null; + const encryptedTags: string | null = serverSpell.tags ? System.encryptDataWithUserKey(serverSpell.tags, userEncryptionKey) : null; const encryptedPowerLevel: string | null = serverSpell.power_level ? System.encryptDataWithUserKey(serverSpell.power_level, userEncryptionKey) : null; const encryptedComponents: string | null = serverSpell.components ? System.encryptDataWithUserKey(serverSpell.components, userEncryptionKey) : null; const encryptedLimitations: string | null = serverSpell.limitations ? System.encryptDataWithUserKey(serverSpell.limitations, userEncryptionKey) : null; @@ -1113,4 +1147,131 @@ export default class Sync { }; }); } + + // ===== SERIES SYNC METHODS ===== + + /** + * Retrieves all series for the current user with lightweight structure for sync comparison. + * Returns synced series with nested characters, worlds, locations, spells, and spell tags. + * All encrypted fields are decrypted before return. + * @param userId - The unique identifier of the user + * @param lang - The language for error messages ('fr' or 'en') + * @returns An array of synced series with all nested entities + */ + static getSyncedSeries(userId: string, lang: 'fr' | 'en'): SyncedSeries[] { + const userEncryptionKey: string = getUserEncryptionKey(userId); + + const allSeries: SyncedSeriesResult[] = SeriesRepo.fetchSyncedSeries(userId, lang); + const allSeriesBooks: SyncedSeriesBookResult[] = SeriesRepo.fetchSyncedSeriesBooks(userId, lang); + const allCharacters: SyncedSeriesCharacterResult[] = SeriesCharacterRepo.fetchSyncedSeriesCharacters(userId, lang); + const allCharacterAttributes: SyncedSeriesCharacterAttributeResult[] = SeriesCharacterRepo.fetchSyncedSeriesCharacterAttributes(userId, lang); + const allWorlds: SyncedSeriesWorldResult[] = SeriesWorldRepo.fetchSyncedSeriesWorlds(userId, lang); + const allWorldElements: SyncedSeriesWorldElementResult[] = SeriesWorldRepo.fetchSyncedSeriesWorldElements(userId, lang); + const allLocations: SyncedSeriesLocationResult[] = SeriesLocationRepo.fetchSyncedSeriesLocations(userId, lang); + const allLocationElements: SyncedSeriesLocationElementResult[] = SeriesLocationRepo.fetchSyncedSeriesLocationElements(userId, lang); + const allLocationSubElements: SyncedSeriesLocationSubElementResult[] = SeriesLocationRepo.fetchSyncedSeriesLocationSubElements(userId, lang); + const allSpells: SyncedSeriesSpellResult[] = SeriesSpellRepo.fetchSyncedSeriesSpells(userId, lang); + const allSpellTags: SyncedSeriesSpellTagResult[] = SeriesSpellRepo.fetchSyncedSeriesSpellTags(userId, lang); + + return allSeries.map((series: SyncedSeriesResult): SyncedSeries => { + const seriesId: string = series.series_id; + + // Map series books + const books: SyncedSeriesBook[] = allSeriesBooks + .filter((sb: SyncedSeriesBookResult): boolean => sb.series_id === seriesId) + .map((sb: SyncedSeriesBookResult): SyncedSeriesBook => ({ + bookId: sb.book_id, + order: sb.book_order, + lastUpdate: sb.last_update + })); + + // Map characters with attributes + const characters: SyncedSeriesCharacter[] = allCharacters + .filter((c: SyncedSeriesCharacterResult): boolean => c.series_id === seriesId) + .map((c: SyncedSeriesCharacterResult): SyncedSeriesCharacter => ({ + id: c.character_id, + name: System.decryptDataWithUserKey(c.first_name, userEncryptionKey), + lastUpdate: c.last_update, + attributes: allCharacterAttributes + .filter((a: SyncedSeriesCharacterAttributeResult): boolean => a.character_id === c.character_id) + .map((a: SyncedSeriesCharacterAttributeResult): SyncedSeriesCharacterAttribute => ({ + id: a.attr_id, + name: System.decryptDataWithUserKey(a.attribute_name, userEncryptionKey), + lastUpdate: a.last_update + })) + })); + + // Map worlds with elements + const worlds: SyncedSeriesWorld[] = allWorlds + .filter((w: SyncedSeriesWorldResult): boolean => w.series_id === seriesId) + .map((w: SyncedSeriesWorldResult): SyncedSeriesWorld => ({ + id: w.world_id, + name: System.decryptDataWithUserKey(w.name, userEncryptionKey), + lastUpdate: w.last_update, + elements: allWorldElements + .filter((e: SyncedSeriesWorldElementResult): boolean => e.world_id === w.world_id) + .map((e: SyncedSeriesWorldElementResult): SyncedSeriesWorldElement => ({ + id: e.element_id, + name: System.decryptDataWithUserKey(e.name, userEncryptionKey), + lastUpdate: e.last_update + })) + })); + + // Map locations with elements and sub-elements + const locations: SyncedSeriesLocation[] = allLocations + .filter((l: SyncedSeriesLocationResult): boolean => l.series_id === seriesId) + .map((l: SyncedSeriesLocationResult): SyncedSeriesLocation => ({ + id: l.loc_id, + name: System.decryptDataWithUserKey(l.loc_name, userEncryptionKey), + lastUpdate: l.last_update, + elements: allLocationElements + .filter((e: SyncedSeriesLocationElementResult): boolean => e.location_id === l.loc_id) + .map((e: SyncedSeriesLocationElementResult): SyncedSeriesLocationElement => ({ + id: e.element_id, + name: System.decryptDataWithUserKey(e.element_name, userEncryptionKey), + lastUpdate: e.last_update, + subElements: allLocationSubElements + .filter((se: SyncedSeriesLocationSubElementResult): boolean => se.element_id === e.element_id) + .map((se: SyncedSeriesLocationSubElementResult): SyncedSeriesLocationSubElement => ({ + id: se.sub_element_id, + name: System.decryptDataWithUserKey(se.sub_elem_name, userEncryptionKey), + lastUpdate: se.last_update + })) + })) + })); + + // Map spells + const spells: SyncedSeriesSpell[] = allSpells + .filter((s: SyncedSeriesSpellResult): boolean => s.series_id === seriesId) + .map((s: SyncedSeriesSpellResult): SyncedSeriesSpell => ({ + id: s.spell_id, + name: System.decryptDataWithUserKey(s.name, userEncryptionKey), + lastUpdate: s.last_update + })); + + // Map spell tags + const spellTags: SyncedSeriesSpellTag[] = allSpellTags + .filter((t: SyncedSeriesSpellTagResult): boolean => t.series_id === seriesId) + .map((t: SyncedSeriesSpellTagResult): SyncedSeriesSpellTag => ({ + id: t.tag_id, + name: System.decryptDataWithUserKey(t.name, userEncryptionKey), + lastUpdate: t.last_update + })); + + return { + id: seriesId, + name: System.decryptDataWithUserKey(series.name, userEncryptionKey), + description: series.description + ? System.decryptDataWithUserKey(series.description, userEncryptionKey) + : null, + lastUpdate: series.last_update, + books, + characters, + worlds, + locations, + spells, + spellTags + }; + }); + } } diff --git a/electron/database/models/Upload.ts b/electron/database/models/Upload.ts index 05ae28e..e250077 100644 --- a/electron/database/models/Upload.ts +++ b/electron/database/models/Upload.ts @@ -261,9 +261,9 @@ export default class Upload { const spells: BookSpellsTable[] = encryptedSpells.map((spell: BookSpellsTable): BookSpellsTable => ({ ...spell, name: System.decryptDataWithUserKey(spell.name, userEncryptionKey), - description: System.decryptDataWithUserKey(spell.description, userEncryptionKey), - appearance: System.decryptDataWithUserKey(spell.appearance, userEncryptionKey), - tags: System.decryptDataWithUserKey(spell.tags, userEncryptionKey), + description: spell.description ? System.decryptDataWithUserKey(spell.description, userEncryptionKey) : null, + appearance: spell.appearance ? System.decryptDataWithUserKey(spell.appearance, userEncryptionKey) : null, + tags: spell.tags ? System.decryptDataWithUserKey(spell.tags, userEncryptionKey) : null, power_level: spell.power_level ? System.decryptDataWithUserKey(spell.power_level, userEncryptionKey) : null, components: spell.components ? System.decryptDataWithUserKey(spell.components, userEncryptionKey) : null, limitations: spell.limitations ? System.decryptDataWithUserKey(spell.limitations, userEncryptionKey) : null, diff --git a/electron/database/models/World.ts b/electron/database/models/World.ts index 546d0dd..41463df 100644 --- a/electron/database/models/World.ts +++ b/electron/database/models/World.ts @@ -2,6 +2,7 @@ import { getUserEncryptionKey } from "../keyManager.js"; import System from "../System.js"; import WorldRepository, { WorldElementValue, WorldQuery } from "../repositories/world.repository.js"; import BookRepo, {BookToolsTable} from "../repositories/book.repository.js"; +import RemovedItem from "./RemovedItem.js"; export interface SyncedWorld { id: string; @@ -269,12 +270,18 @@ export default class World { /** * Removes an element from a world. * @param userId - The unique identifier of the user + * @param bookId - The unique identifier of the book * @param elementId - The unique identifier of the element to remove + * @param deletedAt - The timestamp of deletion * @param lang - The language for error messages ('fr' or 'en'), defaults to 'fr' * @returns True if the deletion was successful, false otherwise */ - public static removeElementFromWorld(userId: string, elementId: string, lang: 'fr' | 'en' = 'fr'): boolean { - return WorldRepository.deleteElement(userId, elementId, lang); + public static removeElementFromWorld(userId: string, bookId: string, elementId: string, deletedAt: number = System.timeStampInSeconds(), lang: 'fr' | 'en' = 'fr'): boolean { + const deleted: boolean = WorldRepository.deleteElement(userId, elementId, lang); + if (deleted) { + RemovedItem.deleteTracker(userId, bookId, 'book_world_elements', elementId, deletedAt, lang); + } + return deleted; } } diff --git a/electron/database/repositories/book.repository.ts b/electron/database/repositories/book.repository.ts index b01026d..b24aba3 100644 --- a/electron/database/repositories/book.repository.ts +++ b/electron/database/repositories/book.repository.ts @@ -123,7 +123,7 @@ export default class BookRepo { let book: BookQuery; try { const db: Database = System.getDb(); - const query: string = 'SELECT book_id, author_id, title, summary, sub_title, cover_image, desired_release_date, desired_word_count, words_count FROM erit_books WHERE book_id=? AND author_id=?'; + const query: string = 'SELECT book_id, author_id, title, summary, sub_title, cover_image, desired_release_date, desired_word_count, words_count, serie_id FROM erit_books WHERE book_id=? AND author_id=?'; const params: SQLiteValue[] = [bookId, userId]; book = db.get(query, params) as BookQuery; } catch (error: unknown) { @@ -456,4 +456,40 @@ export default class BookRepo { throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); } } + + static isBookExist(userId: string, bookId: string, lang: 'fr' | 'en'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT 1 FROM erit_books WHERE author_id = ? AND book_id = ? LIMIT 1'; + const params: SQLiteValue[] = [userId, bookId]; + const result = db.get(query, params); + return result !== undefined && result !== null; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + } + return false; + } + } + + /** + * Retrieves the series_id for a book from series_books table. + * @param bookId - The book identifier + * @param lang - The language for error messages + * @returns The series_id or null if book is not in a series + */ + static fetchBookSeriesId(bookId: string, lang: 'fr' | 'en'): string | null { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT series_id FROM series_books WHERE book_id = ? LIMIT 1'; + const params: SQLiteValue[] = [bookId]; + const result = db.get(query, params) as { series_id: string } | undefined; + return result?.series_id || null; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + } + return null; + } + } } \ No newline at end of file diff --git a/electron/database/repositories/removed-items.repository.ts b/electron/database/repositories/removed-items.repository.ts new file mode 100644 index 0000000..395f107 --- /dev/null +++ b/electron/database/repositories/removed-items.repository.ts @@ -0,0 +1,158 @@ +import { Database, RunResult, SQLiteValue } from 'node-sqlite3-wasm'; +import System from '../System.js'; + +export interface RemovedItemRecord extends Record { + removal_id: string; + table_name: string; + entity_id: string; + book_id: string | null; + user_id: string; + deleted_at: number; +} + +/** + * Repository for tracking deleted items for sync purposes. + */ +export default class RemovedItemsRepository { + /** + * Inserts a removal record into the database. + * @param removalId - The unique ID for this removal record. + * @param tableName - The name of the table from which the item is deleted. + * @param entityId - The UUID of the deleted entity. + * @param bookId - Book ID (null for series items). + * @param userId - The user ID who owns the item. + * @param deletedAt - Timestamp of deletion. + * @param lang - The language for error messages ('fr' or 'en'). + * @returns True if inserted successfully. + */ + public static insert( + removalId: string, + tableName: string, + entityId: string, + bookId: string | null, + userId: string, + deletedAt: number, + lang: 'fr' | 'en' + ): boolean { + try { + const db: Database = System.getDb(); + const query: string = ` + INSERT INTO removed_items (removal_id, table_name, entity_id, book_id, user_id, deleted_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(table_name, entity_id) DO UPDATE SET deleted_at = excluded.deleted_at + `; + const params: SQLiteValue[] = [removalId, tableName, entityId, bookId, userId, deletedAt]; + const result: RunResult = db.run(query, params); + return result.changes > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible d'enregistrer la suppression.` : `Unable to record deletion.`); + } else { + console.error("An unknown error occurred."); + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + /** + * Retrieves deletions since a specific timestamp. + * Used to get deletions that occurred since last sync. + * @param userId - The user ID. + * @param since - Timestamp to get deletions after. + * @param lang - The language for error messages ('fr' or 'en'). + * @returns Array of removed item records. + */ + public static getDeletionsSince(userId: string, since: number, lang: 'fr' | 'en'): RemovedItemRecord[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT * FROM removed_items WHERE user_id = ? AND deleted_at > ?'; + const params: SQLiteValue[] = [userId, since]; + const records: RemovedItemRecord[] = db.all(query, params) as RemovedItemRecord[]; + return records; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer les suppressions.` : `Unable to retrieve deletions.`); + } else { + console.error("An unknown error occurred."); + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + /** + * Checks if an entity was previously deleted. + * @param tableName - The table name. + * @param entityId - The entity ID. + * @param lang - The language for error messages ('fr' or 'en'). + * @returns True if the entity was deleted locally. + */ + public static wasDeleted(tableName: string, entityId: string, lang: 'fr' | 'en'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT 1 FROM removed_items WHERE table_name = ? AND entity_id = ? LIMIT 1'; + const params: SQLiteValue[] = [tableName, entityId]; + const result = db.get(query, params); + return result !== null; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de vérifier si l'élément a été supprimé.` : `Unable to check if item was deleted.`); + } else { + console.error("An unknown error occurred."); + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + /** + * Retrieves all tracked deletions for a specific book. + * @param userId - The user ID. + * @param bookId - The book ID. + * @param lang - The language for error messages ('fr' or 'en'). + * @returns Array of removed item records for that book. + */ + public static getDeletionsForBook(userId: string, bookId: string, lang: 'fr' | 'en'): RemovedItemRecord[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT * FROM removed_items WHERE user_id = ? AND book_id = ?'; + const params: SQLiteValue[] = [userId, bookId]; + const records: RemovedItemRecord[] = db.all(query, params) as RemovedItemRecord[]; + return records; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer les suppressions pour ce livre.` : `Unable to retrieve deletions for this book.`); + } else { + console.error("An unknown error occurred."); + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + /** + * Clears all deletion records for a user. + * WARNING: Only use this when wiping user data completely. + * @param userId - The user ID. + * @param lang - The language for error messages ('fr' or 'en'). + * @returns True if cleared successfully. + */ + public static clearAllForUser(userId: string, lang: 'fr' | 'en'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'DELETE FROM removed_items WHERE user_id = ?'; + const params: SQLiteValue[] = [userId]; + const result: RunResult = db.run(query, params); + return result.changes > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de supprimer les enregistrements de suppression.` : `Unable to clear deletion records.`); + } else { + console.error("An unknown error occurred."); + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } +} diff --git a/electron/database/repositories/series-character.repo.ts b/electron/database/repositories/series-character.repo.ts index b9c1c51..f631bc9 100644 --- a/electron/database/repositories/series-character.repo.ts +++ b/electron/database/repositories/series-character.repo.ts @@ -469,4 +469,73 @@ export default class SeriesCharacterRepo { } } } + + static fetchCharactersTableForSync(seriesId: string, lang: 'fr' | 'en' = 'fr'): SeriesCharactersTableResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT character_id, series_id, user_id, first_name, last_name, nickname, age, gender, species, nationality, status, title, category, image, role, biography, history, speech_pattern, catchphrase, residence, notes, color, last_update FROM series_characters WHERE series_id = ?'; + return db.all(query, [seriesId]) as SeriesCharactersTableResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer les personnages pour sync.` : `Unable to retrieve characters for sync.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + static fetchCharacterAttributesTableForSync(seriesId: string, lang: 'fr' | 'en' = 'fr'): SeriesCharacterAttributesTableResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT sca.attr_id, sca.character_id, sca.user_id, sca.attribute_name, sca.attribute_value, sca.last_update FROM series_characters_attributes sca INNER JOIN series_characters sc ON sca.character_id = sc.character_id WHERE sc.series_id = ?'; + return db.all(query, [seriesId]) as SeriesCharacterAttributesTableResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer les attributs pour sync.` : `Unable to retrieve attributes for sync.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + /** + * Fetches all characters for a series (alias for fetchCharacters that returns full table result). + */ + static fetchSeriesCharacters(userId: string, seriesId: string, lang: 'fr' | 'en' = 'fr'): SeriesCharactersTableResult[] { + return this.fetchSeriesCharactersTable(userId, seriesId, lang); + } + + /** + * Fetches all character attributes for a series by series ID. + */ + static fetchSeriesCharacterAttributesBySeriesId(userId: string, seriesId: string, lang: 'fr' | 'en' = 'fr'): SeriesCharacterAttributesTableResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT sca.attr_id, sca.character_id, sca.user_id, sca.attribute_name, sca.attribute_value, sca.last_update FROM series_characters_attributes sca INNER JOIN series_characters sc ON sca.character_id = sc.character_id WHERE sc.series_id = ? AND sc.user_id = ?'; + return db.all(query, [seriesId, userId]) as SeriesCharacterAttributesTableResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer les attributs par série.` : `Unable to retrieve attributes by series.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + /** + * Checks if a series character exists (alias for isCharacterExist). + */ + static seriesCharacterExists(userId: string, characterId: string, lang: 'fr' | 'en' = 'fr'): boolean { + return this.isCharacterExist(userId, characterId, lang); + } + + /** + * Checks if a series character attribute exists (alias for isAttributeExist). + */ + static seriesCharacterAttributeExists(userId: string, attrId: string, lang: 'fr' | 'en' = 'fr'): boolean { + return this.isAttributeExist(userId, attrId, lang); + } } diff --git a/electron/database/repositories/series-location.repo.ts b/electron/database/repositories/series-location.repo.ts index 180ce39..9d89194 100644 --- a/electron/database/repositories/series-location.repo.ts +++ b/electron/database/repositories/series-location.repo.ts @@ -620,4 +620,194 @@ export default class SeriesLocationRepo { } } } + + public static fetchLocationsTableForSync(seriesId: string, lang: 'fr' | 'en' = 'fr'): SeriesLocationsTableResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT loc_id, series_id, user_id, loc_name, loc_original_name, last_update FROM series_locations WHERE series_id = ?'; + return db.all(query, [seriesId]) as SeriesLocationsTableResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer les lieux pour sync.` : `Unable to retrieve locations for sync.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + public static fetchLocationElementsTableForSync(seriesId: string, lang: 'fr' | 'en' = 'fr'): SeriesLocationElementsTableResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT sle.element_id, sle.location_id, sle.user_id, sle.element_name, sle.original_name, sle.element_description, sle.last_update FROM series_location_elements sle INNER JOIN series_locations sl ON sle.location_id = sl.loc_id WHERE sl.series_id = ?'; + return db.all(query, [seriesId]) as SeriesLocationElementsTableResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer les éléments de lieu pour sync.` : `Unable to retrieve location elements for sync.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + public static fetchLocationSubElementsTableForSync(seriesId: string, lang: 'fr' | 'en' = 'fr'): SeriesLocationSubElementsTableResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT slse.sub_element_id, slse.element_id, slse.user_id, slse.sub_elem_name, slse.original_name, slse.sub_elem_description, slse.last_update FROM series_location_sub_elements slse INNER JOIN series_location_elements sle ON slse.element_id = sle.element_id INNER JOIN series_locations sl ON sle.location_id = sl.loc_id WHERE sl.series_id = ?'; + return db.all(query, [seriesId]) as SeriesLocationSubElementsTableResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer les sous-éléments de lieu pour sync.` : `Unable to retrieve location sub-elements for sync.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + /** + * Fetches all locations for a series (alias for fetchSeriesLocationsTable). + */ + public static fetchSeriesLocations(userId: string, seriesId: string, lang: 'fr' | 'en' = 'fr'): SeriesLocationsTableResult[] { + return this.fetchSeriesLocationsTable(userId, seriesId, lang); + } + + /** + * Fetches all location elements for a series by series ID. + */ + public static fetchSeriesLocationElementsBySeriesId(userId: string, seriesId: string, lang: 'fr' | 'en' = 'fr'): SeriesLocationElementsTableResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT sle.element_id, sle.location_id, sle.user_id, sle.element_name, sle.original_name, sle.element_description, sle.last_update FROM series_location_elements sle INNER JOIN series_locations sl ON sle.location_id = sl.loc_id WHERE sl.series_id = ? AND sl.user_id = ?'; + return db.all(query, [seriesId, userId]) as SeriesLocationElementsTableResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer les éléments de lieu par série.` : `Unable to retrieve location elements by series.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + /** + * Fetches all location sub-elements for a series by series ID. + */ + public static fetchSeriesLocationSubElementsBySeriesId(userId: string, seriesId: string, lang: 'fr' | 'en' = 'fr'): SeriesLocationSubElementsTableResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT slse.sub_element_id, slse.element_id, slse.user_id, slse.sub_elem_name, slse.original_name, slse.sub_elem_description, slse.last_update FROM series_location_sub_elements slse INNER JOIN series_location_elements sle ON slse.element_id = sle.element_id INNER JOIN series_locations sl ON sle.location_id = sl.loc_id WHERE sl.series_id = ? AND sl.user_id = ?'; + return db.all(query, [seriesId, userId]) as SeriesLocationSubElementsTableResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer les sous-éléments de lieu par série.` : `Unable to retrieve location sub-elements by series.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + /** + * Checks if a series location exists (alias for isLocationExist). + */ + public static seriesLocationExists(userId: string, locationId: string, lang: 'fr' | 'en' = 'fr'): boolean { + return this.isLocationExist(userId, locationId, lang); + } + + /** + * Checks if a series location element exists (alias for isLocationElementExist). + */ + public static seriesLocationElementExists(userId: string, elementId: string, lang: 'fr' | 'en' = 'fr'): boolean { + return this.isLocationElementExist(userId, elementId, lang); + } + + /** + * Checks if a series location sub-element exists (alias for isLocationSubElementExist). + */ + public static seriesLocationSubElementExists(userId: string, subElementId: string, lang: 'fr' | 'en' = 'fr'): boolean { + return this.isLocationSubElementExist(userId, subElementId, lang); + } + + /** + * Inserts a series location for sync (alias with compatible signature). + */ + public static insertSyncSeriesLocation(locationId: string, seriesId: string, userId: string, locName: string, locOriginalName: string, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + return this.insertSyncLocation(locationId, seriesId, userId, locName, locOriginalName, lastUpdate, lang); + } + + /** + * Updates a series location for sync (without originalName). + */ + public static updateSyncSeriesLocation(locationId: string, userId: string, locName: string, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'UPDATE series_locations SET loc_name = ?, last_update = ? WHERE loc_id = ? AND user_id = ?'; + const params: SQLiteValue[] = [locName, lastUpdate, locationId, userId]; + const updateResult: RunResult = db.run(query, params); + return updateResult.changes > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de mettre à jour le lieu série pour sync.` : `Unable to update series location for sync.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + /** + * Inserts a series location element for sync (alias with compatible signature). + */ + public static insertSyncSeriesLocationElement(elementId: string, locationId: string, userId: string, elementName: string, originalName: string, elementDescription: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + return this.insertSyncLocationElement(elementId, locationId, userId, elementName, originalName, elementDescription, lastUpdate, lang); + } + + /** + * Updates a series location element for sync (without originalName). + */ + public static updateSyncSeriesLocationElement(elementId: string, userId: string, elementName: string, elementDescription: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'UPDATE series_location_elements SET element_name = ?, element_description = ?, last_update = ? WHERE element_id = ? AND user_id = ?'; + const params: SQLiteValue[] = [elementName, elementDescription, lastUpdate, elementId, userId]; + const updateResult: RunResult = db.run(query, params); + return updateResult.changes > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de mettre à jour l'élément de lieu série pour sync.` : `Unable to update series location element for sync.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + /** + * Inserts a series location sub-element for sync (alias with compatible signature). + */ + public static insertSyncSeriesLocationSubElement(subElementId: string, elementId: string, userId: string, subElemName: string, originalName: string, subElemDescription: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + return this.insertSyncLocationSubElement(subElementId, elementId, userId, subElemName, originalName, subElemDescription, lastUpdate, lang); + } + + /** + * Updates a series location sub-element for sync (without originalName). + */ + public static updateSyncSeriesLocationSubElement(subElementId: string, userId: string, subElemName: string, subElemDescription: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'UPDATE series_location_sub_elements SET sub_elem_name = ?, sub_elem_description = ?, last_update = ? WHERE sub_element_id = ? AND user_id = ?'; + const params: SQLiteValue[] = [subElemName, subElemDescription, lastUpdate, subElementId, userId]; + const updateResult: RunResult = db.run(query, params); + return updateResult.changes > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de mettre à jour le sous-élément série pour sync.` : `Unable to update series sub-element for sync.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } } diff --git a/electron/database/repositories/series-spell.repo.ts b/electron/database/repositories/series-spell.repo.ts index 75e7896..250b685 100644 --- a/electron/database/repositories/series-spell.repo.ts +++ b/electron/database/repositories/series-spell.repo.ts @@ -26,9 +26,9 @@ export interface SeriesSpellsTableResult extends Record { user_id: string; name: string; name_hash: string; - description: string; - appearance: string; - tags: string; + description: string | null; + appearance: string | null; + tags: string | null; power_level: string | null; components: string | null; limitations: string | null; @@ -496,4 +496,152 @@ export default class SeriesSpellRepo { } } } + + static fetchSpellsTableForSync(seriesId: string, lang: 'fr' | 'en' = 'fr'): SeriesSpellsTableResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT spell_id, series_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update FROM series_spells WHERE series_id = ?'; + return db.all(query, [seriesId]) as SeriesSpellsTableResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer les sorts pour sync.` : `Unable to retrieve spells for sync.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + static fetchSpellTagsTableForSync(seriesId: string, lang: 'fr' | 'en' = 'fr'): SeriesSpellTagsTableResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT tag_id, series_id, user_id, name, hashed_name, color, last_update FROM series_spell_tags WHERE series_id = ?'; + return db.all(query, [seriesId]) as SeriesSpellTagsTableResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer les tags de sort pour sync.` : `Unable to retrieve spell tags for sync.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + /** + * Fetches all spells for a series (alias for fetchSeriesSpellsTable). + */ + static fetchSeriesSpells(userId: string, seriesId: string, lang: 'fr' | 'en' = 'fr'): SeriesSpellsTableResult[] { + return this.fetchSeriesSpellsTable(userId, seriesId, lang); + } + + /** + * Fetches all spell tags for a series (alias for fetchSeriesSpellTagsTable). + */ + static fetchSeriesSpellTags(userId: string, seriesId: string, lang: 'fr' | 'en' = 'fr'): SeriesSpellTagsTableResult[] { + return this.fetchSeriesSpellTagsTable(userId, seriesId, lang); + } + + /** + * Checks if a series spell exists (alias for isSpellExist). + */ + static seriesSpellExists(userId: string, spellId: string, lang: 'fr' | 'en' = 'fr'): boolean { + return this.isSpellExist(userId, spellId, lang); + } + + /** + * Checks if a series spell tag exists (alias for isSpellTagExist). + */ + static seriesSpellTagExists(userId: string, tagId: string, lang: 'fr' | 'en' = 'fr'): boolean { + return this.isSpellTagExist(userId, tagId, lang); + } + + /** + * Fetches a complete spell by ID for sync (array format). + */ + static fetchCompleteSpellById(spellId: string, lang: 'fr' | 'en' = 'fr'): SeriesSpellsTableResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT spell_id, series_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update FROM series_spells WHERE spell_id = ?'; + return db.all(query, [spellId]) as SeriesSpellsTableResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer le sort complet.` : `Unable to retrieve complete spell.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + /** + * Fetches a complete spell tag by ID for sync (array format). + */ + static fetchCompleteSpellTagById(tagId: string, lang: 'fr' | 'en' = 'fr'): SeriesSpellTagsTableResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT tag_id, series_id, user_id, name, hashed_name, color, last_update FROM series_spell_tags WHERE tag_id = ?'; + return db.all(query, [tagId]) as SeriesSpellTagsTableResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer le tag complet.` : `Unable to retrieve complete tag.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + /** + * Inserts a series spell for sync (alias with compatible signature). + */ + static insertSyncSeriesSpell(spellId: string, seriesId: string, userId: string, name: string, nameHash: string, description: string, appearance: string, tags: string, powerLevel: string | null, components: string | null, limitations: string | null, notes: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + return this.insertSyncSpell(spellId, seriesId, userId, name, nameHash, description, appearance, tags, powerLevel, components, limitations, notes, lastUpdate, lang); + } + + /** + * Updates a series spell for sync (simplified signature). + */ + static updateSyncSeriesSpell(spellId: string, userId: string, name: string, description: string, appearance: string, tags: string, powerLevel: string | null, components: string | null, limitations: string | null, notes: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'UPDATE series_spells SET name = ?, description = ?, appearance = ?, tags = ?, power_level = ?, components = ?, limitations = ?, notes = ?, last_update = ? WHERE spell_id = ? AND user_id = ?'; + const params: SQLiteValue[] = [name, description, appearance, tags, powerLevel, components, limitations, notes, lastUpdate, spellId, userId]; + const updateResult: RunResult = db.run(query, params); + return updateResult.changes > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de mettre à jour le sort série pour sync.` : `Unable to update series spell for sync.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + /** + * Inserts a series spell tag for sync (alias with compatible signature). + */ + static insertSyncSeriesSpellTag(tagId: string, seriesId: string, userId: string, name: string, hashedName: string, color: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + return this.insertSyncSpellTag(tagId, seriesId, userId, name, hashedName, color, lastUpdate, lang); + } + + /** + * Updates a series spell tag for sync (simplified signature). + */ + static updateSyncSeriesSpellTag(tagId: string, userId: string, name: string, color: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'UPDATE series_spell_tags SET name = ?, color = ?, last_update = ? WHERE tag_id = ? AND user_id = ?'; + const params: SQLiteValue[] = [name, color, lastUpdate, tagId, userId]; + const updateResult: RunResult = db.run(query, params); + return updateResult.changes > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de mettre à jour le tag série pour sync.` : `Unable to update series tag for sync.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } } diff --git a/electron/database/repositories/series-world.repo.ts b/electron/database/repositories/series-world.repo.ts index 457ca0b..4622f77 100644 --- a/electron/database/repositories/series-world.repo.ts +++ b/electron/database/repositories/series-world.repo.ts @@ -429,4 +429,127 @@ export default class SeriesWorldRepo { } } } + + public static fetchWorldsTableForSync(seriesId: string, lang: 'fr' | 'en' = 'fr'): SeriesWorldsTableResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT world_id, series_id, user_id, name, hashed_name, history, politics, economy, religion, languages, last_update FROM series_worlds WHERE series_id = ?'; + return db.all(query, [seriesId]) as SeriesWorldsTableResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer les mondes pour sync.` : `Unable to retrieve worlds for sync.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + public static fetchWorldElementsTableForSync(seriesId: string, lang: 'fr' | 'en' = 'fr'): SeriesWorldElementsTableResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT swe.element_id, swe.world_id, swe.user_id, swe.element_type, swe.name, swe.original_name, swe.description, swe.last_update FROM series_world_elements swe INNER JOIN series_worlds sw ON swe.world_id = sw.world_id WHERE sw.series_id = ?'; + return db.all(query, [seriesId]) as SeriesWorldElementsTableResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer les éléments de monde pour sync.` : `Unable to retrieve world elements for sync.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + /** + * Fetches all worlds for a series (alias for fetchSeriesWorldsTable). + */ + public static fetchSeriesWorlds(userId: string, seriesId: string, lang: 'fr' | 'en' = 'fr'): SeriesWorldsTableResult[] { + return this.fetchSeriesWorldsTable(userId, seriesId, lang); + } + + /** + * Fetches all world elements for a series by series ID. + */ + public static fetchSeriesWorldElementsBySeriesId(userId: string, seriesId: string, lang: 'fr' | 'en' = 'fr'): SeriesWorldElementsTableResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT swe.element_id, swe.world_id, swe.user_id, swe.element_type, swe.name, swe.original_name, swe.description, swe.last_update FROM series_world_elements swe INNER JOIN series_worlds sw ON swe.world_id = sw.world_id WHERE sw.series_id = ? AND sw.user_id = ?'; + return db.all(query, [seriesId, userId]) as SeriesWorldElementsTableResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer les éléments de monde par série.` : `Unable to retrieve world elements by series.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + /** + * Checks if a series world exists (alias for isWorldExist). + */ + public static seriesWorldExists(userId: string, worldId: string, lang: 'fr' | 'en' = 'fr'): boolean { + return this.isWorldExist(userId, worldId, lang); + } + + /** + * Checks if a series world element exists (alias for isWorldElementExist). + */ + public static seriesWorldElementExists(userId: string, elementId: string, lang: 'fr' | 'en' = 'fr'): boolean { + return this.isWorldElementExist(userId, elementId, lang); + } + + /** + * Inserts a series world for sync (alias with compatible signature). + */ + public static insertSyncSeriesWorld(worldId: string, seriesId: string, userId: string, name: string, hashedName: string, history: string | null, politics: string | null, economy: string | null, religion: string | null, languages: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + return this.insertSyncWorld(worldId, seriesId, userId, name, hashedName, history, politics, economy, religion, languages, lastUpdate, lang); + } + + /** + * Updates a series world for sync (without hashedName). + */ + public static updateSyncSeriesWorld(worldId: string, userId: string, name: string, history: string | null, politics: string | null, economy: string | null, religion: string | null, languages: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'UPDATE series_worlds SET name = ?, history = ?, politics = ?, economy = ?, religion = ?, languages = ?, last_update = ? WHERE world_id = ? AND user_id = ?'; + const params: SQLiteValue[] = [name, history, politics, economy, religion, languages, lastUpdate, worldId, userId]; + const updateResult: RunResult = db.run(query, params); + return updateResult.changes > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de mettre à jour le monde série pour sync.` : `Unable to update series world for sync.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } + + /** + * Inserts a series world element for sync (alias with compatible signature). + */ + public static insertSyncSeriesWorldElement(elementId: string, worldId: string, userId: string, elementType: number, name: string, originalName: string, description: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + return this.insertSyncWorldElement(elementId, worldId, userId, elementType, name, originalName, description, lastUpdate, lang); + } + + /** + * Updates a series world element for sync (without elementType and originalName). + */ + public static updateSyncSeriesWorldElement(elementId: string, userId: string, name: string, description: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'UPDATE series_world_elements SET name = ?, description = ?, last_update = ? WHERE element_id = ? AND user_id = ?'; + const params: SQLiteValue[] = [name, description, lastUpdate, elementId, userId]; + const updateResult: RunResult = db.run(query, params); + return updateResult.changes > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de mettre à jour l'élément de monde série pour sync.` : `Unable to update series world element for sync.`); + } else { + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : "An unknown error occurred."); + } + } + } } diff --git a/electron/database/repositories/series.repo.ts b/electron/database/repositories/series.repo.ts index 6402ddd..67595c9 100644 --- a/electron/database/repositories/series.repo.ts +++ b/electron/database/repositories/series.repo.ts @@ -539,4 +539,15 @@ export default class SeriesRepo { } } } + + /** + * Checks if a series exists for a user (alias for isSeriesExist). + * @param userId - The unique identifier of the user + * @param seriesId - The unique identifier of the series + * @param lang - The language for error messages ('fr' or 'en') + * @returns True if the series exists + */ + public static seriesExists(userId: string, seriesId: string, lang: 'fr' | 'en' = 'fr'): boolean { + return this.isSeriesExist(userId, seriesId, lang); + } } diff --git a/electron/database/repositories/spell.repo.ts b/electron/database/repositories/spell.repo.ts index 9975f05..71127f2 100644 --- a/electron/database/repositories/spell.repo.ts +++ b/electron/database/repositories/spell.repo.ts @@ -5,9 +5,9 @@ export interface SpellResult extends Record { spell_id: string; book_id: string; name: string; - description: string; - appearance: string; - tags: string; + description: string | null; + appearance: string | null; + tags: string | null; power_level: string | null; components: string | null; limitations: string | null; @@ -21,9 +21,9 @@ export interface BookSpellsTable extends Record { user_id: string; name: string; name_hash: string; - description: string; - appearance: string; - tags: string; + description: string | null; + appearance: string | null; + tags: string | null; power_level: string | null; components: string | null; limitations: string | null; @@ -301,7 +301,7 @@ export default class SpellRepo { * @param lang - The language for error messages ('fr' or 'en') * @returns True if the insertion was successful */ - static insertSyncSpell(spellId: string, bookId: string, userId: string, name: string, nameHash: string, description: string, appearance: string, tags: string, powerLevel: string | null, components: string | null, limitations: string | null, notes: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + static insertSyncSpell(spellId: string, bookId: string, userId: string, name: string, nameHash: string, description: string | null, appearance: string | null, tags: string | null, powerLevel: string | null, components: string | null, limitations: string | null, notes: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { try { const db: Database = System.getDb(); const query: string = 'INSERT OR REPLACE INTO book_spells (spell_id, book_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)'; @@ -359,7 +359,7 @@ export default class SpellRepo { * @param lang - The language for error messages ('fr' or 'en') * @returns True if the update was successful */ - static updateSyncSpell(userId: string, spellId: string, name: string, nameHash: string, description: string, appearance: string, tags: string, powerLevel: string | null, components: string | null, limitations: string | null, notes: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + static updateSyncSpell(userId: string, spellId: string, name: string, nameHash: string, description: string | null, appearance: string | null, tags: string | null, powerLevel: string | null, components: string | null, limitations: string | null, notes: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { try { const db: Database = System.getDb(); const query: string = 'UPDATE book_spells SET name=?, name_hash=?, description=?, appearance=?, tags=?, power_level=?, components=?, limitations=?, notes=?, last_update=? WHERE spell_id=? AND user_id=?'; diff --git a/electron/database/schema.ts b/electron/database/schema.ts index 3c76c9b..d2e5dc4 100644 --- a/electron/database/schema.ts +++ b/electron/database/schema.ts @@ -19,7 +19,205 @@ const schemaVersion = 3; * DEV ONLY - S'exécute à chaque refresh, pas besoin de version * Mets ta query, test, efface après */ -const devQueries: string[] = []; +const devQueries: string[] = [ + // V3 Migration: Series tables and series_*_id columns + + // Book Series + `CREATE TABLE IF NOT EXISTS book_series ( + series_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + hashed_name TEXT NOT NULL, + description TEXT, + cover_image TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES erit_users(user_id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS idx_book_series_user ON book_series(user_id)`, + + // Series Books + `CREATE TABLE IF NOT EXISTS series_books ( + series_id TEXT NOT NULL, + book_id TEXT NOT NULL, + book_order INTEGER NOT NULL DEFAULT 1, + last_update INTEGER DEFAULT 0, + PRIMARY KEY (series_id, book_id), + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE, + FOREIGN KEY (book_id) REFERENCES erit_books(book_id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS idx_series_books_book ON series_books(book_id)`, + + // Series Characters + `CREATE TABLE IF NOT EXISTS series_characters ( + character_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT, + nickname TEXT, + age TEXT, + gender TEXT, + species TEXT, + nationality TEXT, + status TEXT, + category TEXT NOT NULL, + title TEXT, + image TEXT, + role TEXT, + biography TEXT, + history TEXT, + speech_pattern TEXT, + catchphrase TEXT, + residence TEXT, + notes TEXT, + color TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS idx_series_characters_series ON series_characters(series_id)`, + `CREATE INDEX IF NOT EXISTS idx_series_characters_user ON series_characters(user_id)`, + + // Series Characters Attributes + `CREATE TABLE IF NOT EXISTS series_characters_attributes ( + attr_id TEXT PRIMARY KEY, + character_id TEXT NOT NULL, + user_id TEXT NOT NULL, + attribute_name TEXT NOT NULL, + attribute_value TEXT NOT NULL, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (character_id) REFERENCES series_characters(character_id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS idx_series_char_attrs_character ON series_characters_attributes(character_id)`, + `CREATE INDEX IF NOT EXISTS idx_series_char_attrs_user ON series_characters_attributes(user_id)`, + + // Series Worlds + `CREATE TABLE IF NOT EXISTS series_worlds ( + world_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + hashed_name TEXT NOT NULL, + history TEXT, + politics TEXT, + economy TEXT, + religion TEXT, + languages TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS idx_series_worlds_series ON series_worlds(series_id)`, + `CREATE INDEX IF NOT EXISTS idx_series_worlds_user ON series_worlds(user_id)`, + + // Series World Elements + `CREATE TABLE IF NOT EXISTS series_world_elements ( + element_id TEXT PRIMARY KEY, + world_id TEXT NOT NULL, + user_id TEXT NOT NULL, + element_type INTEGER NOT NULL, + name TEXT NOT NULL, + original_name TEXT NOT NULL, + description TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (world_id) REFERENCES series_worlds(world_id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS idx_series_world_elements_world ON series_world_elements(world_id)`, + `CREATE INDEX IF NOT EXISTS idx_series_world_elements_user ON series_world_elements(user_id)`, + + // Series Locations + `CREATE TABLE IF NOT EXISTS series_locations ( + loc_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + loc_name TEXT NOT NULL, + loc_original_name TEXT NOT NULL, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS idx_series_locations_series ON series_locations(series_id)`, + `CREATE INDEX IF NOT EXISTS idx_series_locations_user ON series_locations(user_id)`, + + // Series Location Elements + `CREATE TABLE IF NOT EXISTS series_location_elements ( + element_id TEXT PRIMARY KEY, + location_id TEXT NOT NULL, + user_id TEXT NOT NULL, + element_name TEXT NOT NULL, + original_name TEXT NOT NULL, + element_description TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (location_id) REFERENCES series_locations(loc_id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS idx_series_loc_elements_location ON series_location_elements(location_id)`, + `CREATE INDEX IF NOT EXISTS idx_series_loc_elements_user ON series_location_elements(user_id)`, + + // Series Location Sub Elements + `CREATE TABLE IF NOT EXISTS series_location_sub_elements ( + sub_element_id TEXT PRIMARY KEY, + element_id TEXT NOT NULL, + user_id TEXT NOT NULL, + sub_elem_name TEXT NOT NULL, + original_name TEXT NOT NULL, + sub_elem_description TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (element_id) REFERENCES series_location_elements(element_id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS idx_series_loc_sub_elements_element ON series_location_sub_elements(element_id)`, + `CREATE INDEX IF NOT EXISTS idx_series_loc_sub_elements_user ON series_location_sub_elements(user_id)`, + + // Series Spells + `CREATE TABLE IF NOT EXISTS series_spells ( + spell_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + name_hash TEXT NOT NULL, + description TEXT, + appearance TEXT, + tags TEXT, + power_level TEXT, + components TEXT, + limitations TEXT, + notes TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS idx_series_spells_series ON series_spells(series_id)`, + `CREATE INDEX IF NOT EXISTS idx_series_spells_user ON series_spells(user_id)`, + + // Series Spell Tags + `CREATE TABLE IF NOT EXISTS series_spell_tags ( + tag_id TEXT PRIMARY KEY, + series_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + hashed_name TEXT NOT NULL, + color TEXT, + last_update INTEGER DEFAULT 0, + FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS idx_series_spell_tags_series ON series_spell_tags(series_id)`, + `CREATE INDEX IF NOT EXISTS idx_series_spell_tags_user ON series_spell_tags(user_id)`, + + // Add series_*_id columns to existing book tables (will fail silently if already exists) + `ALTER TABLE book_characters ADD COLUMN series_character_id TEXT DEFAULT NULL`, + `ALTER TABLE book_world ADD COLUMN series_world_id TEXT DEFAULT NULL`, + `ALTER TABLE book_location ADD COLUMN series_location_id TEXT DEFAULT NULL`, + `ALTER TABLE book_spells ADD COLUMN series_spell_id TEXT DEFAULT NULL`, + + // Removed Items (sync deletion tracking) + `CREATE TABLE IF NOT EXISTS removed_items ( + removal_id TEXT PRIMARY KEY, + table_name TEXT NOT NULL, + entity_id TEXT NOT NULL, + book_id TEXT, + user_id TEXT NOT NULL, + deleted_at INTEGER NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_removed_items_user ON removed_items(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_removed_items_book ON removed_items(book_id)`, + `CREATE INDEX IF NOT EXISTS idx_removed_items_deleted_at ON removed_items(deleted_at)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_removed_items_entity ON removed_items(table_name, entity_id)`, +]; const isDev:boolean = !app.isPackaged; @@ -123,9 +321,9 @@ function migrateFromOldSystem(db: Database): void { user_id TEXT NOT NULL, name TEXT NOT NULL, name_hash TEXT NOT NULL, - description TEXT NOT NULL, - appearance TEXT NOT NULL, - tags TEXT NOT NULL, + description TEXT, + appearance TEXT, + tags TEXT, power_level TEXT, components TEXT, limitations TEXT, @@ -172,7 +370,7 @@ function migrateFromOldSystem(db: Database): void { db.exec(`CREATE INDEX IF NOT EXISTS idx_series_loc_sub_elements_element ON series_location_sub_elements(element_id)`); db.exec(`CREATE INDEX IF NOT EXISTS idx_series_loc_sub_elements_user ON series_location_sub_elements(user_id)`); - db.exec(`CREATE TABLE IF NOT EXISTS series_spells (spell_id TEXT PRIMARY KEY, series_id TEXT NOT NULL, user_id TEXT NOT NULL, name TEXT NOT NULL, name_hash TEXT NOT NULL, description TEXT NOT NULL, appearance TEXT NOT NULL, tags TEXT, power_level TEXT, components TEXT, limitations TEXT, notes TEXT, last_update INTEGER DEFAULT 0, FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE);`); + db.exec(`CREATE TABLE IF NOT EXISTS series_spells (spell_id TEXT PRIMARY KEY, series_id TEXT NOT NULL, user_id TEXT NOT NULL, name TEXT NOT NULL, name_hash TEXT NOT NULL, description TEXT, appearance TEXT, tags TEXT, power_level TEXT, components TEXT, limitations TEXT, notes TEXT, last_update INTEGER DEFAULT 0, FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE);`); db.exec(`CREATE INDEX IF NOT EXISTS idx_series_spells_series ON series_spells(series_id)`); db.exec(`CREATE INDEX IF NOT EXISTS idx_series_spells_user ON series_spells(user_id)`); @@ -186,6 +384,23 @@ function migrateFromOldSystem(db: Database): void { addColumn(db, 'book_location', 'series_location_id', 'TEXT DEFAULT NULL'); addColumn(db, 'book_spells', 'series_spell_id', 'TEXT DEFAULT NULL'); + // Removed Items (sync deletion tracking) + db.exec(` + CREATE TABLE IF NOT EXISTS removed_items ( + removal_id TEXT PRIMARY KEY, + table_name TEXT NOT NULL, + entity_id TEXT NOT NULL, + book_id TEXT, + user_id TEXT NOT NULL, + deleted_at INTEGER NOT NULL, + removed_time INTEGER NOT NULL DEFAULT 0 + ) + `); + db.exec(`CREATE INDEX IF NOT EXISTS idx_removed_items_user ON removed_items(user_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_removed_items_book ON removed_items(book_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_removed_items_deleted_at ON removed_items(deleted_at)`); + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_removed_items_entity ON removed_items(table_name, entity_id)`); + // Drop old schema version table db.exec('DROP TABLE IF EXISTS _schema_version'); } @@ -255,9 +470,9 @@ export function runMigrations(db: Database): void { user_id TEXT NOT NULL, name TEXT NOT NULL, name_hash TEXT NOT NULL, - description TEXT NOT NULL, - appearance TEXT NOT NULL, - tags TEXT NOT NULL, + description TEXT, + appearance TEXT, + tags TEXT, power_level TEXT, components TEXT, limitations TEXT, @@ -333,7 +548,7 @@ export function runMigrations(db: Database): void { db.exec(`CREATE INDEX IF NOT EXISTS idx_series_loc_sub_elements_user ON series_location_sub_elements(user_id)`); // Series Spells - db.exec(`CREATE TABLE IF NOT EXISTS series_spells (spell_id TEXT PRIMARY KEY, series_id TEXT NOT NULL, user_id TEXT NOT NULL, name TEXT NOT NULL, name_hash TEXT NOT NULL, description TEXT NOT NULL, appearance TEXT NOT NULL, tags TEXT, power_level TEXT, components TEXT, limitations TEXT, notes TEXT, last_update INTEGER DEFAULT 0, FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE);`); + db.exec(`CREATE TABLE IF NOT EXISTS series_spells (spell_id TEXT PRIMARY KEY, series_id TEXT NOT NULL, user_id TEXT NOT NULL, name TEXT NOT NULL, name_hash TEXT NOT NULL, description TEXT, appearance TEXT, tags TEXT, power_level TEXT, components TEXT, limitations TEXT, notes TEXT, last_update INTEGER DEFAULT 0, FOREIGN KEY (series_id) REFERENCES book_series(series_id) ON DELETE CASCADE);`); db.exec(`CREATE INDEX IF NOT EXISTS idx_series_spells_series ON series_spells(series_id)`); db.exec(`CREATE INDEX IF NOT EXISTS idx_series_spells_user ON series_spells(user_id)`); @@ -347,6 +562,23 @@ export function runMigrations(db: Database): void { addColumn(db, 'book_world', 'series_world_id', 'TEXT DEFAULT NULL'); addColumn(db, 'book_location', 'series_location_id', 'TEXT DEFAULT NULL'); addColumn(db, 'book_spells', 'series_spell_id', 'TEXT DEFAULT NULL'); + + // Removed Items (sync deletion tracking) + db.exec(` + CREATE TABLE IF NOT EXISTS removed_items ( + removal_id TEXT PRIMARY KEY, + table_name TEXT NOT NULL, + entity_id TEXT NOT NULL, + book_id TEXT, + user_id TEXT NOT NULL, + deleted_at INTEGER NOT NULL, + removed_time INTEGER NOT NULL DEFAULT 0 + ) + `); + db.exec(`CREATE INDEX IF NOT EXISTS idx_removed_items_user ON removed_items(user_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_removed_items_book ON removed_items(book_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_removed_items_deleted_at ON removed_items(deleted_at)`); + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_removed_items_entity ON removed_items(table_name, entity_id)`); } setDbVersion(db, schemaVersion); @@ -808,9 +1040,9 @@ export function initializeSchema(db: Database): void { user_id TEXT NOT NULL, name TEXT NOT NULL, name_hash TEXT NOT NULL, - description TEXT NOT NULL, - appearance TEXT NOT NULL, - tags TEXT NOT NULL, + description TEXT, + appearance TEXT, + tags TEXT, power_level TEXT, components TEXT, limitations TEXT, @@ -978,8 +1210,8 @@ export function initializeSchema(db: Database): void { user_id TEXT NOT NULL, name TEXT NOT NULL, name_hash TEXT NOT NULL, - description TEXT NOT NULL, - appearance TEXT NOT NULL, + description TEXT, + appearance TEXT, tags TEXT, power_level TEXT, components TEXT, @@ -1004,6 +1236,19 @@ export function initializeSchema(db: Database): void { ); `); + // Removed Items (sync deletion tracking) + db.exec(` + CREATE TABLE IF NOT EXISTS removed_items ( + removal_id TEXT PRIMARY KEY, + table_name TEXT NOT NULL, + entity_id TEXT NOT NULL, + book_id TEXT, + user_id TEXT NOT NULL, + deleted_at INTEGER NOT NULL, + removed_time INTEGER NOT NULL DEFAULT 0 + ) + `); + // Create indexes for better performance createIndexes(db); } @@ -1049,6 +1294,11 @@ function createIndexes(db: Database): void { db.exec(`CREATE INDEX IF NOT EXISTS idx_series_spells_user ON series_spells(user_id)`); db.exec(`CREATE INDEX IF NOT EXISTS idx_series_spell_tags_series ON series_spell_tags(series_id)`); db.exec(`CREATE INDEX IF NOT EXISTS idx_series_spell_tags_user ON series_spell_tags(user_id)`); + // Removed items indexes + db.exec(`CREATE INDEX IF NOT EXISTS idx_removed_items_user ON removed_items(user_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_removed_items_book ON removed_items(book_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_removed_items_deleted_at ON removed_items(deleted_at)`); + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_removed_items_entity ON removed_items(table_name, entity_id)`); } /** diff --git a/electron/ipc/book.ipc.ts b/electron/ipc/book.ipc.ts index 317f4e9..937b669 100644 --- a/electron/ipc/book.ipc.ts +++ b/electron/ipc/book.ipc.ts @@ -270,11 +270,12 @@ ipcMain.handle( interface RemoveIncidentData { bookId: string; incidentId: string; + deletedAt: number; } ipcMain.handle('db:book:incident:remove', createHandler( function(userId: string, data: RemoveIncidentData, lang: 'fr' | 'en') { - return Incident.removeIncident(userId, data.bookId, data.incidentId, lang); + return Incident.removeIncident(userId, data.bookId, data.incidentId, data.deletedAt, lang); } ) ); @@ -297,12 +298,14 @@ ipcMain.handle('db:book:plot:add', createHandler( // DELETE /book/plot/remove - Remove plot point interface RemovePlotData { plotId: string; + bookId: string; + deletedAt: number; } ipcMain.handle( 'db:book:plot:remove', createHandler( function(userId: string, data: RemovePlotData, lang: 'fr' | 'en') { - return PlotPoint.removePlotPoint(userId, data.plotId, lang); + return PlotPoint.removePlotPoint(userId, data.bookId, data.plotId, data.deletedAt, lang); } ) ); @@ -319,10 +322,11 @@ ipcMain.handle('db:book:issue:add', createHandler( interface RemoveIssueData { bookId: string; issueId: string; + deletedAt: number; } ipcMain.handle('db:book:issue:remove', createHandler( function(userId: string, data: RemoveIssueData, lang: 'fr' | 'en') { - return Issue.removeIssue(userId, data.issueId, lang); + return Issue.removeIssue(userId, data.bookId, data.issueId, data.deletedAt, lang); } ) ); @@ -363,10 +367,12 @@ ipcMain.handle('db:book:world:element:add', createHandler( function(userId: string, data: RemoveWorldElementData, lang: 'fr' | 'en') { - return World.removeElementFromWorld(userId, data.elementId, lang); + return World.removeElementFromWorld(userId, data.bookId, data.elementId, data.deletedAt, lang); } ) ); @@ -374,10 +380,11 @@ ipcMain.handle('db:book:world:element:remove', createHandler( function(userId: string, data: DeleteBookData, lang: 'fr' | 'en') { - return Book.removeBook(userId, data.id, lang); + return Book.removeBook(userId, data.id, data.deletedAt, lang); } ) ); diff --git a/electron/ipc/chapter.ipc.ts b/electron/ipc/chapter.ipc.ts index 5c50f13..9de5e34 100644 --- a/electron/ipc/chapter.ipc.ts +++ b/electron/ipc/chapter.ipc.ts @@ -120,11 +120,12 @@ ipcMain.handle('db:chapter:add', createHandler( // DELETE /chapter/remove - Remove chapter interface RemoveChapterData { chapterId: string; - bookId?: string; + bookId: string; + deletedAt: number; } ipcMain.handle('db:chapter:remove', createHandler( function(userId: string, data: RemoveChapterData, lang: 'fr' | 'en'): boolean { - return Chapter.removeChapter(userId, data.chapterId, lang); + return Chapter.removeChapter(userId, data.bookId, data.chapterId, data.deletedAt, lang); } ) ); @@ -157,10 +158,12 @@ ipcMain.handle('db:chapter:information:add', createHandler( function(userId: string, data: RemoveChapterInfoData, lang: 'fr' | 'en'): boolean { - return Chapter.removeChapterInformation(userId, data.chapterInfoId, lang); + return Chapter.removeChapterInformation(userId, data.bookId, data.chapterInfoId, data.deletedAt, lang); } ) ); diff --git a/electron/ipc/character.ipc.ts b/electron/ipc/character.ipc.ts index deefa26..5534ba8 100644 --- a/electron/ipc/character.ipc.ts +++ b/electron/ipc/character.ipc.ts @@ -57,10 +57,12 @@ ipcMain.handle('db:character:attribute:add', createHandler( function(userId: string, data: DeleteAttributeData, lang: 'fr' | 'en'): boolean { - return Character.deleteAttribute(userId, data.attributeId, lang); + return Character.deleteAttribute(userId, data.bookId, data.attributeId, data.deletedAt, lang); } ) ); @@ -79,10 +81,12 @@ ipcMain.handle('db:character:update', createHandler( function(userId: string, data: DeleteCharacterData, lang: 'fr' | 'en'): boolean { - return Character.deleteCharacter(userId, data.characterId, lang); + return Character.deleteCharacter(userId, data.bookId, data.characterId, data.deletedAt, lang); } ) ); \ No newline at end of file diff --git a/electron/ipc/location.ipc.ts b/electron/ipc/location.ipc.ts index 24a447c..8824ceb 100644 --- a/electron/ipc/location.ipc.ts +++ b/electron/ipc/location.ipc.ts @@ -90,10 +90,12 @@ ipcMain.handle('db:location:section:update', createHandler( function(userId: string, data: DeleteLocationData, lang: 'fr' | 'en'): boolean { - return Location.deleteLocationSection(userId, data.locationId, lang); + return Location.deleteLocationSection(userId, data.bookId, data.locationId, data.deletedAt, lang); } ) ); @@ -101,10 +103,12 @@ ipcMain.handle('db:location:delete', createHandler( // DELETE /location/element/delete - Delete location element interface DeleteLocationElementData { elementId: string; + bookId: string; + deletedAt: number; } ipcMain.handle('db:location:element:delete', createHandler( function(userId: string, data: DeleteLocationElementData, lang: 'fr' | 'en'): boolean { - return Location.deleteLocationElement(userId, data.elementId, lang); + return Location.deleteLocationElement(userId, data.bookId, data.elementId, data.deletedAt, lang); } ) ); @@ -112,10 +116,12 @@ ipcMain.handle('db:location:element:delete', createHandler( function(userId: string, data: DeleteLocationSubElementData, lang: 'fr' | 'en'): boolean { - return Location.deleteLocationSubElement(userId, data.subElementId, lang); + return Location.deleteLocationSubElement(userId, data.bookId, data.subElementId, data.deletedAt, lang); } ) ); diff --git a/electron/ipc/series-character.ipc.ts b/electron/ipc/series-character.ipc.ts index cbaea36..a6b5c0f 100644 --- a/electron/ipc/series-character.ipc.ts +++ b/electron/ipc/series-character.ipc.ts @@ -21,6 +21,7 @@ interface UpdateCharacterData { interface DeleteCharacterData { characterId: string; + deletedAt: number; } interface AddAttributeData { @@ -31,6 +32,7 @@ interface AddAttributeData { interface DeleteAttributeData { attributeId: string; + deletedAt: number; } // GET /series/character/list - Get character list @@ -64,7 +66,7 @@ ipcMain.handle('db:series:character:update', createHandler( function(userId: string, data: DeleteCharacterData, lang: 'fr' | 'en'): boolean { - return SeriesCharacter.deleteCharacter(userId, data.characterId, lang); + return SeriesCharacter.deleteCharacter(userId, data.characterId, data.deletedAt, lang); } )); @@ -78,6 +80,6 @@ ipcMain.handle('db:series:character:attribute:add', createHandler( function(userId: string, data: DeleteAttributeData, lang: 'fr' | 'en'): boolean { - return SeriesCharacter.deleteAttribute(userId, data.attributeId, lang); + return SeriesCharacter.deleteAttribute(userId, data.attributeId, data.deletedAt, lang); } )); diff --git a/electron/ipc/series-location.ipc.ts b/electron/ipc/series-location.ipc.ts index 3f23d5f..c18480b 100644 --- a/electron/ipc/series-location.ipc.ts +++ b/electron/ipc/series-location.ipc.ts @@ -25,14 +25,17 @@ interface AddSubElementData { interface DeleteLocationData { locationId: string; + deletedAt: number; } interface DeleteElementData { elementId: string; + deletedAt: number; } interface DeleteSubElementData { subElementId: string; + deletedAt: number; } // GET /series/location/list - Get location list @@ -66,20 +69,20 @@ ipcMain.handle('db:series:location:subelement:add', createHandler( function(userId: string, data: DeleteLocationData, lang: 'fr' | 'en'): boolean { - return SeriesLocation.deleteLocation(userId, data.locationId, lang); + return SeriesLocation.deleteLocation(userId, data.locationId, data.deletedAt, lang); } )); // DELETE /series/location/element/delete - Delete element ipcMain.handle('db:series:location:element:delete', createHandler( function(userId: string, data: DeleteElementData, lang: 'fr' | 'en'): boolean { - return SeriesLocation.deleteElement(userId, data.elementId, lang); + return SeriesLocation.deleteElement(userId, data.elementId, data.deletedAt, lang); } )); // DELETE /series/location/sub-element/delete - Delete sub-element ipcMain.handle('db:series:location:subelement:delete', createHandler( function(userId: string, data: DeleteSubElementData, lang: 'fr' | 'en'): boolean { - return SeriesLocation.deleteSubElement(userId, data.subElementId, lang); + return SeriesLocation.deleteSubElement(userId, data.subElementId, data.deletedAt, lang); } )); diff --git a/electron/ipc/series-spell.ipc.ts b/electron/ipc/series-spell.ipc.ts index 2bf2db3..aeb6e10 100644 --- a/electron/ipc/series-spell.ipc.ts +++ b/electron/ipc/series-spell.ipc.ts @@ -36,6 +36,7 @@ interface UpdateSpellData { interface DeleteSpellData { spellId: string; + deletedAt: number; } interface AddTagData { @@ -52,6 +53,7 @@ interface UpdateTagData { interface DeleteTagData { tagId: string; + deletedAt: number; } // GET /series/spell/list - Get spell list @@ -85,7 +87,7 @@ ipcMain.handle('db:series:spell:update', createHandler // DELETE /series/spell/delete - Delete spell ipcMain.handle('db:series:spell:delete', createHandler( function(userId: string, data: DeleteSpellData, lang: 'fr' | 'en'): boolean { - return SeriesSpell.deleteSpell(userId, data.spellId, lang); + return SeriesSpell.deleteSpell(userId, data.spellId, data.deletedAt, lang); } )); @@ -106,6 +108,6 @@ ipcMain.handle('db:series:spell:tag:update', createHandler( function(userId: string, data: DeleteTagData, lang: 'fr' | 'en'): boolean { - return SeriesSpell.deleteTag(userId, data.tagId, lang); + return SeriesSpell.deleteTag(userId, data.tagId, data.deletedAt, lang); } )); diff --git a/electron/ipc/series-sync.ipc.ts b/electron/ipc/series-sync.ipc.ts index ecb8891..172e3db 100644 --- a/electron/ipc/series-sync.ipc.ts +++ b/electron/ipc/series-sync.ipc.ts @@ -1,6 +1,6 @@ import { ipcMain } from 'electron'; import { createHandler } from '../database/LocalSystem.js'; -import SeriesSync, { SeriesSyncUploadPayload, SeriesSyncResult } from '../database/models/SeriesSync.js'; +import SeriesSync, { SeriesSyncUploadPayload, SeriesSyncResult, CompleteSeries, SyncedSeries } from '../database/models/SeriesSync.js'; import { SyncElementType } from '../database/repositories/series-sync.repo.js'; interface UploadToSeriesData { @@ -10,7 +10,6 @@ interface UploadToSeriesData { value: string; } -// POST /series/sync/upload - Upload field to series ipcMain.handle('db:series:sync:upload', createHandler( function(userId: string, data: UploadToSeriesData, lang: 'fr' | 'en'): SeriesSyncResult { const payload: SeriesSyncUploadPayload = { @@ -22,3 +21,34 @@ ipcMain.handle('db:series:sync:upload', createHandler( + function(userId: string, _data: void, lang: 'fr' | 'en'): SyncedSeries[] { + return SeriesSync.getSyncedSeries(userId, lang); + } +)); + +ipcMain.handle('db:series:uploadToServer', createHandler( + async function(userId: string, seriesId: string, lang: 'fr' | 'en'): Promise { + return SeriesSync.getCompleteSeriesForUpload(userId, seriesId, lang); + } +)); + +ipcMain.handle('db:series:syncSave', createHandler( + async function(userId: string, completeSeries: CompleteSeries, lang: 'fr' | 'en'): Promise { + return SeriesSync.saveCompleteSeries(userId, completeSeries, lang); + } +)); + +ipcMain.handle('db:series:sync:toClient', createHandler( + async function(userId: string, completeSeries: CompleteSeries, lang: 'fr' | 'en'): Promise { + return SeriesSync.syncSeriesFromServerToClient(userId, completeSeries, lang); + } +)); + +ipcMain.handle('db:series:sync:toServer', createHandler( + async function(userId: string, syncCompare: object, lang: 'fr' | 'en'): Promise { + const seriesId = (syncCompare as { id: string }).id; + return SeriesSync.getCompleteSeriesForUpload(userId, seriesId, lang); + } +)); diff --git a/electron/ipc/series-world.ipc.ts b/electron/ipc/series-world.ipc.ts index 2323a95..580fd03 100644 --- a/electron/ipc/series-world.ipc.ts +++ b/electron/ipc/series-world.ipc.ts @@ -30,6 +30,7 @@ interface AddElementData { interface DeleteElementData { elementId: string; + deletedAt: number; } // GET /series/world/list - Get world list @@ -71,6 +72,6 @@ ipcMain.handle('db:series:world:element:add', createHandler( function(userId: string, data: DeleteElementData, lang: 'fr' | 'en'): boolean { - return SeriesWorld.deleteElement(userId, data.elementId, lang); + return SeriesWorld.deleteElement(userId, data.elementId, data.deletedAt, lang); } )); diff --git a/electron/ipc/series.ipc.ts b/electron/ipc/series.ipc.ts index 8f20e89..d03affe 100644 --- a/electron/ipc/series.ipc.ts +++ b/electron/ipc/series.ipc.ts @@ -16,6 +16,7 @@ interface UpdateSeriesData { interface DeleteSeriesData { seriesId: string; + deletedAt: number; } interface GetSeriesDetailData { @@ -31,6 +32,7 @@ interface AddBookToSeriesData { interface RemoveBookFromSeriesData { seriesId: string; bookId: string; + deletedAt: number; } interface UpdateBooksOrderData { @@ -77,7 +79,7 @@ ipcMain.handle('db:series:update', createHandler( // DELETE /series/delete - Delete series ipcMain.handle('db:series:delete', createHandler( async function(userId: string, data: DeleteSeriesData, lang: 'fr' | 'en'): Promise { - return await Series.deleteSeries(userId, data.seriesId, lang); + return await Series.deleteSeries(userId, data.seriesId, data.deletedAt, lang); } )); @@ -98,7 +100,7 @@ ipcMain.handle('db:series:book:add', createHandler // DELETE /series/book/remove - Remove book from series ipcMain.handle('db:series:book:remove', createHandler( async function(userId: string, data: RemoveBookFromSeriesData, lang: 'fr' | 'en'): Promise { - return await Series.removeBookFromSeries(userId, data.seriesId, data.bookId, lang); + return await Series.removeBookFromSeries(userId, data.seriesId, data.bookId, data.deletedAt, lang); } )); diff --git a/electron/ipc/spell.ipc.ts b/electron/ipc/spell.ipc.ts index 27693d0..8716359 100644 --- a/electron/ipc/spell.ipc.ts +++ b/electron/ipc/spell.ipc.ts @@ -46,6 +46,8 @@ interface UpdateSpellData { interface DeleteSpellData { spellId: string; + bookId: string; + deletedAt: number; } interface CreateTagData { @@ -63,6 +65,7 @@ interface UpdateTagData { interface DeleteTagData { tagId: string; bookId: string; + deletedAt: number; } // ==================== SPELL HANDLERS ==================== @@ -152,7 +155,7 @@ ipcMain.handle( 'db:spell:delete', createHandler( function (userId: string, data: DeleteSpellData, lang: 'fr' | 'en'): boolean { - return Spell.deleteSpell(userId, data.spellId, lang); + return Spell.deleteSpell(userId, data.bookId, data.spellId, data.deletedAt, lang); }, ), ); @@ -198,7 +201,7 @@ ipcMain.handle( 'db:spell:tag:delete', createHandler( function (userId: string, data: DeleteTagData, lang: 'fr' | 'en'): boolean { - return Spell.deleteSpellTag(userId, data.tagId, data.bookId, lang); + return Spell.deleteSpellTag(userId, data.bookId, data.tagId, data.deletedAt, lang); }, ), ); diff --git a/electron/ipc/tombstone.ipc.ts b/electron/ipc/tombstone.ipc.ts new file mode 100644 index 0000000..87e41f8 --- /dev/null +++ b/electron/ipc/tombstone.ipc.ts @@ -0,0 +1,122 @@ +import { ipcMain } from 'electron'; +import { createHandler } from '../database/LocalSystem.js'; +import RemovedItemsRepository, { RemovedItemRecord } from '../database/repositories/removed-items.repository.js'; +import Book from '../database/models/Book.js'; +import Chapter from '../database/models/Chapter.js'; +import Character from '../database/models/Character.js'; +import Location from '../database/models/Location.js'; +import World from '../database/models/World.js'; +import Incident from '../database/models/Incident.js'; +import PlotPoint from '../database/models/PlotPoint.js'; +import Issue from '../database/models/Issue.js'; +import Spell from '../database/models/Spell.js'; +import Series from '../database/models/Series.js'; +import SeriesCharacter from '../database/models/SeriesCharacter.js'; +import SeriesLocation from '../database/models/SeriesLocation.js'; +import SeriesWorld from '../database/models/SeriesWorld.js'; +import SeriesSpell from '../database/models/SeriesSpell.js'; + +/** + * Get tombstones since a specific timestamp. + */ +ipcMain.handle('db:tombstones:since', createHandler( + function(userId: string, since: number, lang: 'fr' | 'en'): RemovedItemRecord[] { + return RemovedItemsRepository.getDeletionsSince(userId, since, lang); + }) +); + +/** + * Apply server tombstones for book entities locally. + */ +ipcMain.handle('db:tombstones:apply:books', createHandler( + function(userId: string, tombstones: RemovedItemRecord[], lang: 'fr' | 'en'): void { + for (const tombstone of tombstones) { + switch (tombstone.table_name) { + case 'erit_books': + Book.removeBook(userId, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'book_chapters': + Chapter.removeChapter(userId, tombstone.book_id!, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'book_chapter_infos': + Chapter.removeChapterInformation(userId, tombstone.book_id!, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'book_characters': + Character.deleteCharacter(userId, tombstone.book_id!, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'book_characters_attributes': + Character.deleteAttribute(userId, tombstone.book_id!, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'book_location': + Location.deleteLocationSection(userId, tombstone.book_id!, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'location_element': + Location.deleteLocationElement(userId, tombstone.book_id!, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'location_sub_element': + Location.deleteLocationSubElement(userId, tombstone.book_id!, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'book_world_elements': + World.removeElementFromWorld(userId, tombstone.book_id!, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'book_incidents': + Incident.removeIncident(userId, tombstone.book_id!, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'book_plot_points': + PlotPoint.removePlotPoint(userId, tombstone.book_id!, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'book_issues': + Issue.removeIssue(userId, tombstone.book_id!, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'book_spells': + Spell.deleteSpell(userId, tombstone.book_id!, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'book_spell_tags': + Spell.deleteSpellTag(userId, tombstone.book_id!, tombstone.entity_id, tombstone.deleted_at, lang); + break; + } + } + }) +); + +/** + * Apply server tombstones for series entities locally. + */ +ipcMain.handle('db:tombstones:apply:series', createHandler( + function(userId: string, tombstones: RemovedItemRecord[], lang: 'fr' | 'en'): void { + for (const tombstone of tombstones) { + switch (tombstone.table_name) { + case 'erit_series': + Series.deleteSeries(userId, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'series_books': + Series.removeBookFromSeries(userId, tombstone.book_id!, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'series_characters': + SeriesCharacter.deleteCharacter(userId, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'series_characters_attributes': + SeriesCharacter.deleteAttribute(userId, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'series_locations': + SeriesLocation.deleteLocation(userId, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'series_location_elements': + SeriesLocation.deleteElement(userId, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'series_location_sub_elements': + SeriesLocation.deleteSubElement(userId, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'series_world_elements': + SeriesWorld.deleteElement(userId, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'series_spells': + SeriesSpell.deleteSpell(userId, tombstone.entity_id, tombstone.deleted_at, lang); + break; + case 'series_spell_tags': + SeriesSpell.deleteTag(userId, tombstone.entity_id, tombstone.deleted_at, lang); + break; + } + } + }) +); diff --git a/electron/main.ts b/electron/main.ts index a44668e..b2763a5 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -16,6 +16,13 @@ import './ipc/character.ipc.js'; import './ipc/location.ipc.js'; import './ipc/offline.ipc.js'; import './ipc/spell.ipc.js'; +import './ipc/series.ipc.js'; +import './ipc/series-sync.ipc.js'; +import './ipc/series-character.ipc.js'; +import './ipc/series-location.ipc.js'; +import './ipc/series-world.ipc.js'; +import './ipc/series-spell.ipc.js'; +import './ipc/tombstone.ipc.js'; // Fix pour __dirname en ES modules const __filename = fileURLToPath(import.meta.url); diff --git a/hooks/settings/useCharacters.ts b/hooks/settings/useCharacters.ts new file mode 100644 index 0000000..4992444 --- /dev/null +++ b/hooks/settings/useCharacters.ts @@ -0,0 +1,975 @@ +'use client' +import {useCallback, useContext, useEffect, useState} from 'react'; +import {Attribute, CharacterListResponse, CharacterProps} from '@/lib/models/Character'; +import {SeriesCharacterProps} from '@/lib/models/Series'; +import {SessionContext} from '@/context/SessionContext'; +import {BookContext} from '@/context/BookContext'; +import {AlertContext} from '@/context/AlertContext'; +import {LangContext, LangContextProps} from '@/context/LangContext'; +import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; +import {LocalSyncQueueContext, LocalSyncQueueContextProps} from '@/context/SyncQueueContext'; +import {BooksSyncContext, BooksSyncContextProps} from '@/context/BooksSyncContext'; +import {SyncedBook} from '@/lib/models/SyncedBook'; +import {SeriesContext, SeriesContextProps} from '@/context/SeriesContext'; +import {SeriesSyncContext, SeriesSyncContextProps} from '@/context/SeriesSyncContext'; +import {SyncedSeries} from '@/lib/models/SyncedSeries'; +import System from '@/lib/models/System'; +import {useTranslations} from 'next-intl'; +import {ViewMode} from '@/shared/interface'; + +const initialCharacterState: CharacterProps = { + id: null, + name: '', + lastName: '', + nickname: '', + age: null, + gender: '', + species: '', + nationality: '', + status: 'alive', + category: 'none', + title: '', + role: '', + image: 'https://via.placeholder.com/150', + biography: '', + history: '', + speechPattern: '', + catchphrase: '', + residence: '', + notes: '', + color: '', + physical: [], + psychological: [], + relations: [], + skills: [], + weaknesses: [], + strengths: [], + goals: [], + motivations: [], + arc: [], + secrets: [], + fears: [], + flaws: [], + beliefs: [], + conflicts: [], + quotes: [], + distinguishingMarks: [], + items: [], + affiliations: [], +}; + +export interface UseCharactersConfig { + entityType: 'book' | 'series'; + entityId: string; +} + +export interface UseCharactersReturn { + // State + characters: CharacterProps[]; + seriesCharacters: SeriesCharacterProps[]; + selectedCharacter: CharacterProps | null; + toolEnabled: boolean; + isLoading: boolean; + isSeriesMode: boolean; + bookSeriesId: string | null; + + // Navigation state + viewMode: ViewMode; + characterBackup: CharacterProps | null; + + // Actions + selectCharacter: (character: CharacterProps) => void; + addNewCharacter: () => void; + clearSelection: () => void; + saveCharacter: () => Promise; + deleteCharacter: (characterId: string) => Promise; + updateCharacterField: (key: keyof CharacterProps, value: string | number | null) => void; + addAttribute: (section: keyof CharacterProps, value: Attribute) => Promise; + removeAttribute: (section: keyof CharacterProps, index: number, attrId: string) => Promise; + toggleTool: (enabled: boolean) => Promise; + importFromSeries: (seriesCharacterId: string) => Promise; + exportToSeries: () => Promise; + refreshCharacters: () => Promise; + refreshSeriesCharacters: () => Promise; + setSelectedCharacter: React.Dispatch>; + + // Navigation actions + enterDetailMode: (character: CharacterProps) => void; + enterEditMode: () => void; + exitEditMode: (save: boolean) => Promise; + backToList: () => void; +} + +export function useCharacters(config: UseCharactersConfig): UseCharactersReturn { + const {entityType, entityId} = config; + const t = useTranslations(); + const {lang} = useContext(LangContext); + const {session} = useContext(SessionContext); + const {book, setBook} = useContext(BookContext); + const {errorMessage, successMessage} = useContext(AlertContext); + const {isCurrentlyOffline} = useContext(OfflineContext); + const {addToQueue} = useContext(LocalSyncQueueContext); + const {localSyncedBooks} = useContext(BooksSyncContext); + const {localSeries} = useContext(SeriesContext); + const {localSyncedSeries} = useContext(SeriesSyncContext); + + const [characters, setCharacters] = useState([]); + const [seriesCharacters, setSeriesCharacters] = useState([]); + const [selectedCharacter, setSelectedCharacter] = useState(null); + const [toolEnabled, setToolEnabled] = useState(entityType === 'series' || (book?.tools?.characters ?? false)); + const [isLoading, setIsLoading] = useState(true); + + // Navigation state + const [viewMode, setViewMode] = useState('list'); + const [characterBackup, setCharacterBackup] = useState(null); + + const isSeriesMode: boolean = entityType === 'series'; + const bookSeriesId: string | null = book?.seriesId || null; + const userToken: string = session?.accessToken || ''; + + // Load characters on mount + useEffect(function (): void { + if (entityId) { + refreshCharacters(); + } + }, [entityId]); + + // Load series characters for book mode + useEffect(function (): void { + if (bookSeriesId && !isSeriesMode) { + refreshSeriesCharacters(); + } + }, [bookSeriesId, isSeriesMode]); + + const refreshSeriesCharacters = useCallback(async function (): Promise { + if (!bookSeriesId) return; + try { + let response: SeriesCharacterProps[]; + // Dual logic: offline ou livre local → IPC, sinon serveur + if (isCurrentlyOffline() || book?.localBook) { + response = await window.electron.invoke( + 'db:series:character:list', + {seriesId: bookSeriesId} + ); + } else { + response = await System.authGetQueryToServer( + 'series/character/list', + userToken, + lang, + {seriesid: bookSeriesId} + ); + } + if (response) { + setSeriesCharacters(response); + } + } catch (e: unknown) { + if (e instanceof Error) { + console.error('Error loading series characters:', e.message); + } + } + }, [bookSeriesId, userToken, lang, isCurrentlyOffline, book?.localBook]); + + const refreshCharacters = useCallback(async function (): Promise { + setIsLoading(true); + try { + if (isSeriesMode) { + // Series mode - dual logic + let response: SeriesCharacterProps[]; + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke( + 'db:series:character:list', + {seriesId: entityId} + ); + } else { + response = await System.authGetQueryToServer( + 'series/character/list', + userToken, + lang, + {seriesid: entityId} + ); + } + if (response) { + const mappedCharacters: CharacterProps[] = response.map(function (char: SeriesCharacterProps): CharacterProps { + return { + id: char.id, + name: char.name, + lastName: char.lastName || '', + nickname: '', + age: char.age ?? null, + gender: '', + species: '', + nationality: '', + status: 'alive' as const, + category: char.category as CharacterProps['category'], + title: '', + role: char.role || '', + image: char.image || 'https://via.placeholder.com/150', + color: char.color || '', + physical: [], + psychological: [], + relations: [], + skills: [], + weaknesses: [], + strengths: [], + goals: [], + motivations: [], + arc: [], + secrets: [], + fears: [], + flaws: [], + beliefs: [], + conflicts: [], + quotes: [], + distinguishingMarks: [], + items: [], + affiliations: [], + }; + }); + setCharacters(mappedCharacters); + } + } else { + // Pattern B: GET dans contexte livre + let response: CharacterListResponse; + if (isCurrentlyOffline()) { + // Offline → IPC + response = await window.electron.invoke('db:character:list', {bookid: entityId}); + } else if (book?.localBook) { + // Online mais livre local → IPC + response = await window.electron.invoke('db:character:list', {bookid: entityId}); + } else { + // Online + livre serveur → Server + response = await System.authGetQueryToServer( + 'character/list', + userToken, + lang, + {bookid: entityId} + ); + } + if (response) { + setCharacters(response.characters); + setToolEnabled(response.enabled); + if (setBook && book) { + setBook({ + ...book, + tools: { + characters: response.enabled, + worlds: book.tools?.worlds ?? false, + locations: book.tools?.locations ?? false, + spells: book.tools?.spells ?? false + } + }); + } + } + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("common.unknownError")); + } + } finally { + setIsLoading(false); + } + }, [entityId, isSeriesMode, userToken, lang, book, setBook, errorMessage, t, isCurrentlyOffline]); + + const selectCharacter = useCallback(function (character: CharacterProps): void { + setSelectedCharacter({...character}); + }, []); + + const addNewCharacter = useCallback(function (): void { + setSelectedCharacter({...initialCharacterState}); + setViewMode('edit'); + setCharacterBackup(null); + }, []); + + const clearSelection = useCallback(function (): void { + setSelectedCharacter(null); + setViewMode('list'); + setCharacterBackup(null); + }, []); + + const updateCharacterField = useCallback(function (key: keyof CharacterProps, value: string | number | null): void { + setSelectedCharacter(function (prev: CharacterProps | null): CharacterProps | null { + if (!prev) return null; + return {...prev, [key]: value}; + }); + }, []); + + const toggleTool = useCallback(async function (enabled: boolean): Promise { + if (isSeriesMode) return; + try { + const requestData = { + bookId: book?.bookId, + toolName: 'characters', + enabled: enabled + }; + + let response: boolean; + if (isCurrentlyOffline() || book?.localBook) { + // Offline OU livre local → IPC + response = await window.electron.invoke('db:book:tool:update', requestData); + } else { + // Online + livre serveur → Server + response = await System.authPatchToServer('book/tool-setting', requestData, userToken, lang); + + // Si le livre a une copie locale → addToQueue pour sync + if (book?.bookId && localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === book.bookId)) { + addToQueue('db:book:tool:update', requestData); + } + } + + if (response && setBook && book) { + setToolEnabled(enabled); + setBook({ + ...book, + tools: { + characters: enabled, + worlds: book.tools?.worlds ?? false, + locations: book.tools?.locations ?? false, + spells: book.tools?.spells ?? false + } + }); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + }, [isSeriesMode, book, setBook, userToken, lang, errorMessage, isCurrentlyOffline, addToQueue, localSyncedBooks]); + + const saveCharacter = useCallback(async function (): Promise { + if (!selectedCharacter) return false; + + if (selectedCharacter.id === null) { + return await addCharacterInternal(selectedCharacter); + } else { + return await updateCharacterInternal(selectedCharacter); + } + }, [selectedCharacter]); + + async function addCharacterInternal(character: CharacterProps): Promise { + if (!character.name) { + errorMessage(t("characterComponent.errorNameRequired")); + return false; + } + if (character.category === 'none') { + errorMessage(t("characterComponent.errorCategoryRequired")); + return false; + } + try { + let characterId: string; + if (isSeriesMode) { + // Series mode - dual logic + const seriesCharacterData = { + seriesId: entityId, + character: { + name: character.name, + lastName: character.lastName || null, + nickname: character.nickname || null, + age: character.age || null, + gender: character.gender || null, + species: character.species || null, + nationality: character.nationality || null, + status: character.status || null, + category: character.category, + title: character.title || null, + image: character.image || null, + role: character.role || null, + biography: character.biography || null, + history: character.history || null, + speechPattern: character.speechPattern || null, + catchphrase: character.catchphrase || null, + residence: character.residence || null, + notes: character.notes || null, + color: character.color || null, + } + }; + if (isCurrentlyOffline() || localSeries) { + characterId = await window.electron.invoke('db:series:character:add', seriesCharacterData); + } else { + characterId = await System.authPostToServer( + 'series/character/add', + seriesCharacterData, + userToken, + lang + ); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:character:add', {...seriesCharacterData, id: characterId}); + } + } + } else { + // Pattern A: mutations + const requestData = { + bookId: entityId, + character: character, + }; + + if (isCurrentlyOffline() || book?.localBook) { + // Offline OU livre local → IPC + characterId = await window.electron.invoke('db:character:create', requestData); + } else { + // Online + livre serveur → Server + characterId = await System.authPostToServer('character/add', requestData, userToken, lang); + + // Si le livre a une copie locale → addToQueue pour sync + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:character:create', {...requestData, id: characterId}); + } + } + } + + if (!characterId) { + errorMessage(t("characterComponent.errorAddCharacter")); + return false; + } + + const newCharacter: CharacterProps = {...character, id: characterId}; + setCharacters(function (prev: CharacterProps[]): CharacterProps[] { + return [...prev, newCharacter]; + }); + setSelectedCharacter(null); + return true; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("common.unknownError")); + } + return false; + } + } + + async function updateCharacterInternal(character: CharacterProps): Promise { + try { + let response: boolean; + if (isSeriesMode) { + // Series mode - dual logic + const updateData = { + characterId: character.id, + character: { + id: character.id, + name: character.name, + lastName: character.lastName || null, + nickname: character.nickname || null, + age: character.age || null, + gender: character.gender || null, + species: character.species || null, + nationality: character.nationality || null, + status: character.status || null, + category: character.category, + title: character.title || null, + image: character.image || null, + role: character.role || null, + biography: character.biography || null, + history: character.history || null, + speechPattern: character.speechPattern || null, + catchphrase: character.catchphrase || null, + residence: character.residence || null, + notes: character.notes || null, + color: character.color || null, + } + }; + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke('db:series:character:update', updateData); + } else { + response = await System.authPatchToServer('series/character/update', updateData, userToken, lang); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:character:update', updateData); + } + } + } else { + // Pattern A: mutations + const requestData = { + character: character, + }; + + if (isCurrentlyOffline() || book?.localBook) { + // Offline OU livre local → IPC + response = await window.electron.invoke('db:character:update', requestData); + } else { + // Online + livre serveur → Server + response = await System.authPostToServer('character/update', requestData, userToken, lang); + + // Si le livre a une copie locale → addToQueue pour sync + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:character:update', requestData); + } + } + } + + if (!response) { + errorMessage(t("characterComponent.errorUpdateCharacter")); + return false; + } + + setCharacters(function (prev: CharacterProps[]): CharacterProps[] { + return prev.map(function (c: CharacterProps): CharacterProps { + return c.id === character.id ? character : c; + }); + }); + setSelectedCharacter(null); + successMessage(t("characterComponent.successUpdate")); + return true; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("common.unknownError")); + } + return false; + } + } + + const deleteCharacter = useCallback(async function (characterId: string): Promise { + try { + let response: boolean; + const requestData = {characterId: characterId}; + + if (isSeriesMode) { + // Series mode - dual logic + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke('db:series:character:delete', requestData); + } else { + response = await System.authDeleteToServer('series/character/delete', requestData, userToken, lang); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:character:delete', requestData); + } + } + } else { + // Pattern A: mutations + if (isCurrentlyOffline() || book?.localBook) { + // Offline OU livre local → IPC + response = await window.electron.invoke('db:character:delete', requestData); + } else { + // Online + livre serveur → Server + response = await System.authDeleteToServer('character/delete', requestData, userToken, lang); + + // Si le livre a une copie locale → addToQueue pour sync + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:character:delete', requestData); + } + } + } + + if (!response) { + errorMessage(t("characterComponent.errorDeleteCharacter")); + return; + } + + setCharacters(function (prev: CharacterProps[]): CharacterProps[] { + return prev.filter(function (c: CharacterProps): boolean { + return c.id !== characterId; + }); + }); + setSelectedCharacter(null); + successMessage(t("characterComponent.successDelete")); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("common.unknownError")); + } + } + }, [isSeriesMode, userToken, lang, errorMessage, successMessage, t, isCurrentlyOffline, book, addToQueue, localSyncedBooks, entityId]); + + const addAttribute = useCallback(async function (section: keyof CharacterProps, value: Attribute): Promise { + if (!selectedCharacter) return; + + if (selectedCharacter.id === null) { + const updatedSection: Attribute[] = [ + ...(selectedCharacter[section] as Attribute[]), + value, + ]; + setSelectedCharacter({...selectedCharacter, [section]: updatedSection}); + } else { + try { + const requestData = { + characterId: selectedCharacter.id, + type: section, + name: value.name, + }; + + let attributeId: string; + if (isSeriesMode) { + // Series mode - dual logic + if (isCurrentlyOffline() || localSeries) { + attributeId = await window.electron.invoke('db:series:character:attribute:add', requestData); + } else { + attributeId = await System.authPostToServer('series/character/attribute/add', requestData, userToken, lang); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:character:attribute:add', {...requestData, id: attributeId}); + } + } + } else { + // Pattern A: mutations + if (isCurrentlyOffline() || book?.localBook) { + // Offline OU livre local → IPC + attributeId = await window.electron.invoke('db:character:attribute:add', requestData); + } else { + // Online + livre serveur → Server + attributeId = await System.authPostToServer('character/attribute/add', requestData, userToken, lang); + + // Si le livre a une copie locale → addToQueue pour sync + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:character:attribute:add', {...requestData, id: attributeId}); + } + } + } + + if (!attributeId) { + errorMessage(t("characterComponent.errorAddAttribute")); + return; + } + + const newValue: Attribute = { + name: value.name, + id: attributeId, + }; + const updatedSection: Attribute[] = [...(selectedCharacter[section] as Attribute[]), newValue]; + setSelectedCharacter({...selectedCharacter, [section]: updatedSection}); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("common.unknownError")); + } + } + } + }, [selectedCharacter, isSeriesMode, userToken, lang, errorMessage, t, isCurrentlyOffline, book, addToQueue, localSyncedBooks, entityId]); + + const removeAttribute = useCallback(async function (section: keyof CharacterProps, index: number, attrId: string): Promise { + if (!selectedCharacter) return; + + if (selectedCharacter.id === null) { + const updatedSection: Attribute[] = ( + selectedCharacter[section] as Attribute[] + ).filter(function (_: Attribute, i: number): boolean { + return i !== index; + }); + setSelectedCharacter({...selectedCharacter, [section]: updatedSection}); + } else { + try { + const requestData = {attributeId: attrId}; + + let response: boolean; + if (isSeriesMode) { + // Series mode - dual logic + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke('db:series:character:attribute:delete', requestData); + } else { + response = await System.authDeleteToServer('series/character/attribute/delete', requestData, userToken, lang); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:character:attribute:delete', requestData); + } + } + } else { + // Pattern A: mutations + if (isCurrentlyOffline() || book?.localBook) { + // Offline OU livre local → IPC + response = await window.electron.invoke('db:character:attribute:delete', requestData); + } else { + // Online + livre serveur → Server + response = await System.authDeleteToServer('character/attribute/delete', requestData, userToken, lang); + + // Si le livre a une copie locale → addToQueue pour sync + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:character:attribute:delete', requestData); + } + } + } + + if (!response) { + errorMessage(t("characterComponent.errorRemoveAttribute")); + return; + } + + const updatedSection: Attribute[] = ( + selectedCharacter[section] as Attribute[] + ).filter(function (_: Attribute, i: number): boolean { + return i !== index; + }); + setSelectedCharacter({...selectedCharacter, [section]: updatedSection}); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("common.unknownError")); + } + } + } + }, [selectedCharacter, isSeriesMode, userToken, lang, errorMessage, t, isCurrentlyOffline, book, addToQueue, localSyncedBooks, entityId]); + + const exportToSeries = useCallback(async function (): Promise { + if (!selectedCharacter || !bookSeriesId) return; + + try { + const seriesCharacterData = { + seriesId: bookSeriesId, + character: { + name: selectedCharacter.name, + lastName: selectedCharacter.lastName || null, + nickname: selectedCharacter.nickname || null, + age: selectedCharacter.age || null, + gender: selectedCharacter.gender || null, + species: selectedCharacter.species || null, + nationality: selectedCharacter.nationality || null, + status: selectedCharacter.status || null, + category: selectedCharacter.category, + title: selectedCharacter.title || null, + image: selectedCharacter.image || null, + role: selectedCharacter.role || null, + biography: selectedCharacter.biography || null, + history: selectedCharacter.history || null, + speechPattern: selectedCharacter.speechPattern || null, + catchphrase: selectedCharacter.catchphrase || null, + residence: selectedCharacter.residence || null, + notes: selectedCharacter.notes || null, + color: selectedCharacter.color || null, + } + }; + + let seriesCharacterId: string; + if (isCurrentlyOffline() || book?.localBook) { + // Mode offline ou livre local → IPC + seriesCharacterId = await window.electron.invoke('db:series:character:add', seriesCharacterData); + } else { + // Mode online → Serveur + seriesCharacterId = await System.authPostToServer( + 'series/character/add', + seriesCharacterData, + userToken, + lang + ); + // Si la série a une copie locale → addToQueue + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === bookSeriesId)) { + addToQueue('db:series:character:add', {...seriesCharacterData, id: seriesCharacterId}); + } + } + + if (seriesCharacterId) { + const updateData = { + character: { + ...selectedCharacter, + seriesCharacterId: seriesCharacterId + }, + }; + + let updateResponse: boolean; + if (isCurrentlyOffline() || book?.localBook) { + // Mode offline ou livre local → IPC + updateResponse = await window.electron.invoke('db:character:update', updateData); + } else { + // Mode online → Serveur + updateResponse = await System.authPostToServer('character/update', updateData, userToken, lang); + // Si le livre a une copie locale → addToQueue + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:character:update', updateData); + } + } + + if (updateResponse) { + setSelectedCharacter({...selectedCharacter, seriesCharacterId: seriesCharacterId}); + setCharacters(function (prev: CharacterProps[]): CharacterProps[] { + return prev.map(function (c: CharacterProps): CharacterProps { + return c.id === selectedCharacter.id ? {...c, seriesCharacterId: seriesCharacterId} : c; + }); + }); + const newSeriesCharacter: SeriesCharacterProps = { + id: seriesCharacterId, + name: selectedCharacter.name, + lastName: selectedCharacter.lastName || null, + nickname: selectedCharacter.nickname || null, + age: selectedCharacter.age || null, + gender: selectedCharacter.gender || null, + species: selectedCharacter.species || null, + nationality: selectedCharacter.nationality || null, + status: selectedCharacter.status || null, + category: selectedCharacter.category, + title: selectedCharacter.title || null, + image: selectedCharacter.image || null, + role: selectedCharacter.role || null, + biography: selectedCharacter.biography || null, + history: selectedCharacter.history || null, + speechPattern: selectedCharacter.speechPattern || null, + catchphrase: selectedCharacter.catchphrase || null, + residence: selectedCharacter.residence || null, + notes: selectedCharacter.notes || null, + color: selectedCharacter.color || null, + }; + setSeriesCharacters(function (prev: SeriesCharacterProps[]): SeriesCharacterProps[] { + return [...prev, newSeriesCharacter]; + }); + successMessage(t("characterComponent.exportSuccess")); + } + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + }, [selectedCharacter, bookSeriesId, userToken, lang, successMessage, errorMessage, t, isCurrentlyOffline, book, addToQueue, localSyncedBooks, localSyncedSeries, entityId]); + + const importFromSeries = useCallback(async function (seriesCharacterId: string): Promise { + const seriesChar = seriesCharacters.find(function (c: SeriesCharacterProps): boolean { + return c.id === seriesCharacterId; + }); + if (!seriesChar) return; + + try { + const characterToImport: CharacterProps = { + id: null, + name: seriesChar.name, + lastName: seriesChar.lastName || '', + nickname: seriesChar.nickname || '', + age: seriesChar.age ?? null, + gender: seriesChar.gender || '', + species: seriesChar.species || '', + nationality: seriesChar.nationality || '', + status: (seriesChar.status as 'alive' | 'dead' | 'unknown') || 'alive', + category: (seriesChar.category as CharacterProps['category']) || 'none', + title: seriesChar.title || '', + role: seriesChar.role || '', + image: seriesChar.image || 'https://via.placeholder.com/150', + biography: seriesChar.biography || '', + history: seriesChar.history || '', + speechPattern: seriesChar.speechPattern || '', + catchphrase: seriesChar.catchphrase || '', + residence: seriesChar.residence || '', + notes: seriesChar.notes || '', + color: seriesChar.color || '', + physical: [], + psychological: [], + relations: [], + skills: [], + weaknesses: [], + strengths: [], + goals: [], + motivations: [], + arc: [], + secrets: [], + fears: [], + flaws: [], + beliefs: [], + conflicts: [], + quotes: [], + distinguishingMarks: [], + items: [], + affiliations: [], + seriesCharacterId: seriesCharacterId, + }; + + const requestData = { + bookId: entityId, + character: characterToImport, + }; + + let characterId: string; + if (isCurrentlyOffline() || book?.localBook) { + // Mode offline ou livre local → IPC + characterId = await window.electron.invoke('db:character:create', requestData); + } else { + // Mode online → Serveur + characterId = await System.authPostToServer('character/add', requestData, userToken, lang); + // Si le livre a une copie locale → addToQueue + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:character:create', {...requestData, id: characterId}); + } + } + + if (!characterId) { + errorMessage(t("characterComponent.importError")); + return; + } + + characterToImport.id = characterId; + setCharacters(function (prev: CharacterProps[]): CharacterProps[] { + return [...prev, characterToImport]; + }); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + }, [seriesCharacters, entityId, userToken, lang, errorMessage, successMessage, t, isCurrentlyOffline, book, addToQueue, localSyncedBooks]); + + // Navigation functions + const enterDetailMode = useCallback(function (character: CharacterProps): void { + setSelectedCharacter({...character}); + setViewMode('detail'); + setCharacterBackup(null); + }, []); + + const enterEditMode = useCallback(function (): void { + if (selectedCharacter) { + setCharacterBackup({...selectedCharacter}); + } + setViewMode('edit'); + }, [selectedCharacter]); + + const exitEditMode = useCallback(async function (save: boolean): Promise { + if (save) { + const success: boolean = await saveCharacter(); + if (!success) { + // Stay in edit mode on error + return; + } + if (characterBackup) { + setViewMode('detail'); + } else { + setViewMode('list'); + } + } else { + if (characterBackup) { + setSelectedCharacter(characterBackup); + setViewMode('detail'); + } else { + setSelectedCharacter(null); + setViewMode('list'); + } + } + setCharacterBackup(null); + }, [saveCharacter, characterBackup]); + + const backToList = useCallback(function (): void { + setSelectedCharacter(null); + setCharacterBackup(null); + setViewMode('list'); + }, []); + + return { + // State + characters, + seriesCharacters, + selectedCharacter, + toolEnabled, + isLoading, + isSeriesMode, + bookSeriesId, + + // Navigation state + viewMode, + characterBackup, + + // Actions + selectCharacter, + addNewCharacter, + clearSelection, + saveCharacter, + deleteCharacter, + updateCharacterField, + addAttribute, + removeAttribute, + toggleTool, + importFromSeries, + exportToSeries, + refreshCharacters, + refreshSeriesCharacters, + setSelectedCharacter, + + // Navigation actions + enterDetailMode, + enterEditMode, + exitEditMode, + backToList, + }; +} diff --git a/hooks/settings/useLocations.ts b/hooks/settings/useLocations.ts new file mode 100644 index 0000000..ded3da3 --- /dev/null +++ b/hooks/settings/useLocations.ts @@ -0,0 +1,982 @@ +'use client' +import {useCallback, useContext, useEffect, useState} from 'react'; +import {SeriesLocationItem, SeriesLocationElement, SeriesLocationSubElement} from '@/lib/models/Series'; +import {SessionContext} from '@/context/SessionContext'; +import {BookContext} from '@/context/BookContext'; +import {AlertContext} from '@/context/AlertContext'; +import {LangContext, LangContextProps} from '@/context/LangContext'; +import System from '@/lib/models/System'; +import {useTranslations} from 'next-intl'; +import {ViewMode} from '@/shared/interface'; +import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; +import {LocalSyncQueueContext, LocalSyncQueueContextProps} from '@/context/SyncQueueContext'; +import {BooksSyncContext, BooksSyncContextProps} from '@/context/BooksSyncContext'; +import {SyncedBook} from '@/lib/models/SyncedBook'; +import {SeriesContext, SeriesContextProps} from '@/context/SeriesContext'; +import {SeriesSyncContext, SeriesSyncContextProps} from '@/context/SeriesSyncContext'; +import {SyncedSeries} from '@/lib/models/SyncedSeries'; + +export interface SubElement { + id: string; + name: string; + description: string; +} + +export interface Element { + id: string; + name: string; + description: string; + subElements: SubElement[]; +} + +export interface LocationProps { + id: string; + name: string; + elements: Element[]; + seriesLocationId?: string | null; +} + +interface LocationListResponse { + locations: LocationProps[]; + enabled: boolean; +} + +export interface UseLocationsConfig { + entityType: 'book' | 'series'; + entityId: string; +} + +export interface UseLocationsReturn { + // State + sections: LocationProps[]; + seriesLocations: SeriesLocationItem[]; + toolEnabled: boolean; + isLoading: boolean; + isSeriesMode: boolean; + bookSeriesId: string | null; + newSectionName: string; + newElementNames: { [key: string]: string }; + newSubElementNames: { [key: string]: string }; + + // Navigation state + viewMode: ViewMode; + selectedSectionIndex: number; + sectionsBackup: LocationProps[] | null; + + // Actions + addSection: () => Promise; + addElement: (sectionId: string) => Promise; + addSubElement: (sectionId: string, elementIndex: number) => Promise; + removeSection: (sectionId: string) => Promise; + removeElement: (sectionId: string, elementIndex: number) => Promise; + removeSubElement: (sectionId: string, elementIndex: number, subElementIndex: number) => Promise; + updateElement: (sectionId: string, elementIndex: number, field: keyof Element, value: string) => void; + updateSubElement: (sectionId: string, elementIndex: number, subElementIndex: number, field: keyof SubElement, value: string) => void; + saveLocations: () => Promise; + toggleTool: (enabled: boolean) => Promise; + importFromSeries: (seriesLocationId: string) => Promise; + exportToSeries: (section: LocationProps) => Promise; + refreshLocations: () => Promise; + refreshSeriesLocations: () => Promise; + setNewSectionName: (name: string) => void; + setNewElementNames: React.Dispatch>; + setNewSubElementNames: React.Dispatch>; + + // Navigation actions + enterDetailMode: (sectionIndex: number) => void; + enterEditMode: () => void; + exitEditMode: (save: boolean) => Promise; + backToList: () => void; +} + +export function useLocations(config: UseLocationsConfig): UseLocationsReturn { + const {entityType, entityId} = config; + const t = useTranslations(); + const {lang} = useContext(LangContext); + const {session} = useContext(SessionContext); + const {book, setBook} = useContext(BookContext); + const {errorMessage, successMessage} = useContext(AlertContext); + const {isCurrentlyOffline} = useContext(OfflineContext); + const {addToQueue} = useContext(LocalSyncQueueContext); + const {localSyncedBooks} = useContext(BooksSyncContext); + const {localSeries} = useContext(SeriesContext); + const {localSyncedSeries} = useContext(SeriesSyncContext); + + const [sections, setSections] = useState([]); + const [seriesLocations, setSeriesLocations] = useState([]); + const [toolEnabled, setToolEnabled] = useState(entityType === 'series' || (book?.tools?.locations ?? false)); + const [isLoading, setIsLoading] = useState(true); + const [newSectionName, setNewSectionName] = useState(''); + const [newElementNames, setNewElementNames] = useState<{ [key: string]: string }>({}); + const [newSubElementNames, setNewSubElementNames] = useState<{ [key: string]: string }>({}); + const [isAddingElement, setIsAddingElement] = useState(false); + const [isAddingSubElement, setIsAddingSubElement] = useState(false); + const [isAddingSection, setIsAddingSection] = useState(false); + + // Navigation state + const [viewMode, setViewMode] = useState('list'); + const [selectedSectionIndex, setSelectedSectionIndex] = useState(-1); + const [sectionsBackup, setSectionsBackup] = useState(null); + + const isSeriesMode: boolean = entityType === 'series'; + const bookSeriesId: string | null = book?.seriesId || null; + const userToken: string = session?.accessToken || ''; + + // Load locations on mount + useEffect(function (): void { + if (entityId) { + refreshLocations(); + } + }, [entityId]); + + // Load series locations for book mode + useEffect(function (): void { + if (bookSeriesId && !isSeriesMode) { + refreshSeriesLocations(); + } + }, [bookSeriesId, isSeriesMode]); + + const refreshSeriesLocations = useCallback(async function (): Promise { + if (!bookSeriesId) return; + try { + let response: SeriesLocationItem[]; + // Dual logic: offline ou livre local → IPC, sinon serveur + if (isCurrentlyOffline() || book?.localBook) { + response = await window.electron.invoke( + 'db:series:location:list', + {seriesId: bookSeriesId} + ); + } else { + response = await System.authGetQueryToServer( + 'series/location/list', + userToken, + lang, + {seriesid: bookSeriesId} + ); + } + if (response) { + setSeriesLocations(response); + } + } catch (e: unknown) { + if (e instanceof Error) { + console.error('Error loading series locations:', e.message); + } + } + }, [bookSeriesId, userToken, lang, isCurrentlyOffline, book?.localBook]); + + const refreshLocations = useCallback(async function (): Promise { + setIsLoading(true); + try { + if (isSeriesMode) { + // Series mode - dual logic + let response: SeriesLocationItem[]; + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke( + 'db:series:location:list', + {seriesId: entityId} + ); + } else { + response = await System.authGetQueryToServer( + 'series/location/list', + userToken, + lang, + {seriesid: entityId} + ); + } + if (response) { + const mappedLocations: LocationProps[] = response.map(function (loc: SeriesLocationItem): LocationProps { + return { + id: loc.id, + name: loc.name, + elements: loc.elements.map(function (elem: SeriesLocationElement): Element { + return { + id: elem.id, + name: elem.name, + description: elem.description, + subElements: elem.subElements.map(function (sub: SeriesLocationSubElement): SubElement { + return { + id: sub.id, + name: sub.name, + description: sub.description, + }; + }), + }; + }), + }; + }); + setSections(mappedLocations); + } + } else { + let response: LocationListResponse; + if (isCurrentlyOffline()) { + response = await window.electron.invoke('db:location:all', {bookid: entityId}); + } else if (book?.localBook) { + response = await window.electron.invoke('db:location:all', {bookid: entityId}); + } else { + response = await System.authGetQueryToServer( + 'location/all', + userToken, + lang, + {bookid: entityId} + ); + } + if (response) { + setSections(response.locations); + setToolEnabled(response.enabled); + if (setBook && book) { + setBook({ + ...book, + tools: { + characters: book.tools?.characters ?? false, + worlds: book.tools?.worlds ?? false, + locations: response.enabled, + spells: book.tools?.spells ?? false + } + }); + } + } + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('locationComponent.errorUnknownFetchLocations')); + } + } finally { + setIsLoading(false); + } + }, [entityId, isSeriesMode, userToken, lang, book, setBook, errorMessage, t, isCurrentlyOffline]); + + const toggleTool = useCallback(async function (enabled: boolean): Promise { + if (isSeriesMode) return; + try { + const requestData = { + bookId: book?.bookId, + toolName: 'locations', + enabled: enabled + }; + let response: boolean; + if (isCurrentlyOffline() || book?.localBook) { + response = await window.electron.invoke('db:book:tool:update', requestData); + } else { + response = await System.authPatchToServer('book/tool-setting', requestData, userToken, lang); + + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === book?.bookId)) { + addToQueue('db:book:tool:update', requestData); + } + } + if (response && setBook && book) { + setToolEnabled(enabled); + setBook({ + ...book, + tools: { + characters: book.tools?.characters ?? false, + worlds: book.tools?.worlds ?? false, + locations: enabled, + spells: book.tools?.spells ?? false + } + }); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + }, [isSeriesMode, book, setBook, userToken, lang, errorMessage, isCurrentlyOffline, addToQueue, localSyncedBooks]); + + const addSection = useCallback(async function (): Promise { + if (isAddingSection) return; + if (!newSectionName.trim()) { + errorMessage(t('locationComponent.errorSectionNameEmpty')); + return; + } + setIsAddingSection(true); + try { + let sectionId: string; + if (isSeriesMode) { + // Series mode - dual logic + const addData = { + seriesId: entityId, + name: newSectionName, + }; + if (isCurrentlyOffline() || localSeries) { + sectionId = await window.electron.invoke('db:series:location:section:add', addData); + } else { + sectionId = await System.authPostToServer( + 'series/location/section/add', + addData, + userToken, + lang + ); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:location:section:add', {...addData, id: sectionId}); + } + } + if (!sectionId) { + errorMessage(t('locationComponent.errorUnknownAddSection')); + return; + } + } else { + const requestData = { + bookId: entityId, + locationName: newSectionName, + }; + if (isCurrentlyOffline() || book?.localBook) { + sectionId = await window.electron.invoke('db:location:section:add', requestData); + } else { + sectionId = await System.authPostToServer('location/section/add', requestData, userToken, lang); + + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:location:section:add', {...requestData, id: sectionId}); + } + } + if (!sectionId) { + errorMessage(t('locationComponent.errorUnknownAddSection')); + return; + } + } + const newLocation: LocationProps = { + id: sectionId, + name: newSectionName, + elements: [], + }; + setSections(function (prev: LocationProps[]): LocationProps[] { + return [...prev, newLocation]; + }); + setNewSectionName(''); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('locationComponent.errorUnknownAddSection')); + } + } finally { + setIsAddingSection(false); + } + }, [newSectionName, isSeriesMode, entityId, userToken, lang, errorMessage, t, isCurrentlyOffline, addToQueue, localSyncedBooks, book, isAddingSection]); + + const addElement = useCallback(async function (sectionId: string): Promise { + if (isAddingElement) return; + if (!newElementNames[sectionId]?.trim()) { + errorMessage(t('locationComponent.errorElementNameEmpty')); + return; + } + setIsAddingElement(true); + try { + let elementId: string; + if (isSeriesMode) { + // Series mode - dual logic + const addData = { + locationId: sectionId, + name: newElementNames[sectionId], + }; + if (isCurrentlyOffline() || localSeries) { + elementId = await window.electron.invoke('db:series:location:element:add', addData); + } else { + elementId = await System.authPostToServer( + 'series/location/element/add', + addData, + userToken, + lang + ); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:location:element:add', {...addData, id: elementId}); + } + } + if (!elementId) { + errorMessage(t('locationComponent.errorUnknownAddElement')); + return; + } + } else { + const requestData = { + bookId: entityId, + locationId: sectionId, + elementName: newElementNames[sectionId], + }; + if (isCurrentlyOffline() || book?.localBook) { + elementId = await window.electron.invoke('db:location:element:add', requestData); + } else { + elementId = await System.authPostToServer('location/element/add', requestData, userToken, lang); + + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:location:element:add', {...requestData, id: elementId}); + } + } + if (!elementId) { + errorMessage(t('locationComponent.errorUnknownAddElement')); + return; + } + } + setSections(function (prev: LocationProps[]): LocationProps[] { + const updated: LocationProps[] = [...prev]; + const sectionIndex: number = updated.findIndex(function (section: LocationProps): boolean { + return section.id === sectionId; + }); + updated[sectionIndex].elements.push({ + id: elementId, + name: newElementNames[sectionId], + description: '', + subElements: [], + }); + return updated; + }); + setNewElementNames(function (prev: { [key: string]: string }): { [key: string]: string } { + return {...prev, [sectionId]: ''}; + }); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('locationComponent.errorUnknownAddElement')); + } + } finally { + setIsAddingElement(false); + } + }, [newElementNames, isSeriesMode, entityId, userToken, lang, errorMessage, t, isCurrentlyOffline, addToQueue, localSyncedBooks, book, isAddingElement]); + + const addSubElement = useCallback(async function (sectionId: string, elementIndex: number): Promise { + if (isAddingSubElement) return; + if (!newSubElementNames[elementIndex]?.trim()) { + errorMessage(t('locationComponent.errorSubElementNameEmpty')); + return; + } + setIsAddingSubElement(true); + const sectionIndex: number = sections.findIndex(function (section: LocationProps): boolean { + return section.id === sectionId; + }); + try { + let subElementId: string; + if (isSeriesMode) { + // Series mode - dual logic + const addData = { + elementId: sections[sectionIndex].elements[elementIndex].id, + name: newSubElementNames[elementIndex], + }; + if (isCurrentlyOffline() || localSeries) { + subElementId = await window.electron.invoke('db:series:location:subelement:add', addData); + } else { + subElementId = await System.authPostToServer( + 'series/location/sub-element/add', + addData, + userToken, + lang + ); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:location:subelement:add', {...addData, id: subElementId}); + } + } + if (!subElementId) { + errorMessage(t('locationComponent.errorUnknownAddSubElement')); + return; + } + } else { + const requestData = { + elementId: sections[sectionIndex].elements[elementIndex].id, + subElementName: newSubElementNames[elementIndex], + }; + if (isCurrentlyOffline() || book?.localBook) { + subElementId = await window.electron.invoke('db:location:subelement:add', requestData); + } else { + subElementId = await System.authPostToServer('location/sub-element/add', requestData, userToken, lang); + + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:location:subelement:add', {...requestData, id: subElementId}); + } + } + if (!subElementId) { + errorMessage(t('locationComponent.errorUnknownAddSubElement')); + return; + } + } + setSections(function (prev: LocationProps[]): LocationProps[] { + const updated: LocationProps[] = [...prev]; + updated[sectionIndex].elements[elementIndex].subElements.push({ + id: subElementId, + name: newSubElementNames[elementIndex], + description: '', + }); + return updated; + }); + setNewSubElementNames(function (prev: { [key: string]: string }): { [key: string]: string } { + return {...prev, [elementIndex]: ''}; + }); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('locationComponent.errorUnknownAddSubElement')); + } + } finally { + setIsAddingSubElement(false); + } + }, [sections, newSubElementNames, isSeriesMode, userToken, lang, errorMessage, t, isCurrentlyOffline, addToQueue, localSyncedBooks, entityId, book, isAddingSubElement]); + + const removeSection = useCallback(async function (sectionId: string): Promise { + try { + let success: boolean; + if (isSeriesMode) { + // Series mode - dual logic + const deleteData = {locationId: sectionId}; + if (isCurrentlyOffline() || localSeries) { + success = await window.electron.invoke('db:series:location:delete', deleteData); + } else { + success = await System.authDeleteToServer('series/location/delete', deleteData, userToken, lang); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:location:delete', deleteData); + } + } + } else { + const requestData = { + locationId: sectionId, + }; + if (isCurrentlyOffline() || book?.localBook) { + success = await window.electron.invoke('db:location:delete', requestData); + } else { + success = await System.authDeleteToServer('location/delete', requestData, userToken, lang); + + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:location:delete', requestData); + } + } + } + if (!success) { + errorMessage(t('locationComponent.errorUnknownDeleteSection')); + return; + } + setSections(function (prev: LocationProps[]): LocationProps[] { + return prev.filter(function (section: LocationProps): boolean { + return section.id !== sectionId; + }); + }); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('locationComponent.errorUnknownDeleteSection')); + } + } + }, [isSeriesMode, userToken, lang, errorMessage, t, isCurrentlyOffline, addToQueue, localSyncedBooks, entityId, book]); + + const removeElement = useCallback(async function (sectionId: string, elementIndex: number): Promise { + try { + const elementId: string | undefined = sections.find(function (section: LocationProps): boolean { + return section.id === sectionId; + })?.elements[elementIndex].id; + let success: boolean; + if (isSeriesMode) { + // Series mode - dual logic + const deleteData = {elementId: elementId}; + if (isCurrentlyOffline() || localSeries) { + success = await window.electron.invoke('db:series:location:element:delete', deleteData); + } else { + success = await System.authDeleteToServer('series/location/element/delete', deleteData, userToken, lang); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:location:element:delete', deleteData); + } + } + } else { + const requestData = { + elementId: elementId, + }; + if (isCurrentlyOffline() || book?.localBook) { + success = await window.electron.invoke('db:location:element:delete', requestData); + } else { + success = await System.authDeleteToServer('location/element/delete', requestData, userToken, lang); + + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:location:element:delete', requestData); + } + } + } + if (!success) { + errorMessage(t('locationComponent.errorUnknownDeleteElement')); + return; + } + setSections(function (prev: LocationProps[]): LocationProps[] { + const updated: LocationProps[] = [...prev]; + const sectionIndex: number = updated.findIndex(function (section: LocationProps): boolean { + return section.id === sectionId; + }); + updated[sectionIndex].elements.splice(elementIndex, 1); + return updated; + }); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('locationComponent.errorUnknownDeleteElement')); + } + } + }, [sections, isSeriesMode, userToken, lang, errorMessage, t, isCurrentlyOffline, addToQueue, localSyncedBooks, entityId, book]); + + const removeSubElement = useCallback(async function (sectionId: string, elementIndex: number, subElementIndex: number): Promise { + try { + const subElementId: string | undefined = sections.find(function (section: LocationProps): boolean { + return section.id === sectionId; + })?.elements[elementIndex].subElements[subElementIndex].id; + let success: boolean; + if (isSeriesMode) { + // Series mode - dual logic + const deleteData = {subElementId: subElementId}; + if (isCurrentlyOffline() || localSeries) { + success = await window.electron.invoke('db:series:location:subelement:delete', deleteData); + } else { + success = await System.authDeleteToServer('series/location/sub-element/delete', deleteData, userToken, lang); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:location:subelement:delete', deleteData); + } + } + } else { + const requestData = { + subElementId: subElementId, + }; + if (isCurrentlyOffline() || book?.localBook) { + success = await window.electron.invoke('db:location:subelement:delete', requestData); + } else { + success = await System.authDeleteToServer('location/sub-element/delete', requestData, userToken, lang); + + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:location:subelement:delete', requestData); + } + } + } + if (!success) { + errorMessage(t('locationComponent.errorUnknownDeleteSubElement')); + return; + } + setSections(function (prev: LocationProps[]): LocationProps[] { + const updated: LocationProps[] = [...prev]; + const sectionIndex: number = updated.findIndex(function (section: LocationProps): boolean { + return section.id === sectionId; + }); + updated[sectionIndex].elements[elementIndex].subElements.splice(subElementIndex, 1); + return updated; + }); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('locationComponent.errorUnknownDeleteSubElement')); + } + } + }, [sections, isSeriesMode, userToken, lang, errorMessage, t, isCurrentlyOffline, addToQueue, localSyncedBooks, entityId, book]); + + const updateElement = useCallback(function (sectionId: string, elementIndex: number, field: keyof Element, value: string): void { + setSections(function (prev: LocationProps[]): LocationProps[] { + const updated: LocationProps[] = [...prev]; + const sectionIndex: number = updated.findIndex(function (section: LocationProps): boolean { + return section.id === sectionId; + }); + // @ts-ignore + updated[sectionIndex].elements[elementIndex][field] = value; + return updated; + }); + }, []); + + const updateSubElement = useCallback(function (sectionId: string, elementIndex: number, subElementIndex: number, field: keyof SubElement, value: string): void { + setSections(function (prev: LocationProps[]): LocationProps[] { + const updated: LocationProps[] = [...prev]; + const sectionIndex: number = updated.findIndex(function (section: LocationProps): boolean { + return section.id === sectionId; + }); + updated[sectionIndex].elements[elementIndex].subElements[subElementIndex][field] = value; + return updated; + }); + }, []); + + const saveLocations = useCallback(async function (): Promise { + try { + const requestData = { + locations: sections, + }; + let response: boolean; + if (isCurrentlyOffline() || book?.localBook) { + response = await window.electron.invoke('db:location:update', requestData); + } else { + response = await System.authPostToServer('location/update', requestData, userToken, lang); + + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:location:update', requestData); + } + } + if (!response) { + errorMessage(t('locationComponent.errorUnknownSave')); + return false; + } + successMessage(t('locationComponent.successSave')); + return true; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('locationComponent.errorUnknownSave')); + } + return false; + } + }, [sections, userToken, lang, errorMessage, successMessage, t, isCurrentlyOffline, addToQueue, localSyncedBooks, entityId, book]); + + const exportToSeries = useCallback(async function (section: LocationProps): Promise { + if (!bookSeriesId) return; + + try { + const seriesLocationData = { + seriesId: bookSeriesId, + name: section.name, + }; + + let seriesLocationId: string; + if (isCurrentlyOffline() || book?.localBook) { + // Mode offline ou livre local → IPC + seriesLocationId = await window.electron.invoke('db:series:location:section:add', seriesLocationData); + } else { + // Mode online → Serveur + seriesLocationId = await System.authPostToServer('series/location/section/add', seriesLocationData, userToken, lang); + // Si la série a une copie locale → addToQueue avec l'ID du serveur + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === bookSeriesId)) { + addToQueue('db:series:location:section:add', {...seriesLocationData, id: seriesLocationId}); + } + } + + if (seriesLocationId) { + const updateData = { + sectionId: section.id, + sectionName: section.name, + seriesLocationId: seriesLocationId, + }; + + let updateResponse: boolean; + if (isCurrentlyOffline() || book?.localBook) { + // Mode offline ou livre local → IPC + updateResponse = await window.electron.invoke('db:location:section:update', updateData); + } else { + // Mode online → Serveur + updateResponse = await System.authPostToServer('location/section/update', updateData, userToken, lang); + // Si le livre a une copie locale → addToQueue + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:location:section:update', updateData); + } + } + + if (updateResponse) { + setSections(function (prev: LocationProps[]): LocationProps[] { + return prev.map(function (s: LocationProps): LocationProps { + return s.id === section.id ? {...s, seriesLocationId: seriesLocationId} : s; + }); + }); + const newSeriesLocation: SeriesLocationItem = { + id: seriesLocationId, + name: section.name, + elements: [], + }; + setSeriesLocations(function (prev: SeriesLocationItem[]): SeriesLocationItem[] { + return [...prev, newSeriesLocation]; + }); + successMessage(t("locationComponent.exportSuccess")); + } + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + }, [bookSeriesId, userToken, lang, successMessage, errorMessage, t, isCurrentlyOffline, book, addToQueue, localSyncedBooks, localSyncedSeries, entityId]); + + const importFromSeries = useCallback(async function (seriesLocationId: string): Promise { + const seriesLocation: SeriesLocationItem | undefined = seriesLocations.find(function (location: SeriesLocationItem): boolean { + return location.id === seriesLocationId; + }); + if (!seriesLocation) return; + + try { + const sectionData = { + bookId: entityId, + locationName: seriesLocation.name, + seriesLocationId: seriesLocationId, + }; + + let sectionId: string; + if (isCurrentlyOffline() || book?.localBook) { + // Mode offline ou livre local → IPC + sectionId = await window.electron.invoke('db:location:section:add', sectionData); + } else { + // Mode online → Serveur + sectionId = await System.authPostToServer('location/section/add', sectionData, userToken, lang); + // Si le livre a une copie locale → addToQueue avec l'ID du serveur + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:location:section:add', {...sectionData, id: sectionId}); + } + } + + if (!sectionId) { + errorMessage(t('locationComponent.importError')); + return; + } + + const importedElements: Element[] = []; + + for (const seriesElement of seriesLocation.elements) { + const elementData = { + bookId: entityId, + locationId: sectionId, + elementName: seriesElement.name, + }; + + let elementId: string; + if (isCurrentlyOffline() || book?.localBook) { + // Mode offline ou livre local → IPC + elementId = await window.electron.invoke('db:location:element:add', elementData); + } else { + // Mode online → Serveur + elementId = await System.authPostToServer('location/element/add', elementData, userToken, lang); + // Si le livre a une copie locale → addToQueue avec l'ID du serveur + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:location:element:add', {...elementData, id: elementId}); + } + } + + if (!elementId) continue; + + const importedSubElements: SubElement[] = []; + + for (const seriesSubElement of seriesElement.subElements) { + const subElementData = { + elementId: elementId, + subElementName: seriesSubElement.name, + }; + + let subElementId: string; + if (isCurrentlyOffline() || book?.localBook) { + // Mode offline ou livre local → IPC + subElementId = await window.electron.invoke('db:location:subelement:add', subElementData); + } else { + // Mode online → Serveur + subElementId = await System.authPostToServer('location/sub-element/add', subElementData, userToken, lang); + // Si le livre a une copie locale → addToQueue avec l'ID du serveur + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:location:subelement:add', {...subElementData, id: subElementId}); + } + } + + if (subElementId) { + importedSubElements.push({ + id: subElementId, + name: seriesSubElement.name, + description: seriesSubElement.description, + }); + } + } + + importedElements.push({ + id: elementId, + name: seriesElement.name, + description: seriesElement.description, + subElements: importedSubElements, + }); + } + + const newLocation: LocationProps = { + id: sectionId, + name: seriesLocation.name, + elements: importedElements, + seriesLocationId: seriesLocationId, + }; + setSections(function (prev: LocationProps[]): LocationProps[] { + return [...prev, newLocation]; + }); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + }, [seriesLocations, entityId, userToken, lang, errorMessage, successMessage, t, isCurrentlyOffline, book, addToQueue, localSyncedBooks]); + + // Navigation functions + const enterDetailMode = useCallback(function (sectionIndex: number): void { + setSelectedSectionIndex(sectionIndex); + setViewMode('detail'); + setSectionsBackup(null); + }, []); + + const enterEditMode = useCallback(function (): void { + setSectionsBackup(sections.map(function (section: LocationProps): LocationProps { + return { + ...section, + elements: section.elements.map(function (element: Element): Element { + return { + ...element, + subElements: [...element.subElements] + }; + }) + }; + })); + setViewMode('edit'); + }, [sections]); + + const exitEditMode = useCallback(async function (save: boolean): Promise { + if (save) { + const success: boolean = await saveLocations(); + if (!success) { + // Stay in edit mode on error + return; + } + setViewMode('detail'); + } else { + if (sectionsBackup) { + setSections(sectionsBackup); + setViewMode('detail'); + } else { + setViewMode('list'); + } + } + setSectionsBackup(null); + }, [saveLocations, sectionsBackup]); + + const backToList = useCallback(function (): void { + setSelectedSectionIndex(-1); + setSectionsBackup(null); + setViewMode('list'); + }, []); + + return { + // State + sections, + seriesLocations, + toolEnabled, + isLoading, + isSeriesMode, + bookSeriesId, + newSectionName, + newElementNames, + newSubElementNames, + + // Navigation state + viewMode, + selectedSectionIndex, + sectionsBackup, + + // Actions + addSection, + addElement, + addSubElement, + removeSection, + removeElement, + removeSubElement, + updateElement, + updateSubElement, + saveLocations, + toggleTool, + importFromSeries, + exportToSeries, + refreshLocations, + refreshSeriesLocations, + setNewSectionName, + setNewElementNames, + setNewSubElementNames, + + // Navigation actions + enterDetailMode, + enterEditMode, + exitEditMode, + backToList, + }; +} diff --git a/hooks/settings/useSpells.ts b/hooks/settings/useSpells.ts new file mode 100644 index 0000000..762bb09 --- /dev/null +++ b/hooks/settings/useSpells.ts @@ -0,0 +1,975 @@ +'use client' +import {useCallback, useContext, useEffect, useState} from 'react'; +import { + initialSpellState, + SpellEditState, + SpellListItem, + SpellListResponse, + SpellProps, + SpellTagProps +} from '@/lib/models/Spell'; +import {SeriesSpellDetailResponse, SeriesSpellListItem, SeriesSpellListResponse} from '@/lib/models/Series'; +import {SessionContext} from '@/context/SessionContext'; +import {BookContext} from '@/context/BookContext'; +import {AlertContext} from '@/context/AlertContext'; +import {LangContext, LangContextProps} from '@/context/LangContext'; +import System from '@/lib/models/System'; +import {useTranslations} from 'next-intl'; +import {ViewMode} from '@/shared/interface'; +import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; +import {LocalSyncQueueContext, LocalSyncQueueContextProps} from '@/context/SyncQueueContext'; +import {BooksSyncContext, BooksSyncContextProps} from '@/context/BooksSyncContext'; +import {SyncedBook} from '@/lib/models/SyncedBook'; +import {SeriesContext, SeriesContextProps} from '@/context/SeriesContext'; +import {SeriesSyncContext, SeriesSyncContextProps} from '@/context/SeriesSyncContext'; +import {SyncedSeries} from '@/lib/models/SyncedSeries'; + +export interface UseSpellsConfig { + entityType: 'book' | 'series'; + entityId: string; +} + +export interface UseSpellsReturn { + spells: SpellListItem[]; + seriesSpells: SeriesSpellListItem[]; + tags: SpellTagProps[]; + selectedSpell: SpellEditState | null; + selectedSeriesSpell: SeriesSpellDetailResponse | null; + toolEnabled: boolean; + isLoading: boolean; + isSeriesMode: boolean; + bookSeriesId: string | null; + showTagManager: boolean; + viewMode: ViewMode; + spellBackup: SpellEditState | null; + selectSpell: (spell: SpellListItem) => Promise; + addNewSpell: () => void; + clearSelection: () => void; + saveSpell: () => Promise; + deleteSpell: (spellId: string) => Promise; + updateSpellField: (key: keyof SpellEditState, value: string | string[] | null) => void; + toggleTool: (enabled: boolean) => Promise; + importFromSeries: (seriesSpellId: string) => Promise; + exportToSeries: () => Promise; + refreshSeriesSpells: () => Promise; + setSelectedSpell: React.Dispatch>; + setShowTagManager: (show: boolean) => void; + + enterDetailMode: (spell: SpellListItem) => Promise; + enterEditMode: () => void; + exitEditMode: (save: boolean) => Promise; + backToList: () => void; + + createTag: (name: string, color: string) => Promise; + updateTag: (tagId: string, name: string, color: string) => Promise; + deleteTag: (tagId: string) => Promise; + handleSyncComplete: () => Promise; +} + +export function useSpells(config: UseSpellsConfig): UseSpellsReturn { + const {entityType, entityId} = config; + const t = useTranslations(); + const {lang} = useContext(LangContext); + const {session} = useContext(SessionContext); + const {book, setBook} = useContext(BookContext); + const {errorMessage, successMessage} = useContext(AlertContext); + const {isCurrentlyOffline} = useContext(OfflineContext); + const {addToQueue} = useContext(LocalSyncQueueContext); + const {localSyncedBooks} = useContext(BooksSyncContext); + const {localSeries} = useContext(SeriesContext); + const {localSyncedSeries} = useContext(SeriesSyncContext); + + const [spells, setSpells] = useState([]); + const [seriesSpells, setSeriesSpells] = useState([]); + const [tags, setTags] = useState([]); + const [selectedSpell, setSelectedSpell] = useState(null); + const [selectedSeriesSpell, setSelectedSeriesSpell] = useState(null); + const [toolEnabled, setToolEnabled] = useState(entityType === 'series' || (book?.tools?.spells ?? false)); + const [isLoading, setIsLoading] = useState(true); + const [showTagManager, setShowTagManager] = useState(false); + + const [viewMode, setViewMode] = useState('list'); + const [spellBackup, setSpellBackup] = useState(null); + + const isSeriesMode: boolean = entityType === 'series'; + const bookSeriesId: string | null = book?.seriesId || null; + const userToken: string = session?.accessToken || ''; + + useEffect(function (): void { + if (entityId) { + refreshSpells().then(); + } + }, [entityId]); + + useEffect(function (): void { + if (bookSeriesId && !isSeriesMode) { + refreshSeriesSpells().then(); + } + }, [bookSeriesId, isSeriesMode]); + + const refreshSeriesSpells = useCallback(async function (): Promise { + if (!bookSeriesId) return; + try { + let response: SeriesSpellListResponse; + // Dual logic: offline ou livre local → IPC, sinon serveur + if (isCurrentlyOffline() || book?.localBook) { + response = await window.electron.invoke( + 'db:series:spell:list', + {seriesId: bookSeriesId} + ); + } else { + response = await System.authGetQueryToServer( + 'series/spell/list', + userToken, + lang, + {seriesid: bookSeriesId} + ); + } + if (response) { + setSeriesSpells(response.spells); + } + } catch (e: unknown) { + if (e instanceof Error) { + console.error('Error loading series spells:', e.message); + } + } + }, [bookSeriesId, userToken, lang, isCurrentlyOffline, book?.localBook]); + + const refreshSpells = useCallback(async function (): Promise { + setIsLoading(true); + try { + if (isSeriesMode) { + // Series mode - dual logic + let response: SeriesSpellListResponse; + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke( + 'db:series:spell:list', + {seriesId: entityId} + ); + } else { + response = await System.authGetQueryToServer( + 'series/spell/list', + userToken, + lang, + {seriesid: entityId} + ); + } + if (response) { + const mappedSpells: SpellListItem[] = response.spells.map(function (spell: SeriesSpellListItem): SpellListItem { + return { + id: spell.id, + name: spell.name, + description: spell.description, + tags: spell.tags ? spell.tags.map(function (tagId: string): SpellTagProps { + const foundTag: SpellTagProps | undefined = response.tags.find(function (t: SpellTagProps): boolean { + return t.id === tagId; + }); + return foundTag || {id: tagId, name: tagId, color: null}; + }) : [], + }; + }); + setSpells(mappedSpells); + setTags(response.tags || []); + } + } else { + let response: SpellListResponse; + if (isCurrentlyOffline()) { + response = await window.electron.invoke('db:spell:list', {bookid: entityId}); + } else if (book?.localBook) { + response = await window.electron.invoke('db:spell:list', {bookid: entityId}); + } else { + response = await System.authGetQueryToServer( + 'spell/list', + userToken, + lang, + {bookid: entityId} + ); + } + if (response) { + setSpells(response.spells.map(function (spell: SpellListItem): SpellListItem { + return { + ...spell, + tags: spell.tags || [] + }; + })); + setTags(response.tags || []); + setToolEnabled(response.enabled); + if (setBook && book) { + setBook({ + ...book, + tools: { + characters: book.tools?.characters ?? false, + worlds: book.tools?.worlds ?? false, + locations: book.tools?.locations ?? false, + spells: response.enabled + } + }); + } + } + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("common.unknownError")); + } + } finally { + setIsLoading(false); + } + }, [entityId, isSeriesMode, userToken, lang, book, setBook, errorMessage, t, isCurrentlyOffline]); + + const selectSpell = useCallback(async function (spell: SpellListItem): Promise { + const tagIds: string[] = spell.tags ? spell.tags.map(function (tag: SpellTagProps): string { + return tag.id; + }) : []; + + setSelectedSpell({ + id: spell.id, + name: spell.name, + description: spell.description, + appearance: '', + tags: tagIds, + powerLevel: null, + components: null, + limitations: null, + notes: null, + seriesSpellId: spell.seriesSpellId || null, + }); + setSelectedSeriesSpell(null); + + try { + if (isSeriesMode) { + // Series mode - dual logic + let response: SeriesSpellDetailResponse; + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke( + 'db:series:spell:detail', + {spellId: spell.id} + ); + } else { + response = await System.authGetQueryToServer( + 'series/spell/detail', + userToken, + lang, + {spellid: spell.id} + ); + } + if (response) { + setSelectedSpell(function (prev: SpellEditState | null): SpellEditState | null { + if (!prev) return null; + return { + ...prev, + appearance: response.appearance, + powerLevel: response.powerLevel, + components: response.components, + limitations: response.limitations, + notes: response.notes, + }; + }); + } + } else { + let response: SpellProps; + if (isCurrentlyOffline()) { + response = await window.electron.invoke('db:spell:detail', {spellid: spell.id}); + } else if (book?.localBook) { + response = await window.electron.invoke('db:spell:detail', {spellid: spell.id}); + } else { + response = await System.authGetQueryToServer( + 'spell/detail', + userToken, + lang, + {spellid: spell.id} + ); + } + if (response) { + setSelectedSpell(function (prev: SpellEditState | null): SpellEditState | null { + if (!prev) return null; + return { + ...prev, + appearance: response.appearance, + powerLevel: response.powerLevel, + components: response.components, + limitations: response.limitations, + notes: response.notes, + seriesSpellId: response.seriesSpellId || null, + }; + }); + + if (response.seriesSpellId) { + const seriesSpellResponse: SeriesSpellDetailResponse = await System.authGetQueryToServer( + 'series/spell/detail', + userToken, + lang, + {spellid: response.seriesSpellId} + ); + if (seriesSpellResponse) { + setSelectedSeriesSpell(seriesSpellResponse); + } + } + } + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + }, [isSeriesMode, userToken, lang, errorMessage, isCurrentlyOffline, book?.localBook]); + + const addNewSpell = useCallback(function (): void { + setSelectedSpell({...initialSpellState}); + setSelectedSeriesSpell(null); + setViewMode('edit'); + setSpellBackup(null); + }, []); + + const clearSelection = useCallback(function (): void { + setSelectedSpell(null); + setSelectedSeriesSpell(null); + setViewMode('list'); + setSpellBackup(null); + }, []); + + const updateSpellField = useCallback(function (key: keyof SpellEditState, value: string | string[] | null): void { + if (selectedSpell) { + setSelectedSpell({...selectedSpell, [key]: value}); + } + }, [selectedSpell]); + + const toggleTool = useCallback(async function (enabled: boolean): Promise { + if (isSeriesMode) return; + try { + const requestData = { + bookId: book?.bookId, + toolName: 'spells', + enabled: enabled + }; + let response: boolean; + if (isCurrentlyOffline() || book?.localBook) { + response = await window.electron.invoke('db:book:tool:update', requestData); + } else { + response = await System.authPatchToServer('book/tool-setting', requestData, userToken, lang); + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === book?.bookId)) { + addToQueue('db:book:tool:update', requestData); + } + } + if (response && setBook && book) { + setToolEnabled(enabled); + setBook({ + ...book, + tools: { + characters: book.tools?.characters ?? false, + worlds: book.tools?.worlds ?? false, + locations: book.tools?.locations ?? false, + spells: enabled + } + }); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + }, [isSeriesMode, book, setBook, userToken, lang, errorMessage, isCurrentlyOffline, localSyncedBooks, addToQueue]); + + const saveSpell = useCallback(async function (): Promise { + if (!selectedSpell) return false; + + if (selectedSpell.id === null) { + return await addSpellInternal(selectedSpell); + } else { + return await updateSpellInternal(selectedSpell); + } + }, [selectedSpell]); + + async function addSpellInternal(spell: SpellEditState): Promise { + if (!spell.name) { + errorMessage(t("spellComponent.errorNameRequired")); + return false; + } + try { + let newSpellId: string; + if (isSeriesMode) { + // Series mode - dual logic + const data = { + seriesId: entityId, + name: spell.name, + description: spell.description, + appearance: spell.appearance, + tags: spell.tags, + powerLevel: spell.powerLevel, + components: spell.components, + limitations: spell.limitations, + notes: spell.notes, + }; + if (isCurrentlyOffline() || localSeries) { + newSpellId = await window.electron.invoke('db:series:spell:add', data); + } else { + newSpellId = await System.authPostToServer('series/spell/add', data, userToken, lang); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:spell:add', {...data, id: newSpellId}); + } + } + } else { + const data = { + bookId: entityId, + spell: { + name: spell.name, + description: spell.description, + appearance: spell.appearance, + tags: spell.tags, + powerLevel: spell.powerLevel, + components: spell.components, + limitations: spell.limitations, + notes: spell.notes, + } + }; + if (isCurrentlyOffline() || book?.localBook) { + newSpellId = await window.electron.invoke('db:spell:create', data); + } else { + newSpellId = await System.authPostToServer('spell/add', data, userToken, lang); + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:spell:create', {...data, id: newSpellId}); + } + } + } + if (!newSpellId) { + errorMessage(t("spellComponent.errorAddSpell")); + return false; + } + const resolvedTags: SpellTagProps[] = tags.filter(function (tag: SpellTagProps): boolean { + return spell.tags.includes(tag.id); + }); + const newSpellListItem: SpellListItem = { + id: newSpellId, + name: spell.name, + description: spell.description.length > 150 + ? spell.description.substring(0, 150) + '...' + : spell.description, + tags: resolvedTags, + }; + setSpells(function (prev: SpellListItem[]): SpellListItem[] { + return [...prev, newSpellListItem]; + }); + setSelectedSpell(null); + return true; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("common.unknownError")); + } + return false; + } + } + + async function updateSpellInternal(spellToUpdate: SpellEditState): Promise { + if (!spellToUpdate.id) return false; + if (!spellToUpdate.name) { + errorMessage(t("spellComponent.errorNameRequired")); + return false; + } + try { + let success: boolean; + const data = { + id: spellToUpdate.id, + name: spellToUpdate.name, + description: spellToUpdate.description, + appearance: spellToUpdate.appearance, + tags: spellToUpdate.tags, + powerLevel: spellToUpdate.powerLevel, + components: spellToUpdate.components, + limitations: spellToUpdate.limitations, + notes: spellToUpdate.notes, + }; + if (isSeriesMode) { + // Series mode - dual logic + if (isCurrentlyOffline() || localSeries) { + success = await window.electron.invoke('db:series:spell:update', data); + } else { + success = await System.authPutToServer('series/spell/update', data, userToken, lang); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:spell:update', data); + } + } + } else { + if (isCurrentlyOffline() || book?.localBook) { + success = await window.electron.invoke('db:spell:update', data); + } else { + success = await System.authPutToServer('spell/update', data, userToken, lang); + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:spell:update', data); + } + } + } + if (!success) { + errorMessage(t("spellComponent.errorUpdateSpell")); + return false; + } + const resolvedTags: SpellTagProps[] = tags.filter(function (tag: SpellTagProps): boolean { + return spellToUpdate.tags.includes(tag.id); + }); + setSpells(function (prev: SpellListItem[]): SpellListItem[] { + return prev.map(function (spell: SpellListItem): SpellListItem { + return spell.id === spellToUpdate.id ? { + id: spellToUpdate.id, + name: spellToUpdate.name, + description: spellToUpdate.description.length > 150 + ? spellToUpdate.description.substring(0, 150) + '...' + : spellToUpdate.description, + tags: resolvedTags, + } : spell; + }); + }); + setSelectedSpell(null); + successMessage(t("spellComponent.successUpdate")); + return true; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("common.unknownError")); + } + return false; + } + } + + const deleteSpell = useCallback(async function (spellId: string): Promise { + try { + let success: boolean; + const requestData = {spellId}; + if (isSeriesMode) { + // Series mode - dual logic + if (isCurrentlyOffline() || localSeries) { + success = await window.electron.invoke('db:series:spell:delete', requestData); + } else { + success = await System.authDeleteToServer('series/spell/delete', requestData, userToken, lang); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:spell:delete', requestData); + } + } + } else { + if (isCurrentlyOffline() || book?.localBook) { + success = await window.electron.invoke('db:spell:delete', requestData); + } else { + success = await System.authDeleteToServer('spell/delete', requestData, userToken, lang); + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:spell:delete', requestData); + } + } + } + if (!success) { + errorMessage(t("spellComponent.errorDeleteSpell")); + return; + } + setSpells(function (prev: SpellListItem[]): SpellListItem[] { + return prev.filter(function (s: SpellListItem): boolean { + return s.id !== spellId; + }); + }); + setSelectedSpell(null); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("common.unknownError")); + } + } + }, [isSeriesMode, userToken, lang, errorMessage, t, isCurrentlyOffline, book?.localBook, entityId, localSyncedBooks, addToQueue]); + + const exportToSeries = useCallback(async function (): Promise { + if (!selectedSpell || !selectedSpell.id || !bookSeriesId) return; + + try { + const seriesSpellData = { + seriesId: bookSeriesId, + name: selectedSpell.name, + description: selectedSpell.description, + appearance: selectedSpell.appearance || '', + tags: [], + powerLevel: selectedSpell.powerLevel || null, + components: selectedSpell.components || null, + limitations: selectedSpell.limitations || null, + notes: selectedSpell.notes || null, + }; + + let seriesSpellId: string; + if (isCurrentlyOffline() || book?.localBook) { + // Mode offline ou livre local → IPC + seriesSpellId = await window.electron.invoke('db:series:spell:add', seriesSpellData); + } else { + // Mode online → Serveur + seriesSpellId = await System.authPostToServer( + 'series/spell/add', + seriesSpellData, + userToken, + lang + ); + // Si la série a une copie locale → addToQueue + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === bookSeriesId)) { + addToQueue('db:series:spell:add', {...seriesSpellData, id: seriesSpellId}); + } + } + + if (seriesSpellId) { + const updateData = { + id: selectedSpell.id, + name: selectedSpell.name, + description: selectedSpell.description, + appearance: selectedSpell.appearance, + tags: selectedSpell.tags, + powerLevel: selectedSpell.powerLevel, + components: selectedSpell.components, + limitations: selectedSpell.limitations, + notes: selectedSpell.notes, + seriesSpellId: seriesSpellId + }; + + let updateSuccess: boolean; + if (isCurrentlyOffline() || book?.localBook) { + // Mode offline ou livre local → IPC + updateSuccess = await window.electron.invoke('db:spell:update', updateData); + } else { + // Mode online → Serveur + updateSuccess = await System.authPutToServer('spell/update', updateData, userToken, lang); + // Si le livre a une copie locale → addToQueue + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:spell:update', updateData); + } + } + + if (updateSuccess) { + setSelectedSpell({...selectedSpell, seriesSpellId: seriesSpellId}); + setSpells(function (prev: SpellListItem[]): SpellListItem[] { + return prev.map(function (s: SpellListItem): SpellListItem { + return s.id === selectedSpell.id ? {...s, seriesSpellId: seriesSpellId} : s; + }); + }); + const newSeriesSpell: SeriesSpellListItem = { + id: seriesSpellId, + name: selectedSpell.name, + description: selectedSpell.description.length > 150 + ? selectedSpell.description.substring(0, 150) + '...' + : selectedSpell.description, + tags: null, + }; + setSeriesSpells(function (prev: SeriesSpellListItem[]): SeriesSpellListItem[] { + return [...prev, newSeriesSpell]; + }); + successMessage(t("spellComponent.exportSuccess")); + } + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + }, [selectedSpell, bookSeriesId, userToken, lang, successMessage, errorMessage, t, isCurrentlyOffline, book, addToQueue, localSyncedBooks, localSyncedSeries, entityId]); + + const importFromSeries = useCallback(async function (seriesSpellId: string): Promise { + try { + // 1. Récupérer les détails du sort de la série + let seriesSpellDetail: SeriesSpellDetailResponse; + if (isCurrentlyOffline() || book?.localBook) { + // Mode offline → IPC pour récupérer les détails du sort de la série locale + seriesSpellDetail = await window.electron.invoke( + 'db:series:spell:detail', + {spellId: seriesSpellId} + ); + } else { + // Mode online → Serveur + seriesSpellDetail = await System.authGetQueryToServer( + 'series/spell/detail', + userToken, + lang, + {spellid: seriesSpellId} + ); + } + + if (!seriesSpellDetail) return; + + // 2. Créer le sort dans le livre + const spellData = { + bookId: entityId, + spell: { + name: seriesSpellDetail.name, + description: seriesSpellDetail.description, + appearance: seriesSpellDetail.appearance || '', + tags: [], + powerLevel: seriesSpellDetail.powerLevel, + components: seriesSpellDetail.components, + limitations: seriesSpellDetail.limitations, + notes: seriesSpellDetail.notes, + seriesSpellId: seriesSpellId + } + }; + + let createdSpellId: string; + if (isCurrentlyOffline() || book?.localBook) { + // Mode offline ou livre local → IPC + createdSpellId = await window.electron.invoke('db:spell:create', spellData); + } else { + // Mode online → Serveur + createdSpellId = await System.authPostToServer('spell/add', spellData, userToken, lang); + // Si le livre a une copie locale → addToQueue + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:spell:create', {...spellData, id: createdSpellId}); + } + } + + if (createdSpellId) { + const newSpellListItem: SpellListItem = { + id: createdSpellId, + name: seriesSpellDetail.name, + description: seriesSpellDetail.description.length > 150 + ? seriesSpellDetail.description.substring(0, 150) + '...' + : seriesSpellDetail.description, + tags: [], + seriesSpellId: seriesSpellId, + }; + setSpells(function (prev: SpellListItem[]): SpellListItem[] { + return [...prev, newSpellListItem]; + }); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + }, [entityId, userToken, lang, errorMessage, successMessage, t, isCurrentlyOffline, book, addToQueue, localSyncedBooks]); + + const createTag = useCallback(async function (name: string, color: string): Promise { + try { + if (isSeriesMode) { + // Series mode - dual logic + const addData = { + seriesId: entityId, + name: name, + color: color, + }; + let tagId: string; + if (isCurrentlyOffline() || localSeries) { + tagId = await window.electron.invoke('db:series:spell:tag:add', addData); + } else { + tagId = await System.authPostToServer( + 'series/spell/tag/add', + addData, + userToken, + lang + ); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:spell:tag:add', {...addData, id: tagId}); + } + } + if (tagId) { + const newTag: SpellTagProps = {id: tagId, name: name, color: color}; + setTags(function (prev: SpellTagProps[]): SpellTagProps[] { + return [...prev, newTag]; + }); + return newTag; + } + return null; + } else { + const requestData = { + bookId: entityId, + name: name, + color: color, + }; + let newTag: SpellTagProps; + if (isCurrentlyOffline() || book?.localBook) { + newTag = await window.electron.invoke('db:spell:tag:create', requestData); + } else { + newTag = await System.authPostToServer('spell/tag/add', requestData, userToken, lang); + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:spell:tag:create', {...requestData, id: newTag?.id}); + } + } + if (newTag && newTag.id) { + setTags(function (prev: SpellTagProps[]): SpellTagProps[] { + return [...prev, newTag]; + }); + return newTag; + } + return null; + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + return null; + } + }, [isSeriesMode, entityId, userToken, lang, errorMessage, isCurrentlyOffline, book?.localBook, localSyncedBooks, addToQueue]); + + const updateTag = useCallback(async function (tagId: string, name: string, color: string): Promise { + try { + let success: boolean; + const requestData = {tagId, name, color}; + if (isSeriesMode) { + // Series mode - dual logic + if (isCurrentlyOffline() || localSeries) { + success = await window.electron.invoke('db:series:spell:tag:update', requestData); + } else { + success = await System.authPutToServer('series/spell/tag/update', requestData, userToken, lang); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:spell:tag:update', requestData); + } + } + } else { + if (isCurrentlyOffline() || book?.localBook) { + success = await window.electron.invoke('db:spell:tag:update', requestData); + } else { + success = await System.authPutToServer('spell/tag/update', requestData, userToken, lang); + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:spell:tag:update', requestData); + } + } + } + if (!success) { + errorMessage(t("spellComponent.updateSuccess")); + return false; + } + setTags(function (prev: SpellTagProps[]): SpellTagProps[] { + return prev.map(function (tag: SpellTagProps): SpellTagProps { + return tag.id === tagId ? {id: tagId, name: name, color: color} : tag; + }); + }); + return true; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + return false; + } + }, [isSeriesMode, userToken, lang, errorMessage, t, isCurrentlyOffline, book?.localBook, entityId, localSyncedBooks, addToQueue]); + + const deleteTag = useCallback(async function (tagId: string): Promise { + try { + let success: boolean; + if (isSeriesMode) { + // Series mode - dual logic + const deleteData = {tagId}; + if (isCurrentlyOffline() || localSeries) { + success = await window.electron.invoke('db:series:spell:tag:delete', deleteData); + } else { + success = await System.authDeleteToServer('series/spell/tag/delete', deleteData, userToken, lang); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:spell:tag:delete', deleteData); + } + } + } else { + const requestData = {tagId, bookId: entityId}; + if (isCurrentlyOffline() || book?.localBook) { + success = await window.electron.invoke('db:spell:tag:delete', requestData); + } else { + success = await System.authDeleteToServer('spell/tag/delete', requestData, userToken, lang); + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:spell:tag:delete', requestData); + } + } + } + if (success) { + setTags(function (prev: SpellTagProps[]): SpellTagProps[] { + return prev.filter(function (tag: SpellTagProps): boolean { + return tag.id !== tagId; + }); + }); + return true; + } + return false; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + return false; + } + }, [isSeriesMode, entityId, userToken, lang, errorMessage, isCurrentlyOffline, book?.localBook, localSyncedBooks, addToQueue]); + + const handleSyncComplete = useCallback(async function (): Promise { + if (selectedSpell?.seriesSpellId) { + const seriesSpellResponse: SeriesSpellDetailResponse = await System.authGetQueryToServer( + 'series/spell/detail', + userToken, + lang, + {spellid: selectedSpell.seriesSpellId} + ); + if (seriesSpellResponse) { + setSelectedSeriesSpell(seriesSpellResponse); + } + } + }, [selectedSpell?.seriesSpellId, userToken, lang]); + + const enterDetailMode = useCallback(async function (spell: SpellListItem): Promise { + await selectSpell(spell); + setViewMode('detail'); + setSpellBackup(null); + }, [selectSpell]); + + const enterEditMode = useCallback(function (): void { + if (selectedSpell) { + setSpellBackup({...selectedSpell}); + } + setViewMode('edit'); + }, [selectedSpell]); + + const exitEditMode = useCallback(async function (save: boolean): Promise { + if (save) { + const success: boolean = await saveSpell(); + if (!success) return; + if (spellBackup) { + setViewMode('detail'); + } else { + setViewMode('list'); + } + } else { + if (spellBackup) { + setSelectedSpell(spellBackup); + setViewMode('detail'); + } else { + setSelectedSpell(null); + setViewMode('list'); + } + } + setSpellBackup(null); + }, [saveSpell, spellBackup]); + + const backToList = useCallback(function (): void { + setSelectedSpell(null); + setSelectedSeriesSpell(null); + setSpellBackup(null); + setViewMode('list'); + }, []); + + return { + spells, + seriesSpells, + tags, + selectedSpell, + selectedSeriesSpell, + toolEnabled, + isLoading, + isSeriesMode, + bookSeriesId, + showTagManager, + viewMode, + spellBackup, + selectSpell, + addNewSpell, + clearSelection, + saveSpell, + deleteSpell, + updateSpellField, + toggleTool, + importFromSeries, + exportToSeries, + refreshSeriesSpells, + setSelectedSpell, + setShowTagManager, + enterDetailMode, + enterEditMode, + exitEditMode, + backToList, + createTag, + updateTag, + deleteTag, + handleSyncComplete, + }; +} diff --git a/hooks/settings/useWorlds.ts b/hooks/settings/useWorlds.ts new file mode 100644 index 0000000..97a97b3 --- /dev/null +++ b/hooks/settings/useWorlds.ts @@ -0,0 +1,720 @@ +'use client' +import {useCallback, useContext, useEffect, useState} from 'react'; +import {WorldListResponse, WorldProps} from '@/lib/models/World'; +import {SeriesWorldProps} from '@/lib/models/Series'; +import {SessionContext} from '@/context/SessionContext'; +import {BookContext} from '@/context/BookContext'; +import {AlertContext} from '@/context/AlertContext'; +import {LangContext, LangContextProps} from '@/context/LangContext'; +import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; +import {LocalSyncQueueContext, LocalSyncQueueContextProps} from '@/context/SyncQueueContext'; +import {BooksSyncContext, BooksSyncContextProps} from '@/context/BooksSyncContext'; +import {SyncedBook} from '@/lib/models/SyncedBook'; +import {SeriesContext, SeriesContextProps} from '@/context/SeriesContext'; +import {SeriesSyncContext, SeriesSyncContextProps} from '@/context/SeriesSyncContext'; +import {SyncedSeries} from '@/lib/models/SyncedSeries'; +import System from '@/lib/models/System'; +import {useTranslations} from 'next-intl'; +import {SelectBoxProps} from '@/shared/interface'; +import {ViewMode} from '@/shared/interface'; + +const initialWorldState: WorldProps = { + id: '', + name: '', + history: '', + politics: '', + economy: '', + religion: '', + languages: '', + laws: [], + biomes: [], + issues: [], + customs: [], + kingdoms: [], + climate: [], + resources: [], + wildlife: [], + arts: [], + ethnicGroups: [], + socialClasses: [], + importantCharacters: [], +}; + +export interface UseWorldsConfig { + entityType: 'book' | 'series'; + entityId: string; +} + +export interface UseWorldsReturn { + // State + worlds: WorldProps[]; + seriesWorlds: SeriesWorldProps[]; + selectedWorldIndex: number; + worldsSelector: SelectBoxProps[]; + toolEnabled: boolean; + isLoading: boolean; + isSeriesMode: boolean; + bookSeriesId: string | null; + showAddNewWorld: boolean; + newWorldName: string; + + // Navigation state + viewMode: ViewMode; + worldBackup: WorldProps | null; + + // Actions + selectWorld: (worldId: string) => void; + addNewWorld: () => Promise; + saveWorld: () => Promise; + updateWorldField: (field: keyof WorldProps, value: string) => void; + updateWorldArrayField: (field: keyof WorldProps, value: unknown) => void; + toggleTool: (enabled: boolean) => Promise; + importFromSeries: (seriesWorldId: string) => Promise; + exportToSeries: () => Promise; + refreshWorlds: () => Promise; + refreshSeriesWorlds: () => Promise; + setShowAddNewWorld: (show: boolean) => void; + setNewWorldName: (name: string) => void; + setWorlds: React.Dispatch>; + getSeriesWorldForCurrentWorld: () => SeriesWorldProps | null; + + // Navigation actions + enterDetailMode: (worldId: string) => void; + enterEditMode: () => void; + exitEditMode: (save: boolean) => Promise; + backToList: () => void; +} + +export function useWorlds(config: UseWorldsConfig): UseWorldsReturn { + const {entityType, entityId} = config; + const t = useTranslations(); + const {lang} = useContext(LangContext); + const {session} = useContext(SessionContext); + const {book, setBook} = useContext(BookContext); + const {errorMessage, successMessage} = useContext(AlertContext); + const {isCurrentlyOffline} = useContext(OfflineContext); + const {addToQueue} = useContext(LocalSyncQueueContext); + const {localSyncedBooks} = useContext(BooksSyncContext); + const {localSeries} = useContext(SeriesContext); + const {localSyncedSeries} = useContext(SeriesSyncContext); + + const [worlds, setWorlds] = useState([]); + const [seriesWorlds, setSeriesWorlds] = useState([]); + const [selectedWorldIndex, setSelectedWorldIndex] = useState(0); + const [worldsSelector, setWorldsSelector] = useState([]); + const [toolEnabled, setToolEnabled] = useState(entityType === 'series' || (book?.tools?.worlds ?? false)); + const [isLoading, setIsLoading] = useState(true); + const [showAddNewWorld, setShowAddNewWorld] = useState(false); + const [newWorldName, setNewWorldName] = useState(''); + + // Navigation state + const [viewMode, setViewMode] = useState('list'); + const [worldBackup, setWorldBackup] = useState(null); + + const isSeriesMode: boolean = entityType === 'series'; + const bookSeriesId: string | null = book?.seriesId || null; + const userToken: string = session?.accessToken || ''; + + // Load worlds on mount + useEffect(function (): void { + if (entityId) { + refreshWorlds(); + } + }, [entityId]); + + // Load series worlds for book mode + useEffect(function (): void { + if (bookSeriesId && !isSeriesMode) { + refreshSeriesWorlds(); + } + }, [bookSeriesId, isSeriesMode]); + + const refreshSeriesWorlds = useCallback(async function (): Promise { + if (!bookSeriesId) return; + try { + let response: SeriesWorldProps[]; + // Dual logic: offline ou livre local → IPC, sinon serveur + if (isCurrentlyOffline() || book?.localBook) { + response = await window.electron.invoke( + 'db:series:world:list', + {seriesId: bookSeriesId} + ); + } else { + response = await System.authGetQueryToServer( + 'series/world/list', + userToken, + lang, + {seriesid: bookSeriesId} + ); + } + if (response) { + setSeriesWorlds(response); + } + } catch (e: unknown) { + if (e instanceof Error) { + console.error('Error loading series worlds:', e.message); + } + } + }, [bookSeriesId, userToken, lang, isCurrentlyOffline, book?.localBook]); + + const refreshWorlds = useCallback(async function (): Promise { + setIsLoading(true); + try { + if (isSeriesMode) { + // Series mode - dual logic + let response: SeriesWorldProps[]; + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke( + 'db:series:world:list', + {seriesId: entityId} + ); + } else { + response = await System.authGetQueryToServer( + 'series/world/list', + userToken, + lang, + {seriesid: entityId} + ); + } + if (response) { + const mappedWorlds: WorldProps[] = response.map(function (world: SeriesWorldProps): WorldProps { + return { + id: world.id, + name: world.name, + history: world.history || '', + politics: world.politics || '', + economy: world.economy || '', + religion: world.religion || '', + languages: world.languages || '', + laws: world.laws || [], + biomes: world.biomes || [], + issues: world.issues || [], + customs: world.customs || [], + kingdoms: world.kingdoms || [], + climate: world.climate || [], + resources: world.resources || [], + wildlife: world.wildlife || [], + arts: world.arts || [], + ethnicGroups: world.ethnicGroups || [], + socialClasses: world.socialClasses || [], + importantCharacters: world.importantCharacters || [], + }; + }); + setWorlds(mappedWorlds); + const formattedWorlds: SelectBoxProps[] = response.map(function (world: SeriesWorldProps): SelectBoxProps { + return { + label: world.name, + value: world.id, + }; + }); + setWorldsSelector(formattedWorlds); + } + } else { + let response: WorldListResponse; + if (isCurrentlyOffline()) { + response = await window.electron.invoke('db:book:worlds:get', {bookid: entityId}); + } else if (book?.localBook) { + response = await window.electron.invoke('db:book:worlds:get', {bookid: entityId}); + } else { + response = await System.authGetQueryToServer( + 'book/worlds', + userToken, + lang, + {bookid: entityId} + ); + } + if (response) { + setWorlds(response.worlds); + setToolEnabled(response.enabled); + if (setBook && book) { + setBook({ + ...book, + tools: { + characters: book.tools?.characters ?? false, + worlds: response.enabled, + locations: book.tools?.locations ?? false, + spells: book.tools?.spells ?? false + } + }); + } + const formattedWorlds: SelectBoxProps[] = response.worlds.map(function (world: WorldProps): SelectBoxProps { + return { + label: world.name, + value: world.id.toString(), + }; + }); + setWorldsSelector(formattedWorlds); + } + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("worldSetting.unknownError")); + } + } finally { + setIsLoading(false); + } + }, [entityId, isSeriesMode, userToken, lang, book, setBook, errorMessage, t, isCurrentlyOffline]); + + const selectWorld = useCallback(function (worldId: string): void { + const index: number = worlds.findIndex(function (world: WorldProps): boolean { + return world.id === worldId; + }); + if (index !== -1) { + setSelectedWorldIndex(index); + } + }, [worlds]); + + const updateWorldField = useCallback(function (field: keyof WorldProps, value: string): void { + setWorlds(function (prev: WorldProps[]): WorldProps[] { + const updated: WorldProps[] = [...prev]; + (updated[selectedWorldIndex][field] as string) = value; + return updated; + }); + }, [selectedWorldIndex]); + + const updateWorldArrayField = useCallback(function (field: keyof WorldProps, value: unknown): void { + setWorlds(function (prev: WorldProps[]): WorldProps[] { + const updated: WorldProps[] = [...prev]; + // @ts-ignore - Le type dépend du champ + updated[selectedWorldIndex][field] = value; + return updated; + }); + }, [selectedWorldIndex]); + + const toggleTool = useCallback(async function (enabled: boolean): Promise { + if (isSeriesMode) return; + try { + const requestData = { + bookId: book?.bookId, + toolName: 'worlds', + enabled: enabled + }; + let response: boolean; + if (isCurrentlyOffline() || book?.localBook) { + response = await window.electron.invoke('db:book:tool:update', requestData); + } else { + response = await System.authPatchToServer('book/tool-setting', requestData, userToken, lang); + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === book?.bookId)) { + addToQueue('db:book:tool:update', requestData); + } + } + if (response && setBook && book) { + setToolEnabled(enabled); + setBook({ + ...book, + tools: { + characters: book.tools?.characters ?? false, + worlds: enabled, + locations: book.tools?.locations ?? false, + spells: book.tools?.spells ?? false + } + }); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + }, [isSeriesMode, book, setBook, userToken, lang, errorMessage, isCurrentlyOffline, localSyncedBooks, addToQueue]); + + const addNewWorld = useCallback(async function (): Promise { + if (newWorldName.trim() === '') { + errorMessage(t("worldSetting.newWorldNameError")); + return; + } + try { + let newWorldId: string; + if (isSeriesMode) { + // Series mode - dual logic + const addData = { + seriesId: entityId, + name: newWorldName, + }; + if (isCurrentlyOffline() || localSeries) { + newWorldId = await window.electron.invoke('db:series:world:add', addData); + } else { + newWorldId = await System.authPostToServer( + 'series/world/add', + addData, + userToken, + lang + ); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:world:add', {...addData, id: newWorldId}); + } + } + if (!newWorldId) { + errorMessage(t("worldSetting.addWorldError")); + return; + } + } else { + const requestData = { + worldName: newWorldName, + bookId: entityId, + }; + if (isCurrentlyOffline() || book?.localBook) { + newWorldId = await window.electron.invoke('db:book:world:add', requestData); + } else { + newWorldId = await System.authPostToServer('book/world/add', requestData, userToken, lang); + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:book:world:add', {...requestData, id: newWorldId}); + } + } + if (!newWorldId) { + errorMessage(t("worldSetting.addWorldError")); + return; + } + } + const newWorld: WorldProps = { + ...initialWorldState, + id: newWorldId, + name: newWorldName, + }; + setWorlds(function (prev: WorldProps[]): WorldProps[] { + return [...prev, newWorld]; + }); + setWorldsSelector(function (prev: SelectBoxProps[]): SelectBoxProps[] { + return [...prev, {label: newWorldName, value: newWorldId}]; + }); + setNewWorldName(''); + setShowAddNewWorld(false); + // Sélectionner le nouveau monde et passer en mode edit + setSelectedWorldIndex(worlds.length); // Le nouveau monde est à la fin + setViewMode('edit'); + setWorldBackup(null); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("worldSetting.unknownError")); + } + } + }, [newWorldName, isSeriesMode, entityId, userToken, lang, errorMessage, t, isCurrentlyOffline, book?.localBook, localSyncedBooks, addToQueue]); + + const saveWorld = useCallback(async function (): Promise { + if (worlds.length === 0) return false; + try { + const currentWorld: WorldProps = worlds[selectedWorldIndex]; + if (isSeriesMode) { + // Series mode - dual logic + const updateData = { + worldId: currentWorld.id, + name: currentWorld.name, + history: currentWorld.history, + politics: currentWorld.politics, + economy: currentWorld.economy, + religion: currentWorld.religion, + languages: currentWorld.languages, + }; + let response: boolean; + if (isCurrentlyOffline() || localSeries) { + response = await window.electron.invoke('db:series:world:update', updateData); + } else { + response = await System.authPatchToServer('series/world/update', updateData, userToken, lang); + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) { + addToQueue('db:series:world:update', updateData); + } + } + if (!response) { + errorMessage(t("worldSetting.updateWorldError")); + return false; + } + } else { + const requestData = { + world: currentWorld, + bookId: entityId, + }; + let response: boolean; + if (isCurrentlyOffline() || book?.localBook) { + response = await window.electron.invoke('db:book:world:update', requestData); + } else { + response = await System.authPatchToServer('book/world/update', requestData, userToken, lang); + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:book:world:update', requestData); + } + } + if (!response) { + errorMessage(t("worldSetting.updateWorldError")); + return false; + } + } + successMessage(t("worldSetting.updateWorldSuccess")); + return true; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("worldSetting.unknownError")); + } + return false; + } + }, [worlds, selectedWorldIndex, isSeriesMode, entityId, userToken, lang, errorMessage, successMessage, t, isCurrentlyOffline, book?.localBook, localSyncedBooks, addToQueue]); + + const exportToSeries = useCallback(async function (): Promise { + const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex]; + if (!selectedWorld || !bookSeriesId) return; + + try { + const seriesWorldData = { + seriesId: bookSeriesId, + name: selectedWorld.name, + history: selectedWorld.history || null, + politics: selectedWorld.politics || null, + economy: selectedWorld.economy || null, + religion: selectedWorld.religion || null, + languages: selectedWorld.languages || null, + }; + + let seriesWorldId: string; + if (isCurrentlyOffline() || book?.localBook) { + // Mode offline ou livre local → IPC + seriesWorldId = await window.electron.invoke('db:series:world:add', seriesWorldData); + } else { + // Mode online → Serveur + seriesWorldId = await System.authPostToServer('series/world/add', { + seriesId: bookSeriesId, + world: { + name: selectedWorld.name, + history: selectedWorld.history || null, + politics: selectedWorld.politics || null, + economy: selectedWorld.economy || null, + religion: selectedWorld.religion || null, + languages: selectedWorld.languages || null, + } + }, userToken, lang); + // Si la série a une copie locale → addToQueue + if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === bookSeriesId)) { + addToQueue('db:series:world:add', {...seriesWorldData, id: seriesWorldId}); + } + } + + if (seriesWorldId) { + const updateData = { + world: { + ...selectedWorld, + seriesWorldId: seriesWorldId + }, + bookId: entityId, + }; + + let updateResponse: boolean; + if (isCurrentlyOffline() || book?.localBook) { + // Mode offline ou livre local → IPC + updateResponse = await window.electron.invoke('db:book:world:update', updateData); + } else { + // Mode online → Serveur + updateResponse = await System.authPostToServer('book/world/update', updateData, userToken, lang); + // Si le livre a une copie locale → addToQueue + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:book:world:update', updateData); + } + } + + if (updateResponse) { + setWorlds(function (prev: WorldProps[]): WorldProps[] { + const updated: WorldProps[] = [...prev]; + updated[selectedWorldIndex] = {...selectedWorld, seriesWorldId: seriesWorldId}; + return updated; + }); + const newSeriesWorld: SeriesWorldProps = { + id: seriesWorldId, + name: selectedWorld.name, + history: selectedWorld.history || '', + politics: selectedWorld.politics || '', + economy: selectedWorld.economy || '', + religion: selectedWorld.religion || '', + languages: selectedWorld.languages || '', + laws: [], + biomes: [], + issues: [], + customs: [], + kingdoms: [], + climate: [], + resources: [], + wildlife: [], + arts: [], + ethnicGroups: [], + socialClasses: [], + importantCharacters: [], + }; + setSeriesWorlds(function (prev: SeriesWorldProps[]): SeriesWorldProps[] { + return [...prev, newSeriesWorld]; + }); + successMessage(t("worldSetting.exportSuccess")); + } + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + }, [worlds, selectedWorldIndex, bookSeriesId, userToken, lang, successMessage, errorMessage, t, isCurrentlyOffline, book, addToQueue, localSyncedBooks, localSyncedSeries, entityId]); + + const importFromSeries = useCallback(async function (seriesWorldId: string): Promise { + const seriesWorld: SeriesWorldProps | undefined = seriesWorlds.find(function (w: SeriesWorldProps): boolean { + return w.id === seriesWorldId; + }); + if (!seriesWorld) return; + + try { + const requestData = { + worldName: seriesWorld.name, + bookId: entityId, + seriesWorldId: seriesWorldId, + }; + + let worldId: string; + if (isCurrentlyOffline() || book?.localBook) { + // Mode offline ou livre local → IPC + worldId = await window.electron.invoke('db:book:world:add', requestData); + } else { + // Mode online → Serveur + worldId = await System.authPostToServer('book/world/add', requestData, userToken, lang); + // Si le livre a une copie locale → addToQueue + if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) { + addToQueue('db:book:world:add', {...requestData, id: worldId}); + } + } + + if (!worldId) { + errorMessage(t("worldSetting.importError")); + return; + } + + const newWorld: WorldProps = { + id: worldId, + name: seriesWorld.name, + history: seriesWorld.history || '', + politics: seriesWorld.politics || '', + economy: seriesWorld.economy || '', + religion: seriesWorld.religion || '', + languages: seriesWorld.languages || '', + laws: [], + biomes: [], + issues: [], + customs: [], + kingdoms: [], + climate: [], + resources: [], + wildlife: [], + arts: [], + ethnicGroups: [], + socialClasses: [], + importantCharacters: [], + seriesWorldId: seriesWorldId, + }; + setWorlds(function (prev: WorldProps[]): WorldProps[] { + return [...prev, newWorld]; + }); + setWorldsSelector(function (prev: SelectBoxProps[]): SelectBoxProps[] { + return [...prev, {label: seriesWorld.name, value: worldId}]; + }); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + }, [seriesWorlds, entityId, userToken, lang, errorMessage, t, isCurrentlyOffline, book, addToQueue, localSyncedBooks]); + + const getSeriesWorldForCurrentWorld = useCallback(function (): SeriesWorldProps | null { + const currentWorld: WorldProps | undefined = worlds[selectedWorldIndex]; + if (!currentWorld?.seriesWorldId) return null; + return seriesWorlds.find(function (world: SeriesWorldProps): boolean { + return world.id === currentWorld.seriesWorldId; + }) || null; + }, [worlds, selectedWorldIndex, seriesWorlds]); + + // Navigation functions + const enterDetailMode = useCallback(function (worldId: string): void { + const index: number = worlds.findIndex(function (world: WorldProps): boolean { + return world.id === worldId; + }); + if (index !== -1) { + setSelectedWorldIndex(index); + setViewMode('detail'); + setWorldBackup(null); + } + }, [worlds]); + + const enterEditMode = useCallback(function (): void { + if (worlds.length > 0 && selectedWorldIndex >= 0) { + setWorldBackup({...worlds[selectedWorldIndex]}); + } + setViewMode('edit'); + }, [worlds, selectedWorldIndex]); + + const exitEditMode = useCallback(async function (save: boolean): Promise { + if (save) { + const success: boolean = await saveWorld(); + if (!success) { + // Stay in edit mode on error + return; + } + if (worldBackup) { + setViewMode('detail'); + } else { + setViewMode('list'); + } + } else { + if (worldBackup && selectedWorldIndex >= 0) { + setWorlds(function (prev: WorldProps[]): WorldProps[] { + const updated: WorldProps[] = [...prev]; + updated[selectedWorldIndex] = worldBackup; + return updated; + }); + setViewMode('detail'); + } else { + setViewMode('list'); + } + } + setWorldBackup(null); + }, [saveWorld, worldBackup, selectedWorldIndex]); + + const backToList = useCallback(function (): void { + setSelectedWorldIndex(0); + setWorldBackup(null); + setViewMode('list'); + }, []); + + return { + // State + worlds, + seriesWorlds, + selectedWorldIndex, + worldsSelector, + toolEnabled, + isLoading, + isSeriesMode, + bookSeriesId, + showAddNewWorld, + newWorldName, + + // Navigation state + viewMode, + worldBackup, + + // Actions + selectWorld, + addNewWorld, + saveWorld, + updateWorldField, + updateWorldArrayField, + toggleTool, + importFromSeries, + exportToSeries, + refreshWorlds, + refreshSeriesWorlds, + setShowAddNewWorld, + setNewWorldName, + setWorlds, + getSeriesWorldForCurrentWorld, + + // Navigation actions + enterDetailMode, + enterEditMode, + exitEditMode, + backToList, + }; +} diff --git a/hooks/useSyncBooks.ts b/hooks/useSyncBooks.ts index c6caf68..a0fbe6f 100644 --- a/hooks/useSyncBooks.ts +++ b/hooks/useSyncBooks.ts @@ -9,6 +9,20 @@ import {CompleteBook} from '@/lib/models/Book'; import {BookSyncCompare, SyncedBook} from '@/lib/models/SyncedBook'; import {useTranslations} from 'next-intl'; +interface RemovedItemRecord { + removal_id: string; + table_name: string; + entity_id: string; + book_id: string | null; + user_id: string; + deleted_at: number; +} + +interface SyncedBooksResponse { + books: SyncedBook[]; + tombstones: RemovedItemRecord[]; +} + export default function useSyncBooks() { const t = useTranslations(); const {session} = useContext(SessionContext); @@ -23,7 +37,9 @@ export default function useSyncBooks() { setLocalOnlyBooks, setServerOnlyBooks, setServerSyncedBooks, - setLocalSyncedBooks + setLocalSyncedBooks, + setBooksToSyncFromServer, + setBooksToSyncToServer } = useContext(BooksSyncContext); async function upload(bookId: string): Promise { @@ -115,6 +131,9 @@ export default function useSyncBooks() { errorMessage(t('bookCard.syncFromServerError')); return false; } + setBooksToSyncFromServer((prev: BookSyncCompare[]): BookSyncCompare[] => + prev.filter((book: BookSyncCompare): boolean => book.id !== bookId) + ); return true; } catch (e: unknown) { if (e instanceof Error) { @@ -147,6 +166,9 @@ export default function useSyncBooks() { errorMessage(t('bookCard.syncToServerError')); return false; } + setBooksToSyncToServer((prev: BookSyncCompare[]): BookSyncCompare[] => + prev.filter((book: BookSyncCompare): boolean => book.id !== bookId) + ); return true; } catch (e: unknown) { if (e instanceof Error) { @@ -172,8 +194,39 @@ export default function useSyncBooks() { if (!isCurrentlyOffline()) { if (offlineMode.isDatabaseInitialized) { localBooksResponse = await window.electron.invoke('db:books:synced'); + + // Get lastOnlineTimestamp from localStorage (or 0 if not set) + const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp'); + const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0; + + // Get local tombstones since lastOnlineTimestamp via IPC + const localTombstones: RemovedItemRecord[] = await window.electron.invoke( + 'db:tombstones:since', + lastOnlineTimestamp + ); + + // Call server with POST and tombstones + const serverResponse: SyncedBooksResponse = await System.authPostToServer( + 'books/synced', + { lastOnlineTimestamp, tombstones: localTombstones }, + session.accessToken, + lang + ); + + serverBooksResponse = serverResponse.books; + + // Apply server tombstones locally via IPC + await window.electron.invoke('db:tombstones:apply:books', serverResponse.tombstones); + } else { + // No local DB but online - just get server books without tombstones + const serverResponse: SyncedBooksResponse = await System.authPostToServer( + 'books/synced', + { lastOnlineTimestamp: 0, tombstones: [] }, + session.accessToken, + lang + ); + serverBooksResponse = serverResponse.books; } - serverBooksResponse = await System.authGetQueryToServer('books/synced', session.accessToken, lang); } else { if (offlineMode.isDatabaseInitialized) { localBooksResponse = await window.electron.invoke('db:books:synced'); diff --git a/hooks/useSyncSeries.ts b/hooks/useSyncSeries.ts new file mode 100644 index 0000000..022d67f --- /dev/null +++ b/hooks/useSyncSeries.ts @@ -0,0 +1,352 @@ +import { useContext } from 'react'; +import System from '@/lib/models/System'; +import { SessionContext } from '@/context/SessionContext'; +import { LangContext } from '@/context/LangContext'; +import { AlertContext } from '@/context/AlertContext'; +import OfflineContext from '@/context/OfflineContext'; +import { SeriesSyncContext } from '@/context/SeriesSyncContext'; +import { SeriesSyncCompare, SyncedSeries } from '@/lib/models/SyncedSeries'; +import { useTranslations } from 'next-intl'; + +interface RemovedItemRecord { + removal_id: string; + table_name: string; + entity_id: string; + book_id: string | null; + user_id: string; + deleted_at: number; +} + +interface SyncedSeriesResponse { + series: SyncedSeries[]; + tombstones: RemovedItemRecord[]; +} + +/** + * Complete series data structure for full upload/download operations. + * Mirrors the backend CompleteSeries interface. + */ +interface CompleteSeries { + series: unknown[]; + seriesBooks: unknown[]; + seriesCharacters: unknown[]; + seriesCharacterAttributes: unknown[]; + seriesWorlds: unknown[]; + seriesWorldElements: unknown[]; + seriesLocations: unknown[]; + seriesLocationElements: unknown[]; + seriesLocationSubElements: unknown[]; + seriesSpells: unknown[]; + seriesSpellTags: unknown[]; +} + +/** + * Hook for managing series synchronization between local database and server. + * Provides methods for upload, download, and partial sync operations. + */ +export default function useSyncSeries() { + const t = useTranslations(); + const { session } = useContext(SessionContext); + const { lang } = useContext(LangContext); + const { errorMessage } = useContext(AlertContext); + const { isCurrentlyOffline, offlineMode } = useContext(OfflineContext); + const { + seriesToSyncToServer, + seriesToSyncFromServer, + localOnlySeries, + serverOnlySeries, + setLocalOnlySeries, + setServerOnlySeries, + setServerSyncedSeries, + setLocalSyncedSeries, + setSeriesToSyncFromServer, + setSeriesToSyncToServer + } = useContext(SeriesSyncContext); + + /** + * Uploads a local-only series to the server. + * @param seriesId - The ID of the series to upload + * @returns True if upload was successful, false otherwise + */ + async function upload(seriesId: string): Promise { + if (isCurrentlyOffline()) return false; + + try { + const seriesToSync: CompleteSeries = await window.electron.invoke('db:series:uploadToServer', seriesId); + if (!seriesToSync) { + errorMessage(t('seriesCard.uploadError')); + return false; + } + + const response: boolean = await System.authPostToServer('series/sync/upload', { + series: seriesToSync + }, session.accessToken, lang); + + if (!response) { + errorMessage(t('seriesCard.uploadError')); + return false; + } + + // Move series from local-only to synced + const uploadedSeries: SyncedSeries | undefined = localOnlySeries.find( + (series: SyncedSeries): boolean => series.id === seriesId + ); + setLocalOnlySeries((prevSeries: SyncedSeries[]): SyncedSeries[] => { + return prevSeries.filter((series: SyncedSeries): boolean => series.id !== seriesId); + }); + if (uploadedSeries) { + setLocalSyncedSeries((prev: SyncedSeries[]): SyncedSeries[] => [...prev, uploadedSeries]); + setServerSyncedSeries((prev: SyncedSeries[]): SyncedSeries[] => [...prev, uploadedSeries]); + } + return true; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('seriesCard.uploadError')); + } + return false; + } + } + + /** + * Downloads a server-only series to the local database. + * @param seriesId - The ID of the series to download + * @returns True if download was successful, false otherwise + */ + async function download(seriesId: string): Promise { + if (isCurrentlyOffline()) return false; + + try { + const response: CompleteSeries = await System.authGetQueryToServer( + 'series/sync/download', + session.accessToken, + lang, + { seriesId } + ); + + if (!response) { + errorMessage(t('seriesCard.downloadError')); + return false; + } + + const syncStatus: boolean = await window.electron.invoke('db:series:syncSave', response); + if (!syncStatus) { + errorMessage(t('seriesCard.downloadError')); + return false; + } + + // Move series from server-only to synced + const downloadedSeries: SyncedSeries | undefined = serverOnlySeries.find( + (series: SyncedSeries): boolean => series.id === seriesId + ); + setServerOnlySeries((prevSeries: SyncedSeries[]): SyncedSeries[] => { + return prevSeries.filter((series: SyncedSeries): boolean => series.id !== seriesId); + }); + if (downloadedSeries) { + setLocalSyncedSeries((prev: SyncedSeries[]): SyncedSeries[] => [...prev, downloadedSeries]); + setServerSyncedSeries((prev: SyncedSeries[]): SyncedSeries[] => [...prev, downloadedSeries]); + } + return true; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('seriesCard.downloadError')); + } + return false; + } + } + + /** + * Syncs changes from server to local database for a specific series. + * Only transfers entities that have changed based on the comparison. + * @param seriesId - The ID of the series to sync + * @returns True if sync was successful, false otherwise + */ + async function syncFromServer(seriesId: string): Promise { + if (isCurrentlyOffline()) return false; + + try { + const seriesToFetch: SeriesSyncCompare | undefined = seriesToSyncFromServer.find( + (series: SeriesSyncCompare): boolean => series.id === seriesId + ); + if (!seriesToFetch) { + errorMessage(t('seriesCard.syncFromServerError')); + return false; + } + + const response: CompleteSeries = await System.authPostToServer( + 'series/sync/server-to-client', + { seriesToSync: seriesToFetch }, + session.accessToken, + lang + ); + + if (!response) { + errorMessage(t('seriesCard.syncFromServerError')); + return false; + } + + const syncStatus: boolean = await window.electron.invoke('db:series:sync:toClient', response); + if (!syncStatus) { + errorMessage(t('seriesCard.syncFromServerError')); + return false; + } + + // Remove from pending sync list + setSeriesToSyncFromServer((prev: SeriesSyncCompare[]): SeriesSyncCompare[] => + prev.filter((series: SeriesSyncCompare): boolean => series.id !== seriesId) + ); + return true; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('seriesCard.syncFromServerError')); + } + return false; + } + } + + /** + * Syncs local changes to the server for a specific series. + * Only transfers entities that have changed based on the comparison. + * @param seriesId - The ID of the series to sync + * @returns True if sync was successful, false otherwise + */ + async function syncToServer(seriesId: string): Promise { + if (isCurrentlyOffline()) { + return false; + } + + try { + const seriesToFetch: SeriesSyncCompare | undefined = seriesToSyncToServer.find( + (series: SeriesSyncCompare): boolean => series.id === seriesId + ); + if (!seriesToFetch) { + // La série n'est plus dans la liste - probablement déjà sync par AutoSyncOnReconnect + // Retourner true car ce n'est pas une erreur, juste déjà fait + return true; + } + + const seriesToSync: CompleteSeries = await window.electron.invoke( + 'db:series:sync:toServer', + seriesToFetch + ); + if (!seriesToSync) { + errorMessage(t('seriesCard.syncToServerError')); + return false; + } + + const response: boolean = await System.authPatchToServer( + 'series/sync/client-to-server', + { series: seriesToSync }, + session.accessToken, + lang + ); + + if (!response) { + errorMessage(t('seriesCard.syncToServerError')); + return false; + } + + // Remove from pending sync list + setSeriesToSyncToServer((prev: SeriesSyncCompare[]): SeriesSyncCompare[] => + prev.filter((series: SeriesSyncCompare): boolean => series.id !== seriesId) + ); + return true; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('seriesCard.syncToServerError')); + } + return false; + } + } + + /** + * Syncs all series that have local changes to the server. + */ + async function syncAllToServer(): Promise { + for (const diff of seriesToSyncToServer) { + await syncToServer(diff.id); + } + } + + /** + * Refreshes the sync status of all series by comparing local and server data. + * Updates the context with the latest sync information. + */ + async function refreshSeries(): Promise { + try { + let localSeriesResponse: SyncedSeries[] = []; + let serverSeriesResponse: SyncedSeries[] = []; + + if (!isCurrentlyOffline()) { + if (offlineMode.isDatabaseInitialized) { + localSeriesResponse = await window.electron.invoke('db:series:synced'); + + // Get lastOnlineTimestamp from localStorage (or 0 if not set) + const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp'); + const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0; + + // Get local tombstones since lastOnlineTimestamp via IPC + const localTombstones: RemovedItemRecord[] = await window.electron.invoke( + 'db:tombstones:since', + lastOnlineTimestamp + ); + + // Call server with POST and tombstones + const serverResponse: SyncedSeriesResponse = await System.authPostToServer( + 'series/synced', + { lastOnlineTimestamp, tombstones: localTombstones }, + session.accessToken, + lang + ); + + serverSeriesResponse = serverResponse.series; + + // Apply server tombstones locally via IPC + await window.electron.invoke('db:tombstones:apply:series', serverResponse.tombstones); + } else { + // No local DB but online - just get server series without tombstones + const serverResponse: SyncedSeriesResponse = await System.authPostToServer( + 'series/synced', + { lastOnlineTimestamp: 0, tombstones: [] }, + session.accessToken, + lang + ); + serverSeriesResponse = serverResponse.series; + } + } else { + if (offlineMode.isDatabaseInitialized) { + localSeriesResponse = await window.electron.invoke('db:series:synced'); + } + } + + setServerSyncedSeries(serverSeriesResponse); + setLocalSyncedSeries(localSeriesResponse); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t('seriesCard.refreshError')); + } + } + } + + return { + upload, + download, + syncFromServer, + syncToServer, + syncAllToServer, + refreshSeries, + localOnlySeries, + serverOnlySeries, + seriesToSyncToServer, + seriesToSyncFromServer + }; +} diff --git a/lib/db/sync.service.ts b/lib/db/sync.service.ts deleted file mode 100644 index 7cdc252..0000000 --- a/lib/db/sync.service.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Sync progress interface - */ -export interface SyncProgress { - isSyncing: boolean; - pendingChanges: number; - isOnline: boolean; - lastError?: string; -} - -/** - * Get sync status from local database - */ -export async function getSyncStatus(): Promise { - if (!window.electron) { - return { - isSyncing: false, - pendingChanges: 0, - isOnline: navigator.onLine - }; - } - - try { - const result = await window.electron.invoke('db:sync:status'); - return result; - } catch (error) { - console.error('Failed to get sync status:', error); - return { - isSyncing: false, - pendingChanges: 0, - isOnline: navigator.onLine, - lastError: error instanceof Error ? error.message : 'Unknown error' - }; - } -} - -/** - * Get pending changes to sync - */ -export async function getPendingChanges(limit: number = 100) { - if (!window.electron) { - return []; - } - - try { - const result = await window.electron.invoke('db:sync:pending-changes', limit); - return result || []; - } catch (error) { - console.error('Failed to get pending changes:', error); - return []; - } -} diff --git a/lib/locales/en.json b/lib/locales/en.json index a9de3eb..b440916 100644 --- a/lib/locales/en.json +++ b/lib/locales/en.json @@ -154,7 +154,9 @@ "errorBookCreate": "An error occurred while creating the book.", "errorBooksFetch": "An error occurred while retrieving the books.", "errorBookDetails": "Error fetching book details.", - "errorUnknown": "An unknown error occurred." + "errorUnknown": "An unknown error occurred.", + "seriesSettings": "Series settings", + "emptySeries": "Empty series" }, "bookCard": { "noCoverAlt": "No cover", @@ -200,6 +202,11 @@ "title": "Lyrics Generator", "description": "Create song lyrics in a few clicks.", "badge": "Lyrics" + }, + "addSeries": { + "title": "Create a series", + "description": "Create a series to group multiple books.", + "badge": "SERIES" } } }, @@ -350,6 +357,8 @@ } }, "worldSetting": { + "exportSuccess": "World exported to series successfully.", + "exportToSeries": "Export to series", "getWorldsError": "Error while fetching worlds.", "unknownError": "Unknown error.", "newWorldNameError": "Please enter a name for the new world.", @@ -358,11 +367,19 @@ "createWorldLabel": "Create world", "selectWorldPlaceholder": "Select a world", "noWorldAvailable": "No world available.", + "noWorldDescription": "Create your first world to develop your story's universe.", "newWorldPlaceholder": "New world name...", + "search": "Search for a world...", + "newWorld": "New world", + "deleteTitle": "Delete world", + "deleteMessage": "You are about to permanently delete the world \"{name}\".", "worldName": "World name", "worldNamePlaceholder": "Enter the world name", "worldHistory": "World history", "worldHistoryPlaceholder": "Describe the history of your world", + "basicInfo": "Basic information", + "politicsEconomy": "Politics and economy", + "cultureLanguages": "Culture and languages", "politics": "Political description", "politicsPlaceholder": "The political description of this world...", "economy": "Rules and economic status", @@ -380,8 +397,16 @@ "toolDisabled": "World management disabled." }, "locationComponent": { + "exportSuccess": "Location exported to series successfully.", + "exportToSeries": "Export to series", "newSectionPlaceholder": "New section name", "addSectionLabel": "Add section", + "search": "Search for a section...", + "noSectionDescription": "Create your first section to organize your story's locations.", + "newSection": "New section", + "deleteTitle": "Delete section", + "deleteMessage": "You are about to permanently delete the section \"{name}\".", + "elementName": "Element name", "elementNamePlaceholder": "Element name", "elementDescriptionPlaceholder": "Element description", "subElementsHeading": "Sub-elements", @@ -392,6 +417,10 @@ "newElementPlaceholder": "New element", "noSectionAvailable": "No section available.", "createSectionLabel": "Create section", + "elementsCount": "{count} elements", + "element": "Element", + "addElement": "Add an element", + "addSubElement": "Add a sub-element", "errorSectionNameEmpty": "Section name cannot be empty.", "errorElementNameEmpty": "Element name cannot be empty.", "errorSubElementNameEmpty": "Sub-element name cannot be empty.", @@ -416,7 +445,19 @@ "toolEnabled": "Location management enabled.", "toolDisabled": "Location management disabled." }, + "characterCategories": { + "none": "Select role", + "main": "Main", + "secondary": "Secondary", + "recurring": "Recurring" + }, + "characterStatus": { + "alive": "Alive", + "dead": "Deceased", + "unknown": "Unknown" + }, "characterComponent": { + "exportSuccess": "Character exported to series successfully.", "errorNameRequired": "Character name is required.", "errorCategoryRequired": "Character role is required.", "successAdd": "Character added successfully.", @@ -435,51 +476,53 @@ "characterDetail": { "back": "Back", "newCharacter": "New character", + "exportToSeries": "Export to series", + "deleteTitle": "Delete character", + "deleteMessage": "You are about to permanently delete the character \"{name}\".", "basicInfo": "Basic information", - "name": "First name", - "namePlaceholder": "Enter a first name", + "name": "Name", + "namePlaceholder": "Enter a name", "lastName": "Last name", "lastNamePlaceholder": "Example: Smith", "nickname": "Nickname", - "nicknamePlaceholder": "Nickname or alias", + "nicknamePlaceholder": "Alias or nickname", "role": "Role", "title": "Title", - "titlePlaceholder": "Example: King, Captain, Doctor...", + "titlePlaceholder": "Ex: King, Captain, Doctor...", "gender": "Gender", - "genderPlaceholder": "Character's gender", + "genderPlaceholder": "Ex: Male, Female, Non-binary", "age": "Age", - "agePlaceholder": "Character's age", + "agePlaceholder": "Ex: 25", + "yearsOld": "years old", + "species": "Species", + "speciesPlaceholder": "Ex: Human, Elf, Vampire", + "nationality": "Nationality/Origin", + "nationalityPlaceholder": "Ex: French, Elven", + "status": "Status", + "residence": "Place of residence", + "residencePlaceholder": "Where the character lives", + "speechPattern": "Speech pattern", + "speechPatternPlaceholder": "Verbal tics, accent, vocabulary...", + "catchphrase": "Catchphrase", + "catchphrasePlaceholder": "Character's recurring quote", + "notes": "Author notes", + "notesPlaceholder": "Personal notes, reminders...", + "colorLabel": "Associated color", + "colorPlaceholder": "Ex: #51AE84 or green", + "advancedMode": "Advanced mode", + "showAdvanced": "Show", + "hideAdvanced": "Hide", + "identitySection": "Extended identity", + "voiceSection": "Character voice", + "authorSection": "Author notes", "historySection": "Background", "biography": "Biography", "biographyPlaceholder": "Character biography.", "history": "History", "historyPlaceholder": "Character history...", - "roleFull": "Role in the story", + "roleFull": "Role", "roleFullPlaceholder": "Role of the character in the story", - "advancedMode": "Advanced mode", - "showAdvanced": "Show", - "hideAdvanced": "Hide", - "identitySection": "Extended identity", - "species": "Species", - "speciesPlaceholder": "Human, Elf, Vampire...", - "nationality": "Nationality", - "nationalityPlaceholder": "Country or region of origin", - "status": "Status", - "residence": "Residence", - "residencePlaceholder": "Current place of residence", - "voiceSection": "Character voice", - "speechPattern": "Speech pattern", - "speechPatternPlaceholder": "How does this character speak? Accent, speech quirks...", - "catchphrase": "Catchphrase", - "catchphrasePlaceholder": "A signature phrase of the character", - "authorSection": "Author notes", - "notes": "Notes", - "notesPlaceholder": "Personal notes about this character...", - "colorLabel": "Color", - "colorPlaceholder": "Color associated with the character", - "fetchAttributesError": "Error fetching attributes.", - "deleteTitle": "Delete character", - "deleteMessage": "Are you sure you want to delete {name}? This action cannot be undone." + "fetchAttributesError": "Error fetching attributes." }, "characterList": { "search": "Search for a character...", @@ -488,12 +531,15 @@ "unknown": "Unknown", "noLastName": "No last name", "noTitle": "No title", - "noRole": "No role" + "noRole": "No role", + "noCharacters": "No characters", + "noCharactersDescription": "Add your first character to get started." }, "characterSectionElement": { "newItem": "New {item}" }, "spellComponent": { + "exportSuccess": "Spell exported to series successfully.", "enableTool": "Enable spell book", "enableToolDescription": "Manage the spells and magic of your universe.", "errorNameRequired": "Spell name is required.", @@ -520,6 +566,7 @@ "spellDetail": { "back": "Back", "newSpell": "New spell", + "exportToSeries": "Export to series", "save": "Save", "delete": "Delete", "deleteTitle": "Delete spell", @@ -561,9 +608,13 @@ }, "spellPowerLevels": { "none": "None", - "minor": "Minor", - "moderate": "Moderate", - "major": "Major", + "cantrip": "Cantrip", + "novice": "Novice", + "apprentice": "Apprentice", + "journeyman": "Journeyman", + "expert": "Expert", + "master": "Master", + "grandmaster": "Grandmaster", "legendary": "Legendary", "divine": "Divine" }, @@ -763,6 +814,26 @@ "tip": "💡 Tip: Start with a short story to master the basics, then move to longer formats as you gain experience." } }, + "addNewSeriesForm": { + "title": "Create a new series", + "name": "Series name", + "namePlaceholder": "E.g.: The Chronicles of...", + "description": "Description", + "descriptionPlaceholder": "Describe your series...", + "optional": "optional", + "selectBooks": "Books to include", + "selected": "selected", + "noBooks": "No books available", + "add": "Create", + "adding": "Creating...", + "success": "Series created successfully.", + "error": { + "nameMissing": "Series name is required.", + "nameTooShort": "Name is too short. Minimum 2 characters required.", + "nameTooLong": "Name is too long. Maximum 100 characters allowed.", + "addingSeries": "An error occurred while creating the series." + } + }, "searchBook": { "placeholder": "Search for a book..." }, @@ -893,6 +964,13 @@ "chapbook": "Chapbook", "novel": "Novel" }, + "bookType": { + "short": "Short Story", + "novelette": "Novelette", + "novella": "Novella", + "chapbook": "Chapbook", + "novel": "Novel" + }, "chapterVersions": { "prompt": "Prompt", "draft": "Draft", @@ -907,10 +985,90 @@ "world": "Worlds", "locations": "Locations", "characters": "Characters", - "spells": "Spells", + "spells": "Spell Book", + "quillsense": "QuillSense (AI)", "objects": "Objects", - "goals": "Goals", - "quillsense": "QuillSense" + "goals": "Goals" + }, + "seriesSetting": { + "basicInformation": "Information", + "books": "Books", + "characters": "Characters", + "worlds": "Worlds", + "locations": "Locations", + "spells": "Spell Book", + "backToLibrary": "Back to library", + "errorLoading": "Error loading series", + "deleteSeries": "Delete series", + "deleteConfirmMessage": "This will permanently delete the series and all its elements (characters, worlds, locations, spells). Continue?", + "deleteSuccess": "Series deleted successfully", + "deleteError": "Error deleting series" + }, + "seriesSettingOption": { + "basicInformation": "Basic Information", + "books": "Series Books", + "characters": "Global Characters", + "worlds": "Global Worlds", + "locations": "Global Locations", + "spells": "Global Spell Book", + "notAvailable": "This option is not yet available." + }, + "seriesBasicInformation": { + "error": { + "noFileSelected": "No file selected.", + "removeCover": "Error removing cover.", + "nameRequired": "Series name is required.", + "update": "Error updating series information.", + "unknown": "An unknown error occurred." + }, + "success": { + "update": "Series updated successfully." + }, + "fields": { + "name": "Series Name", + "namePlaceholder": "E.g.: The Chronicles of...", + "description": "Description", + "descriptionPlaceholder": "Describe your series...", + "coverImage": "Cover Image", + "coverImageAlt": "Current cover", + "generateWithQuillSense": "Generate with QuillSense" + } + }, + "seriesBooks": { + "addBook": "Add a book", + "add": "Add", + "selectBookPlaceholder": "Select a book...", + "booksInSeries": "Books in the series", + "noBooks": "No books in this series", + "moveUp": "Move up", + "moveDown": "Move down", + "removeBook": "Remove from series", + "error": { + "selectBook": "Please select a book.", + "unknown": "An unknown error occurred." + }, + "success": { + "saved": "Changes saved." + } + }, + "seriesCharacter": { + "noCharacters": "No global characters in this series." + }, + "seriesCard": { + "book": "book", + "books": "books", + "series": "Series", + "settings": "Series settings", + "synced": "Synced", + "localOnly": "Local only", + "serverOnly": "Server only", + "toSyncFromServer": "Download from server", + "toSyncToServer": "Upload to server", + "uploadError": "Error uploading series.", + "downloadError": "Error downloading series.", + "syncFromServerError": "Error syncing from server.", + "syncToServerError": "Error syncing to server.", + "refreshError": "Error refreshing series." }, "basicInformationSetting": { "error": { @@ -969,12 +1127,28 @@ "insert": "Insert" }, "common": { + "back": "Back", "cancel": "Cancel", "confirm": "Confirm", + "create": "Create", "delete": "Delete", + "deleting": "Deleting...", + "edit": "Edit", + "exportToSeries": "Export to series", + "save": "Save", "unknownError": "An unknown error occurred", "loading": "Loading..." }, + "syncField": { + "uploadSuccess": "{count} element(s) updated successfully.", + "uploadTooltip": "Push to series", + "downloadTooltip": "Pull from series" + }, + "seriesImport": { + "importButton": "Import", + "importFromSeries": "Import from series", + "selectElement": "Select an element" + }, "editor": { "error": { "savedFailed": "Save failed", @@ -1026,7 +1200,9 @@ "offlineInitError": "Error initializing offline mode", "syncError": "Error syncing data", "dbInitError": "Error initializing local database", - "offlineError": "Error checking offline mode" + "offlineError": "Error checking offline mode", + "fetchBooksError": "Error fetching books", + "fetchSeriesError": "Error fetching series" } }, "shortStoryGenerator": { diff --git a/lib/locales/fr.json b/lib/locales/fr.json index a84c3e0..76c77fd 100644 --- a/lib/locales/fr.json +++ b/lib/locales/fr.json @@ -154,7 +154,9 @@ "errorBookCreate": "Une erreur est survenue lors de la création du livre.", "errorBooksFetch": "Une erreur est survenue lors de la récupération des livres.", "errorBookDetails": "Erreur lors de la récupération des détails du livre.", - "errorUnknown": "Une erreur inconnue est survenue." + "errorUnknown": "Une erreur inconnue est survenue.", + "seriesSettings": "Paramètres de la série", + "emptySeries": "Séries vides" }, "bookCard": { "noCoverAlt": "Pas de couverture", @@ -200,6 +202,11 @@ "title": "Générateur de paroles", "description": "Créer des paroles de chanson en quelque cliques.", "badge": "Lyrics" + }, + "addSeries": { + "title": "Créer une série", + "description": "Créez une série pour regrouper plusieurs livres.", + "badge": "SÉRIE" } } }, @@ -350,6 +357,8 @@ } }, "worldSetting": { + "exportSuccess": "Monde exporté vers la série avec succès.", + "exportToSeries": "Exporter vers la série", "getWorldsError": "Erreur lors de la récupération des mondes.", "unknownError": "Erreur inconnu.", "newWorldNameError": "Veuillez entrer un nom pour le nouveau monde.", @@ -358,11 +367,19 @@ "createWorldLabel": "Créer un monde", "selectWorldPlaceholder": "Sélectionner un monde", "noWorldAvailable": "Aucun monde disponible.", + "noWorldDescription": "Créez votre premier monde pour développer l'univers de votre histoire.", "newWorldPlaceholder": "Nom du nouveau monde...", + "search": "Rechercher un monde...", + "newWorld": "Nouveau monde", + "deleteTitle": "Supprimer le monde", + "deleteMessage": "Vous êtes sur le point de supprimer le monde « {name} » définitivement.", "worldName": "Nom du monde", "worldNamePlaceholder": "Entrez le nom du monde", "worldHistory": "Histoire du monde", "worldHistoryPlaceholder": "Décrivez l'histoire de votre monde", + "basicInfo": "Informations de base", + "politicsEconomy": "Politique et économie", + "cultureLanguages": "Culture et langues", "politics": "Description politique", "politicsPlaceholder": "La description politique de ce monde...", "economy": "Règles et statut économique", @@ -380,8 +397,16 @@ "toolDisabled": "Gestion des mondes désactivée." }, "locationComponent": { + "exportSuccess": "Lieu exporté vers la série avec succès.", + "exportToSeries": "Exporter vers la série", "newSectionPlaceholder": "Nom de la nouvelle section", "addSectionLabel": "Ajouter une section", + "search": "Rechercher une section...", + "noSectionDescription": "Créez votre première section pour organiser les lieux de votre histoire.", + "newSection": "Nouvelle section", + "deleteTitle": "Supprimer la section", + "deleteMessage": "Vous êtes sur le point de supprimer la section « {name} » définitivement.", + "elementName": "Nom de l'élément", "elementNamePlaceholder": "Nom de l'élément", "elementDescriptionPlaceholder": "Description de l'élément", "subElementsHeading": "Sous-éléments", @@ -392,6 +417,10 @@ "newElementPlaceholder": "Nouvel élément", "noSectionAvailable": "Aucune section disponible.", "createSectionLabel": "Créer une section", + "elementsCount": "{count} éléments", + "element": "Élément", + "addElement": "Ajouter un élément", + "addSubElement": "Ajouter un sous-élément", "errorSectionNameEmpty": "Le nom de la section ne peut pas être vide.", "errorElementNameEmpty": "Le nom de l'élément ne peut pas être vide.", "errorSubElementNameEmpty": "Le nom du sous-élément ne peut pas être vide.", @@ -417,6 +446,7 @@ "toolDisabled": "Gestion des lieux désactivée." }, "characterComponent": { + "exportSuccess": "Personnage exporté vers la série avec succès.", "errorNameRequired": "Le nom du personnage est requis.", "errorCategoryRequired": "Le rôle du personnage est requis.", "successAdd": "Personnage ajouté avec succès.", @@ -435,20 +465,24 @@ "characterDetail": { "back": "Retour", "newCharacter": "Nouveau personnage", + "exportToSeries": "Exporter vers la série", + "deleteTitle": "Supprimer le personnage", + "deleteMessage": "Vous êtes sur le point de supprimer le personnage « {name} » définitivement.", "basicInfo": "Informations de base", - "name": "Prénom", - "namePlaceholder": "Entrer un prénom", + "name": "Nom", + "namePlaceholder": "Entrer un nom", "lastName": "Nom de famille", "lastNamePlaceholder": "Exemple : Smith", "nickname": "Surnom", - "nicknamePlaceholder": "Surnom ou alias du personnage", + "nicknamePlaceholder": "Alias ou surnom", "role": "Rôle", "title": "Titre", - "titlePlaceholder": "Exemple : Roi, Capitaine, Docteur...", + "titlePlaceholder": "Ex: Roi, Capitaine, Docteur...", "gender": "Genre", - "genderPlaceholder": "Genre du personnage", + "genderPlaceholder": "Ex: Masculin, Féminin, Non-binaire", "age": "Âge", - "agePlaceholder": "Âge du personnage", + "agePlaceholder": "Ex: 25", + "yearsOld": "ans", "historySection": "Parcours", "biography": "Biographie", "biographyPlaceholder": "La biographie du personnage.", @@ -488,12 +522,15 @@ "unknown": "Inconnu", "noLastName": "Sans nom", "noTitle": "Sans titre", - "noRole": "Sans rôle" + "noRole": "Sans rôle", + "noCharacters": "Aucun personnage", + "noCharactersDescription": "Ajoutez votre premier personnage pour commencer." }, "characterSectionElement": { "newItem": "Nouveau {item}" }, "spellComponent": { + "exportSuccess": "Sort exporté vers la série avec succès.", "enableTool": "Activer le grimoire de sorts", "enableToolDescription": "Gérez les sorts et la magie de votre univers.", "errorNameRequired": "Le nom du sort est requis.", @@ -520,6 +557,7 @@ "spellDetail": { "back": "Retour", "newSpell": "Nouveau sort", + "exportToSeries": "Exporter vers la série", "save": "Enregistrer", "delete": "Supprimer", "deleteTitle": "Supprimer le sort", @@ -561,9 +599,13 @@ }, "spellPowerLevels": { "none": "Aucun", - "minor": "Mineur", - "moderate": "Modéré", - "major": "Majeur", + "cantrip": "Tour de magie", + "novice": "Novice", + "apprentice": "Apprenti", + "journeyman": "Compagnon", + "expert": "Expert", + "master": "Maître", + "grandmaster": "Grand maître", "legendary": "Légendaire", "divine": "Divin" }, @@ -970,9 +1012,15 @@ "insert": "Insérer" }, "common": { + "back": "Retour", "cancel": "Annuler", "confirm": "Confirmer", + "create": "Créer", "delete": "Supprimer", + "deleting": "Suppression...", + "edit": "Modifier", + "exportToSeries": "Exporter vers la série", + "save": "Enregistrer", "unknownError": "Une erreur inconnue est survenue", "loading": "Chargement..." }, @@ -1027,7 +1075,9 @@ "offlineInitError": "Erreur lors de l'initialisation du mode hors ligne", "syncError": "Erreur lors de la synchronisation des données", "dbInitError": "Erreur lors de l'initialisation de la base de données locale", - "offlineError": "Erreur lors de la vérification du mode hors ligne" + "offlineError": "Erreur lors de la vérification du mode hors ligne", + "fetchBooksError": "Erreur lors de la récupération des livres", + "fetchSeriesError": "Erreur lors de la récupération des séries" } }, "shortStoryGenerator": { @@ -1139,6 +1189,134 @@ "deleteLocalWarning": "Attention : Cette action supprimera le livre du serveur ET de votre appareil. Cette action est irréversible.", "errorUnknown": "Une erreur inconnue est survenue lors de la suppression du livre." }, + "characterCategories": { + "none": "Sélectionner son rôle", + "main": "Principal", + "secondary": "Secondaire", + "recurring": "Récurrent" + }, + "characterStatus": { + "alive": "Vivant", + "dead": "Décédé", + "unknown": "Inconnu" + }, + "addNewSeriesForm": { + "title": "Créer une nouvelle série", + "name": "Nom de la série", + "namePlaceholder": "Ex: Les Chroniques de...", + "description": "Description", + "descriptionPlaceholder": "Décrivez votre série...", + "optional": "optionnel", + "selectBooks": "Livres à inclure", + "selected": "sélectionné(s)", + "noBooks": "Aucun livre disponible", + "add": "Créer", + "adding": "Création...", + "success": "Série créée avec succès.", + "error": { + "nameMissing": "Le nom de la série est requis.", + "nameTooShort": "Le nom est trop court. Minimum 2 caractères requis.", + "nameTooLong": "Le nom est trop long. Maximum 100 caractères autorisés.", + "addingSeries": "Une erreur est survenue lors de la création de la série." + } + }, + "seriesSetting": { + "basicInformation": "Informations", + "books": "Œuvres", + "characters": "Personnages", + "worlds": "Mondes", + "locations": "Emplacements", + "spells": "Grimoire", + "backToLibrary": "Retour à la bibliothèque", + "errorLoading": "Erreur lors du chargement de la série", + "deleteSeries": "Supprimer la série", + "deleteConfirmMessage": "Cette action supprimera définitivement la série et tous ses éléments (personnages, mondes, lieux, sorts). Continuer?", + "deleteSuccess": "Série supprimée avec succès", + "deleteError": "Erreur lors de la suppression de la série" + }, + "seriesSettingOption": { + "basicInformation": "Informations de base", + "books": "Œuvres de la série", + "characters": "Les personnages", + "worlds": "Gérer les mondes", + "locations": "Vos emplacements", + "spells": "Grimoire de sorts", + "notAvailable": "Cette option n'est pas encore disponible." + }, + "seriesBasicInformation": { + "error": { + "noFileSelected": "Aucun fichier sélectionné.", + "removeCover": "Erreur lors de la suppression de la couverture.", + "nameRequired": "Le nom de la série est obligatoire.", + "update": "Erreur lors de la mise à jour des informations de la série.", + "unknown": "Une erreur inconnue est survenue." + }, + "success": { + "update": "Série mise à jour avec succès." + }, + "fields": { + "name": "Nom de la série", + "namePlaceholder": "Ex: Les Chroniques de...", + "description": "Description", + "descriptionPlaceholder": "Décrivez votre série...", + "coverImage": "Image couverture", + "coverImageAlt": "Couverture actuelle", + "generateWithQuillSense": "Générer avec QuillSense" + } + }, + "seriesBooks": { + "addBook": "Ajouter un livre", + "add": "Ajouter", + "selectBookPlaceholder": "Sélectionner un livre...", + "booksInSeries": "Livres dans la série", + "noBooks": "Aucun livre dans cette série", + "moveUp": "Monter", + "moveDown": "Descendre", + "removeBook": "Retirer de la série", + "error": { + "selectBook": "Veuillez sélectionner un livre.", + "unknown": "Une erreur inconnue est survenue." + }, + "success": { + "saved": "Changements enregistrés." + } + }, + "seriesCharacter": { + "noCharacters": "Aucun personnage global dans cette série." + }, + "seriesCard": { + "book": "livre", + "books": "livres", + "series": "Série", + "settings": "Paramètres de la série", + "synced": "Synchronisé", + "localOnly": "Local uniquement", + "serverOnly": "Sur le serveur uniquement", + "toSyncFromServer": "Télécharger depuis le serveur", + "toSyncToServer": "Envoyer vers le serveur", + "uploadError": "Erreur lors du téléversement de la série.", + "downloadError": "Erreur lors du téléchargement de la série.", + "syncFromServerError": "Erreur lors de la synchronisation depuis le serveur.", + "syncToServerError": "Erreur lors de la synchronisation vers le serveur.", + "refreshError": "Erreur lors du rafraîchissement des séries." + }, + "bookType": { + "short": "Nouvelle", + "novelette": "Novelette", + "novella": "Novella", + "chapbook": "Chapbook", + "novel": "Roman" + }, + "syncField": { + "uploadSuccess": "{count} élément(s) mis à jour avec succès.", + "uploadTooltip": "Envoyer vers la série", + "downloadTooltip": "Récupérer depuis la série" + }, + "seriesImport": { + "importButton": "Importer", + "importFromSeries": "Importer depuis la série", + "selectElement": "Sélectionner un élément" + }, "quillSenseSetting": { "title": "Paramètres QuillSense", "description": "Gérez les fonctionnalités d'intelligence artificielle pour ce livre.", diff --git a/lib/models/Book.ts b/lib/models/Book.ts index 5055669..9f64166 100644 --- a/lib/models/Book.ts +++ b/lib/models/Book.ts @@ -70,6 +70,7 @@ export interface BookProps { title: string; author?: Author; serie?: number; + seriesId?: string | null; subTitle?: string; summary?: string; publicationDate?: string; diff --git a/lib/models/Character.ts b/lib/models/Character.ts index dcb7a0f..d48eb62 100755 --- a/lib/models/Character.ts +++ b/lib/models/Character.ts @@ -23,28 +23,16 @@ import {SelectBoxProps} from "@/shared/interface"; type CharacterCategory = 'main' | 'secondary' | 'recurring' | 'none'; export const characterCategories: SelectBoxProps[] = [ - { - value: 'none', - label: 'Sélectionner son rôle', - }, - { - value: 'main', - label: 'Principal', - }, - { - value: 'secondary', - label: 'Secondaire', - }, - { - value: 'recurring', - label: 'Récurrent', - }, + {value: 'none', label: 'characterCategories.none'}, + {value: 'main', label: 'characterCategories.main'}, + {value: 'secondary', label: 'characterCategories.secondary'}, + {value: 'recurring', label: 'characterCategories.recurring'}, ]; export const characterStatus: SelectBoxProps[] = [ - {value: 'alive', label: 'Vivant'}, - {value: 'dead', label: 'Décédé'}, - {value: 'unknown', label: 'Inconnu'}, + {value: 'alive', label: 'characterStatus.alive'}, + {value: 'dead', label: 'characterStatus.dead'}, + {value: 'unknown', label: 'characterStatus.unknown'}, ]; export interface Relation { @@ -68,7 +56,7 @@ export interface CharacterProps { name: string; lastName: string; nickname: string; - age: string; + age: number | null; gender: string; species: string; nationality: string; @@ -102,6 +90,7 @@ export interface CharacterProps { residence?: string; notes?: string; color?: string; + seriesCharacterId?: string | null; } export interface CharacterListResponse { diff --git a/lib/models/Series.ts b/lib/models/Series.ts new file mode 100644 index 0000000..ddf6245 --- /dev/null +++ b/lib/models/Series.ts @@ -0,0 +1,170 @@ +export interface SeriesProps { + id: string | null; + name: string; + description: string; + coverImage: string | null; +} + +export interface SeriesBookProps { + bookId: string; + title: string; + order: number; + coverImage: string | null; +} + +export interface SeriesDetailResponse { + id: string; + name: string; + description: string; + coverImage: string | null; + books: SeriesBookProps[]; +} + +export interface SeriesAddResponse { + seriesId: string; +} + +export interface SeriesUpdateResponse { + success: boolean; +} + +export interface SeriesListItemProps { + id: string; + name: string; + description: string; + coverImage: string | null; + bookCount: number; + bookIds: string[]; +} + +// Personnages de série +export interface SeriesCharacterListItem { + id: string; + name: string; + lastName: string | null; + category: string; + role: string | null; + color: string | null; + image: string | null; +} + +export interface SeriesCharacterDetailResponse { + id: string; + name: string; + lastName: string | null; + nickname: string | null; + age: number | null; + gender: string | null; + species: string | null; + nationality: string | null; + status: string | null; + category: string; + title: string | null; + image: string | null; + role: string | null; + biography: string | null; + history: string | null; + speechPattern: string | null; + catchphrase: string | null; + residence: string | null; + notes: string | null; + color: string | null; + attributes?: SeriesCharacterAttribute[]; +} + +export interface SeriesCharacterAttribute { + id: string; + name: string; + value: string; +} + +export type SeriesCharacterProps = SeriesCharacterDetailResponse; + +// Mondes de série +export interface SeriesWorldElementItem { + id: string; + name: string; + description: string; +} + +export interface SeriesWorldListItem { + id: string; + name: string; + history: string; + politics: string; + economy: string; + religion: string; + languages: string; + laws: SeriesWorldElementItem[]; + biomes: SeriesWorldElementItem[]; + issues: SeriesWorldElementItem[]; + customs: SeriesWorldElementItem[]; + kingdoms: SeriesWorldElementItem[]; + climate: SeriesWorldElementItem[]; + resources: SeriesWorldElementItem[]; + wildlife: SeriesWorldElementItem[]; + arts: SeriesWorldElementItem[]; + ethnicGroups: SeriesWorldElementItem[]; + socialClasses: SeriesWorldElementItem[]; + importantCharacters: SeriesWorldElementItem[]; +} + +export type SeriesWorldProps = SeriesWorldListItem; + +export interface SeriesWorldElement { + id: string; + type: number; + name: string; + description: string; +} + +// Lieux de série +export interface SeriesLocationSubElement { + id: string; + name: string; + description: string; +} + +export interface SeriesLocationElement { + id: string; + name: string; + description: string; + subElements: SeriesLocationSubElement[]; +} + +export interface SeriesLocationItem { + id: string; + name: string; + elements: SeriesLocationElement[]; +} + +// Sorts de série (Grimoire) +export interface SeriesSpellTag { + id: string; + name: string; + color: string | null; +} + +export interface SeriesSpellListItem { + id: string; + name: string; + description: string; + tags: string[] | null; +} + +export interface SeriesSpellListResponse { + spells: SeriesSpellListItem[]; + tags: SeriesSpellTag[]; +} + +export interface SeriesSpellDetailResponse { + id: string; + name: string; + description: string; + appearance: string; + tags: string[]; + powerLevel: string | null; + components: string | null; + limitations: string | null; + notes: string | null; +} diff --git a/lib/models/Spell.ts b/lib/models/Spell.ts index d671c2a..448a5b3 100644 --- a/lib/models/Spell.ts +++ b/lib/models/Spell.ts @@ -21,6 +21,7 @@ export interface SpellProps { components: string | null; limitations: string | null; notes: string | null; + seriesSpellId?: string | null; } // Pour POST /spell/add et PUT /spell/update @@ -34,6 +35,7 @@ export interface SpellPropsPost { components?: string | null; limitations?: string | null; notes?: string | null; + seriesSpellId?: string | null; } // Item dans la liste (GET /spell/list) @@ -42,6 +44,7 @@ export interface SpellListItem { name: string; description: string; tags: SpellTagProps[]; // Tags résolus (pas les IDs) + seriesSpellId?: string | null; } // Réponse de GET /spell/list @@ -62,6 +65,7 @@ export interface SpellEditState { components: string | null; limitations: string | null; notes: string | null; + seriesSpellId?: string | null; } export const initialSpellState: SpellEditState = { @@ -74,6 +78,7 @@ export const initialSpellState: SpellEditState = { components: null, limitations: null, notes: null, + seriesSpellId: null, }; export const spellPowerLevels: SelectBoxProps[] = [ diff --git a/lib/models/SyncedBook.ts b/lib/models/SyncedBook.ts index fc7a069..a9c63c8 100644 --- a/lib/models/SyncedBook.ts +++ b/lib/models/SyncedBook.ts @@ -7,6 +7,7 @@ export interface SyncedBook { type: string; title: string; subTitle: string | null; + seriesId: string | null; lastUpdate: number; chapters: SyncedChapter[]; characters: SyncedCharacter[]; diff --git a/lib/models/SyncedSeries.ts b/lib/models/SyncedSeries.ts new file mode 100644 index 0000000..68cab65 --- /dev/null +++ b/lib/models/SyncedSeries.ts @@ -0,0 +1,280 @@ +/** + * Lightweight sync structures for series comparison. + * These interfaces mirror the backend SyncedSeries* types from Book.ts + * but are used in the frontend for sync status detection. + */ + +export interface SyncedSeriesBook { + bookId: string; + order: number; + lastUpdate: number; +} + +export interface SyncedSeriesCharacterAttribute { + id: string; + name: string; + lastUpdate: number; +} + +export interface SyncedSeriesCharacter { + id: string; + name: string; + lastUpdate: number; + attributes: SyncedSeriesCharacterAttribute[]; +} + +export interface SyncedSeriesWorldElement { + id: string; + name: string; + lastUpdate: number; +} + +export interface SyncedSeriesWorld { + id: string; + name: string; + lastUpdate: number; + elements: SyncedSeriesWorldElement[]; +} + +export interface SyncedSeriesLocationSubElement { + id: string; + name: string; + lastUpdate: number; +} + +export interface SyncedSeriesLocationElement { + id: string; + name: string; + lastUpdate: number; + subElements: SyncedSeriesLocationSubElement[]; +} + +export interface SyncedSeriesLocation { + id: string; + name: string; + lastUpdate: number; + elements: SyncedSeriesLocationElement[]; +} + +export interface SyncedSeriesSpell { + id: string; + name: string; + lastUpdate: number; +} + +export interface SyncedSeriesSpellTag { + id: string; + name: string; + lastUpdate: number; +} + +export interface SyncedSeries { + id: string; + name: string; + description: string | null; + lastUpdate: number; + books: SyncedSeriesBook[]; + characters: SyncedSeriesCharacter[]; + worlds: SyncedSeriesWorld[]; + locations: SyncedSeriesLocation[]; + spells: SyncedSeriesSpell[]; + spellTags: SyncedSeriesSpellTag[]; +} + +/** + * Comparison result containing IDs of changed entities. + * Used for partial synchronization - only changed entities are transferred. + */ +export interface SeriesSyncCompare { + id: string; + books: string[]; + characters: string[]; + characterAttributes: string[]; + worlds: string[]; + worldElements: string[]; + locations: string[]; + locationElements: string[]; + locationSubElements: string[]; + spells: string[]; + spellTags: string[]; +} + +/** + * Compares two versions of a series to find changed entities. + * The "newer" series is compared against the "older" one to find + * entities that have been added or modified. + * + * @param newerSeries - The series version with potentially newer data + * @param olderSeries - The series version to compare against + * @returns SeriesSyncCompare with IDs of changed entities, or null if no changes + */ +export function compareSeriesSyncs(newerSeries: SyncedSeries, olderSeries: SyncedSeries): SeriesSyncCompare | null { + const changedBookIds: string[] = []; + const changedCharacterIds: string[] = []; + const changedCharacterAttributeIds: string[] = []; + const changedWorldIds: string[] = []; + const changedWorldElementIds: string[] = []; + const changedLocationIds: string[] = []; + const changedLocationElementIds: string[] = []; + const changedLocationSubElementIds: string[] = []; + const changedSpellIds: string[] = []; + const changedSpellTagIds: string[] = []; + + // Compare books + newerSeries.books.forEach((newerBook: SyncedSeriesBook): void => { + const olderBook: SyncedSeriesBook | undefined = olderSeries.books.find( + (book: SyncedSeriesBook): boolean => book.bookId === newerBook.bookId + ); + if (!olderBook || newerBook.lastUpdate > olderBook.lastUpdate) { + changedBookIds.push(newerBook.bookId); + } + }); + + // Compare characters and their attributes + newerSeries.characters.forEach((newerCharacter: SyncedSeriesCharacter): void => { + const olderCharacter: SyncedSeriesCharacter | undefined = olderSeries.characters.find( + (character: SyncedSeriesCharacter): boolean => character.id === newerCharacter.id + ); + + if (!olderCharacter) { + changedCharacterIds.push(newerCharacter.id); + newerCharacter.attributes.forEach((attr: SyncedSeriesCharacterAttribute): void => { + changedCharacterAttributeIds.push(attr.id); + }); + } else if (newerCharacter.lastUpdate > olderCharacter.lastUpdate) { + changedCharacterIds.push(newerCharacter.id); + } else { + // Check attributes even if character hasn't changed + newerCharacter.attributes.forEach((newerAttr: SyncedSeriesCharacterAttribute): void => { + const olderAttr: SyncedSeriesCharacterAttribute | undefined = olderCharacter.attributes.find( + (attr: SyncedSeriesCharacterAttribute): boolean => attr.id === newerAttr.id + ); + if (!olderAttr || newerAttr.lastUpdate > olderAttr.lastUpdate) { + changedCharacterAttributeIds.push(newerAttr.id); + } + }); + } + }); + + // Compare worlds and their elements + newerSeries.worlds.forEach((newerWorld: SyncedSeriesWorld): void => { + const olderWorld: SyncedSeriesWorld | undefined = olderSeries.worlds.find( + (world: SyncedSeriesWorld): boolean => world.id === newerWorld.id + ); + + if (!olderWorld) { + changedWorldIds.push(newerWorld.id); + newerWorld.elements.forEach((element: SyncedSeriesWorldElement): void => { + changedWorldElementIds.push(element.id); + }); + } else if (newerWorld.lastUpdate > olderWorld.lastUpdate) { + changedWorldIds.push(newerWorld.id); + } else { + // Check elements even if world hasn't changed + newerWorld.elements.forEach((newerElement: SyncedSeriesWorldElement): void => { + const olderElement: SyncedSeriesWorldElement | undefined = olderWorld.elements.find( + (element: SyncedSeriesWorldElement): boolean => element.id === newerElement.id + ); + if (!olderElement || newerElement.lastUpdate > olderElement.lastUpdate) { + changedWorldElementIds.push(newerElement.id); + } + }); + } + }); + + // Compare locations, their elements, and sub-elements + newerSeries.locations.forEach((newerLocation: SyncedSeriesLocation): void => { + const olderLocation: SyncedSeriesLocation | undefined = olderSeries.locations.find( + (location: SyncedSeriesLocation): boolean => location.id === newerLocation.id + ); + + if (!olderLocation) { + changedLocationIds.push(newerLocation.id); + newerLocation.elements.forEach((element: SyncedSeriesLocationElement): void => { + changedLocationElementIds.push(element.id); + element.subElements.forEach((subElement: SyncedSeriesLocationSubElement): void => { + changedLocationSubElementIds.push(subElement.id); + }); + }); + } else if (newerLocation.lastUpdate > olderLocation.lastUpdate) { + changedLocationIds.push(newerLocation.id); + } else { + // Check elements + newerLocation.elements.forEach((newerElement: SyncedSeriesLocationElement): void => { + const olderElement: SyncedSeriesLocationElement | undefined = olderLocation.elements.find( + (element: SyncedSeriesLocationElement): boolean => element.id === newerElement.id + ); + + if (!olderElement) { + changedLocationElementIds.push(newerElement.id); + newerElement.subElements.forEach((subElement: SyncedSeriesLocationSubElement): void => { + changedLocationSubElementIds.push(subElement.id); + }); + } else if (newerElement.lastUpdate > olderElement.lastUpdate) { + changedLocationElementIds.push(newerElement.id); + } else { + // Check sub-elements + newerElement.subElements.forEach((newerSubElement: SyncedSeriesLocationSubElement): void => { + const olderSubElement: SyncedSeriesLocationSubElement | undefined = olderElement.subElements.find( + (subElement: SyncedSeriesLocationSubElement): boolean => subElement.id === newerSubElement.id + ); + if (!olderSubElement || newerSubElement.lastUpdate > olderSubElement.lastUpdate) { + changedLocationSubElementIds.push(newerSubElement.id); + } + }); + } + }); + } + }); + + // Compare spells + newerSeries.spells.forEach((newerSpell: SyncedSeriesSpell): void => { + const olderSpell: SyncedSeriesSpell | undefined = olderSeries.spells.find( + (spell: SyncedSeriesSpell): boolean => spell.id === newerSpell.id + ); + if (!olderSpell || newerSpell.lastUpdate > olderSpell.lastUpdate) { + changedSpellIds.push(newerSpell.id); + } + }); + + // Compare spell tags + newerSeries.spellTags.forEach((newerTag: SyncedSeriesSpellTag): void => { + const olderTag: SyncedSeriesSpellTag | undefined = olderSeries.spellTags.find( + (tag: SyncedSeriesSpellTag): boolean => tag.id === newerTag.id + ); + if (!olderTag || newerTag.lastUpdate > olderTag.lastUpdate) { + changedSpellTagIds.push(newerTag.id); + } + }); + + // Check if there are any changes + const hasChanges: boolean = + changedBookIds.length > 0 || + changedCharacterIds.length > 0 || + changedCharacterAttributeIds.length > 0 || + changedWorldIds.length > 0 || + changedWorldElementIds.length > 0 || + changedLocationIds.length > 0 || + changedLocationElementIds.length > 0 || + changedLocationSubElementIds.length > 0 || + changedSpellIds.length > 0 || + changedSpellTagIds.length > 0; + + if (!hasChanges) { + return null; + } + + return { + id: newerSeries.id, + books: changedBookIds, + characters: changedCharacterIds, + characterAttributes: changedCharacterAttributeIds, + worlds: changedWorldIds, + worldElements: changedWorldElementIds, + locations: changedLocationIds, + locationElements: changedLocationElementIds, + locationSubElements: changedLocationSubElementIds, + spells: changedSpellIds, + spellTags: changedSpellTagIds + }; +} diff --git a/lib/models/System.ts b/lib/models/System.ts index 0dfdd6f..85ac8e1 100644 --- a/lib/models/System.ts +++ b/lib/models/System.ts @@ -7,6 +7,11 @@ export default class System{ return pattern.test(input); } + public static timeStampInSeconds(): number { + const date: number = new Date().getTime(); + return Math.floor(date / 1000); + } + public static formatHTMLContent(htmlContent: string): string { return htmlContent .replace(/

/g, '

') diff --git a/lib/models/World.ts b/lib/models/World.ts index d477a05..fe79e64 100755 --- a/lib/models/World.ts +++ b/lib/models/World.ts @@ -11,8 +11,14 @@ import { faSnowflake, faUserCog, faUserFriends, + IconDefinition, } from '@fortawesome/free-solid-svg-icons'; -import {ElementSection} from "@/components/book/settings/world/WorldSetting"; + +export interface ElementSection { + title: string; + section: keyof WorldProps; + icon: IconDefinition; +} export interface WorldElement { id: string; @@ -40,6 +46,7 @@ export interface WorldProps { ethnicGroups: WorldElement[]; socialClasses: WorldElement[]; importantCharacters: WorldElement[]; + seriesWorldId?: string | null; } export interface WorldListResponse { diff --git a/shared/interface.ts b/shared/interface.ts index 6e18585..7747991 100755 --- a/shared/interface.ts +++ b/shared/interface.ts @@ -13,4 +13,6 @@ export interface FormResponse { export interface SelectBoxProps { label: string; value: string; -} \ No newline at end of file +} + +export type ViewMode = 'list' | 'detail' | 'edit'; \ No newline at end of file