Remove CharacterComponent and CharacterDetail components

- Deleted `CharacterComponent` and `CharacterDetail` files from the project.
- Refactored related logic to improve code maintainability and reduce redundancy.
This commit is contained in:
natreex
2026-02-05 14:12:08 -05:00
parent cec5830360
commit 209dc6f85a
133 changed files with 17673 additions and 3110 deletions

View File

@@ -251,10 +251,6 @@ body {
border: none !important;
}
.setting-container {
height: calc(100vh - 10rem);
}
.composer-panel-h {
height: calc(100vh - 8rem);
}

View File

@@ -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<boolean>(false);
const [isRefreshing, setIsRefreshing] = useState<boolean>(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<void>[] = [];
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<SyncedBook[]>([]);
const [localOnlyBooks, setLocalOnlyBooks] = useState<SyncedBook[]>([]);
const [serverSyncedSeries, setServerSyncedSeries] = useState<SyncedSeries[]>([]);
const [localSyncedSeries, setLocalSyncedSeries] = useState<SyncedSeries[]>([]);
const [seriesSyncDiffsFromServer, setSeriesSyncDiffsFromServer] = useState<SeriesSyncCompare[]>([]);
const [seriesSyncDiffsToServer, setSeriesSyncDiffsToServer] = useState<SeriesSyncCompare[]>([]);
const [serverOnlySeries, setServerOnlySeries] = useState<SyncedSeries[]>([]);
const [localOnlySeries, setLocalOnlySeries] = useState<SyncedSeries[]>([]);
const [currentCredits, setCurrentCredits] = useState<number>(160);
const [amountSpent, setAmountSpent] = useState<number>(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]);
useEffect((): void => {
const diffsFromServer: SeriesSyncCompare[] = [];
const diffsToServer: SeriesSyncCompare[] = [];
async function getBooks(): Promise<void> {
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<void> {
try {
let localBooksResponse: SyncedBook[] = [];
let serverBooksResponse: SyncedBook[] = [];
if (!isCurrentlyOffline()){
if (!isCurrentlyOffline()) {
if (offlineMode.isDatabaseInitialized) {
localBooksResponse = await window.electron.invoke<SyncedBook[]>('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<RemovedItemRecord[]>('db:tombstones:since', lastOnlineTimestamp);
const serverResponse: SyncedBooksResponse = await System.authPostToServer<SyncedBooksResponse>('books/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale);
serverBooksResponse = serverResponse.books;
await window.electron.invoke<void>('db:tombstones:apply:books', serverResponse.tombstones);
} else {
const serverResponse: SyncedBooksResponse = await System.authPostToServer<SyncedBooksResponse>('books/synced', { lastOnlineTimestamp: 0, tombstones: [] }, session.accessToken, locale);
serverBooksResponse = serverResponse.books;
}
serverBooksResponse = await System.authGetQueryToServer<SyncedBook[]>('books/synced', session.accessToken, locale);
} else {
if (offlineMode.isDatabaseInitialized) {
localBooksResponse = await window.electron.invoke<SyncedBook[]>('db:books:synced');
@@ -297,6 +405,42 @@ function ScribeContent() {
}
}
async function refreshSeries(): Promise<void> {
try {
let localSeriesResponse: SyncedSeries[] = [];
let serverSeriesResponse: SyncedSeries[] = [];
if (!isCurrentlyOffline()) {
if (offlineMode.isDatabaseInitialized) {
localSeriesResponse = await window.electron.invoke<SyncedSeries[]>('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<RemovedItemRecord[]>('db:tombstones:since', lastOnlineTimestamp);
const serverResponse: SyncedSeriesResponse = await System.authPostToServer<SyncedSeriesResponse>('series/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale);
serverSeriesResponse = serverResponse.series;
await window.electron.invoke<void>('db:tombstones:apply:series', serverResponse.tombstones);
} else {
const serverResponse: SyncedSeriesResponse = await System.authPostToServer<SyncedSeriesResponse>('series/synced', { lastOnlineTimestamp: 0, tombstones: [] }, session.accessToken, locale);
serverSeriesResponse = serverResponse.series;
}
} else {
if (offlineMode.isDatabaseInitialized) {
localSeriesResponse = await window.electron.invoke<SyncedSeries[]>('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,
}}>
<BooksSyncContext.Provider value={{serverSyncedBooks, localSyncedBooks, booksToSyncFromServer:bookSyncDiffsFromServer, booksToSyncToServer:bookSyncDiffsToServer, setServerSyncedBooks, setLocalSyncedBooks, setServerOnlyBooks, setLocalOnlyBooks, serverOnlyBooks, localOnlyBooks}}>
<BooksSyncContext.Provider value={{serverSyncedBooks, localSyncedBooks, booksToSyncFromServer:bookSyncDiffsFromServer, booksToSyncToServer:bookSyncDiffsToServer, setServerSyncedBooks, setLocalSyncedBooks, setServerOnlyBooks, setLocalOnlyBooks, setBooksToSyncFromServer:setBookSyncDiffsFromServer, setBooksToSyncToServer:setBookSyncDiffsToServer, serverOnlyBooks, localOnlyBooks}}>
<SeriesSyncContext.Provider value={{serverSyncedSeries, localSyncedSeries, seriesToSyncFromServer:seriesSyncDiffsFromServer, seriesToSyncToServer:seriesSyncDiffsToServer, setServerSyncedSeries, setLocalSyncedSeries, setServerOnlySeries, setLocalOnlySeries, setSeriesToSyncFromServer:setSeriesSyncDiffsFromServer, setSeriesToSyncToServer:setSeriesSyncDiffsToServer, serverOnlySeries, localOnlySeries}}>
<AutoSyncOnReconnect/>
<BookContext.Provider value={{book: currentBook, setBook: setCurrentBook}}>
<ChapterContext.Provider value={{chapter: currentChapter, setChapter: setCurrentChapter}}>
@@ -650,6 +795,7 @@ function ScribeContent() {
</AIUsageContext.Provider>
</ChapterContext.Provider>
</BookContext.Provider>
</SeriesSyncContext.Provider>
</BooksSyncContext.Provider>
</LocalSyncQueueContext.Provider>
</SessionContext.Provider>

View File

@@ -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(
<FontAwesomeIcon icon={faX} className={'w-5 h-5 transition-transform group-hover:rotate-90'}/>
</button>
</div>
<div className="flex-1 overflow-auto">
<div className={`flex-1 ${enableOverflow ? 'overflow-auto' : 'overflow-hidden'}`}>
{children}
</div>
{

View File

@@ -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<LangContextProps>(LangContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext)
const {serverOnlyBooks,localOnlyBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {serverSyncedBooks, serverOnlyBooks, localOnlyBooks} = useContext<BooksSyncContextProps>(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() {
</div>
<UserMenu/>
</div>
{
showSettingPanel &&
<Modal title={t("controllerBar.bookSettings")}
size={'large'}
onClose={() => setShowSettingPanel(false)}
onConfirm={() => {
}}
children={<BookSetting/>}
enableFooter={false}
/>
}
{showSettingPanel && <BookSetting onClose={() => setShowSettingPanel(false)}/>}
</div>
)
}

View File

@@ -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<boolean>(false);
useEffect((): void => {
setMounted(true);
}, []);
if (!mounted) return null;
return createPortal(
<div className="fixed inset-0 z-40 bg-black/60 backdrop-blur-md flex items-center justify-center">
<div className="w-3/4 h-[85vh] bg-tertiary rounded-2xl border border-secondary/50 shadow-2xl flex flex-col">
<div className="bg-primary px-6 py-4 rounded-t-2xl flex justify-between items-center">
<h2 className="font-['ADLaM_Display'] text-xl text-text-primary">{title}</h2>
<button
onClick={onClose}
className="text-white/80 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-all"
>
<FontAwesomeIcon icon={faX} className="w-5 h-5"/>
</button>
</div>
<div className="flex flex-1 overflow-hidden">
<div className="w-64 bg-secondary/30 border-r border-secondary/50">
{sidebar}
</div>
<div className="flex-1 overflow-y-auto">
{children}
</div>
</div>
</div>
</div>,
document.body
);
}

View File

@@ -75,7 +75,9 @@ export default function StaticAlert(
/>
</div>
<div className="flex-grow mr-3">
<div className="text-text-primary font-medium text-base">{message}</div>
<div className="text-text-primary font-medium text-base">
{typeof message === 'string' ? message : String(message ?? 'Une erreur est survenue')}
</div>
</div>
<button
onClick={handleClose}

131
components/SyncSeries.tsx Normal file
View File

@@ -0,0 +1,131 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCloud, faCloudArrowDown, faCloudArrowUp, faSpinner } from "@fortawesome/free-solid-svg-icons";
import { useTranslations } from "next-intl";
import { useState, useContext, useEffect } from "react";
import OfflineContext, { OfflineContextType } from "@/context/OfflineContext";
import { SeriesSyncType } from "@/context/SeriesSyncContext";
import useSyncSeries from "@/hooks/useSyncSeries";
interface SyncSeriesProps {
seriesId: string;
status: SeriesSyncType;
}
export default function SyncSeries({ seriesId, status }: SyncSeriesProps) {
const t = useTranslations();
const { isCurrentlyOffline } = useContext<OfflineContextType>(OfflineContext);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [currentStatus, setCurrentStatus] = useState<SeriesSyncType>(status);
const { upload: hookUpload, download: hookDownload, syncFromServer: hookSyncFromServer, syncToServer: hookSyncToServer } = useSyncSeries();
// Synchroniser le state local avec le prop quand il change (ex: après sync auto)
useEffect(() => {
setCurrentStatus(status);
}, [status]);
const isOffline: boolean = isCurrentlyOffline();
async function upload(event: React.MouseEvent): Promise<void> {
event.stopPropagation();
if (isOffline) return;
setIsLoading(true);
const success: boolean = await hookUpload(seriesId);
if (success) setCurrentStatus('synced');
setIsLoading(false);
}
async function download(event: React.MouseEvent): Promise<void> {
event.stopPropagation();
if (isOffline) return;
setIsLoading(true);
const success = await hookDownload(seriesId);
if (success) setCurrentStatus('synced');
setIsLoading(false);
}
async function syncFromServer(event: React.MouseEvent): Promise<void> {
event.stopPropagation();
if (isOffline) return;
setIsLoading(true);
const success = await hookSyncFromServer(seriesId);
if (success) setCurrentStatus('synced');
setIsLoading(false);
}
async function syncToServer(event: React.MouseEvent): Promise<void> {
event.stopPropagation();
if (isOffline || isLoading) return;
setIsLoading(true);
const success = await hookSyncToServer(seriesId);
if (success) setCurrentStatus('synced');
setIsLoading(false);
}
if (isLoading) {
return (
<div className="flex items-center gap-2">
<span className="text-primary">
<FontAwesomeIcon icon={faSpinner} className="w-4 h-4 animate-spin"/>
</span>
</div>
);
}
return (
<div className="flex items-center gap-2">
{currentStatus === 'synced' && (
<span
className="text-gray-light"
title={t("seriesCard.synced")}
>
<FontAwesomeIcon icon={faCloud} className="w-4 h-4"/>
</span>
)}
{currentStatus === 'local-only' && (
<button
onClick={upload}
className={`transition-colors ${isOffline ? 'text-gray-dark cursor-not-allowed' : 'text-gray hover:text-primary cursor-pointer'}`}
title={t("seriesCard.localOnly")}
type="button"
disabled={isOffline}
>
<FontAwesomeIcon icon={faCloudArrowUp} className="w-4 h-4"/>
</button>
)}
{currentStatus === 'server-only' && (
<button
onClick={download}
className={`transition-colors ${isOffline ? 'text-gray-dark cursor-not-allowed' : 'text-gray hover:text-primary cursor-pointer'}`}
title={t("seriesCard.serverOnly")}
type="button"
disabled={isOffline}
>
<FontAwesomeIcon icon={faCloudArrowDown} className="w-4 h-4"/>
</button>
)}
{currentStatus === 'to-sync-from-server' && (
<button
onClick={syncFromServer}
className={`transition-colors ${isOffline ? 'text-gray-dark cursor-not-allowed' : 'text-warning hover:text-primary cursor-pointer'}`}
title={t("seriesCard.toSyncFromServer")}
type="button"
disabled={isOffline}
>
<FontAwesomeIcon icon={faCloudArrowDown} className="w-4 h-4"/>
</button>
)}
{currentStatus === 'to-sync-to-server' && (
<button
onClick={syncToServer}
className={`transition-colors ${isOffline ? 'text-gray-dark cursor-not-allowed' : 'text-warning hover:text-primary cursor-pointer'}`}
title={t("seriesCard.toSyncToServer")}
type="button"
disabled={isOffline}
>
<FontAwesomeIcon icon={faCloudArrowUp} className="w-4 h-4"/>
</button>
)}
</div>
);
}

View File

@@ -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: []
}]);
}

View File

@@ -18,8 +18,8 @@ export default function BookCard({book, onClickCallback, index, syncStatus}: Boo
const t = useTranslations();
return (
<div
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 hover:scale-105">
<div className="relative w-full h-[400px] sm:h-32 md:h-48 lg:h-64 xl:h-80 flex-shrink-0 overflow-hidden">
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">
<div className="relative w-full aspect-[2/3] flex-shrink-0 overflow-hidden rounded-t-2xl">
<button onClick={(): void => onClickCallback(book.bookId)} className="w-full h-full text-left block" type="button">
{book.coverImage ? (
<img

View File

@@ -15,7 +15,18 @@ import {useTranslations} from "next-intl";
import {LangContext, LangContextProps} from "@/context/LangContext";
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
import {BooksSyncContext, BooksSyncContextProps, SyncType} from "@/context/BooksSyncContext";
import {SeriesSyncContext, SeriesSyncContextProps, SeriesSyncType} from "@/context/SeriesSyncContext";
import {BookSyncCompare, SyncedBook} from "@/lib/models/SyncedBook";
import {SeriesSyncCompare, SyncedSeries} from "@/lib/models/SyncedSeries";
import {SeriesListItemProps} from "@/lib/models/Series";
import SeriesCard, {SeriesCardProps} from "@/components/series/SeriesCard";
import SeriesSetting from "@/components/series/SeriesSetting";
interface CategoryItem {
type: 'book' | 'series';
book?: BookProps;
series?: SeriesCardProps;
}
export default function BookList() {
const {session, setSession} = useContext(SessionContext);
@@ -23,13 +34,27 @@ export default function BookList() {
const {errorMessage} = useContext(AlertContext);
const {setBook} = useContext(BookContext);
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext)
const {isCurrentlyOffline, offlineMode} = useContext<OfflineContextType>(OfflineContext)
const {booksToSyncFromServer, booksToSyncToServer, serverOnlyBooks, localOnlyBooks} = useContext<BooksSyncContextProps>(BooksSyncContext)
const {lang} = useContext<LangContextProps>(LangContext);
const {isCurrentlyOffline, offlineMode} = useContext<OfflineContextType>(OfflineContext);
const {
booksToSyncFromServer,
booksToSyncToServer,
serverOnlyBooks,
localOnlyBooks,
serverSyncedBooks
} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {
seriesToSyncFromServer,
seriesToSyncToServer,
serverOnlySeries,
localOnlySeries
} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
const [searchQuery, setSearchQuery] = useState<string>('');
const [groupedBooks, setGroupedBooks] = useState<Record<string, BookProps[]>>({});
const [groupedItems, setGroupedItems] = useState<Record<string, CategoryItem[]>>({});
const [isLoadingBooks, setIsLoadingBooks] = useState<boolean>(true);
const [showSeriesSettingId, setShowSeriesSettingId] = useState<string | null>(null);
const [isLocalSeries, setIsLocalSeries] = useState<boolean>(false);
const [bookGuide, setBookGuide] = useState<boolean>(false);
@@ -79,21 +104,21 @@ export default function BookList() {
</div>
),
},
]
];
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,7 +128,8 @@ export default function BookList() {
booksToSyncFromServer,
booksToSyncToServer,
serverOnlyBooks,
localOnlyBooks
localOnlyBooks,
serverSyncedBooks
]);
async function handleFirstBookGuide(): Promise<void> {
@@ -136,39 +162,91 @@ export default function BookList() {
}
}
async function getBooks(): Promise<void> {
async function loadBooksAndSeries(): Promise<void> {
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<BookProps[]>('books', accessToken, lang),
offlineMode.isDatabaseInitialized
? window.electron.invoke<BookProps[]>('db:book:books')
: Promise.resolve([]),
System.authGetQueryToServer<SeriesListItemProps[]>('series/list', accessToken, lang),
offlineMode.isDatabaseInitialized
? window.electron.invoke<SeriesListItemProps[]>('db:series:list')
: Promise.resolve([])
]);
const onlineBookIds: Set<string> = 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<BookProps[]>('db:book:books');
bookResponse = localBooks.map((book: BookProps): BookProps & { itIsLocal: boolean } => ({ ...book, itIsLocal: true }));
const [localBooks, localSeries] = await Promise.all([
window.electron.invoke<BookProps[]>('db:book:books'),
window.electron.invoke<SeriesListItemProps[]>('db:series:list')
]);
booksResponse = localBooks.map(b => ({...b, itIsLocal: true}));
seriesResponse = localSeries;
}
console.log(bookResponse);
if (bookResponse) {
const booksByType: Record<string, BookProps[]> = bookResponse.reduce((groups: Record<string, BookProps[]>, book: BookProps): Record<string, BookProps[]> => {
const imageDataUrl: string = book.coverImage ? 'data:image/jpeg;base64,' + book.coverImage : '';
const categoryLabel: string = Book.getBookTypeLabel(book.type);
const transformedBook: BookProps = {
// ═══════════════════════════════════════════════════════════════
// PARTIE 2 : CRÉATION DU MAPPING BOOK → SERIES
// ═══════════════════════════════════════════════════════════════
const bookToSeriesMap: Map<string, SeriesListItemProps> = 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: categoryLabel,
type: Book.getBookTypeLabel(book.type),
title: book.title,
subTitle: book.subTitle,
summary: book.summary,
@@ -177,18 +255,78 @@ export default function BookList() {
desiredWordCount: book.desiredWordCount,
totalWordCount: 0,
coverImage: imageDataUrl,
itIsLocal: book.itIsLocal
};
if (!groups[t(categoryLabel)]) {
groups[t(categoryLabel)] = [];
});
// ═══════════════════════════════════════════════════════════════
// PARTIE 4 : GROUPEMENT PAR CATÉGORIE AVEC SÉRIES
// ═══════════════════════════════════════════════════════════════
const itemsByCategory: Record<string, CategoryItem[]> = {};
const processedSeriesIds: Set<string> = new Set();
transformedBooks.forEach((book): void => {
const categoryLabel: string = t(book.type);
if (!itemsByCategory[categoryLabel]) {
itemsByCategory[categoryLabel] = [];
}
groups[t(categoryLabel)].push(transformedBook);
return groups;
}, {});
setGroupedBooks(booksByType);
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
};
itemsByCategory[categoryLabel].push({
type: 'series',
series: seriesCard
});
} else if (!seriesInfo) {
// Livre individuel (pas dans une série)
itemsByCategory[categoryLabel].push({
type: 'book',
book: book
});
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
});
// 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] = [];
}
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"));
}
@@ -197,89 +335,127 @@ export default function BookList() {
}
}
const filteredGroupedBooks: Record<string, BookProps[]> = Object.entries(groupedBooks).reduce(
(acc: Record<string, BookProps[]>, [category, books]: [string, BookProps[]]): Record<string, BookProps[]> => {
const filteredBooks: BookProps[] = books.filter((book: BookProps): boolean =>
book.title.toLowerCase().includes(searchQuery.toLowerCase())
);
function getFilteredGroupedItems(): Record<string, CategoryItem[]> {
if (!searchQuery) {
return groupedItems;
}
if (filteredBooks.length > 0) {
acc[category] = filteredBooks;
}
return acc;
},
{}
);
const filtered: Record<string, CategoryItem[]> = {};
function detectBookSyncStatus(bookId: string):SyncType {
if (serverOnlyBooks.find((book: SyncedBook):boolean => book.id === bookId)) {
return 'server-only';
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 (localOnlyBooks.find((book: SyncedBook):boolean => book.id === bookId)) {
return 'local-only';
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;
}
if (booksToSyncFromServer.find((book: BookSyncCompare):boolean => book.id === bookId)) {
return 'to-sync-from-server';
return false;
});
if (filteredItems.length > 0) {
filtered[category] = filteredItems;
}
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<void> {
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<void> {
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<BookProps>(`book/basic-information`, accessToken, lang, {id: bookId});
bookResponse = await System.authGetQueryToServer<BookProps>(
'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 (
<div
className="flex flex-col items-center h-full overflow-hidden w-full text-text-primary font-['Lora']">
@@ -313,40 +489,58 @@ export default function BookList() {
</div>
</div>
</>
) : Object.entries(filteredGroupedBooks).length > 0 ? (
) : Object.entries(filteredItems).length > 0 ? (
<>
<div className="text-center mb-8 px-6">
<h1 className="font-['ADLaM_Display'] text-4xl mb-3 text-text-primary">{t("bookList.library")}</h1>
<p className="text-muted italic text-lg">{t("bookList.booksAreMirrors")}</p>
</div>
{Object.entries(filteredGroupedBooks).map(([category, books], index) => (
<div {...(index === 0 && {'data-guide': 'book-category'})} key={category}
{Object.entries(filteredItems).map(([category, items], index) => (
<div key={category} {...(index === 0 && {'data-guide': 'book-category'})}
className="w-full mb-10">
<div
className="flex justify-between items-center w-full max-w-5xl mx-auto mb-6 px-6">
<h2 className="text-3xl text-text-primary capitalize font-['ADLaM_Display'] flex items-center gap-3">
<span className="w-1 h-8 bg-primary rounded-full"></span>
<span>{category}</span>
{category}
</h2>
<span
className="text-muted text-lg font-medium bg-secondary/30 px-4 py-1.5 rounded-full">{books.length} {t("bookList.works")}</span>
className="text-muted text-lg font-medium bg-secondary/30 px-4 py-1.5 rounded-full">
{getTotalItemsCount(items)} {t("bookList.works")}
</span>
</div>
<div className="flex flex-wrap justify-center items-start w-full px-4">
{
books.map((book: BookProps, idx) => (
<div key={book.bookId || `book-${idx}`}
<div className="flex items-start justify-center w-full px-4 overflow-x-auto pb-4">
{items.map((item, idx) => {
if (item.type === 'book' && item.book) {
return (
<div key={item.book.bookId}
{...(idx === 0 && {'data-guide': 'book-card'})}
className={`w-full sm:w-1/3 md:w-1/4 lg:w-1/5 xl:w-1/6 p-2 box-border ${User.guideTourDone(session.user?.guideTour || [], 'new-first-book') && 'mb-[200px]'}`}>
<BookCard book={book}
syncStatus={detectBookSyncStatus(book.bookId)}
onClickCallback={getBook}
className={`flex-shrink-0 w-64 sm:w-52 md:w-48 lg:w-56 xl:w-64 p-2 box-border ${User.guideTourDone(session.user?.guideTour || [], 'new-first-book') && 'mb-[200px]'}`}>
<BookCard
book={item.book}
syncStatus={detectBookSyncStatus(item.book.bookId)}
onClickCallback={handleBookClick}
index={idx}
/>
</div>
))
);
}
if (item.type === 'series' && item.series) {
return (
<SeriesCard
key={item.series.id}
series={item.series}
onBookClick={handleBookClick}
onSettingsClick={handleSeriesSettingsClick}
getSyncStatus={detectBookSyncStatus}
seriesSyncStatus={detectSeriesSyncStatus(item.series.id)}
/>
);
}
return null;
})}
</div>
</div>
))}
@@ -354,8 +548,9 @@ export default function BookList() {
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center p-8 max-w-lg">
<div className="w-24 h-24 bg-primary/20 text-primary rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg">
<FontAwesomeIcon icon={faBook} size={'3x'}/>
<div
className="w-24 h-24 bg-primary/20 text-primary rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg animate-pulse">
<FontAwesomeIcon icon={faBook} className={'w-12 h-12'}/>
</div>
<h2 className="text-4xl font-['ADLaM_Display'] mb-4 text-text-primary">{t("bookList.welcomeWritingWorkshop")}</h2>
<p className="text-muted mb-6 text-lg leading-relaxed">
@@ -369,6 +564,16 @@ export default function BookList() {
bookGuide && <GuideTour stepId={0} steps={bookGuideSteps} onComplete={handleFirstBookGuide}
onClose={() => setBookGuide(false)}/>
}
{showSeriesSettingId && (
<SeriesSetting
seriesId={showSeriesSettingId}
localSeries={isLocalSeries}
onClose={() => {
setShowSeriesSettingId(null);
setIsLocalSeries(false);
}}
/>
)}
</div>
);
}

View File

@@ -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<string>('basic-information')
return (
<div
className={'flex justify-start bg-tertiary/90 backdrop-blur-sm rounded-2xl overflow-hidden border border-secondary/50 shadow-2xl'}>
<div className={'bg-secondary/30 backdrop-blur-sm w-1/4 border-r border-secondary/50'}>
<BookSettingSidebar selectedSetting={currentSetting} setSelectedSetting={setCurrentSetting}/>
</div>
<div className={'flex-1 setting-container bg-tertiary/50 p-6'}>
<BookSettingOption setting={currentSetting}/>
</div>
</div>
)
interface BookSettingProps {
onClose: () => void;
}
export default function BookSetting({onClose}: BookSettingProps) {
const t = useTranslations();
const [currentSetting, setCurrentSetting] = useState<string>('basic-information');
return (
<SettingsPanel
title={t("controllerBar.bookSettings")}
sidebar={<BookSettingSidebar selectedSetting={currentSetting} setSelectedSetting={setCurrentSetting}/>}
onClose={onClose}
>
<BookSettingOption setting={currentSetting}/>
</SettingsPanel>
);
}

View File

@@ -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,
}: {
// 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 (
<div className="flex items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
</div>
);
}
interface BookSettingOptionProps {
setting: string;
}) {
const t = useTranslations(); // Ajouté pour la traduction
}
const basicInfoRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
handleSave: () => Promise<void>
}>(null);
const guideLineRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
handleSave: () => Promise<void>
}>(null);
const storyRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
handleSave: () => Promise<void>
}>(null);
const worldRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
handleSave: () => Promise<void>
}>(null);
const locationRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
handleSave: () => Promise<void>
}>(null);
const characterRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
handleSave: () => Promise<void>
}>(null);
const spellRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
handleSave: () => Promise<void>
}>(null);
const quillSenseRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
handleSave: () => Promise<void>
}>(null);
interface SettingRef {
handleSave: () => Promise<void>;
}
// 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<SettingRef>(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<void> {
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 (
<div className="space-y-5">
<div className="px-6">
<div className="sticky top-0 z-10 bg-tertiary pt-6 pb-4">
<PanelHeader
icon={faPen}
badge={`BI`}
badge="BI"
title={renderTitle()}
description={``}
secondActionCallback={handleSaveClick}
callBackAction={handleSaveClick}
secondActionIcon={faSave}
description=""
secondActionCallback={showSaveButton ? handleSaveClick : undefined}
callBackAction={showSaveButton ? handleSaveClick : undefined}
secondActionIcon={showSaveButton ? faSave : undefined}
/>
<div className="bg-secondary/10 rounded-xl overflow-auto max-h-[calc(100vh-250px)] p-1">
{
setting === 'basic-information' ? (
<BasicInformationSetting ref={basicInfoRef}/>
) : setting === 'guide-line' ? (
<GuideLineSetting ref={guideLineRef}/>
) : setting === 'story' ? (
<StorySetting ref={storyRef}/>
) : setting === 'world' ? (
<WorldSetting ref={worldRef}/>
) : setting === 'locations' ? (
<LocationComponent ref={locationRef}/>
) : setting === 'characters' ? (
<CharacterComponent ref={characterRef}/>
) : setting === 'spells' ? (
<SpellComponent ref={spellRef}/>
) : setting === 'quillsense' ? (
<QuillSenseSetting ref={quillSenseRef}/>
) : <div
className="text-text-secondary py-4 text-center">{t("bookSettingOption.notAvailable")}</div>
}
</div>
<div className="bg-secondary/10 rounded-xl p-1 mb-6">
<Suspense fallback={<LoadingSpinner/>}>
{setting === 'basic-information' && <BasicInformationSetting ref={settingRef}/>}
{setting === 'guide-line' && <GuideLineSetting ref={settingRef}/>}
{setting === 'story' && <StorySetting ref={settingRef}/>}
{setting === 'quillsense' && <QuillSenseSetting ref={settingRef}/>}
{(setting === 'world' || setting === 'worlds') && (
<WorldSettings entityType="book" showToggle={true}/>
)}
{setting === 'locations' && (
<LocationSettings entityType="book" showToggle={true}/>
)}
{setting === 'characters' && (
<CharacterSettings entityType="book" showToggle={true}/>
)}
{setting === 'spells' && (
<SpellSettings entityType="book" showToggle={true}/>
)}
{!['basic-information', 'guide-line', 'story', 'world', 'worlds', 'locations', 'characters', 'spells', 'quillsense'].includes(setting) && (
<div className="text-text-secondary py-4 text-center">
{t("bookSettingOption.notAvailable")}
</div>
)}
</Suspense>
</div>
</div>
)
);
}

View File

@@ -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,6 +31,7 @@ export default function BookSettingSidebar(
setSelectedSetting: Dispatch<SetStateAction<string>>
}) {
const t = useTranslations();
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const settings: BookSettingOption[] = [
{
@@ -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 (
<div className="py-6 px-3">
<nav className="space-y-1">
{
settings.map((setting: BookSettingOption) => (
availableSettings.map((setting: BookSettingOption) => (
<button
key={setting.id}
onClick={(): void => setSelectedSetting(setting.id)}

View File

@@ -36,7 +36,8 @@ export default function DeleteBook({bookId}: DeleteBookProps) {
async function handleDeleteBook(): Promise<void> {
try {
let response: boolean;
const deleteData = { id: bookId };
const deletedAt: number = System.timeStampInSeconds();
const deleteData = { id: bookId, deletedAt };
if (isCurrentlyOffline() || ifLocalOnlyBook) {
response = await window.electron.invoke<boolean>('db:book:delete', deleteData);

View File

@@ -0,0 +1,169 @@
'use client';
import React from 'react';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faArrowLeft, faEdit, faPlus, faSave, faShare, faTrash, faTimes} from '@fortawesome/free-solid-svg-icons';
import {IconDefinition} from '@fortawesome/fontawesome-svg-core';
import {useTranslations} from 'next-intl';
import {ViewMode} from '@/shared/interface';
interface ActionButton {
icon: IconDefinition;
onClick: () => void;
title?: string;
variant?: 'primary' | 'danger' | 'secondary' | 'blue';
}
interface SidebarHeaderProps {
title: string;
defaultTitle: string;
viewMode: ViewMode;
isNew: boolean;
onBack: () => void;
onEdit?: () => void;
onSave?: () => void;
onCancel?: () => void;
onDelete?: () => void;
onExport?: () => void;
showExport?: boolean;
showDelete?: boolean;
}
/**
* ToolDetailHeader - Header pour les composants Editor (ComposerRightBar) et Settings Detail/Edit
* Comportement par mode:
* - list: Retourne null (pas de header)
* - detail: Boutons Back, Edit, Delete, Export (si applicable)
* - edit: Boutons Cancel, Save/Create
*/
export default function ToolDetailHeader({
title,
defaultTitle,
viewMode,
isNew,
onBack,
onEdit,
onSave,
onCancel,
onDelete,
onExport,
showExport = false,
showDelete = true,
}: SidebarHeaderProps): React.JSX.Element | null {
const t = useTranslations();
if (viewMode === 'list') {
return null;
}
function getVariantClasses(variant: 'primary' | 'danger' | 'secondary' | 'blue'): string {
switch (variant) {
case 'primary':
return 'bg-primary/10 hover:bg-primary/20 border-primary/30';
case 'danger':
return 'bg-error/10 hover:bg-error/20 border-error/30';
case 'blue':
return 'bg-blue-500/10 hover:bg-blue-500/20 border-blue-500/30';
case 'secondary':
default:
return 'bg-secondary/50 hover:bg-secondary border-secondary/50 hover:border-secondary';
}
}
function getIconColorClass(variant: 'primary' | 'danger' | 'secondary' | 'blue'): string {
switch (variant) {
case 'primary':
return 'text-primary';
case 'danger':
return 'text-error';
case 'blue':
return 'text-blue-500';
case 'secondary':
default:
return 'text-text-primary';
}
}
function renderActionButton(button: ActionButton, index: number): React.JSX.Element {
const variant = button.variant || 'secondary';
return (
<button
key={`action-${index}`}
onClick={button.onClick}
title={button.title}
className={`group flex items-center justify-center w-10 h-10 rounded-lg border hover:shadow-md hover:scale-110 transition-all duration-200 ${getVariantClasses(variant)}`}
>
<FontAwesomeIcon icon={button.icon} className={`w-4 h-4 transition-transform group-hover:scale-110 ${getIconColorClass(variant)}`}/>
</button>
);
}
function getActionButtons(): ActionButton[] {
const buttons: ActionButton[] = [];
if (viewMode === 'detail') {
if (showExport && onExport) {
buttons.push({
icon: faShare,
onClick: onExport,
title: t('common.exportToSeries'),
variant: 'blue',
});
}
if (showDelete && onDelete) {
buttons.push({
icon: faTrash,
onClick: onDelete,
title: t('common.delete'),
variant: 'danger',
});
}
if (onEdit) {
buttons.push({
icon: faEdit,
onClick: onEdit,
title: t('common.edit'),
variant: 'primary',
});
}
} else if (viewMode === 'edit') {
if (onCancel) {
buttons.push({
icon: faTimes,
onClick: onCancel,
title: t('common.cancel'),
variant: 'danger',
});
}
if (onSave) {
buttons.push({
icon: isNew ? faPlus : faSave,
onClick: onSave,
title: isNew ? t('common.create') : t('common.save'),
variant: 'primary',
});
}
}
return buttons;
}
return (
<div className="flex justify-between items-center p-4 border-b border-secondary/50 bg-tertiary/50 backdrop-blur-sm">
<button
onClick={onBack}
className="flex items-center gap-2 bg-secondary/50 py-2 px-4 rounded-xl border border-secondary/50 hover:bg-secondary hover:border-secondary hover:shadow-md hover:scale-105 transition-all duration-200"
>
<FontAwesomeIcon icon={faArrowLeft} className="text-primary w-4 h-4"/>
<span className="text-text-primary font-medium">{t('common.back')}</span>
</button>
<span className="text-text-primary font-semibold text-lg">
{title || defaultTitle}
</span>
<div className="flex items-center gap-2">
{getActionButtons().map(renderActionButton)}
</div>
</div>
);
}

View File

@@ -1,470 +0,0 @@
'use client';
import {Dispatch, forwardRef, SetStateAction, useContext, useEffect, useImperativeHandle, useState} from 'react';
import {Attribute, CharacterProps, CharacterListResponse} from "@/lib/models/Character";
import {SessionContext} from "@/context/SessionContext";
import CharacterList from './CharacterList';
import System from '@/lib/models/System';
import {AlertContext} from "@/context/AlertContext";
import {BookContext} from "@/context/BookContext";
import CharacterDetail from "@/components/book/settings/characters/CharacterDetail";
import {useTranslations} from "next-intl";
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 ToggleSwitch from "@/components/form/ToggleSwitch";
import InputField from "@/components/form/InputField";
import {faToggleOn} from "@fortawesome/free-solid-svg-icons";
interface CharacterDetailProps {
selectedCharacter: CharacterProps | null;
setSelectedCharacter: Dispatch<SetStateAction<CharacterProps | null>>;
handleCharacterChange: (key: keyof CharacterProps, value: string) => void;
handleAddElement: (section: keyof CharacterProps, element: any) => void;
handleRemoveElement: (
section: keyof CharacterProps,
index: number,
attrId: string,
) => void;
handleSaveCharacter: () => void;
handleDeleteCharacter: (characterId: string) => Promise<void>;
}
const initialCharacterState: CharacterProps = {
id: null,
name: '',
lastName: '',
nickname: '',
age: '',
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 function CharacterComponent({showToggle = true}: {showToggle?: boolean}, ref: any) {
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext)
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {session} = useContext(SessionContext);
const {book, setBook} = useContext(BookContext);
const {errorMessage, successMessage} = useContext(AlertContext);
const [characters, setCharacters] = useState<CharacterProps[]>([]);
const [selectedCharacter, setSelectedCharacter] = useState<CharacterProps | null>(null);
const [toolEnabled, setToolEnabled] = useState<boolean>(book?.tools?.characters ?? false);
useImperativeHandle(ref, function () {
return {
handleSave: handleSaveCharacter,
};
});
useEffect((): void => {
getCharacters().then();
}, []);
async function handleToggleTool(enabled: boolean): Promise<void> {
try {
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:book:tool:update', {
bookId: book?.bookId,
toolName: 'characters',
enabled: enabled
});
} else {
response = await System.authPatchToServer<boolean>('book/tool-setting', {
bookId: book?.bookId,
toolName: 'characters',
enabled: enabled
}, session.accessToken, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) {
addToQueue('db:book:tool:update', {
bookId: book?.bookId,
toolName: 'characters',
enabled: enabled
});
}
}
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);
}
}
}
async function getCharacters(): Promise<void> {
try {
let response: CharacterListResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<CharacterListResponse>('db:character:list', {bookid: book?.bookId});
} else {
if (book?.localBook) {
response = await window.electron.invoke<CharacterListResponse>('db:character:list', {bookid: book?.bookId});
} else {
response = await System.authGetQueryToServer<CharacterListResponse>(`character/list`, session.accessToken, lang, {
bookid: book?.bookId,
});
}
}
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"));
}
}
}
function handleCharacterClick(character: CharacterProps): void {
setSelectedCharacter({...character});
}
function handleAddCharacter(): void {
setSelectedCharacter({...initialCharacterState});
}
async function handleSaveCharacter(): Promise<void> {
if (selectedCharacter) {
const updatedCharacter: CharacterProps = {...selectedCharacter};
if (selectedCharacter.id === null) {
await addNewCharacter(updatedCharacter);
} else {
await updateCharacter(updatedCharacter);
}
}
}
async function handleDeleteCharacter(characterId: string): Promise<void> {
try {
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:character:delete', {
characterId: characterId,
});
} else {
response = await System.authDeleteToServer<boolean>('character/delete', {
characterId: characterId,
}, session.accessToken, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) {
addToQueue('db:character:delete', {
characterId: characterId,
});
}
}
if (!response) {
errorMessage(t("characterComponent.errorDeleteCharacter"));
return;
}
setCharacters(characters.filter((c: CharacterProps): boolean => c.id !== characterId));
setSelectedCharacter(null);
successMessage(t("characterComponent.successDelete"));
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("common.unknownError"));
}
}
}
async function addNewCharacter(updatedCharacter: CharacterProps): Promise<void> {
if (!updatedCharacter.name) {
errorMessage(t("characterComponent.errorNameRequired"));
return;
}
if (updatedCharacter.category === 'none') {
errorMessage(t("characterComponent.errorCategoryRequired"));
return;
}
try {
let characterId: string;
if (isCurrentlyOffline() || book?.localBook) {
characterId = await window.electron.invoke<string>('db:character:create', {
bookId: book?.bookId,
character: updatedCharacter,
});
} else {
characterId = await System.authPostToServer<string>(`character/add`, {
bookId: book?.bookId,
character: updatedCharacter,
}, session.accessToken, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) {
addToQueue('db:character:create', {
bookId: book?.bookId,
characterId,
character: updatedCharacter,
});
}
}
if (!characterId) {
errorMessage(t("characterComponent.errorAddCharacter"));
return;
}
updatedCharacter.id = characterId;
setCharacters([...characters, updatedCharacter]);
setSelectedCharacter(null);
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("common.unknownError"));
}
}
}
async function updateCharacter(updatedCharacter: CharacterProps,): Promise<void> {
try {
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:character:update', {
character: updatedCharacter,
});
} else {
response = await System.authPostToServer<boolean>(`character/update`, {
character: updatedCharacter,
}, session.accessToken, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) {
addToQueue('db:character:update', {
character: updatedCharacter,
});
}
}
if (!response) {
errorMessage(t("characterComponent.errorUpdateCharacter"));
return;
}
setCharacters(
characters.map((char: CharacterProps): CharacterProps =>
char.id === updatedCharacter.id ? updatedCharacter : char,
),
);
setSelectedCharacter(null);
successMessage(t("characterComponent.successUpdate"));
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("common.unknownError"));
}
}
}
function handleCharacterChange(
key: keyof CharacterProps,
value: string,
): void {
if (selectedCharacter) {
setSelectedCharacter({...selectedCharacter, [key]: value});
}
}
async function handleAddElement(
section: keyof CharacterProps,
value: Attribute,
): Promise<void> {
if (selectedCharacter) {
if (selectedCharacter.id === null) {
const updatedSection: any[] = [
...(selectedCharacter[section] as any[]),
value,
];
setSelectedCharacter({...selectedCharacter, [section]: updatedSection});
} else {
try {
let attributeId: string;
if (isCurrentlyOffline() || book?.localBook) {
attributeId = await window.electron.invoke<string>('db:character:attribute:add', {
characterId: selectedCharacter.id,
type: section,
name: value.name,
});
} else {
attributeId = await System.authPostToServer<string>(`character/attribute/add`, {
characterId: selectedCharacter.id,
type: section,
name: value.name,
}, session.accessToken, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) {
addToQueue('db:character:attribute:add', {
characterId: selectedCharacter.id,
attributeId,
type: section,
name: value.name,
});
}
}
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"));
}
}
}
}
}
async function handleRemoveElement(
section: keyof CharacterProps,
index: number,
attrId: string,
): Promise<void> {
if (selectedCharacter) {
if (selectedCharacter.id === null) {
const updatedSection: Attribute[] = (
selectedCharacter[section] as Attribute[]
).filter((_, i: number): boolean => i !== index);
setSelectedCharacter({...selectedCharacter, [section]: updatedSection});
} else {
try {
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:character:attribute:delete', {
attributeId: attrId,
});
} else {
response = await System.authDeleteToServer<boolean>(`character/attribute/delete`, {
attributeId: attrId,
}, session.accessToken, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) {
addToQueue('db:character:attribute:delete', {
attributeId: attrId,
});
}
}
if (!response) {
errorMessage(t("characterComponent.errorRemoveAttribute"));
return;
}
const updatedSection: Attribute[] = (
selectedCharacter[section] as Attribute[]
).filter((_, i: number): boolean => i !== index);
setSelectedCharacter({
...selectedCharacter,
[section]: updatedSection,
});
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("common.unknownError"));
}
}
}
}
}
return (
<div className="space-y-5">
{showToggle && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('characterComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={async (checked: boolean): Promise<void> => handleToggleTool(checked)}
/>
}
/>
<p className="text-muted text-sm mt-2">
{t('characterComponent.enableToolDescription')}
</p>
</div>
)}
{toolEnabled && (
<>
{selectedCharacter ? (
<CharacterDetail
selectedCharacter={selectedCharacter}
setSelectedCharacter={setSelectedCharacter}
handleAddElement={handleAddElement}
handleRemoveElement={handleRemoveElement}
handleCharacterChange={handleCharacterChange}
handleSaveCharacter={handleSaveCharacter}
handleDeleteCharacter={handleDeleteCharacter}
/>
) : (
<CharacterList
characters={characters}
handleAddCharacter={handleAddCharacter}
handleCharacterClick={handleCharacterClick}
/>
)}
</>
)}
</div>
);
}
export default forwardRef(CharacterComponent);

