Remove CharacterComponent and CharacterDetail components

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

View File

@@ -170,7 +170,10 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
actSummaries: [],
guideLine: null,
aiGuideLine: null,
bookTools: null
bookTools: null,
seriesId: null,
spells: [],
spellTags: []
}]);
}
else {
@@ -190,7 +193,10 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
actSummaries: [],
guideLine: null,
aiGuideLine: null,
bookTools: null
bookTools: null,
seriesId: null,
spells: [],
spellTags: []
}]);
}

View File

@@ -18,8 +18,8 @@ export default function BookCard({book, onClickCallback, index, syncStatus}: Boo
const t = useTranslations();
return (
<div
className="group bg-tertiary/90 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 h-full border border-secondary/50 hover:border-primary/50 flex flex-col hover:scale-105">
<div className="relative w-full h-[400px] sm:h-32 md:h-48 lg:h-64 xl:h-80 flex-shrink-0 overflow-hidden">
className="group bg-tertiary/90 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 h-full border border-secondary/50 hover:border-primary/50 flex flex-col">
<div className="relative w-full aspect-[2/3] flex-shrink-0 overflow-hidden rounded-t-2xl">
<button onClick={(): void => onClickCallback(book.bookId)} className="w-full h-full text-left block" type="button">
{book.coverImage ? (
<img

View File

@@ -15,7 +15,18 @@ import {useTranslations} from "next-intl";
import {LangContext, LangContextProps} from "@/context/LangContext";
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
import {BooksSyncContext, BooksSyncContextProps, SyncType} from "@/context/BooksSyncContext";
import {SeriesSyncContext, SeriesSyncContextProps, SeriesSyncType} from "@/context/SeriesSyncContext";
import {BookSyncCompare, SyncedBook} from "@/lib/models/SyncedBook";
import {SeriesSyncCompare, SyncedSeries} from "@/lib/models/SyncedSeries";
import {SeriesListItemProps} from "@/lib/models/Series";
import SeriesCard, {SeriesCardProps} from "@/components/series/SeriesCard";
import SeriesSetting from "@/components/series/SeriesSetting";
interface CategoryItem {
type: 'book' | 'series';
book?: BookProps;
series?: SeriesCardProps;
}
export default function BookList() {
const {session, setSession} = useContext(SessionContext);
@@ -23,13 +34,27 @@ export default function BookList() {
const {errorMessage} = useContext(AlertContext);
const {setBook} = useContext(BookContext);
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext)
const {isCurrentlyOffline, offlineMode} = useContext<OfflineContextType>(OfflineContext)
const {booksToSyncFromServer, booksToSyncToServer, serverOnlyBooks, localOnlyBooks} = useContext<BooksSyncContextProps>(BooksSyncContext)
const {lang} = useContext<LangContextProps>(LangContext);
const {isCurrentlyOffline, offlineMode} = useContext<OfflineContextType>(OfflineContext);
const {
booksToSyncFromServer,
booksToSyncToServer,
serverOnlyBooks,
localOnlyBooks,
serverSyncedBooks
} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {
seriesToSyncFromServer,
seriesToSyncToServer,
serverOnlySeries,
localOnlySeries
} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
const [searchQuery, setSearchQuery] = useState<string>('');
const [groupedBooks, setGroupedBooks] = useState<Record<string, BookProps[]>>({});
const [groupedItems, setGroupedItems] = useState<Record<string, CategoryItem[]>>({});
const [isLoadingBooks, setIsLoadingBooks] = useState<boolean>(true);
const [showSeriesSettingId, setShowSeriesSettingId] = useState<string | null>(null);
const [isLocalSeries, setIsLocalSeries] = useState<boolean>(false);
const [bookGuide, setBookGuide] = useState<boolean>(false);
@@ -79,21 +104,21 @@ export default function BookList() {
</div>
),
},
]
];
useEffect((): void => {
if (groupedBooks && Object.keys(groupedBooks).length > 0 && User.guideTourDone(session.user?.guideTour || [], 'new-first-book')) {
if (groupedItems && Object.keys(groupedItems).length > 0 && User.guideTourDone(session.user?.guideTour || [], 'new-first-book')) {
setBookGuide(true);
}
}, [groupedBooks]);
}, [groupedItems]);
useEffect((): void => {
const shouldFetchBooks:boolean|"" =
const shouldFetch: boolean | "" =
(session.isConnected || accessToken) &&
(!isCurrentlyOffline() || offlineMode.isDatabaseInitialized);
if (shouldFetchBooks) {
getBooks().then();
if (shouldFetch) {
loadBooksAndSeries().then();
}
}, [
session.isConnected,
@@ -103,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>
);
}
}

View File

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

View File

@@ -1,48 +1,63 @@
import BasicInformationSetting from "./BasicInformationSetting";
import GuideLineSetting from "./guide-line/GuideLineSetting";
import StorySetting from "./story/StorySetting";
import WorldSetting from "@/components/book/settings/world/WorldSetting";
import {faPen, faSave} from "@fortawesome/free-solid-svg-icons";
import {RefObject, useRef} from "react";
import PanelHeader from "@/components/PanelHeader";
import LocationComponent from "@/components/book/settings/locations/LocationComponent";
import CharacterComponent from "@/components/book/settings/characters/CharacterComponent";
import SpellComponent from "@/components/book/settings/spells/SpellComponent";
import QuillSenseSetting from "@/components/book/settings/quillsense/QuillSenseSetting";
import {useTranslations} from "next-intl"; // Ajouté pour la traduction
'use client'
import React, {lazy, Suspense, useRef} from 'react';
import {faPen, faSave} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faSpinner} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
import PanelHeader from '@/components/PanelHeader';
export default function BookSettingOption(
{
setting,
}: {
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>
)
}
);
}

View File

