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:
@@ -251,10 +251,6 @@ body {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.setting-container {
|
||||
height: calc(100vh - 10rem);
|
||||
}
|
||||
|
||||
.composer-panel-h {
|
||||
height: calc(100vh - 8rem);
|
||||
}
|
||||
|
||||
172
app/page.tsx
172
app/page.tsx
@@ -34,10 +34,32 @@ import OfflineContext, {OfflineMode} from "@/context/OfflineContext";
|
||||
import OfflinePinSetup from "@/components/offline/OfflinePinSetup";
|
||||
import OfflinePinVerify from "@/components/offline/OfflinePinVerify";
|
||||
import {SyncedBook, BookSyncCompare, compareBookSyncs} from "@/lib/models/SyncedBook";
|
||||
import {SyncedSeries, SeriesSyncCompare, compareSeriesSyncs} from "@/lib/models/SyncedSeries";
|
||||
import {BooksSyncContext} from "@/context/BooksSyncContext";
|
||||
import {SeriesSyncContext} from "@/context/SeriesSyncContext";
|
||||
import useSyncBooks from "@/hooks/useSyncBooks";
|
||||
import useSyncSeries from "@/hooks/useSyncSeries";
|
||||
import {LocalSyncQueueContext, LocalSyncOperation} from "@/context/SyncQueueContext";
|
||||
|
||||
interface RemovedItemRecord {
|
||||
removal_id: string;
|
||||
table_name: string;
|
||||
entity_id: string;
|
||||
book_id: string | null;
|
||||
user_id: string;
|
||||
deleted_at: number;
|
||||
}
|
||||
|
||||
interface SyncedBooksResponse {
|
||||
books: SyncedBook[];
|
||||
tombstones: RemovedItemRecord[];
|
||||
}
|
||||
|
||||
interface SyncedSeriesResponse {
|
||||
series: SyncedSeries[];
|
||||
tombstones: RemovedItemRecord[];
|
||||
}
|
||||
|
||||
const messagesMap = {
|
||||
fr: frMessages,
|
||||
en: enMessages
|
||||
@@ -45,22 +67,59 @@ const messagesMap = {
|
||||
|
||||
function AutoSyncOnReconnect() {
|
||||
const {offlineMode} = useContext(OfflineContext);
|
||||
const {syncAllToServer, refreshBooks, booksToSyncToServer} = useSyncBooks();
|
||||
const {syncAllToServer: syncAllBooksToServer, refreshBooks, booksToSyncToServer} = useSyncBooks();
|
||||
const {syncAllToServer: syncAllSeriesToServer, refreshSeries, seriesToSyncToServer} = useSyncSeries();
|
||||
const [pendingSync, setPendingSync] = useState<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]);
|
||||
|
||||
|
||||
async function getBooks(): Promise<void> {
|
||||
useEffect((): void => {
|
||||
const diffsFromServer: SeriesSyncCompare[] = [];
|
||||
const diffsToServer: SeriesSyncCompare[] = [];
|
||||
|
||||
serverSyncedSeries.forEach((serverSeries: SyncedSeries): void => {
|
||||
const localSeries: SyncedSeries | undefined = localSyncedSeries.find((series: SyncedSeries): boolean => series.id === serverSeries.id);
|
||||
if (!localSeries) {
|
||||
return;
|
||||
}
|
||||
|
||||
const diff: SeriesSyncCompare | null = compareSeriesSyncs(serverSeries, localSeries);
|
||||
if (diff) {
|
||||
diffsFromServer.push(diff);
|
||||
}
|
||||
});
|
||||
|
||||
localSyncedSeries.forEach((localSeries: SyncedSeries): void => {
|
||||
const serverSeries: SyncedSeries | undefined = serverSyncedSeries.find((series: SyncedSeries): boolean => series.id === localSeries.id);
|
||||
if (!serverSeries) {
|
||||
return;
|
||||
}
|
||||
|
||||
const diff: SeriesSyncCompare | null = compareSeriesSyncs(localSeries, serverSeries);
|
||||
if (diff) {
|
||||
diffsToServer.push(diff);
|
||||
}
|
||||
});
|
||||
|
||||
setSeriesSyncDiffsFromServer(diffsFromServer);
|
||||
setSeriesSyncDiffsToServer(diffsToServer);
|
||||
setServerOnlySeries(serverSyncedSeries.filter((serverSeries: SyncedSeries): boolean => !localSyncedSeries.find((localSeries: SyncedSeries): boolean => localSeries.id === serverSeries.id)));
|
||||
setLocalOnlySeries(localSyncedSeries.filter((localSeries: SyncedSeries): boolean => !serverSyncedSeries.find((serverSeries: SyncedSeries): boolean => serverSeries.id === localSeries.id)));
|
||||
}, [localSyncedSeries, serverSyncedSeries]);
|
||||
|
||||
async function refreshBooks(): Promise<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');
|
||||
@@ -296,7 +404,43 @@ 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>
|
||||
|
||||
@@ -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>
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
50
components/SettingsPanel.tsx
Normal file
50
components/SettingsPanel.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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
131
components/SyncSeries.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: []
|
||||
}]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,9 +128,10 @@ export default function BookList() {
|
||||
booksToSyncFromServer,
|
||||
booksToSyncToServer,
|
||||
serverOnlyBooks,
|
||||
localOnlyBooks
|
||||
localOnlyBooks,
|
||||
serverSyncedBooks
|
||||
]);
|
||||
|
||||
|
||||
async function handleFirstBookGuide(): Promise<void> {
|
||||
try {
|
||||
if (!isCurrentlyOffline()) {
|
||||
@@ -135,60 +161,172 @@ 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 = {
|
||||
bookId: book.bookId,
|
||||
type: categoryLabel,
|
||||
title: book.title,
|
||||
subTitle: book.subTitle,
|
||||
summary: book.summary,
|
||||
serie: book.serie,
|
||||
publicationDate: book.publicationDate,
|
||||
desiredWordCount: book.desiredWordCount,
|
||||
totalWordCount: 0,
|
||||
coverImage: imageDataUrl,
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PARTIE 2 : CRÉATION DU MAPPING BOOK → SERIES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const bookToSeriesMap: Map<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: Book.getBookTypeLabel(book.type),
|
||||
title: book.title,
|
||||
subTitle: book.subTitle,
|
||||
summary: book.summary,
|
||||
serie: book.serie,
|
||||
publicationDate: book.publicationDate,
|
||||
desiredWordCount: book.desiredWordCount,
|
||||
totalWordCount: 0,
|
||||
coverImage: imageDataUrl,
|
||||
itIsLocal: book.itIsLocal
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PARTIE 4 : GROUPEMENT PAR CATÉGORIE AVEC SÉRIES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const itemsByCategory: Record<string, CategoryItem[]> = {};
|
||||
const processedSeriesIds: Set<string> = new Set();
|
||||
|
||||
transformedBooks.forEach((book): void => {
|
||||
const categoryLabel: string = t(book.type);
|
||||
if (!itemsByCategory[categoryLabel]) {
|
||||
itemsByCategory[categoryLabel] = [];
|
||||
}
|
||||
|
||||
const seriesInfo = bookToSeriesMap.get(book.bookId);
|
||||
|
||||
if (seriesInfo && !processedSeriesIds.has(seriesInfo.id)) {
|
||||
// Livre fait partie d'une série non encore traitée
|
||||
processedSeriesIds.add(seriesInfo.id);
|
||||
|
||||
// Récupérer tous les livres de cette série
|
||||
const seriesBooks: BookProps[] = transformedBooks.filter(
|
||||
b => seriesInfo.bookIds.includes(b.bookId)
|
||||
);
|
||||
|
||||
const seriesCard: SeriesCardProps = {
|
||||
id: seriesInfo.id,
|
||||
name: seriesInfo.name,
|
||||
coverImage: seriesInfo.coverImage,
|
||||
books: seriesBooks
|
||||
};
|
||||
if (!groups[t(categoryLabel)]) {
|
||||
groups[t(categoryLabel)] = [];
|
||||
|
||||
itemsByCategory[categoryLabel].push({
|
||||
type: 'series',
|
||||
series: seriesCard
|
||||
});
|
||||
} else if (!seriesInfo) {
|
||||
// Livre individuel (pas dans une série)
|
||||
itemsByCategory[categoryLabel].push({
|
||||
type: 'book',
|
||||
book: book
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Ajouter les séries vides (orphelines)
|
||||
seriesResponse.forEach((series): void => {
|
||||
if (series.bookIds.length === 0) {
|
||||
const emptySeriesCategory = t('bookList.emptySeries');
|
||||
if (!itemsByCategory[emptySeriesCategory]) {
|
||||
itemsByCategory[emptySeriesCategory] = [];
|
||||
}
|
||||
groups[t(categoryLabel)].push(transformedBook);
|
||||
return groups;
|
||||
}, {});
|
||||
setGroupedBooks(booksByType);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
itemsByCategory[emptySeriesCategory].push({
|
||||
type: 'series',
|
||||
series: {
|
||||
id: series.id,
|
||||
name: series.name,
|
||||
coverImage: series.coverImage,
|
||||
books: []
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setGroupedItems(itemsByCategory);
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
errorMessage(error.message);
|
||||
} else {
|
||||
errorMessage(t("bookList.errorBooksFetch"));
|
||||
}
|
||||
@@ -196,90 +334,128 @@ export default function BookList() {
|
||||
setIsLoadingBooks(false);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredGroupedBooks: Record<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())
|
||||
);
|
||||
|
||||
if (filteredBooks.length > 0) {
|
||||
acc[category] = filteredBooks;
|
||||
|
||||
function getFilteredGroupedItems(): Record<string, CategoryItem[]> {
|
||||
if (!searchQuery) {
|
||||
return groupedItems;
|
||||
}
|
||||
|
||||
const filtered: Record<string, CategoryItem[]> = {};
|
||||
|
||||
Object.entries(groupedItems).forEach(([category, items]) => {
|
||||
const filteredItems = items.filter((item): boolean => {
|
||||
if (item.type === 'book' && item.book) {
|
||||
return item.book.title.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
}
|
||||
if (item.type === 'series' && item.series) {
|
||||
const matchesSeriesName = item.series.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesBookTitle = item.series.books.some(
|
||||
book => book.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
return matchesSeriesName || matchesBookTitle;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (filteredItems.length > 0) {
|
||||
filtered[category] = filteredItems;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
function detectBookSyncStatus(bookId: string):SyncType {
|
||||
if (serverOnlyBooks.find((book: SyncedBook):boolean => book.id === bookId)) {
|
||||
return 'server-only';
|
||||
}
|
||||
if (localOnlyBooks.find((book: SyncedBook):boolean => book.id === bookId)) {
|
||||
return 'local-only';
|
||||
}
|
||||
if (booksToSyncFromServer.find((book: BookSyncCompare):boolean => book.id === bookId)) {
|
||||
return 'to-sync-from-server';
|
||||
}
|
||||
if (booksToSyncToServer.find((book: BookSyncCompare):boolean => book.id === bookId)) {
|
||||
return 'to-sync-to-server';
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function getTotalItemsCount(items: CategoryItem[]): number {
|
||||
return items.reduce((count, item) => {
|
||||
if (item.type === 'book') return count + 1;
|
||||
if (item.type === 'series' && item.series) return count + item.series.books.length;
|
||||
return count;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function detectBookSyncStatus(bookId: string): SyncType {
|
||||
if (serverOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId)) return 'server-only';
|
||||
if (localOnlyBooks.find((book: SyncedBook): boolean => book.id === bookId)) return 'local-only';
|
||||
if (booksToSyncFromServer.find((book: BookSyncCompare): boolean => book.id === bookId)) return 'to-sync-from-server';
|
||||
if (booksToSyncToServer.find((book: BookSyncCompare): boolean => book.id === bookId)) return 'to-sync-to-server';
|
||||
return 'synced';
|
||||
}
|
||||
|
||||
async function getBook(bookId: string): Promise<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']">
|
||||
@@ -302,7 +478,7 @@ export default function BookList() {
|
||||
<div className="h-8 bg-secondary/30 rounded-xl w-32 animate-pulse"></div>
|
||||
<div className="h-6 bg-secondary/20 rounded-lg w-24 animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-wrap justify-center items-start w-full px-4">
|
||||
{Array.from({length: 6}).map((_, id: number) => (
|
||||
<div key={id}
|
||||
@@ -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}`}
|
||||
{...(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}
|
||||
index={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={`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)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,63 @@
|
||||
import BasicInformationSetting from "./BasicInformationSetting";
|
||||
import GuideLineSetting from "./guide-line/GuideLineSetting";
|
||||
import StorySetting from "./story/StorySetting";
|
||||
import WorldSetting from "@/components/book/settings/world/WorldSetting";
|
||||
import {faPen, faSave} from "@fortawesome/free-solid-svg-icons";
|
||||
import {RefObject, useRef} from "react";
|
||||
import PanelHeader from "@/components/PanelHeader";
|
||||
import LocationComponent from "@/components/book/settings/locations/LocationComponent";
|
||||
import CharacterComponent from "@/components/book/settings/characters/CharacterComponent";
|
||||
import SpellComponent from "@/components/book/settings/spells/SpellComponent";
|
||||
import QuillSenseSetting from "@/components/book/settings/quillsense/QuillSenseSetting";
|
||||
import {useTranslations} from "next-intl"; // Ajouté pour la traduction
|
||||
'use client'
|
||||
import React, {lazy, Suspense, useRef} from 'react';
|
||||
import {faPen, faSave} from '@fortawesome/free-solid-svg-icons';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faSpinner} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import PanelHeader from '@/components/PanelHeader';
|
||||
|
||||
export default function BookSettingOption(
|
||||
{
|
||||
setting,
|
||||
}: {
|
||||
setting: string;
|
||||
}) {
|
||||
const t = useTranslations(); // Ajouté pour la traduction
|
||||
|
||||
const basicInfoRef: RefObject<{ handleSave: () => Promise<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);
|
||||
// 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;
|
||||
}
|
||||
|
||||
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">
|
||||
<PanelHeader
|
||||
icon={faPen}
|
||||
badge={`BI`}
|
||||
title={renderTitle()}
|
||||
description={``}
|
||||
secondActionCallback={handleSaveClick}
|
||||
callBackAction={handleSaveClick}
|
||||
secondActionIcon={faSave}
|
||||
/>
|
||||
<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 className="px-6">
|
||||
<div className="sticky top-0 z-10 bg-tertiary pt-6 pb-4">
|
||||
<PanelHeader
|
||||
icon={faPen}
|
||||
badge="BI"
|
||||
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' && <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>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@ import {
|
||||
faUser,
|
||||
faWandMagicSparkles
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import {Dispatch, SetStateAction} from "react";
|
||||
import {Dispatch, SetStateAction, useContext} from "react";
|
||||
import {IconDefinition} from "@fortawesome/fontawesome-svg-core";
|
||||
import {useTranslations} from "next-intl";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
|
||||
interface BookSettingOption {
|
||||
id: string;
|
||||
@@ -30,7 +31,8 @@ export default function BookSettingSidebar(
|
||||
setSelectedSetting: Dispatch<SetStateAction<string>>
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
|
||||
const settings: BookSettingOption[] = [
|
||||
{
|
||||
id: 'basic-information',
|
||||
@@ -84,11 +86,16 @@ export default function BookSettingSidebar(
|
||||
// },
|
||||
]
|
||||
|
||||
// Filter out QuillSense when offline (requires server connection)
|
||||
const availableSettings: BookSettingOption[] = isCurrentlyOffline()
|
||||
? settings.filter((s: BookSettingOption) => s.id !== 'quillsense')
|
||||
: settings;
|
||||
|
||||
return (
|
||||
<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)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
169
components/book/settings/ToolDetailHeader.tsx
Normal file
169
components/book/settings/ToolDetailHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
206
components/book/settings/characters/editor/CharacterEditor.tsx
Normal file
206
components/book/settings/characters/editor/CharacterEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,44 +63,77 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
|
||||
const {session} = useContext(SessionContext);
|
||||
const {successMessage, errorMessage} = useContext(AlertContext);
|
||||
const {book, setBook} = useContext(BookContext);
|
||||
const {seriesId, localSeries} = useContext<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 {
|
||||
handleSave: handleSave,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
useEffect((): void => {
|
||||
getAllLocations().then();
|
||||
}, []);
|
||||
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: {
|
||||
characters: book.tools?.characters ?? false,
|
||||
worlds: book.tools?.worlds ?? false,
|
||||
spells: book.tools?.spells ?? false,
|
||||
locations: enabled
|
||||
}});
|
||||
setBook({
|
||||
...book, tools: {
|
||||
characters: book.tools?.characters ?? false,
|
||||
worlds: book.tools?.worlds ?? false,
|
||||
spells: book.tools?.spells ?? false,
|
||||
locations: enabled
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
@@ -111,28 +159,61 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
|
||||
|
||||
async function getAllLocations(): Promise<void> {
|
||||
try {
|
||||
let response: LocationListResponse;
|
||||
if (isCurrentlyOffline()) {
|
||||
response = await window.electron.invoke<LocationListResponse>('db:location:all', {bookid: bookId});
|
||||
} else {
|
||||
if (book?.localBook) {
|
||||
response = await window.electron.invoke<LocationListResponse>('db:location:all', {bookid: bookId});
|
||||
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<LocationListResponse>(`location/all`, token, lang, {
|
||||
bookid: bookId,
|
||||
});
|
||||
response = await System.authGetQueryToServer<SeriesLocationItem[]>(
|
||||
'series/location/list',
|
||||
token,
|
||||
lang,
|
||||
{seriesid: currentEntityId}
|
||||
);
|
||||
}
|
||||
}
|
||||
if (response) {
|
||||
setSections(response.locations);
|
||||
setToolEnabled(response.enabled);
|
||||
if (setBook && book) {
|
||||
setBook({...book, tools: {
|
||||
characters: book.tools?.characters ?? false,
|
||||
worlds: book.tools?.worlds ?? false,
|
||||
spells: book.tools?.spells ?? false,
|
||||
locations: response.enabled
|
||||
}});
|
||||
if (response) {
|
||||
const mappedLocations: LocationProps[] = response.map((loc: SeriesLocationItem): LocationProps => ({
|
||||
id: loc.id,
|
||||
name: loc.name,
|
||||
elements: loc.elements.map((elem: SeriesLocationElement) => ({
|
||||
id: elem.id,
|
||||
name: elem.name,
|
||||
description: elem.description,
|
||||
subElements: elem.subElements.map((sub: SeriesLocationSubElement) => ({
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
description: sub.description,
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
setSections(mappedLocations);
|
||||
}
|
||||
} else {
|
||||
let response: LocationListResponse;
|
||||
if (isCurrentlyOffline()) {
|
||||
response = await window.electron.invoke<LocationListResponse>('db:location:all', {bookid: currentEntityId});
|
||||
} else {
|
||||
if (book?.localBook) {
|
||||
response = await window.electron.invoke<LocationListResponse>('db:location:all', {bookid: currentEntityId});
|
||||
} else {
|
||||
response = await System.authGetQueryToServer<LocationListResponse>(`location/all`, token, lang, {
|
||||
bookid: currentEntityId,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (response) {
|
||||
setSections(response.locations);
|
||||
setToolEnabled(response.enabled);
|
||||
if (setBook && book) {
|
||||
setBook({
|
||||
...book, tools: {
|
||||
characters: book.tools?.characters ?? false,
|
||||
worlds: book.tools?.worlds ?? false,
|
||||
spells: book.tools?.spells ?? false,
|
||||
locations: response.enabled
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
@@ -143,7 +224,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handleAddSection(): Promise<void> {
|
||||
if (!newSectionName.trim()) {
|
||||
errorMessage(t('locationComponent.errorSectionNameEmpty'))
|
||||
@@ -151,20 +232,42 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
|
||||
}
|
||||
try {
|
||||
let sectionId: string;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
if (isSeriesMode) {
|
||||
const addData = {
|
||||
seriesId: currentEntityId,
|
||||
name: newSectionName,
|
||||
};
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
sectionId = await window.electron.invoke<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,
|
||||
});
|
||||
@@ -189,7 +292,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handleAddElement(sectionId: string): Promise<void> {
|
||||
if (!newElementNames[sectionId]?.trim()) {
|
||||
errorMessage(t('locationComponent.errorElementNameEmpty'))
|
||||
@@ -197,23 +300,45 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
|
||||
}
|
||||
try {
|
||||
let elementId: string;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
if (isSeriesMode) {
|
||||
const addData = {
|
||||
locationId: sectionId,
|
||||
name: newElementNames[sectionId],
|
||||
};
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
elementId = await window.electron.invoke<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],
|
||||
@@ -244,7 +369,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleElementChange(
|
||||
sectionId: string,
|
||||
elementIndex: number,
|
||||
@@ -259,7 +384,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
|
||||
updatedSections[sectionIndex].elements[elementIndex][field] = value;
|
||||
setSections(updatedSections);
|
||||
}
|
||||
|
||||
|
||||
async function handleAddSubElement(
|
||||
sectionId: string,
|
||||
elementIndex: number,
|
||||
@@ -274,7 +399,29 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
|
||||
try {
|
||||
let subElementId: string;
|
||||
const elementId = sections[sectionIndex].elements[elementIndex].id;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
if (isSeriesMode) {
|
||||
const addData = {
|
||||
elementId: elementId,
|
||||
name: newSubElementNames[elementIndex],
|
||||
};
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
subElementId = await window.electron.invoke<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,
|
||||
@@ -330,7 +477,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
|
||||
][field] = value;
|
||||
setSections(updatedSections);
|
||||
}
|
||||
|
||||
|
||||
async function handleRemoveElement(
|
||||
sectionId: string,
|
||||
elementIndex: number,
|
||||
@@ -339,7 +486,17 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
|
||||
let response: boolean;
|
||||
const elementId = sections.find((section: LocationProps): boolean => section.id === sectionId)
|
||||
?.elements[elementIndex].id;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
if (isSeriesMode) {
|
||||
const deleteData = {elementId: elementId};
|
||||
if (isCurrentlyOffline() || localSeries) {
|
||||
response = await window.electron.invoke<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>
|
||||
<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">
|
||||
<FontAwesomeIcon icon={faTrash} className={'w-5 h-5'}/>
|
||||
</button>
|
||||
<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="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 ? (
|
||||
@@ -638,4 +935,4 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(LocationComponent);
|
||||
export default forwardRef(LocationComponent);
|
||||
|
||||
242
components/book/settings/locations/editor/LocationEditor.tsx
Normal file
242
components/book/settings/locations/editor/LocationEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
151
components/book/settings/locations/editor/LocationEditorEdit.tsx
Normal file
151
components/book/settings/locations/editor/LocationEditorEdit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
components/book/settings/locations/editor/LocationEditorList.tsx
Normal file
113
components/book/settings/locations/editor/LocationEditorList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
228
components/book/settings/locations/settings/LocationSettings.tsx
Normal file
228
components/book/settings/locations/settings/LocationSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
217
components/book/settings/spells/editor/SpellEditor.tsx
Normal file
217
components/book/settings/spells/editor/SpellEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
components/book/settings/spells/editor/SpellEditorDetail.tsx
Normal file
79
components/book/settings/spells/editor/SpellEditorDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
368
components/book/settings/spells/editor/SpellEditorEdit.tsx
Normal file
368
components/book/settings/spells/editor/SpellEditorEdit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
components/book/settings/spells/editor/SpellEditorList.tsx
Normal file
163
components/book/settings/spells/editor/SpellEditorList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
components/book/settings/spells/settings/SpellSettings.tsx
Normal file
243
components/book/settings/spells/settings/SpellSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
components/book/settings/spells/settings/SpellSettingsDetail.tsx
Normal file
150
components/book/settings/spells/settings/SpellSettingsDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
384
components/book/settings/spells/settings/SpellSettingsEdit.tsx
Normal file
384
components/book/settings/spells/settings/SpellSettingsEdit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
components/book/settings/spells/settings/SpellSettingsList.tsx
Normal file
159
components/book/settings/spells/settings/SpellSettingsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,43 +47,72 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {book, setBook} = useContext(BookContext);
|
||||
const bookId: string = book?.bookId ? book.bookId.toString() : '';
|
||||
|
||||
const currentEntityId: string = entityId || book?.bookId || '';
|
||||
const isSeriesMode: boolean = entityType === 'series';
|
||||
|
||||
const [worlds, setWorlds] = useState<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 {
|
||||
handleSave: handleUpdateWorld,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
useEffect((): void => {
|
||||
getWorlds().then();
|
||||
}, []);
|
||||
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: {
|
||||
characters: book.tools?.characters ?? false,
|
||||
worlds: enabled,
|
||||
locations: book.tools?.locations ?? false
|
||||
}});
|
||||
setBook({
|
||||
...book, tools: {
|
||||
characters: book.tools?.characters ?? false,
|
||||
worlds: enabled,
|
||||
locations: book.tools?.locations ?? false,
|
||||
spells: book.tools?.spells ?? false
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
@@ -94,37 +136,73 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a
|
||||
}
|
||||
}
|
||||
|
||||
async function getWorlds() {
|
||||
async function getWorlds(): Promise<void> {
|
||||
try {
|
||||
let response: WorldListResponse;
|
||||
if (isCurrentlyOffline()) {
|
||||
response = await window.electron.invoke<WorldListResponse>('db:book:worlds:get', {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 {
|
||||
if (book?.localBook) {
|
||||
response = await window.electron.invoke<WorldListResponse>('db:book:worlds:get', {bookid: bookId});
|
||||
// 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: bookId,
|
||||
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: {
|
||||
characters: book.tools?.characters ?? false,
|
||||
worlds: response.enabled,
|
||||
locations: book.tools?.locations ?? false
|
||||
}});
|
||||
if (response) {
|
||||
setWorlds(response.worlds);
|
||||
setToolEnabled(response.enabled);
|
||||
if (setBook && book) {
|
||||
setBook({
|
||||
...book, tools: {
|
||||
characters: book.tools?.characters ?? false,
|
||||
worlds: response.enabled,
|
||||
locations: book.tools?.locations ?? false,
|
||||
spells: book.tools?.spells ?? false
|
||||
}
|
||||
});
|
||||
}
|
||||
const formattedWorlds: SelectBoxProps[] = response.worlds.map(
|
||||
(world: WorldProps): SelectBoxProps => ({
|
||||
label: world.name,
|
||||
value: world.id.toString(),
|
||||
}),
|
||||
);
|
||||
setWorldsSelector(formattedWorlds);
|
||||
}
|
||||
const formattedWorlds: SelectBoxProps[] = response.worlds.map(
|
||||
(world: WorldProps): SelectBoxProps => ({
|
||||
label: world.name,
|
||||
value: world.id.toString(),
|
||||
}),
|
||||
);
|
||||
setWorldsSelector(formattedWorlds);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
@@ -134,38 +212,57 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handleAddNewWorld(): Promise<void> {
|
||||
if (newWorldName.trim() === '') {
|
||||
errorMessage(t("worldSetting.newWorldNameError"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let worldId: string;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
worldId = await window.electron.invoke<string>('db:book:world:add', {
|
||||
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;
|
||||
}
|
||||
} else if (isCurrentlyOffline() || book?.localBook) {
|
||||
// Book mode: offline/local
|
||||
newWorldId = await window.electron.invoke<string>('db:book:world:add', {
|
||||
worldName: newWorldName,
|
||||
bookId: bookId,
|
||||
bookId: currentEntityId,
|
||||
});
|
||||
if (!newWorldId) {
|
||||
errorMessage(t("worldSetting.addWorldError"));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
worldId = await System.authPostToServer<string>('book/world/add', {
|
||||
// Book mode: online
|
||||
newWorldId = await System.authPostToServer<string>('book/world/add', {
|
||||
worldName: newWorldName,
|
||||
bookId: bookId,
|
||||
bookId: currentEntityId,
|
||||
}, session.accessToken, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) {
|
||||
if (!newWorldId) {
|
||||
errorMessage(t("worldSetting.addWorldError"));
|
||||
return;
|
||||
}
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) {
|
||||
addToQueue('db:book:world:add', {
|
||||
worldName: newWorldName,
|
||||
worldId,
|
||||
bookId: bookId,
|
||||
worldId: newWorldId,
|
||||
bookId: currentEntityId,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!worldId) {
|
||||
errorMessage(t("worldSetting.addWorldError"));
|
||||
return;
|
||||
}
|
||||
const newWorldId: string = worldId;
|
||||
const newWorld: WorldProps = {
|
||||
id: newWorldId,
|
||||
name: newWorldName,
|
||||
@@ -202,23 +299,44 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateWorld(): Promise<void> {
|
||||
try {
|
||||
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);
|
||||
async function handleUpdateWorld(): Promise<void> {
|
||||
if (worlds.length === 0) return;
|
||||
try {
|
||||
const currentWorld: WorldProps = worlds[selectedWorldIndex];
|
||||
let response: boolean;
|
||||
|
||||
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;
|
||||
@@ -232,16 +350,130 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleWorldSelect(worldId: string): void {
|
||||
const index: number = worlds.findIndex((world: WorldProps): boolean => world.id === worldId);
|
||||
if (index !== -1) {
|
||||
setSelectedWorldIndex(index);
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputChange(value: string, field: keyof WorldProps) {
|
||||
const updatedWorlds = [...worlds] as WorldProps[];
|
||||
(updatedWorlds[selectedWorldIndex][field] as string) = value;
|
||||
setWorlds(updatedWorlds);
|
||||
}
|
||||
|
||||
|
||||
async function handleExportToSeries(): Promise<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,37 +538,78 @@ 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={
|
||||
<TextInput
|
||||
value={worlds[selectedWorldIndex].name}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedWorlds: WorldProps[] = [...worlds];
|
||||
updatedWorlds[selectedWorldIndex].name = e.target.value
|
||||
setWorlds(updatedWorlds);
|
||||
<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);
|
||||
}
|
||||
}}
|
||||
placeholder={t("worldSetting.worldNamePlaceholder")}
|
||||
/>
|
||||
onSyncComplete={getSeriesWorlds}
|
||||
>
|
||||
<TextInput
|
||||
value={worlds[selectedWorldIndex].name}
|
||||
setValue={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedWorlds: WorldProps[] = [...worlds];
|
||||
updatedWorlds[selectedWorldIndex].name = e.target.value
|
||||
setWorlds(updatedWorlds);
|
||||
}}
|
||||
placeholder={t("worldSetting.worldNamePlaceholder")}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<InputField
|
||||
fieldName={t("worldSetting.worldHistory")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={worlds[selectedWorldIndex].history || ''}
|
||||
setValue={(e) => handleInputChange(e.target.value, 'history')}
|
||||
placeholder={t("worldSetting.worldHistoryPlaceholder")}
|
||||
/>
|
||||
<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={
|
||||
<TexteAreaInput
|
||||
value={worlds[selectedWorldIndex].politics || ''}
|
||||
setValue={(e) => handleInputChange(e.target.value, 'politics')}
|
||||
placeholder={t("worldSetting.politicsPlaceholder")}
|
||||
/>
|
||||
<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={
|
||||
<TexteAreaInput
|
||||
value={worlds[selectedWorldIndex].economy || ''}
|
||||
setValue={(e) => handleInputChange(e.target.value, 'economy')}
|
||||
placeholder={t("worldSetting.economyPlaceholder")}
|
||||
/>
|
||||
<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={
|
||||
<TexteAreaInput
|
||||
value={worlds[selectedWorldIndex].religion || ''}
|
||||
setValue={(e) => handleInputChange(e.target.value, 'religion')}
|
||||
placeholder={t("worldSetting.religionPlaceholder")}
|
||||
/>
|
||||
<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={
|
||||
<TexteAreaInput
|
||||
value={worlds[selectedWorldIndex].languages || ''}
|
||||
setValue={(e) => handleInputChange(e.target.value, 'languages')}
|
||||
placeholder={t("worldSetting.languagesPlaceholder")}
|
||||
/>
|
||||
<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>
|
||||
@@ -417,4 +752,4 @@ export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: a
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(WorldSetting);
|
||||
export default forwardRef(WorldSetting);
|
||||
|
||||
226
components/book/settings/world/editor/WorldEditor.tsx
Normal file
226
components/book/settings/world/editor/WorldEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
components/book/settings/world/editor/WorldEditorDetail.tsx
Normal file
87
components/book/settings/world/editor/WorldEditorDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
237
components/book/settings/world/editor/WorldEditorEdit.tsx
Normal file
237
components/book/settings/world/editor/WorldEditorEdit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
components/book/settings/world/editor/WorldEditorList.tsx
Normal file
107
components/book/settings/world/editor/WorldEditorList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
components/book/settings/world/settings/WorldSettings.tsx
Normal file
200
components/book/settings/world/settings/WorldSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
components/book/settings/world/settings/WorldSettingsDetail.tsx
Normal file
128
components/book/settings/world/settings/WorldSettingsDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
240
components/book/settings/world/settings/WorldSettingsEdit.tsx
Normal file
240
components/book/settings/world/settings/WorldSettingsEdit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
components/book/settings/world/settings/WorldSettingsList.tsx
Normal file
127
components/book/settings/world/settings/WorldSettingsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)) {
|
||||
setValue(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
|
||||
|
||||
88
components/form/SeriesImportSelector.tsx
Normal file
88
components/form/SeriesImportSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
components/form/SyncFieldWrapper.tsx
Normal file
152
components/form/SyncFieldWrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)}/>
|
||||
|
||||
@@ -1,54 +1,87 @@
|
||||
'use client'
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faFeather, faGlobe, faHatWizard, faInfoCircle, faMapMarkerAlt, faUsers} from "@fortawesome/free-solid-svg-icons";
|
||||
import {RefObject, useContext, useEffect, useRef, useState} from "react";
|
||||
import {
|
||||
faFeather,
|
||||
faGlobe,
|
||||
faHatWizard,
|
||||
faInfoCircle,
|
||||
faMapMarkerAlt,
|
||||
faUsers
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, {lazy, Suspense, useContext, useEffect, useState} from "react";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import {PanelComponent} from "@/lib/models/Editor";
|
||||
import PanelHeader from "@/components/PanelHeader";
|
||||
import AboutEditors from "@/components/rightbar/AboutERitors";
|
||||
import {faDiscord, faFacebook} from "@fortawesome/free-brands-svg-icons";
|
||||
import WorldSetting from "@/components/book/settings/world/WorldSetting";
|
||||
import LocationComponent from "@/components/book/settings/locations/LocationComponent";
|
||||
import CharacterComponent from "@/components/book/settings/characters/CharacterComponent";
|
||||
import SpellComponent from "@/components/book/settings/spells/SpellComponent";
|
||||
import QuillSense from "@/components/quillsense/QuillSenseComponent";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {faSpinner} from "@fortawesome/free-solid-svg-icons";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
|
||||
export default function ComposerRightBar() {
|
||||
// Lazy loaded Editor components
|
||||
const WorldEditor = lazy(function () {
|
||||
return import('@/components/book/settings/world/editor/WorldEditor');
|
||||
});
|
||||
const LocationEditor = lazy(function () {
|
||||
return import('@/components/book/settings/locations/editor/LocationEditor');
|
||||
});
|
||||
const CharacterEditor = lazy(function () {
|
||||
return import('@/components/book/settings/characters/editor/CharacterEditor');
|
||||
});
|
||||
const SpellEditor = lazy(function () {
|
||||
return import('@/components/book/settings/spells/editor/SpellEditor');
|
||||
});
|
||||
|
||||
function LoadingSpinner(): React.JSX.Element {
|
||||
return (
|
||||
<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) {
|
||||
@@ -60,15 +93,7 @@ export default function ComposerRightBar() {
|
||||
setPanelHidden(true);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(():void => {
|
||||
if (!book){
|
||||
setCurrentPanel(undefined);
|
||||
setPanelHidden(false);
|
||||
return;
|
||||
}
|
||||
}, [book]);
|
||||
|
||||
|
||||
const editorComponents: PanelComponent[] = [
|
||||
{
|
||||
id: 1,
|
||||
@@ -105,15 +130,8 @@ export default function ComposerRightBar() {
|
||||
badge: t("composerRightBar.editorComponents.spells.badge"),
|
||||
icon: faHatWizard
|
||||
},
|
||||
/*{
|
||||
id: 6,
|
||||
title: t("composerRightBar.editorComponents.items.title"),
|
||||
description: t("composerRightBar.editorComponents.items.description"),
|
||||
badge: t("composerRightBar.editorComponents.items.badge"),
|
||||
icon: faCube,
|
||||
}*/
|
||||
]
|
||||
|
||||
];
|
||||
|
||||
const homeComponents: PanelComponent[] = [
|
||||
{
|
||||
id: 1,
|
||||
@@ -121,7 +139,9 @@ export default function ComposerRightBar() {
|
||||
description: t("composerRightBar.homeComponents.about.description"),
|
||||
badge: t("composerRightBar.homeComponents.about.badge"),
|
||||
icon: faInfoCircle,
|
||||
action: () => setShowAbout(true)
|
||||
action: function (): void {
|
||||
setShowAbout(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
@@ -129,7 +149,9 @@ export default function ComposerRightBar() {
|
||||
description: t("composerRightBar.homeComponents.facebook.description"),
|
||||
badge: t("composerRightBar.homeComponents.facebook.badge"),
|
||||
icon: faFacebook,
|
||||
action: ():Promise<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,124 +159,115 @@ export default function ComposerRightBar() {
|
||||
description: t("composerRightBar.homeComponents.discord.description"),
|
||||
badge: t("composerRightBar.homeComponents.discord.badge"),
|
||||
icon: faDiscord,
|
||||
action: () => window.electron.openExternal('https://discord.gg/CHXRPvmaXm')
|
||||
action: function (): Promise<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;
|
||||
}
|
||||
return book === null;
|
||||
}
|
||||
|
||||
|
||||
useEffect(function (): void {
|
||||
if (!book) {
|
||||
setCurrentPanel(undefined);
|
||||
setPanelHidden(false);
|
||||
}
|
||||
}, [book]);
|
||||
|
||||
async function handleClosePanel(): Promise<void> {
|
||||
setPanelHidden(!panelHidden);
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="right-panel-container" className="flex transition-all duration-300">
|
||||
{panelHidden && (
|
||||
<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
|
||||
if (component.id === 1) {
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
return false;
|
||||
}
|
||||
if (book?.quillsenseEnabled === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Filter Worlds if tools.worlds is disabled
|
||||
if (component.id === 2 && !book?.tools?.worlds) {
|
||||
{book ? editorComponents.filter(function (component: PanelComponent): boolean {
|
||||
// Masquer QuillSense (id=1) si offline, livre local ou desactive pour ce livre
|
||||
if (component.id === 1) {
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
return false;
|
||||
}
|
||||
// Filter Locations if tools.locations is disabled
|
||||
if (component.id === 3 && !book?.tools?.locations) {
|
||||
if (book.quillsenseEnabled === false) {
|
||||
return false;
|
||||
}
|
||||
// Filter Characters if tools.characters is disabled
|
||||
if (component.id === 4 && !book?.tools?.characters) {
|
||||
return false;
|
||||
}
|
||||
// Filter Spells if tools.spells is disabled
|
||||
if (component.id === 5 && !book?.tools?.spells) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((component: PanelComponent) => (
|
||||
<button
|
||||
key={component.id}
|
||||
disabled={disabled(component.id)}
|
||||
onClick={() => {
|
||||
togglePanel(component);
|
||||
setCurrentPanel(component);
|
||||
}}
|
||||
className={`group relative p-3 rounded-xl transition-all duration-200 ${
|
||||
disabled(component.id)
|
||||
? 'bg-secondary/10 text-muted cursor-not-allowed opacity-40'
|
||||
: 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'}/>
|
||||
{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>
|
||||
)}
|
||||
</button>
|
||||
)) : homeComponents.map((component: PanelComponent) => (
|
||||
<button
|
||||
key={component.id}
|
||||
onClick={component.action ?? (() => {
|
||||
})}
|
||||
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'}/>
|
||||
</button>
|
||||
))}
|
||||
}
|
||||
// Masquer Worlds (id=2) si desactive pour ce livre
|
||||
if (component.id === 2 && !book?.tools?.worlds) {
|
||||
return false;
|
||||
}
|
||||
// Masquer Locations (id=3) si desactive pour ce livre
|
||||
if (component.id === 3 && !book?.tools?.locations) {
|
||||
return false;
|
||||
}
|
||||
// Masquer Characters (id=4) si desactive pour ce livre
|
||||
if (component.id === 4 && !book?.tools?.characters) {
|
||||
return false;
|
||||
}
|
||||
// Masquer Spells (id=5) si desactive pour ce livre
|
||||
if (component.id === 5 && !book?.tools?.spells) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).map(function (component: PanelComponent) {
|
||||
return (
|
||||
<button
|
||||
key={component.id}
|
||||
disabled={disabled(component.id)}
|
||||
onClick={function (): void {
|
||||
togglePanel(component);
|
||||
setCurrentPanel(component);
|
||||
}}
|
||||
className={`group relative p-3 rounded-xl transition-all duration-200 ${
|
||||
disabled(component.id)
|
||||
? 'bg-secondary/10 text-muted cursor-not-allowed opacity-40'
|
||||
: 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-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>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}) : homeComponents.map(function (component: PanelComponent) {
|
||||
return (
|
||||
<button
|
||||
key={component.id}
|
||||
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-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>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
258
components/series/AddNewSeriesForm.tsx
Normal file
258
components/series/AddNewSeriesForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
components/series/SeriesCard.tsx
Normal file
112
components/series/SeriesCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
components/series/SeriesSetting.tsx
Normal file
37
components/series/SeriesSetting.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
components/series/SeriesSettingOption.tsx
Normal file
121
components/series/SeriesSettingOption.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
177
components/series/SeriesSettingSidebar.tsx
Normal file
177
components/series/SeriesSettingSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
components/series/settings/BasicSeriesInformation.tsx
Normal file
151
components/series/settings/BasicSeriesInformation.tsx
Normal 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);
|
||||
372
components/series/settings/SeriesBooksManager.tsx
Normal file
372
components/series/settings/SeriesBooksManager.tsx
Normal 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);
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type {Context, Dispatch, JSX, ReactNode, SetStateAction} from 'react';
|
||||
import {createContext, useCallback, useState} from 'react';
|
||||
import React, {createContext, useCallback, useState} from 'react';
|
||||
import AlertStack from '@/components/AlertStack';
|
||||
import {cleanErrorMessage} from '@/lib/errors';
|
||||
|
||||
@@ -38,10 +38,11 @@ export const AlertContext: Context<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 => {
|
||||
|
||||
@@ -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
11
context/SeriesContext.ts
Normal 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
|
||||
});
|
||||
42
context/SeriesSyncContext.ts
Normal file
42
context/SeriesSyncContext.ts
Normal 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: []
|
||||
});
|
||||
@@ -5,6 +5,7 @@ export interface WorldContextProps {
|
||||
worlds: WorldProps[];
|
||||
setWorlds: Dispatch<SetStateAction<WorldProps[]>>;
|
||||
selectedWorldIndex: number;
|
||||
isSeriesMode?: boolean;
|
||||
}
|
||||
|
||||
export const WorldContext = createContext<WorldContextProps>({} as WorldContextProps);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
41
electron/database/models/RemovedItem.ts
Normal file
41
electron/database/models/RemovedItem.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
158
electron/database/repositories/removed-items.repository.ts
Normal file
158
electron/database/repositories/removed-items.repository.ts
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user