View File

@@ -1,472 +0,0 @@
import CollapsableArea from "@/components/CollapsableArea";
import InputField from "@/components/form/InputField";
import TexteAreaInput from "@/components/form/TexteAreaInput";
import TextInput from "@/components/form/TextInput";
import SelectBox from "@/components/form/SelectBox";
import {AlertContext} from "@/context/AlertContext";
import {SessionContext} from "@/context/SessionContext";
import {
Attribute,
CharacterAttribute,
characterCategories,
CharacterElement,
basicCharacterElements,
advancedCharacterElements,
CharacterProps,
characterStatus
} from "@/lib/models/Character";
import System from "@/lib/models/System";
import {
faArrowLeft,
faBook,
faPlus,
faSave,
faScroll,
faUser,
faSliders,
faGlobe,
faCommentDots,
faStickyNote
} from "@fortawesome/free-solid-svg-icons";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {Dispatch, SetStateAction, useContext, useEffect, useState} from "react";
import CharacterSectionElement from "@/components/book/settings/characters/CharacterSectionElement";
import DeleteButton from "@/components/form/DeleteButton";
import {useTranslations} from "next-intl";
import {LangContext} from "@/context/LangContext";
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
import {BookContext} from "@/context/BookContext";
type AttributeResponse = { type: string; values: Attribute[] }[];
interface CharacterDetailProps {
selectedCharacter: CharacterProps | null;
setSelectedCharacter: Dispatch<SetStateAction<CharacterProps | null>>;
handleCharacterChange: (key: keyof CharacterProps, value: string) => void;
handleAddElement: (section: keyof CharacterProps, element: any) => void;
handleRemoveElement: (
section: keyof CharacterProps,
index: number,
attrId: string,
) => void;
handleSaveCharacter: () => void;
handleDeleteCharacter: (characterId: string) => Promise<void>;
}
export default function CharacterDetail(
{
setSelectedCharacter,
selectedCharacter,
handleCharacterChange,
handleRemoveElement,
handleAddElement,
handleSaveCharacter,
handleDeleteCharacter,
}: CharacterDetailProps
) {
const t = useTranslations();
const {lang} = useContext(LangContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {book} = useContext(BookContext);
const {session} = useContext(SessionContext);
const {errorMessage} = useContext(AlertContext);
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
useEffect((): void => {
if (selectedCharacter?.id !== null) {
getAttributes().then();
}
}, []);
async function getAttributes(): Promise<void> {
try {
let response: AttributeResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {
characterId: selectedCharacter?.id,
});
} else {
if (book?.localBook) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {
characterId: selectedCharacter?.id,
});
} else {
response = await System.authGetQueryToServer<AttributeResponse>(`character/attribute`, session.accessToken, lang, {
characterId: selectedCharacter?.id,
});
}
}
if (response) {
const attributes: CharacterAttribute = {};
response.forEach((item: {
type: string
values: Attribute[]
}):void => {
attributes[item.type] = item.values;
});
setSelectedCharacter({
id: selectedCharacter?.id ?? '',
name: selectedCharacter?.name ?? '',
lastName: selectedCharacter?.lastName ?? '',
nickname: selectedCharacter?.nickname ?? '',
age: selectedCharacter?.age ?? '',
gender: selectedCharacter?.gender ?? '',
species: selectedCharacter?.species ?? '',
nationality: selectedCharacter?.nationality ?? '',
status: selectedCharacter?.status ?? 'alive',
category: selectedCharacter?.category ?? 'none',
title: selectedCharacter?.title ?? '',
image: selectedCharacter?.image ?? '',
role: selectedCharacter?.role ?? '',
biography: selectedCharacter?.biography,
history: selectedCharacter?.history,
speechPattern: selectedCharacter?.speechPattern,
catchphrase: selectedCharacter?.catchphrase,
residence: selectedCharacter?.residence,
notes: selectedCharacter?.notes,
color: selectedCharacter?.color,
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);
} else {
errorMessage(t("characterDetail.fetchAttributesError"));
}
}
}
return (
<div className="space-y-4">
<div
className="flex justify-between items-center p-4 border-b border-secondary/50 bg-tertiary/50 backdrop-blur-sm">
<button onClick={() => setSelectedCharacter(null)}
className="flex items-center gap-2 bg-secondary/50 py-2 px-4 rounded-xl border border-secondary/50 hover:bg-secondary hover:border-secondary hover:shadow-md hover:scale-105 transition-all duration-200">
<FontAwesomeIcon icon={faArrowLeft} className="text-primary w-4 h-4"/>
<span className="text-text-primary font-medium">{t("characterDetail.back")}</span>
</button>
<span className="text-text-primary font-semibold text-lg">
{selectedCharacter?.name || t("characterDetail.newCharacter")}
</span>
<div className="flex items-center gap-2">
{selectedCharacter?.id && (
<DeleteButton
onDelete={(): Promise<void> => handleDeleteCharacter(selectedCharacter.id as string)}
confirmTitle={t("characterDetail.deleteTitle")}
confirmMessage={t("characterDetail.deleteMessage", {name: selectedCharacter.name})}
confirmButtonText={t("common.delete")}
cancelButtonText={t("common.cancel")}
/>
)}
<button onClick={handleSaveCharacter}
className="flex items-center justify-center bg-primary w-10 h-10 rounded-xl border border-primary-dark shadow-md hover:shadow-lg hover:scale-110 transition-all duration-200">
<FontAwesomeIcon icon={selectedCharacter?.id ? faSave : faPlus}
className="text-text-primary w-5 h-5"/>
</button>
</div>
</div>
<div className="overflow-y-auto max-h-[calc(100vh-350px)] space-y-4 px-2 pb-4">
<CollapsableArea title={t("characterDetail.basicInfo")} icon={faUser}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t("characterDetail.name")}
input={
<TextInput
value={selectedCharacter?.name || ''}
setValue={(e) => handleCharacterChange('name', e.target.value)}
placeholder={t("characterDetail.namePlaceholder")}
/>
}
/>
<InputField
fieldName={t("characterDetail.lastName")}
input={
<TextInput
value={selectedCharacter?.lastName || ''}
setValue={(e) => handleCharacterChange('lastName', e.target.value)}
placeholder={t("characterDetail.lastNamePlaceholder")}
/>
}
/>
<InputField
fieldName={t("characterDetail.nickname")}
input={
<TextInput
value={selectedCharacter?.nickname || ''}
setValue={(e) => handleCharacterChange('nickname', e.target.value)}
placeholder={t("characterDetail.nicknamePlaceholder")}
/>
}
/>
<InputField
fieldName={t("characterDetail.role")}
input={
<SelectBox
defaultValue={selectedCharacter?.category || 'none'}
onChangeCallBack={(e) => setSelectedCharacter(prev =>
prev ? {...prev, category: e.target.value as CharacterProps['category']} : prev
)}
data={characterCategories}
/>
}
/>
<InputField
fieldName={t("characterDetail.title")}
input={
<TextInput
value={selectedCharacter?.title || ''}
setValue={(e) => handleCharacterChange('title', e.target.value)}
placeholder={t("characterDetail.titlePlaceholder")}
/>
}
/>
<InputField
fieldName={t("characterDetail.gender")}
input={
<TextInput
value={selectedCharacter?.gender || ''}
setValue={(e) => handleCharacterChange('gender', e.target.value)}
placeholder={t("characterDetail.genderPlaceholder")}
/>
}
/>
<InputField
fieldName={t("characterDetail.age")}
input={
<TextInput
value={selectedCharacter?.age || ''}
setValue={(e) => handleCharacterChange('age', e.target.value)}
placeholder={t("characterDetail.agePlaceholder")}
/>
}
/>
</div>
</CollapsableArea>
<CollapsableArea title={t("characterDetail.historySection")} icon={faUser}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t("characterDetail.biography")}
input={
<TexteAreaInput
value={selectedCharacter?.biography || ''}
setValue={(e) => handleCharacterChange('biography', e.target.value)}
placeholder={t("characterDetail.biographyPlaceholder")}
/>
}
icon={faBook}
/>
<InputField
fieldName={t("characterDetail.history")}
input={
<TexteAreaInput
value={selectedCharacter?.history || ''}
setValue={(e) => handleCharacterChange('history', e.target.value)}
placeholder={t("characterDetail.historyPlaceholder")}
/>
}
icon={faScroll}
/>
<InputField
fieldName={t("characterDetail.roleFull")}
input={
<TexteAreaInput
value={selectedCharacter?.role || ''}
setValue={(e) => handleCharacterChange('role', e.target.value)}
placeholder={t("characterDetail.roleFullPlaceholder")}
/>
}
icon={faScroll}
/>
</div>
</CollapsableArea>
{/* Attributs de base - toujours visibles */}
{basicCharacterElements.map((item: CharacterElement, index: number) => (
<CharacterSectionElement
key={`basic-${index}`}
title={item.title}
section={item.section}
placeholder={item.placeholder}
icon={item.icon}
selectedCharacter={selectedCharacter as CharacterProps}
setSelectedCharacter={setSelectedCharacter}
handleAddElement={handleAddElement}
handleRemoveElement={handleRemoveElement}
/>
))}
{/* Toggle Mode Avancé */}
<div className="flex items-center justify-between p-4 bg-secondary/30 rounded-xl border border-secondary/50">
<div className="flex items-center gap-3">
<FontAwesomeIcon icon={faSliders} className="text-primary w-5 h-5"/>
<span className="text-text-primary font-medium">{t("characterDetail.advancedMode")}</span>
</div>
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className={`px-4 py-2 rounded-lg transition-all duration-200 ${
showAdvanced
? 'bg-primary text-white'
: 'bg-secondary/50 text-text-primary hover:bg-secondary'
}`}
>
{showAdvanced ? t("characterDetail.hideAdvanced") : t("characterDetail.showAdvanced")}
</button>
</div>
{/* Sections avancées - visibles uniquement si showAdvanced est true */}
{showAdvanced && (
<>
{/* Identité étendue */}
<CollapsableArea title={t("characterDetail.identitySection")} icon={faGlobe}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t("characterDetail.species")}
input={
<TextInput
value={selectedCharacter?.species || ''}
setValue={(e) => handleCharacterChange('species', e.target.value)}
placeholder={t("characterDetail.speciesPlaceholder")}
/>
}
/>
<InputField
fieldName={t("characterDetail.nationality")}
input={
<TextInput
value={selectedCharacter?.nationality || ''}
setValue={(e) => handleCharacterChange('nationality', e.target.value)}
placeholder={t("characterDetail.nationalityPlaceholder")}
/>
}
/>
<InputField
fieldName={t("characterDetail.status")}
input={
<SelectBox
defaultValue={selectedCharacter?.status || 'alive'}
onChangeCallBack={(e) => setSelectedCharacter(prev =>
prev ? {...prev, status: e.target.value as CharacterProps['status']} : prev
)}
data={characterStatus}
/>
}
/>
<InputField
fieldName={t("characterDetail.residence")}
input={
<TextInput
value={selectedCharacter?.residence || ''}
setValue={(e) => handleCharacterChange('residence', e.target.value)}
placeholder={t("characterDetail.residencePlaceholder")}
/>
}
/>
</div>
</CollapsableArea>
{/* Voix du personnage */}
<CollapsableArea title={t("characterDetail.voiceSection")} icon={faCommentDots}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t("characterDetail.speechPattern")}
input={
<TexteAreaInput
value={selectedCharacter?.speechPattern || ''}
setValue={(e) => handleCharacterChange('speechPattern', e.target.value)}
placeholder={t("characterDetail.speechPatternPlaceholder")}
/>
}
/>
<InputField
fieldName={t("characterDetail.catchphrase")}
input={
<TextInput
value={selectedCharacter?.catchphrase || ''}
setValue={(e) => handleCharacterChange('catchphrase', e.target.value)}
placeholder={t("characterDetail.catchphrasePlaceholder")}
/>
}
/>
</div>
</CollapsableArea>
{/* Notes de l'auteur */}
<CollapsableArea title={t("characterDetail.authorSection")} icon={faStickyNote}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t("characterDetail.notes")}
input={
<TexteAreaInput
value={selectedCharacter?.notes || ''}
setValue={(e) => handleCharacterChange('notes', e.target.value)}
placeholder={t("characterDetail.notesPlaceholder")}
/>
}
/>
<InputField
fieldName={t("characterDetail.colorLabel")}
input={
<TextInput
value={selectedCharacter?.color || ''}
setValue={(e) => handleCharacterChange('color', e.target.value)}
placeholder={t("characterDetail.colorPlaceholder")}
/>
}
/>
</div>
</CollapsableArea>
{/* Attributs avancés */}
{advancedCharacterElements.map((item: CharacterElement, index: number) => (
<CharacterSectionElement
key={`advanced-${index}`}
title={item.title}
section={item.section}
placeholder={item.placeholder}
icon={item.icon}
selectedCharacter={selectedCharacter as CharacterProps}
setSelectedCharacter={setSelectedCharacter}
handleAddElement={handleAddElement}
handleRemoveElement={handleRemoveElement}
/>
))}
</>
)}
</div>
</div>
);
}

View File