@@ -11,9 +11,10 @@ import {
faUser,
faWandMagicSparkles
} from "@fortawesome/free-solid-svg-icons";
import {Dispatch, SetStateAction} from "react";
import {Dispatch, SetStateAction, useContext} from "react";
import {IconDefinition} from "@fortawesome/fontawesome-svg-core";
import {useTranslations} from "next-intl";
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
interface BookSettingOption {
id: string;
@@ -30,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)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,206 @@
'use client';
import React, {useCallback, useContext, useMemo, useState} from 'react';
import {useCharacters, UseCharactersConfig} from '@/hooks/settings/useCharacters';
import {useTranslations} from 'next-intl';
import {CharacterProps} from '@/lib/models/Character';
import {SeriesCharacterProps} from '@/lib/models/Series';
import {BookContext} from '@/context/BookContext';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons';
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
import InputField from '@/components/form/InputField';
import ToggleSwitch from '@/components/form/ToggleSwitch';
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
import AlertBox from '@/components/AlertBox';
import CharacterEditorList from './CharacterEditorList';
import CharacterEditorDetail from './CharacterEditorDetail';
import CharacterEditorEdit from './CharacterEditorEdit';
/**
* CharacterEditor - Orchestrateur pour ComposerRightBar
* Mêmes fonctionnalités que CharacterSettings, layout condensé
*/
export default function CharacterEditor(): React.JSX.Element {
const t = useTranslations();
const {book} = useContext(BookContext);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
const config: UseCharactersConfig = useMemo(function (): UseCharactersConfig {
return {
entityType: 'book',
entityId: book?.bookId || '',
};
}, [book?.bookId]);
const {
characters,
seriesCharacters,
selectedCharacter,
toolEnabled,
isLoading,
bookSeriesId,
viewMode,
saveCharacter,
deleteCharacter,
updateCharacterField,
addAttribute,
removeAttribute,
toggleTool,
importFromSeries,
exportToSeries,
refreshSeriesCharacters,
setSelectedCharacter,
enterDetailMode,
enterEditMode,
exitEditMode,
backToList,
addNewCharacter,
} = useCharacters(config);
const availableSeriesCharacters = useMemo(function (): SeriesCharacterProps[] {
return seriesCharacters.filter(function (sc: SeriesCharacterProps): boolean {
return !characters.some(function (c: CharacterProps): boolean {
return c.seriesCharacterId === sc.id;
});
});
}, [seriesCharacters, characters]);
const handleCharacterChange = useCallback(function (key: keyof CharacterProps, value: string | number | null): void {
updateCharacterField(key, value);
}, [updateCharacterField]);
async function handleSave(): Promise<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
async function handleDelete(): Promise<void> {
if (selectedCharacter?.id) {
await deleteCharacter(selectedCharacter.id);
setShowDeleteConfirm(false);
backToList();
}
}
function getSeriesCharacterForSelected(): SeriesCharacterProps | null {
if (!selectedCharacter?.seriesCharacterId) return null;
return seriesCharacters.find(function (sc: SeriesCharacterProps): boolean {
return sc.id === selectedCharacter.seriesCharacterId;
}) || null;
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin"/>
</div>
);
}
const isNew: boolean = selectedCharacter?.id === null;
const canExport: boolean = Boolean(bookSeriesId && selectedCharacter?.id && !selectedCharacter.seriesCharacterId);
return (
<div className="flex flex-col h-full">
<ToolDetailHeader
title={selectedCharacter?.name || ''}
defaultTitle={t('characterDetail.newCharacter')}
viewMode={viewMode}
isNew={isNew}
onBack={backToList}
onEdit={enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onDelete={function (): void { setShowDeleteConfirm(true); }}
onExport={canExport ? exportToSeries : undefined}
showExport={canExport}
showDelete={Boolean(selectedCharacter?.id)}
/>
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-3 p-2">
{/* Toggle tool */}
<div className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('characterComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
</div>
{toolEnabled && (
<>
{/* Import from series */}
{bookSeriesId && availableSeriesCharacters.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesCharacters.map(function (sc: SeriesCharacterProps) {
return {
id: sc.id,
name: `${sc.name}${sc.lastName ? ' ' + sc.lastName : ''}`
};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
)}
<CharacterEditorList
characters={characters}
onCharacterClick={enterDetailMode}
onAddCharacter={addNewCharacter}
/>
</>
)}
</div>
)}
{viewMode === 'detail' && selectedCharacter && (
<div className="p-4">
<CharacterEditorDetail
character={selectedCharacter}
seriesCharacter={getSeriesCharacterForSelected()}
/>
</div>
)}
{viewMode === 'edit' && selectedCharacter && (
<div className="p-4">
<CharacterEditorEdit
character={selectedCharacter}
setCharacter={setSelectedCharacter}
onCharacterChange={handleCharacterChange}
onAddAttribute={addAttribute}
onRemoveAttribute={removeAttribute}
seriesCharacter={getSeriesCharacterForSelected()}
onSyncComplete={refreshSeriesCharacters}
/>
</div>
)}
</div>
{showDeleteConfirm && selectedCharacter?.id && (
<AlertBox
title={t('characterDetail.deleteTitle')}
message={t('characterDetail.deleteMessage', {name: selectedCharacter.name})}
type="danger"
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={handleDelete}
onCancel={function (): void { setShowDeleteConfirm(false); }}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import React, {useContext, useEffect} from 'react';
import {
Attribute,
CharacterAttribute,
characterCategories,
CharacterProps
} from '@/lib/models/Character';
import {SeriesCharacterProps} from '@/lib/models/Series';
import {useTranslations} from 'next-intl';
import {SessionContext} from '@/context/SessionContext';
import {AlertContext} from '@/context/AlertContext';
import {LangContext} from '@/context/LangContext';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {BookContext} from '@/context/BookContext';
import System from '@/lib/models/System';
type AttributeResponse = { type: string; values: Attribute[] }[];
interface CharacterEditorDetailProps {
character: CharacterProps;
seriesCharacter?: SeriesCharacterProps | null;
onLoadAttributes?: (attributes: CharacterAttribute) => void;
}
/**
* CharacterEditorDetail - Version sidebar lecture seule
* Layout linéaire simple, juste les infos essentielles empilées
* PAS de CollapsableArea, PAS de grids
*/
export default function CharacterEditorDetail({
character,
seriesCharacter,
onLoadAttributes,
}: CharacterEditorDetailProps): React.JSX.Element {
const t = useTranslations();
const {lang} = useContext(LangContext);
const {session} = useContext(SessionContext);
const {errorMessage} = useContext(AlertContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {book} = useContext(BookContext);
useEffect(function (): void {
if (character?.id !== null) {
getAttributes().then();
}
}, [character?.id]);
async function getAttributes(): Promise<void> {
try {
let response: AttributeResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else if (book?.localBook) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else {
response = await System.authGetQueryToServer<AttributeResponse>(
'character/attribute',
session.accessToken,
lang,
{characterId: character?.id}
);
}
if (response && onLoadAttributes) {
const attributes: CharacterAttribute = {};
response.forEach(function (item: { type: string; values: Attribute[] }): void {
attributes[item.type] = item.values;
});
onLoadAttributes(attributes);
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
}
}
}
function renderField(label: string, value: string | number | null | undefined): React.JSX.Element | null {
if (!value) return null;
return (
<div className="mb-3">
<span className="text-text-secondary text-xs block mb-1">{label}</span>
<p className="text-text-primary text-sm">{value}</p>
</div>
);
}
function getCategoryLabel(category: string | null | undefined): string {
if (!category) return '';
const found = characterCategories.find(function (c): boolean { return c.value === category; });
return found ? t(found.label) : category;
}
return (
<div>
{/* Image du personnage - version compacte */}
{character.image && (
<div className="flex justify-center mb-4">
<div className="w-16 h-16 rounded-full border-2 border-primary overflow-hidden">
<img
src={character.image}
alt={character.name}
className="w-full h-full object-cover"
/>
</div>
</div>
)}
<h3 className="text-text-primary font-semibold text-base mb-4">
{character.name} {character.lastName}
</h3>
{renderField(t('characterDetail.role'), getCategoryLabel(character.category))}
{renderField(t('characterDetail.title'), character.title)}
{renderField(t('characterDetail.gender'), character.gender)}
{renderField(t('characterDetail.age'), character.age)}
{renderField(t('characterDetail.biography'), character.biography)}
{renderField(t('characterDetail.roleFull'), character.role)}
</div>
);
}

View File

@@ -0,0 +1,395 @@
'use client';
import React, {useContext, useEffect, useMemo, useState} from 'react';
import {
advancedCharacterElements,
Attribute,
basicCharacterElements,
CharacterAttribute,
characterCategories,
CharacterElement,
CharacterProps,
characterStatus
} from '@/lib/models/Character';
import {SeriesCharacterProps} from '@/lib/models/Series';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import TexteAreaInput from '@/components/form/TexteAreaInput';
import NumberInput from '@/components/form/NumberInput';
import SelectBox from '@/components/form/SelectBox';
import CharacterSectionElement from '@/components/book/settings/characters/CharacterSectionElement';
import SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faSliders} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
import {SessionContext} from '@/context/SessionContext';
import {AlertContext} from '@/context/AlertContext';
import {LangContext} from '@/context/LangContext';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {BookContext} from '@/context/BookContext';
import System from '@/lib/models/System';
import {Dispatch, SetStateAction} from 'react';
type AttributeResponse = { type: string; values: Attribute[] }[];
interface CharacterEditorEditProps {
character: CharacterProps;
setCharacter: Dispatch<SetStateAction<CharacterProps | null>>;
onCharacterChange: (key: keyof CharacterProps, value: string | number | null) => void;
onAddAttribute: (section: keyof CharacterProps, attr: Attribute) => Promise<void>;
onRemoveAttribute: (section: keyof CharacterProps, idx: number, id: string) => Promise<void>;
seriesCharacter?: SeriesCharacterProps | null;
onSyncComplete?: () => void;
}
/**
* CharacterEditorEdit - Version sidebar édition
* Mêmes fonctionnalités que CharacterSettingsEdit, layout linéaire
*/
export default function CharacterEditorEdit({
character,
setCharacter,
onCharacterChange,
onAddAttribute,
onRemoveAttribute,
seriesCharacter,
onSyncComplete,
}: CharacterEditorEditProps): React.JSX.Element {
const t = useTranslations();
const {lang} = useContext(LangContext);
const {session} = useContext(SessionContext);
const {errorMessage} = useContext(AlertContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {book} = useContext(BookContext);
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
// Traduire les données des SelectBox
const translatedCharacterCategories = useMemo(() =>
characterCategories.map((item) => ({
...item,
label: t(item.label)
})), [t]);
const translatedCharacterStatus = useMemo(() =>
characterStatus.map((item) => ({
...item,
label: t(item.label)
})), [t]);
useEffect(function (): void {
if (character?.id !== null) {
getAttributes().then();
}
}, [character?.id]);
async function getAttributes(): Promise<void> {
try {
let response: AttributeResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else if (book?.localBook) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else {
response = await System.authGetQueryToServer<AttributeResponse>(
'character/attribute',
session.accessToken,
lang,
{characterId: character?.id}
);
}
if (response) {
const attributes: CharacterAttribute = {};
response.forEach(function (item: { type: string; values: Attribute[] }): void {
attributes[item.type] = item.values;
});
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
if (!prev) return null;
return {
...prev,
physical: attributes.physical ?? [],
psychological: attributes.psychological ?? [],
relations: attributes.relations ?? [],
skills: attributes.skills ?? [],
weaknesses: attributes.weaknesses ?? [],
strengths: attributes.strengths ?? [],
goals: attributes.goals ?? [],
motivations: attributes.motivations ?? [],
arc: attributes.arc ?? [],
secrets: attributes.secrets ?? [],
fears: attributes.fears ?? [],
flaws: attributes.flaws ?? [],
beliefs: attributes.beliefs ?? [],
conflicts: attributes.conflicts ?? [],
quotes: attributes.quotes ?? [],
distinguishingMarks: attributes.distinguishingMarks ?? [],
items: attributes.items ?? [],
affiliations: attributes.affiliations ?? [],
};
});
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
}
}
}
return (
<div className="space-y-4">
{/* Informations de base */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.basicInfo')}</h4>
<div className="space-y-3">
<InputField
fieldName={t('characterDetail.name')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.name || ''}
currentValue={character.name || ''}
bookElementId={character.id || ''}
field="name"
elementType="character"
onDownload={function (): void { onCharacterChange('name', seriesCharacter?.name || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.name || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('name', e.target.value);
}}
placeholder={t('characterDetail.namePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.lastName')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.lastName || ''}
currentValue={character.lastName || ''}
bookElementId={character.id || ''}
field="lastName"
elementType="character"
onDownload={function (): void { onCharacterChange('lastName', seriesCharacter?.lastName || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.lastName || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('lastName', e.target.value);
}}
placeholder={t('characterDetail.lastNamePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.role')}
input={
<SelectBox
defaultValue={character.category || 'none'}
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, category: e.target.value as CharacterProps['category']} : prev;
});
}}
data={translatedCharacterCategories}
/>
}
/>
<InputField
fieldName={t('characterDetail.gender')}
input={
<TextInput
value={character.gender || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('gender', e.target.value);
}}
placeholder={t('characterDetail.genderPlaceholder')}
/>
}
/>
<InputField
fieldName={t('characterDetail.age')}
input={
<NumberInput
value={character.age ?? null}
onValueChange={function (val: number | null): void {
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, age: val} : prev;
});
}}
placeholder={t('characterDetail.agePlaceholder')}
/>
}
/>
</div>
</div>
{/* Histoire */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.historySection')}</h4>
<div className="space-y-3">
<InputField
fieldName={t('characterDetail.biography')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.biography || ''}
currentValue={character.biography || ''}
bookElementId={character.id || ''}
field="biography"
elementType="character"
onDownload={function (): void { onCharacterChange('biography', seriesCharacter?.biography || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={character.biography || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('biography', e.target.value);
}}
placeholder={t('characterDetail.biographyPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.roleFull')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.role || ''}
currentValue={character.role || ''}
bookElementId={character.id || ''}
field="role"
elementType="character"
onDownload={function (): void { onCharacterChange('role', seriesCharacter?.role || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={character.role || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('role', e.target.value);
}}
placeholder={t('characterDetail.roleFullPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
</div>
</div>
{/* Attributs de base */}
{basicCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element {
return (
<CharacterSectionElement
key={`basic-${index}`}
title={item.title}
section={item.section}
placeholder={item.placeholder}
icon={item.icon}
selectedCharacter={character}
setSelectedCharacter={setCharacter}
handleAddElement={onAddAttribute}
handleRemoveElement={onRemoveAttribute}
/>
);
})}
{/* Toggle Mode Avancé */}
<div className="flex items-center justify-between p-3 bg-secondary/30 rounded-lg border border-secondary/50">
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faSliders} className="text-primary w-4 h-4"/>
<span className="text-text-primary font-medium text-sm">{t('characterDetail.advancedMode')}</span>
</div>
<button
onClick={function (): void { setShowAdvanced(!showAdvanced); }}
className={`px-3 py-1.5 rounded-lg text-sm transition-all duration-200 ${
showAdvanced
? 'bg-primary text-white'
: 'bg-secondary/50 text-text-primary hover:bg-secondary'
}`}
>
{showAdvanced ? t('characterDetail.hideAdvanced') : t('characterDetail.showAdvanced')}
</button>
</div>
{/* Sections avancées */}
{showAdvanced && (
<>
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.identitySection')}</h4>
<div className="space-y-3">
<InputField
fieldName={t('characterDetail.species')}
input={
<TextInput
value={character.species || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('species', e.target.value);
}}
placeholder={t('characterDetail.speciesPlaceholder')}
/>
}
/>
<InputField
fieldName={t('characterDetail.status')}
input={
<SelectBox
defaultValue={character.status || 'alive'}
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, status: e.target.value as CharacterProps['status']} : prev;
});
}}
data={translatedCharacterStatus}
/>
}
/>
</div>
</div>
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.authorSection')}</h4>
<InputField
fieldName={t('characterDetail.notes')}
input={
<TexteAreaInput
value={character.notes || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('notes', e.target.value);
}}
placeholder={t('characterDetail.notesPlaceholder')}
/>
}
/>
</div>
{advancedCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element {
return (
<CharacterSectionElement
key={`advanced-${index}`}
title={item.title}
section={item.section}
placeholder={item.placeholder}
icon={item.icon}
selectedCharacter={character}
setSelectedCharacter={setCharacter}
handleAddElement={onAddAttribute}
handleRemoveElement={onRemoveAttribute}
/>
);
})}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,116 @@
'use client';
import React, {useState} from 'react';
import {CharacterProps} from '@/lib/models/Character';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faChevronRight, faPlus, faUser} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
interface CharacterEditorListProps {
characters: CharacterProps[];
onCharacterClick: (character: CharacterProps) => void;
onAddCharacter: () => void;
}
/**
* CharacterEditorList - Liste des personnages pour ComposerRightBar
* Version compacte sans groupage par catégorie
* PAS de scroll interne (géré par parent ComposerRightBar)
*/
export default function CharacterEditorList({
characters,
onCharacterClick,
onAddCharacter,
}: CharacterEditorListProps): React.JSX.Element {
const t = useTranslations();
const [searchQuery, setSearchQuery] = useState<string>('');
function getFilteredCharacters(): CharacterProps[] {
return characters.filter(function (char: CharacterProps): boolean {
return char.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(char.lastName?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false);
});
}
const filteredCharacters: CharacterProps[] = getFilteredCharacters();
return (
<div className="space-y-3">
<div className="px-2">
<InputField
input={
<TextInput
value={searchQuery}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setSearchQuery(e.target.value);
}}
placeholder={t('characterList.search')}
/>
}
actionIcon={faPlus}
actionLabel={t('characterList.add')}
addButtonCallBack={async function (): Promise<void> {
onAddCharacter();
}}
/>
</div>
<div className="px-2 space-y-2">
{filteredCharacters.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
<FontAwesomeIcon icon={faUser} className="text-primary w-8 h-8"/>
</div>
<h3 className="text-text-primary font-semibold text-base mb-1">
{t('characterList.noCharacters')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('characterList.noCharactersDescription')}
</p>
</div>
) : (
filteredCharacters.map(function (char: CharacterProps): React.JSX.Element {
return (
<div
key={char.id}
onClick={function (): void { onCharacterClick(char); }}
className="group flex items-center p-3 bg-secondary/30 rounded-lg border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md transition-all duration-200 hover:border-primary/50"
>
<div className="w-10 h-10 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-sm group-hover:scale-110 transition-transform">
{char.image ? (
<img
src={char.image}
alt={char.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-primary/10 text-primary font-bold text-sm">
{char.name?.charAt(0)?.toUpperCase() || '?'}
</div>
)}
</div>
<div className="ml-3 flex-1 min-w-0">
<div className="text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate">
{char.name || t('characterList.unknown')}
</div>
<div className="text-muted text-xs truncate">
{char.title || char.role || t('characterList.noRole')}
</div>
</div>
<div className="w-6 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3"
/>
</div>
</div>
);
})
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,230 @@
'use client';
import React, {useCallback, useContext, useMemo, useState} from 'react';
import {useCharacters, UseCharactersConfig} from '@/hooks/settings/useCharacters';
import {useTranslations} from 'next-intl';
import {CharacterProps} from '@/lib/models/Character';
import {SeriesCharacterProps} from '@/lib/models/Series';
import {BookContext} from '@/context/BookContext';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons';
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
import InputField from '@/components/form/InputField';
import ToggleSwitch from '@/components/form/ToggleSwitch';
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
import AlertBox from '@/components/AlertBox';
import CharacterSettingsList from './CharacterSettingsList';
import CharacterSettingsDetail from './CharacterSettingsDetail';
import CharacterSettingsEdit from './CharacterSettingsEdit';
interface CharacterSettingsProps {
entityType?: 'book' | 'series';
entityId?: string;
showToggle?: boolean;
}
/**
* CharacterSettings - Orchestrateur pour BookSetting/SerieSetting
* Gère le viewMode (list/detail/edit) et coordonne les sous-composants
* Inclut: toggle tool, import from series, header avec actions
*/
export default function CharacterSettings({
entityType = 'book',
entityId,
showToggle = true,
}: CharacterSettingsProps): React.JSX.Element {
const t = useTranslations();
const {book} = useContext(BookContext);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
const resolvedEntityId: string = entityId || book?.bookId || '';
const config: UseCharactersConfig = useMemo(function (): UseCharactersConfig {
return {
entityType,
entityId: resolvedEntityId,
};
}, [entityType, resolvedEntityId]);
const {
characters,
seriesCharacters,
selectedCharacter,
toolEnabled,
isLoading,
isSeriesMode,
bookSeriesId,
viewMode,
saveCharacter,
deleteCharacter,
updateCharacterField,
addAttribute,
removeAttribute,
toggleTool,
importFromSeries,
exportToSeries,
refreshSeriesCharacters,
setSelectedCharacter,
enterDetailMode,
enterEditMode,
exitEditMode,
backToList,
addNewCharacter,
} = useCharacters(config);
const availableSeriesCharacters = useMemo(function (): SeriesCharacterProps[] {
return seriesCharacters.filter(function (sc: SeriesCharacterProps): boolean {
return !characters.some(function (c: CharacterProps): boolean {
return c.seriesCharacterId === sc.id;
});
});
}, [seriesCharacters, characters]);
const handleCharacterChange = useCallback(function (key: keyof CharacterProps, value: string | number | null): void {
updateCharacterField(key, value);
}, [updateCharacterField]);
async function handleSave(): Promise<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
async function handleDelete(): Promise<void> {
if (selectedCharacter?.id) {
await deleteCharacter(selectedCharacter.id);
setShowDeleteConfirm(false);
backToList();
}
}
function getSeriesCharacterForSelected(): SeriesCharacterProps | null {
if (!selectedCharacter?.seriesCharacterId) return null;
return seriesCharacters.find(function (sc: SeriesCharacterProps): boolean {
return sc.id === selectedCharacter.seriesCharacterId;
}) || null;
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
</div>
);
}
const isNew: boolean = selectedCharacter?.id === null;
const canExport: boolean = Boolean(bookSeriesId && selectedCharacter?.id && !selectedCharacter.seriesCharacterId);
return (
<div className="flex flex-col h-full">
{/* Header - uniquement pour detail/edit */}
<ToolDetailHeader
title={selectedCharacter?.name || ''}
defaultTitle={t('characterDetail.newCharacter')}
viewMode={viewMode}
isNew={isNew}
onBack={backToList}
onEdit={enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onDelete={function (): void { setShowDeleteConfirm(true); }}
onExport={canExport ? exportToSeries : undefined}
showExport={canExport}
showDelete={Boolean(selectedCharacter?.id)}
/>
{/* Contenu principal */}
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-5 p-4">
{/* Toggle tool */}
{showToggle && !isSeriesMode && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('characterComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
<p className="text-muted text-sm mt-2">
{t('characterComponent.enableToolDescription')}
</p>
</div>
)}
{/* Contenu si outil activé */}
{(toolEnabled || isSeriesMode) && (
<>
{/* Import from series */}
{!isSeriesMode && bookSeriesId && availableSeriesCharacters.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesCharacters.map(function (sc: SeriesCharacterProps) {
return {
id: sc.id,
name: `${sc.name}${sc.lastName ? ' ' + sc.lastName : ''}`
};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
)}
{/* Liste des personnages */}
<CharacterSettingsList
characters={characters}
onCharacterClick={enterDetailMode}
onAddCharacter={addNewCharacter}
/>
</>
)}
</div>
)}
{viewMode === 'detail' && selectedCharacter && (
<div className="p-4">
<CharacterSettingsDetail
character={selectedCharacter}
seriesCharacter={getSeriesCharacterForSelected()}
/>
</div>
)}
{viewMode === 'edit' && selectedCharacter && (
<div className="p-4">
<CharacterSettingsEdit
character={selectedCharacter}
setCharacter={setSelectedCharacter}
onCharacterChange={handleCharacterChange}
onAddAttribute={addAttribute}
onRemoveAttribute={removeAttribute}
seriesCharacter={getSeriesCharacterForSelected()}
onSyncComplete={refreshSeriesCharacters}
/>
</div>
)}
</div>
{/* Modal de confirmation de suppression */}
{showDeleteConfirm && selectedCharacter?.id && (
<AlertBox
title={t('characterDetail.deleteTitle')}
message={t('characterDetail.deleteMessage', {name: selectedCharacter.name})}
type="danger"
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={handleDelete}
onCancel={function (): void { setShowDeleteConfirm(false); }}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,326 @@
'use client';
import React, {useContext, useEffect, useState} from 'react';
import {
advancedCharacterElements,
Attribute,
basicCharacterElements,
CharacterAttribute,
characterCategories,
CharacterElement,
CharacterProps,
characterStatus
} from '@/lib/models/Character';
import {SeriesCharacterProps} from '@/lib/models/Series';
import CollapsableArea from '@/components/CollapsableArea';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {
faBook,
faCommentDots,
faGlobe,
faSliders,
faStickyNote,
faUser,
faVenusMars,
faCakeCandles,
faTag,
faCrown,
faQuoteLeft,
faFlag,
faHouse,
faSkull,
faDna,
faPalette,
faNoteSticky
} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
import {SessionContext} from '@/context/SessionContext';
import {AlertContext} from '@/context/AlertContext';
import {LangContext} from '@/context/LangContext';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {BookContext} from '@/context/BookContext';
import System from '@/lib/models/System';
type AttributeResponse = { type: string; values: Attribute[] }[];
interface CharacterSettingsDetailProps {
character: CharacterProps;
seriesCharacter?: SeriesCharacterProps | null;
onLoadAttributes?: (attributes: CharacterAttribute) => void;
}
export default function CharacterSettingsDetail({
character,
seriesCharacter,
onLoadAttributes,
}: CharacterSettingsDetailProps): React.JSX.Element {
const t = useTranslations();
const {lang} = useContext(LangContext);
const {session} = useContext(SessionContext);
const {errorMessage} = useContext(AlertContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {book} = useContext(BookContext);
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
useEffect(function (): void {
if (character?.id !== null) {
getAttributes().then();
}
}, [character?.id]);
async function getAttributes(): Promise<void> {
try {
let response: AttributeResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else if (book?.localBook) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else {
response = await System.authGetQueryToServer<AttributeResponse>(
'character/attribute',
session.accessToken,
lang,
{characterId: character?.id}
);
}
if (response && onLoadAttributes) {
const attributes: CharacterAttribute = {};
response.forEach(function (item: { type: string; values: Attribute[] }): void {
attributes[item.type] = item.values;
});
onLoadAttributes(attributes);
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
}
}
}
function getCategoryLabel(): string {
const cat = characterCategories.find(c => c.value === character.category);
return cat ? t(cat.label) : character.category || '—';
}
function getStatusLabel(): string {
const stat = characterStatus.find(s => s.value === character.status);
return stat ? t(stat.label) : character.status || '—';
}
function renderAttributeSection(element: CharacterElement): React.JSX.Element | null {
const attributes: Attribute[] = character[element.section] as Attribute[] || [];
if (attributes.length === 0) return null;
return (
<CollapsableArea key={element.section} title={element.title} icon={element.icon}>
<div className="flex flex-wrap gap-2 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
{attributes.map(function (attr: Attribute, index: number): React.JSX.Element {
return (
<span key={index} className="px-3 py-1 bg-primary/20 text-primary rounded-full text-sm border border-primary/30">
{attr.name}
</span>
);
})}
</div>
</CollapsableArea>
);
}
return (
<div className="space-y-6 px-2 pb-4">
{/* Hero Section - Image + Infos principales */}
<div className="flex gap-6 p-6 bg-gradient-to-r from-secondary/30 to-transparent rounded-2xl border border-secondary/30">
{/* Image */}
<div className="shrink-0">
{character.image ? (
<div className="w-32 h-32 rounded-2xl border-4 border-primary/50 overflow-hidden shadow-lg">
<img src={character.image} alt={character.name} className="w-full h-full object-cover"/>
</div>
) : (
<div className="w-32 h-32 rounded-2xl border-4 border-secondary/50 bg-secondary/30 flex items-center justify-center">
<FontAwesomeIcon icon={faUser} className="w-12 h-12 text-text-secondary/50"/>
</div>
)}
</div>
{/* Infos principales */}
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-bold text-text-primary">
{character.name} {character.lastName}
</h2>
{character.nickname && (
<p className="text-primary italic mt-1">« {character.nickname} »</p>
)}
{character.title && (
<p className="text-text-secondary mt-2">{character.title}</p>
)}
{/* Badges */}
<div className="flex flex-wrap gap-2 mt-4">
<span className="inline-flex items-center gap-2 px-3 py-1 bg-primary/20 text-primary rounded-lg text-sm border border-primary/30">
<FontAwesomeIcon icon={faTag} className="w-3 h-3"/>
{getCategoryLabel()}
</span>
{character.gender && (
<span className="inline-flex items-center gap-2 px-3 py-1 bg-secondary/50 text-text-primary rounded-lg text-sm border border-secondary/50">
<FontAwesomeIcon icon={faVenusMars} className="w-3 h-3"/>
{character.gender}
</span>
)}
{character.age && (
<span className="inline-flex items-center gap-2 px-3 py-1 bg-secondary/50 text-text-primary rounded-lg text-sm border border-secondary/50">
<FontAwesomeIcon icon={faCakeCandles} className="w-3 h-3"/>
{character.age} {t('characterDetail.yearsOld')}
</span>
)}
</div>
</div>
</div>
{/* Histoire & Biographie */}
<CollapsableArea title={t('characterDetail.historySection')} icon={faBook}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="lg:col-span-2">
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.biography')}</h4>
<p className={`${character.biography ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{character.biography || '—'}
</p>
</div>
<div>
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.history')}</h4>
<p className={`${character.history ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{character.history || '—'}
</p>
</div>
<div>
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.roleFull')}</h4>
<p className={`${character.role ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{character.role || '—'}
</p>
</div>
</div>
</CollapsableArea>
{/* Attributs de base */}
{basicCharacterElements.map(renderAttributeSection)}
{/* Toggle Mode Avancé */}
<div className="flex items-center justify-between p-4 bg-secondary/30 rounded-xl border border-secondary/50">
<div className="flex items-center gap-3">
<FontAwesomeIcon icon={faSliders} className="text-primary w-5 h-5"/>
<span className="text-text-primary font-medium">{t('characterDetail.advancedMode')}</span>
</div>
<button
onClick={function (): void { setShowAdvanced(!showAdvanced); }}
className={`px-4 py-2 rounded-lg transition-all duration-200 ${
showAdvanced
? 'bg-primary text-white'
: 'bg-secondary/50 text-text-primary hover:bg-secondary'
}`}
>
{showAdvanced ? t('characterDetail.hideAdvanced') : t('characterDetail.showAdvanced')}
</button>
</div>
{/* Sections avancées */}
{showAdvanced && (
<>
{/* Identité étendue */}
<CollapsableArea title={t('characterDetail.identitySection')} icon={faGlobe}>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="p-3 bg-dark-background/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faDna} className="w-3 h-3 text-primary"/>
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.species')}</span>
</div>
<p className={character.species ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
{character.species || '—'}
</p>
</div>
<div className="p-3 bg-dark-background/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faFlag} className="w-3 h-3 text-primary"/>
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.nationality')}</span>
</div>
<p className={character.nationality ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
{character.nationality || '—'}
</p>
</div>
<div className="p-3 bg-dark-background/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faSkull} className="w-3 h-3 text-primary"/>
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.status')}</span>
</div>
<p className={character.status ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
{getStatusLabel()}
</p>
</div>
<div className="p-3 bg-dark-background/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faHouse} className="w-3 h-3 text-primary"/>
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.residence')}</span>
</div>
<p className={character.residence ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
{character.residence || '—'}
</p>
</div>
</div>
</CollapsableArea>
{/* Voix du personnage */}
<CollapsableArea title={t('characterDetail.voiceSection')} icon={faCommentDots}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="p-4 bg-dark-background/30 rounded-lg">
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.speechPattern')}</h4>
<p className={`${character.speechPattern ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{character.speechPattern || '—'}
</p>
</div>
<div className="p-4 bg-dark-background/30 rounded-lg relative">
<FontAwesomeIcon icon={faQuoteLeft} className="absolute top-2 left-2 w-6 h-6 text-primary/20"/>
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.catchphrase')}</h4>
<p className={`italic ${character.catchphrase ? 'text-text-primary' : 'text-text-secondary/50'}`}>
{character.catchphrase ? `« ${character.catchphrase} »` : '—'}
</p>
</div>
</div>
</CollapsableArea>
{/* Notes de l'auteur */}
<CollapsableArea title={t('characterDetail.authorSection')} icon={faStickyNote}>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="lg:col-span-2 p-4 bg-dark-background/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faNoteSticky} className="w-3 h-3 text-primary"/>
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.notes')}</span>
</div>
<p className={`${character.notes ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{character.notes || '—'}
</p>
</div>
<div className="p-4 bg-dark-background/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faPalette} className="w-3 h-3 text-primary"/>
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.colorLabel')}</span>
</div>
{character.color ? (
<div className="flex items-center gap-3">
<div
className="w-8 h-8 rounded-lg border-2 border-white/20"
style={{backgroundColor: character.color}}
/>
<span className="text-text-primary font-mono">{character.color}</span>
</div>
) : (
<p className="text-text-secondary/50 italic"></p>
)}
</div>
</div>
</CollapsableArea>
{/* Attributs avancés */}
{advancedCharacterElements.map(renderAttributeSection)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,684 @@
'use client';
import React, {useContext, useEffect, useMemo, useState} from 'react';
import {
advancedCharacterElements,
Attribute,
basicCharacterElements,
CharacterAttribute,
characterCategories,
CharacterElement,
CharacterProps,
characterStatus
} from '@/lib/models/Character';
import {SeriesCharacterProps} from '@/lib/models/Series';
import CollapsableArea from '@/components/CollapsableArea';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import TexteAreaInput from '@/components/form/TexteAreaInput';
import NumberInput from '@/components/form/NumberInput';
import SelectBox from '@/components/form/SelectBox';
import CharacterSectionElement from '@/components/book/settings/characters/CharacterSectionElement';
import SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {
faBook,
faCommentDots,
faGlobe,
faScroll,
faSliders,
faStickyNote,
faUser
} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
import {SessionContext} from '@/context/SessionContext';
import {AlertContext} from '@/context/AlertContext';
import {LangContext} from '@/context/LangContext';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {BookContext} from '@/context/BookContext';
import System from '@/lib/models/System';
import {Dispatch, SetStateAction} from 'react';
type AttributeResponse = { type: string; values: Attribute[] }[];
interface CharacterSettingsEditProps {
character: CharacterProps;
setCharacter: Dispatch<SetStateAction<CharacterProps | null>>;
onCharacterChange: (key: keyof CharacterProps, value: string | number | null) => void;
onAddAttribute: (section: keyof CharacterProps, attr: Attribute) => Promise<void>;
onRemoveAttribute: (section: keyof CharacterProps, idx: number, id: string) => Promise<void>;
seriesCharacter?: SeriesCharacterProps | null;
onSyncComplete?: () => void;
}
/**
* CharacterSettingsEdit - Vue édition des détails d'un personnage
* Pour BookSetting/SerieSetting - Tous les champs éditables avec SyncFieldWrapper
* PAS de scroll interne (géré par parent)
*/
export default function CharacterSettingsEdit({
character,
setCharacter,
onCharacterChange,
onAddAttribute,
onRemoveAttribute,
seriesCharacter,
onSyncComplete,
}: CharacterSettingsEditProps): React.JSX.Element {
const t = useTranslations();
const {lang} = useContext(LangContext);
const {session} = useContext(SessionContext);
const {errorMessage} = useContext(AlertContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {book} = useContext(BookContext);
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
// Traduire les données des SelectBox
const translatedCharacterCategories = useMemo(() =>
characterCategories.map((item) => ({
...item,
label: t(item.label)
})), [t]);
const translatedCharacterStatus = useMemo(() =>
characterStatus.map((item) => ({
...item,
label: t(item.label)
})), [t]);
useEffect(function (): void {
if (character?.id !== null) {
getAttributes().then();
}
}, [character?.id]);
async function getAttributes(): Promise<void> {
try {
let response: AttributeResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else if (book?.localBook) {
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
} else {
response = await System.authGetQueryToServer<AttributeResponse>(
'character/attribute',
session.accessToken,
lang,
{characterId: character?.id}
);
}
if (response) {
const attributes: CharacterAttribute = {};
response.forEach(function (item: { type: string; values: Attribute[] }): void {
attributes[item.type] = item.values;
});
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
if (!prev) return null;
return {
...prev,
physical: attributes.physical ?? [],
psychological: attributes.psychological ?? [],
relations: attributes.relations ?? [],
skills: attributes.skills ?? [],
weaknesses: attributes.weaknesses ?? [],
strengths: attributes.strengths ?? [],
goals: attributes.goals ?? [],
motivations: attributes.motivations ?? [],
arc: attributes.arc ?? [],
secrets: attributes.secrets ?? [],
fears: attributes.fears ?? [],
flaws: attributes.flaws ?? [],
beliefs: attributes.beliefs ?? [],
conflicts: attributes.conflicts ?? [],
quotes: attributes.quotes ?? [],
distinguishingMarks: attributes.distinguishingMarks ?? [],
items: attributes.items ?? [],
affiliations: attributes.affiliations ?? [],
};
});
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
}
}
}
return (
<div className="space-y-4 px-2 pb-4">
{/* Informations de base */}
<CollapsableArea title={t('characterDetail.basicInfo')} icon={faUser}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t('characterDetail.name')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.name || ''}
currentValue={character.name || ''}
bookElementId={character.id || ''}
field="name"
elementType="character"
onDownload={function (): void { onCharacterChange('name', seriesCharacter?.name || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.name || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('name', e.target.value);
}}
placeholder={t('characterDetail.namePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.lastName')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.lastName || ''}
currentValue={character.lastName || ''}
bookElementId={character.id || ''}
field="lastName"
elementType="character"
onDownload={function (): void { onCharacterChange('lastName', seriesCharacter?.lastName || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.lastName || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('lastName', e.target.value);
}}
placeholder={t('characterDetail.lastNamePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.nickname')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.nickname || ''}
currentValue={character.nickname || ''}
bookElementId={character.id || ''}
field="nickname"
elementType="character"
onDownload={function (): void { onCharacterChange('nickname', seriesCharacter?.nickname || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.nickname || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('nickname', e.target.value);
}}
placeholder={t('characterDetail.nicknamePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.role')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.category || 'none'}
currentValue={character.category || 'none'}
bookElementId={character.id || ''}
field="category"
elementType="character"
onDownload={function (): void {
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, category: (seriesCharacter?.category || 'none') as CharacterProps['category']} : prev;
});
}}
onSyncComplete={onSyncComplete}
>
<SelectBox
defaultValue={character.category || 'none'}
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, category: e.target.value as CharacterProps['category']} : prev;
});
}}
data={translatedCharacterCategories}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.title')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.title || ''}
currentValue={character.title || ''}
bookElementId={character.id || ''}
field="title"
elementType="character"
onDownload={function (): void { onCharacterChange('title', seriesCharacter?.title || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.title || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('title', e.target.value);
}}
placeholder={t('characterDetail.titlePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.gender')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.gender || ''}
currentValue={character.gender || ''}
bookElementId={character.id || ''}
field="gender"
elementType="character"
onDownload={function (): void { onCharacterChange('gender', seriesCharacter?.gender || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.gender || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('gender', e.target.value);
}}
placeholder={t('characterDetail.genderPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.age')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.age !== null && seriesCharacter?.age !== undefined ? String(seriesCharacter.age) : ''}
currentValue={character.age !== null && character.age !== undefined ? String(character.age) : ''}
bookElementId={character.id || ''}
field="age"
elementType="character"
onDownload={function (): void {
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, age: seriesCharacter?.age ?? null} : prev;
});
}}
onSyncComplete={onSyncComplete}
>
<NumberInput
value={character.age ?? null}
onValueChange={function (val: number | null): void {
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, age: val} : prev;
});
}}
placeholder={t('characterDetail.agePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
{/* Histoire */}
<CollapsableArea title={t('characterDetail.historySection')} icon={faBook}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t('characterDetail.biography')}
icon={faBook}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.biography || ''}
currentValue={character.biography || ''}
bookElementId={character.id || ''}
field="biography"
elementType="character"
onDownload={function (): void { onCharacterChange('biography', seriesCharacter?.biography || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={character.biography || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('biography', e.target.value);
}}
placeholder={t('characterDetail.biographyPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.history')}
icon={faScroll}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.history || ''}
currentValue={character.history || ''}
bookElementId={character.id || ''}
field="history"
elementType="character"
onDownload={function (): void { onCharacterChange('history', seriesCharacter?.history || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={character.history || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('history', e.target.value);
}}
placeholder={t('characterDetail.historyPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.roleFull')}
icon={faScroll}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.role || ''}
currentValue={character.role || ''}
bookElementId={character.id || ''}
field="role"
elementType="character"
onDownload={function (): void { onCharacterChange('role', seriesCharacter?.role || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={character.role || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('role', e.target.value);
}}
placeholder={t('characterDetail.roleFullPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
{/* Attributs de base */}
{basicCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element {
return (
<CharacterSectionElement
key={`basic-${index}`}
title={item.title}
section={item.section}
placeholder={item.placeholder}
icon={item.icon}
selectedCharacter={character}
setSelectedCharacter={setCharacter}
handleAddElement={onAddAttribute}
handleRemoveElement={onRemoveAttribute}
/>
);
})}
{/* Toggle Mode Avancé */}
<div className="flex items-center justify-between p-4 bg-secondary/30 rounded-xl border border-secondary/50">
<div className="flex items-center gap-3">
<FontAwesomeIcon icon={faSliders} className="text-primary w-5 h-5"/>
<span className="text-text-primary font-medium">{t('characterDetail.advancedMode')}</span>
</div>
<button
onClick={function (): void { setShowAdvanced(!showAdvanced); }}
className={`px-4 py-2 rounded-lg transition-all duration-200 ${
showAdvanced
? 'bg-primary text-white'
: 'bg-secondary/50 text-text-primary hover:bg-secondary'
}`}
>
{showAdvanced ? t('characterDetail.hideAdvanced') : t('characterDetail.showAdvanced')}
</button>
</div>
{/* Sections avancées */}
{showAdvanced && (
<>
{/* Identité étendue */}
<CollapsableArea title={t('characterDetail.identitySection')} icon={faGlobe}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t('characterDetail.species')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.species || ''}
currentValue={character.species || ''}
bookElementId={character.id || ''}
field="species"
elementType="character"
onDownload={function (): void { onCharacterChange('species', seriesCharacter?.species || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.species || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('species', e.target.value);
}}
placeholder={t('characterDetail.speciesPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.nationality')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.nationality || ''}
currentValue={character.nationality || ''}
bookElementId={character.id || ''}
field="nationality"
elementType="character"
onDownload={function (): void { onCharacterChange('nationality', seriesCharacter?.nationality || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.nationality || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('nationality', e.target.value);
}}
placeholder={t('characterDetail.nationalityPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.status')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.status || 'alive'}
currentValue={character.status || 'alive'}
bookElementId={character.id || ''}
field="status"
elementType="character"
onDownload={function (): void {
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, status: (seriesCharacter?.status || 'alive') as CharacterProps['status']} : prev;
});
}}
onSyncComplete={onSyncComplete}
>
<SelectBox
defaultValue={character.status || 'alive'}
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
return prev ? {...prev, status: e.target.value as CharacterProps['status']} : prev;
});
}}
data={translatedCharacterStatus}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.residence')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.residence || ''}
currentValue={character.residence || ''}
bookElementId={character.id || ''}
field="residence"
elementType="character"
onDownload={function (): void { onCharacterChange('residence', seriesCharacter?.residence || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.residence || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('residence', e.target.value);
}}
placeholder={t('characterDetail.residencePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
{/* Voix du personnage */}
<CollapsableArea title={t('characterDetail.voiceSection')} icon={faCommentDots}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t('characterDetail.speechPattern')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.speechPattern || ''}
currentValue={character.speechPattern || ''}
bookElementId={character.id || ''}
field="speechPattern"
elementType="character"
onDownload={function (): void { onCharacterChange('speechPattern', seriesCharacter?.speechPattern || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={character.speechPattern || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('speechPattern', e.target.value);
}}
placeholder={t('characterDetail.speechPatternPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.catchphrase')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.catchphrase || ''}
currentValue={character.catchphrase || ''}
bookElementId={character.id || ''}
field="catchphrase"
elementType="character"
onDownload={function (): void { onCharacterChange('catchphrase', seriesCharacter?.catchphrase || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.catchphrase || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('catchphrase', e.target.value);
}}
placeholder={t('characterDetail.catchphrasePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
{/* Notes de l'auteur */}
<CollapsableArea title={t('characterDetail.authorSection')} icon={faStickyNote}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t('characterDetail.notes')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.notes || ''}
currentValue={character.notes || ''}
bookElementId={character.id || ''}
field="notes"
elementType="character"
onDownload={function (): void { onCharacterChange('notes', seriesCharacter?.notes || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={character.notes || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onCharacterChange('notes', e.target.value);
}}
placeholder={t('characterDetail.notesPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('characterDetail.colorLabel')}
input={
<SyncFieldWrapper
seriesElementId={character.seriesCharacterId}
seriesValue={seriesCharacter?.color || ''}
currentValue={character.color || ''}
bookElementId={character.id || ''}
field="color"
elementType="character"
onDownload={function (): void { onCharacterChange('color', seriesCharacter?.color || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={character.color || ''}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onCharacterChange('color', e.target.value);
}}
placeholder={t('characterDetail.colorPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
{/* Attributs avancés */}
{advancedCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element {
return (
<CharacterSectionElement
key={`advanced-${index}`}
title={item.title}
section={item.section}
placeholder={item.placeholder}
icon={item.icon}
selectedCharacter={character}
setSelectedCharacter={setCharacter}
handleAddElement={onAddAttribute}
handleRemoveElement={onRemoveAttribute}
/>
);
})}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,151 @@
'use client';
import React, {useState} from 'react';
import {characterCategories, CharacterProps} from '@/lib/models/Character';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import CollapsableArea from '@/components/CollapsableArea';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faChevronRight, faPlus, faUser} from '@fortawesome/free-solid-svg-icons';
import {SelectBoxProps} from '@/shared/interface';
import {useTranslations} from 'next-intl';
interface CharacterSettingsListProps {
characters: CharacterProps[];
onCharacterClick: (character: CharacterProps) => void;
onAddCharacter: () => void;
}
/**
* CharacterSettingsList - Liste des personnages pour BookSetting/SerieSetting
* Version complète avec groupage par catégorie et sections collapsibles
* PAS de scroll interne (géré par parent SettingsContainer)
*/
export default function CharacterSettingsList({
characters,
onCharacterClick,
onAddCharacter,
}: CharacterSettingsListProps): React.JSX.Element {
const t = useTranslations();
const [searchQuery, setSearchQuery] = useState<string>('');
function getFilteredCharacters(): CharacterProps[] {
return characters.filter(function (char: CharacterProps): boolean {
return char.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(char.lastName?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false);
});
}
const filteredCharacters: CharacterProps[] = getFilteredCharacters();
return (
<div className="space-y-4">
<div className="px-4 mb-4">
<InputField
input={
<TextInput
value={searchQuery}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setSearchQuery(e.target.value);
}}
placeholder={t('characterList.search')}
/>
}
actionIcon={faPlus}
actionLabel={t('characterList.add')}
addButtonCallBack={async function (): Promise<void> {
onAddCharacter();
}}
/>
</div>
<div className="px-2">
{characterCategories.map(function (category: SelectBoxProps): React.JSX.Element | null {
const categoryCharacters: CharacterProps[] = filteredCharacters.filter(
function (char: CharacterProps): boolean {
return char.category === category.value;
}
);
if (categoryCharacters.length === 0) {
return null;
}
return (
<CollapsableArea
key={category.value}
title={t(category.label)}
icon={faUser}
>
<div className="space-y-2 p-2">
{categoryCharacters.map(function (char: CharacterProps): React.JSX.Element {
return (
<div
key={char.id}
onClick={function (): void {
onCharacterClick(char);
}}
className="group flex items-center p-4 bg-secondary/30 rounded-xl border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md hover:scale-102 transition-all duration-200 hover:border-primary/50"
>
<div className="w-14 h-14 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform">
{char.image ? (
<img
src={char.image}
alt={char.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-primary/10 text-primary font-bold text-lg">
{char.name?.charAt(0)?.toUpperCase() || '?'}
</div>
)}
</div>
<div className="ml-4 flex-1">
<div className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
{char.name || t('characterList.unknown')}
</div>
<div className="text-text-secondary text-sm mt-0.5">
{char.lastName || t('characterList.noLastName')}
</div>
</div>
<div className="w-28 px-3">
<div className="text-primary text-sm font-semibold truncate">
{char.title || t('characterList.noTitle')}
</div>
<div className="text-muted text-xs truncate mt-0.5">
{char.role || t('characterList.noRole')}
</div>
</div>
<div className="w-8 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"
/>
</div>
</div>
);
})}
</div>
</CollapsableArea>
);
})}
{filteredCharacters.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<FontAwesomeIcon icon={faUser} className="text-primary w-10 h-10"/>
</div>
<h3 className="text-text-primary font-semibold text-lg mb-2">
{t('characterList.noCharacters')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('characterList.noCharactersDescription')}
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
'use client'
import {faMapMarkerAlt, faPlus, faToggleOn, faTrash} from '@fortawesome/free-solid-svg-icons';
import {faMapMarkerAlt, faPlus, faShare, faToggleOn, faTrash} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react';
import React, {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react';
import {SessionContext} from "@/context/SessionContext";
import {AlertContext} from "@/context/AlertContext";
import {BookContext} from "@/context/BookContext";
@@ -15,7 +15,12 @@ import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
import {SyncedBook} from "@/lib/models/SyncedBook";
import {SeriesContext, SeriesContextProps} from "@/context/SeriesContext";
import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext";
import {SyncedSeries} from "@/lib/models/SyncedSeries";
import ToggleSwitch from "@/components/form/ToggleSwitch";
import {SeriesLocationElement, SeriesLocationItem, SeriesLocationSubElement} from "@/lib/models/Series";
import SeriesImportSelector from "@/components/form/SeriesImportSelector";
interface SubElement {
id: string;
@@ -34,6 +39,7 @@ interface LocationProps {
id: string;
name: string;
elements: Element[];
seriesLocationId?: string | null;
}
interface LocationListResponse {
@@ -41,7 +47,14 @@ interface LocationListResponse {
enabled: boolean;
}
export function LocationComponent({showToggle = true}: {showToggle?: boolean}, ref: any) {
interface LocationComponentProps {
showToggle?: boolean;
entityType?: 'book' | 'series';
entityId?: string;
}
export function LocationComponent(props: LocationComponentProps, ref: React.Ref<{ handleSave: () => Promise<void> }>) {
const {showToggle = true, entityType = 'book', entityId} = props;
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
@@ -50,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);

View File

@@ -0,0 +1,242 @@
'use client';
import React, {useCallback, useContext, useMemo, useState} from 'react';
import {useLocations, UseLocationsConfig, LocationProps, Element, SubElement} from '@/hooks/settings/useLocations';
import {useTranslations} from 'next-intl';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faSpinner, faPlus, faToggleOn} from '@fortawesome/free-solid-svg-icons';
import {BookContext} from '@/context/BookContext';
import {SeriesLocationItem} from '@/lib/models/Series';
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
import AlertBox from '@/components/AlertBox';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import ToggleSwitch from '@/components/form/ToggleSwitch';
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
import LocationEditorList from './LocationEditorList';
import LocationEditorDetail from './LocationEditorDetail';
import LocationEditorEdit from './LocationEditorEdit';
/**
* LocationEditor - Orchestrateur pour ComposerRightBar
* Mêmes fonctionnalités que LocationSettings, layout condensé
* Inclut: toggle tool, import from series, export to series
*/
export default function LocationEditor(): React.JSX.Element {
const t = useTranslations();
const {book} = useContext(BookContext);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
const [showAddForm, setShowAddForm] = useState<boolean>(false);
const config: UseLocationsConfig = useMemo(function (): UseLocationsConfig {
return {
entityType: 'book',
entityId: book?.bookId || '',
};
}, [book?.bookId]);
const {
sections,
seriesLocations,
toolEnabled,
isLoading,
bookSeriesId,
newSectionName,
newElementNames,
newSubElementNames,
viewMode,
selectedSectionIndex,
addSection,
addElement,
addSubElement,
removeSection,
removeElement,
removeSubElement,
updateElement,
updateSubElement,
saveLocations,
toggleTool,
importFromSeries,
exportToSeries,
setNewSectionName,
setNewElementNames,
setNewSubElementNames,
enterDetailMode,
enterEditMode,
exitEditMode,
backToList,
} = useLocations(config);
const availableSeriesLocations = useMemo(function (): SeriesLocationItem[] {
return seriesLocations.filter(function (sl: SeriesLocationItem): boolean {
return !sections.some(function (s: LocationProps): boolean {
return s.seriesLocationId === sl.id;
});
});
}, [seriesLocations, sections]);
// Wrapper pour convertir LocationProps en index
const handleSectionClick = useCallback(function (section: LocationProps, index: number): void {
enterDetailMode(index);
}, [enterDetailMode]);
// Gestion de l'ajout
async function handleAddSection(): Promise<void> {
if (newSectionName.trim()) {
await addSection();
setShowAddForm(false);
} else {
setShowAddForm(true);
}
}
async function handleSave(): Promise<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
async function handleDelete(): Promise<void> {
if (selectedSectionIndex >= 0 && sections[selectedSectionIndex]) {
await removeSection(sections[selectedSectionIndex].id);
setShowDeleteConfirm(false);
backToList();
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin"/>
</div>
);
}
const selectedSection: LocationProps | undefined = sections[selectedSectionIndex];
const canExport: boolean = Boolean(bookSeriesId && selectedSection && !selectedSection.seriesLocationId);
return (
<div className="flex flex-col h-full">
<ToolDetailHeader
title={selectedSection?.name || ''}
defaultTitle={t('locationComponent.newSection')}
viewMode={viewMode}
isNew={false}
onBack={backToList}
onEdit={enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onDelete={function (): void { setShowDeleteConfirm(true); }}
onExport={canExport ? function (): Promise<void> { return exportToSeries(selectedSection!); } : undefined}
showExport={canExport}
showDelete={Boolean(selectedSection)}
/>
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-3 p-2">
{/* Toggle tool */}
<div className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('locationComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
</div>
{toolEnabled && (
<>
{/* Import from series */}
{bookSeriesId && availableSeriesLocations.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesLocations.map(function (sl: SeriesLocationItem) {
return {id: sl.id, name: sl.name};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
)}
{showAddForm && (
<div className="px-2">
<InputField
input={
<TextInput
value={newSectionName}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setNewSectionName(e.target.value);
}}
placeholder={t('locationComponent.newSectionPlaceholder')}
/>
}
actionIcon={faPlus}
actionLabel={t('locationComponent.addSectionLabel')}
addButtonCallBack={async function (): Promise<void> {
await addSection();
setShowAddForm(false);
}}
/>
</div>
)}
<LocationEditorList
sections={sections}
onSectionClick={handleSectionClick}
onAddSection={handleAddSection}
/>
</>
)}
</div>
)}
{viewMode === 'detail' && selectedSection && (
<div className="p-4">
<LocationEditorDetail section={selectedSection}/>
</div>
)}
{viewMode === 'edit' && selectedSection && (
<div className="p-4">
<LocationEditorEdit
section={selectedSection}
newElementNames={newElementNames}
newSubElementNames={newSubElementNames}
onAddElement={addElement}
onAddSubElement={addSubElement}
onRemoveElement={removeElement}
onRemoveSubElement={removeSubElement}
onUpdateElement={updateElement}
onUpdateSubElement={updateSubElement}
onNewElementNameChange={function (sectionId: string, name: string): void {
setNewElementNames({...newElementNames, [sectionId]: name});
}}
onNewSubElementNameChange={function (elementIndex: number, name: string): void {
setNewSubElementNames({...newSubElementNames, [elementIndex]: name});
}}
/>
</div>
)}
</div>
{showDeleteConfirm && selectedSection && (
<AlertBox
title={t('locationComponent.deleteTitle')}
message={t('locationComponent.deleteMessage', {name: selectedSection.name})}
type="danger"
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={handleDelete}
onCancel={function (): void { setShowDeleteConfirm(false); }}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import React from 'react';
import {LocationProps, Element, SubElement} from '@/hooks/settings/useLocations';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faLocationDot, faMapPin} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
interface LocationEditorDetailProps {
section: LocationProps;
}
/**
* LocationEditorDetail - Version sidebar lecture seule
* Layout linéaire simple, juste les infos essentielles empilées
* PAS de CollapsableArea, PAS de grids
*/
export default function LocationEditorDetail({
section,
}: LocationEditorDetailProps): React.JSX.Element {
const t = useTranslations();
return (
<div>
<h3 className="text-text-primary font-semibold text-base mb-4">{section.name}</h3>
{section.elements.length === 0 ? (
<p className="text-muted text-sm">{t('locationComponent.noElementAvailable')}</p>
) : (
<div className="space-y-4">
{section.elements.map(function (element: Element): React.JSX.Element {
return (
<div key={element.id} className="border-b border-secondary/30 pb-3 last:border-b-0">
<div className="flex items-center gap-2 mb-1">
<FontAwesomeIcon icon={faMapPin} className="text-primary w-3 h-3"/>
<span className="text-text-primary font-medium text-sm">{element.name}</span>
</div>
{element.description && (
<p className="text-text-secondary text-xs ml-5 mb-2">{element.description}</p>
)}
{element.subElements.length > 0 && (
<div className="ml-5 mt-2 space-y-1">
{element.subElements.map(function (subElement: SubElement): React.JSX.Element {
return (
<div key={subElement.id} className="flex items-start gap-2">
<FontAwesomeIcon icon={faLocationDot} className="text-muted w-2 h-2 mt-1.5"/>
<div>
<span className="text-text-primary text-xs">{subElement.name}</span>
{subElement.description && (
<p className="text-muted text-xs">{subElement.description}</p>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,151 @@
'use client';
import React, {ChangeEvent} from 'react';
import {LocationProps, Element, SubElement} from '@/hooks/settings/useLocations';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import TexteAreaInput from '@/components/form/TexteAreaInput';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faMapPin, faPlus} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
interface LocationEditorEditProps {
section: LocationProps;
newElementNames: { [key: string]: string };
newSubElementNames: { [key: string]: string };
onAddElement: (sectionId: string) => Promise<void>;
onAddSubElement: (sectionId: string, elementIndex: number) => Promise<void>;
onRemoveElement: (sectionId: string, elementIndex: number) => Promise<void>;
onRemoveSubElement: (sectionId: string, elementIndex: number, subElementIndex: number) => Promise<void>;
onUpdateElement: (sectionId: string, elementIndex: number, field: keyof Element, value: string) => void;
onUpdateSubElement: (sectionId: string, elementIndex: number, subElementIndex: number, field: keyof SubElement, value: string) => void;
onNewElementNameChange: (sectionId: string, name: string) => void;
onNewSubElementNameChange: (elementIndex: number, name: string) => void;
}
/**
* LocationEditorEdit - Version sidebar édition
* Layout linéaire simple, champs empilés verticalement
* PAS de CollapsableArea, PAS de grids
*/
export default function LocationEditorEdit({
section,
newElementNames,
newSubElementNames,
onAddElement,
onAddSubElement,
onRemoveElement,
onRemoveSubElement,
onUpdateElement,
onUpdateSubElement,
onNewElementNameChange,
onNewSubElementNameChange,
}: LocationEditorEditProps): React.JSX.Element {
const t = useTranslations();
return (
<div className="space-y-4">
<h3 className="text-text-primary font-semibold text-base">{section.name}</h3>
{/* Éléments existants */}
{section.elements.map(function (element: Element, elementIndex: number): React.JSX.Element {
return (
<div key={element.id} className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faMapPin} className="text-primary w-3 h-3"/>
<span className="text-text-secondary text-xs">{t('locationComponent.element')}</span>
</div>
<InputField
input={
<TextInput
value={element.name}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onUpdateElement(section.id, elementIndex, 'name', e.target.value);
}}
placeholder={t('locationComponent.elementNamePlaceholder')}
/>
}
removeButtonCallBack={function (): Promise<void> {
return onRemoveElement(section.id, elementIndex);
}}
/>
<div className="mt-2">
<TexteAreaInput
value={element.description}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onUpdateElement(section.id, elementIndex, 'description', e.target.value);
}}
placeholder={t('locationComponent.elementDescriptionPlaceholder')}
/>
</div>
{/* Sous-éléments */}
{element.subElements.length > 0 && (
<div className="mt-3 pt-3 border-t border-secondary/30 space-y-2">
{element.subElements.map(function (subElement: SubElement, subElementIndex: number): React.JSX.Element {
return (
<div key={subElement.id} className="bg-dark-background/50 rounded p-2">
<InputField
input={
<TextInput
value={subElement.name}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onUpdateSubElement(section.id, elementIndex, subElementIndex, 'name', e.target.value);
}}
placeholder={t('locationComponent.subElementNamePlaceholder')}
/>
}
removeButtonCallBack={function (): Promise<void> {
return onRemoveSubElement(section.id, elementIndex, subElementIndex);
}}
/>
</div>
);
})}
</div>
)}
{/* Ajouter sous-élément */}
<div className="mt-2">
<InputField
input={
<TextInput
value={newSubElementNames[elementIndex] || ''}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onNewSubElementNameChange(elementIndex, e.target.value);
}}
placeholder={t('locationComponent.newSubElementPlaceholder')}
/>
}
actionIcon={faPlus}
actionLabel={t('locationComponent.addSubElement')}
addButtonCallBack={function (): Promise<void> {
return onAddSubElement(section.id, elementIndex);
}}
/>
</div>
</div>
);
})}
{/* Ajouter élément */}
<InputField
fieldName={t('locationComponent.addElement')}
input={
<TextInput
value={newElementNames[section.id] || ''}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onNewElementNameChange(section.id, e.target.value);
}}
placeholder={t('locationComponent.newElementPlaceholder')}
/>
}
actionIcon={faPlus}
addButtonCallBack={function (): Promise<void> {
return onAddElement(section.id);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,113 @@
'use client';
import React, {useState} from 'react';
import {LocationProps, Element} from '@/hooks/settings/useLocations';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faChevronRight, faMapMarkerAlt, faPlus} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
interface LocationEditorListProps {
sections: LocationProps[];
onSectionClick: (section: LocationProps, index: number) => void;
onAddSection: () => void;
}
/**
* LocationEditorList - Liste des sections pour ComposerRightBar
* Version compacte avec liste cliquable (même pattern que CharacterEditorList)
* PAS de scroll interne (géré par parent ComposerRightBar)
*/
export default function LocationEditorList({
sections,
onSectionClick,
onAddSection,
}: LocationEditorListProps): React.JSX.Element {
const t = useTranslations();
const [searchQuery, setSearchQuery] = useState<string>('');
function getFilteredSections(): LocationProps[] {
return sections.filter(function (section: LocationProps): boolean {
return section.name.toLowerCase().includes(searchQuery.toLowerCase());
});
}
function countTotalElements(section: LocationProps): number {
let count: number = section.elements.length;
section.elements.forEach(function (element: Element): void {
count += element.subElements.length;
});
return count;
}
const filteredSections: LocationProps[] = getFilteredSections();
return (
<div className="space-y-3">
<div className="px-2">
<InputField
input={
<TextInput
value={searchQuery}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setSearchQuery(e.target.value);
}}
placeholder={t('locationComponent.search')}
/>
}
actionIcon={faPlus}
actionLabel={t('locationComponent.addSectionLabel')}
addButtonCallBack={async function (): Promise<void> {
onAddSection();
}}
/>
</div>
<div className="px-2 space-y-2">
{filteredSections.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-8 h-8"/>
</div>
<h3 className="text-text-primary font-semibold text-base mb-1">
{t('locationComponent.noSectionAvailable')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('locationComponent.noSectionDescription')}
</p>
</div>
) : (
filteredSections.map(function (section: LocationProps, index: number): React.JSX.Element {
return (
<div
key={section.id}
onClick={function (): void { onSectionClick(section, index); }}
className="group flex items-center p-3 bg-secondary/30 rounded-lg border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md transition-all duration-200 hover:border-primary/50"
>
<div className="w-10 h-10 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-sm group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-5 h-5"/>
</div>
<div className="ml-3 flex-1 min-w-0">
<div className="text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate">
{section.name}
</div>
<div className="text-muted text-xs truncate">
{t('locationComponent.elementsCount', {count: countTotalElements(section)})}
</div>
</div>
<div className="w-6 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3"
/>
</div>
</div>
);
})
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,228 @@
'use client';
import React, {useContext, useMemo, useState} from 'react';
import {useLocations, UseLocationsConfig, LocationProps, Element, SubElement} from '@/hooks/settings/useLocations';
import {useTranslations} from 'next-intl';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons';
import {BookContext} from '@/context/BookContext';
import {SeriesLocationItem} from '@/lib/models/Series';
import InputField from '@/components/form/InputField';
import ToggleSwitch from '@/components/form/ToggleSwitch';
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
import AlertBox from '@/components/AlertBox';
import LocationSettingsList from './LocationSettingsList';
import LocationSettingsDetail from './LocationSettingsDetail';
import LocationSettingsEdit from './LocationSettingsEdit';
interface LocationSettingsProps {
entityType?: 'book' | 'series';
entityId?: string;
showToggle?: boolean;
}
/**
* LocationSettings - Orchestrateur pour BookSetting/SerieSetting
* Gère le viewMode (list/detail/edit) et coordonne les sous-composants
* Inclut: toggle tool, import from series, export to series
*/
export default function LocationSettings({
entityType = 'book',
entityId,
showToggle = true,
}: LocationSettingsProps): React.JSX.Element {
const t = useTranslations();
const {book} = useContext(BookContext);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
const resolvedEntityId: string = entityId || book?.bookId || '';
const config: UseLocationsConfig = useMemo(function (): UseLocationsConfig {
return {
entityType,
entityId: resolvedEntityId,
};
}, [entityType, resolvedEntityId]);
const {
sections,
seriesLocations,
toolEnabled,
isLoading,
isSeriesMode,
bookSeriesId,
newSectionName,
newElementNames,
newSubElementNames,
viewMode,
selectedSectionIndex,
addSection,
addElement,
addSubElement,
removeSection,
removeElement,
removeSubElement,
updateElement,
updateSubElement,
saveLocations,
toggleTool,
importFromSeries,
exportToSeries,
setNewSectionName,
setNewElementNames,
setNewSubElementNames,
enterDetailMode,
enterEditMode,
exitEditMode,
backToList,
} = useLocations(config);
const availableSeriesLocations = useMemo(function (): SeriesLocationItem[] {
return seriesLocations.filter(function (sl: SeriesLocationItem): boolean {
return !sections.some(function (s: LocationProps): boolean {
return s.seriesLocationId === sl.id;
});
});
}, [seriesLocations, sections]);
async function handleSave(): Promise<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
async function handleDelete(): Promise<void> {
if (selectedSectionIndex >= 0 && sections[selectedSectionIndex]) {
await removeSection(sections[selectedSectionIndex].id);
setShowDeleteConfirm(false);
backToList();
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
</div>
);
}
const selectedSection: LocationProps | undefined = sections[selectedSectionIndex];
const canExport: boolean = Boolean(bookSeriesId && selectedSection && !selectedSection.seriesLocationId);
return (
<div className="flex flex-col h-full">
{/* Header - uniquement pour detail/edit */}
<ToolDetailHeader
title={selectedSection?.name || ''}
defaultTitle={t('locationComponent.newSection')}
viewMode={viewMode}
isNew={false}
onBack={backToList}
onEdit={enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onDelete={function (): void { setShowDeleteConfirm(true); }}
onExport={canExport ? function (): Promise<void> { return exportToSeries(selectedSection!); } : undefined}
showExport={canExport}
showDelete={Boolean(selectedSection)}
/>
{/* Contenu principal */}
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-5 p-4">
{/* Toggle tool */}
{showToggle && !isSeriesMode && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('locationComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
<p className="text-muted text-sm mt-2">
{t('locationComponent.enableToolDescription')}
</p>
</div>
)}
{/* Contenu si outil activé */}
{(toolEnabled || isSeriesMode) && (
<>
{/* Import from series */}
{!isSeriesMode && bookSeriesId && availableSeriesLocations.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesLocations.map(function (sl: SeriesLocationItem) {
return {id: sl.id, name: sl.name};
})}
onImport={importFromSeries}
placeholder={t("seriesImport.selectElement")}
label={t("seriesImport.importFromSeries")}
/>
)}
{/* Liste des sections */}
<LocationSettingsList
sections={sections}
newSectionName={newSectionName}
onSectionClick={enterDetailMode}
onAddSection={addSection}
onNewSectionNameChange={setNewSectionName}
/>
</>
)}
</div>
)}
{viewMode === 'detail' && selectedSection && (
<div className="p-4">
<LocationSettingsDetail section={selectedSection}/>
</div>
)}
{viewMode === 'edit' && selectedSection && (
<div className="p-4">
<LocationSettingsEdit
section={selectedSection}
newElementNames={newElementNames}
newSubElementNames={newSubElementNames}
onAddElement={addElement}
onAddSubElement={addSubElement}
onRemoveElement={removeElement}
onRemoveSubElement={removeSubElement}
onUpdateElement={updateElement}
onUpdateSubElement={updateSubElement}
onNewElementNameChange={function (sectionId: string, name: string): void {
setNewElementNames({...newElementNames, [sectionId]: name});
}}
onNewSubElementNameChange={function (elementIndex: number, name: string): void {
setNewSubElementNames({...newSubElementNames, [elementIndex]: name});
}}
/>
</div>
)}
</div>
{/* Modal de confirmation de suppression */}
{showDeleteConfirm && selectedSection && (
<AlertBox
title={t('locationComponent.deleteTitle')}
message={t('locationComponent.deleteMessage', {name: selectedSection.name})}
type="danger"
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={handleDelete}
onCancel={function (): void { setShowDeleteConfirm(false); }}
/>
)}
</div>
);
}

View File

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

View File

@@ -0,0 +1,169 @@
'use client';
import React, {ChangeEvent} from 'react';
import {LocationProps, Element, SubElement} from '@/hooks/settings/useLocations';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import TexteAreaInput from '@/components/form/TexteAreaInput';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faMapMarkerAlt, faPlus, faTrash} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
interface LocationSettingsEditProps {
section: LocationProps;
newElementNames: { [key: string]: string };
newSubElementNames: { [key: string]: string };
onAddElement: (sectionId: string) => Promise<void>;
onAddSubElement: (sectionId: string, elementIndex: number) => Promise<void>;
onRemoveElement: (sectionId: string, elementIndex: number) => Promise<void>;
onRemoveSubElement: (sectionId: string, elementIndex: number, subElementIndex: number) => Promise<void>;
onUpdateElement: (sectionId: string, elementIndex: number, field: keyof Element, value: string) => void;
onUpdateSubElement: (sectionId: string, elementIndex: number, subElementIndex: number, field: keyof SubElement, value: string) => void;
onNewElementNameChange: (sectionId: string, name: string) => void;
onNewSubElementNameChange: (elementIndex: number, name: string) => void;
}
/**
* LocationSettingsEdit - Vue édition pour BookSetting/SerieSetting
* Permet d'éditer les éléments et sous-éléments
* PAS de scroll interne (géré par parent)
*/
export default function LocationSettingsEdit({
section,
newElementNames,
newSubElementNames,
onAddElement,
onAddSubElement,
onRemoveElement,
onRemoveSubElement,
onUpdateElement,
onUpdateSubElement,
onNewElementNameChange,
onNewSubElementNameChange,
}: LocationSettingsEditProps): React.JSX.Element {
const t = useTranslations();
return (
<div className="space-y-4 px-2 pb-4">
{/* Header de la section */}
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-6 h-6"/>
</div>
<div>
<h2 className="text-text-primary font-bold text-xl">{section.name}</h2>
</div>
</div>
{/* Éléments existants */}
{section.elements.map(function (element: Element, elementIndex: number): React.JSX.Element {
return (
<div key={element.id} className="bg-secondary/30 rounded-xl p-4 border border-secondary/50">
<div className="mb-3">
<InputField
fieldName={t("locationComponent.elementName")}
input={
<TextInput
value={element.name}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onUpdateElement(section.id, elementIndex, 'name', e.target.value);
}}
placeholder={t("locationComponent.elementNamePlaceholder")}
/>
}
removeButtonCallBack={function (): Promise<void> {
return onRemoveElement(section.id, elementIndex);
}}
/>
</div>
<TexteAreaInput
value={element.description}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onUpdateElement(section.id, elementIndex, 'description', e.target.value);
}}
placeholder={t("locationComponent.elementDescriptionPlaceholder")}
/>
{/* Sous-éléments */}
<div className="mt-4 pt-4 border-t border-secondary/50">
{element.subElements.length > 0 && (
<h4 className="text-sm italic text-text-secondary mb-3">
{t("locationComponent.subElementsHeading")}
</h4>
)}
{element.subElements.map(function (subElement: SubElement, subElementIndex: number): React.JSX.Element {
return (
<div key={subElement.id} className="bg-dark-background rounded-lg p-3 mb-3">
<div className="mb-2">
<InputField
input={
<TextInput
value={subElement.name}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onUpdateSubElement(section.id, elementIndex, subElementIndex, 'name', e.target.value);
}}
placeholder={t("locationComponent.subElementNamePlaceholder")}
/>
}
removeButtonCallBack={function (): Promise<void> {
return onRemoveSubElement(section.id, elementIndex, subElementIndex);
}}
/>
</div>
<TexteAreaInput
value={subElement.description}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onUpdateSubElement(section.id, elementIndex, subElementIndex, 'description', e.target.value);
}}
placeholder={t("locationComponent.subElementDescriptionPlaceholder")}
/>
</div>
);
})}
{/* Ajouter sous-élément */}
<InputField
input={
<TextInput
value={newSubElementNames[elementIndex] || ''}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onNewSubElementNameChange(elementIndex, e.target.value);
}}
placeholder={t("locationComponent.newSubElementPlaceholder")}
/>
}
actionIcon={faPlus}
actionLabel={t("locationComponent.addSubElement")}
addButtonCallBack={function (): Promise<void> {
return onAddSubElement(section.id, elementIndex);
}}
/>
</div>
</div>
);
})}
{/* Ajouter élément */}
<div className="bg-secondary/20 rounded-xl p-4 border border-secondary/30">
<InputField
fieldName={t("locationComponent.addElement")}
input={
<TextInput
value={newElementNames[section.id] || ''}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onNewElementNameChange(section.id, e.target.value);
}}
placeholder={t("locationComponent.newElementPlaceholder")}
/>
}
actionIcon={faPlus}
actionLabel={t("locationComponent.addElement")}
addButtonCallBack={function (): Promise<void> {
return onAddElement(section.id);
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import React, {ChangeEvent} from 'react';
import {LocationProps, Element} from '@/hooks/settings/useLocations';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faChevronRight, faMapMarkerAlt, faPlus} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
interface LocationSettingsListProps {
sections: LocationProps[];
newSectionName: string;
onSectionClick: (sectionIndex: number) => void;
onAddSection: () => Promise<void>;
onNewSectionNameChange: (name: string) => void;
}
/**
* LocationSettingsList - Liste des sections de lieux pour BookSetting/SerieSetting
* Inclut recherche et bouton d'ajout
* PAS de scroll interne (géré par parent)
*/
export default function LocationSettingsList({
sections,
newSectionName,
onSectionClick,
onAddSection,
onNewSectionNameChange,
}: LocationSettingsListProps): React.JSX.Element {
const t = useTranslations();
function countTotalElements(section: LocationProps): number {
let count: number = section.elements.length;
section.elements.forEach(function (element: Element): void {
count += element.subElements.length;
});
return count;
}
return (
<div className="space-y-4">
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl shadow-lg p-4 border border-secondary/50">
<InputField
input={
<TextInput
value={newSectionName}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onNewSectionNameChange(e.target.value);
}}
placeholder={t("locationComponent.newSectionPlaceholder")}
/>
}
actionIcon={faPlus}
actionLabel={t("locationComponent.addSectionLabel")}
addButtonCallBack={onAddSection}
/>
</div>
<div className="space-y-2 px-2">
{sections.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-10 h-10"/>
</div>
<h3 className="text-text-primary font-semibold text-lg mb-2">
{t("locationComponent.noSectionAvailable")}
</h3>
<p className="text-muted text-sm max-w-xs">
{t("locationComponent.noSectionDescription")}
</p>
</div>
) : (
sections.map(function (section: LocationProps, index: number): React.JSX.Element {
return (
<div
key={section.id}
onClick={function (): void { onSectionClick(index); }}
className="group flex items-center p-4 bg-secondary/30 rounded-xl border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md hover:scale-102 transition-all duration-200 hover:border-primary/50"
>
<div className="w-12 h-12 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faMapMarkerAlt} className="text-primary w-6 h-6"/>
</div>
<div className="ml-4 flex-1 min-w-0">
<div className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
{section.name}
</div>
<div className="text-text-secondary text-sm mt-0.5">
{t("locationComponent.elementsCount", {count: countTotalElements(section)})}
</div>
</div>
<div className="w-8 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"
/>
</div>
</div>
);
})
)}
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -1,141 +0,0 @@
'use client';
import React, {useState} from 'react';
import {SpellListItem, SpellTagProps} from "@/lib/models/Spell";
import InputField from "@/components/form/InputField";
import TextInput from "@/components/form/TextInput";
import SpellTagChip from "@/components/book/settings/spells/SpellTagChip";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faChevronRight, faCog, faHatWizard, faPlus} from "@fortawesome/free-solid-svg-icons";
import {useTranslations} from "next-intl";
interface SpellListProps {
spells: SpellListItem[];
tags: SpellTagProps[];
handleSpellClick: (spell: SpellListItem) => void;
handleAddSpell: () => void;
handleManageTags: () => void;
}
export default function SpellList(
{
spells,
tags,
handleSpellClick,
handleAddSpell,
handleManageTags,
}: SpellListProps) {
const t = useTranslations();
const [searchQuery, setSearchQuery] = useState<string>('');
const [filterTag, setFilterTag] = useState<string>('all');
function getFilteredSpells(): SpellListItem[] {
return spells.filter((spell: SpellListItem) => {
const matchesSearch = spell.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesTag = filterTag === 'all' || spell.tags.some((tag: SpellTagProps) => tag.id === filterTag);
return matchesSearch && matchesTag;
});
}
const filteredSpells = getFilteredSpells();
return (
<div className="space-y-4">
<div className="px-4 space-y-3">
<InputField
input={
<TextInput
value={searchQuery}
setValue={(e) => setSearchQuery(e.target.value)}
placeholder={t("spellList.search")}
/>
}
actionIcon={faPlus}
actionLabel={t("spellList.add")}
addButtonCallBack={async () => handleAddSpell()}
/>
<div className="flex flex-wrap gap-3 items-center">
<div className="flex-1 min-w-[150px]">
<select
value={filterTag}
onChange={(e) => setFilterTag(e.target.value)}
className="w-full text-text-primary bg-secondary/50 hover:bg-secondary px-4 py-2.5 rounded-xl border border-secondary/50 focus:border-primary focus:ring-4 focus:ring-primary/20 focus:bg-secondary hover:border-secondary outline-none transition-all duration-200 cursor-pointer"
>
<option value="all"
className="bg-tertiary text-text-primary">{t("spellList.allTags")}</option>
{tags.map((tag: SpellTagProps) => (
<option key={tag.id} value={tag.id} className="bg-tertiary text-text-primary">
{tag.name}
</option>
))}
</select>
</div>
<button
onClick={handleManageTags}
className="flex items-center gap-2 px-4 py-2.5 bg-secondary/50 rounded-xl border border-secondary/50 hover:bg-secondary hover:border-secondary hover:shadow-md hover:scale-105 transition-all duration-200"
>
<FontAwesomeIcon icon={faCog} className="text-primary w-4 h-4"/>
<span className="text-text-primary text-sm font-medium">{t("spellList.manageTags")}</span>
</button>
</div>
</div>
<div className="overflow-y-auto max-h-[calc(100vh-450px)] px-2">
{filteredSpells.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-10 h-10"/>
</div>
<h3 className="text-text-primary font-semibold text-lg mb-2">{t("spellList.noSpells")}</h3>
<p className="text-muted text-sm max-w-xs">{t("spellList.noSpellsDescription")}</p>
</div>
) : (
<div className="space-y-2 p-2">
{filteredSpells.map((spell: SpellListItem) => (
<div
key={spell.id}
onClick={() => handleSpellClick(spell)}
className="group flex items-center p-4 bg-secondary/30 rounded-xl border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md hover:scale-102 transition-all duration-200 hover:border-primary/50"
>
<div
className="w-12 h-12 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-6 h-6"/>
</div>
<div className="ml-4 flex-1 min-w-0">
<div
className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
{spell.name}
</div>
<div className="text-text-secondary text-sm mt-0.5 truncate">
{spell.description}
</div>
{spell.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{spell.tags.slice(0, 3).map((tag: SpellTagProps) => (
<SpellTagChip key={tag.id} tag={tag} size="sm"/>
))}
{spell.tags.length > 3 && (
<span
className="text-muted text-xs px-2 py-0.5 bg-secondary/50 rounded-full">
+{spell.tags.length - 3}
</span>
)}
</div>
)}
</div>
<div className="w-8 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,217 @@
'use client';
import React, {useCallback, useContext, useMemo, useState} from 'react';
import {useSpells, UseSpellsConfig} from '@/hooks/settings/useSpells';
import {useTranslations} from 'next-intl';
import {SpellEditState, SpellListItem} from '@/lib/models/Spell';
import {SeriesSpellListItem} from '@/lib/models/Series';
import {BookContext} from '@/context/BookContext';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons';
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
import InputField from '@/components/form/InputField';
import ToggleSwitch from '@/components/form/ToggleSwitch';
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
import AlertBox from '@/components/AlertBox';
import SpellTagManager from '@/components/book/settings/spells/SpellTagManager';
import SpellEditorList from './SpellEditorList';
import SpellEditorDetail from './SpellEditorDetail';
import SpellEditorEdit from './SpellEditorEdit';
/**
* SpellEditor - Orchestrateur pour ComposerRightBar
* Mêmes fonctionnalités que SpellSettings, layout condensé
*/
export default function SpellEditor(): React.JSX.Element {
const t = useTranslations();
const {book} = useContext(BookContext);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
const config: UseSpellsConfig = useMemo(function (): UseSpellsConfig {
return {
entityType: 'book',
entityId: book?.bookId || '',
};
}, [book?.bookId]);
const {
spells,
seriesSpells,
tags,
selectedSpell,
selectedSeriesSpell,
toolEnabled,
isLoading,
bookSeriesId,
showTagManager,
viewMode,
saveSpell,
deleteSpell,
updateSpellField,
toggleTool,
importFromSeries,
exportToSeries,
setSelectedSpell,
setShowTagManager,
enterDetailMode,
enterEditMode,
exitEditMode,
backToList,
addNewSpell,
createTag,
updateTag,
deleteTag,
handleSyncComplete,
} = useSpells(config);
const availableSeriesSpells = useMemo(function (): SeriesSpellListItem[] {
return seriesSpells.filter(function (ss: SeriesSpellListItem): boolean {
return !spells.some(function (s: SpellListItem): boolean {
return s.seriesSpellId === ss.id;
});
});
}, [seriesSpells, spells]);
const handleSpellChange = useCallback(function (key: keyof SpellEditState, value: string | string[] | null): void {
updateSpellField(key, value);
}, [updateSpellField]);
async function handleSave(): Promise<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
async function handleDelete(): Promise<void> {
if (selectedSpell?.id) {
await deleteSpell(selectedSpell.id);
setShowDeleteConfirm(false);
backToList();
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin"/>
</div>
);
}
const isNew: boolean = selectedSpell?.id === null;
const canExport: boolean = Boolean(bookSeriesId && selectedSpell?.id && !selectedSpell.seriesSpellId);
return (
<div className="flex flex-col h-full">
<ToolDetailHeader
title={selectedSpell?.name || ''}
defaultTitle={t('spellDetail.newSpell')}
viewMode={viewMode}
isNew={isNew}
onBack={backToList}
onEdit={enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onDelete={function (): void { setShowDeleteConfirm(true); }}
onExport={canExport ? exportToSeries : undefined}
showExport={canExport}
showDelete={Boolean(selectedSpell?.id)}
/>
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-3 p-2">
{/* Toggle tool */}
<div className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('spellComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
</div>
{toolEnabled && (
<>
{/* Import from series */}
{bookSeriesId && availableSeriesSpells.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesSpells.map(function (ss: SeriesSpellListItem) {
return {
id: ss.id,
name: ss.name
};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
)}
<SpellEditorList
spells={spells}
tags={tags}
onSpellClick={enterDetailMode}
onAddSpell={addNewSpell}
onManageTags={function (): void { setShowTagManager(true); }}
/>
</>
)}
</div>
)}
{viewMode === 'detail' && selectedSpell && (
<div className="p-4">
<SpellEditorDetail
spell={selectedSpell}
availableTags={tags}
seriesSpell={selectedSeriesSpell}
/>
</div>
)}
{viewMode === 'edit' && selectedSpell && (
<div className="p-4">
<SpellEditorEdit
spell={selectedSpell}
availableTags={tags}
onSpellChange={handleSpellChange}
onCreateTag={createTag}
seriesSpell={selectedSeriesSpell}
onSyncComplete={handleSyncComplete}
/>
</div>
)}
</div>
{showDeleteConfirm && selectedSpell?.id && (
<AlertBox
title={t('spellDetail.deleteTitle')}
message={t('spellDetail.deleteMessage', {name: selectedSpell.name})}
type="danger"
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={handleDelete}
onCancel={function (): void { setShowDeleteConfirm(false); }}
/>
)}
{showTagManager && (
<SpellTagManager
tags={tags}
onBack={function (): void { setShowTagManager(false); }}
onCreateTag={createTag}
onUpdateTag={updateTag}
onDeleteTag={deleteTag}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import React from 'react';
import {SpellEditState, spellPowerLevels, SpellTagProps} from '@/lib/models/Spell';
import {SeriesSpellDetailResponse} from '@/lib/models/Series';
import {SelectBoxProps} from '@/shared/interface';
import SpellTagChip from '@/components/book/settings/spells/SpellTagChip';
import {useTranslations} from 'next-intl';
interface SpellEditorDetailProps {
spell: SpellEditState;
availableTags: SpellTagProps[];
seriesSpell?: SeriesSpellDetailResponse | null;
}
/**
* SpellEditorDetail - Version sidebar lecture seule
* Layout linéaire simple, juste les infos essentielles empilées
* PAS de CollapsableArea, PAS de grids
*/
export default function SpellEditorDetail({
spell,
availableTags,
seriesSpell,
}: SpellEditorDetailProps): React.JSX.Element {
const t = useTranslations();
function getSelectedTags(): SpellTagProps[] {
return availableTags.filter(function (tag: SpellTagProps): boolean {
return spell.tags.includes(tag.id);
});
}
function getLocalizedPowerLevel(): string {
if (!spell.powerLevel || spell.powerLevel === 'none') {
return '';
}
const level: SelectBoxProps | undefined = spellPowerLevels.find(function (l: SelectBoxProps): boolean {
return l.value === spell.powerLevel;
});
return level ? t(level.label) : spell.powerLevel;
}
function renderField(label: string, value: string | null | undefined): React.JSX.Element | null {
if (!value) return null;
return (
<div className="mb-3">
<span className="text-text-secondary text-xs block mb-1">{label}</span>
<p className="text-text-primary text-sm whitespace-pre-wrap">{value}</p>
</div>
);
}
const selectedTags: SpellTagProps[] = getSelectedTags();
const powerLevelText: string = getLocalizedPowerLevel();
return (
<div>
<h3 className="text-text-primary font-semibold text-base mb-4">{spell.name}</h3>
{renderField(t('spellDetail.description'), spell.description)}
{renderField(t('spellDetail.appearance'), spell.appearance)}
{powerLevelText && renderField(t('spellDetail.powerLevel'), powerLevelText)}
{renderField(t('spellDetail.components'), spell.components)}
{renderField(t('spellDetail.limitations'), spell.limitations)}
{renderField(t('spellDetail.notes'), spell.notes)}
{selectedTags.length > 0 && (
<div className="mb-3">
<span className="text-text-secondary text-xs block mb-1">{t('spellDetail.tags')}</span>
<div className="flex flex-wrap gap-1.5">
{selectedTags.map(function (tag: SpellTagProps): React.JSX.Element {
return <SpellTagChip key={tag.id} tag={tag} size="sm"/>;
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,368 @@
'use client';
import React, {ChangeEvent, useState} from 'react';
import {defaultTagColors, SpellEditState, spellPowerLevels, SpellTagProps} from '@/lib/models/Spell';
import {SeriesSpellDetailResponse} from '@/lib/models/Series';
import {SelectBoxProps} from '@/shared/interface';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import TexteAreaInput from '@/components/form/TexteAreaInput';
import SelectBox from '@/components/form/SelectBox';
import SpellTagChip from '@/components/book/settings/spells/SpellTagChip';
import SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faPlus} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
interface SpellEditorEditProps {
spell: SpellEditState;
availableTags: SpellTagProps[];
onSpellChange: (key: keyof SpellEditState, value: string | string[] | null) => void;
onCreateTag: (name: string, color: string) => Promise<SpellTagProps | null>;
seriesSpell?: SeriesSpellDetailResponse | null;
onSyncComplete?: () => void;
}
/**
* SpellEditorEdit - Version sidebar édition
* Mêmes fonctionnalités que SpellSettingsEdit, layout linéaire
* Gestion des tags, SyncFieldWrapper, tous les champs
*/
export default function SpellEditorEdit({
spell,
availableTags,
onSpellChange,
onCreateTag,
seriesSpell,
onSyncComplete,
}: SpellEditorEditProps): React.JSX.Element {
const t = useTranslations();
const [tagSearchQuery, setTagSearchQuery] = useState<string>('');
const [isCreatingTag, setIsCreatingTag] = useState<boolean>(false);
const [newTagColor, setNewTagColor] = useState<string>(defaultTagColors[0]);
function handleAddTag(tagId: string): void {
if (!spell.tags.includes(tagId)) {
onSpellChange('tags', [...spell.tags, tagId]);
}
setTagSearchQuery('');
}
function handleRemoveTag(tagId: string): void {
onSpellChange('tags', spell.tags.filter(function (id: string): boolean {
return id !== tagId;
}));
}
function getFilteredAvailableTags(): SpellTagProps[] {
return availableTags.filter(function (tag: SpellTagProps): boolean {
const notAlreadyAdded: boolean = !spell.tags.includes(tag.id);
const matchesSearch: boolean = tag.name.toLowerCase().includes(tagSearchQuery.toLowerCase());
return notAlreadyAdded && matchesSearch;
});
}
function getSelectedTags(): SpellTagProps[] {
return availableTags.filter(function (tag: SpellTagProps): boolean {
return spell.tags.includes(tag.id);
});
}
async function handleCreateTag(): Promise<void> {
if (!tagSearchQuery.trim()) return;
const newTag: SpellTagProps | null = await onCreateTag(tagSearchQuery.trim(), newTagColor);
if (newTag) {
handleAddTag(newTag.id);
setIsCreatingTag(false);
setNewTagColor(defaultTagColors[0]);
}
}
function getLocalizedPowerLevels(): SelectBoxProps[] {
return spellPowerLevels.map(function (level: SelectBoxProps): SelectBoxProps {
return {
value: level.value,
label: t(level.label),
};
});
}
const filteredTags: SpellTagProps[] = getFilteredAvailableTags();
const selectedTags: SpellTagProps[] = getSelectedTags();
const showCreateOption: boolean = Boolean(
tagSearchQuery.trim() &&
!availableTags.some(function (tag: SpellTagProps): boolean {
return tag.name.toLowerCase() === tagSearchQuery.toLowerCase();
})
);
return (
<div className="space-y-4">
{/* Informations de base */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.basicInfo')}</h4>
<div className="space-y-3">
<InputField
fieldName={t('spellDetail.name')}
input={
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.name || ''}
currentValue={spell.name}
bookElementId={spell.id || ''}
field="name"
elementType="spell"
onDownload={function (): void { onSpellChange('name', seriesSpell?.name || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={spell.name}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onSpellChange('name', e.target.value);
}}
placeholder={t('spellDetail.namePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('spellDetail.description')}
input={
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.description || ''}
currentValue={spell.description}
bookElementId={spell.id || ''}
field="description"
elementType="spell"
onDownload={function (): void { onSpellChange('description', seriesSpell?.description || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.description}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('description', e.target.value);
}}
placeholder={t('spellDetail.descriptionPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('spellDetail.appearance')}
input={
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.appearance || ''}
currentValue={spell.appearance}
bookElementId={spell.id || ''}
field="appearance"
elementType="spell"
onDownload={function (): void { onSpellChange('appearance', seriesSpell?.appearance || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.appearance}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('appearance', e.target.value);
}}
placeholder={t('spellDetail.appearancePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
</div>
</div>
{/* Tags */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.tags')}</h4>
<div className="space-y-3">
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedTags.map(function (tag: SpellTagProps): React.JSX.Element {
return (
<SpellTagChip
key={tag.id}
tag={tag}
onRemove={function (): void { handleRemoveTag(tag.id); }}
/>
);
})}
</div>
)}
<TextInput
value={tagSearchQuery}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
setTagSearchQuery(e.target.value);
}}
placeholder={t('spellDetail.addTag')}
/>
{filteredTags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{filteredTags.map(function (tag: SpellTagProps): React.JSX.Element {
return (
<button
key={tag.id}
onClick={function (): void { handleAddTag(tag.id); }}
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-all duration-200 hover:scale-105 border"
style={{
backgroundColor: `${tag.color || '#3B82F6'}20`,
borderColor: `${tag.color || '#3B82F6'}50`,
color: tag.color || '#3B82F6'
}}
>
<FontAwesomeIcon icon={faPlus} className="w-2.5 h-2.5"/>
{tag.name}
</button>
);
})}
</div>
)}
{showCreateOption && !isCreatingTag && (
<button
onClick={function (): void { setIsCreatingTag(true); }}
className="w-full flex items-center gap-2 px-3 py-2 bg-tertiary hover:bg-secondary/50 transition-colors text-left rounded-lg border border-secondary/50"
>
<FontAwesomeIcon icon={faPlus} className="text-primary w-3 h-3"/>
<span className="text-primary font-medium text-sm">
{t('spellDetail.createTag', {name: tagSearchQuery})}
</span>
</button>
)}
{isCreatingTag && (
<div className="p-3 bg-tertiary rounded-lg border border-secondary/50">
<p className="text-text-secondary text-xs mb-2">
{t('spellDetail.createTag', {name: tagSearchQuery})}
</p>
<div className="flex flex-wrap gap-1.5 mb-2">
{defaultTagColors.map(function (color: string): React.JSX.Element {
return (
<button
key={color}
onClick={function (): void { setNewTagColor(color); }}
className={`w-6 h-6 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-1 ring-primary scale-110' : 'hover:scale-110'}`}
style={{backgroundColor: color}}
/>
);
})}
</div>
<div className="flex gap-2">
<button
onClick={function (): void { setIsCreatingTag(false); }}
className="flex-1 py-1.5 px-2 bg-secondary/50 text-text-primary rounded-lg hover:bg-secondary transition-colors text-sm"
>
{t('common.cancel')}
</button>
<button
onClick={handleCreateTag}
className="flex-1 py-1.5 px-2 bg-primary text-text-primary rounded-lg hover:bg-primary-dark transition-colors text-sm"
>
{t('common.confirm')}
</button>
</div>
</div>
)}
</div>
</div>
{/* Niveau de puissance */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.powerLevel')}</h4>
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.powerLevel || 'none'}
currentValue={spell.powerLevel || 'none'}
bookElementId={spell.id || ''}
field="powerLevel"
elementType="spell"
onDownload={function (): void { onSpellChange('powerLevel', seriesSpell?.powerLevel || null); }}
onSyncComplete={onSyncComplete}
>
<SelectBox
defaultValue={spell.powerLevel || 'none'}
onChangeCallBack={function (e: ChangeEvent<HTMLSelectElement>): void {
onSpellChange('powerLevel', e.target.value === 'none' ? null : e.target.value);
}}
data={getLocalizedPowerLevels()}
/>
</SyncFieldWrapper>
</div>
{/* Composants */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.components')}</h4>
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.components || ''}
currentValue={spell.components || ''}
bookElementId={spell.id || ''}
field="components"
elementType="spell"
onDownload={function (): void { onSpellChange('components', seriesSpell?.components || null); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.components || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('components', e.target.value || null);
}}
placeholder={t('spellDetail.componentsPlaceholder')}
/>
</SyncFieldWrapper>
</div>
{/* Limitations */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.limitations')}</h4>
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.limitations || ''}
currentValue={spell.limitations || ''}
bookElementId={spell.id || ''}
field="limitations"
elementType="spell"
onDownload={function (): void { onSpellChange('limitations', seriesSpell?.limitations || null); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.limitations || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('limitations', e.target.value || null);
}}
placeholder={t('spellDetail.limitationsPlaceholder')}
/>
</SyncFieldWrapper>
</div>
{/* Notes */}
<div className="pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.notes')}</h4>
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.notes || ''}
currentValue={spell.notes || ''}
bookElementId={spell.id || ''}
field="notes"
elementType="spell"
onDownload={function (): void { onSpellChange('notes', seriesSpell?.notes || null); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.notes || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('notes', e.target.value || null);
}}
placeholder={t('spellDetail.notesPlaceholder')}
/>
</SyncFieldWrapper>
</div>
</div>
);
}

View File

@@ -0,0 +1,163 @@
'use client';
import React, {useState} from 'react';
import {SpellListItem, SpellTagProps} from '@/lib/models/Spell';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import SpellTagChip from '@/components/book/settings/spells/SpellTagChip';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faChevronRight, faHatWizard, faPlus, faTags} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
interface SpellEditorListProps {
spells: SpellListItem[];
tags: SpellTagProps[];
onSpellClick: (spell: SpellListItem) => void;
onAddSpell: () => void;
onManageTags: () => void;
}
/**
* SpellEditorList - Liste compacte pour ComposerRightBar
* Mêmes fonctionnalités que SpellSettingsList, layout condensé
*/
export default function SpellEditorList({
spells,
tags,
onSpellClick,
onAddSpell,
onManageTags,
}: SpellEditorListProps): React.JSX.Element {
const t = useTranslations();
const [searchQuery, setSearchQuery] = useState<string>('');
const [selectedTagId, setSelectedTagId] = useState<string | null>(null);
function getFilteredSpells(): SpellListItem[] {
return spells.filter(function (spell: SpellListItem): boolean {
const matchesSearch: boolean = spell.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesTag: boolean = selectedTagId === null || spell.tags.some(function (tag: SpellTagProps): boolean {
return tag.id === selectedTagId;
});
return matchesSearch && matchesTag;
});
}
const filteredSpells: SpellListItem[] = getFilteredSpells();
return (
<div className="space-y-3">
<div className="px-2">
<InputField
input={
<TextInput
value={searchQuery}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setSearchQuery(e.target.value);
}}
placeholder={t('spellList.search')}
/>
}
actionIcon={faPlus}
actionLabel={t('spellList.add')}
addButtonCallBack={async function (): Promise<void> {
onAddSpell();
}}
/>
</div>
{/* Tag filter + manage button */}
<div className="px-2 flex items-center gap-2 flex-wrap">
<button
onClick={function (): void { setSelectedTagId(null); }}
className={`px-2 py-1 text-xs rounded-full transition-colors ${
selectedTagId === null
? 'bg-primary text-white'
: 'bg-secondary/50 text-text-secondary hover:bg-secondary'
}`}
>
{t('spellList.allTags')}
</button>
{tags.slice(0, 4).map(function (tag: SpellTagProps): React.JSX.Element {
return (
<button
key={tag.id}
onClick={function (): void { setSelectedTagId(tag.id === selectedTagId ? null : tag.id); }}
className={`px-2 py-1 text-xs rounded-full transition-colors ${
selectedTagId === tag.id
? 'bg-primary text-white'
: 'bg-secondary/50 text-text-secondary hover:bg-secondary'
}`}
style={selectedTagId === tag.id ? {} : {borderLeft: `3px solid ${tag.color}`}}
>
{tag.name}
</button>
);
})}
<button
onClick={onManageTags}
className="px-2 py-1 text-xs rounded-full bg-secondary/30 text-text-secondary hover:bg-secondary transition-colors flex items-center gap-1"
>
<FontAwesomeIcon icon={faTags} className="w-3 h-3"/>
{t('spellList.manageTags')}
</button>
</div>
<div className="px-2 space-y-2">
{filteredSpells.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-8 h-8"/>
</div>
<h3 className="text-text-primary font-semibold text-base mb-1">
{t('spellList.noSpells')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('spellList.noSpellsDescription')}
</p>
</div>
) : (
filteredSpells.map(function (spell: SpellListItem): React.JSX.Element {
return (
<div
key={spell.id}
onClick={function (): void { onSpellClick(spell); }}
className="group flex items-center p-3 bg-secondary/30 rounded-lg border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md transition-all duration-200 hover:border-primary/50"
>
<div className="w-10 h-10 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-sm group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-5 h-5"/>
</div>
<div className="ml-3 flex-1 min-w-0">
<div className="text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate">
{spell.name}
</div>
<div className="text-muted text-xs truncate">
{spell.description}
</div>
{spell.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{spell.tags.slice(0, 2).map(function (tag: SpellTagProps): React.JSX.Element {
return <SpellTagChip key={tag.id} tag={tag} size="sm"/>;
})}
{spell.tags.length > 2 && (
<span className="text-muted text-xs px-1.5 py-0.5 bg-secondary/50 rounded-full">
+{spell.tags.length - 2}
</span>
)}
</div>
)}
</div>
<div className="w-6 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3"
/>
</div>
</div>
);
})
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,243 @@
'use client';
import React, {useCallback, useContext, useMemo, useState} from 'react';
import {useSpells, UseSpellsConfig} from '@/hooks/settings/useSpells';
import {useTranslations} from 'next-intl';
import {SpellEditState, SpellListItem, SpellTagProps} from '@/lib/models/Spell';
import {SeriesSpellListItem} from '@/lib/models/Series';
import {BookContext} from '@/context/BookContext';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons';
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
import InputField from '@/components/form/InputField';
import ToggleSwitch from '@/components/form/ToggleSwitch';
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
import AlertBox from '@/components/AlertBox';
import SpellTagManager from '@/components/book/settings/spells/SpellTagManager';
import SpellSettingsList from './SpellSettingsList';
import SpellSettingsDetail from './SpellSettingsDetail';
import SpellSettingsEdit from './SpellSettingsEdit';
interface SpellSettingsProps {
entityType?: 'book' | 'series';
entityId?: string;
showToggle?: boolean;
}
/**
* SpellSettings - Orchestrateur pour BookSetting/SerieSetting
* Gère le viewMode (list/detail/edit) et coordonne les sous-composants
* Inclut: toggle tool, import from series, tag manager
*/
export default function SpellSettings({
entityType = 'book',
entityId,
showToggle = true,
}: SpellSettingsProps): React.JSX.Element {
const t = useTranslations();
const {book} = useContext(BookContext);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
const resolvedEntityId: string = entityId || book?.bookId || '';
const config: UseSpellsConfig = useMemo(function (): UseSpellsConfig {
return {
entityType,
entityId: resolvedEntityId,
};
}, [entityType, resolvedEntityId]);
const {
spells,
seriesSpells,
tags,
selectedSpell,
selectedSeriesSpell,
toolEnabled,
isLoading,
isSeriesMode,
bookSeriesId,
showTagManager,
viewMode,
saveSpell,
deleteSpell,
updateSpellField,
toggleTool,
importFromSeries,
exportToSeries,
refreshSeriesSpells,
setSelectedSpell,
setShowTagManager,
enterDetailMode,
enterEditMode,
exitEditMode,
backToList,
addNewSpell,
createTag,
updateTag,
deleteTag,
handleSyncComplete,
} = useSpells(config);
const availableSeriesSpells = useMemo(function (): SeriesSpellListItem[] {
return seriesSpells.filter(function (ss: SeriesSpellListItem): boolean {
return !spells.some(function (s: SpellListItem): boolean {
return s.seriesSpellId === ss.id;
});
});
}, [seriesSpells, spells]);
const handleSpellChange = useCallback(function (key: keyof SpellEditState, value: string | string[] | null): void {
updateSpellField(key, value);
}, [updateSpellField]);
async function handleSave(): Promise<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
async function handleDelete(): Promise<void> {
if (selectedSpell?.id) {
await deleteSpell(selectedSpell.id);
setShowDeleteConfirm(false);
backToList();
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
</div>
);
}
const isNew: boolean = selectedSpell?.id === null;
const canExport: boolean = Boolean(bookSeriesId && selectedSpell?.id && !selectedSpell.seriesSpellId);
return (
<div className="flex flex-col h-full">
{/* Header - uniquement pour detail/edit */}
<ToolDetailHeader
title={selectedSpell?.name || ''}
defaultTitle={t('spellDetail.newSpell')}
viewMode={viewMode}
isNew={isNew}
onBack={backToList}
onEdit={enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onDelete={function (): void { setShowDeleteConfirm(true); }}
onExport={canExport ? exportToSeries : undefined}
showExport={canExport}
showDelete={Boolean(selectedSpell?.id)}
/>
{/* Contenu principal */}
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-5 p-4">
{/* Toggle tool */}
{showToggle && !isSeriesMode && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('spellComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
<p className="text-muted text-sm mt-2">
{t('spellComponent.enableToolDescription')}
</p>
</div>
)}
{/* Contenu si outil activé */}
{(toolEnabled || isSeriesMode) && (
<>
{/* Import from series */}
{!isSeriesMode && bookSeriesId && availableSeriesSpells.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesSpells.map(function (ss: SeriesSpellListItem) {
return {
id: ss.id,
name: ss.name
};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
)}
{/* Liste des sorts */}
<SpellSettingsList
spells={spells}
tags={tags}
onSpellClick={enterDetailMode}
onAddSpell={addNewSpell}
onManageTags={function (): void { setShowTagManager(true); }}
/>
</>
)}
</div>
)}
{viewMode === 'detail' && selectedSpell && (
<div className="p-4">
<SpellSettingsDetail
spell={selectedSpell}
availableTags={tags}
seriesSpell={selectedSeriesSpell}
/>
</div>
)}
{viewMode === 'edit' && selectedSpell && (
<div className="p-4">
<SpellSettingsEdit
spell={selectedSpell}
availableTags={tags}
onSpellChange={handleSpellChange}
onCreateTag={createTag}
seriesSpell={selectedSeriesSpell}
onSyncComplete={handleSyncComplete}
/>
</div>
)}
</div>
{/* Modal de confirmation de suppression */}
{showDeleteConfirm && selectedSpell?.id && (
<AlertBox
title={t('spellDetail.deleteTitle')}
message={t('spellDetail.deleteMessage', {name: selectedSpell.name})}
type="danger"
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={handleDelete}
onCancel={function (): void { setShowDeleteConfirm(false); }}
/>
)}
{/* Tag Manager Modal */}
{showTagManager && (
<SpellTagManager
tags={tags}
onBack={function (): void { setShowTagManager(false); }}
onCreateTag={createTag}
onUpdateTag={updateTag}
onDeleteTag={deleteTag}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,150 @@
'use client';
import React from 'react';
import {SpellEditState, spellPowerLevels, SpellTagProps} from '@/lib/models/Spell';
import {SeriesSpellDetailResponse} from '@/lib/models/Series';
import {SelectBoxProps} from '@/shared/interface';
import SpellTagChip from '@/components/book/settings/spells/SpellTagChip';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {
faBolt,
faEye,
faHatWizard,
faPuzzlePiece,
faStickyNote,
faTags,
faTriangleExclamation,
faWandMagicSparkles
} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
interface SpellSettingsDetailProps {
spell: SpellEditState;
availableTags: SpellTagProps[];
seriesSpell?: SeriesSpellDetailResponse | null;
}
export default function SpellSettingsDetail({
spell,
availableTags,
seriesSpell,
}: SpellSettingsDetailProps): React.JSX.Element {
const t = useTranslations();
function getSelectedTags(): SpellTagProps[] {
return availableTags.filter(function (tag: SpellTagProps): boolean {
return spell.tags.includes(tag.id);
});
}
function getLocalizedPowerLevel(): string {
if (!spell.powerLevel || spell.powerLevel === 'none') {
return t('spellPowerLevels.none');
}
const level: SelectBoxProps | undefined = spellPowerLevels.find(function (l: SelectBoxProps): boolean {
return l.value === spell.powerLevel;
});
return level ? t(level.label) : spell.powerLevel;
}
function getPowerLevelColor(): string {
switch (spell.powerLevel) {
case 'weak': return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'moderate': return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
case 'strong': return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
case 'legendary': return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
default: return 'bg-secondary/50 text-text-secondary border-secondary/50';
}
}
const selectedTags: SpellTagProps[] = getSelectedTags();
return (
<div className="space-y-6 px-2 pb-4">
{/* Hero Section */}
<div className="p-6 bg-gradient-to-r from-primary/10 via-secondary/20 to-transparent rounded-2xl border border-secondary/30">
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl bg-primary/20 flex items-center justify-center shrink-0">
<FontAwesomeIcon icon={faWandMagicSparkles} className="w-8 h-8 text-primary"/>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-bold text-text-primary">{spell.name || '—'}</h2>
{/* Power Level Badge */}
<div className="flex items-center gap-3 mt-3">
<span className={`inline-flex items-center gap-2 px-3 py-1 rounded-lg text-sm border ${getPowerLevelColor()}`}>
<FontAwesomeIcon icon={faBolt} className="w-3 h-3"/>
{getLocalizedPowerLevel()}
</span>
</div>
{/* Tags */}
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4">
{selectedTags.map(function (tag: SpellTagProps): React.JSX.Element {
return <SpellTagChip key={tag.id} tag={tag}/>;
})}
</div>
)}
</div>
</div>
</div>
{/* Description & Appearance - Side by side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faHatWizard} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('spellDetail.description')}</h3>
</div>
<p className={`whitespace-pre-wrap ${spell.description ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{spell.description || '—'}
</p>
</div>
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faEye} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('spellDetail.appearance')}</h3>
</div>
<p className={`whitespace-pre-wrap ${spell.appearance ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{spell.appearance || '—'}
</p>
</div>
</div>
{/* Components & Limitations - Side by side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faPuzzlePiece} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('spellDetail.components')}</h3>
</div>
<p className={`whitespace-pre-wrap ${spell.components ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{spell.components || '—'}
</p>
</div>
<div className="p-5 bg-error/10 rounded-xl border border-error/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faTriangleExclamation} className="w-4 h-4 text-error"/>
<h3 className="text-text-primary font-semibold">{t('spellDetail.limitations')}</h3>
</div>
<p className={`whitespace-pre-wrap ${spell.limitations ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{spell.limitations || '—'}
</p>
</div>
</div>
{/* Notes - Full width */}
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faStickyNote} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('spellDetail.notes')}</h3>
</div>
<p className={`whitespace-pre-wrap ${spell.notes ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{spell.notes || '—'}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,384 @@
'use client';
import React, {useState} from 'react';
import {defaultTagColors, SpellEditState, spellPowerLevels, SpellTagProps} from '@/lib/models/Spell';
import {SeriesSpellDetailResponse} from '@/lib/models/Series';
import {SelectBoxProps} from '@/shared/interface';
import CollapsableArea from '@/components/CollapsableArea';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import TexteAreaInput from '@/components/form/TexteAreaInput';
import SelectBox from '@/components/form/SelectBox';
import SpellTagChip from '@/components/book/settings/spells/SpellTagChip';
import SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {
faBolt,
faBook,
faEye,
faHatWizard,
faPlus,
faPuzzlePiece,
faStickyNote,
faTags,
faTriangleExclamation
} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
interface SpellSettingsEditProps {
spell: SpellEditState;
availableTags: SpellTagProps[];
onSpellChange: (key: keyof SpellEditState, value: string | string[] | null) => void;
onCreateTag: (name: string, color: string) => Promise<SpellTagProps | null>;
seriesSpell?: SeriesSpellDetailResponse | null;
onSyncComplete?: () => void;
}
/**
* SpellSettingsEdit - Vue édition pour BookSetting/SerieSetting
* Tous les champs avec SyncFieldWrapper
* Gestion des tags inline
* PAS de scroll interne (géré par parent)
*/
export default function SpellSettingsEdit({
spell,
availableTags,
onSpellChange,
onCreateTag,
seriesSpell,
onSyncComplete,
}: SpellSettingsEditProps): React.JSX.Element {
const t = useTranslations();
const [tagSearchQuery, setTagSearchQuery] = useState<string>('');
const [isCreatingTag, setIsCreatingTag] = useState<boolean>(false);
const [newTagColor, setNewTagColor] = useState<string>(defaultTagColors[0]);
function handleAddTag(tagId: string): void {
if (!spell.tags.includes(tagId)) {
onSpellChange('tags', [...spell.tags, tagId]);
}
setTagSearchQuery('');
}
function handleRemoveTag(tagId: string): void {
onSpellChange('tags', spell.tags.filter(function (id: string): boolean {
return id !== tagId;
}));
}
function getFilteredAvailableTags(): SpellTagProps[] {
return availableTags.filter(function (tag: SpellTagProps): boolean {
const notAlreadyAdded: boolean = !spell.tags.includes(tag.id);
const matchesSearch: boolean = tag.name.toLowerCase().includes(tagSearchQuery.toLowerCase());
return notAlreadyAdded && matchesSearch;
});
}
function getSelectedTags(): SpellTagProps[] {
return availableTags.filter(function (tag: SpellTagProps): boolean {
return spell.tags.includes(tag.id);
});
}
async function handleCreateTag(): Promise<void> {
if (!tagSearchQuery.trim()) return;
const newTag: SpellTagProps | null = await onCreateTag(tagSearchQuery.trim(), newTagColor);
if (newTag) {
handleAddTag(newTag.id);
setIsCreatingTag(false);
setNewTagColor(defaultTagColors[0]);
}
}
function getLocalizedPowerLevels(): SelectBoxProps[] {
return spellPowerLevels.map(function (level: SelectBoxProps): SelectBoxProps {
return {
value: level.value,
label: t(level.label),
};
});
}
const filteredTags: SpellTagProps[] = getFilteredAvailableTags();
const selectedTags: SpellTagProps[] = getSelectedTags();
const showCreateOption: boolean = Boolean(
tagSearchQuery.trim() &&
!availableTags.some(function (tag: SpellTagProps): boolean {
return tag.name.toLowerCase() === tagSearchQuery.toLowerCase();
})
);
return (
<div className="space-y-4 px-2 pb-4">
{/* Informations de base */}
<CollapsableArea title={t('spellDetail.basicInfo')} icon={faHatWizard}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t('spellDetail.name')}
input={
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.name || ''}
currentValue={spell.name}
bookElementId={spell.id || ''}
field="name"
elementType="spell"
onDownload={function (): void { onSpellChange('name', seriesSpell?.name || ''); }}
onSyncComplete={onSyncComplete}
>
<TextInput
value={spell.name}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
onSpellChange('name', e.target.value);
}}
placeholder={t('spellDetail.namePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('spellDetail.description')}
icon={faBook}
input={
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.description || ''}
currentValue={spell.description}
bookElementId={spell.id || ''}
field="description"
elementType="spell"
onDownload={function (): void { onSpellChange('description', seriesSpell?.description || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.description}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('description', e.target.value);
}}
placeholder={t('spellDetail.descriptionPlaceholder')}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t('spellDetail.appearance')}
icon={faEye}
input={
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.appearance || ''}
currentValue={spell.appearance}
bookElementId={spell.id || ''}
field="appearance"
elementType="spell"
onDownload={function (): void { onSpellChange('appearance', seriesSpell?.appearance || ''); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.appearance}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('appearance', e.target.value);
}}
placeholder={t('spellDetail.appearancePlaceholder')}
/>
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
{/* Tags */}
<CollapsableArea title={t('spellDetail.tags')} icon={faTags}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{selectedTags.map(function (tag: SpellTagProps): React.JSX.Element {
return (
<SpellTagChip
key={tag.id}
tag={tag}
onRemove={function (): void { handleRemoveTag(tag.id); }}
/>
);
})}
</div>
)}
<TextInput
value={tagSearchQuery}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setTagSearchQuery(e.target.value);
}}
placeholder={t('spellDetail.addTag')}
/>
{filteredTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{filteredTags.map(function (tag: SpellTagProps): React.JSX.Element {
return (
<button
key={tag.id}
onClick={function (): void { handleAddTag(tag.id); }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm transition-all duration-200 hover:scale-105 hover:shadow-md border"
style={{
backgroundColor: `${tag.color || '#3B82F6'}20`,
borderColor: `${tag.color || '#3B82F6'}50`,
color: tag.color || '#3B82F6'
}}
>
<FontAwesomeIcon icon={faPlus} className="w-3 h-3"/>
{tag.name}
</button>
);
})}
</div>
)}
{showCreateOption && !isCreatingTag && (
<button
onClick={function (): void { setIsCreatingTag(true); }}
className="w-full flex items-center gap-2 px-4 py-2.5 bg-tertiary hover:bg-secondary/50 transition-colors text-left rounded-xl border border-secondary/50"
>
<FontAwesomeIcon icon={faPlus} className="text-primary w-3 h-3"/>
<span className="text-primary font-medium">
{t('spellDetail.createTag', {name: tagSearchQuery})}
</span>
</button>
)}
{isCreatingTag && (
<div className="p-4 bg-tertiary rounded-xl border border-secondary/50">
<p className="text-text-secondary text-sm mb-3">
{t('spellDetail.createTag', {name: tagSearchQuery})}
</p>
<div className="flex flex-wrap gap-2 mb-3">
{defaultTagColors.map(function (color: string): React.JSX.Element {
return (
<button
key={color}
onClick={function (): void { setNewTagColor(color); }}
className={`w-8 h-8 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-2 ring-primary scale-110' : 'hover:scale-110'}`}
style={{backgroundColor: color}}
/>
);
})}
</div>
<div className="flex gap-2">
<button
onClick={function (): void { setIsCreatingTag(false); }}
className="flex-1 py-2 px-3 bg-secondary/50 text-text-primary rounded-lg hover:bg-secondary transition-colors"
>
{t('common.cancel')}
</button>
<button
onClick={handleCreateTag}
className="flex-1 py-2 px-3 bg-primary text-text-primary rounded-lg hover:bg-primary-dark transition-colors"
>
{t('common.confirm')}
</button>
</div>
</div>
)}
</div>
</CollapsableArea>
{/* Niveau de puissance */}
<CollapsableArea title={t('spellDetail.powerLevel')} icon={faBolt}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.powerLevel || 'none'}
currentValue={spell.powerLevel || 'none'}
bookElementId={spell.id || ''}
field="powerLevel"
elementType="spell"
onDownload={function (): void { onSpellChange('powerLevel', seriesSpell?.powerLevel || null); }}
onSyncComplete={onSyncComplete}
>
<SelectBox
defaultValue={spell.powerLevel || 'none'}
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
onSpellChange('powerLevel', e.target.value === 'none' ? null : e.target.value);
}}
data={getLocalizedPowerLevels()}
/>
</SyncFieldWrapper>
</div>
</CollapsableArea>
{/* Composants */}
<CollapsableArea title={t('spellDetail.components')} icon={faPuzzlePiece}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.components || ''}
currentValue={spell.components || ''}
bookElementId={spell.id || ''}
field="components"
elementType="spell"
onDownload={function (): void { onSpellChange('components', seriesSpell?.components || null); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.components || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('components', e.target.value || null);
}}
placeholder={t('spellDetail.componentsPlaceholder')}
/>
</SyncFieldWrapper>
</div>
</CollapsableArea>
{/* Limitations */}
<CollapsableArea title={t('spellDetail.limitations')} icon={faTriangleExclamation}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.limitations || ''}
currentValue={spell.limitations || ''}
bookElementId={spell.id || ''}
field="limitations"
elementType="spell"
onDownload={function (): void { onSpellChange('limitations', seriesSpell?.limitations || null); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.limitations || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('limitations', e.target.value || null);
}}
placeholder={t('spellDetail.limitationsPlaceholder')}
/>
</SyncFieldWrapper>
</div>
</CollapsableArea>
{/* Notes */}
<CollapsableArea title={t('spellDetail.notes')} icon={faStickyNote}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<SyncFieldWrapper
seriesElementId={spell.seriesSpellId}
seriesValue={seriesSpell?.notes || ''}
currentValue={spell.notes || ''}
bookElementId={spell.id || ''}
field="notes"
elementType="spell"
onDownload={function (): void { onSpellChange('notes', seriesSpell?.notes || null); }}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={spell.notes || ''}
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
onSpellChange('notes', e.target.value || null);
}}
placeholder={t('spellDetail.notesPlaceholder')}
/>
</SyncFieldWrapper>
</div>
</CollapsableArea>
</div>
);
}