@@ -1,124 +0,0 @@
import {characterCategories, CharacterProps} from "@/lib/models/Character";
import InputField from "@/components/form/InputField";
import TextInput from "@/components/form/TextInput";
import {faChevronRight, faPlus, faUser} from "@fortawesome/free-solid-svg-icons";
import {SelectBoxProps} from "@/shared/interface";
import CollapsableArea from "@/components/CollapsableArea";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {useState} from "react";
import {useTranslations} from "next-intl";
interface CharacterListProps {
characters: CharacterProps[];
handleCharacterClick: (character: CharacterProps) => void;
handleAddCharacter: () => void;
}
export default function CharacterList(
{
characters,
handleCharacterClick,
handleAddCharacter,
}: CharacterListProps) {
const t = useTranslations();
const [searchQuery, setSearchQuery] = useState<string>('');
function getFilteredCharacters(
characters: CharacterProps[],
searchQuery: string,
): CharacterProps[] {
return characters.filter(
(char: CharacterProps) =>
char.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(char.lastName &&
char.lastName.toLowerCase().includes(searchQuery.toLowerCase())),
);
}
const filteredCharacters: CharacterProps[] = getFilteredCharacters(
characters,
searchQuery,
);
return (
<div className="space-y-4">
<div className="px-4 mb-4">
<InputField
input={
<TextInput
value={searchQuery}
setValue={(e) => setSearchQuery(e.target.value)}
placeholder={t("characterList.search")}
/>
}
actionIcon={faPlus}
actionLabel={t("characterList.add")}
addButtonCallBack={async () => handleAddCharacter()}
/>
</div>
<div className="overflow-y-auto max-h-[calc(100vh-350px)] px-2">
{characterCategories.map((category: SelectBoxProps) => {
const categoryCharacters = filteredCharacters.filter(
(char: CharacterProps) => char.category === category.value
);
if (categoryCharacters.length === 0) {
return null;
}
return (
<CollapsableArea
key={category.value}
title={category.label}
icon={faUser}
children={<div className="space-y-2 p-2">
{categoryCharacters.map(char => (
<div
key={char.id}
onClick={() => handleCharacterClick(char)}
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"
>
<div
className="w-14 h-14 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform">
{char.image ? (
<img
src={char.image}
alt={char.name}
className="w-full h-full object-cover"
/>
) : (
<div
className="w-full h-full flex items-center justify-center bg-primary/10 text-primary font-bold text-lg">
{char.name?.charAt(0)?.toUpperCase() || '?'}
</div>
)}
</div>
<div className="ml-4 flex-1">
<div
className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">{char.name || t("characterList.unknown")}</div>
<div
className="text-text-secondary text-sm mt-0.5">{char.lastName || t("characterList.noLastName")}</div>
</div>
<div className="w-28 px-3">
<div
className="text-primary text-sm font-semibold truncate">{char.title || t("characterList.noTitle")}</div>
<div
className="text-muted text-xs truncate mt-0.5">{char.role || t("characterList.noRole")}</div>
</div>
<div className="w-8 flex justify-center">
<FontAwesomeIcon icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"/>
</div>
</div>
))}
</div>}
/>
);
})}
</div>
</div>
)
}

View File

@@ -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<boolean>(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<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
async function handleDelete(): Promise<void> {
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 (
<div className="flex items-center justify-center py-8">
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin"/>
</div>
);
}
const isNew: boolean = selectedCharacter?.id === null;
const canExport: boolean = Boolean(bookSeriesId && selectedCharacter?.id && !selectedCharacter.seriesCharacterId);
return (
<div className="flex flex-col h-full">
<ToolDetailHeader
title={selectedCharacter?.name || ''}
defaultTitle={t('characterDetail.newCharacter')}
viewMode={viewMode}
isNew={isNew}
onBack={backToList}
onEdit={enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onDelete={function (): void { setShowDeleteConfirm(true); }}
onExport={canExport ? exportToSeries : undefined}
showExport={canExport}
showDelete={Boolean(selectedCharacter?.id)}
/>
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-3 p-2">
{/* Toggle tool */}
<div className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('characterComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
</div>
{toolEnabled && (
<>
{/* Import from series */}
{bookSeriesId && availableSeriesCharacters.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesCharacters.map(function (sc: SeriesCharacterProps) {
return {
id: sc.id,
name: `${sc.name}${sc.lastName ? ' ' + sc.lastName : ''}`
};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
)}
<CharacterEditorList
characters={characters}
onCharacterClick={enterDetailMode}
onAddCharacter={addNewCharacter}
/>
</>
)}
</div>
)}
{viewMode === 'detail' && selectedCharacter && (
<div className="p-4">
<CharacterEditorDetail
character={selectedCharacter}
seriesCharacter={getSeriesCharacterForSelected()}
/>
</div>
)}
{viewMode === 'edit' && selectedCharacter && (
<div className="p-4">
<CharacterEditorEdit
character={selectedCharacter}
setCharacter={setSelectedCharacter}
onCharacterChange={handleCharacterChange}
onAddAttribute={addAttribute}
onRemoveAttribute={removeAttribute}
seriesCharacter={getSeriesCharacterForSelected()}
onSyncComplete={refreshSeriesCharacters}
/>
</div>
)}
</div>
{showDeleteConfirm && selectedCharacter?.id && (
<AlertBox
title={t('characterDetail.deleteTitle')}
message={t('characterDetail.deleteMessage', {name: selectedCharacter.name})}
type="danger"
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={handleDelete}
onCancel={function (): void { setShowDeleteConfirm(false); }}
/>
)}
</div>
);
}

View File

@@ -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<OfflineContextType>(OfflineContext);
const {book} = useContext(BookContext);
useEffect(function (): void {
if (character?.id !== null) {
getAttributes().then();
}
}, [character?.id]);
async function getAttributes(): Promise<void> {
try {
let response: AttributeResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else if (book?.localBook) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else {
response = await System.authGetQueryToServer<AttributeResponse>(
'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 (
<div className="mb-3">
<span className="text-text-secondary text-xs block mb-1">{label}</span>
<p className="text-text-primary text-sm">{value}</p>
</div>
);
}
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 (
<div>
{/* Image du personnage - version compacte */}
{character.image && (
<div className="flex justify-center mb-4">
<div className="w-16 h-16 rounded-full border-2 border-primary overflow-hidden">
<img
src={character.image}
alt={character.name}
className="w-full h-full object-cover"
/>
</div>
</div>
)}
<h3 className="text-text-primary font-semibold text-base mb-4">
{character.name} {character.lastName}
</h3>
{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)}
</div>
);
}

View File

@@ -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<SetStateAction<CharacterProps | null>>;
onCharacterChange: (key: keyof CharacterProps, value: string | number | null) => void;
onAddAttribute: (section: keyof CharacterProps, attr: Attribute) => Promise<void>;
onRemoveAttribute: (section: keyof CharacterProps, idx: number, id: string) => Promise<void>;
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<OfflineContextType>(OfflineContext);
const {book} = useContext(BookContext);
const [showAdvanced, setShowAdvanced] = useState<boolean>(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<void> {
try {
let response: AttributeResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else if (book?.localBook) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else {
response = await System.authGetQueryToServer<AttributeResponse>(
'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 (
<div className="space-y-4">
{/* Informations de base */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.basicInfo')}</h4>
<div className="space-y-3">
<InputField
fieldName={t('characterDetail.name')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.name || ''}
currentValue={character.name || ''}
bookElementId={character.id || ''}
field="name"
elementType="character"
onDownload={function (): void { onCharacterChange('name', seriesCharacter?.name || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.name || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('name', e.target.value);
}}
placeholder={t('characterDetail.namePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.lastName')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.lastName || ''}
currentValue={character.lastName || ''}
bookElementId={character.id || ''}
field="lastName"
elementType="character"
onDownload={function (): void { onCharacterChange('lastName', seriesCharacter?.lastName || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.lastName || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('lastName', e.target.value);
}}
placeholder={t('characterDetail.lastNamePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.role')}
input={
<SelectBox
defaultValue={character.category || 'none'}
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, category: e.target.value as CharacterProps['category']} : prev;
});
}}
data={translatedCharacterCategories}
/>
}
/>
<InputField
fieldName={t('characterDetail.gender')}
input={
<TextInput
value={character.gender || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('gender', e.target.value);
}}
placeholder={t('characterDetail.genderPlaceholder')}
/>
}
/>
<InputField
fieldName={t('characterDetail.age')}
input={
<NumberInput
value={character.age ?? null}
onValueChange={function (val: number | null): void {
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, age: val} : prev;
});
}}
placeholder={t('characterDetail.agePlaceholder')}
/>
}
/>
</div>
</div>
{/* Histoire */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.historySection')}</h4>
<div className="space-y-3">
<InputField
fieldName={t('characterDetail.biography')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.biography || ''}
currentValue={character.biography || ''}
bookElementId={character.id || ''}
field="biography"
elementType="character"
onDownload={function (): void { onCharacterChange('biography', seriesCharacter?.biography || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={character.biography || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('biography', e.target.value);
}}
placeholder={t('characterDetail.biographyPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.roleFull')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.role || ''}
currentValue={character.role || ''}
bookElementId={character.id || ''}
field="role"
elementType="character"
onDownload={function (): void { onCharacterChange('role', seriesCharacter?.role || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={character.role || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('role', e.target.value);
}}
placeholder={t('characterDetail.roleFullPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
</div>
</div>
{/* Attributs de base */}
{basicCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element {
return (
<CharacterSectionElement
key={`basic-${index}`}
title={item.title}
section={item.section}
placeholder={item.placeholder}
icon={item.icon}
selectedCharacter={character}
setSelectedCharacter={setCharacter}
handleAddElement={onAddAttribute}
handleRemoveElement={onRemoveAttribute}
/>
);
})}
{/* Toggle Mode Avancé */}
<div className="flex items-center justify-between p-3 bg-secondary/30 rounded-lg border border-secondary/50">
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faSliders} className="text-primary w-4 h-4"/>
<span className="text-text-primary font-medium text-sm">{t('characterDetail.advancedMode')}</span>
</div>
<button
onClick={function (): void { setShowAdvanced(!showAdvanced); }}
className={`px-3 py-1.5 rounded-lg text-sm transition-all duration-200 ${
showAdvanced
? 'bg-primary text-white'
: 'bg-secondary/50 text-text-primary hover:bg-secondary'
}`}
>
{showAdvanced ? t('characterDetail.hideAdvanced') : t('characterDetail.showAdvanced')}
</button>
</div>
{/* Sections avancées */}
{showAdvanced && (
<>
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.identitySection')}</h4>
<div className="space-y-3">
<InputField
fieldName={t('characterDetail.species')}
input={
<TextInput
value={character.species || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('species', e.target.value);
}}
placeholder={t('characterDetail.speciesPlaceholder')}
/>
}
/>
<InputField
fieldName={t('characterDetail.status')}
input={
<SelectBox
defaultValue={character.status || 'alive'}
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, status: e.target.value as CharacterProps['status']} : prev;
});
}}
data={translatedCharacterStatus}
/>
}
/>
</div>
</div>
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.authorSection')}</h4>
<InputField
fieldName={t('characterDetail.notes')}
input={
<TexteAreaInput
value={character.notes || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('notes', e.target.value);
}}
placeholder={t('characterDetail.notesPlaceholder')}
/>
}
/>
</div>
{advancedCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element {
return (
<CharacterSectionElement
key={`advanced-${index}`}
title={item.title}
section={item.section}
placeholder={item.placeholder}
icon={item.icon}
selectedCharacter={character}
setSelectedCharacter={setCharacter}
handleAddElement={onAddAttribute}
handleRemoveElement={onRemoveAttribute}
/>
);
})}
</>
)}
</div>
);
}

View File

@@ -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<string>('');
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 (
<div className="space-y-3">
<div className="px-2">
<InputField
input={
<TextInput
value={searchQuery}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setSearchQuery(e.target.value);
}}
placeholder={t('characterList.search')}
/>
}
actionIcon={faPlus}
actionLabel={t('characterList.add')}
addButtonCallBack={async function (): Promise<void> {
onAddCharacter();
}}
/>
</div>
<div className="px-2 space-y-2">
{filteredCharacters.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
<FontAwesomeIcon icon={faUser} className="text-primary w-8 h-8"/>
</div>
<h3 className="text-text-primary font-semibold text-base mb-1">
{t('characterList.noCharacters')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('characterList.noCharactersDescription')}
</p>
</div>
) : (
filteredCharacters.map(function (char: CharacterProps): React.JSX.Element {
return (
<div
key={char.id}
onClick={function (): void { onCharacterClick(char); }}
className="group flex items-center p-3 bg-secondary/30 rounded-lg border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md transition-all duration-200 hover:border-primary/50"
>
<div className="w-10 h-10 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-sm group-hover:scale-110 transition-transform">
{char.image ? (
<img
src={char.image}
alt={char.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-primary/10 text-primary font-bold text-sm">
{char.name?.charAt(0)?.toUpperCase() || '?'}
</div>
)}
</div>
<div className="ml-3 flex-1 min-w-0">
<div className="text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate">
{char.name || t('characterList.unknown')}
</div>
<div className="text-muted text-xs truncate">
{char.title || char.role || t('characterList.noRole')}
</div>
</div>
<div className="w-6 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3"
/>
</div>
</div>
);
})
)}
</div>
</div>
);
}

View File

@@ -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<boolean>(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<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
async function handleDelete(): Promise<void> {
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 (
<div className="flex items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
</div>
);
}
const isNew: boolean = selectedCharacter?.id === null;
const canExport: boolean = Boolean(bookSeriesId && selectedCharacter?.id && !selectedCharacter.seriesCharacterId);
return (
<div className="flex flex-col h-full">
{/* Header - uniquement pour detail/edit */}
<ToolDetailHeader
title={selectedCharacter?.name || ''}
defaultTitle={t('characterDetail.newCharacter')}
viewMode={viewMode}
isNew={isNew}
onBack={backToList}
onEdit={enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onDelete={function (): void { setShowDeleteConfirm(true); }}
onExport={canExport ? exportToSeries : undefined}
showExport={canExport}
showDelete={Boolean(selectedCharacter?.id)}
/>
{/* Contenu principal */}
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-5 p-4">
{/* Toggle tool */}
{showToggle && !isSeriesMode && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('characterComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
<p className="text-muted text-sm mt-2">
{t('characterComponent.enableToolDescription')}
</p>
</div>
)}
{/* Contenu si outil activé */}
{(toolEnabled || isSeriesMode) && (
<>
{/* Import from series */}
{!isSeriesMode && bookSeriesId && availableSeriesCharacters.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesCharacters.map(function (sc: SeriesCharacterProps) {
return {
id: sc.id,
name: `${sc.name}${sc.lastName ? ' ' + sc.lastName : ''}`
};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
)}
{/* Liste des personnages */}
<CharacterSettingsList
characters={characters}
onCharacterClick={enterDetailMode}
onAddCharacter={addNewCharacter}
/>
</>
)}
</div>
)}
{viewMode === 'detail' && selectedCharacter && (
<div className="p-4">
<CharacterSettingsDetail
character={selectedCharacter}
seriesCharacter={getSeriesCharacterForSelected()}
/>
</div>
)}
{viewMode === 'edit' && selectedCharacter && (
<div className="p-4">
<CharacterSettingsEdit
character={selectedCharacter}
setCharacter={setSelectedCharacter}
onCharacterChange={handleCharacterChange}
onAddAttribute={addAttribute}
onRemoveAttribute={removeAttribute}
seriesCharacter={getSeriesCharacterForSelected()}
onSyncComplete={refreshSeriesCharacters}
/>
</div>
)}
</div>
{/* Modal de confirmation de suppression */}
{showDeleteConfirm && selectedCharacter?.id && (
<AlertBox
title={t('characterDetail.deleteTitle')}
message={t('characterDetail.deleteMessage', {name: selectedCharacter.name})}
type="danger"
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={handleDelete}
onCancel={function (): void { setShowDeleteConfirm(false); }}
/>
)}
</div>
);
}

View File

@@ -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<OfflineContextType>(OfflineContext);
const {book} = useContext(BookContext);
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
useEffect(function (): void {
if (character?.id !== null) {
getAttributes().then();
}
}, [character?.id]);
async function getAttributes(): Promise<void> {
try {
let response: AttributeResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else if (book?.localBook) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else {
response = await System.authGetQueryToServer<AttributeResponse>(
'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 (
<CollapsableArea key={element.section} title={element.title} icon={element.icon}>
<div className="flex flex-wrap gap-2 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
{attributes.map(function (attr: Attribute, index: number): React.JSX.Element {
return (
<span key={index} className="px-3 py-1 bg-primary/20 text-primary rounded-full text-sm border border-primary/30">
{attr.name}
</span>
);
})}
</div>
</CollapsableArea>
);
}
return (
<div className="space-y-6 px-2 pb-4">
{/* Hero Section - Image + Infos principales */}
<div className="flex gap-6 p-6 bg-gradient-to-r from-secondary/30 to-transparent rounded-2xl border border-secondary/30">
{/* Image */}
<div className="shrink-0">
{character.image ? (
<div className="w-32 h-32 rounded-2xl border-4 border-primary/50 overflow-hidden shadow-lg">
<img src={character.image} alt={character.name} className="w-full h-full object-cover"/>
</div>
) : (
<div className="w-32 h-32 rounded-2xl border-4 border-secondary/50 bg-secondary/30 flex items-center justify-center">
<FontAwesomeIcon icon={faUser} className="w-12 h-12 text-text-secondary/50"/>
</div>
)}
</div>
{/* Infos principales */}
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-bold text-text-primary">
{character.name} {character.lastName}
</h2>
{character.nickname && (
<p className="text-primary italic mt-1">« {character.nickname} »</p>
)}
{character.title && (
<p className="text-text-secondary mt-2">{character.title}</p>
)}
{/* Badges */}
<div className="flex flex-wrap gap-2 mt-4">
<span className="inline-flex items-center gap-2 px-3 py-1 bg-primary/20 text-primary rounded-lg text-sm border border-primary/30">
<FontAwesomeIcon icon={faTag} className="w-3 h-3"/>
{getCategoryLabel()}
</span>
{character.gender && (
<span className="inline-flex items-center gap-2 px-3 py-1 bg-secondary/50 text-text-primary rounded-lg text-sm border border-secondary/50">
<FontAwesomeIcon icon={faVenusMars} className="w-3 h-3"/>
{character.gender}
</span>
)}
{character.age && (
<span className="inline-flex items-center gap-2 px-3 py-1 bg-secondary/50 text-text-primary rounded-lg text-sm border border-secondary/50">
<FontAwesomeIcon icon={faCakeCandles} className="w-3 h-3"/>
{character.age} {t('characterDetail.yearsOld')}
</span>
)}
</div>
</div>
</div>
{/* Histoire & Biographie */}
<CollapsableArea title={t('characterDetail.historySection')} icon={faBook}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="lg:col-span-2">
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.biography')}</h4>
<p className={`${character.biography ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{character.biography || '—'}
</p>
</div>
<div>
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.history')}</h4>
<p className={`${character.history ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{character.history || '—'}
</p>
</div>
<div>
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.roleFull')}</h4>
<p className={`${character.role ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{character.role || '—'}
</p>
</div>
</div>
</CollapsableArea>
{/* Attributs de base */}
{basicCharacterElements.map(renderAttributeSection)}
{/* Toggle Mode Avancé */}
<div className="flex items-center justify-between p-4 bg-secondary/30 rounded-xl border border-secondary/50">
<div className="flex items-center gap-3">
<FontAwesomeIcon icon={faSliders} className="text-primary w-5 h-5"/>
<span className="text-text-primary font-medium">{t('characterDetail.advancedMode')}</span>
</div>
<button
onClick={function (): void { setShowAdvanced(!showAdvanced); }}
className={`px-4 py-2 rounded-lg transition-all duration-200 ${
showAdvanced
? 'bg-primary text-white'
: 'bg-secondary/50 text-text-primary hover:bg-secondary'
}`}
>
{showAdvanced ? t('characterDetail.hideAdvanced') : t('characterDetail.showAdvanced')}
</button>
</div>
{/* Sections avancées */}
{showAdvanced && (
<>
{/* Identité étendue */}
<CollapsableArea title={t('characterDetail.identitySection')} icon={faGlobe}>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="p-3 bg-dark-background/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faDna} className="w-3 h-3 text-primary"/>
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.species')}</span>
</div>
<p className={character.species ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
{character.species || '—'}
</p>
</div>
<div className="p-3 bg-dark-background/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faFlag} className="w-3 h-3 text-primary"/>
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.nationality')}</span>
</div>
<p className={character.nationality ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
{character.nationality || '—'}
</p>
</div>
<div className="p-3 bg-dark-background/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faSkull} className="w-3 h-3 text-primary"/>
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.status')}</span>
</div>
<p className={character.status ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
{getStatusLabel()}
</p>
</div>
<div className="p-3 bg-dark-background/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faHouse} className="w-3 h-3 text-primary"/>
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.residence')}</span>
</div>
<p className={character.residence ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
{character.residence || '—'}
</p>
</div>
</div>
</CollapsableArea>
{/* Voix du personnage */}
<CollapsableArea title={t('characterDetail.voiceSection')} icon={faCommentDots}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="p-4 bg-dark-background/30 rounded-lg">
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.speechPattern')}</h4>
<p className={`${character.speechPattern ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{character.speechPattern || '—'}
</p>
</div>
<div className="p-4 bg-dark-background/30 rounded-lg relative">
<FontAwesomeIcon icon={faQuoteLeft} className="absolute top-2 left-2 w-6 h-6 text-primary/20"/>
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.catchphrase')}</h4>
<p className={`italic ${character.catchphrase ? 'text-text-primary' : 'text-text-secondary/50'}`}>
{character.catchphrase ? `« ${character.catchphrase} »` : '—'}
</p>
</div>
</div>
</CollapsableArea>
{/* Notes de l'auteur */}
<CollapsableArea title={t('characterDetail.authorSection')} icon={faStickyNote}>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="lg:col-span-2 p-4 bg-dark-background/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faNoteSticky} className="w-3 h-3 text-primary"/>
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.notes')}</span>
</div>
<p className={`${character.notes ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{character.notes || '—'}
</p>
</div>
<div className="p-4 bg-dark-background/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faPalette} className="w-3 h-3 text-primary"/>
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.colorLabel')}</span>
</div>
{character.color ? (
<div className="flex items-center gap-3">
<div
className="w-8 h-8 rounded-lg border-2 border-white/20"
style={{backgroundColor: character.color}}
/>
<span className="text-text-primary font-mono">{character.color}</span>
</div>
) : (
<p className="text-text-secondary/50 italic"></p>
)}
</div>
</div>
</CollapsableArea>
{/* Attributs avancés */}
{advancedCharacterElements.map(renderAttributeSection)}
</>
)}
</div>
);
}

View File

@@ -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<SetStateAction<CharacterProps | null>>;
onCharacterChange: (key: keyof CharacterProps, value: string | number | null) => void;
onAddAttribute: (section: keyof CharacterProps, attr: Attribute) => Promise<void>;
onRemoveAttribute: (section: keyof CharacterProps, idx: number, id: string) => Promise<void>;
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<OfflineContextType>(OfflineContext);
const {book} = useContext(BookContext);
const [showAdvanced, setShowAdvanced] = useState<boolean>(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<void> {
try {
let response: AttributeResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else if (book?.localBook) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else {
response = await System.authGetQueryToServer<AttributeResponse>(
'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 (
<div className="space-y-4 px-2 pb-4">
{/* Informations de base */}
<CollapsableArea title={t('characterDetail.basicInfo')} icon={faUser}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t('characterDetail.name')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.name || ''}
currentValue={character.name || ''}
bookElementId={character.id || ''}
field="name"
elementType="character"
onDownload={function (): void { onCharacterChange('name', seriesCharacter?.name || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.name || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('name', e.target.value);
}}
placeholder={t('characterDetail.namePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.lastName')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.lastName || ''}
currentValue={character.lastName || ''}
bookElementId={character.id || ''}
field="lastName"
elementType="character"
onDownload={function (): void { onCharacterChange('lastName', seriesCharacter?.lastName || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.lastName || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('lastName', e.target.value);
}}
placeholder={t('characterDetail.lastNamePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.nickname')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.nickname || ''}
currentValue={character.nickname || ''}
bookElementId={character.id || ''}
field="nickname"
elementType="character"
onDownload={function (): void { onCharacterChange('nickname', seriesCharacter?.nickname || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.nickname || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('nickname', e.target.value);
}}
placeholder={t('characterDetail.nicknamePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.role')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.category || 'none'}
currentValue={character.category || 'none'}
bookElementId={character.id || ''}
field="category"
elementType="character"
onDownload={function (): void {
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, category: (seriesCharacter?.category || 'none') as CharacterProps['category']} : prev;
});
}}
onSyncComplete={onSyncComplete}
>
<SelectBox
defaultValue={character.category || 'none'}
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, category: e.target.value as CharacterProps['category']} : prev;
});
}}
data={translatedCharacterCategories}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.title')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.title || ''}
currentValue={character.title || ''}
bookElementId={character.id || ''}
field="title"
elementType="character"
onDownload={function (): void { onCharacterChange('title', seriesCharacter?.title || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.title || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('title', e.target.value);
}}
placeholder={t('characterDetail.titlePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.gender')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.gender || ''}
currentValue={character.gender || ''}
bookElementId={character.id || ''}
field="gender"
elementType="character"
onDownload={function (): void { onCharacterChange('gender', seriesCharacter?.gender || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.gender || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('gender', e.target.value);
}}
placeholder={t('characterDetail.genderPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.age')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.age !== null && seriesCharacter?.age !== undefined ? String(seriesCharacter.age) : ''}
currentValue={character.age !== null && character.age !== undefined ? String(character.age) : ''}
bookElementId={character.id || ''}
field="age"
elementType="character"
onDownload={function (): void {
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, age: seriesCharacter?.age ?? null} : prev;
});
}}
onSyncComplete={onSyncComplete}
>
<NumberInput
value={character.age ?? null}
onValueChange={function (val: number | null): void {
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, age: val} : prev;
});
}}
placeholder={t('characterDetail.agePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
{/* Histoire */}
<CollapsableArea title={t('characterDetail.historySection')} icon={faBook}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t('characterDetail.biography')}
icon={faBook}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.biography || ''}
currentValue={character.biography || ''}
bookElementId={character.id || ''}
field="biography"
elementType="character"
onDownload={function (): void { onCharacterChange('biography', seriesCharacter?.biography || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={character.biography || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('biography', e.target.value);
}}
placeholder={t('characterDetail.biographyPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.history')}
icon={faScroll}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.history || ''}
currentValue={character.history || ''}
bookElementId={character.id || ''}
field="history"
elementType="character"
onDownload={function (): void { onCharacterChange('history', seriesCharacter?.history || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={character.history || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('history', e.target.value);
}}
placeholder={t('characterDetail.historyPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.roleFull')}
icon={faScroll}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.role || ''}
currentValue={character.role || ''}
bookElementId={character.id || ''}
field="role"
elementType="character"
onDownload={function (): void { onCharacterChange('role', seriesCharacter?.role || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={character.role || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('role', e.target.value);
}}
placeholder={t('characterDetail.roleFullPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
{/* Attributs de base */}
{basicCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element {
return (
<CharacterSectionElement
key={`basic-${index}`}
title={item.title}
section={item.section}
placeholder={item.placeholder}
icon={item.icon}
selectedCharacter={character}
setSelectedCharacter={setCharacter}
handleAddElement={onAddAttribute}
handleRemoveElement={onRemoveAttribute}
/>
);
})}
{/* Toggle Mode Avancé */}
<div className="flex items-center justify-between p-4 bg-secondary/30 rounded-xl border border-secondary/50">
<div className="flex items-center gap-3">
<FontAwesomeIcon icon={faSliders} className="text-primary w-5 h-5"/>
<span className="text-text-primary font-medium">{t('characterDetail.advancedMode')}</span>
</div>
<button
onClick={function (): void { setShowAdvanced(!showAdvanced); }}
className={`px-4 py-2 rounded-lg transition-all duration-200 ${
showAdvanced
? 'bg-primary text-white'
: 'bg-secondary/50 text-text-primary hover:bg-secondary'
}`}
>
{showAdvanced ? t('characterDetail.hideAdvanced') : t('characterDetail.showAdvanced')}
</button>
</div>
{/* Sections avancées */}
{showAdvanced && (
<>
{/* Identité étendue */}
<CollapsableArea title={t('characterDetail.identitySection')} icon={faGlobe}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t('characterDetail.species')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.species || ''}
currentValue={character.species || ''}
bookElementId={character.id || ''}
field="species"
elementType="character"
onDownload={function (): void { onCharacterChange('species', seriesCharacter?.species || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.species || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('species', e.target.value);
}}
placeholder={t('characterDetail.speciesPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.nationality')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.nationality || ''}
currentValue={character.nationality || ''}
bookElementId={character.id || ''}
field="nationality"
elementType="character"
onDownload={function (): void { onCharacterChange('nationality', seriesCharacter?.nationality || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.nationality || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('nationality', e.target.value);
}}
placeholder={t('characterDetail.nationalityPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.status')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.status || 'alive'}
currentValue={character.status || 'alive'}
bookElementId={character.id || ''}
field="status"
elementType="character"
onDownload={function (): void {
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, status: (seriesCharacter?.status || 'alive') as CharacterProps['status']} : prev;
});
}}
onSyncComplete={onSyncComplete}
>
<SelectBox
defaultValue={character.status || 'alive'}
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, status: e.target.value as CharacterProps['status']} : prev;
});
}}
data={translatedCharacterStatus}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.residence')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.residence || ''}
currentValue={character.residence || ''}
bookElementId={character.id || ''}
field="residence"
elementType="character"
onDownload={function (): void { onCharacterChange('residence', seriesCharacter?.residence || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.residence || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('residence', e.target.value);
}}
placeholder={t('characterDetail.residencePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
{/* Voix du personnage */}
<CollapsableArea title={t('characterDetail.voiceSection')} icon={faCommentDots}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t('characterDetail.speechPattern')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.speechPattern || ''}
currentValue={character.speechPattern || ''}
bookElementId={character.id || ''}
field="speechPattern"
elementType="character"
onDownload={function (): void { onCharacterChange('speechPattern', seriesCharacter?.speechPattern || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={character.speechPattern || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('speechPattern', e.target.value);
}}
placeholder={t('characterDetail.speechPatternPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.catchphrase')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.catchphrase || ''}
currentValue={character.catchphrase || ''}
bookElementId={character.id || ''}
field="catchphrase"
elementType="character"
onDownload={function (): void { onCharacterChange('catchphrase', seriesCharacter?.catchphrase || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.catchphrase || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('catchphrase', e.target.value);
}}
placeholder={t('characterDetail.catchphrasePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
{/* Notes de l'auteur */}
<CollapsableArea title={t('characterDetail.authorSection')} icon={faStickyNote}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t('characterDetail.notes')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.notes || ''}
currentValue={character.notes || ''}
bookElementId={character.id || ''}
field="notes"
elementType="character"
onDownload={function (): void { onCharacterChange('notes', seriesCharacter?.notes || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={character.notes || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('notes', e.target.value);
}}
placeholder={t('characterDetail.notesPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.colorLabel')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.color || ''}
currentValue={character.color || ''}
bookElementId={character.id || ''}
field="color"
elementType="character"
onDownload={function (): void { onCharacterChange('color', seriesCharacter?.color || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.color || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('color', e.target.value);
}}
placeholder={t('characterDetail.colorPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
{/* Attributs avancés */}
{advancedCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element {
return (
<CharacterSectionElement
key={`advanced-${index}`}
title={item.title}
section={item.section}
placeholder={item.placeholder}
icon={item.icon}
selectedCharacter={character}
setSelectedCharacter={setCharacter}
handleAddElement={onAddAttribute}
handleRemoveElement={onRemoveAttribute}
/>
);
})}
</>
)}
</div>
);
}

View File

@@ -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<string>('');
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 (
<div className="space-y-4">
<div className="px-4 mb-4">
<InputField
input={
<TextInput
value={searchQuery}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setSearchQuery(e.target.value);
}}
placeholder={t('characterList.search')}
/>
}
actionIcon={faPlus}
actionLabel={t('characterList.add')}
addButtonCallBack={async function (): Promise<void> {
onAddCharacter();
}}
/>
</div>
<div className="px-2">
{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 (
<CollapsableArea
key={category.value}
title={t(category.label)}
icon={faUser}
>
<div className="space-y-2 p-2">
{categoryCharacters.map(function (char: CharacterProps): React.JSX.Element {
return (
<div
key={char.id}
onClick={function (): void {
onCharacterClick(char);
}}
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"
>
<div className="w-14 h-14 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform">
{char.image ? (
<img
src={char.image}
alt={char.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-primary/10 text-primary font-bold text-lg">
{char.name?.charAt(0)?.toUpperCase() || '?'}
</div>
)}
</div>
<div className="ml-4 flex-1">
<div className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
{char.name || t('characterList.unknown')}
</div>
<div className="text-text-secondary text-sm mt-0.5">
{char.lastName || t('characterList.noLastName')}
</div>
</div>
<div className="w-28 px-3">
<div className="text-primary text-sm font-semibold truncate">
{char.title || t('characterList.noTitle')}
</div>
<div className="text-muted text-xs truncate mt-0.5">
{char.role || t('characterList.noRole')}
</div>
</div>
<div className="w-8 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"
/>
</div>
</div>
);
})}
</div>
</CollapsableArea>
);
})}
{filteredCharacters.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<FontAwesomeIcon icon={faUser} className="text-primary w-10 h-10"/>
</div>
<h3 className="text-text-primary font-semibold text-lg mb-2">
{t('characterList.noCharacters')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('characterList.noCharactersDescription')}
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -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<void> }>) {
const {showToggle = true, entityType = 'book', entityId} = props;
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
@@ -50,15 +63,20 @@ 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<SeriesContextProps>(SeriesContext);
const {localSyncedSeries} = useContext<SeriesSyncContextProps>(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<LocationProps[]>([]);
const [seriesLocations, setSeriesLocations] = useState<SeriesLocationItem[]>([]);
const [newSectionName, setNewSectionName] = useState<string>('');
const [newElementNames, setNewElementNames] = useState<{ [key: string]: string }>({});
const [newSubElementNames, setNewSubElementNames] = useState<{ [key: string]: string }>({});
const [toolEnabled, setToolEnabled] = useState<boolean>(book?.tools?.locations ?? false);
const [toolEnabled, setToolEnabled] = useState<boolean>(isSeriesMode ? true : (book?.tools?.locations ?? false));
const bookSeriesId: string | null = book?.seriesId || null;
useImperativeHandle(ref, function () {
return {
@@ -67,27 +85,55 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
});
useEffect((): void => {
if (currentEntityId) {
getAllLocations().then();
}, []);
}
}, [currentEntityId]);
useEffect((): void => {
if (bookSeriesId && !isSeriesMode) {
getSeriesLocations().then();
}
}, [bookSeriesId]);
async function getSeriesLocations(): Promise<void> {
if (!bookSeriesId) return;
try {
const response: SeriesLocationItem[] = await System.authGetQueryToServer<SeriesLocationItem[]>(
'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<void> {
if (isSeriesMode) return;
try {
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:book:tool:update', {
bookId: bookId,
bookId: currentEntityId,
toolName: 'locations',
enabled: enabled
});
} else {
response = await System.authPatchToServer<boolean>('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: {
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,15 +159,45 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
async function getAllLocations(): Promise<void> {
try {
if (isSeriesMode) {
let response: SeriesLocationItem[];
if (isCurrentlyOffline() || localSeries) {
response = await window.electron.invoke<SeriesLocationItem[]>('db:series:location:list', {seriesId: currentEntityId});
} else {
response = await System.authGetQueryToServer<SeriesLocationItem[]>(
'series/location/list',
token,
lang,
{seriesid: currentEntityId}
);
}
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<LocationListResponse>('db:location:all', {bookid: bookId});
response = await window.electron.invoke<LocationListResponse>('db:location:all', {bookid: currentEntityId});
} else {
if (book?.localBook) {
response = await window.electron.invoke<LocationListResponse>('db:location:all', {bookid: bookId});
response = await window.electron.invoke<LocationListResponse>('db:location:all', {bookid: currentEntityId});
} else {
response = await System.authGetQueryToServer<LocationListResponse>(`location/all`, token, lang, {
bookid: bookId,
bookid: currentEntityId,
});
}
}
@@ -127,12 +205,15 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
setSections(response.locations);
setToolEnabled(response.enabled);
if (setBook && book) {
setBook({...book, tools: {
setBook({
...book, tools: {
characters: book.tools?.characters ?? false,
worlds: book.tools?.worlds ?? false,
spells: book.tools?.spells ?? false,
locations: response.enabled
}});
}
});
}
}
}
} catch (e: unknown) {
@@ -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<string>('db:series:location:section:add', addData);
} else {
sectionId = await System.authPostToServer<string>(
'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<string>('db:location:section:add', {
bookId: bookId,
bookId: currentEntityId,
locationName: newSectionName,
});
} else {
sectionId = await System.authPostToServer<string>(`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,
});
@@ -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<string>('db:series:location:element:add', addData);
} else {
elementId = await System.authPostToServer<string>(
'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<string>('db:location:element:add', {
bookId: bookId,
bookId: currentEntityId,
locationId: sectionId,
elementName: newElementNames[sectionId],
});
} else {
elementId = await System.authPostToServer<string>(`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],
@@ -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<string>('db:series:location:subelement:add', addData);
} else {
subElementId = await System.authPostToServer<string>(
'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<string>('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,
@@ -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<boolean>('db:series:location:element:delete', deleteData);
} else {
response = await System.authDeleteToServer<boolean>('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<boolean>('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<boolean>('db:series:location:subelement:delete', deleteData);
} else {
response = await System.authDeleteToServer<boolean>('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<boolean>('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<void> {
try {
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
if (isSeriesMode) {
const deleteData = {locationId: sectionId};
if (isCurrentlyOffline() || localSeries) {
response = await window.electron.invoke<boolean>('db:series:location:delete', deleteData);
} else {
response = await System.authDeleteToServer<boolean>('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<boolean>('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<void> {
if (!bookSeriesId) return;
try {
const seriesLocationId: string = await System.authPostToServer<string>('series/location/section/add', {
seriesId: bookSeriesId,
name: section.name,
}, token, lang);
if (seriesLocationId) {
const updateResponse: boolean = await System.authPostToServer<boolean>('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<void> {
const seriesLocation: SeriesLocationItem | undefined = seriesLocations.find((location: SeriesLocationItem): boolean => location.id === seriesLocationId);
if (!seriesLocation) return;
try {
const sectionId: string = await System.authPostToServer<string>('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<string>('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<string>('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 (
<div className="space-y-6">
{showToggle && (
{showToggle && !isSeriesMode && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faToggleOn}
@@ -495,8 +770,19 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
</p>
</div>
)}
{toolEnabled && (
{(toolEnabled || isSeriesMode) && (
<>
{!isSeriesMode && bookSeriesId &&
seriesLocations.filter((seriesLocation: SeriesLocationItem): boolean => !sections.some((section: LocationProps): boolean => section.seriesLocationId === seriesLocation.id)).length > 0 && (
<SeriesImportSelector
availableItems={seriesLocations
.filter((seriesLocation: SeriesLocationItem): boolean => !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")}
/>
)}
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-4 border border-secondary/50">
<div className="grid grid-cols-1 gap-4 mb-4">
<InputField
@@ -525,10 +811,21 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
className="ml-2 text-sm bg-dark-background text-text-secondary py-0.5 px-2 rounded-full">
{section.elements.length || 0}
</span>
<div className="ml-auto flex items-center gap-2">
{!isSeriesMode && bookSeriesId && !section.seriesLocationId && (
<button
onClick={(): Promise<void> => handleExportToSeries(section)}
title={t("locationComponent.exportToSeries")}
className="bg-blue-500/90 text-text-primary rounded-full p-1.5 hover:bg-blue-500 transition-colors shadow-md"
>
<FontAwesomeIcon icon={faShare} className={'w-5 h-5'}/>
</button>
)}
<button onClick={(): Promise<void> => handleRemoveSection(section.id)}
className="ml-auto bg-dark-background text-text-primary rounded-full p-1.5 hover:bg-secondary transition-colors shadow-md">
className="bg-dark-background text-text-primary rounded-full p-1.5 hover:bg-secondary transition-colors shadow-md">
<FontAwesomeIcon icon={faTrash} className={'w-5 h-5'}/>
</button>
</div>
</h3>
<div className="space-y-4">
{section.elements.length > 0 ? (

View File

@@ -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<boolean>(false);
const [showAddForm, setShowAddForm] = useState<boolean>(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<void> {
if (newSectionName.trim()) {
await addSection();
setShowAddForm(false);
} else {
setShowAddForm(true);
}
}
async function handleSave(): Promise<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
async function handleDelete(): Promise<void> {
if (selectedSectionIndex >= 0 && sections[selectedSectionIndex]) {
await removeSection(sections[selectedSectionIndex].id);
setShowDeleteConfirm(false);
backToList();
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin"/>
</div>
);
}
const selectedSection: LocationProps | undefined = sections[selectedSectionIndex];
const canExport: boolean = Boolean(bookSeriesId && selectedSection && !selectedSection.seriesLocationId);
return (
<div className="flex flex-col h-full">
<ToolDetailHeader
title={selectedSection?.name || ''}
defaultTitle={t('locationComponent.newSection')}
viewMode={viewMode}
isNew={false}
onBack={backToList}
onEdit={enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onDelete={function (): void { setShowDeleteConfirm(true); }}
onExport={canExport ? function (): Promise<void> { return exportToSeries(selectedSection!); } : undefined}
showExport={canExport}
showDelete={Boolean(selectedSection)}
/>
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-3 p-2">
{/* Toggle tool */}
<div className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('locationComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
</div>
{toolEnabled && (
<>
{/* Import from series */}
{bookSeriesId && availableSeriesLocations.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesLocations.map(function (sl: SeriesLocationItem) {
return {id: sl.id, name: sl.name};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
)}
{showAddForm && (
<div className="px-2">
<InputField
input={
<TextInput
value={newSectionName}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setNewSectionName(e.target.value);
}}
placeholder={t('locationComponent.newSectionPlaceholder')}
/>
}
actionIcon={faPlus}
actionLabel={t('locationComponent.addSectionLabel')}
addButtonCallBack={async function (): Promise<void> {
await addSection();
setShowAddForm(false);
}}
/>
</div>
)}
<LocationEditorList
sections={sections}
onSectionClick={handleSectionClick}
onAddSection={handleAddSection}
/>
</>
)}
</div>
)}
{viewMode === 'detail' && selectedSection && (
<div className="p-4">
<LocationEditorDetail section={selectedSection}/>
</div>
)}
{viewMode === 'edit' && selectedSection && (
<div className="p-4">
<LocationEditorEdit
section={selectedSection}
newElementNames={newElementNames}
newSubElementNames={newSubElementNames}
onAddElement={addElement}
onAddSubElement={addSubElement}
onRemoveElement={removeElement}
onRemoveSubElement={removeSubElement}
onUpdateElement={updateElement}
onUpdateSubElement={updateSubElement}
onNewElementNameChange={function (sectionId: string, name: string): void {
setNewElementNames({...newElementNames, [sectionId]: name});
}}
onNewSubElementNameChange={function (elementIndex: number, name: string): void {
setNewSubElementNames({...newSubElementNames, [elementIndex]: name});
}}
/>
</div>
)}
</div>
{showDeleteConfirm && selectedSection && (
<AlertBox
title={t('locationComponent.deleteTitle')}
message={t('locationComponent.deleteMessage', {name: selectedSection.name})}
type="danger"
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={handleDelete}
onCancel={function (): void { setShowDeleteConfirm(false); }}
/>
)}
</div>
);
}

View File

@@ -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 (
<div>
<h3 className="text-text-primary font-semibold text-base mb-4">{section.name}</h3>
{section.elements.length === 0 ? (
<p className="text-muted text-sm">{t('locationComponent.noElementAvailable')}</p>
) : (
<div className="space-y-4">
{section.elements.map(function (element: Element): React.JSX.Element {
return (
<div key={element.id} className="border-b border-secondary/30 pb-3 last:border-b-0">
<div className="flex items-center gap-2 mb-1">
<FontAwesomeIcon icon={faMapPin} className="text-primary w-3 h-3"/>
<span className="text-text-primary font-medium text-sm">{element.name}</span>
</div>
{element.description && (
<p className="text-text-secondary text-xs ml-5 mb-2">{element.description}</p>
)}
{element.subElements.length > 0 && (
<div className="ml-5 mt-2 space-y-1">
{element.subElements.map(function (subElement: SubElement): React.JSX.Element {
return (
<div key={subElement.id} className="flex items-start gap-2">
<FontAwesomeIcon icon={faLocationDot} className="text-muted w-2 h-2 mt-1.5"/>
<div>
<span className="text-text-primary text-xs">{subElement.name}</span>
{subElement.description && (
<p className="text-muted text-xs">{subElement.description}</p>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -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<void>;
onAddSubElement: (sectionId: string, elementIndex: number) => Promise<void>;
onRemoveElement: (sectionId: string, elementIndex: number) => Promise<void>;
onRemoveSubElement: (sectionId: string, elementIndex: number, subElementIndex: number) => Promise<void>;
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 (
<div className="space-y-4">
<h3 className="text-text-primary font-semibold text-base">{section.name}</h3>
{/* Éléments existants */}
{section.elements.map(function (element: Element, elementIndex: number): React.JSX.Element {
return (
<div key={element.id} className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faMapPin} className="text-primary w-3 h-3"/>
<span className="text-text-secondary text-xs">{t('locationComponent.element')}</span>
</div>
<InputField
input={
<TextInput
value={element.name}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onUpdateElement(section.id, elementIndex, 'name', e.target.value);
}}
placeholder={t('locationComponent.elementNamePlaceholder')}
/>
}
removeButtonCallBack={function (): Promise<void> {
return onRemoveElement(section.id, elementIndex);
}}
/>
<div className="mt-2">
<TexteAreaInput
value={element.description}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onUpdateElement(section.id, elementIndex, 'description', e.target.value);
}}
placeholder={t('locationComponent.elementDescriptionPlaceholder')}
/>
</div>
{/* Sous-éléments */}
{element.subElements.length > 0 && (
<div className="mt-3 pt-3 border-t border-secondary/30 space-y-2">
{element.subElements.map(function (subElement: SubElement, subElementIndex: number): React.JSX.Element {
return (
<div key={subElement.id} className="bg-dark-background/50 rounded p-2">
<InputField
input={
<TextInput
value={subElement.name}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onUpdateSubElement(section.id, elementIndex, subElementIndex, 'name', e.target.value);
}}
placeholder={t('locationComponent.subElementNamePlaceholder')}
/>
}
removeButtonCallBack={function (): Promise<void> {
return onRemoveSubElement(section.id, elementIndex, subElementIndex);
}}
/>
</div>
);
})}
</div>
)}
{/* Ajouter sous-élément */}
<div className="mt-2">
<InputField
input={
<TextInput
value={newSubElementNames[elementIndex] || ''}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onNewSubElementNameChange(elementIndex, e.target.value);
}}
placeholder={t('locationComponent.newSubElementPlaceholder')}
/>
}
actionIcon={faPlus}
actionLabel={t('locationComponent.addSubElement')}
addButtonCallBack={function (): Promise<void> {
return onAddSubElement(section.id, elementIndex);
}}
/>
</div>
</div>
);
})}
{/* Ajouter élément */}
<InputField
fieldName={t('locationComponent.addElement')}
input={
<TextInput
value={newElementNames[section.id] || ''}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onNewElementNameChange(section.id, e.target.value);
}}
placeholder={t('locationComponent.newElementPlaceholder')}
/>
}
actionIcon={faPlus}
addButtonCallBack={function (): Promise<void> {
return onAddElement(section.id);
}}
/>
</div>
);
}

View File

@@ -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<string>('');
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 (
<div className="space-y-3">
<div className="px-2">
<InputField
input={
<TextInput
value={searchQuery}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setSearchQuery(e.target.value);
}}
placeholder={t('locationComponent.search')}
/>
}
actionIcon={faPlus}
actionLabel={t('locationComponent.addSectionLabel')}
addButtonCallBack={async function (): Promise<void> {
onAddSection();
}}
/>
</div>
<div className="px-2 space-y-2">
{filteredSections.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-8 h-8"/>
</div>
<h3 className="text-text-primary font-semibold text-base mb-1">
{t('locationComponent.noSectionAvailable')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('locationComponent.noSectionDescription')}
</p>
</div>
) : (
filteredSections.map(function (section: LocationProps, index: number): React.JSX.Element {
return (
<div
key={section.id}
onClick={function (): void { onSectionClick(section, index); }}
className="group flex items-center p-3 bg-secondary/30 rounded-lg border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md transition-all duration-200 hover:border-primary/50"
>
<div className="w-10 h-10 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-sm group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-5 h-5"/>
</div>
<div className="ml-3 flex-1 min-w-0">
<div className="text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate">
{section.name}
</div>
<div className="text-muted text-xs truncate">
{t('locationComponent.elementsCount', {count: countTotalElements(section)})}
</div>
</div>
<div className="w-6 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3"
/>
</div>
</div>
);
})
)}
</div>
</div>
);
}

View File

@@ -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<boolean>(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<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
async function handleDelete(): Promise<void> {
if (selectedSectionIndex >= 0 && sections[selectedSectionIndex]) {
await removeSection(sections[selectedSectionIndex].id);
setShowDeleteConfirm(false);
backToList();
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
</div>
);
}
const selectedSection: LocationProps | undefined = sections[selectedSectionIndex];
const canExport: boolean = Boolean(bookSeriesId && selectedSection && !selectedSection.seriesLocationId);
return (
<div className="flex flex-col h-full">
{/* Header - uniquement pour detail/edit */}
<ToolDetailHeader
title={selectedSection?.name || ''}
defaultTitle={t('locationComponent.newSection')}
viewMode={viewMode}
isNew={false}
onBack={backToList}
onEdit={enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onDelete={function (): void { setShowDeleteConfirm(true); }}
onExport={canExport ? function (): Promise<void> { return exportToSeries(selectedSection!); } : undefined}
showExport={canExport}
showDelete={Boolean(selectedSection)}
/>
{/* Contenu principal */}
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-5 p-4">
{/* Toggle tool */}
{showToggle && !isSeriesMode && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('locationComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
<p className="text-muted text-sm mt-2">
{t('locationComponent.enableToolDescription')}
</p>
</div>
)}
{/* Contenu si outil activé */}
{(toolEnabled || isSeriesMode) && (
<>
{/* Import from series */}
{!isSeriesMode && bookSeriesId && availableSeriesLocations.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesLocations.map(function (sl: SeriesLocationItem) {
return {id: sl.id, name: sl.name};
})}
onImport={importFromSeries}
placeholder={t("seriesImport.selectElement")}
label={t("seriesImport.importFromSeries")}
/>
)}
{/* Liste des sections */}
<LocationSettingsList
sections={sections}
newSectionName={newSectionName}
onSectionClick={enterDetailMode}
onAddSection={addSection}
onNewSectionNameChange={setNewSectionName}
/>
</>
)}
</div>
)}
{viewMode === 'detail' && selectedSection && (
<div className="p-4">
<LocationSettingsDetail section={selectedSection}/>
</div>
)}
{viewMode === 'edit' && selectedSection && (
<div className="p-4">
<LocationSettingsEdit
section={selectedSection}
newElementNames={newElementNames}
newSubElementNames={newSubElementNames}
onAddElement={addElement}
onAddSubElement={addSubElement}
onRemoveElement={removeElement}
onRemoveSubElement={removeSubElement}
onUpdateElement={updateElement}
onUpdateSubElement={updateSubElement}
onNewElementNameChange={function (sectionId: string, name: string): void {
setNewElementNames({...newElementNames, [sectionId]: name});
}}
onNewSubElementNameChange={function (elementIndex: number, name: string): void {
setNewSubElementNames({...newSubElementNames, [elementIndex]: name});
}}
/>
</div>
)}
</div>
{/* Modal de confirmation de suppression */}
{showDeleteConfirm && selectedSection && (
<AlertBox
title={t('locationComponent.deleteTitle')}
message={t('locationComponent.deleteMessage', {name: selectedSection.name})}
type="danger"
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={handleDelete}
onCancel={function (): void { setShowDeleteConfirm(false); }}
/>
)}
</div>
);
}

View File

@@ -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 (
<div className="space-y-6 px-2 pb-4">
{/* Hero Section */}
<div className="p-6 bg-gradient-to-r from-primary/10 via-secondary/20 to-transparent rounded-2xl border border-secondary/30">
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl bg-primary/20 flex items-center justify-center shrink-0">
<FontAwesomeIcon icon={faMapMarkerAlt} className="w-8 h-8 text-primary"/>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-bold text-text-primary">{section.name}</h2>
<p className="text-text-secondary mt-2">
{t("locationComponent.elementsCount", {count: section.elements.length})}
</p>
</div>
</div>
</div>
{/* Éléments en grille */}
{section.elements.length === 0 ? (
<div className="text-center py-12 text-text-secondary bg-secondary/10 rounded-xl border border-secondary/20">
<FontAwesomeIcon icon={faMapPin} className="w-8 h-8 mb-3 opacity-50"/>
<p>{t("locationComponent.noElementAvailable")}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{section.elements.map(function (element: Element): React.JSX.Element {
return (
<div key={element.id} className="p-5 bg-secondary/20 rounded-xl border border-secondary/30 hover:border-primary/30 transition-colors">
{/* Element header */}
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-lg bg-primary/20 flex items-center justify-center">
<FontAwesomeIcon icon={faMapPin} className="w-5 h-5 text-primary"/>
</div>
<h3 className="text-text-primary font-semibold text-lg">{element.name}</h3>
</div>
{/* Description */}
<p className={`mb-4 ${element.description ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{element.description || '—'}
</p>
{/* Sub-elements */}
{element.subElements.length > 0 && (
<div className="pt-4 border-t border-secondary/30">
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-3 flex items-center gap-2">
<FontAwesomeIcon icon={faLocationDot} className="w-3 h-3"/>
{t("locationComponent.subElementsHeading")} ({element.subElements.length})
</h4>
<div className="space-y-2">
{element.subElements.map(function (subElement: SubElement): React.JSX.Element {
return (
<div key={subElement.id} className="flex items-start gap-2 p-2 bg-dark-background/30 rounded-lg">
<FontAwesomeIcon icon={faChevronRight} className="w-3 h-3 text-primary mt-1 shrink-0"/>
<div className="min-w-0">
<p className="text-text-primary font-medium">{subElement.name}</p>
{subElement.description && (
<p className="text-text-secondary text-sm mt-1">{subElement.description}</p>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -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<void>;
onAddSubElement: (sectionId: string, elementIndex: number) => Promise<void>;
onRemoveElement: (sectionId: string, elementIndex: number) => Promise<void>;
onRemoveSubElement: (sectionId: string, elementIndex: number, subElementIndex: number) => Promise<void>;
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 (
<div className="space-y-4 px-2 pb-4">
{/* Header de la section */}
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-6 h-6"/>
</div>
<div>
<h2 className="text-text-primary font-bold text-xl">{section.name}</h2>
</div>
</div>
{/* Éléments existants */}
{section.elements.map(function (element: Element, elementIndex: number): React.JSX.Element {
return (
<div key={element.id} className="bg-secondary/30 rounded-xl p-4 border border-secondary/50">
<div className="mb-3">
<InputField
fieldName={t("locationComponent.elementName")}
input={
<TextInput
value={element.name}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onUpdateElement(section.id, elementIndex, 'name', e.target.value);
}}
placeholder={t("locationComponent.elementNamePlaceholder")}
/>
}
removeButtonCallBack={function (): Promise<void> {
return onRemoveElement(section.id, elementIndex);
}}
/>
</div>
<TexteAreaInput
value={element.description}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onUpdateElement(section.id, elementIndex, 'description', e.target.value);
}}
placeholder={t("locationComponent.elementDescriptionPlaceholder")}
/>
{/* Sous-éléments */}
<div className="mt-4 pt-4 border-t border-secondary/50">
{element.subElements.length > 0 && (
<h4 className="text-sm italic text-text-secondary mb-3">
{t("locationComponent.subElementsHeading")}
</h4>
)}
{element.subElements.map(function (subElement: SubElement, subElementIndex: number): React.JSX.Element {
return (
<div key={subElement.id} className="bg-dark-background rounded-lg p-3 mb-3">
<div className="mb-2">
<InputField
input={
<TextInput
value={subElement.name}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onUpdateSubElement(section.id, elementIndex, subElementIndex, 'name', e.target.value);
}}
placeholder={t("locationComponent.subElementNamePlaceholder")}
/>
}
removeButtonCallBack={function (): Promise<void> {
return onRemoveSubElement(section.id, elementIndex, subElementIndex);
}}
/>
</div>
<TexteAreaInput
value={subElement.description}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onUpdateSubElement(section.id, elementIndex, subElementIndex, 'description', e.target.value);
}}
placeholder={t("locationComponent.subElementDescriptionPlaceholder")}
/>
</div>
);
})}
{/* Ajouter sous-élément */}
<InputField
input={
<TextInput
value={newSubElementNames[elementIndex] || ''}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onNewSubElementNameChange(elementIndex, e.target.value);
}}
placeholder={t("locationComponent.newSubElementPlaceholder")}
/>
}
actionIcon={faPlus}
actionLabel={t("locationComponent.addSubElement")}
addButtonCallBack={function (): Promise<void> {
return onAddSubElement(section.id, elementIndex);
}}
/>
</div>
</div>
);
})}
{/* Ajouter élément */}
<div className="bg-secondary/20 rounded-xl p-4 border border-secondary/30">
<InputField
fieldName={t("locationComponent.addElement")}
input={
<TextInput
value={newElementNames[section.id] || ''}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onNewElementNameChange(section.id, e.target.value);
}}
placeholder={t("locationComponent.newElementPlaceholder")}
/>
}
actionIcon={faPlus}
actionLabel={t("locationComponent.addElement")}
addButtonCallBack={function (): Promise<void> {
return onAddElement(section.id);
}}
/>
</div>
</div>
);
}

View File

@@ -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<void>;
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 (
<div className="space-y-4">
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-4 border border-secondary/50">
<InputField
input={
<TextInput
value={newSectionName}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onNewSectionNameChange(e.target.value);
}}
placeholder={t("locationComponent.newSectionPlaceholder")}
/>
}
actionIcon={faPlus}
actionLabel={t("locationComponent.addSectionLabel")}
addButtonCallBack={onAddSection}
/>
</div>
<div className="space-y-2 px-2">
{sections.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-10 h-10"/>
</div>
<h3 className="text-text-primary font-semibold text-lg mb-2">
{t("locationComponent.noSectionAvailable")}
</h3>
<p className="text-muted text-sm max-w-xs">
{t("locationComponent.noSectionDescription")}
</p>
</div>
) : (
sections.map(function (section: LocationProps, index: number): React.JSX.Element {
return (
<div
key={section.id}
onClick={function (): void { onSectionClick(index); }}
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"
>
<div className="w-12 h-12 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-6 h-6"/>
</div>
<div className="ml-4 flex-1 min-w-0">
<div className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
{section.name}
</div>
<div className="text-text-secondary text-sm mt-0.5">
{t("locationComponent.elementsCount", {count: countTotalElements(section)})}
</div>
</div>
<div className="w-8 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"
/>
</div>
</div>
);
})
)}
</div>
</div>
);
}