View File

@@ -0,0 +1,159 @@
'use client';
import React, {useState} from 'react';
import {SpellListItem, SpellTagProps} from '@/lib/models/Spell';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import SpellTagChip from '@/components/book/settings/spells/SpellTagChip';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faChevronRight, faCog, faHatWizard, faPlus} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
interface SpellSettingsListProps {
spells: SpellListItem[];
tags: SpellTagProps[];
onSpellClick: (spell: SpellListItem) => void;
onAddSpell: () => void;
onManageTags: () => void;
}
/**
* SpellSettingsList - Liste des sorts pour BookSetting/SerieSetting
* Inclut recherche, filtre par tag, et gestion des tags
* PAS de scroll interne (géré par parent)
*/
export default function SpellSettingsList({
spells,
tags,
onSpellClick,
onAddSpell,
onManageTags,
}: SpellSettingsListProps): React.JSX.Element {
const t = useTranslations();
const [searchQuery, setSearchQuery] = useState<string>('');
const [filterTag, setFilterTag] = useState<string>('all');
function getFilteredSpells(): SpellListItem[] {
return spells.filter(function (spell: SpellListItem): boolean {
const matchesSearch: boolean = spell.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesTag: boolean = filterTag === 'all' || spell.tags.some(function (tag: SpellTagProps): boolean {
return tag.id === filterTag;
});
return matchesSearch && matchesTag;
});
}
const filteredSpells: SpellListItem[] = getFilteredSpells();
return (
<div className="space-y-4">
<div className="px-4 space-y-3">
<InputField
input={
<TextInput
value={searchQuery}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setSearchQuery(e.target.value);
}}
placeholder={t('spellList.search')}
/>
}
actionIcon={faPlus}
actionLabel={t('spellList.add')}
addButtonCallBack={async function (): Promise<void> {
onAddSpell();
}}
/>
<div className="flex flex-wrap gap-3 items-center">
<div className="flex-1 min-w-[150px]">
<select
value={filterTag}
onChange={function (e: React.ChangeEvent<HTMLSelectElement>): void {
setFilterTag(e.target.value);
}}
className="w-full text-text-primary bg-secondary/50 hover:bg-secondary px-4 py-2.5 rounded-xl border border-secondary/50 focus:border-primary focus:ring-4 focus:ring-primary/20 focus:bg-secondary hover:border-secondary outline-none transition-all duration-200 cursor-pointer"
>
<option value="all" className="bg-tertiary text-text-primary">
{t('spellList.allTags')}
</option>
{tags.map(function (tag: SpellTagProps): React.JSX.Element {
return (
<option key={tag.id} value={tag.id} className="bg-tertiary text-text-primary">
{tag.name}
</option>
);
})}
</select>
</div>
<button
onClick={onManageTags}
className="flex items-center gap-2 px-4 py-2.5 bg-secondary/50 rounded-xl border border-secondary/50 hover:bg-secondary hover:border-secondary hover:shadow-md hover:scale-105 transition-all duration-200"
>
<FontAwesomeIcon icon={faCog} className="text-primary w-4 h-4"/>
<span className="text-text-primary text-sm font-medium">{t('spellList.manageTags')}</span>
</button>
</div>
</div>
<div className="px-2">
{filteredSpells.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-10 h-10"/>
</div>
<h3 className="text-text-primary font-semibold text-lg mb-2">
{t('spellList.noSpells')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('spellList.noSpellsDescription')}
</p>
</div>
) : (
<div className="space-y-2 p-2">
{filteredSpells.map(function (spell: SpellListItem): React.JSX.Element {
return (
<div
key={spell.id}
onClick={function (): void { onSpellClick(spell); }}
className="group flex items-center p-4 bg-secondary/30 rounded-xl border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md hover:scale-102 transition-all duration-200 hover:border-primary/50"
>
<div className="w-12 h-12 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-6 h-6"/>
</div>
<div className="ml-4 flex-1 min-w-0">
<div className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
{spell.name}
</div>
<div className="text-text-secondary text-sm mt-0.5 truncate">
{spell.description}
</div>
{spell.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{spell.tags.slice(0, 3).map(function (tag: SpellTagProps): React.JSX.Element {
return <SpellTagChip key={tag.id} tag={tag} size="sm"/>;
})}
{spell.tags.length > 3 && (
<span className="text-muted text-xs px-2 py-0.5 bg-secondary/50 rounded-full">
+{spell.tags.length - 3}
</span>
)}
</div>
)}
</div>
<div className="w-8 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"
/>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -86,9 +86,11 @@ export default function MainChapter({chapters, setChapters}: MainChapterProps) {
try {
setDeleteConfirmMessage(false);
let response: boolean;
const deletedAt: number = System.timeStampInSeconds();
const deleteData = {
bookId,
chapterId: chapterIdToRemove,
deletedAt,
};
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:chapter:remove', deleteData);

View File

@@ -16,12 +16,33 @@ import {BookContext} from "@/context/BookContext";
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
import {SyncedBook} from "@/lib/models/SyncedBook";
import {SeriesContext, SeriesContextProps} from "@/context/SeriesContext";
import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext";
import {SyncedSeries} from "@/lib/models/SyncedSeries";
interface WorldElementInputProps {
sectionLabel: string;
sectionType: string;
}
function getElementTypeNumber(sectionType: string): number {
const typeMap: { [key: string]: number } = {
'laws': 0,
'biomes': 1,
'issues': 2,
'customs': 3,
'kingdoms': 4,
'climate': 5,
'resources': 6,
'wildlife': 7,
'arts': 8,
'ethnicGroups': 9,
'socialClasses': 10,
'importantCharacters': 11,
};
return typeMap[sectionType] ?? 0;
}
export default function WorldElementComponent({sectionLabel, sectionType}: WorldElementInputProps) {
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
@@ -29,9 +50,11 @@ export default function WorldElementComponent({sectionLabel, sectionType}: World
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {book} = useContext(BookContext);
const {worlds, setWorlds, selectedWorldIndex} = useContext(WorldContext);
const {errorMessage, successMessage} = useContext(AlertContext);
const {worlds, setWorlds, selectedWorldIndex, isSeriesMode} = useContext(WorldContext);
const {errorMessage} = useContext(AlertContext);
const {session} = useContext(SessionContext);
const {seriesId, localSeries} = useContext<SeriesContextProps>(SeriesContext);
const {localSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
const [newElementName, setNewElementName] = useState<string>('');
@@ -42,7 +65,17 @@ export default function WorldElementComponent({sectionLabel, sectionType}: World
try {
let response: boolean;
const elementId = (worlds[selectedWorldIndex][section] as WorldElement[])[index].id;
if (isCurrentlyOffline() || book?.localBook) {
if (isSeriesMode) {
const deleteData = {elementId: elementId};
if (isCurrentlyOffline() || localSeries) {
response = await window.electron.invoke<boolean>('db:series:world:element:delete', deleteData);
} else {
response = await System.authDeleteToServer<boolean>('series/world/element/delete', deleteData, session.accessToken, lang);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
addToQueue('db:series:world:element:delete', deleteData);
}
}
} else if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:book:world:element:remove', {
elementId: elementId,
});
@@ -82,7 +115,30 @@ export default function WorldElementComponent({sectionLabel, sectionType}: World
}
try {
let elementId: string;
if (isCurrentlyOffline() || book?.localBook) {
if (isSeriesMode) {
const addData = {
worldId: worlds[selectedWorldIndex].id,
elementType: getElementTypeNumber(section as string),
name: newElementName,
};
if (isCurrentlyOffline() || localSeries) {
elementId = await window.electron.invoke<string>('db:series:world:element:add', addData);
} else {
elementId = await System.authPostToServer<string>(
'series/world/element/add',
addData,
session.accessToken,
lang
);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) {
addToQueue('db:series:world:element:add', addData);
}
}
if (!elementId) {
errorMessage(t("worldSetting.unknownError"))
return;
}
} else if (isCurrentlyOffline() || book?.localBook) {
elementId = await window.electron.invoke<string>('db:book:world:element:add', {
elementType: section,
worldId: worlds[selectedWorldIndex].id,

View File

@@ -1,13 +1,13 @@
'use client'
import {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react';
import React, {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react';
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faPlus, faToggleOn, IconDefinition} from "@fortawesome/free-solid-svg-icons";
import {faPlus, faShare, faToggleOn, IconDefinition} from "@fortawesome/free-solid-svg-icons";
import {WorldContext} from '@/context/WorldContext';
import {BookContext} from "@/context/BookContext";
import {AlertContext} from "@/context/AlertContext";
import {SelectBoxProps} from "@/shared/interface";
import System from "@/lib/models/System";
import {elementSections, WorldProps, WorldListResponse} from "@/lib/models/World";
import {elementSections, WorldListResponse, WorldProps} from "@/lib/models/World";
import {SessionContext} from "@/context/SessionContext";
import InputField from "@/components/form/InputField";
import TextInput from '@/components/form/TextInput';
@@ -21,6 +21,9 @@ import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQ
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
import {SyncedBook} from "@/lib/models/SyncedBook";
import ToggleSwitch from "@/components/form/ToggleSwitch";
import {SeriesWorldProps, SeriesWorldListItem} from "@/lib/models/Series";
import SeriesImportSelector from "@/components/form/SeriesImportSelector";
import SyncFieldWrapper from "@/components/form/SyncFieldWrapper";
export interface ElementSection {
title: string;
@@ -28,7 +31,14 @@ export interface ElementSection {
icon: IconDefinition;
}
export function WorldSetting({showToggle = true}: {showToggle?: boolean}, ref: any) {
interface WorldSettingProps {
showToggle?: boolean;
entityType?: 'book' | 'series';
entityId?: string;
}
export function WorldSetting(props: WorldSettingProps, ref: React.Ref<{ handleSave: () => Promise<void> }>) {
const {showToggle = true, entityType = 'book', entityId} = props;
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
@@ -37,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);

View File

@@ -0,0 +1,226 @@
'use client';
import React, {useCallback, useContext, useMemo, useState} from 'react';
import {useWorlds, UseWorldsConfig} from '@/hooks/settings/useWorlds';
import {useTranslations} from 'next-intl';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faPlus, faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons';
import {BookContext} from '@/context/BookContext';
import {WorldProps} from '@/lib/models/World';
import {SeriesWorldProps} from '@/lib/models/Series';
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
import AlertBox from '@/components/AlertBox';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import ToggleSwitch from '@/components/form/ToggleSwitch';
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
import WorldEditorList from './WorldEditorList';
import WorldEditorDetail from './WorldEditorDetail';
import WorldEditorEdit from './WorldEditorEdit';
/**
* WorldEditor - Orchestrateur pour ComposerRightBar
* Mêmes fonctionnalités que WorldSettings, layout condensé
* Inclut: toggle tool, import from series, export to series
*/
export default function WorldEditor(): React.JSX.Element {
const t = useTranslations();
const {book} = useContext(BookContext);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
const [showAddForm, setShowAddForm] = useState<boolean>(false);
const config: UseWorldsConfig = useMemo(function (): UseWorldsConfig {
return {
entityType: 'book',
entityId: book?.bookId || '',
};
}, [book?.bookId]);
const {
worlds,
seriesWorlds,
selectedWorldIndex,
toolEnabled,
isLoading,
bookSeriesId,
newWorldName,
viewMode,
saveWorld,
updateWorldField,
addNewWorld,
toggleTool,
importFromSeries,
exportToSeries,
refreshSeriesWorlds,
setNewWorldName,
setWorlds,
getSeriesWorldForCurrentWorld,
enterDetailMode,
enterEditMode,
exitEditMode,
backToList,
} = useWorlds(config);
const availableSeriesWorlds = useMemo(function (): SeriesWorldProps[] {
return seriesWorlds.filter(function (sw: SeriesWorldProps): boolean {
return !worlds.some(function (w: WorldProps): boolean {
return w.seriesWorldId === sw.id;
});
});
}, [seriesWorlds, worlds]);
const handleWorldFieldChange = useCallback(function (field: keyof WorldProps, value: string): void {
updateWorldField(field, value);
}, [updateWorldField]);
// Wrapper pour convertir WorldProps en worldId
const handleWorldClick = useCallback(function (world: WorldProps): void {
enterDetailMode(world.id);
}, [enterDetailMode]);
// Gestion de l'ajout
async function handleAddWorld(): Promise<void> {
if (newWorldName.trim()) {
await addNewWorld();
setShowAddForm(false);
} else {
setShowAddForm(true);
}
}
async function handleSave(): Promise<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin"/>
</div>
);
}
const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex];
const canExport: boolean = Boolean(bookSeriesId && selectedWorld && !selectedWorld.seriesWorldId);
return (
<div className="flex flex-col h-full">
<ToolDetailHeader
title={selectedWorld?.name || ''}
defaultTitle={t('worldSetting.newWorld')}
viewMode={viewMode}
isNew={false}
onBack={backToList}
onEdit={enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onExport={canExport ? exportToSeries : undefined}
showExport={canExport}
showDelete={false}
/>
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-3 p-2">
{/* Toggle tool */}
<div className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('worldSetting.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
</div>
{toolEnabled && (
<>
{/* Import from series */}
{bookSeriesId && availableSeriesWorlds.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesWorlds.map(function (sw: SeriesWorldProps) {
return {id: sw.id, name: sw.name};
})}
onImport={importFromSeries}
placeholder={t('seriesImport.selectElement')}
label={t('seriesImport.importFromSeries')}
/>
)}
{showAddForm && (
<div className="px-2">
<InputField
input={
<TextInput
value={newWorldName}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setNewWorldName(e.target.value);
}}
placeholder={t('worldSetting.newWorldPlaceholder')}
/>
}
actionIcon={faPlus}
actionLabel={t('worldSetting.createWorldLabel')}
addButtonCallBack={async function (): Promise<void> {
await addNewWorld();
setShowAddForm(false);
}}
/>
</div>
)}
<WorldEditorList
worlds={worlds}
onWorldClick={handleWorldClick}
onAddWorld={handleAddWorld}
/>
</>
)}
</div>
)}
{viewMode === 'detail' && selectedWorld && (
<div className="p-4">
<WorldEditorDetail
world={selectedWorld}
seriesWorld={getSeriesWorldForCurrentWorld()}
/>
</div>
)}
{viewMode === 'edit' && selectedWorld && (
<div className="p-4">
<WorldEditorEdit
world={selectedWorld}
worlds={worlds}
selectedWorldIndex={selectedWorldIndex}
setWorlds={setWorlds}
onWorldFieldChange={handleWorldFieldChange}
seriesWorld={getSeriesWorldForCurrentWorld()}
onSyncComplete={refreshSeriesWorlds}
/>
</div>
)}
</div>
{showDeleteConfirm && selectedWorld && (
<AlertBox
title={t('worldSetting.deleteTitle')}
message={t('worldSetting.deleteMessage', {name: selectedWorld.name})}
type="danger"
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
onConfirm={async function (): Promise<void> { setShowDeleteConfirm(false); }}
onCancel={function (): void { setShowDeleteConfirm(false); }}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import React from 'react';
import {WorldProps, elementSections, ElementSection, WorldElement} from '@/lib/models/World';
import {SeriesWorldProps} from '@/lib/models/Series';
import {useTranslations} from 'next-intl';
interface WorldEditorDetailProps {
world: WorldProps;
seriesWorld?: SeriesWorldProps | null;
}
/**
* WorldEditorDetail - Version sidebar lecture seule
* Mêmes fonctionnalités que WorldSettingsDetail, layout linéaire
*/
export default function WorldEditorDetail({
world,
seriesWorld,
}: WorldEditorDetailProps): React.JSX.Element {
const t = useTranslations();
function renderField(label: string, value: string | null | undefined): React.JSX.Element | null {
if (!value) return null;
return (
<div className="mb-3">
<span className="text-text-secondary text-xs block mb-1">{label}</span>
<p className="text-text-primary text-sm whitespace-pre-wrap">{value}</p>
</div>
);
}
function renderElementSection(section: ElementSection): React.JSX.Element | null {
const elements: WorldElement[] = world[section.section] as WorldElement[];
if (!elements || elements.length === 0) return null;
return (
<div key={section.section} className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-2">{section.title}</h4>
<div className="space-y-2">
{elements.map(function (element: WorldElement): React.JSX.Element {
return (
<div key={element.id} className="bg-secondary/20 rounded-lg p-2">
<p className="text-text-primary font-medium text-sm">{element.name}</p>
{element.description && (
<p className="text-text-secondary text-xs mt-1">{element.description}</p>
)}
</div>
);
})}
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* Informations de base */}
<div className="border-b border-secondary/30 pb-3">
<h3 className="text-text-primary font-semibold text-base mb-3">{world.name}</h3>
{renderField(t('worldSetting.worldHistory'), world.history)}
</div>
{/* Politique et économie */}
{(world.politics || world.economy) && (
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-2">{t('worldSetting.politicsEconomy')}</h4>
{renderField(t('worldSetting.politics'), world.politics)}
{renderField(t('worldSetting.economy'), world.economy)}
</div>
)}
{/* Religion et langues */}
{(world.religion || world.languages) && (
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-2">{t('worldSetting.cultureLanguages')}</h4>
{renderField(t('worldSetting.religion'), world.religion)}
{renderField(t('worldSetting.languages'), world.languages)}
</div>
)}
{/* Sections d'éléments */}
{elementSections.map(function (section: ElementSection): React.JSX.Element | null {
return renderElementSection(section);
})}
</div>
);
}

View File

@@ -0,0 +1,237 @@
'use client';
import React, {ChangeEvent, Dispatch, SetStateAction} from 'react';
import {WorldProps, elementSections, ElementSection} from '@/lib/models/World';
import {SeriesWorldProps} from '@/lib/models/Series';
import {WorldContext} from '@/context/WorldContext';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import TexteAreaInput from '@/components/form/TexteAreaInput';
import SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
import WorldElementComponent from '@/components/book/settings/world/WorldElement';
import {useTranslations} from 'next-intl';
interface WorldEditorEditProps {
world: WorldProps;
worlds: WorldProps[];
selectedWorldIndex: number;
setWorlds: Dispatch<SetStateAction<WorldProps[]>>;
onWorldFieldChange: (field: keyof WorldProps, value: string) => void;
seriesWorld?: SeriesWorldProps | null;
onSyncComplete?: () => void;
}
/**
* WorldEditorEdit - Version sidebar édition
* Mêmes fonctionnalités que WorldSettingsEdit, layout linéaire
* SyncFieldWrapper pour tous les champs
*/
export default function WorldEditorEdit({
world,
worlds,
selectedWorldIndex,
setWorlds,
onWorldFieldChange,
seriesWorld,
onSyncComplete,
}: WorldEditorEditProps): React.JSX.Element {
const t = useTranslations();
return (
<WorldContext.Provider value={{worlds, setWorlds, selectedWorldIndex, isSeriesMode: false}}>
<div className="space-y-4">
{/* Informations de base */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('worldSetting.basicInfo')}</h4>
<div className="space-y-3">
<InputField
fieldName={t("worldSetting.worldName")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.name || ''}
currentValue={world.name}
bookElementId={world.id}
field="name"
elementType="world"
onDownload={function (): void {
if (seriesWorld) {
const updatedWorlds: WorldProps[] = [...worlds];
updatedWorlds[selectedWorldIndex].name = seriesWorld.name;
setWorlds(updatedWorlds);
}
}}
onSyncComplete={onSyncComplete}
>
<TextInput
value={world.name}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
const updatedWorlds: WorldProps[] = [...worlds];
updatedWorlds[selectedWorldIndex].name = e.target.value;
setWorlds(updatedWorlds);
}}
placeholder={t("worldSetting.worldNamePlaceholder")}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.worldHistory")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.history || ''}
currentValue={world.history || ''}
bookElementId={world.id}
field="history"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('history', seriesWorld.history || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.history || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('history', e.target.value);
}}
placeholder={t("worldSetting.worldHistoryPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>
</div>
{/* Politique et économie */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('worldSetting.politicsEconomy')}</h4>
<div className="space-y-3">
<InputField
fieldName={t("worldSetting.politics")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.politics || ''}
currentValue={world.politics || ''}
bookElementId={world.id}
field="politics"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('politics', seriesWorld.politics || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.politics || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('politics', e.target.value);
}}
placeholder={t("worldSetting.politicsPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.economy")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.economy || ''}
currentValue={world.economy || ''}
bookElementId={world.id}
field="economy"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('economy', seriesWorld.economy || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.economy || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('economy', e.target.value);
}}
placeholder={t("worldSetting.economyPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>
</div>
{/* Religion et langues */}
<div className="border-b border-secondary/30 pb-3">
<h4 className="text-text-primary font-medium text-sm mb-3">{t('worldSetting.cultureLanguages')}</h4>
<div className="space-y-3">
<InputField
fieldName={t("worldSetting.religion")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.religion || ''}
currentValue={world.religion || ''}
bookElementId={world.id}
field="religion"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('religion', seriesWorld.religion || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.religion || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('religion', e.target.value);
}}
placeholder={t("worldSetting.religionPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.languages")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.languages || ''}
currentValue={world.languages || ''}
bookElementId={world.id}
field="languages"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('languages', seriesWorld.languages || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.languages || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('languages', e.target.value);
}}
placeholder={t("worldSetting.languagesPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>
</div>
{/* Sections d'éléments */}
{elementSections.map(function (section: ElementSection): React.JSX.Element {
return (
<div key={section.section} className="border-b border-secondary/30 pb-3 last:border-b-0">
<h4 className="text-text-primary font-medium text-sm mb-3">{section.title}</h4>
<WorldElementComponent
sectionLabel={section.title}
sectionType={section.section}
/>
</div>
);
})}
</div>
</WorldContext.Provider>
);
}

View File

@@ -0,0 +1,107 @@
'use client';
import React, {useState} from 'react';
import {WorldProps} from '@/lib/models/World';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faChevronRight, faGlobe, faPlus} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
interface WorldEditorListProps {
worlds: WorldProps[];
onWorldClick: (world: WorldProps) => void;
onAddWorld: () => void;
}
/**
* WorldEditorList - Liste des mondes pour ComposerRightBar
* Version compacte avec liste cliquable (même pattern que CharacterEditorList)
* PAS de scroll interne (géré par parent ComposerRightBar)
*/
export default function WorldEditorList({
worlds,
onWorldClick,
onAddWorld,
}: WorldEditorListProps): React.JSX.Element {
const t = useTranslations();
const [searchQuery, setSearchQuery] = useState<string>('');
function getFilteredWorlds(): WorldProps[] {
return worlds.filter(function (world: WorldProps): boolean {
return world.name.toLowerCase().includes(searchQuery.toLowerCase());
});
}
const filteredWorlds: WorldProps[] = getFilteredWorlds();
return (
<div className="space-y-3">
<div className="px-2">
<InputField
input={
<TextInput
value={searchQuery}
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
setSearchQuery(e.target.value);
}}
placeholder={t('worldSetting.search')}
/>
}
actionIcon={faPlus}
actionLabel={t('worldSetting.addWorldLabel')}
addButtonCallBack={async function (): Promise<void> {
onAddWorld();
}}
/>
</div>
<div className="px-2 space-y-2">
{filteredWorlds.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
<FontAwesomeIcon icon={faGlobe} className="text-primary w-8 h-8"/>
</div>
<h3 className="text-text-primary font-semibold text-base mb-1">
{t('worldSetting.noWorldAvailable')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('worldSetting.noWorldDescription')}
</p>
</div>
) : (
filteredWorlds.map(function (world: WorldProps): React.JSX.Element {
return (
<div
key={world.id}
onClick={function (): void { onWorldClick(world); }}
className="group flex items-center p-3 bg-secondary/30 rounded-lg border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md transition-all duration-200 hover:border-primary/50"
>
<div className="w-10 h-10 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-sm group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faGlobe} className="text-primary w-5 h-5"/>
</div>
<div className="ml-3 flex-1 min-w-0">
<div className="text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate">
{world.name}
</div>
{world.history && (
<div className="text-muted text-xs truncate">
{world.history.substring(0, 50)}{world.history.length > 50 ? '...' : ''}
</div>
)}
</div>
<div className="w-6 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3"
/>
</div>
</div>
);
})
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,200 @@
'use client';
import React, {useCallback, useContext, useMemo, useState} from 'react';
import {useWorlds, UseWorldsConfig} from '@/hooks/settings/useWorlds';
import {useTranslations} from 'next-intl';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons';
import {BookContext} from '@/context/BookContext';
import {WorldProps} from '@/lib/models/World';
import {SeriesWorldProps} from '@/lib/models/Series';
import InputField from '@/components/form/InputField';
import ToggleSwitch from '@/components/form/ToggleSwitch';
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
import AlertBox from '@/components/AlertBox';
import WorldSettingsList from './WorldSettingsList';
import WorldSettingsDetail from './WorldSettingsDetail';
import WorldSettingsEdit from './WorldSettingsEdit';
interface WorldSettingsProps {
entityType?: 'book' | 'series';
entityId?: string;
showToggle?: boolean;
}
/**
* WorldSettings - Orchestrateur pour BookSetting/SerieSetting
* Gère le viewMode (list/detail/edit) et coordonne les sous-composants
* Inclut: toggle tool, import from series, export to series
*/
export default function WorldSettings({
entityType = 'book',
entityId,
showToggle = true,
}: WorldSettingsProps): React.JSX.Element {
const t = useTranslations();
const {book} = useContext(BookContext);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
const resolvedEntityId: string = entityId || book?.bookId || '';
const config: UseWorldsConfig = useMemo(function (): UseWorldsConfig {
return {
entityType,
entityId: resolvedEntityId,
};
}, [entityType, resolvedEntityId]);
const {
worlds,
seriesWorlds,
selectedWorldIndex,
toolEnabled,
isLoading,
isSeriesMode,
bookSeriesId,
newWorldName,
viewMode,
addNewWorld,
saveWorld,
updateWorldField,
toggleTool,
importFromSeries,
exportToSeries,
refreshSeriesWorlds,
setNewWorldName,
setWorlds,
getSeriesWorldForCurrentWorld,
enterDetailMode,
enterEditMode,
exitEditMode,
backToList,
} = useWorlds(config);
const availableSeriesWorlds = useMemo(function (): SeriesWorldProps[] {
return seriesWorlds.filter(function (sw: SeriesWorldProps): boolean {
return !worlds.some(function (w: WorldProps): boolean {
return w.seriesWorldId === sw.id;
});
});
}, [seriesWorlds, worlds]);
const handleWorldFieldChange = useCallback(function (field: keyof WorldProps, value: string): void {
updateWorldField(field, value);
}, [updateWorldField]);
async function handleSave(): Promise<void> {
await exitEditMode(true);
}
function handleCancel(): void {
exitEditMode(false);
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
</div>
);
}
const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex];
const canExport: boolean = Boolean(bookSeriesId && selectedWorld && !selectedWorld.seriesWorldId);
return (
<div className="flex flex-col h-full">
{/* Header - uniquement pour detail/edit */}
<ToolDetailHeader
title={selectedWorld?.name || ''}
defaultTitle={t('worldSetting.newWorld')}
viewMode={viewMode}
isNew={false}
onBack={backToList}
onEdit={enterEditMode}
onSave={handleSave}
onCancel={handleCancel}
onExport={canExport ? exportToSeries : undefined}
showExport={canExport}
showDelete={false}
/>
{/* Contenu principal */}
<div className="flex-1 overflow-y-auto">
{viewMode === 'list' && (
<div className="space-y-5 p-4">
{/* Toggle tool */}
{showToggle && !isSeriesMode && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('worldSetting.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={toggleTool}
/>
}
/>
<p className="text-muted text-sm mt-2">
{t('worldSetting.enableToolDescription')}
</p>
</div>
)}
{/* Contenu si outil activé */}
{(toolEnabled || isSeriesMode) && (
<>
{/* Import from series */}
{!isSeriesMode && bookSeriesId && availableSeriesWorlds.length > 0 && (
<SeriesImportSelector
availableItems={availableSeriesWorlds.map(function (sw: SeriesWorldProps) {
return {id: sw.id, name: sw.name};
})}
onImport={importFromSeries}
placeholder={t("seriesImport.selectElement")}
label={t("seriesImport.importFromSeries")}
/>
)}
{/* Liste des mondes */}
<WorldSettingsList
worlds={worlds}
onWorldClick={enterDetailMode}
onAddWorld={addNewWorld}
newWorldName={newWorldName}
onNewWorldNameChange={setNewWorldName}
/>
</>
)}
</div>
)}
{viewMode === 'detail' && selectedWorld && (
<div className="p-4">
<WorldSettingsDetail
world={selectedWorld}
seriesWorld={getSeriesWorldForCurrentWorld()}
/>
</div>
)}
{viewMode === 'edit' && selectedWorld && (
<div className="p-4">
<WorldSettingsEdit
world={selectedWorld}
worlds={worlds}
selectedWorldIndex={selectedWorldIndex}
setWorlds={setWorlds}
onWorldFieldChange={handleWorldFieldChange}
seriesWorld={getSeriesWorldForCurrentWorld()}
isSeriesMode={isSeriesMode}
onSyncComplete={refreshSeriesWorlds}
/>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import React from 'react';
import {WorldProps, elementSections, ElementSection, WorldElement} from '@/lib/models/World';
import {SeriesWorldProps} from '@/lib/models/Series';
import CollapsableArea from '@/components/CollapsableArea';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {
faGlobe,
faLandmark,
faBook,
faCoins,
faChurch,
faLanguage,
faScroll
} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
interface WorldSettingsDetailProps {
world: WorldProps;
seriesWorld?: SeriesWorldProps | null;
}
export default function WorldSettingsDetail({
world,
seriesWorld,
}: WorldSettingsDetailProps): React.JSX.Element {
const t = useTranslations();
function renderElementSection(section: ElementSection): React.JSX.Element | null {
const elements: WorldElement[] = world[section.section] as WorldElement[];
if (!elements || elements.length === 0) return null;
return (
<CollapsableArea key={section.section} title={section.title} icon={section.icon}>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
{elements.map(function (element: WorldElement): React.JSX.Element {
return (
<div key={element.id} className="p-4 bg-dark-background/30 rounded-lg border border-secondary/20 hover:border-primary/30 transition-colors">
<h4 className="text-text-primary font-semibold">{element.name}</h4>
{element.description && (
<p className="text-text-secondary text-sm mt-2 line-clamp-3">{element.description}</p>
)}
</div>
);
})}
</div>
</CollapsableArea>
);
}
return (
<div className="space-y-6 px-2 pb-4">
{/* Hero Section */}
<div className="p-6 bg-gradient-to-r from-primary/10 via-secondary/20 to-transparent rounded-2xl border border-secondary/30">
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl bg-primary/20 flex items-center justify-center shrink-0">
<FontAwesomeIcon icon={faGlobe} className="w-8 h-8 text-primary"/>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-bold text-text-primary">{world.name || '—'}</h2>
</div>
</div>
</div>
{/* Histoire du monde - Full width */}
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faScroll} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('worldSetting.worldHistory')}</h3>
</div>
<p className={`whitespace-pre-wrap ${world.history ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{world.history || '—'}
</p>
</div>
{/* Politique & Économie - Side by side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faLandmark} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('worldSetting.politics')}</h3>
</div>
<p className={`whitespace-pre-wrap ${world.politics ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{world.politics || '—'}
</p>
</div>
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faCoins} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('worldSetting.economy')}</h3>
</div>
<p className={`whitespace-pre-wrap ${world.economy ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{world.economy || '—'}
</p>
</div>
</div>
{/* Religion & Langues - Side by side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faChurch} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('worldSetting.religion')}</h3>
</div>
<p className={`whitespace-pre-wrap ${world.religion ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{world.religion || '—'}
</p>
</div>
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
<div className="flex items-center gap-2 mb-3">
<FontAwesomeIcon icon={faLanguage} className="w-4 h-4 text-primary"/>
<h3 className="text-text-primary font-semibold">{t('worldSetting.languages')}</h3>
</div>
<p className={`whitespace-pre-wrap ${world.languages ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
{world.languages || '—'}
</p>
</div>
</div>
{/* Sections d'éléments - Grille de cards */}
{elementSections.map(function (section: ElementSection): React.JSX.Element | null {
return renderElementSection(section);
})}
</div>
);
}

View File

@@ -0,0 +1,240 @@
'use client';
import React, {ChangeEvent, Dispatch, SetStateAction} from 'react';
import {WorldProps, elementSections, ElementSection} from '@/lib/models/World';
import {SeriesWorldProps} from '@/lib/models/Series';
import {WorldContext} from '@/context/WorldContext';
import CollapsableArea from '@/components/CollapsableArea';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import TexteAreaInput from '@/components/form/TexteAreaInput';
import SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
import WorldElementComponent from '@/components/book/settings/world/WorldElement';
import {faBook, faGlobe, faLandmark} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {useTranslations} from 'next-intl';
interface WorldSettingsEditProps {
world: WorldProps;
worlds: WorldProps[];
selectedWorldIndex: number;
setWorlds: Dispatch<SetStateAction<WorldProps[]>>;
onWorldFieldChange: (field: keyof WorldProps, value: string) => void;
seriesWorld?: SeriesWorldProps | null;
isSeriesMode: boolean;
onSyncComplete?: () => void;
}
/**
* WorldSettingsEdit - Vue édition pour BookSetting/SerieSetting
* Tous les champs avec SyncFieldWrapper
* PAS de scroll interne (géré par parent)
*/
export default function WorldSettingsEdit({
world,
worlds,
selectedWorldIndex,
setWorlds,
onWorldFieldChange,
seriesWorld,
isSeriesMode,
onSyncComplete,
}: WorldSettingsEditProps): React.JSX.Element {
const t = useTranslations();
return (
<WorldContext.Provider value={{worlds, setWorlds, selectedWorldIndex, isSeriesMode}}>
<div className="space-y-4 px-2 pb-4">
{/* Informations de base */}
<CollapsableArea title={t('worldSetting.basicInfo')} icon={faGlobe}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t("worldSetting.worldName")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.name || ''}
currentValue={world.name}
bookElementId={world.id}
field="name"
elementType="world"
onDownload={function (): void {
if (seriesWorld) {
const updatedWorlds: WorldProps[] = [...worlds];
updatedWorlds[selectedWorldIndex].name = seriesWorld.name;
setWorlds(updatedWorlds);
}
}}
onSyncComplete={onSyncComplete}
>
<TextInput
value={world.name}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
const updatedWorlds: WorldProps[] = [...worlds];
updatedWorlds[selectedWorldIndex].name = e.target.value;
setWorlds(updatedWorlds);
}}
placeholder={t("worldSetting.worldNamePlaceholder")}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.worldHistory")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.history || ''}
currentValue={world.history || ''}
bookElementId={world.id}
field="history"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('history', seriesWorld.history || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.history || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('history', e.target.value);
}}
placeholder={t("worldSetting.worldHistoryPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
{/* Politique et économie */}
<CollapsableArea title={t('worldSetting.politicsEconomy')} icon={faLandmark}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t("worldSetting.politics")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.politics || ''}
currentValue={world.politics || ''}
bookElementId={world.id}
field="politics"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('politics', seriesWorld.politics || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.politics || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('politics', e.target.value);
}}
placeholder={t("worldSetting.politicsPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.economy")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.economy || ''}
currentValue={world.economy || ''}
bookElementId={world.id}
field="economy"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('economy', seriesWorld.economy || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.economy || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('economy', e.target.value);
}}
placeholder={t("worldSetting.economyPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
{/* Religion et langues */}
<CollapsableArea title={t('worldSetting.cultureLanguages')} icon={faBook}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t("worldSetting.religion")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.religion || ''}
currentValue={world.religion || ''}
bookElementId={world.id}
field="religion"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('religion', seriesWorld.religion || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.religion || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('religion', e.target.value);
}}
placeholder={t("worldSetting.religionPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
<InputField
fieldName={t("worldSetting.languages")}
input={
<SyncFieldWrapper
seriesElementId={world.seriesWorldId}
seriesValue={seriesWorld?.languages || ''}
currentValue={world.languages || ''}
bookElementId={world.id}
field="languages"
elementType="world"
onDownload={function (): void {
if (seriesWorld) onWorldFieldChange('languages', seriesWorld.languages || '');
}}
onSyncComplete={onSyncComplete}
>
<TexteAreaInput
value={world.languages || ''}
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
onWorldFieldChange('languages', e.target.value);
}}
placeholder={t("worldSetting.languagesPlaceholder")}
/>
</SyncFieldWrapper>
}
/>
</div>
</CollapsableArea>
{/* Sections d'éléments */}
{elementSections.map(function (section: ElementSection): React.JSX.Element {
return (
<CollapsableArea key={section.section} title={section.title} icon={section.icon}>
<div className="p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<WorldElementComponent
sectionLabel={section.title}
sectionType={section.section}
/>
</div>
</CollapsableArea>
);
})}
</div>
</WorldContext.Provider>
);
}

View File

@@ -0,0 +1,127 @@
'use client';
import React, {ChangeEvent, useState} from 'react';
import {WorldProps} from '@/lib/models/World';
import InputField from '@/components/form/InputField';
import TextInput from '@/components/form/TextInput';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faChevronRight, faGlobe, faPlus} from '@fortawesome/free-solid-svg-icons';
import {useTranslations} from 'next-intl';
interface WorldSettingsListProps {
worlds: WorldProps[];
onWorldClick: (worldId: string) => void;
onAddWorld: () => Promise<void>;
newWorldName: string;
onNewWorldNameChange: (name: string) => void;
}
/**
* WorldSettingsList - Liste des mondes pour BookSetting/SerieSetting
* Liste cliquable avec recherche et formulaire d'ajout
* PAS de SelectBox, même pattern que CharacterSettingsList
* PAS de scroll interne (géré par parent)
*/
export default function WorldSettingsList({
worlds,
onWorldClick,
onAddWorld,
newWorldName,
onNewWorldNameChange,
}: WorldSettingsListProps): React.JSX.Element {
const t = useTranslations();
const [searchQuery, setSearchQuery] = useState<string>('');
function getFilteredWorlds(): WorldProps[] {
return worlds.filter(function (world: WorldProps): boolean {
return world.name.toLowerCase().includes(searchQuery.toLowerCase());
});
}
const filteredWorlds: WorldProps[] = getFilteredWorlds();
return (
<div className="space-y-4">
{/* Recherche et ajout */}
<div className="bg-tertiary/90 backdrop-blur-sm rounded-xl p-5 border border-secondary/50 shadow-lg">
<div className="grid grid-cols-1 gap-4">
<InputField
input={
<TextInput
value={searchQuery}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
setSearchQuery(e.target.value);
}}
placeholder={t('worldSetting.search')}
/>
}
/>
<InputField
fieldName={t('worldSetting.addWorldLabel')}
input={
<TextInput
value={newWorldName}
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
onNewWorldNameChange(e.target.value);
}}
placeholder={t('worldSetting.newWorldPlaceholder')}
/>
}
actionIcon={faPlus}
actionLabel={t('worldSetting.createWorldLabel')}
addButtonCallBack={onAddWorld}
/>
</div>
</div>
{/* Liste des mondes cliquables */}
<div className="space-y-2 px-2">
{filteredWorlds.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<FontAwesomeIcon icon={faGlobe} className="text-primary w-10 h-10"/>
</div>
<h3 className="text-text-primary font-semibold text-lg mb-2">
{t('worldSetting.noWorldAvailable')}
</h3>
<p className="text-muted text-sm max-w-xs">
{t('worldSetting.noWorldDescription')}
</p>
</div>
) : (
filteredWorlds.map(function (world: WorldProps): React.JSX.Element {
return (
<div
key={world.id}
onClick={function (): void { onWorldClick(world.id); }}
className="group flex items-center p-4 bg-secondary/30 rounded-xl border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md hover:scale-102 transition-all duration-200 hover:border-primary/50"
>
<div className="w-12 h-12 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faGlobe} className="text-primary w-6 h-6"/>
</div>
<div className="ml-4 flex-1 min-w-0">
<div className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
{world.name}
</div>
{world.history && (
<div className="text-text-secondary text-sm mt-0.5 truncate">
{world.history.substring(0, 60)}{world.history.length > 60 ? '...' : ''}
</div>
)}
</div>
<div className="w-8 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"
/>
</div>
</div>
);
})
)}
</div>
</div>
);
}