View File

@@ -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<void> }>) {
const {showToggle = true} = props;
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(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<SpellListItem[]>([]);
const [tags, setTags] = useState<SpellTagProps[]>([]);
const [selectedSpell, setSelectedSpell] = useState<SpellEditState | null>(null);
const [toolEnabled, setToolEnabled] = useState<boolean>(book?.tools?.spells ?? false);
const [showTagManager, setShowTagManager] = useState<boolean>(false);
useImperativeHandle(ref, function () {
return {
handleSave: handleSaveSpell,
};
});
useEffect((): void => {
getSpells().then();
}, []);
async function handleToggleTool(enabled: boolean): Promise<void> {
try {
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:book:tool:update', {
bookId: bookId,
toolName: 'spells',
enabled: enabled
});
} else {
response = await System.authPatchToServer<boolean>('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<void> {
try {
let response: SpellListResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<SpellListResponse>('db:spell:list', {bookid: bookId});
} else {
if (book?.localBook) {
response = await window.electron.invoke<SpellListResponse>('db:spell:list', {bookid: bookId});
} else {
response = await System.authGetQueryToServer<SpellListResponse>('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<void> {
// 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<SpellProps>('db:spell:detail', {spellid: spell.id});
} else {
if (book?.localBook) {
response = await window.electron.invoke<SpellProps>('db:spell:detail', {spellid: spell.id});
} else {
response = await System.authGetQueryToServer<SpellProps>('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<void> {
if (selectedSpell) {
if (selectedSpell.id === null) {
await addNewSpell(selectedSpell);
} else {
await updateSpell(selectedSpell);
}
}
}
async function addNewSpell(spell: SpellEditState): Promise<void> {
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<string>('db:spell:create', {
bookId: bookId,
spell: spellPost,
});
} else {
const createdSpell: SpellProps = await System.authPostToServer<SpellProps>('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<void> {
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<boolean>('db:spell:update', {
spellId: spell.id,
spell: spellPost,
});
} else {
response = await System.authPutToServer<boolean>('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<void> {
try {
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:spell:delete', {
spellId: spellId,
});
} else {
response = await System.authDeleteToServer<boolean>('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<SpellTagProps | null> {
try {
let tagId: string;
if (isCurrentlyOffline() || book?.localBook) {
tagId = await window.electron.invoke<string>('db:spell:tag:create', {
bookId: bookId,
name: name,
color: color,
});
} else {
const newTag: SpellTagProps = await System.authPostToServer<SpellTagProps>('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<boolean> {
try {
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:spell:tag:update', {
tagId: tagId,
name: name,
color: color,
});
} else {
response = await System.authPutToServer<boolean>('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<boolean> {
try {
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:spell:tag:delete', {
tagId: tagId,
bookId: bookId,
});
} else {
response = await System.authDeleteToServer<boolean>('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 (
<div className="space-y-5">
{showToggle && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('spellComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={async (checked: boolean): Promise<void> => handleToggleTool(checked)}
/>
}
/>
<p className="text-muted text-sm mt-2">
{t('spellComponent.enableToolDescription')}
</p>
</div>
)}
{toolEnabled && (
<>
{showTagManager ? (
<SpellTagManager
tags={tags}
onBack={handleBackFromTagManager}
onCreateTag={handleCreateTag}
onUpdateTag={handleUpdateTag}
onDeleteTag={handleDeleteTag}
/>
) : selectedSpell ? (
<SpellDetail
selectedSpell={selectedSpell}
setSelectedSpell={setSelectedSpell}
availableTags={tags}
handleSpellChange={handleSpellChange}
handleSaveSpell={handleSaveSpell}
handleDeleteSpell={handleDeleteSpell}
handleCreateTagInline={handleCreateTag}
/>
) : (
<SpellList
spells={spells}
tags={tags}
handleSpellClick={handleSpellClick}
handleAddSpell={handleAddSpell}
handleManageTags={handleManageTags}
/>
)}
</>
)}
</div>
);
}
export default forwardRef(SpellComponent);

View File

@@ -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<SetStateAction<SpellEditState | null>>;
availableTags: SpellTagProps[];
handleSpellChange: (key: keyof SpellEditState, value: string | string[] | null) => void;
handleSaveSpell: () => Promise<void>;
handleDeleteSpell: (spellId: string) => Promise<void>;
handleCreateTagInline: (name: string, color: string) => Promise<SpellTagProps | null>;
}
export default function SpellDetail(
{
selectedSpell,
setSelectedSpell,
availableTags,
handleSpellChange,
handleSaveSpell,
handleDeleteSpell,
handleCreateTagInline,
}: SpellDetailProps) {
const t = useTranslations();
const [tagSearchQuery, setTagSearchQuery] = useState<string>('');
const [showTagDropdown, setShowTagDropdown] = useState<boolean>(false);
const [isCreatingTag, setIsCreatingTag] = useState<boolean>(false);
const [newTagColor, setNewTagColor] = useState<string>(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<void> {
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 (
<div className="space-y-4">
<div
className="flex justify-between items-center p-4 border-b border-secondary/50 bg-tertiary/50 backdrop-blur-sm">
<button
onClick={() => setSelectedSpell(null)}
className="flex items-center gap-2 bg-secondary/50 py-2 px-4 rounded-xl border border-secondary/50 hover:bg-secondary hover:border-secondary hover:shadow-md hover:scale-105 transition-all duration-200"
>
<FontAwesomeIcon icon={faArrowLeft} className="text-primary w-4 h-4"/>
<span className="text-text-primary font-medium">{t("spellDetail.back")}</span>
</button>
<span className="text-text-primary font-semibold text-lg">
{selectedSpell.name || t("spellDetail.newSpell")}
</span>
<div className="flex items-center gap-2">
{selectedSpell.id && (
<DeleteButton
onDelete={(): Promise<void> => handleDeleteSpell(selectedSpell.id as string)}
confirmTitle={t("spellDetail.deleteTitle")}
confirmMessage={t("spellDetail.deleteMessage", {name: selectedSpell.name})}
confirmButtonText={t("common.delete")}
cancelButtonText={t("common.cancel")}
/>
)}
<button
onClick={handleSaveSpell}
className="flex items-center justify-center bg-primary w-10 h-10 rounded-xl border border-primary-dark shadow-md hover:shadow-lg hover:scale-110 transition-all duration-200"
>
<FontAwesomeIcon icon={selectedSpell.id ? faSave : faPlus}
className="text-text-primary w-5 h-5"/>
</button>
</div>
</div>
<div className="overflow-y-auto max-h-[calc(100vh-350px)] space-y-4 px-2 pb-4">
<CollapsableArea title={t("spellDetail.basicInfo")} icon={faHatWizard}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t("spellDetail.name")}
input={
<TextInput
value={selectedSpell.name}
setValue={(e) => handleSpellChange('name', e.target.value)}
placeholder={t("spellDetail.namePlaceholder")}
/>
}
/>
<InputField
fieldName={t("spellDetail.description")}
icon={faBook}
input={
<TexteAreaInput
value={selectedSpell.description}
setValue={(e) => handleSpellChange('description', e.target.value)}
placeholder={t("spellDetail.descriptionPlaceholder")}
/>
}
/>
<InputField
fieldName={t("spellDetail.appearance")}
icon={faEye}
input={
<TexteAreaInput
value={selectedSpell.appearance}
setValue={(e) => handleSpellChange('appearance', e.target.value)}
placeholder={t("spellDetail.appearancePlaceholder")}
/>
}
/>
</div>
</CollapsableArea>
<CollapsableArea title={t("spellDetail.tags")} icon={faTags}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{selectedTags.map((tag: SpellTagProps) => (
<SpellTagChip
key={tag.id}
tag={tag}
onRemove={() => handleRemoveTag(tag.id)}
/>
))}
</div>
)}
<div className="relative">
<TextInput
value={tagSearchQuery}
setValue={(e) => {
setTagSearchQuery(e.target.value);
setShowTagDropdown(true);
}}
placeholder={t("spellDetail.addTag")}
onFocus={() => setShowTagDropdown(true)}
/>
{showTagDropdown && (tagSearchQuery || filteredTags.length > 0) && (
<div
className="absolute z-10 w-full mt-1 bg-tertiary border border-secondary/50 rounded-xl shadow-lg max-h-48 overflow-y-auto">
{filteredTags.map((tag: SpellTagProps) => (
<button
key={tag.id}
onClick={() => handleAddTag(tag.id)}
className="w-full flex items-center gap-2 px-4 py-2 hover:bg-secondary/50 transition-colors text-left"
>
<span
className="w-3 h-3 rounded-full"
style={{backgroundColor: tag.color || '#3B82F6'}}
/>
<span className="text-text-primary">{tag.name}</span>
</button>
))}
{showCreateOption && !isCreatingTag && (
<button
onClick={() => setIsCreatingTag(true)}
className="w-full flex items-center gap-2 px-4 py-2 hover:bg-secondary/50 transition-colors text-left border-t border-secondary/30"
>
<FontAwesomeIcon icon={faPlus} className="text-primary w-3 h-3"/>
<span className="text-primary font-medium">
{t("spellDetail.createTag", {name: tagSearchQuery})}
</span>
</button>
)}
{isCreatingTag && (
<div className="p-4 border-t border-secondary/30">
<p className="text-text-secondary text-sm mb-3">
{t("spellDetail.createTag", {name: tagSearchQuery})}
</p>
<div className="flex flex-wrap gap-2 mb-3">
{defaultTagColors.map((color: string) => (
<button
key={color}
onClick={() => setNewTagColor(color)}
className={`w-8 h-8 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-2 ring-primary scale-110' : 'hover:scale-110'}`}
style={{backgroundColor: color}}
/>
))}
</div>
<div className="flex gap-2">
<button
onClick={() => setIsCreatingTag(false)}
className="flex-1 py-2 px-3 bg-secondary/50 text-text-primary rounded-lg hover:bg-secondary transition-colors"
>
{t("common.cancel")}
</button>
<button
onClick={handleCreateTag}
className="flex-1 py-2 px-3 bg-primary text-text-primary rounded-lg hover:bg-primary-dark transition-colors"
>
{t("common.confirm")}
</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
</CollapsableArea>
<CollapsableArea title={t("spellDetail.powerLevel")} icon={faBolt}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<SelectBox
defaultValue={selectedSpell.powerLevel || 'none'}
onChangeCallBack={(e) => handleSpellChange('powerLevel', e.target.value === 'none' ? null : e.target.value)}
data={getLocalizedPowerLevels()}
/>
</div>
</CollapsableArea>
<CollapsableArea title={t("spellDetail.components")} icon={faPuzzlePiece}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<TexteAreaInput
value={selectedSpell.components || ''}
setValue={(e) => handleSpellChange('components', e.target.value || null)}
placeholder={t("spellDetail.componentsPlaceholder")}
/>
</div>
</CollapsableArea>
<CollapsableArea title={t("spellDetail.limitations")} icon={faTriangleExclamation}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<TexteAreaInput
value={selectedSpell.limitations || ''}
setValue={(e) => handleSpellChange('limitations', e.target.value || null)}
placeholder={t("spellDetail.limitationsPlaceholder")}
/>
</div>
</CollapsableArea>
<CollapsableArea title={t("spellDetail.notes")} icon={faStickyNote}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<TexteAreaInput
value={selectedSpell.notes || ''}
setValue={(e) => handleSpellChange('notes', e.target.value || null)}
placeholder={t("spellDetail.notesPlaceholder")}
/>
</div>
</CollapsableArea>
</div>
</div>
);
}

View File

@@ -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<string>('');
const [filterTag, setFilterTag] = useState<string>('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 (
<div className="space-y-4">
<div className="px-4 space-y-3">
<InputField
input={
<TextInput
value={searchQuery}
setValue={(e) => setSearchQuery(e.target.value)}
placeholder={t("spellList.search")}
/>
}
actionIcon={faPlus}
actionLabel={t("spellList.add")}
addButtonCallBack={async () => handleAddSpell()}
/>
<div className="flex flex-wrap gap-3 items-center">
<div className="flex-1 min-w-[150px]">
<select
value={filterTag}
onChange={(e) => setFilterTag(e.target.value)}
className="w-full text-text-primary bg-secondary/50 hover:bg-secondary px-4 py-2.5 rounded-xl border border-secondary/50 focus:border-primary focus:ring-4 focus:ring-primary/20 focus:bg-secondary hover:border-secondary outline-none transition-all duration-200 cursor-pointer"
>
<option value="all"
className="bg-tertiary text-text-primary">{t("spellList.allTags")}</option>
{tags.map((tag: SpellTagProps) => (
<option key={tag.id} value={tag.id} className="bg-tertiary text-text-primary">
{tag.name}
</option>
))}
</select>
</div>
<button
onClick={handleManageTags}
className="flex items-center gap-2 px-4 py-2.5 bg-secondary/50 rounded-xl border border-secondary/50 hover:bg-secondary hover:border-secondary hover:shadow-md hover:scale-105 transition-all duration-200"
>
<FontAwesomeIcon icon={faCog} className="text-primary w-4 h-4"/>
<span className="text-text-primary text-sm font-medium">{t("spellList.manageTags")}</span>
</button>
</div>
</div>
<div className="overflow-y-auto max-h-[calc(100vh-450px)] px-2">
{filteredSpells.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-10 h-10"/>
</div>
<h3 className="text-text-primary font-semibold text-lg mb-2">{t("spellList.noSpells")}</h3>
<p className="text-muted text-sm max-w-xs">{t("spellList.noSpellsDescription")}</p>
</div>
) : (
<div className="space-y-2 p-2">
{filteredSpells.map((spell: SpellListItem) => (
<div
key={spell.id}
onClick={() => 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"
>
<div
className="w-12 h-12 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-6 h-6"/>
</div>
<div className="ml-4 flex-1 min-w-0">
<div
className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
{spell.name}
</div>
<div className="text-text-secondary text-sm mt-0.5 truncate">
{spell.description}
</div>
{spell.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{spell.tags.slice(0, 3).map((tag: SpellTagProps) => (
<SpellTagChip key={tag.id} tag={tag} size="sm"/>
))}
{spell.tags.length > 3 && (
<span
className="text-muted text-xs px-2 py-0.5 bg-secondary/50 rounded-full">
+{spell.tags.length - 3}
</span>
)}
</div>
)}
</div>
<div className="w-8 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -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<boolean>(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<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
async function handleDelete(): Promise<void> {
if (selectedSpell?.id) {
await deleteSpell(selectedSpell.id);
setShowDeleteConfirm(false);
backToList();
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin"/>
</div>
);
}
const isNew: boolean = selectedSpell?.id === null;
const canExport: boolean = Boolean(bookSeriesId && selectedSpell?.id && !selectedSpell.seriesSpellId);
return (
<div className="flex flex-col h-full">
<ToolDetailHeader
title={selectedSpell?.name || ''}
defaultTitle={t('spellDetail.newSpell')}
viewMode={viewMode}
isNew={isNew}
onBack={backToList}
onEdit={enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onDelete={function (): void { setShowDeleteConfirm(true); }}
onExport={canExport ? exportToSeries : undefined}
showExport={canExport}
showDelete={Boolean(selectedSpell?.id)}
/>
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-3 p-2">
{/* Toggle tool */}
<div className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('spellComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
</div>
{toolEnabled && (
<>
{/* Import from series */}
{bookSeriesId && availableSeriesSpells.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesSpells.map(function (ss: SeriesSpellListItem) {
return {
id: ss.id,
name: ss.name
};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
)}
<SpellEditorList
spells={spells}
tags={tags}
onSpellClick={enterDetailMode}
onAddSpell={addNewSpell}
onManageTags={function (): void { setShowTagManager(true); }}
/>
</>
)}
</div>
)}
{viewMode === 'detail' && selectedSpell && (
<div className="p-4">
<SpellEditorDetail
spell={selectedSpell}
availableTags={tags}
seriesSpell={selectedSeriesSpell}
/>
</div>
)}
{viewMode === 'edit' && selectedSpell && (
<div className="p-4">
<SpellEditorEdit
spell={selectedSpell}
availableTags={tags}
onSpellChange={handleSpellChange}
onCreateTag={createTag}
seriesSpell={selectedSeriesSpell}
onSyncComplete={handleSyncComplete}
/>
</div>
)}
</div>
{showDeleteConfirm && selectedSpell?.id && (
<AlertBox
title={t('spellDetail.deleteTitle')}
message={t('spellDetail.deleteMessage', {name: selectedSpell.name})}
type="danger"
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={handleDelete}
onCancel={function (): void { setShowDeleteConfirm(false); }}
/>
)}
{showTagManager && (
<SpellTagManager
tags={tags}
onBack={function (): void { setShowTagManager(false); }}
onCreateTag={createTag}
onUpdateTag={updateTag}
onDeleteTag={deleteTag}
/>
)}
</div>
);
}

View File

@@ -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 (
<div className="mb-3">
<span className="text-text-secondary text-xs block mb-1">{label}</span>
<p className="text-text-primary text-sm whitespace-pre-wrap">{value}</p>
</div>
);
}
const selectedTags: SpellTagProps[] = getSelectedTags();
const powerLevelText: string = getLocalizedPowerLevel();
return (
<div>
<h3 className="text-text-primary font-semibold text-base mb-4">{spell.name}</h3>
{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 && (
<div className="mb-3">
<span className="text-text-secondary text-xs block mb-1">{t('spellDetail.tags')}</span>
<div className="flex flex-wrap gap-1.5">
{selectedTags.map(function (tag: SpellTagProps): React.JSX.Element {
return <SpellTagChip key={tag.id} tag={tag} size="sm"/>;
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -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<SpellTagProps | null>;
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<string>('');
const [isCreatingTag, setIsCreatingTag] = useState<boolean>(false);
const [newTagColor, setNewTagColor] = useState<string>(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<void> {
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 (
<div className="space-y-4">
{/* Informations de base */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.basicInfo')}</h4>
<div className="space-y-3">
<InputField
fieldName={t('spellDetail.name')}
input={
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.name || ''}
currentValue={spell.name}
bookElementId={spell.id || ''}
field="name"
elementType="spell"
onDownload={function (): void { onSpellChange('name', seriesSpell?.name || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={spell.name}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onSpellChange('name', e.target.value);
}}
placeholder={t('spellDetail.namePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('spellDetail.description')}
input={
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.description || ''}
currentValue={spell.description}
bookElementId={spell.id || ''}
field="description"
elementType="spell"
onDownload={function (): void { onSpellChange('description', seriesSpell?.description || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.description}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('description', e.target.value);
}}
placeholder={t('spellDetail.descriptionPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('spellDetail.appearance')}
input={
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.appearance || ''}
currentValue={spell.appearance}
bookElementId={spell.id || ''}
field="appearance"
elementType="spell"
onDownload={function (): void { onSpellChange('appearance', seriesSpell?.appearance || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.appearance}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('appearance', e.target.value);
}}
placeholder={t('spellDetail.appearancePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
</div>
</div>
{/* Tags */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.tags')}</h4>
<div className="space-y-3">
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedTags.map(function (tag: SpellTagProps): React.JSX.Element {
return (
<SpellTagChip
key={tag.id}
tag={tag}
onRemove={function (): void { handleRemoveTag(tag.id); }}
/>
);
})}
</div>
)}
<TextInput
value={tagSearchQuery}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
setTagSearchQuery(e.target.value);
}}
placeholder={t('spellDetail.addTag')}
/>
{filteredTags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{filteredTags.map(function (tag: SpellTagProps): React.JSX.Element {
return (
<button
key={tag.id}
onClick={function (): void { handleAddTag(tag.id); }}
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-all duration-200 hover:scale-105 border"
style={{
backgroundColor: `${tag.color || '#3B82F6'}20`,
borderColor: `${tag.color || '#3B82F6'}50`,
color: tag.color || '#3B82F6'
}}
>
<FontAwesomeIcon icon={faPlus} className="w-2.5 h-2.5"/>
{tag.name}
</button>
);
})}
</div>
)}
{showCreateOption && !isCreatingTag && (
<button
onClick={function (): void { setIsCreatingTag(true); }}
className="w-full flex items-center gap-2 px-3 py-2 bg-tertiary hover:bg-secondary/50 transition-colors text-left rounded-lg border border-secondary/50"
>
<FontAwesomeIcon icon={faPlus} className="text-primary w-3 h-3"/>
<span className="text-primary font-medium text-sm">
{t('spellDetail.createTag', {name: tagSearchQuery})}
</span>
</button>
)}
{isCreatingTag && (
<div className="p-3 bg-tertiary rounded-lg border border-secondary/50">
<p className="text-text-secondary text-xs mb-2">
{t('spellDetail.createTag', {name: tagSearchQuery})}
</p>
<div className="flex flex-wrap gap-1.5 mb-2">
{defaultTagColors.map(function (color: string): React.JSX.Element {
return (
<button
key={color}
onClick={function (): void { setNewTagColor(color); }}
className={`w-6 h-6 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-1 ring-primary scale-110' : 'hover:scale-110'}`}
style={{backgroundColor: color}}
/>
);
})}
</div>
<div className="flex gap-2">
<button
onClick={function (): void { setIsCreatingTag(false); }}
className="flex-1 py-1.5 px-2 bg-secondary/50 text-text-primary rounded-lg hover:bg-secondary transition-colors text-sm"
>
{t('common.cancel')}
</button>
<button
onClick={handleCreateTag}
className="flex-1 py-1.5 px-2 bg-primary text-text-primary rounded-lg hover:bg-primary-dark transition-colors text-sm"
>
{t('common.confirm')}
</button>
</div>
</div>
)}
</div>
</div>
{/* Niveau de puissance */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.powerLevel')}</h4>
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.powerLevel || 'none'}
currentValue={spell.powerLevel || 'none'}
bookElementId={spell.id || ''}
field="powerLevel"
elementType="spell"
onDownload={function (): void { onSpellChange('powerLevel', seriesSpell?.powerLevel || null); }}
onSyncComplete={onSyncComplete}
>
<SelectBox
defaultValue={spell.powerLevel || 'none'}
onChangeCallBack={function (e: ChangeEvent<HTMLSelectElement>): void {
onSpellChange('powerLevel', e.target.value === 'none' ? null : e.target.value);
}}
data={getLocalizedPowerLevels()}
/>
</SyncFieldWrapper>
</div>
{/* Composants */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.components')}</h4>
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.components || ''}
currentValue={spell.components || ''}
bookElementId={spell.id || ''}
field="components"
elementType="spell"
onDownload={function (): void { onSpellChange('components', seriesSpell?.components || null); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.components || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('components', e.target.value || null);
}}
placeholder={t('spellDetail.componentsPlaceholder')}
/>
</SyncFieldWrapper>
</div>
{/* Limitations */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.limitations')}</h4>
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.limitations || ''}
currentValue={spell.limitations || ''}
bookElementId={spell.id || ''}
field="limitations"
elementType="spell"
onDownload={function (): void { onSpellChange('limitations', seriesSpell?.limitations || null); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.limitations || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('limitations', e.target.value || null);
}}
placeholder={t('spellDetail.limitationsPlaceholder')}
/>
</SyncFieldWrapper>
</div>
{/* Notes */}
<div className="pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.notes')}</h4>
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.notes || ''}
currentValue={spell.notes || ''}
bookElementId={spell.id || ''}
field="notes"
elementType="spell"
onDownload={function (): void { onSpellChange('notes', seriesSpell?.notes || null); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.notes || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('notes', e.target.value || null);
}}
placeholder={t('spellDetail.notesPlaceholder')}
/>
</SyncFieldWrapper>
</div>
</div>
);
}

View File

@@ -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<string>('');
const [selectedTagId, setSelectedTagId] = useState<string | null>(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 (
<div className="space-y-3">
<div className="px-2">
<InputField
input={
<TextInput
value={searchQuery}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setSearchQuery(e.target.value);
}}
placeholder={t('spellList.search')}
/>
}
actionIcon={faPlus}
actionLabel={t('spellList.add')}
addButtonCallBack={async function (): Promise<void> {
onAddSpell();
}}
/>
</div>
{/* Tag filter + manage button */}
<div className="px-2 flex items-center gap-2 flex-wrap">
<button
onClick={function (): void { setSelectedTagId(null); }}
className={`px-2 py-1 text-xs rounded-full transition-colors ${
selectedTagId === null
? 'bg-primary text-white'
: 'bg-secondary/50 text-text-secondary hover:bg-secondary'
}`}
>
{t('spellList.allTags')}
</button>
{tags.slice(0, 4).map(function (tag: SpellTagProps): React.JSX.Element {
return (
<button
key={tag.id}
onClick={function (): void { setSelectedTagId(tag.id === selectedTagId ? null : tag.id); }}
className={`px-2 py-1 text-xs rounded-full transition-colors ${
selectedTagId === tag.id
? 'bg-primary text-white'
: 'bg-secondary/50 text-text-secondary hover:bg-secondary'
}`}
style={selectedTagId === tag.id ? {} : {borderLeft: `3px solid ${tag.color}`}}
>
{tag.name}
</button>
);
})}
<button
onClick={onManageTags}
className="px-2 py-1 text-xs rounded-full bg-secondary/30 text-text-secondary hover:bg-secondary transition-colors flex items-center gap-1"
>
<FontAwesomeIcon icon={faTags} className="w-3 h-3"/>
{t('spellList.manageTags')}
</button>
</div>
<div className="px-2 space-y-2">
{filteredSpells.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-8 h-8"/>
</div>
<h3 className="text-text-primary font-semibold text-base mb-1">
{t('spellList.noSpells')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('spellList.noSpellsDescription')}
</p>
</div>
) : (
filteredSpells.map(function (spell: SpellListItem): React.JSX.Element {
return (
<div
key={spell.id}
onClick={function (): void { onSpellClick(spell); }}
className="group flex items-center p-3 bg-secondary/30 rounded-lg border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md transition-all duration-200 hover:border-primary/50"
>
<div className="w-10 h-10 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-sm group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-5 h-5"/>
</div>
<div className="ml-3 flex-1 min-w-0">
<div className="text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate">
{spell.name}
</div>
<div className="text-muted text-xs truncate">
{spell.description}
</div>
{spell.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{spell.tags.slice(0, 2).map(function (tag: SpellTagProps): React.JSX.Element {
return <SpellTagChip key={tag.id} tag={tag} size="sm"/>;
})}
{spell.tags.length > 2 && (
<span className="text-muted text-xs px-1.5 py-0.5 bg-secondary/50 rounded-full">
+{spell.tags.length - 2}
</span>
)}
</div>
)}
</div>
<div className="w-6 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3"
/>
</div>
</div>
);
})
)}
</div>
</div>
);
}

View File

@@ -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<boolean>(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<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
async function handleDelete(): Promise<void> {
if (selectedSpell?.id) {
await deleteSpell(selectedSpell.id);
setShowDeleteConfirm(false);
backToList();
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
</div>
);
}
const isNew: boolean = selectedSpell?.id === null;
const canExport: boolean = Boolean(bookSeriesId && selectedSpell?.id && !selectedSpell.seriesSpellId);
return (
<div className="flex flex-col h-full">
{/* Header - uniquement pour detail/edit */}
<ToolDetailHeader
title={selectedSpell?.name || ''}
defaultTitle={t('spellDetail.newSpell')}
viewMode={viewMode}
isNew={isNew}
onBack={backToList}
onEdit={enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onDelete={function (): void { setShowDeleteConfirm(true); }}
onExport={canExport ? exportToSeries : undefined}
showExport={canExport}
showDelete={Boolean(selectedSpell?.id)}
/>
{/* Contenu principal */}
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-5 p-4">
{/* Toggle tool */}
{showToggle && !isSeriesMode && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('spellComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
<p className="text-muted text-sm mt-2">
{t('spellComponent.enableToolDescription')}
</p>
</div>
)}
{/* Contenu si outil activé */}
{(toolEnabled || isSeriesMode) && (
<>
{/* Import from series */}
{!isSeriesMode && bookSeriesId && availableSeriesSpells.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesSpells.map(function (ss: SeriesSpellListItem) {
return {
id: ss.id,
name: ss.name
};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
)}
{/* Liste des sorts */}
<SpellSettingsList
spells={spells}
tags={tags}
onSpellClick={enterDetailMode}
onAddSpell={addNewSpell}
onManageTags={function (): void { setShowTagManager(true); }}
/>
</>
)}
</div>
)}
{viewMode === 'detail' && selectedSpell && (
<div className="p-4">
<SpellSettingsDetail
spell={selectedSpell}
availableTags={tags}
seriesSpell={selectedSeriesSpell}
/>
</div>
)}
{viewMode === 'edit' && selectedSpell && (
<div className="p-4">
<SpellSettingsEdit
spell={selectedSpell}
availableTags={tags}
onSpellChange={handleSpellChange}
onCreateTag={createTag}
seriesSpell={selectedSeriesSpell}
onSyncComplete={handleSyncComplete}
/>
</div>
)}
</div>
{/* Modal de confirmation de suppression */}
{showDeleteConfirm && selectedSpell?.id && (
<AlertBox
title={t('spellDetail.deleteTitle')}
message={t('spellDetail.deleteMessage', {name: selectedSpell.name})}
type="danger"
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={handleDelete}
onCancel={function (): void { setShowDeleteConfirm(false); }}
/>
)}
{/* Tag Manager Modal */}
{showTagManager && (
<SpellTagManager
tags={tags}
onBack={function (): void { setShowTagManager(false); }}
onCreateTag={createTag}
onUpdateTag={updateTag}
onDeleteTag={deleteTag}
/>
)}
</div>
);
}

View File

@@ -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 (
<div className="space-y-6 px-2 pb-4">
{/* Hero Section */}
<div className="p-6 bg-gradient-to-r from-primary/10 via-secondary/20 to-transparent rounded-2xl border border-secondary/30">
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl bg-primary/20 flex items-center justify-center shrink-0">
<FontAwesomeIcon icon={faWandMagicSparkles} className="w-8 h-8 text-primary"/>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-bold text-text-primary">{spell.name || '—'}</h2>
{/* Power Level Badge */}
<div className="flex items-center gap-3 mt-3">
<span className={`inline-flex items-center gap-2 px-3 py-1 rounded-lg text-sm border ${getPowerLevelColor()}`}>
<FontAwesomeIcon icon={faBolt} className="w-3 h-3"/>
{getLocalizedPowerLevel()}
</span>
</div>
{/* Tags */}
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4">
{selectedTags.map(function (tag: SpellTagProps): React.JSX.Element {
return <SpellTagChip key={tag.id} tag={tag}/>;
})}
</div>
)}
</div>
</div>
</div>
{/* Description & Appearance - Side by side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faHatWizard} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('spellDetail.description')}</h3>
</div>
<p className={`whitespace-pre-wrap ${spell.description ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{spell.description || '—'}
</p>
</div>
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faEye} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('spellDetail.appearance')}</h3>
</div>
<p className={`whitespace-pre-wrap ${spell.appearance ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{spell.appearance || '—'}
</p>
</div>
</div>
{/* Components & Limitations - Side by side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faPuzzlePiece} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('spellDetail.components')}</h3>
</div>
<p className={`whitespace-pre-wrap ${spell.components ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{spell.components || '—'}
</p>
</div>
<div className="p-5 bg-error/10 rounded-xl border border-error/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faTriangleExclamation} className="w-4 h-4 text-error"/>
<h3 className="text-text-primary font-semibold">{t('spellDetail.limitations')}</h3>
</div>
<p className={`whitespace-pre-wrap ${spell.limitations ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{spell.limitations || '—'}
</p>
</div>
</div>
{/* Notes - Full width */}
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faStickyNote} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('spellDetail.notes')}</h3>
</div>
<p className={`whitespace-pre-wrap ${spell.notes ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{spell.notes || '—'}
</p>
</div>
</div>
);
}

View File

@@ -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<SpellTagProps | null>;
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<string>('');
const [isCreatingTag, setIsCreatingTag] = useState<boolean>(false);
const [newTagColor, setNewTagColor] = useState<string>(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<void> {
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 (
<div className="space-y-4 px-2 pb-4">
{/* Informations de base */}
<CollapsableArea title={t('spellDetail.basicInfo')} icon={faHatWizard}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t('spellDetail.name')}
input={
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.name || ''}
currentValue={spell.name}
bookElementId={spell.id || ''}
field="name"
elementType="spell"
onDownload={function (): void { onSpellChange('name', seriesSpell?.name || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={spell.name}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onSpellChange('name', e.target.value);
}}
placeholder={t('spellDetail.namePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('spellDetail.description')}
icon={faBook}
input={
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.description || ''}
currentValue={spell.description}
bookElementId={spell.id || ''}
field="description"
elementType="spell"
onDownload={function (): void { onSpellChange('description', seriesSpell?.description || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.description}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('description', e.target.value);
}}
placeholder={t('spellDetail.descriptionPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('spellDetail.appearance')}
icon={faEye}
input={
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.appearance || ''}
currentValue={spell.appearance}
bookElementId={spell.id || ''}
field="appearance"
elementType="spell"
onDownload={function (): void { onSpellChange('appearance', seriesSpell?.appearance || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.appearance}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('appearance', e.target.value);
}}
placeholder={t('spellDetail.appearancePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
{/* Tags */}
<CollapsableArea title={t('spellDetail.tags')} icon={faTags}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{selectedTags.map(function (tag: SpellTagProps): React.JSX.Element {
return (
<SpellTagChip
key={tag.id}
tag={tag}
onRemove={function (): void { handleRemoveTag(tag.id); }}
/>
);
})}
</div>
)}
<TextInput
value={tagSearchQuery}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setTagSearchQuery(e.target.value);
}}
placeholder={t('spellDetail.addTag')}
/>
{filteredTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{filteredTags.map(function (tag: SpellTagProps): React.JSX.Element {
return (
<button
key={tag.id}
onClick={function (): void { handleAddTag(tag.id); }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm transition-all duration-200 hover:scale-105 hover:shadow-md border"
style={{
backgroundColor: `${tag.color || '#3B82F6'}20`,
borderColor: `${tag.color || '#3B82F6'}50`,
color: tag.color || '#3B82F6'
}}
>
<FontAwesomeIcon icon={faPlus} className="w-3 h-3"/>
{tag.name}
</button>
);
})}
</div>
)}
{showCreateOption && !isCreatingTag && (
<button
onClick={function (): void { setIsCreatingTag(true); }}
className="w-full flex items-center gap-2 px-4 py-2.5 bg-tertiary hover:bg-secondary/50 transition-colors text-left rounded-xl border border-secondary/50"
>
<FontAwesomeIcon icon={faPlus} className="text-primary w-3 h-3"/>
<span className="text-primary font-medium">
{t('spellDetail.createTag', {name: tagSearchQuery})}
</span>
</button>
)}
{isCreatingTag && (
<div className="p-4 bg-tertiary rounded-xl border border-secondary/50">
<p className="text-text-secondary text-sm mb-3">
{t('spellDetail.createTag', {name: tagSearchQuery})}
</p>
<div className="flex flex-wrap gap-2 mb-3">
{defaultTagColors.map(function (color: string): React.JSX.Element {
return (
<button
key={color}
onClick={function (): void { setNewTagColor(color); }}
className={`w-8 h-8 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-2 ring-primary scale-110' : 'hover:scale-110'}`}
style={{backgroundColor: color}}
/>
);
})}
</div>
<div className="flex gap-2">
<button
onClick={function (): void { setIsCreatingTag(false); }}
className="flex-1 py-2 px-3 bg-secondary/50 text-text-primary rounded-lg hover:bg-secondary transition-colors"
>
{t('common.cancel')}
</button>
<button
onClick={handleCreateTag}
className="flex-1 py-2 px-3 bg-primary text-text-primary rounded-lg hover:bg-primary-dark transition-colors"
>
{t('common.confirm')}
</button>
</div>
</div>
)}
</div>
</CollapsableArea>
{/* Niveau de puissance */}
<CollapsableArea title={t('spellDetail.powerLevel')} icon={faBolt}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.powerLevel || 'none'}
currentValue={spell.powerLevel || 'none'}
bookElementId={spell.id || ''}
field="powerLevel"
elementType="spell"
onDownload={function (): void { onSpellChange('powerLevel', seriesSpell?.powerLevel || null); }}
onSyncComplete={onSyncComplete}
>
<SelectBox
defaultValue={spell.powerLevel || 'none'}
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
onSpellChange('powerLevel', e.target.value === 'none' ? null : e.target.value);
}}
data={getLocalizedPowerLevels()}
/>
</SyncFieldWrapper>
</div>
</CollapsableArea>
{/* Composants */}
<CollapsableArea title={t('spellDetail.components')} icon={faPuzzlePiece}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.components || ''}
currentValue={spell.components || ''}
bookElementId={spell.id || ''}
field="components"
elementType="spell"
onDownload={function (): void { onSpellChange('components', seriesSpell?.components || null); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.components || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('components', e.target.value || null);
}}
placeholder={t('spellDetail.componentsPlaceholder')}
/>
</SyncFieldWrapper>
</div>
</CollapsableArea>
{/* Limitations */}
<CollapsableArea title={t('spellDetail.limitations')} icon={faTriangleExclamation}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.limitations || ''}
currentValue={spell.limitations || ''}
bookElementId={spell.id || ''}
field="limitations"
elementType="spell"
onDownload={function (): void { onSpellChange('limitations', seriesSpell?.limitations || null); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.limitations || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('limitations', e.target.value || null);
}}
placeholder={t('spellDetail.limitationsPlaceholder')}
/>
</SyncFieldWrapper>
</div>
</CollapsableArea>
{/* Notes */}
<CollapsableArea title={t('spellDetail.notes')} icon={faStickyNote}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.notes || ''}
currentValue={spell.notes || ''}
bookElementId={spell.id || ''}
field="notes"
elementType="spell"
onDownload={function (): void { onSpellChange('notes', seriesSpell?.notes || null); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.notes || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('notes', e.target.value || null);
}}
placeholder={t('spellDetail.notesPlaceholder')}
/>
</SyncFieldWrapper>
</div>
</CollapsableArea>
</div>
);
}

View File

@@ -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<string>('');
const [filterTag, setFilterTag] = useState<string>('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 (
<div className="space-y-4">
<div className="px-4 space-y-3">
<InputField
input={
<TextInput
value={searchQuery}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setSearchQuery(e.target.value);
}}
placeholder={t('spellList.search')}
/>
}
actionIcon={faPlus}
actionLabel={t('spellList.add')}
addButtonCallBack={async function (): Promise<void> {
onAddSpell();
}}
/>
<div className="flex flex-wrap gap-3 items-center">
<div className="flex-1 min-w-[150px]">
<select
value={filterTag}
onChange={function (e: React.ChangeEvent<HTMLSelectElement>): void {
setFilterTag(e.target.value);
}}
className="w-full text-text-primary bg-secondary/50 hover:bg-secondary px-4 py-2.5 rounded-xl border border-secondary/50 focus:border-primary focus:ring-4 focus:ring-primary/20 focus:bg-secondary hover:border-secondary outline-none transition-all duration-200 cursor-pointer"
>
<option value="all" className="bg-tertiary text-text-primary">
{t('spellList.allTags')}
</option>
{tags.map(function (tag: SpellTagProps): React.JSX.Element {
return (
<option key={tag.id} value={tag.id} className="bg-tertiary text-text-primary">
{tag.name}
</option>
);
})}
</select>
</div>
<button
onClick={onManageTags}
className="flex items-center gap-2 px-4 py-2.5 bg-secondary/50 rounded-xl border border-secondary/50 hover:bg-secondary hover:border-secondary hover:shadow-md hover:scale-105 transition-all duration-200"
>
<FontAwesomeIcon icon={faCog} className="text-primary w-4 h-4"/>
<span className="text-text-primary text-sm font-medium">{t('spellList.manageTags')}</span>
</button>
</div>
</div>
<div className="px-2">
{filteredSpells.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-10 h-10"/>
</div>
<h3 className="text-text-primary font-semibold text-lg mb-2">
{t('spellList.noSpells')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('spellList.noSpellsDescription')}
</p>
</div>
) : (
<div className="space-y-2 p-2">
{filteredSpells.map(function (spell: SpellListItem): React.JSX.Element {
return (
<div
key={spell.id}
onClick={function (): void { onSpellClick(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"
>
<div className="w-12 h-12 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-6 h-6"/>
</div>
<div className="ml-4 flex-1 min-w-0">
<div className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
{spell.name}
</div>
<div className="text-text-secondary text-sm mt-0.5 truncate">
{spell.description}
</div>
{spell.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{spell.tags.slice(0, 3).map(function (tag: SpellTagProps): React.JSX.Element {
return <SpellTagChip key={tag.id} tag={tag} size="sm"/>;
})}
{spell.tags.length > 3 && (
<span className="text-muted text-xs px-2 py-0.5 bg-secondary/50 rounded-full">
+{spell.tags.length - 3}
</span>
)}
</div>
)}
</div>
<div className="w-8 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"
/>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -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<boolean>('db:chapter:remove', deleteData);

View File

@@ -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<LangContextProps>(LangContext);
@@ -29,9 +50,11 @@ export default function WorldElementComponent({sectionLabel, sectionType}: World
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(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<SeriesContextProps>(SeriesContext);
const {localSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
const [newElementName, setNewElementName] = useState<string>('');
@@ -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<boolean>('db:series:world:element:delete', deleteData);
} else {
response = await System.authDeleteToServer<boolean>('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<boolean>('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<string>('db:series:world:element:add', addData);
} else {
elementId = await System.authPostToServer<string>(
'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<string>('db:book:world:element:add', {
elementType: section,
worldId: worlds[selectedWorldIndex].id,

View File

@@ -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<void> }>) {
const {showToggle = true, entityType = 'book', entityId} = props;
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
@@ -37,14 +47,18 @@ 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<WorldProps[]>([]);
const [seriesWorlds, setSeriesWorlds] = useState<SeriesWorldProps[]>([]);
const [newWorldName, setNewWorldName] = useState<string>('');
const [selectedWorldIndex, setSelectedWorldIndex] = useState<number>(0);
const [worldsSelector, setWorldsSelector] = useState<SelectBoxProps[]>([]);
const [showAddNewWorld, setShowAddNewWorld] = useState<boolean>(false);
const [toolEnabled, setToolEnabled] = useState<boolean>(book?.tools?.worlds ?? false);
const [toolEnabled, setToolEnabled] = useState<boolean>(isSeriesMode ? true : (book?.tools?.worlds ?? false));
const bookSeriesId: string | null = book?.seriesId || null;
useImperativeHandle(ref, function () {
return {
@@ -53,27 +67,52 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a
});
useEffect((): void => {
if (currentEntityId) {
getWorlds().then();
}, []);
}
}, [currentEntityId]);
useEffect((): void => {
if (bookSeriesId && !isSeriesMode && !isCurrentlyOffline()) {
getSeriesWorlds().then();
}
}, [bookSeriesId]);
async function getSeriesWorlds(): Promise<void> {
if (!bookSeriesId || isCurrentlyOffline()) return;
try {
const response: SeriesWorldProps[] = await System.authGetQueryToServer<SeriesWorldProps[]>('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<void> {
if (isSeriesMode) return;
try {
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:book:tool:update', {
bookId: bookId,
bookId: currentEntityId,
toolName: 'worlds',
enabled: enabled
});
} else {
response = await System.authPatchToServer<boolean>('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: {
setBook({
...book, tools: {
characters: book.tools?.characters ?? false,
worlds: enabled,
locations: book.tools?.locations ?? false
}});
locations: book.tools?.locations ?? false,
spells: book.tools?.spells ?? false
}
});
}
} catch (e: unknown) {
if (e instanceof Error) {
@@ -94,29 +136,64 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a
}
}
async function getWorlds() {
async function getWorlds(): Promise<void> {
try {
let response: WorldListResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<WorldListResponse>('db:book:worlds:get', {bookid: bookId});
} else {
if (book?.localBook) {
response = await window.electron.invoke<WorldListResponse>('db:book:worlds:get', {bookid: bookId});
} else {
response = await System.authGetQueryToServer<WorldListResponse>(`book/worlds`, session.accessToken, lang, {
bookid: bookId,
if (isSeriesMode) {
// Series mode: server only (series are not local)
const response: SeriesWorldProps[] = await System.authGetQueryToServer<SeriesWorldProps[]>('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 {
// Book mode: dual offline/online logic
let response: WorldListResponse;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<WorldListResponse>('db:book:worlds:get', {bookid: currentEntityId});
} else {
response = await System.authGetQueryToServer<WorldListResponse>('book/worlds', session.accessToken, lang, {
bookid: currentEntityId,
});
}
if (response) {
setWorlds(response.worlds);
setToolEnabled(response.enabled);
if (setBook && book) {
setBook({...book, tools: {
setBook({
...book, tools: {
characters: book.tools?.characters ?? false,
worlds: response.enabled,
locations: book.tools?.locations ?? false
}});
locations: book.tools?.locations ?? false,
spells: book.tools?.spells ?? false
}
});
}
const formattedWorlds: SelectBoxProps[] = response.worlds.map(
(world: WorldProps): SelectBoxProps => ({
@@ -126,6 +203,7 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a
);
setWorldsSelector(formattedWorlds);
}
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
@@ -141,31 +219,50 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a
return;
}
try {
let worldId: string;
if (isCurrentlyOffline() || book?.localBook) {
worldId = await window.electron.invoke<string>('db:book:world:add', {
worldName: newWorldName,
bookId: bookId,
});
} else {
worldId = await System.authPostToServer<string>('book/world/add', {
worldName: newWorldName,
bookId: bookId,
}, session.accessToken, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('db:book:world:add', {
worldName: newWorldName,
worldId,
bookId: bookId,
});
}
}
if (!worldId) {
let newWorldId: string;
if (isSeriesMode) {
// Series mode: server only
newWorldId = await System.authPostToServer<string>(
'series/world/add',
{
seriesId: currentEntityId,
name: newWorldName,
},
session.accessToken,
lang
);
if (!newWorldId) {
errorMessage(t("worldSetting.addWorldError"));
return;
}
const newWorldId: string = worldId;
} else if (isCurrentlyOffline() || book?.localBook) {
// Book mode: offline/local
newWorldId = await window.electron.invoke<string>('db:book:world:add', {
worldName: newWorldName,
bookId: currentEntityId,
});
if (!newWorldId) {
errorMessage(t("worldSetting.addWorldError"));
return;
}
} else {
// Book mode: online
newWorldId = await System.authPostToServer<string>('book/world/add', {
worldName: newWorldName,
bookId: currentEntityId,
}, session.accessToken, lang);
if (!newWorldId) {
errorMessage(t("worldSetting.addWorldError"));
return;
}
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
addToQueue('db:book:world:add', {
worldName: newWorldName,
worldId: newWorldId,
bookId: currentEntityId,
});
}
}
const newWorld: WorldProps = {
id: newWorldId,
name: newWorldName,
@@ -204,21 +301,42 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a
}
async function handleUpdateWorld(): Promise<void> {
if (worlds.length === 0) return;
try {
const currentWorld: WorldProps = worlds[selectedWorldIndex];
let response: boolean;
const worldData = {
world: worlds[selectedWorldIndex],
bookId: bookId,
};
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:book:world:update', worldData);
} else {
response = await System.authPatchToServer<boolean>('book/world/update', worldData, session.accessToken, lang);
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
addToQueue('db:book:world:update', worldData);
if (isSeriesMode) {
// Series mode: server only
response = await System.authPatchToServer<boolean>('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<boolean>('db:book:world:update', {
world: currentWorld,
bookId: currentEntityId,
});
} else {
// Book mode: online
response = await System.authPatchToServer<boolean>('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;
@@ -233,15 +351,129 @@ 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<void> {
if (isCurrentlyOffline()) return;
const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex];
if (!selectedWorld || !bookSeriesId) return;
try {
const seriesWorldId: string = await System.authPostToServer<string>('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<boolean>('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<void> {
if (isCurrentlyOffline()) return;
const seriesWorld: SeriesWorldListItem | undefined = seriesWorlds.find((w) => w.id === seriesWorldId);
if (!seriesWorld) return;
try {
const worldId: string = await System.authPostToServer<string>('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 (
<div className="space-y-6">
{showToggle && (
{showToggle && !isSeriesMode && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faToggleOn}
@@ -258,21 +490,27 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a
</p>
</div>
)}
{toolEnabled && (
{(toolEnabled || isSeriesMode) && (
<>
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-lg">
{!isSeriesMode && bookSeriesId && !isCurrentlyOffline() &&
seriesWorlds.filter((seriesWorld: SeriesWorldProps): boolean => !worlds.some((world: WorldProps): boolean => world.seriesWorldId === seriesWorld.id)).length > 0 && (
<SeriesImportSelector
availableItems={seriesWorlds
.filter((seriesWorld: SeriesWorldProps): boolean => !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")}
/>
)}
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-lg">
<div className="grid grid-cols-1 gap-4 mb-4">
<InputField
fieldName={t("worldSetting.selectWorld")}
input={
<SelectBox
onChangeCallBack={(e) => {
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,17 +538,43 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a
addButtonCallBack={handleAddNewWorld}
/>
)}
{!isSeriesMode && bookSeriesId && !isCurrentlyOffline() && worlds[selectedWorldIndex] && !worlds[selectedWorldIndex].seriesWorldId && (
<button
onClick={handleExportToSeries}
className="flex items-center gap-2 px-4 py-2 bg-blue-500/90 hover:bg-blue-500 rounded-xl border border-blue-600 shadow-md hover:shadow-lg transition-all duration-200"
>
<FontAwesomeIcon icon={faShare} className="w-4 h-4 text-text-primary"/>
<span className="text-text-primary font-medium">{t("worldSetting.exportToSeries")}</span>
</button>
)}
</div>
</div>
{worlds.length > 0 && worlds[selectedWorldIndex] ? (
<WorldContext.Provider value={{worlds, setWorlds, selectedWorldIndex}}>
<WorldContext.Provider value={{worlds, setWorlds, selectedWorldIndex, isSeriesMode}}>
<div
className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-lg">
<div className="mb-4">
<InputField
fieldName={t("worldSetting.worldName")}
input={
<SyncFieldWrapper
seriesElementId={worlds[selectedWorldIndex].seriesWorldId}
seriesValue={getSeriesWorldForCurrentWorld()?.name || ''}
currentValue={worlds[selectedWorldIndex].name}
bookElementId={worlds[selectedWorldIndex].id}
field="name"
elementType="world"
onDownload={() => {
const seriesWorld = getSeriesWorldForCurrentWorld();
if (seriesWorld) {
const updatedWorlds: WorldProps[] = [...worlds];
updatedWorlds[selectedWorldIndex].name = seriesWorld.name;
setWorlds(updatedWorlds);
}
}}
onSyncComplete={getSeriesWorlds}
>
<TextInput
value={worlds[selectedWorldIndex].name}
setValue={(e: ChangeEvent<HTMLInputElement>) => {
@@ -320,17 +584,32 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a
}}
placeholder={t("worldSetting.worldNamePlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>
<InputField
fieldName={t("worldSetting.worldHistory")}
input={
<SyncFieldWrapper
seriesElementId={worlds[selectedWorldIndex].seriesWorldId}
seriesValue={getSeriesWorldForCurrentWorld()?.history || ''}
currentValue={worlds[selectedWorldIndex].history || ''}
bookElementId={worlds[selectedWorldIndex].id}
field="history"
elementType="world"
onDownload={() => {
const seriesWorld = getSeriesWorldForCurrentWorld();
if (seriesWorld) handleInputChange(seriesWorld.history || '', 'history');
}}
onSyncComplete={getSeriesWorlds}
>
<TexteAreaInput
value={worlds[selectedWorldIndex].history || ''}
setValue={(e) => handleInputChange(e.target.value, 'history')}
placeholder={t("worldSetting.worldHistoryPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>
@@ -341,21 +620,49 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a
<InputField
fieldName={t("worldSetting.politics")}
input={
<SyncFieldWrapper
seriesElementId={worlds[selectedWorldIndex].seriesWorldId}
seriesValue={getSeriesWorldForCurrentWorld()?.politics || ''}
currentValue={worlds[selectedWorldIndex].politics || ''}
bookElementId={worlds[selectedWorldIndex].id}
field="politics"
elementType="world"
onDownload={() => {
const seriesWorld = getSeriesWorldForCurrentWorld();
if (seriesWorld) handleInputChange(seriesWorld.politics || '', 'politics');
}}
onSyncComplete={getSeriesWorlds}
>
<TexteAreaInput
value={worlds[selectedWorldIndex].politics || ''}
setValue={(e) => handleInputChange(e.target.value, 'politics')}
placeholder={t("worldSetting.politicsPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.economy")}
input={
<SyncFieldWrapper
seriesElementId={worlds[selectedWorldIndex].seriesWorldId}
seriesValue={getSeriesWorldForCurrentWorld()?.economy || ''}
currentValue={worlds[selectedWorldIndex].economy || ''}
bookElementId={worlds[selectedWorldIndex].id}
field="economy"
elementType="world"
onDownload={() => {
const seriesWorld = getSeriesWorldForCurrentWorld();
if (seriesWorld) handleInputChange(seriesWorld.economy || '', 'economy');
}}
onSyncComplete={getSeriesWorlds}
>
<TexteAreaInput
value={worlds[selectedWorldIndex].economy || ''}
setValue={(e) => handleInputChange(e.target.value, 'economy')}
placeholder={t("worldSetting.economyPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>
@@ -367,21 +674,49 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a
<InputField
fieldName={t("worldSetting.religion")}
input={
<SyncFieldWrapper
seriesElementId={worlds[selectedWorldIndex].seriesWorldId}
seriesValue={getSeriesWorldForCurrentWorld()?.religion || ''}
currentValue={worlds[selectedWorldIndex].religion || ''}
bookElementId={worlds[selectedWorldIndex].id}
field="religion"
elementType="world"
onDownload={() => {
const seriesWorld = getSeriesWorldForCurrentWorld();
if (seriesWorld) handleInputChange(seriesWorld.religion || '', 'religion');
}}
onSyncComplete={getSeriesWorlds}
>
<TexteAreaInput
value={worlds[selectedWorldIndex].religion || ''}
setValue={(e) => handleInputChange(e.target.value, 'religion')}
placeholder={t("worldSetting.religionPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.languages")}
input={
<SyncFieldWrapper
seriesElementId={worlds[selectedWorldIndex].seriesWorldId}
seriesValue={getSeriesWorldForCurrentWorld()?.languages || ''}
currentValue={worlds[selectedWorldIndex].languages || ''}
bookElementId={worlds[selectedWorldIndex].id}
field="languages"
elementType="world"
onDownload={() => {
const seriesWorld = getSeriesWorldForCurrentWorld();
if (seriesWorld) handleInputChange(seriesWorld.languages || '', 'languages');
}}
onSyncComplete={getSeriesWorlds}
>
<TexteAreaInput
value={worlds[selectedWorldIndex].languages || ''}
setValue={(e) => handleInputChange(e.target.value, 'languages')}
placeholder={t("worldSetting.languagesPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>

View File

@@ -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<boolean>(false);
const [showAddForm, setShowAddForm] = useState<boolean>(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<void> {
if (newWorldName.trim()) {
await addNewWorld();
setShowAddForm(false);
} else {
setShowAddForm(true);
}
}
async function handleSave(): Promise<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin"/>
</div>
);
}
const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex];
const canExport: boolean = Boolean(bookSeriesId && selectedWorld && !selectedWorld.seriesWorldId);
return (
<div className="flex flex-col h-full">
<ToolDetailHeader
title={selectedWorld?.name || ''}
defaultTitle={t('worldSetting.newWorld')}
viewMode={viewMode}
isNew={false}
onBack={backToList}
onEdit={enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onExport={canExport ? exportToSeries : undefined}
showExport={canExport}
showDelete={false}
/>
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-3 p-2">
{/* Toggle tool */}
<div className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('worldSetting.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
</div>
{toolEnabled && (
<>
{/* Import from series */}
{bookSeriesId && availableSeriesWorlds.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesWorlds.map(function (sw: SeriesWorldProps) {
return {id: sw.id, name: sw.name};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
)}
{showAddForm && (
<div className="px-2">
<InputField
input={
<TextInput
value={newWorldName}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setNewWorldName(e.target.value);
}}
placeholder={t('worldSetting.newWorldPlaceholder')}
/>
}
actionIcon={faPlus}
actionLabel={t('worldSetting.createWorldLabel')}
addButtonCallBack={async function (): Promise<void> {
await addNewWorld();
setShowAddForm(false);
}}
/>
</div>
)}
<WorldEditorList
worlds={worlds}
onWorldClick={handleWorldClick}
onAddWorld={handleAddWorld}
/>
</>
)}
</div>
)}
{viewMode === 'detail' && selectedWorld && (
<div className="p-4">
<WorldEditorDetail
world={selectedWorld}
seriesWorld={getSeriesWorldForCurrentWorld()}
/>
</div>
)}
{viewMode === 'edit' && selectedWorld && (
<div className="p-4">
<WorldEditorEdit
world={selectedWorld}
worlds={worlds}
selectedWorldIndex={selectedWorldIndex}
setWorlds={setWorlds}
onWorldFieldChange={handleWorldFieldChange}
seriesWorld={getSeriesWorldForCurrentWorld()}
onSyncComplete={refreshSeriesWorlds}
/>
</div>
)}
</div>
{showDeleteConfirm && selectedWorld && (
<AlertBox
title={t('worldSetting.deleteTitle')}
message={t('worldSetting.deleteMessage', {name: selectedWorld.name})}
type="danger"
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={async function (): Promise<void> { setShowDeleteConfirm(false); }}
onCancel={function (): void { setShowDeleteConfirm(false); }}
/>
)}
</div>
);
}

View File

@@ -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 (
<div className="mb-3">
<span className="text-text-secondary text-xs block mb-1">{label}</span>
<p className="text-text-primary text-sm whitespace-pre-wrap">{value}</p>
</div>
);
}
function renderElementSection(section: ElementSection): React.JSX.Element | null {
const elements: WorldElement[] = world[section.section] as WorldElement[];
if (!elements || elements.length === 0) return null;
return (
<div key={section.section} className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-2">{section.title}</h4>
<div className="space-y-2">
{elements.map(function (element: WorldElement): React.JSX.Element {
return (
<div key={element.id} className="bg-secondary/20 rounded-lg p-2">
<p className="text-text-primary font-medium text-sm">{element.name}</p>
{element.description && (
<p className="text-text-secondary text-xs mt-1">{element.description}</p>
)}
</div>
);
})}
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* Informations de base */}
<div className="border-b border-secondary/30 pb-3">
<h3 className="text-text-primary font-semibold text-base mb-3">{world.name}</h3>
{renderField(t('worldSetting.worldHistory'), world.history)}
</div>
{/* Politique et économie */}
{(world.politics || world.economy) && (
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-2">{t('worldSetting.politicsEconomy')}</h4>
{renderField(t('worldSetting.politics'), world.politics)}
{renderField(t('worldSetting.economy'), world.economy)}
</div>
)}
{/* Religion et langues */}
{(world.religion || world.languages) && (
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-2">{t('worldSetting.cultureLanguages')}</h4>
{renderField(t('worldSetting.religion'), world.religion)}
{renderField(t('worldSetting.languages'), world.languages)}
</div>
)}
{/* Sections d'éléments */}
{elementSections.map(function (section: ElementSection): React.JSX.Element | null {
return renderElementSection(section);
})}
</div>
);
}

View File

@@ -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<SetStateAction<WorldProps[]>>;
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 (
<WorldContext.Provider value={{worlds, setWorlds, selectedWorldIndex, isSeriesMode: false}}>
<div className="space-y-4">
{/* Informations de base */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('worldSetting.basicInfo')}</h4>
<div className="space-y-3">
<InputField
fieldName={t("worldSetting.worldName")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.name || ''}
currentValue={world.name}
bookElementId={world.id}
field="name"
elementType="world"
onDownload={function (): void {
if (seriesWorld) {
const updatedWorlds: WorldProps[] = [...worlds];
updatedWorlds[selectedWorldIndex].name = seriesWorld.name;
setWorlds(updatedWorlds);
}
}}
onSyncComplete={onSyncComplete}
>
<TextInput
value={world.name}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
const updatedWorlds: WorldProps[] = [...worlds];
updatedWorlds[selectedWorldIndex].name = e.target.value;
setWorlds(updatedWorlds);
}}
placeholder={t("worldSetting.worldNamePlaceholder")}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.worldHistory")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.history || ''}
currentValue={world.history || ''}
bookElementId={world.id}
field="history"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('history', seriesWorld.history || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.history || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('history', e.target.value);
}}
placeholder={t("worldSetting.worldHistoryPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>
</div>
{/* Politique et économie */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('worldSetting.politicsEconomy')}</h4>
<div className="space-y-3">
<InputField
fieldName={t("worldSetting.politics")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.politics || ''}
currentValue={world.politics || ''}
bookElementId={world.id}
field="politics"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('politics', seriesWorld.politics || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.politics || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('politics', e.target.value);
}}
placeholder={t("worldSetting.politicsPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.economy")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.economy || ''}
currentValue={world.economy || ''}
bookElementId={world.id}
field="economy"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('economy', seriesWorld.economy || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.economy || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('economy', e.target.value);
}}
placeholder={t("worldSetting.economyPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>
</div>
{/* Religion et langues */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('worldSetting.cultureLanguages')}</h4>
<div className="space-y-3">
<InputField
fieldName={t("worldSetting.religion")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.religion || ''}
currentValue={world.religion || ''}
bookElementId={world.id}
field="religion"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('religion', seriesWorld.religion || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.religion || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('religion', e.target.value);
}}
placeholder={t("worldSetting.religionPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.languages")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.languages || ''}
currentValue={world.languages || ''}
bookElementId={world.id}
field="languages"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('languages', seriesWorld.languages || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.languages || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('languages', e.target.value);
}}
placeholder={t("worldSetting.languagesPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>
</div>
{/* Sections d'éléments */}
{elementSections.map(function (section: ElementSection): React.JSX.Element {
return (
<div key={section.section} className="border-b border-secondary/30 pb-3 last:border-b-0">
<h4 className="text-text-primary font-medium text-sm mb-3">{section.title}</h4>
<WorldElementComponent
sectionLabel={section.title}
sectionType={section.section}
/>
</div>
);
})}
</div>
</WorldContext.Provider>
);
}

View File

@@ -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<string>('');
function getFilteredWorlds(): WorldProps[] {
return worlds.filter(function (world: WorldProps): boolean {
return world.name.toLowerCase().includes(searchQuery.toLowerCase());
});
}
const filteredWorlds: WorldProps[] = getFilteredWorlds();
return (
<div className="space-y-3">
<div className="px-2">
<InputField
input={
<TextInput
value={searchQuery}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setSearchQuery(e.target.value);
}}
placeholder={t('worldSetting.search')}
/>
}
actionIcon={faPlus}
actionLabel={t('worldSetting.addWorldLabel')}
addButtonCallBack={async function (): Promise<void> {
onAddWorld();
}}
/>
</div>
<div className="px-2 space-y-2">
{filteredWorlds.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
<FontAwesomeIcon icon={faGlobe} className="text-primary w-8 h-8"/>
</div>
<h3 className="text-text-primary font-semibold text-base mb-1">
{t('worldSetting.noWorldAvailable')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('worldSetting.noWorldDescription')}
</p>
</div>
) : (
filteredWorlds.map(function (world: WorldProps): React.JSX.Element {
return (
<div
key={world.id}
onClick={function (): void { onWorldClick(world); }}
className="group flex items-center p-3 bg-secondary/30 rounded-lg border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md transition-all duration-200 hover:border-primary/50"
>
<div className="w-10 h-10 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-sm group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faGlobe} className="text-primary w-5 h-5"/>
</div>
<div className="ml-3 flex-1 min-w-0">
<div className="text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate">
{world.name}
</div>
{world.history && (
<div className="text-muted text-xs truncate">
{world.history.substring(0, 50)}{world.history.length > 50 ? '...' : ''}
</div>
)}
</div>
<div className="w-6 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3"
/>
</div>
</div>
);
})
)}
</div>
</div>
);
}

View File

@@ -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<boolean>(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<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
</div>
);
}
const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex];
const canExport: boolean = Boolean(bookSeriesId && selectedWorld && !selectedWorld.seriesWorldId);
return (
<div className="flex flex-col h-full">
{/* Header - uniquement pour detail/edit */}
<ToolDetailHeader
title={selectedWorld?.name || ''}
defaultTitle={t('worldSetting.newWorld')}
viewMode={viewMode}
isNew={false}
onBack={backToList}
onEdit={enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onExport={canExport ? exportToSeries : undefined}
showExport={canExport}
showDelete={false}
/>
{/* Contenu principal */}
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-5 p-4">
{/* Toggle tool */}
{showToggle && !isSeriesMode && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('worldSetting.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
<p className="text-muted text-sm mt-2">
{t('worldSetting.enableToolDescription')}
</p>
</div>
)}
{/* Contenu si outil activé */}
{(toolEnabled || isSeriesMode) && (
<>
{/* Import from series */}
{!isSeriesMode && bookSeriesId && availableSeriesWorlds.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesWorlds.map(function (sw: SeriesWorldProps) {
return {id: sw.id, name: sw.name};
})}
onImport={importFromSeries}
placeholder={t("seriesImport.selectElement")}
label={t("seriesImport.importFromSeries")}
/>
)}
{/* Liste des mondes */}
<WorldSettingsList
worlds={worlds}
onWorldClick={enterDetailMode}
onAddWorld={addNewWorld}
newWorldName={newWorldName}
onNewWorldNameChange={setNewWorldName}
/>
</>
)}
</div>
)}
{viewMode === 'detail' && selectedWorld && (
<div className="p-4">
<WorldSettingsDetail
world={selectedWorld}
seriesWorld={getSeriesWorldForCurrentWorld()}
/>
</div>
)}
{viewMode === 'edit' && selectedWorld && (
<div className="p-4">
<WorldSettingsEdit
world={selectedWorld}
worlds={worlds}
selectedWorldIndex={selectedWorldIndex}
setWorlds={setWorlds}
onWorldFieldChange={handleWorldFieldChange}
seriesWorld={getSeriesWorldForCurrentWorld()}
isSeriesMode={isSeriesMode}
onSyncComplete={refreshSeriesWorlds}
/>
</div>
)}
</div>
</div>
);
}

View File

@@ -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 (
<CollapsableArea key={section.section} title={section.title} icon={section.icon}>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
{elements.map(function (element: WorldElement): React.JSX.Element {
return (
<div key={element.id} className="p-4 bg-dark-background/30 rounded-lg border border-secondary/20 hover:border-primary/30 transition-colors">
<h4 className="text-text-primary font-semibold">{element.name}</h4>
{element.description && (
<p className="text-text-secondary text-sm mt-2 line-clamp-3">{element.description}</p>
)}
</div>
);
})}
</div>
</CollapsableArea>
);
}
return (
<div className="space-y-6 px-2 pb-4">
{/* Hero Section */}
<div className="p-6 bg-gradient-to-r from-primary/10 via-secondary/20 to-transparent rounded-2xl border border-secondary/30">
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl bg-primary/20 flex items-center justify-center shrink-0">
<FontAwesomeIcon icon={faGlobe} className="w-8 h-8 text-primary"/>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-bold text-text-primary">{world.name || '—'}</h2>
</div>
</div>
</div>
{/* Histoire du monde - Full width */}
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faScroll} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('worldSetting.worldHistory')}</h3>
</div>
<p className={`whitespace-pre-wrap ${world.history ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{world.history || '—'}
</p>
</div>
{/* Politique & Économie - Side by side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faLandmark} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('worldSetting.politics')}</h3>
</div>
<p className={`whitespace-pre-wrap ${world.politics ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{world.politics || '—'}
</p>
</div>
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faCoins} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('worldSetting.economy')}</h3>
</div>
<p className={`whitespace-pre-wrap ${world.economy ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{world.economy || '—'}
</p>
</div>
</div>
{/* Religion & Langues - Side by side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faChurch} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('worldSetting.religion')}</h3>
</div>
<p className={`whitespace-pre-wrap ${world.religion ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{world.religion || '—'}
</p>
</div>
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faLanguage} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('worldSetting.languages')}</h3>
</div>
<p className={`whitespace-pre-wrap ${world.languages ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{world.languages || '—'}
</p>
</div>
</div>
{/* Sections d'éléments - Grille de cards */}
{elementSections.map(function (section: ElementSection): React.JSX.Element | null {
return renderElementSection(section);
})}
</div>
);
}

View File

@@ -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<SetStateAction<WorldProps[]>>;
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 (
<WorldContext.Provider value={{worlds, setWorlds, selectedWorldIndex, isSeriesMode}}>
<div className="space-y-4 px-2 pb-4">
{/* Informations de base */}
<CollapsableArea title={t('worldSetting.basicInfo')} icon={faGlobe}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t("worldSetting.worldName")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.name || ''}
currentValue={world.name}
bookElementId={world.id}
field="name"
elementType="world"
onDownload={function (): void {
if (seriesWorld) {
const updatedWorlds: WorldProps[] = [...worlds];
updatedWorlds[selectedWorldIndex].name = seriesWorld.name;
setWorlds(updatedWorlds);
}
}}
onSyncComplete={onSyncComplete}
>
<TextInput
value={world.name}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
const updatedWorlds: WorldProps[] = [...worlds];
updatedWorlds[selectedWorldIndex].name = e.target.value;
setWorlds(updatedWorlds);
}}
placeholder={t("worldSetting.worldNamePlaceholder")}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.worldHistory")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.history || ''}
currentValue={world.history || ''}
bookElementId={world.id}
field="history"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('history', seriesWorld.history || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.history || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('history', e.target.value);
}}
placeholder={t("worldSetting.worldHistoryPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
{/* Politique et économie */}
<CollapsableArea title={t('worldSetting.politicsEconomy')} icon={faLandmark}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t("worldSetting.politics")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.politics || ''}
currentValue={world.politics || ''}
bookElementId={world.id}
field="politics"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('politics', seriesWorld.politics || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.politics || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('politics', e.target.value);
}}
placeholder={t("worldSetting.politicsPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.economy")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.economy || ''}
currentValue={world.economy || ''}
bookElementId={world.id}
field="economy"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('economy', seriesWorld.economy || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.economy || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('economy', e.target.value);
}}
placeholder={t("worldSetting.economyPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
{/* Religion et langues */}
<CollapsableArea title={t('worldSetting.cultureLanguages')} icon={faBook}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t("worldSetting.religion")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.religion || ''}
currentValue={world.religion || ''}
bookElementId={world.id}
field="religion"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('religion', seriesWorld.religion || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.religion || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('religion', e.target.value);
}}
placeholder={t("worldSetting.religionPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.languages")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.languages || ''}
currentValue={world.languages || ''}
bookElementId={world.id}
field="languages"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('languages', seriesWorld.languages || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.languages || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('languages', e.target.value);
}}
placeholder={t("worldSetting.languagesPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
{/* Sections d'éléments */}
{elementSections.map(function (section: ElementSection): React.JSX.Element {
return (
<CollapsableArea key={section.section} title={section.title} icon={section.icon}>
<div className="p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<WorldElementComponent
sectionLabel={section.title}
sectionType={section.section}
/>
</div>
</CollapsableArea>
);
})}
</div>
</WorldContext.Provider>
);
}

View File

@@ -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<void>;
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<string>('');
function getFilteredWorlds(): WorldProps[] {
return worlds.filter(function (world: WorldProps): boolean {
return world.name.toLowerCase().includes(searchQuery.toLowerCase());
});
}
const filteredWorlds: WorldProps[] = getFilteredWorlds();
return (
<div className="space-y-4">
{/* Recherche et ajout */}
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-lg">
<div className="grid grid-cols-1 gap-4">
<InputField
input={
<TextInput
value={searchQuery}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
setSearchQuery(e.target.value);
}}
placeholder={t('worldSetting.search')}
/>
}
/>
<InputField
fieldName={t('worldSetting.addWorldLabel')}
input={
<TextInput
value={newWorldName}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onNewWorldNameChange(e.target.value);
}}
placeholder={t('worldSetting.newWorldPlaceholder')}
/>
}
actionIcon={faPlus}
actionLabel={t('worldSetting.createWorldLabel')}
addButtonCallBack={onAddWorld}
/>
</div>
</div>
{/* Liste des mondes cliquables */}
<div className="space-y-2 px-2">
{filteredWorlds.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<FontAwesomeIcon icon={faGlobe} className="text-primary w-10 h-10"/>
</div>
<h3 className="text-text-primary font-semibold text-lg mb-2">
{t('worldSetting.noWorldAvailable')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('worldSetting.noWorldDescription')}
</p>
</div>
) : (
filteredWorlds.map(function (world: WorldProps): React.JSX.Element {
return (
<div
key={world.id}
onClick={function (): void { onWorldClick(world.id); }}
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"
>
<div className="w-12 h-12 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faGlobe} className="text-primary w-6 h-6"/>
</div>
<div className="ml-4 flex-1 min-w-0">
<div className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
{world.name}
</div>
{world.history && (
<div className="text-text-secondary text-sm mt-0.5 truncate">
{world.history.substring(0, 60)}{world.history.length > 60 ? '...' : ''}
</div>
)}
</div>
<div className="w-8 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"
/>
</div>
</div>
);
})
)}
</div>
</div>
);
}

View File

@@ -1,8 +1,9 @@
import {ChangeEvent, Dispatch} from "react";
import React, {ChangeEvent, Dispatch} from "react";
interface NumberInputProps {
value: number;
setValue: Dispatch<React.SetStateAction<number>>;
value: number | null;
setValue?: Dispatch<React.SetStateAction<number>>;
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<HTMLInputElement>) {
const newValue: number = parseInt(e.target.value);
function handleChange(e: ChangeEvent<HTMLInputElement>): void {
const inputValue: string = e.target.value;
if (inputValue === '') {
if (onValueChange) {
onValueChange(null);
}
return;
}
const newValue: number = parseInt(inputValue, 10);
if (!isNaN(newValue)) {
if (onValueChange) {
onValueChange(newValue);
} else if (setValue) {
setValue(newValue);
}
}
}
return (
<input
type="number"
value={value}
value={value ?? ''}
onChange={handleChange}
className={`w-full bg-secondary/50 text-text-primary px-4 py-2.5 rounded-xl border border-secondary/50
focus:border-primary focus:ring-4 focus:ring-primary/20 focus:bg-secondary

View File

@@ -0,0 +1,88 @@
'use client'
import React, {useState} from 'react';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faDownload, faSpinner} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
import SelectBox from '@/components/form/SelectBox';
interface SeriesImportItem {
id: string;
name: string;
}
interface SeriesImportSelectorProps {
availableItems: SeriesImportItem[];
onImport: (seriesElementId: string) => Promise<void>;
placeholder: string;
label?: string;
}
export default function SeriesImportSelector({
availableItems,
onImport,
placeholder,
label
}: SeriesImportSelectorProps) {
const t = useTranslations();
const [selectedId, setSelectedId] = useState<string>('');
const [isImporting, setIsImporting] = useState<boolean>(false);
async function handleImport(): Promise<void> {
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 (
<div className="bg-primary/10 border border-primary/30 rounded-xl p-4 mb-4">
{label && (
<h4 className="text-sm font-medium text-primary mb-3 flex items-center gap-2">
<FontAwesomeIcon icon={faDownload} className="w-4 h-4"/>
{label}
</h4>
)}
<div className="flex items-center gap-3">
<div className="flex-grow">
<SelectBox
onChangeCallBack={(e) => setSelectedId(e.target.value)}
data={selectData}
defaultValue={selectedId}
placeholder={placeholder}
/>
</div>
<button
onClick={handleImport}
disabled={!selectedId || isImporting}
className={`px-4 py-2 rounded-lg font-medium flex items-center gap-2 transition-all duration-200 ${
selectedId && !isImporting
? 'bg-primary text-white hover:bg-primary-dark hover:scale-105'
: 'bg-secondary/50 text-muted cursor-not-allowed'
}`}
>
{isImporting ? (
<FontAwesomeIcon icon={faSpinner} className="w-4 h-4 animate-spin"/>
) : (
<FontAwesomeIcon icon={faDownload} className="w-4 h-4"/>
)}
{t('seriesImport.importButton')}
</button>
</div>
</div>
);
}

View File

@@ -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<LangContextProps>(LangContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {book} = useContext(BookContext);
const [isUploading, setIsUploading] = useState<boolean>(false);
const isLinkedToSeries: boolean = !!seriesElementId;
const hasSeriesDiff: boolean = isLinkedToSeries && seriesValue !== currentValue;
async function handleUpload(): Promise<void> {
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<SeriesSyncUploadResponse>('db:series:sync:upload', requestData);
} else {
// Online + livre serveur → Server
response = await System.authPostToServer<SeriesSyncUploadResponse>(
'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 (
<div className="flex items-start gap-2 w-full">
<button
onClick={handleDownload}
disabled={!hasSeriesDiff}
title={t('syncField.downloadTooltip')}
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center transition-all duration-200 ${
hasSeriesDiff
? 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/40 hover:scale-110 cursor-pointer'
: 'bg-secondary/30 text-muted cursor-not-allowed opacity-50'
}`}
>
<FontAwesomeIcon icon={faArrowDown} className="w-3.5 h-3.5"/>
</button>
<div className="flex-grow">
{children}
</div>
<button
onClick={handleUpload}
disabled={isUploading || !hasSeriesDiff}
title={t('syncField.uploadTooltip')}
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center transition-all duration-200 ${
hasSeriesDiff && !isUploading
? 'bg-primary/20 text-primary hover:bg-primary/40 hover:scale-110 cursor-pointer'
: 'bg-secondary/30 text-muted cursor-not-allowed opacity-50'
}`}
>
{isUploading ? (
<FontAwesomeIcon icon={faSpinner} className="w-3.5 h-3.5 animate-spin"/>
) : (
<FontAwesomeIcon icon={faArrowUp} className="w-3.5 h-3.5"/>
)}
</button>
</div>
);
}

View File

@@ -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<PanelComponent>();
const [showAddNewBook, setShowAddNewBook] = useState<boolean>(false);
const [showAddNewSeries, setShowAddNewSeries] = useState<boolean>(false);
const [showGenerateShortModal, setShowGenerateShortModal] = useState<boolean>(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 (
<div id="left-panel-container" data-guide={"left-panel-container"} className="flex transition-all duration-300">
<div className="bg-tertiary border-r border-secondary/50 p-3 flex flex-col space-y-3 shadow-xl">
{book ? editorComponents.map((component:PanelComponent) => (
{book ? editorComponents.map(component => (
<button
key={component.id}
onClick={(): void => {
@@ -99,7 +107,7 @@ export default function ScribeLeftBar() {
: 'bg-secondary/30 text-muted hover:text-text-primary hover:bg-secondary hover:shadow-md'}`}
>
<FontAwesomeIcon icon={component.icon}
className={'w-5 h-5 transition-transform duration-200'}/>
className={'w-4 h-4 transition-transform duration-200'}/>
{panelHidden && currentPanel?.id === component.id && (
<div
className="absolute -right-1 top-1/2 -translate-y-1/2 w-1 h-8 bg-primary rounded-l-full"></div>
@@ -107,20 +115,25 @@ export default function ScribeLeftBar() {
</button>
)) : (
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) => (
<button
key={component.id}
onClick={() => component.id === 1 ? setShowAddNewBook(true) : component.id === 2 ? setShowGenerateShortModal(true) : session.user?.groupId && session.user?.groupId === 1}
onClick={() => component.id === 1 ? setShowAddNewBook(true) : component.id === 2 ? setShowGenerateShortModal(true) : component.id === 3 ? setShowAddNewSeries(true) : null}
title={component.title}
className={`group relative p-3 rounded-xl transition-all duration-200 ${panelHidden && currentPanel?.id === component.id
? 'bg-primary text-text-primary shadow-lg shadow-primary/30'
: 'bg-secondary/30 text-muted hover:text-text-primary hover:bg-secondary hover:shadow-md'}`}
>
<FontAwesomeIcon icon={component.icon}
className={'w-5 h-5 transition-transform duration-200'}/>
className={'w-4 h-4 transition-transform duration-200'}/>
</button>
))
)}
@@ -141,6 +154,10 @@ export default function ScribeLeftBar() {
showAddNewBook &&
<AddNewBookForm setCloseForm={setShowAddNewBook}/>
}
{
showAddNewSeries &&
<AddNewSeriesForm setCloseForm={setShowAddNewSeries}/>
}
{
showGenerateShortModal &&
<ShortStoryGenerator onClose={() => setShowGenerateShortModal(false)}/>

View File

@@ -1,55 +1,88 @@
'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 (
<div className="flex items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
</div>
);
}
interface PanelContentProps {
currentPanelId: number | undefined;
}
function PanelContent({currentPanelId}: PanelContentProps): React.JSX.Element {
return (
<Suspense fallback={<LoadingSpinner/>}>
{currentPanelId === 1 && <QuillSense/>}
{currentPanelId === 2 && <WorldEditor/>}
{currentPanelId === 3 && <LocationEditor/>}
{currentPanelId === 4 && <CharacterEditor/>}
{currentPanelId === 5 && <SpellEditor/>}
</Suspense>
);
}
interface PanelHeaderProps {
currentPanel: PanelComponent | undefined;
onClose: () => Promise<void>;
}
function EditorPanelHeader({currentPanel, onClose}: PanelHeaderProps): React.JSX.Element {
return (
<PanelHeader
title={currentPanel?.title ?? ''}
description={currentPanel?.description ?? ''}
badge={currentPanel?.badge ?? ''}
icon={currentPanel?.icon}
callBackAction={onClose}
/>
);
}
export default function ComposerRightBar(): React.JSX.Element {
const {book} = useContext(BookContext);
const t = useTranslations();
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext)
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const [panelHidden, setPanelHidden] = useState<boolean>(false);
const [currentPanel, setCurrentPanel] = useState<PanelComponent | undefined>()
const [currentPanel, setCurrentPanel] = useState<PanelComponent | undefined>();
const [showAbout, setShowAbout] = useState<boolean>(false);
const worldRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{ handleSave: () => Promise<void> }>(null);
const locationRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{ handleSave: () => Promise<void> }>(null);
const characterRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{ handleSave: () => Promise<void> }>(null);
const spellRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{ handleSave: () => Promise<void> }>(null);
async function handleSaveClick(): Promise<void> {
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) {
if (currentPanel?.id === component.id) {
@@ -61,14 +94,6 @@ export default function ComposerRightBar() {
}
}
useEffect(():void => {
if (!book){
setCurrentPanel(undefined);
setPanelHidden(false);
return;
}
}, [book]);
const editorComponents: PanelComponent[] = [
{
id: 1,
@@ -105,14 +130,7 @@ 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[] = [
{
@@ -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<void> => window.electron.openExternal('https://www.facebook.com/profile.php?id=61562628720878')
action: function (): Promise<void> {
return window.electron.openExternal('https://www.facebook.com/profile.php?id=61562628720878');
}
},
{
id: 3,
@@ -137,17 +159,25 @@ 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<void> {
return window.electron.openExternal('https://discord.gg/CHXRPvmaXm');
}
]
}
];
function disabled(componentId: number): boolean {
switch (componentId) {
case 1:
return book === null;
default:
return book === null;
}
useEffect(function (): void {
if (!book) {
setCurrentPanel(undefined);
setPanelHidden(false);
}
}, [book]);
async function handleClosePanel(): Promise<void> {
setPanelHidden(!panelHidden);
}
return (
@@ -156,68 +186,50 @@ export default function ComposerRightBar() {
<div id="right-panel"
className="bg-tertiary/95 backdrop-blur-sm border-l border-secondary/50 min-w-[450px] max-w-[450px] h-full transition-all duration-300 overflow-hidden shadow-2xl">
<div className="flex flex-col h-full">
<PanelHeader title={currentPanel?.title ?? ''}
description={currentPanel?.description ?? ''}
badge={currentPanel?.badge ?? ''}
icon={currentPanel?.icon}
secondActionCallback={currentPanel?.id === 2 || currentPanel?.id === 3 || currentPanel?.id === 4 || currentPanel?.id === 5 ? handleSaveClick : undefined}
callBackAction={async () => setPanelHidden(!panelHidden)}
<EditorPanelHeader
currentPanel={currentPanel}
onClose={handleClosePanel}
/>
<div className="flex-grow overflow-auto">
{currentPanel?.id === 1 && (
<QuillSense/>
)}
{currentPanel?.id === 2 && (
<WorldSetting ref={worldRef} showToggle={false}/>
)}
{currentPanel?.id === 3 && (
<LocationComponent ref={locationRef} showToggle={false}/>
)}
{currentPanel?.id === 4 && (
<CharacterComponent ref={characterRef} showToggle={false}/>
)}
{currentPanel?.id === 5 && (
<SpellComponent ref={spellRef} showToggle={false}/>
)}
<PanelContent currentPanelId={currentPanel?.id}/>
</div>
</div>
</div>
)}
<div className="bg-tertiary border-l border-secondary/50 p-3 flex flex-col space-y-3 shadow-xl">
{book ? editorComponents
.filter((component: PanelComponent):boolean => {
// Filter QuillSense if offline, local book, or quillsenseEnabled is false
{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;
}
if (book?.quillsenseEnabled === false) {
if (book.quillsenseEnabled === false) {
return false;
}
}
// Filter Worlds if tools.worlds is disabled
// Masquer Worlds (id=2) si desactive pour ce livre
if (component.id === 2 && !book?.tools?.worlds) {
return false;
}
// Filter Locations if tools.locations is disabled
// Masquer Locations (id=3) si desactive pour ce livre
if (component.id === 3 && !book?.tools?.locations) {
return false;
}
// Filter Characters if tools.characters is disabled
// Masquer Characters (id=4) si desactive pour ce livre
if (component.id === 4 && !book?.tools?.characters) {
return false;
}
// Filter Spells if tools.spells is disabled
// Masquer Spells (id=5) si desactive pour ce livre
if (component.id === 5 && !book?.tools?.spells) {
return false;
}
return true;
})
.map((component: PanelComponent) => (
}).map(function (component: PanelComponent) {
return (
<button
key={component.id}
disabled={disabled(component.id)}
onClick={() => {
onClick={function (): void {
togglePanel(component);
setCurrentPanel(component);
}}
@@ -231,30 +243,31 @@ export default function ComposerRightBar() {
title={component.title}
>
<FontAwesomeIcon icon={component.icon}
className={'w-5 h-5 transition-transform duration-200 group-hover:scale-110'}/>
className="w-4 h-4 transition-transform duration-200 group-hover:scale-110"/>
{panelHidden && currentPanel?.id === component.id && (
<div
className="absolute -left-1 top-1/2 -translate-y-1/2 w-1 h-8 bg-primary rounded-r-full"></div>
<div className="absolute -left-1 top-1/2 -translate-y-1/2 w-1 h-8 bg-primary rounded-r-full"></div>
)}
</button>
)) : homeComponents.map((component: PanelComponent) => (
);
}) : homeComponents.map(function (component: PanelComponent) {
return (
<button
key={component.id}
onClick={component.action ?? (() => {
})}
onClick={component.action ?? function (): void {}}
className={`group relative p-3 rounded-xl transition-all duration-200 ${panelHidden && currentPanel?.id === component.id
? 'bg-primary text-text-primary shadow-lg shadow-primary/30 scale-105'
: 'bg-secondary/30 text-muted hover:text-text-primary hover:bg-secondary hover:shadow-md hover:scale-105'}`}
title={component.title}
>
<FontAwesomeIcon icon={component.icon}
className={'w-5 h-5 transition-transform duration-200 group-hover:scale-110'}/>
className="w-4 h-4 transition-transform duration-200 group-hover:scale-110"/>
</button>
))}
);
})}
</div>
{
showAbout && <AboutEditors onClose={() => setShowAbout(false)}/>
}
{showAbout && <AboutEditors onClose={function (): void {
setShowAbout(false);
}}/>}
</div>
)
);
}

View File

@@ -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<SetStateAction<boolean>>;
onSeriesCreated?: (seriesId: string, name: string) => void;
}
export default function AddNewSeriesForm({setCloseForm, onSeriesCreated}: AddNewSeriesFormProps) {
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {session} = useContext(SessionContext);
const {errorMessage, successMessage} = useContext(AlertContext);
const {serverSyncedBooks, setServerSyncedBooks, localSyncedBooks, setLocalSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {serverSyncedSeries, localSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
// Get all bookIds already in a series
const booksAlreadyInSeries: Set<string> = new Set(
(isCurrentlyOffline() ? localSyncedSeries : serverSyncedSeries)
.flatMap((series: SyncedSeries): string[] =>
series.books.map((book: SyncedSeriesBook): string => book.bookId)
)
);
const modalRef: React.RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
const [name, setName] = useState<string>('');
const [description, setDescription] = useState<string>('');
const [selectedBookIds, setSelectedBookIds] = useState<string[]>([]);
const [isAddingSeries, setIsAddingSeries] = useState<boolean>(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<void> {
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<string>('db:series:create', createData);
} else {
response = await System.authPostToServer<string>(
'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 (
<div
className="fixed inset-0 flex items-center justify-center bg-black/60 z-50 backdrop-blur-md animate-fadeIn">
<div ref={modalRef}
className="bg-tertiary/95 backdrop-blur-sm text-text-primary rounded-2xl border border-secondary/50 shadow-2xl md:w-3/4 xl:w-2/5 lg:w-2/4 sm:w-11/12 max-h-[85vh] flex flex-col">
<div className="flex justify-between items-center bg-primary px-6 py-4 rounded-t-2xl shadow-lg">
<h2 className="flex items-center gap-3 font-['ADLaM_Display'] text-2xl text-text-primary">
<FontAwesomeIcon icon={faLayerGroup} className="w-6 h-6"/>
{t("addNewSeriesForm.title")}
</h2>
<button
className="text-background hover:text-background w-10 h-10 rounded-xl hover:bg-white/20 transition-all duration-200 flex items-center justify-center hover:scale-110"
onClick={(): void => setCloseForm(false)}
>
<FontAwesomeIcon icon={faX} className={'w-5 h-5'}/>
</button>
</div>
<div className="p-5 overflow-y-auto flex-grow custom-scrollbar">
<div className="space-y-6">
<InputField icon={faPencilAlt} fieldName={t("addNewSeriesForm.name")} input={
<TextInput
value={name}
setValue={(e: ChangeEvent<HTMLInputElement>): void => setName(e.target.value)}
placeholder={t("addNewSeriesForm.namePlaceholder")}
/>
}/>
<InputField
icon={faPencilAlt}
fieldName={`${t("addNewSeriesForm.description")} (${t("addNewSeriesForm.optional")})`}
input={
<TexteAreaInput
value={description}
setValue={(e: ChangeEvent<HTMLTextAreaElement>): void => setDescription(e.target.value)}
placeholder={t("addNewSeriesForm.descriptionPlaceholder")}
/>
}
/>
<div className="space-y-3">
<div className="flex items-center gap-2 text-text-primary">
<FontAwesomeIcon icon={faBook} className="w-4 h-4 text-primary"/>
<span className="font-medium">{t("addNewSeriesForm.selectBooks")}</span>
{selectedBookIds.length > 0 && (
<span className="text-sm text-muted">
({selectedBookIds.length} {t("addNewSeriesForm.selected")})
</span>
)}
</div>
{(isCurrentlyOffline() ? localSyncedBooks : serverSyncedBooks)
.filter((book: SyncedBook): boolean => !booksAlreadyInSeries.has(book.id)).length === 0 ? (
<div className="text-center py-6 text-muted">
<FontAwesomeIcon icon={faBook} className="w-8 h-8 mb-2 opacity-50"/>
<p>{t("addNewSeriesForm.noBooks")}</p>
</div>
) : (
<div className="max-h-48 overflow-y-auto rounded-xl border border-secondary/50 bg-secondary/20">
{(isCurrentlyOffline() ? localSyncedBooks : serverSyncedBooks)
.filter((book: SyncedBook): boolean => !booksAlreadyInSeries.has(book.id))
.map((book: SyncedBook) => {
const isSelected: boolean = selectedBookIds.includes(book.id);
return (
<button
key={book.id}
type="button"
onClick={(): void => toggleBookSelection(book.id)}
className={`w-full flex items-center gap-3 px-4 py-3 transition-all duration-200 border-b border-secondary/30 last:border-b-0 ${
isSelected
? 'bg-primary/20 hover:bg-primary/30'
: 'hover:bg-secondary/50'
}`}
>
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
isSelected
? 'bg-primary border-primary'
: 'border-secondary/50'
}`}>
{isSelected && (
<FontAwesomeIcon icon={faCheck} className="w-3 h-3 text-white"/>
)}
</div>
<div className="flex-1 text-left">
<div className="text-text-primary font-medium">{book.title}</div>
{book.subTitle && (
<div className="text-sm text-muted">{book.subTitle}</div>
)}
</div>
</button>
);
})}
</div>
)}
</div>
</div>
</div>
<div
className="flex justify-between items-center p-5 border-t border-secondary/50 bg-secondary/20 rounded-b-2xl">
<div></div>
<div className="flex gap-3">
<CancelButton callBackFunction={() => setCloseForm(false)}/>
<SubmitButtonWLoading
callBackAction={handleAddSeries}
isLoading={isAddingSeries}
text={t("addNewSeriesForm.add")}
loadingText={t("addNewSeriesForm.adding")}
icon={faLayerGroup}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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<void>;
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<boolean>(false);
return (
<div className="flex flex-shrink-0 p-2">
<div
onClick={() => 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'}`}
>
<div className="relative w-full aspect-[2/3] flex-shrink-0 overflow-hidden rounded-t-2xl">
{series.coverImage ? (
<img
src={series.coverImage}
alt={series.name}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110 opacity-80"
/>
) : (
<div className="relative w-full h-full bg-gradient-to-br from-primary/30 via-primary/20 to-secondary flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-primary/10"></div>
<FontAwesomeIcon icon={faLayerGroup} className="text-primary w-16 h-16 opacity-60"/>
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-tertiary via-tertiary/50 to-transparent h-24"></div>
<div className="absolute top-3 right-3 flex items-center gap-2">
<SyncSeries seriesId={series.id} status={seriesSyncStatus} />
<div className="bg-primary text-white text-xs px-2 py-1 rounded-full font-bold shadow-lg">
{series.books.length}
</div>
</div>
</div>
<div className="p-4 flex-1 flex flex-col justify-between">
<div className="flex-1">
<h3 className="text-text-primary text-center font-bold text-base mb-2 truncate group-hover:text-primary transition-colors tracking-wide">
{series.name}
</h3>
<div className="flex items-center justify-center mb-3 h-5">
<div className="h-px w-8 bg-primary/30"></div>
<p className="text-muted text-center mx-2 text-xs italic truncate px-2">
{series.books.length} {series.books.length > 1 ? t("seriesCard.books") : t("seriesCard.book")}
</p>
<div className="h-px w-8 bg-primary/30"></div>
</div>
</div>
<div className="flex justify-center items-center pt-3 border-t border-primary/30">
<span className="bg-primary/20 text-primary text-xs px-3 py-1 rounded-full font-medium border border-primary/30">
{t("seriesCard.series")}
</span>
</div>
</div>
</div>
<div className={`${isExpanded ? 'flex' : 'hidden'} items-center border-y-2 border-primary bg-primary/5`}>
{series.books.map((book: BookProps, idx: number) => (
<div key={book.bookId} className="flex-shrink-0 w-64 sm:w-52 md:w-48 lg:w-56 xl:w-64 p-2">
<BookCard
book={book}
onClickCallback={onBookClick}
index={idx}
syncStatus={getSyncStatus ? getSyncStatus(book.bookId) : 'synced'}
/>
</div>
))}
{/* Bouton Settings */}
<div className="flex items-center px-4 flex-shrink-0">
<button
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
onSettingsClick(series.id);
}}
className="p-4 rounded-xl bg-primary/20 hover:bg-primary text-primary hover:text-white transition-all duration-200 hover:scale-110 shadow-lg"
title={t("seriesCard.settings")}
>
<FontAwesomeIcon icon={faCog} className="w-6 h-6"/>
</button>
</div>
</div>
{/* Bordure de fin */}
<div className={`${isExpanded ? 'block' : 'hidden'} w-3 border-y-2 border-r-2 border-primary rounded-r-2xl bg-primary/5 flex-shrink-0`}></div>
</div>
);
}

View File

@@ -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<string>('basic-information');
return (
<SeriesContext.Provider value={{seriesId, localSeries}}>
<SettingsPanel
title={t("bookList.seriesSettings")}
sidebar={
<SeriesSettingSidebar
selectedSetting={currentSetting}
setSelectedSetting={setCurrentSetting}
seriesId={seriesId}
onClose={onClose}
/>
}
onClose={onClose}
>
<SeriesSettingOption setting={currentSetting}/>
</SettingsPanel>
</SeriesContext.Provider>
);
}

View File

@@ -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 (
<div className="flex items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
</div>
);
}
interface SeriesSettingOptionProps {
setting: string;
}
interface SettingRef {
handleSave: () => Promise<void>;
}
// 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<SettingRef>(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<void> {
if (settingRef.current?.handleSave) {
await settingRef.current.handleSave();
}
}
return (
<div className="px-6">
<div className="sticky top-0 z-10 bg-tertiary pt-6 pb-4">
<PanelHeader
icon={faPen}
badge="SE"
title={renderTitle()}
description=""
secondActionCallback={showSaveButton ? handleSaveClick : undefined}
callBackAction={showSaveButton ? handleSaveClick : undefined}
secondActionIcon={showSaveButton ? faSave : undefined}
/>
</div>
<div className="bg-secondary/10 rounded-xl p-1 mb-6">
<Suspense fallback={<LoadingSpinner/>}>
{setting === 'basic-information' && <BasicSeriesInformation ref={settingRef}/>}
{setting === 'books' && <SeriesBooksManager ref={settingRef}/>}
{setting === 'worlds' && (
<WorldSettings entityType="series" entityId={seriesId} showToggle={false}/>
)}
{setting === 'locations' && (
<LocationSettings entityType="series" entityId={seriesId} showToggle={false}/>
)}
{setting === 'characters' && (
<CharacterSettings entityType="series" entityId={seriesId} showToggle={false}/>
)}
{setting === 'spells' && (
<SpellSettings entityType="series" entityId={seriesId} showToggle={false}/>
)}
{!['basic-information', 'books', 'worlds', 'locations', 'characters', 'spells'].includes(setting) && (
<div className="text-text-secondary py-4 text-center">
{t("bookSettingOption.notAvailable")}
</div>
)}
</Suspense>
</div>
</div>
);
}

View File

@@ -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<SetStateAction<string>>;
seriesId: string;
onClose: () => void;
}
export default function SeriesSettingSidebar(
{
selectedSetting,
setSelectedSetting,
seriesId,
onClose
}: SeriesSettingSidebarProps) {
const t = useTranslations();
const {session} = useContext(SessionContext);
const {lang} = useContext<LangContextProps>(LangContext);
const {errorMessage, successMessage} = useContext(AlertContext);
const userToken: string = session?.accessToken ? session?.accessToken : '';
const {localSeries} = useContext<SeriesContextProps>(SeriesContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {localSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
async function handleDeleteSeries(): Promise<void> {
try {
const deleteData = {seriesId: seriesId};
let success: boolean;
if (isCurrentlyOffline() || localSeries) {
success = await window.electron.invoke<boolean>('db:series:delete', deleteData);
} else {
success = await System.authDeleteToServer<boolean>(
'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<void> {
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 (
<div className="py-6 px-3 flex flex-col h-full">
<nav className="space-y-1 flex-1">
{
settings.map((setting: SeriesSettingOption) => (
<Link
key={setting.id}
href={''}
onClick={(): void => setSelectedSetting(setting.id)}
className={`flex items-center text-base rounded-xl transition-all duration-200 ${
selectedSetting === setting.id
? 'bg-primary/20 text-text-primary border-l-4 border-primary font-semibold shadow-md scale-105'
: 'text-text-secondary hover:bg-secondary/50 hover:text-text-primary hover:scale-102'
} p-3 mb-1`}>
<FontAwesomeIcon
icon={setting.icon}
className={`mr-3 ${selectedSetting === setting.id ? 'text-primary w-5 h-5' : 'text-text-secondary w-5 h-5'}`}/>
{t(setting.name)}
</Link>
))
}
</nav>
<div className="mt-6 pt-4 border-t border-secondary/50">
<button
onClick={(): void => setShowDeleteConfirm(true)}
className="w-full flex items-center justify-center gap-2 p-3 text-red-400 hover:bg-red-500/20 rounded-xl transition-all duration-200"
>
<FontAwesomeIcon icon={faTrash} className="w-4 h-4"/>
{t('seriesSetting.deleteSeries')}
</button>
</div>
{showDeleteConfirm && (
<AlertBox
title={t('seriesSetting.deleteSeries')}
message={t('seriesSetting.deleteConfirmMessage')}
type="danger"
onConfirm={handleDeleteConfirm}
onCancel={(): void => setShowDeleteConfirm(false)}
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
/>
)}
</div>
);
}

View File

@@ -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<void> }>) {
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {session} = useContext(SessionContext);
const {seriesId, localSeries} = useContext<SeriesContextProps>(SeriesContext);
const userToken: string = session?.accessToken ? session?.accessToken : '';
const {errorMessage, successMessage} = useContext(AlertContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [name, setName] = useState<string>('');
const [description, setDescription] = useState<string>('');
useEffect(function () {
if (seriesId) {
loadSeriesData();
}
}, [seriesId]);
async function loadSeriesData(): Promise<void> {
setIsLoading(true);
try {
let response: SeriesDetailResponse;
if (isCurrentlyOffline() || localSeries) {
response = await window.electron.invoke<SeriesDetailResponse>('db:series:detail', {seriesId});
} else {
response = await System.authGetQueryToServer<SeriesDetailResponse>(
'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<void> {
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<boolean>('db:series:update', updateData);
} else {
const response: SeriesUpdateResponse = await System.authPutToServer<SeriesUpdateResponse>(
'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 (
<div className="flex items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
</div>
);
}
return (
<div className="space-y-6">
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t('seriesBasicInformation.fields.name')} input={<TextInput
value={name}
setValue={(e: ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
placeholder={t('seriesBasicInformation.fields.namePlaceholder')}
/>}/>
</div>
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<InputField fieldName={t('seriesBasicInformation.fields.description')} input={<TexteAreaInput
value={description}
setValue={(e: ChangeEvent<HTMLTextAreaElement>) => setDescription(e.target.value)}
placeholder={t('seriesBasicInformation.fields.descriptionPlaceholder')}
/>}/>
</div>
</div>
);
}
export default forwardRef(BasicSeriesInformation);

View File

@@ -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<void> }>) {
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {session} = useContext(SessionContext);
const {seriesId, localSeries} = useContext<SeriesContextProps>(SeriesContext);
const {serverSyncedBooks, setServerSyncedBooks, localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const userToken: string = session?.accessToken ? session?.accessToken : '';
const {errorMessage, successMessage} = useContext(AlertContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedSeries, serverSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [seriesBooks, setSeriesBooks] = useState<SeriesBookProps[]>([]);
const [selectedBookToAdd, setSelectedBookToAdd] = useState<string>('');
const [availableBooks, setAvailableBooks] = useState<SyncedBook[]>([]);
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<string> = 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<void> {
setIsLoading(true);
try {
let response: SeriesBookProps[];
if (isCurrentlyOffline() || localSeries) {
response = await window.electron.invoke<SeriesBookProps[]>('db:series:books', {seriesId});
} else {
response = await System.authGetQueryToServer<SeriesBookProps[]>(
'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<void> {
successMessage(t('seriesBooks.success.saved'));
}
async function handleAddBook(): Promise<void> {
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<boolean>('db:series:book:add', addData);
} else {
response = await System.authPostToServer<boolean>(
'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<void> {
try {
const removeData = {
seriesId: seriesId,
bookId: bookId
};
let response: boolean;
if (isCurrentlyOffline() || localSeries) {
response = await window.electron.invoke<boolean>('db:series:book:remove', removeData);
} else {
response = await System.authDeleteToServer<boolean>(
'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<void> {
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<boolean>('db:series:book:reorder', reorderData);
} else {
response = await System.authPutToServer<boolean>(
'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 (
<div className="flex items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
</div>
);
}
return (
<div className="space-y-6">
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<h3 className="text-lg font-semibold text-text-primary mb-4">
{t('seriesBooks.addBook')}
</h3>
<div className="flex gap-3">
<select
value={selectedBookToAdd}
onChange={(e) => setSelectedBookToAdd(e.target.value)}
className="flex-1 bg-secondary/50 border border-secondary/50 rounded-lg px-4 py-2 text-text-primary focus:outline-none focus:border-primary"
>
<option value="">{t('seriesBooks.selectBookPlaceholder')}</option>
{availableBooks.map((book: SyncedBook) => (
<option key={book.id} value={book.id}>
{book.title}
</option>
))}
</select>
<button
onClick={handleAddBook}
disabled={!selectedBookToAdd}
className="bg-primary hover:bg-primary-dark disabled:bg-secondary disabled:cursor-not-allowed text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center gap-2"
>
<FontAwesomeIcon icon={faPlus} className="w-4 h-4"/>
{t('seriesBooks.add')}
</button>
</div>
</div>
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-md">
<h3 className="text-lg font-semibold text-text-primary mb-4">
{t('seriesBooks.booksInSeries')} ({seriesBooks.length})
</h3>
{seriesBooks.length === 0 ? (
<div className="text-center py-8 text-text-secondary">
<FontAwesomeIcon icon={faBook} className="w-12 h-12 mb-4 opacity-50"/>
<p>{t('seriesBooks.noBooks')}</p>
</div>
) : (
<div className="space-y-2">
{seriesBooks
.sort((a: SeriesBookProps, b: SeriesBookProps) => a.order - b.order)
.map((book: SeriesBookProps, index: number) => (
<div
key={book.bookId}
className="flex items-center justify-between bg-secondary/30 rounded-lg p-3 border border-secondary/30 hover:border-primary/30 transition-colors duration-200"
>
<div className="flex items-center gap-3">
<span className="bg-primary/20 text-primary font-bold w-8 h-8 rounded-full flex items-center justify-center text-sm">
{book.order}
</span>
<span className="text-text-primary font-medium">{book.title}</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleMoveBook(book.bookId, 'up')}
disabled={index === 0}
className="p-2 rounded-lg hover:bg-secondary/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors duration-200"
title={t('seriesBooks.moveUp')}
>
<FontAwesomeIcon icon={faArrowUp} className="w-4 h-4 text-text-secondary"/>
</button>
<button
onClick={() => handleMoveBook(book.bookId, 'down')}
disabled={index === seriesBooks.length - 1}
className="p-2 rounded-lg hover:bg-secondary/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors duration-200"
title={t('seriesBooks.moveDown')}
>
<FontAwesomeIcon icon={faArrowDown} className="w-4 h-4 text-text-secondary"/>
</button>
<button
onClick={() => handleRemoveBook(book.bookId)}
className="p-2 rounded-lg hover:bg-error/20 text-error transition-colors duration-200"
title={t('seriesBooks.removeBook')}
>
<FontAwesomeIcon icon={faTrash} className="w-4 h-4"/>
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
export default forwardRef(SeriesBooksManager);

View File

@@ -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,9 +38,10 @@ export const AlertContext: Context<AlertContextProps> = createContext<AlertConte
export function AlertProvider({children}: AlertProviderProps): JSX.Element {
const [alerts, setAlerts]: [Alert[], Dispatch<SetStateAction<Alert[]>>] = useState<Alert[]>([]);
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 => {

View File

@@ -12,6 +12,8 @@ export interface BooksSyncContextProps {
setLocalSyncedBooks:Dispatch<SetStateAction<SyncedBook[]>>;
setServerOnlyBooks:Dispatch<SetStateAction<SyncedBook[]>>;
setLocalOnlyBooks:Dispatch<SetStateAction<SyncedBook[]>>;
setBooksToSyncFromServer:Dispatch<SetStateAction<BookSyncCompare[]>>;
setBooksToSyncToServer:Dispatch<SetStateAction<BookSyncCompare[]>>;
serverOnlyBooks:SyncedBook[];
localOnlyBooks:SyncedBook[];
}
@@ -25,6 +27,8 @@ export const BooksSyncContext:Context<BooksSyncContextProps> = createContext<Boo
setLocalSyncedBooks:():void => {},
setServerOnlyBooks:():void => {},
setLocalOnlyBooks:():void => {},
setBooksToSyncFromServer:():void => {},
setBooksToSyncToServer:():void => {},
serverOnlyBooks:[],
localOnlyBooks:[]
})

11
context/SeriesContext.ts Normal file
View File

@@ -0,0 +1,11 @@
import {Context, createContext} from "react";
export interface SeriesContextProps {
seriesId: string;
localSeries: boolean;
}
export const SeriesContext: Context<SeriesContextProps> = createContext<SeriesContextProps>({
seriesId: '',
localSeries: false
});

View File

@@ -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<SetStateAction<SyncedSeries[]>>;
setLocalSyncedSeries: Dispatch<SetStateAction<SyncedSeries[]>>;
setServerOnlySeries: Dispatch<SetStateAction<SyncedSeries[]>>;
setLocalOnlySeries: Dispatch<SetStateAction<SyncedSeries[]>>;
setSeriesToSyncFromServer: Dispatch<SetStateAction<SeriesSyncCompare[]>>;
setSeriesToSyncToServer: Dispatch<SetStateAction<SeriesSyncCompare[]>>;
serverOnlySeries: SyncedSeries[];
localOnlySeries: SyncedSeries[];
}
export const SeriesSyncContext: Context<SeriesSyncContextProps> = createContext<SeriesSyncContextProps>({
serverSyncedSeries: [],
localSyncedSeries: [],
seriesToSyncFromServer: [],
seriesToSyncToServer: [],
setServerSyncedSeries: (): void => {},
setLocalSyncedSeries: (): void => {},
setServerOnlySeries: (): void => {},
setLocalOnlySeries: (): void => {},
setSeriesToSyncFromServer: (): void => {},
setSeriesToSyncToServer: (): void => {},
serverOnlySeries: [],
localOnlySeries: []
});

View File

@@ -5,6 +5,7 @@ export interface WorldContextProps {
worlds: WorldProps[];
setWorlds: Dispatch<SetStateAction<WorldProps[]>>;
selectedWorldIndex: number;
isSeriesMode?: boolean;
}
export const WorldContext = createContext<WorldContextProps>({} as WorldContextProps);

View File

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

View File

@@ -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;
}
/**

View File

@@ -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;
}
/**

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
/**

View File

@@ -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;
}
}

View File

@@ -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
);
}
}

View File

@@ -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;
}
/**

View File

@@ -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;
}
/**

View File

@@ -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;
}
}

View File

@@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
};
});
}
}

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,158 @@
import { Database, RunResult, SQLiteValue } from 'node-sqlite3-wasm';
import System from '../System.js';
export interface RemovedItemRecord extends Record<string, SQLiteValue> {
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.");
}
}
}
}

View File

@@ -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);
}
}

View File

@@ -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.");
}
}
}
}

View File

@@ -26,9 +26,9 @@ export interface SeriesSpellsTableResult extends Record<string, SQLiteValue> {
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.");
}
}
}
}

View File

@@ -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.");
}
}
}
}

View File

@@ -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);
}
}

Some files were not shown because too many files have changed in this diff Show More