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:
@@ -1,470 +0,0 @@
|
||||
'use client';
|
||||
import {Dispatch, forwardRef, SetStateAction, useContext, useEffect, useImperativeHandle, useState} from 'react';
|
||||
import {Attribute, CharacterProps, CharacterListResponse} from "@/lib/models/Character";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import CharacterList from './CharacterList';
|
||||
import System from '@/lib/models/System';
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
import CharacterDetail from "@/components/book/settings/characters/CharacterDetail";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext";
|
||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||
import {SyncedBook} from "@/lib/models/SyncedBook";
|
||||
import ToggleSwitch from "@/components/form/ToggleSwitch";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import {faToggleOn} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface CharacterDetailProps {
|
||||
selectedCharacter: CharacterProps | null;
|
||||
setSelectedCharacter: Dispatch<SetStateAction<CharacterProps | null>>;
|
||||
handleCharacterChange: (key: keyof CharacterProps, value: string) => void;
|
||||
handleAddElement: (section: keyof CharacterProps, element: any) => void;
|
||||
handleRemoveElement: (
|
||||
section: keyof CharacterProps,
|
||||
index: number,
|
||||
attrId: string,
|
||||
) => void;
|
||||
handleSaveCharacter: () => void;
|
||||
handleDeleteCharacter: (characterId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const initialCharacterState: CharacterProps = {
|
||||
id: null,
|
||||
name: '',
|
||||
lastName: '',
|
||||
nickname: '',
|
||||
age: '',
|
||||
gender: '',
|
||||
species: '',
|
||||
nationality: '',
|
||||
status: 'alive',
|
||||
category: 'none',
|
||||
title: '',
|
||||
role: '',
|
||||
image: 'https://via.placeholder.com/150',
|
||||
biography: '',
|
||||
history: '',
|
||||
speechPattern: '',
|
||||
catchphrase: '',
|
||||
residence: '',
|
||||
notes: '',
|
||||
color: '',
|
||||
physical: [],
|
||||
psychological: [],
|
||||
relations: [],
|
||||
skills: [],
|
||||
weaknesses: [],
|
||||
strengths: [],
|
||||
goals: [],
|
||||
motivations: [],
|
||||
arc: [],
|
||||
secrets: [],
|
||||
fears: [],
|
||||
flaws: [],
|
||||
beliefs: [],
|
||||
conflicts: [],
|
||||
quotes: [],
|
||||
distinguishingMarks: [],
|
||||
items: [],
|
||||
affiliations: [],
|
||||
};
|
||||
|
||||
export function CharacterComponent({showToggle = true}: {showToggle?: boolean}, ref: any) {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext<LangContextProps>(LangContext)
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
|
||||
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {book, setBook} = useContext(BookContext);
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const [characters, setCharacters] = useState<CharacterProps[]>([]);
|
||||
const [selectedCharacter, setSelectedCharacter] = useState<CharacterProps | null>(null);
|
||||
const [toolEnabled, setToolEnabled] = useState<boolean>(book?.tools?.characters ?? false);
|
||||
|
||||
useImperativeHandle(ref, function () {
|
||||
return {
|
||||
handleSave: handleSaveCharacter,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect((): void => {
|
||||
getCharacters().then();
|
||||
}, []);
|
||||
|
||||
async function handleToggleTool(enabled: boolean): Promise<void> {
|
||||
try {
|
||||
let response: boolean;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await window.electron.invoke<boolean>('db:book:tool:update', {
|
||||
bookId: book?.bookId,
|
||||
toolName: 'characters',
|
||||
enabled: enabled
|
||||
});
|
||||
} else {
|
||||
response = await System.authPatchToServer<boolean>('book/tool-setting', {
|
||||
bookId: book?.bookId,
|
||||
toolName: 'characters',
|
||||
enabled: enabled
|
||||
}, session.accessToken, lang);
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) {
|
||||
addToQueue('db:book:tool:update', {
|
||||
bookId: book?.bookId,
|
||||
toolName: 'characters',
|
||||
enabled: enabled
|
||||
});
|
||||
}
|
||||
}
|
||||
if (response && setBook && book) {
|
||||
setToolEnabled(enabled);
|
||||
setBook({...book, tools: {
|
||||
characters: enabled,
|
||||
worlds: book.tools?.worlds ?? false,
|
||||
locations: book.tools?.locations ?? false,
|
||||
spells: book.tools?.spells ?? false
|
||||
}});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getCharacters(): Promise<void> {
|
||||
try {
|
||||
let response: CharacterListResponse;
|
||||
if (isCurrentlyOffline()) {
|
||||
response = await window.electron.invoke<CharacterListResponse>('db:character:list', {bookid: book?.bookId});
|
||||
} else {
|
||||
if (book?.localBook) {
|
||||
response = await window.electron.invoke<CharacterListResponse>('db:character:list', {bookid: book?.bookId});
|
||||
} else {
|
||||
response = await System.authGetQueryToServer<CharacterListResponse>(`character/list`, session.accessToken, lang, {
|
||||
bookid: book?.bookId,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (response) {
|
||||
setCharacters(response.characters);
|
||||
setToolEnabled(response.enabled);
|
||||
if (setBook && book) {
|
||||
setBook({...book, tools: {
|
||||
characters: response.enabled,
|
||||
worlds: book.tools?.worlds ?? false,
|
||||
locations: book.tools?.locations ?? false,
|
||||
spells: book.tools?.spells ?? false
|
||||
}});
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("common.unknownError"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleCharacterClick(character: CharacterProps): void {
|
||||
setSelectedCharacter({...character});
|
||||
}
|
||||
|
||||
function handleAddCharacter(): void {
|
||||
setSelectedCharacter({...initialCharacterState});
|
||||
}
|
||||
|
||||
async function handleSaveCharacter(): Promise<void> {
|
||||
if (selectedCharacter) {
|
||||
const updatedCharacter: CharacterProps = {...selectedCharacter};
|
||||
if (selectedCharacter.id === null) {
|
||||
await addNewCharacter(updatedCharacter);
|
||||
} else {
|
||||
await updateCharacter(updatedCharacter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteCharacter(characterId: string): Promise<void> {
|
||||
try {
|
||||
let response: boolean;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await window.electron.invoke<boolean>('db:character:delete', {
|
||||
characterId: characterId,
|
||||
});
|
||||
} else {
|
||||
response = await System.authDeleteToServer<boolean>('character/delete', {
|
||||
characterId: characterId,
|
||||
}, session.accessToken, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) {
|
||||
addToQueue('db:character:delete', {
|
||||
characterId: characterId,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!response) {
|
||||
errorMessage(t("characterComponent.errorDeleteCharacter"));
|
||||
return;
|
||||
}
|
||||
setCharacters(characters.filter((c: CharacterProps): boolean => c.id !== characterId));
|
||||
setSelectedCharacter(null);
|
||||
successMessage(t("characterComponent.successDelete"));
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("common.unknownError"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function addNewCharacter(updatedCharacter: CharacterProps): Promise<void> {
|
||||
if (!updatedCharacter.name) {
|
||||
errorMessage(t("characterComponent.errorNameRequired"));
|
||||
return;
|
||||
}
|
||||
if (updatedCharacter.category === 'none') {
|
||||
errorMessage(t("characterComponent.errorCategoryRequired"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let characterId: string;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
characterId = await window.electron.invoke<string>('db:character:create', {
|
||||
bookId: book?.bookId,
|
||||
character: updatedCharacter,
|
||||
});
|
||||
} else {
|
||||
characterId = await System.authPostToServer<string>(`character/add`, {
|
||||
bookId: book?.bookId,
|
||||
character: updatedCharacter,
|
||||
}, session.accessToken, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) {
|
||||
addToQueue('db:character:create', {
|
||||
bookId: book?.bookId,
|
||||
characterId,
|
||||
character: updatedCharacter,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!characterId) {
|
||||
errorMessage(t("characterComponent.errorAddCharacter"));
|
||||
return;
|
||||
}
|
||||
updatedCharacter.id = characterId;
|
||||
setCharacters([...characters, updatedCharacter]);
|
||||
setSelectedCharacter(null);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("common.unknownError"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCharacter(updatedCharacter: CharacterProps,): Promise<void> {
|
||||
try {
|
||||
let response: boolean;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await window.electron.invoke<boolean>('db:character:update', {
|
||||
character: updatedCharacter,
|
||||
});
|
||||
} else {
|
||||
response = await System.authPostToServer<boolean>(`character/update`, {
|
||||
character: updatedCharacter,
|
||||
}, session.accessToken, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) {
|
||||
addToQueue('db:character:update', {
|
||||
character: updatedCharacter,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!response) {
|
||||
errorMessage(t("characterComponent.errorUpdateCharacter"));
|
||||
return;
|
||||
}
|
||||
setCharacters(
|
||||
characters.map((char: CharacterProps): CharacterProps =>
|
||||
char.id === updatedCharacter.id ? updatedCharacter : char,
|
||||
),
|
||||
);
|
||||
setSelectedCharacter(null);
|
||||
successMessage(t("characterComponent.successUpdate"));
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("common.unknownError"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleCharacterChange(
|
||||
key: keyof CharacterProps,
|
||||
value: string,
|
||||
): void {
|
||||
if (selectedCharacter) {
|
||||
setSelectedCharacter({...selectedCharacter, [key]: value});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddElement(
|
||||
section: keyof CharacterProps,
|
||||
value: Attribute,
|
||||
): Promise<void> {
|
||||
if (selectedCharacter) {
|
||||
if (selectedCharacter.id === null) {
|
||||
const updatedSection: any[] = [
|
||||
...(selectedCharacter[section] as any[]),
|
||||
value,
|
||||
];
|
||||
setSelectedCharacter({...selectedCharacter, [section]: updatedSection});
|
||||
} else {
|
||||
try {
|
||||
let attributeId: string;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
attributeId = await window.electron.invoke<string>('db:character:attribute:add', {
|
||||
characterId: selectedCharacter.id,
|
||||
type: section,
|
||||
name: value.name,
|
||||
});
|
||||
} else {
|
||||
attributeId = await System.authPostToServer<string>(`character/attribute/add`, {
|
||||
characterId: selectedCharacter.id,
|
||||
type: section,
|
||||
name: value.name,
|
||||
}, session.accessToken, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) {
|
||||
addToQueue('db:character:attribute:add', {
|
||||
characterId: selectedCharacter.id,
|
||||
attributeId,
|
||||
type: section,
|
||||
name: value.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!attributeId) {
|
||||
errorMessage(t("characterComponent.errorAddAttribute"));
|
||||
return;
|
||||
}
|
||||
const newValue: Attribute = {
|
||||
name: value.name,
|
||||
id: attributeId,
|
||||
};
|
||||
const updatedSection: Attribute[] = [...(selectedCharacter[section] as Attribute[]), newValue,];
|
||||
setSelectedCharacter({...selectedCharacter, [section]: updatedSection});
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("common.unknownError"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveElement(
|
||||
section: keyof CharacterProps,
|
||||
index: number,
|
||||
attrId: string,
|
||||
): Promise<void> {
|
||||
if (selectedCharacter) {
|
||||
if (selectedCharacter.id === null) {
|
||||
const updatedSection: Attribute[] = (
|
||||
selectedCharacter[section] as Attribute[]
|
||||
).filter((_, i: number): boolean => i !== index);
|
||||
setSelectedCharacter({...selectedCharacter, [section]: updatedSection});
|
||||
} else {
|
||||
try {
|
||||
let response: boolean;
|
||||
if (isCurrentlyOffline() || book?.localBook) {
|
||||
response = await window.electron.invoke<boolean>('db:character:attribute:delete', {
|
||||
attributeId: attrId,
|
||||
});
|
||||
} else {
|
||||
response = await System.authDeleteToServer<boolean>(`character/attribute/delete`, {
|
||||
attributeId: attrId,
|
||||
}, session.accessToken, lang);
|
||||
|
||||
if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === book?.bookId)) {
|
||||
addToQueue('db:character:attribute:delete', {
|
||||
attributeId: attrId,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!response) {
|
||||
errorMessage(t("characterComponent.errorRemoveAttribute"));
|
||||
return;
|
||||
}
|
||||
const updatedSection: Attribute[] = (
|
||||
selectedCharacter[section] as Attribute[]
|
||||
).filter((_, i: number): boolean => i !== index);
|
||||
setSelectedCharacter({
|
||||
...selectedCharacter,
|
||||
[section]: updatedSection,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("common.unknownError"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{showToggle && (
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
<InputField
|
||||
icon={faToggleOn}
|
||||
fieldName={t('characterComponent.enableTool')}
|
||||
input={
|
||||
<ToggleSwitch
|
||||
checked={toolEnabled}
|
||||
onChange={async (checked: boolean): Promise<void> => handleToggleTool(checked)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p className="text-muted text-sm mt-2">
|
||||
{t('characterComponent.enableToolDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{toolEnabled && (
|
||||
<>
|
||||
{selectedCharacter ? (
|
||||
<CharacterDetail
|
||||
selectedCharacter={selectedCharacter}
|
||||
setSelectedCharacter={setSelectedCharacter}
|
||||
handleAddElement={handleAddElement}
|
||||
handleRemoveElement={handleRemoveElement}
|
||||
handleCharacterChange={handleCharacterChange}
|
||||
handleSaveCharacter={handleSaveCharacter}
|
||||
handleDeleteCharacter={handleDeleteCharacter}
|
||||
/>
|
||||
) : (
|
||||
<CharacterList
|
||||
characters={characters}
|
||||
handleAddCharacter={handleAddCharacter}
|
||||
handleCharacterClick={handleCharacterClick}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(CharacterComponent);
|
||||
@@ -1,472 +0,0 @@
|
||||
import CollapsableArea from "@/components/CollapsableArea";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import TexteAreaInput from "@/components/form/TexteAreaInput";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import SelectBox from "@/components/form/SelectBox";
|
||||
import {AlertContext} from "@/context/AlertContext";
|
||||
import {SessionContext} from "@/context/SessionContext";
|
||||
import {
|
||||
Attribute,
|
||||
CharacterAttribute,
|
||||
characterCategories,
|
||||
CharacterElement,
|
||||
basicCharacterElements,
|
||||
advancedCharacterElements,
|
||||
CharacterProps,
|
||||
characterStatus
|
||||
} from "@/lib/models/Character";
|
||||
import System from "@/lib/models/System";
|
||||
import {
|
||||
faArrowLeft,
|
||||
faBook,
|
||||
faPlus,
|
||||
faSave,
|
||||
faScroll,
|
||||
faUser,
|
||||
faSliders,
|
||||
faGlobe,
|
||||
faCommentDots,
|
||||
faStickyNote
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {Dispatch, SetStateAction, useContext, useEffect, useState} from "react";
|
||||
import CharacterSectionElement from "@/components/book/settings/characters/CharacterSectionElement";
|
||||
import DeleteButton from "@/components/form/DeleteButton";
|
||||
import {useTranslations} from "next-intl";
|
||||
import {LangContext} from "@/context/LangContext";
|
||||
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||
import {BookContext} from "@/context/BookContext";
|
||||
|
||||
type AttributeResponse = { type: string; values: Attribute[] }[];
|
||||
|
||||
interface CharacterDetailProps {
|
||||
selectedCharacter: CharacterProps | null;
|
||||
setSelectedCharacter: Dispatch<SetStateAction<CharacterProps | null>>;
|
||||
handleCharacterChange: (key: keyof CharacterProps, value: string) => void;
|
||||
handleAddElement: (section: keyof CharacterProps, element: any) => void;
|
||||
handleRemoveElement: (
|
||||
section: keyof CharacterProps,
|
||||
index: number,
|
||||
attrId: string,
|
||||
) => void;
|
||||
handleSaveCharacter: () => void;
|
||||
handleDeleteCharacter: (characterId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function CharacterDetail(
|
||||
{
|
||||
setSelectedCharacter,
|
||||
selectedCharacter,
|
||||
handleCharacterChange,
|
||||
handleRemoveElement,
|
||||
handleAddElement,
|
||||
handleSaveCharacter,
|
||||
handleDeleteCharacter,
|
||||
}: CharacterDetailProps
|
||||
) {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext(LangContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
|
||||
|
||||
useEffect((): void => {
|
||||
if (selectedCharacter?.id !== null) {
|
||||
getAttributes().then();
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function getAttributes(): Promise<void> {
|
||||
try {
|
||||
let response: AttributeResponse;
|
||||
if (isCurrentlyOffline()) {
|
||||
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {
|
||||
characterId: selectedCharacter?.id,
|
||||
});
|
||||
} else {
|
||||
if (book?.localBook) {
|
||||
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {
|
||||
characterId: selectedCharacter?.id,
|
||||
});
|
||||
} else {
|
||||
response = await System.authGetQueryToServer<AttributeResponse>(`character/attribute`, session.accessToken, lang, {
|
||||
characterId: selectedCharacter?.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (response) {
|
||||
const attributes: CharacterAttribute = {};
|
||||
response.forEach((item: {
|
||||
type: string
|
||||
values: Attribute[]
|
||||
}):void => {
|
||||
attributes[item.type] = item.values;
|
||||
});
|
||||
|
||||
setSelectedCharacter({
|
||||
id: selectedCharacter?.id ?? '',
|
||||
name: selectedCharacter?.name ?? '',
|
||||
lastName: selectedCharacter?.lastName ?? '',
|
||||
nickname: selectedCharacter?.nickname ?? '',
|
||||
age: selectedCharacter?.age ?? '',
|
||||
gender: selectedCharacter?.gender ?? '',
|
||||
species: selectedCharacter?.species ?? '',
|
||||
nationality: selectedCharacter?.nationality ?? '',
|
||||
status: selectedCharacter?.status ?? 'alive',
|
||||
category: selectedCharacter?.category ?? 'none',
|
||||
title: selectedCharacter?.title ?? '',
|
||||
image: selectedCharacter?.image ?? '',
|
||||
role: selectedCharacter?.role ?? '',
|
||||
biography: selectedCharacter?.biography,
|
||||
history: selectedCharacter?.history,
|
||||
speechPattern: selectedCharacter?.speechPattern,
|
||||
catchphrase: selectedCharacter?.catchphrase,
|
||||
residence: selectedCharacter?.residence,
|
||||
notes: selectedCharacter?.notes,
|
||||
color: selectedCharacter?.color,
|
||||
physical: attributes.physical ?? [],
|
||||
psychological: attributes.psychological ?? [],
|
||||
relations: attributes.relations ?? [],
|
||||
skills: attributes.skills ?? [],
|
||||
weaknesses: attributes.weaknesses ?? [],
|
||||
strengths: attributes.strengths ?? [],
|
||||
goals: attributes.goals ?? [],
|
||||
motivations: attributes.motivations ?? [],
|
||||
arc: attributes.arc ?? [],
|
||||
secrets: attributes.secrets ?? [],
|
||||
fears: attributes.fears ?? [],
|
||||
flaws: attributes.flaws ?? [],
|
||||
beliefs: attributes.beliefs ?? [],
|
||||
conflicts: attributes.conflicts ?? [],
|
||||
quotes: attributes.quotes ?? [],
|
||||
distinguishingMarks: attributes.distinguishingMarks ?? [],
|
||||
items: attributes.items ?? [],
|
||||
affiliations: attributes.affiliations ?? [],
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
} else {
|
||||
errorMessage(t("characterDetail.fetchAttributesError"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="flex justify-between items-center p-4 border-b border-secondary/50 bg-tertiary/50 backdrop-blur-sm">
|
||||
<button onClick={() => setSelectedCharacter(null)}
|
||||
className="flex items-center gap-2 bg-secondary/50 py-2 px-4 rounded-xl border border-secondary/50 hover:bg-secondary hover:border-secondary hover:shadow-md hover:scale-105 transition-all duration-200">
|
||||
<FontAwesomeIcon icon={faArrowLeft} className="text-primary w-4 h-4"/>
|
||||
<span className="text-text-primary font-medium">{t("characterDetail.back")}</span>
|
||||
</button>
|
||||
<span className="text-text-primary font-semibold text-lg">
|
||||
{selectedCharacter?.name || t("characterDetail.newCharacter")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedCharacter?.id && (
|
||||
<DeleteButton
|
||||
onDelete={(): Promise<void> => handleDeleteCharacter(selectedCharacter.id as string)}
|
||||
confirmTitle={t("characterDetail.deleteTitle")}
|
||||
confirmMessage={t("characterDetail.deleteMessage", {name: selectedCharacter.name})}
|
||||
confirmButtonText={t("common.delete")}
|
||||
cancelButtonText={t("common.cancel")}
|
||||
/>
|
||||
)}
|
||||
<button onClick={handleSaveCharacter}
|
||||
className="flex items-center justify-center bg-primary w-10 h-10 rounded-xl border border-primary-dark shadow-md hover:shadow-lg hover:scale-110 transition-all duration-200">
|
||||
<FontAwesomeIcon icon={selectedCharacter?.id ? faSave : faPlus}
|
||||
className="text-text-primary w-5 h-5"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-[calc(100vh-350px)] space-y-4 px-2 pb-4">
|
||||
<CollapsableArea title={t("characterDetail.basicInfo")} icon={faUser}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<InputField
|
||||
fieldName={t("characterDetail.name")}
|
||||
input={
|
||||
<TextInput
|
||||
value={selectedCharacter?.name || ''}
|
||||
setValue={(e) => handleCharacterChange('name', e.target.value)}
|
||||
placeholder={t("characterDetail.namePlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t("characterDetail.lastName")}
|
||||
input={
|
||||
<TextInput
|
||||
value={selectedCharacter?.lastName || ''}
|
||||
setValue={(e) => handleCharacterChange('lastName', e.target.value)}
|
||||
placeholder={t("characterDetail.lastNamePlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t("characterDetail.nickname")}
|
||||
input={
|
||||
<TextInput
|
||||
value={selectedCharacter?.nickname || ''}
|
||||
setValue={(e) => handleCharacterChange('nickname', e.target.value)}
|
||||
placeholder={t("characterDetail.nicknamePlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t("characterDetail.role")}
|
||||
input={
|
||||
<SelectBox
|
||||
defaultValue={selectedCharacter?.category || 'none'}
|
||||
onChangeCallBack={(e) => setSelectedCharacter(prev =>
|
||||
prev ? {...prev, category: e.target.value as CharacterProps['category']} : prev
|
||||
)}
|
||||
data={characterCategories}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t("characterDetail.title")}
|
||||
input={
|
||||
<TextInput
|
||||
value={selectedCharacter?.title || ''}
|
||||
setValue={(e) => handleCharacterChange('title', e.target.value)}
|
||||
placeholder={t("characterDetail.titlePlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t("characterDetail.gender")}
|
||||
input={
|
||||
<TextInput
|
||||
value={selectedCharacter?.gender || ''}
|
||||
setValue={(e) => handleCharacterChange('gender', e.target.value)}
|
||||
placeholder={t("characterDetail.genderPlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t("characterDetail.age")}
|
||||
input={
|
||||
<TextInput
|
||||
value={selectedCharacter?.age || ''}
|
||||
setValue={(e) => handleCharacterChange('age', e.target.value)}
|
||||
placeholder={t("characterDetail.agePlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
<CollapsableArea title={t("characterDetail.historySection")} icon={faUser}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<InputField
|
||||
fieldName={t("characterDetail.biography")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={selectedCharacter?.biography || ''}
|
||||
setValue={(e) => handleCharacterChange('biography', e.target.value)}
|
||||
placeholder={t("characterDetail.biographyPlaceholder")}
|
||||
/>
|
||||
}
|
||||
icon={faBook}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t("characterDetail.history")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={selectedCharacter?.history || ''}
|
||||
setValue={(e) => handleCharacterChange('history', e.target.value)}
|
||||
placeholder={t("characterDetail.historyPlaceholder")}
|
||||
/>
|
||||
}
|
||||
icon={faScroll}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t("characterDetail.roleFull")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={selectedCharacter?.role || ''}
|
||||
setValue={(e) => handleCharacterChange('role', e.target.value)}
|
||||
placeholder={t("characterDetail.roleFullPlaceholder")}
|
||||
/>
|
||||
}
|
||||
icon={faScroll}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
{/* Attributs de base - toujours visibles */}
|
||||
{basicCharacterElements.map((item: CharacterElement, index: number) => (
|
||||
<CharacterSectionElement
|
||||
key={`basic-${index}`}
|
||||
title={item.title}
|
||||
section={item.section}
|
||||
placeholder={item.placeholder}
|
||||
icon={item.icon}
|
||||
selectedCharacter={selectedCharacter as CharacterProps}
|
||||
setSelectedCharacter={setSelectedCharacter}
|
||||
handleAddElement={handleAddElement}
|
||||
handleRemoveElement={handleRemoveElement}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Toggle Mode Avancé */}
|
||||
<div className="flex items-center justify-between p-4 bg-secondary/30 rounded-xl border border-secondary/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<FontAwesomeIcon icon={faSliders} className="text-primary w-5 h-5"/>
|
||||
<span className="text-text-primary font-medium">{t("characterDetail.advancedMode")}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className={`px-4 py-2 rounded-lg transition-all duration-200 ${
|
||||
showAdvanced
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-secondary/50 text-text-primary hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
{showAdvanced ? t("characterDetail.hideAdvanced") : t("characterDetail.showAdvanced")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sections avancées - visibles uniquement si showAdvanced est true */}
|
||||
{showAdvanced && (
|
||||
<>
|
||||
{/* Identité étendue */}
|
||||
<CollapsableArea title={t("characterDetail.identitySection")} icon={faGlobe}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<InputField
|
||||
fieldName={t("characterDetail.species")}
|
||||
input={
|
||||
<TextInput
|
||||
value={selectedCharacter?.species || ''}
|
||||
setValue={(e) => handleCharacterChange('species', e.target.value)}
|
||||
placeholder={t("characterDetail.speciesPlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t("characterDetail.nationality")}
|
||||
input={
|
||||
<TextInput
|
||||
value={selectedCharacter?.nationality || ''}
|
||||
setValue={(e) => handleCharacterChange('nationality', e.target.value)}
|
||||
placeholder={t("characterDetail.nationalityPlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t("characterDetail.status")}
|
||||
input={
|
||||
<SelectBox
|
||||
defaultValue={selectedCharacter?.status || 'alive'}
|
||||
onChangeCallBack={(e) => setSelectedCharacter(prev =>
|
||||
prev ? {...prev, status: e.target.value as CharacterProps['status']} : prev
|
||||
)}
|
||||
data={characterStatus}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t("characterDetail.residence")}
|
||||
input={
|
||||
<TextInput
|
||||
value={selectedCharacter?.residence || ''}
|
||||
setValue={(e) => handleCharacterChange('residence', e.target.value)}
|
||||
placeholder={t("characterDetail.residencePlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
{/* Voix du personnage */}
|
||||
<CollapsableArea title={t("characterDetail.voiceSection")} icon={faCommentDots}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<InputField
|
||||
fieldName={t("characterDetail.speechPattern")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={selectedCharacter?.speechPattern || ''}
|
||||
setValue={(e) => handleCharacterChange('speechPattern', e.target.value)}
|
||||
placeholder={t("characterDetail.speechPatternPlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t("characterDetail.catchphrase")}
|
||||
input={
|
||||
<TextInput
|
||||
value={selectedCharacter?.catchphrase || ''}
|
||||
setValue={(e) => handleCharacterChange('catchphrase', e.target.value)}
|
||||
placeholder={t("characterDetail.catchphrasePlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
{/* Notes de l'auteur */}
|
||||
<CollapsableArea title={t("characterDetail.authorSection")} icon={faStickyNote}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<InputField
|
||||
fieldName={t("characterDetail.notes")}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={selectedCharacter?.notes || ''}
|
||||
setValue={(e) => handleCharacterChange('notes', e.target.value)}
|
||||
placeholder={t("characterDetail.notesPlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t("characterDetail.colorLabel")}
|
||||
input={
|
||||
<TextInput
|
||||
value={selectedCharacter?.color || ''}
|
||||
setValue={(e) => handleCharacterChange('color', e.target.value)}
|
||||
placeholder={t("characterDetail.colorPlaceholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
{/* Attributs avancés */}
|
||||
{advancedCharacterElements.map((item: CharacterElement, index: number) => (
|
||||
<CharacterSectionElement
|
||||
key={`advanced-${index}`}
|
||||
title={item.title}
|
||||
section={item.section}
|
||||
placeholder={item.placeholder}
|
||||
icon={item.icon}
|
||||
selectedCharacter={selectedCharacter as CharacterProps}
|
||||
setSelectedCharacter={setSelectedCharacter}
|
||||
handleAddElement={handleAddElement}
|
||||
handleRemoveElement={handleRemoveElement}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import {characterCategories, CharacterProps} from "@/lib/models/Character";
|
||||
import InputField from "@/components/form/InputField";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import {faChevronRight, faPlus, faUser} from "@fortawesome/free-solid-svg-icons";
|
||||
import {SelectBoxProps} from "@/shared/interface";
|
||||
import CollapsableArea from "@/components/CollapsableArea";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {useState} from "react";
|
||||
import {useTranslations} from "next-intl";
|
||||
|
||||
interface CharacterListProps {
|
||||
characters: CharacterProps[];
|
||||
handleCharacterClick: (character: CharacterProps) => void;
|
||||
handleAddCharacter: () => void;
|
||||
}
|
||||
|
||||
export default function CharacterList(
|
||||
{
|
||||
characters,
|
||||
handleCharacterClick,
|
||||
handleAddCharacter,
|
||||
}: CharacterListProps) {
|
||||
const t = useTranslations();
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
function getFilteredCharacters(
|
||||
characters: CharacterProps[],
|
||||
searchQuery: string,
|
||||
): CharacterProps[] {
|
||||
return characters.filter(
|
||||
(char: CharacterProps) =>
|
||||
char.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(char.lastName &&
|
||||
char.lastName.toLowerCase().includes(searchQuery.toLowerCase())),
|
||||
);
|
||||
}
|
||||
|
||||
const filteredCharacters: CharacterProps[] = getFilteredCharacters(
|
||||
characters,
|
||||
searchQuery,
|
||||
);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="px-4 mb-4">
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={searchQuery}
|
||||
setValue={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t("characterList.search")}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionLabel={t("characterList.add")}
|
||||
addButtonCallBack={async () => handleAddCharacter()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-[calc(100vh-350px)] px-2">
|
||||
{characterCategories.map((category: SelectBoxProps) => {
|
||||
const categoryCharacters = filteredCharacters.filter(
|
||||
(char: CharacterProps) => char.category === category.value
|
||||
);
|
||||
|
||||
if (categoryCharacters.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapsableArea
|
||||
key={category.value}
|
||||
title={category.label}
|
||||
icon={faUser}
|
||||
children={<div className="space-y-2 p-2">
|
||||
{categoryCharacters.map(char => (
|
||||
<div
|
||||
key={char.id}
|
||||
onClick={() => handleCharacterClick(char)}
|
||||
className="group flex items-center p-4 bg-secondary/30 rounded-xl border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md hover:scale-102 transition-all duration-200 hover:border-primary/50"
|
||||
>
|
||||
<div
|
||||
className="w-14 h-14 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform">
|
||||
{char.image ? (
|
||||
<img
|
||||
src={char.image}
|
||||
alt={char.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full flex items-center justify-center bg-primary/10 text-primary font-bold text-lg">
|
||||
{char.name?.charAt(0)?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex-1">
|
||||
<div
|
||||
className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">{char.name || t("characterList.unknown")}</div>
|
||||
<div
|
||||
className="text-text-secondary text-sm mt-0.5">{char.lastName || t("characterList.noLastName")}</div>
|
||||
</div>
|
||||
|
||||
<div className="w-28 px-3">
|
||||
<div
|
||||
className="text-primary text-sm font-semibold truncate">{char.title || t("characterList.noTitle")}</div>
|
||||
<div
|
||||
className="text-muted text-xs truncate mt-0.5">{char.role || t("characterList.noRole")}</div>
|
||||
</div>
|
||||
|
||||
<div className="w-8 flex justify-center">
|
||||
<FontAwesomeIcon icon={faChevronRight}
|
||||
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
206
components/book/settings/characters/editor/CharacterEditor.tsx
Normal file
206
components/book/settings/characters/editor/CharacterEditor.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
import React, {useCallback, useContext, useMemo, useState} from 'react';
|
||||
import {useCharacters, UseCharactersConfig} from '@/hooks/settings/useCharacters';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {CharacterProps} from '@/lib/models/Character';
|
||||
import {SeriesCharacterProps} from '@/lib/models/Series';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import ToggleSwitch from '@/components/form/ToggleSwitch';
|
||||
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
|
||||
import AlertBox from '@/components/AlertBox';
|
||||
|
||||
import CharacterEditorList from './CharacterEditorList';
|
||||
import CharacterEditorDetail from './CharacterEditorDetail';
|
||||
import CharacterEditorEdit from './CharacterEditorEdit';
|
||||
|
||||
/**
|
||||
* CharacterEditor - Orchestrateur pour ComposerRightBar
|
||||
* Mêmes fonctionnalités que CharacterSettings, layout condensé
|
||||
*/
|
||||
export default function CharacterEditor(): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {book} = useContext(BookContext);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
|
||||
|
||||
const config: UseCharactersConfig = useMemo(function (): UseCharactersConfig {
|
||||
return {
|
||||
entityType: 'book',
|
||||
entityId: book?.bookId || '',
|
||||
};
|
||||
}, [book?.bookId]);
|
||||
|
||||
const {
|
||||
characters,
|
||||
seriesCharacters,
|
||||
selectedCharacter,
|
||||
toolEnabled,
|
||||
isLoading,
|
||||
bookSeriesId,
|
||||
viewMode,
|
||||
saveCharacter,
|
||||
deleteCharacter,
|
||||
updateCharacterField,
|
||||
addAttribute,
|
||||
removeAttribute,
|
||||
toggleTool,
|
||||
importFromSeries,
|
||||
exportToSeries,
|
||||
refreshSeriesCharacters,
|
||||
setSelectedCharacter,
|
||||
enterDetailMode,
|
||||
enterEditMode,
|
||||
exitEditMode,
|
||||
backToList,
|
||||
addNewCharacter,
|
||||
} = useCharacters(config);
|
||||
|
||||
const availableSeriesCharacters = useMemo(function (): SeriesCharacterProps[] {
|
||||
return seriesCharacters.filter(function (sc: SeriesCharacterProps): boolean {
|
||||
return !characters.some(function (c: CharacterProps): boolean {
|
||||
return c.seriesCharacterId === sc.id;
|
||||
});
|
||||
});
|
||||
}, [seriesCharacters, characters]);
|
||||
|
||||
const handleCharacterChange = useCallback(function (key: keyof CharacterProps, value: string | number | null): void {
|
||||
updateCharacterField(key, value);
|
||||
}, [updateCharacterField]);
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
await exitEditMode(true);
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
exitEditMode(false);
|
||||
}
|
||||
|
||||
async function handleDelete(): Promise<void> {
|
||||
if (selectedCharacter?.id) {
|
||||
await deleteCharacter(selectedCharacter.id);
|
||||
setShowDeleteConfirm(false);
|
||||
backToList();
|
||||
}
|
||||
}
|
||||
|
||||
function getSeriesCharacterForSelected(): SeriesCharacterProps | null {
|
||||
if (!selectedCharacter?.seriesCharacterId) return null;
|
||||
return seriesCharacters.find(function (sc: SeriesCharacterProps): boolean {
|
||||
return sc.id === selectedCharacter.seriesCharacterId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin"/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isNew: boolean = selectedCharacter?.id === null;
|
||||
const canExport: boolean = Boolean(bookSeriesId && selectedCharacter?.id && !selectedCharacter.seriesCharacterId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ToolDetailHeader
|
||||
title={selectedCharacter?.name || ''}
|
||||
defaultTitle={t('characterDetail.newCharacter')}
|
||||
viewMode={viewMode}
|
||||
isNew={isNew}
|
||||
onBack={backToList}
|
||||
onEdit={enterEditMode}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onDelete={function (): void { setShowDeleteConfirm(true); }}
|
||||
onExport={canExport ? exportToSeries : undefined}
|
||||
showExport={canExport}
|
||||
showDelete={Boolean(selectedCharacter?.id)}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{viewMode === 'list' && (
|
||||
<div className="space-y-3 p-2">
|
||||
{/* Toggle tool */}
|
||||
<div className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
|
||||
<InputField
|
||||
icon={faToggleOn}
|
||||
fieldName={t('characterComponent.enableTool')}
|
||||
input={
|
||||
<ToggleSwitch
|
||||
checked={toolEnabled}
|
||||
onChange={toggleTool}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{toolEnabled && (
|
||||
<>
|
||||
{/* Import from series */}
|
||||
{bookSeriesId && availableSeriesCharacters.length > 0 && (
|
||||
<SeriesImportSelector
|
||||
availableItems={availableSeriesCharacters.map(function (sc: SeriesCharacterProps) {
|
||||
return {
|
||||
id: sc.id,
|
||||
name: `${sc.name}${sc.lastName ? ' ' + sc.lastName : ''}`
|
||||
};
|
||||
})}
|
||||
onImport={importFromSeries}
|
||||
placeholder={t('seriesImport.selectElement')}
|
||||
label={t('seriesImport.importFromSeries')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CharacterEditorList
|
||||
characters={characters}
|
||||
onCharacterClick={enterDetailMode}
|
||||
onAddCharacter={addNewCharacter}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'detail' && selectedCharacter && (
|
||||
<div className="p-4">
|
||||
<CharacterEditorDetail
|
||||
character={selectedCharacter}
|
||||
seriesCharacter={getSeriesCharacterForSelected()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'edit' && selectedCharacter && (
|
||||
<div className="p-4">
|
||||
<CharacterEditorEdit
|
||||
character={selectedCharacter}
|
||||
setCharacter={setSelectedCharacter}
|
||||
onCharacterChange={handleCharacterChange}
|
||||
onAddAttribute={addAttribute}
|
||||
onRemoveAttribute={removeAttribute}
|
||||
seriesCharacter={getSeriesCharacterForSelected()}
|
||||
onSyncComplete={refreshSeriesCharacters}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDeleteConfirm && selectedCharacter?.id && (
|
||||
<AlertBox
|
||||
title={t('characterDetail.deleteTitle')}
|
||||
message={t('characterDetail.deleteMessage', {name: selectedCharacter.name})}
|
||||
type="danger"
|
||||
confirmText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={function (): void { setShowDeleteConfirm(false); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
import React, {useContext, useEffect} from 'react';
|
||||
import {
|
||||
Attribute,
|
||||
CharacterAttribute,
|
||||
characterCategories,
|
||||
CharacterProps
|
||||
} from '@/lib/models/Character';
|
||||
import {SeriesCharacterProps} from '@/lib/models/Series';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {SessionContext} from '@/context/SessionContext';
|
||||
import {AlertContext} from '@/context/AlertContext';
|
||||
import {LangContext} from '@/context/LangContext';
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import System from '@/lib/models/System';
|
||||
|
||||
type AttributeResponse = { type: string; values: Attribute[] }[];
|
||||
|
||||
interface CharacterEditorDetailProps {
|
||||
character: CharacterProps;
|
||||
seriesCharacter?: SeriesCharacterProps | null;
|
||||
onLoadAttributes?: (attributes: CharacterAttribute) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CharacterEditorDetail - Version sidebar lecture seule
|
||||
* Layout linéaire simple, juste les infos essentielles empilées
|
||||
* PAS de CollapsableArea, PAS de grids
|
||||
*/
|
||||
export default function CharacterEditorDetail({
|
||||
character,
|
||||
seriesCharacter,
|
||||
onLoadAttributes,
|
||||
}: CharacterEditorDetailProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext(LangContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {book} = useContext(BookContext);
|
||||
|
||||
useEffect(function (): void {
|
||||
if (character?.id !== null) {
|
||||
getAttributes().then();
|
||||
}
|
||||
}, [character?.id]);
|
||||
|
||||
async function getAttributes(): Promise<void> {
|
||||
try {
|
||||
let response: AttributeResponse;
|
||||
if (isCurrentlyOffline()) {
|
||||
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
|
||||
} else if (book?.localBook) {
|
||||
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
|
||||
} else {
|
||||
response = await System.authGetQueryToServer<AttributeResponse>(
|
||||
'character/attribute',
|
||||
session.accessToken,
|
||||
lang,
|
||||
{characterId: character?.id}
|
||||
);
|
||||
}
|
||||
if (response && onLoadAttributes) {
|
||||
const attributes: CharacterAttribute = {};
|
||||
response.forEach(function (item: { type: string; values: Attribute[] }): void {
|
||||
attributes[item.type] = item.values;
|
||||
});
|
||||
onLoadAttributes(attributes);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderField(label: string, value: string | number | null | undefined): React.JSX.Element | null {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<span className="text-text-secondary text-xs block mb-1">{label}</span>
|
||||
<p className="text-text-primary text-sm">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCategoryLabel(category: string | null | undefined): string {
|
||||
if (!category) return '';
|
||||
const found = characterCategories.find(function (c): boolean { return c.value === category; });
|
||||
return found ? t(found.label) : category;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Image du personnage - version compacte */}
|
||||
{character.image && (
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-16 h-16 rounded-full border-2 border-primary overflow-hidden">
|
||||
<img
|
||||
src={character.image}
|
||||
alt={character.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="text-text-primary font-semibold text-base mb-4">
|
||||
{character.name} {character.lastName}
|
||||
</h3>
|
||||
|
||||
{renderField(t('characterDetail.role'), getCategoryLabel(character.category))}
|
||||
{renderField(t('characterDetail.title'), character.title)}
|
||||
{renderField(t('characterDetail.gender'), character.gender)}
|
||||
{renderField(t('characterDetail.age'), character.age)}
|
||||
{renderField(t('characterDetail.biography'), character.biography)}
|
||||
{renderField(t('characterDetail.roleFull'), character.role)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
'use client';
|
||||
import React, {useContext, useEffect, useMemo, useState} from 'react';
|
||||
import {
|
||||
advancedCharacterElements,
|
||||
Attribute,
|
||||
basicCharacterElements,
|
||||
CharacterAttribute,
|
||||
characterCategories,
|
||||
CharacterElement,
|
||||
CharacterProps,
|
||||
characterStatus
|
||||
} from '@/lib/models/Character';
|
||||
import {SeriesCharacterProps} from '@/lib/models/Series';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import TexteAreaInput from '@/components/form/TexteAreaInput';
|
||||
import NumberInput from '@/components/form/NumberInput';
|
||||
import SelectBox from '@/components/form/SelectBox';
|
||||
import CharacterSectionElement from '@/components/book/settings/characters/CharacterSectionElement';
|
||||
import SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faSliders} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {SessionContext} from '@/context/SessionContext';
|
||||
import {AlertContext} from '@/context/AlertContext';
|
||||
import {LangContext} from '@/context/LangContext';
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import System from '@/lib/models/System';
|
||||
import {Dispatch, SetStateAction} from 'react';
|
||||
|
||||
type AttributeResponse = { type: string; values: Attribute[] }[];
|
||||
|
||||
interface CharacterEditorEditProps {
|
||||
character: CharacterProps;
|
||||
setCharacter: Dispatch<SetStateAction<CharacterProps | null>>;
|
||||
onCharacterChange: (key: keyof CharacterProps, value: string | number | null) => void;
|
||||
onAddAttribute: (section: keyof CharacterProps, attr: Attribute) => Promise<void>;
|
||||
onRemoveAttribute: (section: keyof CharacterProps, idx: number, id: string) => Promise<void>;
|
||||
seriesCharacter?: SeriesCharacterProps | null;
|
||||
onSyncComplete?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CharacterEditorEdit - Version sidebar édition
|
||||
* Mêmes fonctionnalités que CharacterSettingsEdit, layout linéaire
|
||||
*/
|
||||
export default function CharacterEditorEdit({
|
||||
character,
|
||||
setCharacter,
|
||||
onCharacterChange,
|
||||
onAddAttribute,
|
||||
onRemoveAttribute,
|
||||
seriesCharacter,
|
||||
onSyncComplete,
|
||||
}: CharacterEditorEditProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext(LangContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
|
||||
|
||||
// Traduire les données des SelectBox
|
||||
const translatedCharacterCategories = useMemo(() =>
|
||||
characterCategories.map((item) => ({
|
||||
...item,
|
||||
label: t(item.label)
|
||||
})), [t]);
|
||||
|
||||
const translatedCharacterStatus = useMemo(() =>
|
||||
characterStatus.map((item) => ({
|
||||
...item,
|
||||
label: t(item.label)
|
||||
})), [t]);
|
||||
|
||||
useEffect(function (): void {
|
||||
if (character?.id !== null) {
|
||||
getAttributes().then();
|
||||
}
|
||||
}, [character?.id]);
|
||||
|
||||
async function getAttributes(): Promise<void> {
|
||||
try {
|
||||
let response: AttributeResponse;
|
||||
if (isCurrentlyOffline()) {
|
||||
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
|
||||
} else if (book?.localBook) {
|
||||
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
|
||||
} else {
|
||||
response = await System.authGetQueryToServer<AttributeResponse>(
|
||||
'character/attribute',
|
||||
session.accessToken,
|
||||
lang,
|
||||
{characterId: character?.id}
|
||||
);
|
||||
}
|
||||
if (response) {
|
||||
const attributes: CharacterAttribute = {};
|
||||
response.forEach(function (item: { type: string; values: Attribute[] }): void {
|
||||
attributes[item.type] = item.values;
|
||||
});
|
||||
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
physical: attributes.physical ?? [],
|
||||
psychological: attributes.psychological ?? [],
|
||||
relations: attributes.relations ?? [],
|
||||
skills: attributes.skills ?? [],
|
||||
weaknesses: attributes.weaknesses ?? [],
|
||||
strengths: attributes.strengths ?? [],
|
||||
goals: attributes.goals ?? [],
|
||||
motivations: attributes.motivations ?? [],
|
||||
arc: attributes.arc ?? [],
|
||||
secrets: attributes.secrets ?? [],
|
||||
fears: attributes.fears ?? [],
|
||||
flaws: attributes.flaws ?? [],
|
||||
beliefs: attributes.beliefs ?? [],
|
||||
conflicts: attributes.conflicts ?? [],
|
||||
quotes: attributes.quotes ?? [],
|
||||
distinguishingMarks: attributes.distinguishingMarks ?? [],
|
||||
items: attributes.items ?? [],
|
||||
affiliations: attributes.affiliations ?? [],
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Informations de base */}
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.basicInfo')}</h4>
|
||||
<div className="space-y-3">
|
||||
<InputField
|
||||
fieldName={t('characterDetail.name')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.name || ''}
|
||||
currentValue={character.name || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="name"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('name', seriesCharacter?.name || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
value={character.name || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
onCharacterChange('name', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.namePlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.lastName')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.lastName || ''}
|
||||
currentValue={character.lastName || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="lastName"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('lastName', seriesCharacter?.lastName || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
value={character.lastName || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
onCharacterChange('lastName', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.lastNamePlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.role')}
|
||||
input={
|
||||
<SelectBox
|
||||
defaultValue={character.category || 'none'}
|
||||
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
|
||||
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
|
||||
return prev ? {...prev, category: e.target.value as CharacterProps['category']} : prev;
|
||||
});
|
||||
}}
|
||||
data={translatedCharacterCategories}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.gender')}
|
||||
input={
|
||||
<TextInput
|
||||
value={character.gender || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
onCharacterChange('gender', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.genderPlaceholder')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.age')}
|
||||
input={
|
||||
<NumberInput
|
||||
value={character.age ?? null}
|
||||
onValueChange={function (val: number | null): void {
|
||||
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
|
||||
return prev ? {...prev, age: val} : prev;
|
||||
});
|
||||
}}
|
||||
placeholder={t('characterDetail.agePlaceholder')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Histoire */}
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.historySection')}</h4>
|
||||
<div className="space-y-3">
|
||||
<InputField
|
||||
fieldName={t('characterDetail.biography')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.biography || ''}
|
||||
currentValue={character.biography || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="biography"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('biography', seriesCharacter?.biography || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
value={character.biography || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onCharacterChange('biography', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.biographyPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.roleFull')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.role || ''}
|
||||
currentValue={character.role || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="role"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('role', seriesCharacter?.role || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
value={character.role || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onCharacterChange('role', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.roleFullPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attributs de base */}
|
||||
{basicCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element {
|
||||
return (
|
||||
<CharacterSectionElement
|
||||
key={`basic-${index}`}
|
||||
title={item.title}
|
||||
section={item.section}
|
||||
placeholder={item.placeholder}
|
||||
icon={item.icon}
|
||||
selectedCharacter={character}
|
||||
setSelectedCharacter={setCharacter}
|
||||
handleAddElement={onAddAttribute}
|
||||
handleRemoveElement={onRemoveAttribute}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Toggle Mode Avancé */}
|
||||
<div className="flex items-center justify-between p-3 bg-secondary/30 rounded-lg border border-secondary/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faSliders} className="text-primary w-4 h-4"/>
|
||||
<span className="text-text-primary font-medium text-sm">{t('characterDetail.advancedMode')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={function (): void { setShowAdvanced(!showAdvanced); }}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm transition-all duration-200 ${
|
||||
showAdvanced
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-secondary/50 text-text-primary hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
{showAdvanced ? t('characterDetail.hideAdvanced') : t('characterDetail.showAdvanced')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sections avancées */}
|
||||
{showAdvanced && (
|
||||
<>
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.identitySection')}</h4>
|
||||
<div className="space-y-3">
|
||||
<InputField
|
||||
fieldName={t('characterDetail.species')}
|
||||
input={
|
||||
<TextInput
|
||||
value={character.species || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
onCharacterChange('species', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.speciesPlaceholder')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.status')}
|
||||
input={
|
||||
<SelectBox
|
||||
defaultValue={character.status || 'alive'}
|
||||
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
|
||||
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
|
||||
return prev ? {...prev, status: e.target.value as CharacterProps['status']} : prev;
|
||||
});
|
||||
}}
|
||||
data={translatedCharacterStatus}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('characterDetail.authorSection')}</h4>
|
||||
<InputField
|
||||
fieldName={t('characterDetail.notes')}
|
||||
input={
|
||||
<TexteAreaInput
|
||||
value={character.notes || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onCharacterChange('notes', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.notesPlaceholder')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{advancedCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element {
|
||||
return (
|
||||
<CharacterSectionElement
|
||||
key={`advanced-${index}`}
|
||||
title={item.title}
|
||||
section={item.section}
|
||||
placeholder={item.placeholder}
|
||||
icon={item.icon}
|
||||
selectedCharacter={character}
|
||||
setSelectedCharacter={setCharacter}
|
||||
handleAddElement={onAddAttribute}
|
||||
handleRemoveElement={onRemoveAttribute}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
import React, {useState} from 'react';
|
||||
import {CharacterProps} from '@/lib/models/Character';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faChevronRight, faPlus, faUser} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
|
||||
interface CharacterEditorListProps {
|
||||
characters: CharacterProps[];
|
||||
onCharacterClick: (character: CharacterProps) => void;
|
||||
onAddCharacter: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CharacterEditorList - Liste des personnages pour ComposerRightBar
|
||||
* Version compacte sans groupage par catégorie
|
||||
* PAS de scroll interne (géré par parent ComposerRightBar)
|
||||
*/
|
||||
export default function CharacterEditorList({
|
||||
characters,
|
||||
onCharacterClick,
|
||||
onAddCharacter,
|
||||
}: CharacterEditorListProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
function getFilteredCharacters(): CharacterProps[] {
|
||||
return characters.filter(function (char: CharacterProps): boolean {
|
||||
return char.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(char.lastName?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false);
|
||||
});
|
||||
}
|
||||
|
||||
const filteredCharacters: CharacterProps[] = getFilteredCharacters();
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="px-2">
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={searchQuery}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setSearchQuery(e.target.value);
|
||||
}}
|
||||
placeholder={t('characterList.search')}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionLabel={t('characterList.add')}
|
||||
addButtonCallBack={async function (): Promise<void> {
|
||||
onAddCharacter();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-2 space-y-2">
|
||||
{filteredCharacters.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
|
||||
<FontAwesomeIcon icon={faUser} className="text-primary w-8 h-8"/>
|
||||
</div>
|
||||
<h3 className="text-text-primary font-semibold text-base mb-1">
|
||||
{t('characterList.noCharacters')}
|
||||
</h3>
|
||||
<p className="text-muted text-sm max-w-xs">
|
||||
{t('characterList.noCharactersDescription')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredCharacters.map(function (char: CharacterProps): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
key={char.id}
|
||||
onClick={function (): void { onCharacterClick(char); }}
|
||||
className="group flex items-center p-3 bg-secondary/30 rounded-lg border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md transition-all duration-200 hover:border-primary/50"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-sm group-hover:scale-110 transition-transform">
|
||||
{char.image ? (
|
||||
<img
|
||||
src={char.image}
|
||||
alt={char.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-primary/10 text-primary font-bold text-sm">
|
||||
{char.name?.charAt(0)?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-3 flex-1 min-w-0">
|
||||
<div className="text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate">
|
||||
{char.name || t('characterList.unknown')}
|
||||
</div>
|
||||
<div className="text-muted text-xs truncate">
|
||||
{char.title || char.role || t('characterList.noRole')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-6 flex justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
'use client';
|
||||
import React, {useCallback, useContext, useMemo, useState} from 'react';
|
||||
import {useCharacters, UseCharactersConfig} from '@/hooks/settings/useCharacters';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {CharacterProps} from '@/lib/models/Character';
|
||||
import {SeriesCharacterProps} from '@/lib/models/Series';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import ToggleSwitch from '@/components/form/ToggleSwitch';
|
||||
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
|
||||
import AlertBox from '@/components/AlertBox';
|
||||
|
||||
import CharacterSettingsList from './CharacterSettingsList';
|
||||
import CharacterSettingsDetail from './CharacterSettingsDetail';
|
||||
import CharacterSettingsEdit from './CharacterSettingsEdit';
|
||||
|
||||
interface CharacterSettingsProps {
|
||||
entityType?: 'book' | 'series';
|
||||
entityId?: string;
|
||||
showToggle?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CharacterSettings - Orchestrateur pour BookSetting/SerieSetting
|
||||
* Gère le viewMode (list/detail/edit) et coordonne les sous-composants
|
||||
* Inclut: toggle tool, import from series, header avec actions
|
||||
*/
|
||||
export default function CharacterSettings({
|
||||
entityType = 'book',
|
||||
entityId,
|
||||
showToggle = true,
|
||||
}: CharacterSettingsProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {book} = useContext(BookContext);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
|
||||
|
||||
const resolvedEntityId: string = entityId || book?.bookId || '';
|
||||
|
||||
const config: UseCharactersConfig = useMemo(function (): UseCharactersConfig {
|
||||
return {
|
||||
entityType,
|
||||
entityId: resolvedEntityId,
|
||||
};
|
||||
}, [entityType, resolvedEntityId]);
|
||||
|
||||
const {
|
||||
characters,
|
||||
seriesCharacters,
|
||||
selectedCharacter,
|
||||
toolEnabled,
|
||||
isLoading,
|
||||
isSeriesMode,
|
||||
bookSeriesId,
|
||||
viewMode,
|
||||
saveCharacter,
|
||||
deleteCharacter,
|
||||
updateCharacterField,
|
||||
addAttribute,
|
||||
removeAttribute,
|
||||
toggleTool,
|
||||
importFromSeries,
|
||||
exportToSeries,
|
||||
refreshSeriesCharacters,
|
||||
setSelectedCharacter,
|
||||
enterDetailMode,
|
||||
enterEditMode,
|
||||
exitEditMode,
|
||||
backToList,
|
||||
addNewCharacter,
|
||||
} = useCharacters(config);
|
||||
|
||||
const availableSeriesCharacters = useMemo(function (): SeriesCharacterProps[] {
|
||||
return seriesCharacters.filter(function (sc: SeriesCharacterProps): boolean {
|
||||
return !characters.some(function (c: CharacterProps): boolean {
|
||||
return c.seriesCharacterId === sc.id;
|
||||
});
|
||||
});
|
||||
}, [seriesCharacters, characters]);
|
||||
|
||||
const handleCharacterChange = useCallback(function (key: keyof CharacterProps, value: string | number | null): void {
|
||||
updateCharacterField(key, value);
|
||||
}, [updateCharacterField]);
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
await exitEditMode(true);
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
exitEditMode(false);
|
||||
}
|
||||
|
||||
async function handleDelete(): Promise<void> {
|
||||
if (selectedCharacter?.id) {
|
||||
await deleteCharacter(selectedCharacter.id);
|
||||
setShowDeleteConfirm(false);
|
||||
backToList();
|
||||
}
|
||||
}
|
||||
|
||||
function getSeriesCharacterForSelected(): SeriesCharacterProps | null {
|
||||
if (!selectedCharacter?.seriesCharacterId) return null;
|
||||
return seriesCharacters.find(function (sc: SeriesCharacterProps): boolean {
|
||||
return sc.id === selectedCharacter.seriesCharacterId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isNew: boolean = selectedCharacter?.id === null;
|
||||
const canExport: boolean = Boolean(bookSeriesId && selectedCharacter?.id && !selectedCharacter.seriesCharacterId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header - uniquement pour detail/edit */}
|
||||
<ToolDetailHeader
|
||||
title={selectedCharacter?.name || ''}
|
||||
defaultTitle={t('characterDetail.newCharacter')}
|
||||
viewMode={viewMode}
|
||||
isNew={isNew}
|
||||
onBack={backToList}
|
||||
onEdit={enterEditMode}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onDelete={function (): void { setShowDeleteConfirm(true); }}
|
||||
onExport={canExport ? exportToSeries : undefined}
|
||||
showExport={canExport}
|
||||
showDelete={Boolean(selectedCharacter?.id)}
|
||||
/>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{viewMode === 'list' && (
|
||||
<div className="space-y-5 p-4">
|
||||
{/* Toggle tool */}
|
||||
{showToggle && !isSeriesMode && (
|
||||
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
|
||||
<InputField
|
||||
icon={faToggleOn}
|
||||
fieldName={t('characterComponent.enableTool')}
|
||||
input={
|
||||
<ToggleSwitch
|
||||
checked={toolEnabled}
|
||||
onChange={toggleTool}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p className="text-muted text-sm mt-2">
|
||||
{t('characterComponent.enableToolDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contenu si outil activé */}
|
||||
{(toolEnabled || isSeriesMode) && (
|
||||
<>
|
||||
{/* Import from series */}
|
||||
{!isSeriesMode && bookSeriesId && availableSeriesCharacters.length > 0 && (
|
||||
<SeriesImportSelector
|
||||
availableItems={availableSeriesCharacters.map(function (sc: SeriesCharacterProps) {
|
||||
return {
|
||||
id: sc.id,
|
||||
name: `${sc.name}${sc.lastName ? ' ' + sc.lastName : ''}`
|
||||
};
|
||||
})}
|
||||
onImport={importFromSeries}
|
||||
placeholder={t('seriesImport.selectElement')}
|
||||
label={t('seriesImport.importFromSeries')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Liste des personnages */}
|
||||
<CharacterSettingsList
|
||||
characters={characters}
|
||||
onCharacterClick={enterDetailMode}
|
||||
onAddCharacter={addNewCharacter}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'detail' && selectedCharacter && (
|
||||
<div className="p-4">
|
||||
<CharacterSettingsDetail
|
||||
character={selectedCharacter}
|
||||
seriesCharacter={getSeriesCharacterForSelected()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'edit' && selectedCharacter && (
|
||||
<div className="p-4">
|
||||
<CharacterSettingsEdit
|
||||
character={selectedCharacter}
|
||||
setCharacter={setSelectedCharacter}
|
||||
onCharacterChange={handleCharacterChange}
|
||||
onAddAttribute={addAttribute}
|
||||
onRemoveAttribute={removeAttribute}
|
||||
seriesCharacter={getSeriesCharacterForSelected()}
|
||||
onSyncComplete={refreshSeriesCharacters}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal de confirmation de suppression */}
|
||||
{showDeleteConfirm && selectedCharacter?.id && (
|
||||
<AlertBox
|
||||
title={t('characterDetail.deleteTitle')}
|
||||
message={t('characterDetail.deleteMessage', {name: selectedCharacter.name})}
|
||||
type="danger"
|
||||
confirmText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={function (): void { setShowDeleteConfirm(false); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
'use client';
|
||||
import React, {useContext, useEffect, useState} from 'react';
|
||||
import {
|
||||
advancedCharacterElements,
|
||||
Attribute,
|
||||
basicCharacterElements,
|
||||
CharacterAttribute,
|
||||
characterCategories,
|
||||
CharacterElement,
|
||||
CharacterProps,
|
||||
characterStatus
|
||||
} from '@/lib/models/Character';
|
||||
import {SeriesCharacterProps} from '@/lib/models/Series';
|
||||
import CollapsableArea from '@/components/CollapsableArea';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBook,
|
||||
faCommentDots,
|
||||
faGlobe,
|
||||
faSliders,
|
||||
faStickyNote,
|
||||
faUser,
|
||||
faVenusMars,
|
||||
faCakeCandles,
|
||||
faTag,
|
||||
faCrown,
|
||||
faQuoteLeft,
|
||||
faFlag,
|
||||
faHouse,
|
||||
faSkull,
|
||||
faDna,
|
||||
faPalette,
|
||||
faNoteSticky
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {SessionContext} from '@/context/SessionContext';
|
||||
import {AlertContext} from '@/context/AlertContext';
|
||||
import {LangContext} from '@/context/LangContext';
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import System from '@/lib/models/System';
|
||||
|
||||
type AttributeResponse = { type: string; values: Attribute[] }[];
|
||||
|
||||
interface CharacterSettingsDetailProps {
|
||||
character: CharacterProps;
|
||||
seriesCharacter?: SeriesCharacterProps | null;
|
||||
onLoadAttributes?: (attributes: CharacterAttribute) => void;
|
||||
}
|
||||
|
||||
export default function CharacterSettingsDetail({
|
||||
character,
|
||||
seriesCharacter,
|
||||
onLoadAttributes,
|
||||
}: CharacterSettingsDetailProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext(LangContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
|
||||
|
||||
useEffect(function (): void {
|
||||
if (character?.id !== null) {
|
||||
getAttributes().then();
|
||||
}
|
||||
}, [character?.id]);
|
||||
|
||||
async function getAttributes(): Promise<void> {
|
||||
try {
|
||||
let response: AttributeResponse;
|
||||
if (isCurrentlyOffline()) {
|
||||
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
|
||||
} else if (book?.localBook) {
|
||||
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
|
||||
} else {
|
||||
response = await System.authGetQueryToServer<AttributeResponse>(
|
||||
'character/attribute',
|
||||
session.accessToken,
|
||||
lang,
|
||||
{characterId: character?.id}
|
||||
);
|
||||
}
|
||||
if (response && onLoadAttributes) {
|
||||
const attributes: CharacterAttribute = {};
|
||||
response.forEach(function (item: { type: string; values: Attribute[] }): void {
|
||||
attributes[item.type] = item.values;
|
||||
});
|
||||
onLoadAttributes(attributes);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCategoryLabel(): string {
|
||||
const cat = characterCategories.find(c => c.value === character.category);
|
||||
return cat ? t(cat.label) : character.category || '—';
|
||||
}
|
||||
|
||||
function getStatusLabel(): string {
|
||||
const stat = characterStatus.find(s => s.value === character.status);
|
||||
return stat ? t(stat.label) : character.status || '—';
|
||||
}
|
||||
|
||||
function renderAttributeSection(element: CharacterElement): React.JSX.Element | null {
|
||||
const attributes: Attribute[] = character[element.section] as Attribute[] || [];
|
||||
if (attributes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<CollapsableArea key={element.section} title={element.title} icon={element.icon}>
|
||||
<div className="flex flex-wrap gap-2 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
{attributes.map(function (attr: Attribute, index: number): React.JSX.Element {
|
||||
return (
|
||||
<span key={index} className="px-3 py-1 bg-primary/20 text-primary rounded-full text-sm border border-primary/30">
|
||||
{attr.name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-2 pb-4">
|
||||
{/* Hero Section - Image + Infos principales */}
|
||||
<div className="flex gap-6 p-6 bg-gradient-to-r from-secondary/30 to-transparent rounded-2xl border border-secondary/30">
|
||||
{/* Image */}
|
||||
<div className="shrink-0">
|
||||
{character.image ? (
|
||||
<div className="w-32 h-32 rounded-2xl border-4 border-primary/50 overflow-hidden shadow-lg">
|
||||
<img src={character.image} alt={character.name} className="w-full h-full object-cover"/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-32 h-32 rounded-2xl border-4 border-secondary/50 bg-secondary/30 flex items-center justify-center">
|
||||
<FontAwesomeIcon icon={faUser} className="w-12 h-12 text-text-secondary/50"/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Infos principales */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-2xl font-bold text-text-primary">
|
||||
{character.name} {character.lastName}
|
||||
</h2>
|
||||
{character.nickname && (
|
||||
<p className="text-primary italic mt-1">« {character.nickname} »</p>
|
||||
)}
|
||||
{character.title && (
|
||||
<p className="text-text-secondary mt-2">{character.title}</p>
|
||||
)}
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1 bg-primary/20 text-primary rounded-lg text-sm border border-primary/30">
|
||||
<FontAwesomeIcon icon={faTag} className="w-3 h-3"/>
|
||||
{getCategoryLabel()}
|
||||
</span>
|
||||
{character.gender && (
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1 bg-secondary/50 text-text-primary rounded-lg text-sm border border-secondary/50">
|
||||
<FontAwesomeIcon icon={faVenusMars} className="w-3 h-3"/>
|
||||
{character.gender}
|
||||
</span>
|
||||
)}
|
||||
{character.age && (
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1 bg-secondary/50 text-text-primary rounded-lg text-sm border border-secondary/50">
|
||||
<FontAwesomeIcon icon={faCakeCandles} className="w-3 h-3"/>
|
||||
{character.age} {t('characterDetail.yearsOld')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Histoire & Biographie */}
|
||||
<CollapsableArea title={t('characterDetail.historySection')} icon={faBook}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="lg:col-span-2">
|
||||
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.biography')}</h4>
|
||||
<p className={`${character.biography ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{character.biography || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.history')}</h4>
|
||||
<p className={`${character.history ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{character.history || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.roleFull')}</h4>
|
||||
<p className={`${character.role ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{character.role || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
{/* Attributs de base */}
|
||||
{basicCharacterElements.map(renderAttributeSection)}
|
||||
|
||||
{/* Toggle Mode Avancé */}
|
||||
<div className="flex items-center justify-between p-4 bg-secondary/30 rounded-xl border border-secondary/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<FontAwesomeIcon icon={faSliders} className="text-primary w-5 h-5"/>
|
||||
<span className="text-text-primary font-medium">{t('characterDetail.advancedMode')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={function (): void { setShowAdvanced(!showAdvanced); }}
|
||||
className={`px-4 py-2 rounded-lg transition-all duration-200 ${
|
||||
showAdvanced
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-secondary/50 text-text-primary hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
{showAdvanced ? t('characterDetail.hideAdvanced') : t('characterDetail.showAdvanced')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sections avancées */}
|
||||
{showAdvanced && (
|
||||
<>
|
||||
{/* Identité étendue */}
|
||||
<CollapsableArea title={t('characterDetail.identitySection')} icon={faGlobe}>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="p-3 bg-dark-background/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faDna} className="w-3 h-3 text-primary"/>
|
||||
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.species')}</span>
|
||||
</div>
|
||||
<p className={character.species ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
|
||||
{character.species || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-background/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faFlag} className="w-3 h-3 text-primary"/>
|
||||
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.nationality')}</span>
|
||||
</div>
|
||||
<p className={character.nationality ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
|
||||
{character.nationality || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-background/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faSkull} className="w-3 h-3 text-primary"/>
|
||||
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.status')}</span>
|
||||
</div>
|
||||
<p className={character.status ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
|
||||
{getStatusLabel()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-background/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faHouse} className="w-3 h-3 text-primary"/>
|
||||
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.residence')}</span>
|
||||
</div>
|
||||
<p className={character.residence ? 'text-text-primary' : 'text-text-secondary/50 italic'}>
|
||||
{character.residence || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
{/* Voix du personnage */}
|
||||
<CollapsableArea title={t('characterDetail.voiceSection')} icon={faCommentDots}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="p-4 bg-dark-background/30 rounded-lg">
|
||||
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.speechPattern')}</h4>
|
||||
<p className={`${character.speechPattern ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{character.speechPattern || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-dark-background/30 rounded-lg relative">
|
||||
<FontAwesomeIcon icon={faQuoteLeft} className="absolute top-2 left-2 w-6 h-6 text-primary/20"/>
|
||||
<h4 className="text-text-secondary text-xs uppercase tracking-wide mb-2">{t('characterDetail.catchphrase')}</h4>
|
||||
<p className={`italic ${character.catchphrase ? 'text-text-primary' : 'text-text-secondary/50'}`}>
|
||||
{character.catchphrase ? `« ${character.catchphrase} »` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
{/* Notes de l'auteur */}
|
||||
<CollapsableArea title={t('characterDetail.authorSection')} icon={faStickyNote}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="lg:col-span-2 p-4 bg-dark-background/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faNoteSticky} className="w-3 h-3 text-primary"/>
|
||||
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.notes')}</span>
|
||||
</div>
|
||||
<p className={`${character.notes ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{character.notes || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-dark-background/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faPalette} className="w-3 h-3 text-primary"/>
|
||||
<span className="text-text-secondary text-xs uppercase">{t('characterDetail.colorLabel')}</span>
|
||||
</div>
|
||||
{character.color ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg border-2 border-white/20"
|
||||
style={{backgroundColor: character.color}}
|
||||
/>
|
||||
<span className="text-text-primary font-mono">{character.color}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-text-secondary/50 italic">—</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
{/* Attributs avancés */}
|
||||
{advancedCharacterElements.map(renderAttributeSection)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
'use client';
|
||||
import React, {useContext, useEffect, useMemo, useState} from 'react';
|
||||
import {
|
||||
advancedCharacterElements,
|
||||
Attribute,
|
||||
basicCharacterElements,
|
||||
CharacterAttribute,
|
||||
characterCategories,
|
||||
CharacterElement,
|
||||
CharacterProps,
|
||||
characterStatus
|
||||
} from '@/lib/models/Character';
|
||||
import {SeriesCharacterProps} from '@/lib/models/Series';
|
||||
import CollapsableArea from '@/components/CollapsableArea';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import TexteAreaInput from '@/components/form/TexteAreaInput';
|
||||
import NumberInput from '@/components/form/NumberInput';
|
||||
import SelectBox from '@/components/form/SelectBox';
|
||||
import CharacterSectionElement from '@/components/book/settings/characters/CharacterSectionElement';
|
||||
import SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBook,
|
||||
faCommentDots,
|
||||
faGlobe,
|
||||
faScroll,
|
||||
faSliders,
|
||||
faStickyNote,
|
||||
faUser
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {SessionContext} from '@/context/SessionContext';
|
||||
import {AlertContext} from '@/context/AlertContext';
|
||||
import {LangContext} from '@/context/LangContext';
|
||||
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import System from '@/lib/models/System';
|
||||
import {Dispatch, SetStateAction} from 'react';
|
||||
|
||||
type AttributeResponse = { type: string; values: Attribute[] }[];
|
||||
|
||||
interface CharacterSettingsEditProps {
|
||||
character: CharacterProps;
|
||||
setCharacter: Dispatch<SetStateAction<CharacterProps | null>>;
|
||||
onCharacterChange: (key: keyof CharacterProps, value: string | number | null) => void;
|
||||
onAddAttribute: (section: keyof CharacterProps, attr: Attribute) => Promise<void>;
|
||||
onRemoveAttribute: (section: keyof CharacterProps, idx: number, id: string) => Promise<void>;
|
||||
seriesCharacter?: SeriesCharacterProps | null;
|
||||
onSyncComplete?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CharacterSettingsEdit - Vue édition des détails d'un personnage
|
||||
* Pour BookSetting/SerieSetting - Tous les champs éditables avec SyncFieldWrapper
|
||||
* PAS de scroll interne (géré par parent)
|
||||
*/
|
||||
export default function CharacterSettingsEdit({
|
||||
character,
|
||||
setCharacter,
|
||||
onCharacterChange,
|
||||
onAddAttribute,
|
||||
onRemoveAttribute,
|
||||
seriesCharacter,
|
||||
onSyncComplete,
|
||||
}: CharacterSettingsEditProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {lang} = useContext(LangContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
const {errorMessage} = useContext(AlertContext);
|
||||
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
|
||||
const {book} = useContext(BookContext);
|
||||
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
|
||||
|
||||
// Traduire les données des SelectBox
|
||||
const translatedCharacterCategories = useMemo(() =>
|
||||
characterCategories.map((item) => ({
|
||||
...item,
|
||||
label: t(item.label)
|
||||
})), [t]);
|
||||
|
||||
const translatedCharacterStatus = useMemo(() =>
|
||||
characterStatus.map((item) => ({
|
||||
...item,
|
||||
label: t(item.label)
|
||||
})), [t]);
|
||||
|
||||
useEffect(function (): void {
|
||||
if (character?.id !== null) {
|
||||
getAttributes().then();
|
||||
}
|
||||
}, [character?.id]);
|
||||
|
||||
async function getAttributes(): Promise<void> {
|
||||
try {
|
||||
let response: AttributeResponse;
|
||||
if (isCurrentlyOffline()) {
|
||||
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
|
||||
} else if (book?.localBook) {
|
||||
response = await window.electron.invoke<AttributeResponse>('db:character:attributes', {characterId: character?.id});
|
||||
} else {
|
||||
response = await System.authGetQueryToServer<AttributeResponse>(
|
||||
'character/attribute',
|
||||
session.accessToken,
|
||||
lang,
|
||||
{characterId: character?.id}
|
||||
);
|
||||
}
|
||||
if (response) {
|
||||
const attributes: CharacterAttribute = {};
|
||||
response.forEach(function (item: { type: string; values: Attribute[] }): void {
|
||||
attributes[item.type] = item.values;
|
||||
});
|
||||
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
physical: attributes.physical ?? [],
|
||||
psychological: attributes.psychological ?? [],
|
||||
relations: attributes.relations ?? [],
|
||||
skills: attributes.skills ?? [],
|
||||
weaknesses: attributes.weaknesses ?? [],
|
||||
strengths: attributes.strengths ?? [],
|
||||
goals: attributes.goals ?? [],
|
||||
motivations: attributes.motivations ?? [],
|
||||
arc: attributes.arc ?? [],
|
||||
secrets: attributes.secrets ?? [],
|
||||
fears: attributes.fears ?? [],
|
||||
flaws: attributes.flaws ?? [],
|
||||
beliefs: attributes.beliefs ?? [],
|
||||
conflicts: attributes.conflicts ?? [],
|
||||
quotes: attributes.quotes ?? [],
|
||||
distinguishingMarks: attributes.distinguishingMarks ?? [],
|
||||
items: attributes.items ?? [],
|
||||
affiliations: attributes.affiliations ?? [],
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorMessage(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-2 pb-4">
|
||||
{/* Informations de base */}
|
||||
<CollapsableArea title={t('characterDetail.basicInfo')} icon={faUser}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<InputField
|
||||
fieldName={t('characterDetail.name')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.name || ''}
|
||||
currentValue={character.name || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="name"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('name', seriesCharacter?.name || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
value={character.name || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
onCharacterChange('name', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.namePlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.lastName')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.lastName || ''}
|
||||
currentValue={character.lastName || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="lastName"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('lastName', seriesCharacter?.lastName || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
value={character.lastName || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
onCharacterChange('lastName', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.lastNamePlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.nickname')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.nickname || ''}
|
||||
currentValue={character.nickname || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="nickname"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('nickname', seriesCharacter?.nickname || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
value={character.nickname || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
onCharacterChange('nickname', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.nicknamePlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.role')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.category || 'none'}
|
||||
currentValue={character.category || 'none'}
|
||||
bookElementId={character.id || ''}
|
||||
field="category"
|
||||
elementType="character"
|
||||
onDownload={function (): void {
|
||||
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
|
||||
return prev ? {...prev, category: (seriesCharacter?.category || 'none') as CharacterProps['category']} : prev;
|
||||
});
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<SelectBox
|
||||
defaultValue={character.category || 'none'}
|
||||
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
|
||||
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
|
||||
return prev ? {...prev, category: e.target.value as CharacterProps['category']} : prev;
|
||||
});
|
||||
}}
|
||||
data={translatedCharacterCategories}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.title')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.title || ''}
|
||||
currentValue={character.title || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="title"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('title', seriesCharacter?.title || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
value={character.title || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
onCharacterChange('title', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.titlePlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.gender')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.gender || ''}
|
||||
currentValue={character.gender || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="gender"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('gender', seriesCharacter?.gender || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
value={character.gender || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
onCharacterChange('gender', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.genderPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.age')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.age !== null && seriesCharacter?.age !== undefined ? String(seriesCharacter.age) : ''}
|
||||
currentValue={character.age !== null && character.age !== undefined ? String(character.age) : ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="age"
|
||||
elementType="character"
|
||||
onDownload={function (): void {
|
||||
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
|
||||
return prev ? {...prev, age: seriesCharacter?.age ?? null} : prev;
|
||||
});
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<NumberInput
|
||||
value={character.age ?? null}
|
||||
onValueChange={function (val: number | null): void {
|
||||
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
|
||||
return prev ? {...prev, age: val} : prev;
|
||||
});
|
||||
}}
|
||||
placeholder={t('characterDetail.agePlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
{/* Histoire */}
|
||||
<CollapsableArea title={t('characterDetail.historySection')} icon={faBook}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<InputField
|
||||
fieldName={t('characterDetail.biography')}
|
||||
icon={faBook}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.biography || ''}
|
||||
currentValue={character.biography || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="biography"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('biography', seriesCharacter?.biography || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
value={character.biography || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onCharacterChange('biography', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.biographyPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.history')}
|
||||
icon={faScroll}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.history || ''}
|
||||
currentValue={character.history || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="history"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('history', seriesCharacter?.history || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
value={character.history || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onCharacterChange('history', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.historyPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.roleFull')}
|
||||
icon={faScroll}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.role || ''}
|
||||
currentValue={character.role || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="role"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('role', seriesCharacter?.role || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
value={character.role || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onCharacterChange('role', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.roleFullPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
{/* Attributs de base */}
|
||||
{basicCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element {
|
||||
return (
|
||||
<CharacterSectionElement
|
||||
key={`basic-${index}`}
|
||||
title={item.title}
|
||||
section={item.section}
|
||||
placeholder={item.placeholder}
|
||||
icon={item.icon}
|
||||
selectedCharacter={character}
|
||||
setSelectedCharacter={setCharacter}
|
||||
handleAddElement={onAddAttribute}
|
||||
handleRemoveElement={onRemoveAttribute}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Toggle Mode Avancé */}
|
||||
<div className="flex items-center justify-between p-4 bg-secondary/30 rounded-xl border border-secondary/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<FontAwesomeIcon icon={faSliders} className="text-primary w-5 h-5"/>
|
||||
<span className="text-text-primary font-medium">{t('characterDetail.advancedMode')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={function (): void { setShowAdvanced(!showAdvanced); }}
|
||||
className={`px-4 py-2 rounded-lg transition-all duration-200 ${
|
||||
showAdvanced
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-secondary/50 text-text-primary hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
{showAdvanced ? t('characterDetail.hideAdvanced') : t('characterDetail.showAdvanced')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sections avancées */}
|
||||
{showAdvanced && (
|
||||
<>
|
||||
{/* Identité étendue */}
|
||||
<CollapsableArea title={t('characterDetail.identitySection')} icon={faGlobe}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<InputField
|
||||
fieldName={t('characterDetail.species')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.species || ''}
|
||||
currentValue={character.species || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="species"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('species', seriesCharacter?.species || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
value={character.species || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
onCharacterChange('species', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.speciesPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.nationality')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.nationality || ''}
|
||||
currentValue={character.nationality || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="nationality"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('nationality', seriesCharacter?.nationality || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
value={character.nationality || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
onCharacterChange('nationality', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.nationalityPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.status')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.status || 'alive'}
|
||||
currentValue={character.status || 'alive'}
|
||||
bookElementId={character.id || ''}
|
||||
field="status"
|
||||
elementType="character"
|
||||
onDownload={function (): void {
|
||||
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
|
||||
return prev ? {...prev, status: (seriesCharacter?.status || 'alive') as CharacterProps['status']} : prev;
|
||||
});
|
||||
}}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<SelectBox
|
||||
defaultValue={character.status || 'alive'}
|
||||
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
|
||||
setCharacter(function (prev: CharacterProps | null): CharacterProps | null {
|
||||
return prev ? {...prev, status: e.target.value as CharacterProps['status']} : prev;
|
||||
});
|
||||
}}
|
||||
data={translatedCharacterStatus}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.residence')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.residence || ''}
|
||||
currentValue={character.residence || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="residence"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('residence', seriesCharacter?.residence || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
value={character.residence || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
onCharacterChange('residence', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.residencePlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
{/* Voix du personnage */}
|
||||
<CollapsableArea title={t('characterDetail.voiceSection')} icon={faCommentDots}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<InputField
|
||||
fieldName={t('characterDetail.speechPattern')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.speechPattern || ''}
|
||||
currentValue={character.speechPattern || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="speechPattern"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('speechPattern', seriesCharacter?.speechPattern || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
value={character.speechPattern || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onCharacterChange('speechPattern', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.speechPatternPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.catchphrase')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.catchphrase || ''}
|
||||
currentValue={character.catchphrase || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="catchphrase"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('catchphrase', seriesCharacter?.catchphrase || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
value={character.catchphrase || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
onCharacterChange('catchphrase', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.catchphrasePlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
{/* Notes de l'auteur */}
|
||||
<CollapsableArea title={t('characterDetail.authorSection')} icon={faStickyNote}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<InputField
|
||||
fieldName={t('characterDetail.notes')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.notes || ''}
|
||||
currentValue={character.notes || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="notes"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('notes', seriesCharacter?.notes || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
value={character.notes || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onCharacterChange('notes', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.notesPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('characterDetail.colorLabel')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={character.seriesCharacterId}
|
||||
seriesValue={seriesCharacter?.color || ''}
|
||||
currentValue={character.color || ''}
|
||||
bookElementId={character.id || ''}
|
||||
field="color"
|
||||
elementType="character"
|
||||
onDownload={function (): void { onCharacterChange('color', seriesCharacter?.color || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
value={character.color || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
onCharacterChange('color', e.target.value);
|
||||
}}
|
||||
placeholder={t('characterDetail.colorPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
{/* Attributs avancés */}
|
||||
{advancedCharacterElements.map(function (item: CharacterElement, index: number): React.JSX.Element {
|
||||
return (
|
||||
<CharacterSectionElement
|
||||
key={`advanced-${index}`}
|
||||
title={item.title}
|
||||
section={item.section}
|
||||
placeholder={item.placeholder}
|
||||
icon={item.icon}
|
||||
selectedCharacter={character}
|
||||
setSelectedCharacter={setCharacter}
|
||||
handleAddElement={onAddAttribute}
|
||||
handleRemoveElement={onRemoveAttribute}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
import React, {useState} from 'react';
|
||||
import {characterCategories, CharacterProps} from '@/lib/models/Character';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import TextInput from '@/components/form/TextInput';
|
||||
import CollapsableArea from '@/components/CollapsableArea';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faChevronRight, faPlus, faUser} from '@fortawesome/free-solid-svg-icons';
|
||||
import {SelectBoxProps} from '@/shared/interface';
|
||||
import {useTranslations} from 'next-intl';
|
||||
|
||||
interface CharacterSettingsListProps {
|
||||
characters: CharacterProps[];
|
||||
onCharacterClick: (character: CharacterProps) => void;
|
||||
onAddCharacter: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CharacterSettingsList - Liste des personnages pour BookSetting/SerieSetting
|
||||
* Version complète avec groupage par catégorie et sections collapsibles
|
||||
* PAS de scroll interne (géré par parent SettingsContainer)
|
||||
*/
|
||||
export default function CharacterSettingsList({
|
||||
characters,
|
||||
onCharacterClick,
|
||||
onAddCharacter,
|
||||
}: CharacterSettingsListProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
function getFilteredCharacters(): CharacterProps[] {
|
||||
return characters.filter(function (char: CharacterProps): boolean {
|
||||
return char.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(char.lastName?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false);
|
||||
});
|
||||
}
|
||||
|
||||
const filteredCharacters: CharacterProps[] = getFilteredCharacters();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="px-4 mb-4">
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={searchQuery}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setSearchQuery(e.target.value);
|
||||
}}
|
||||
placeholder={t('characterList.search')}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionLabel={t('characterList.add')}
|
||||
addButtonCallBack={async function (): Promise<void> {
|
||||
onAddCharacter();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-2">
|
||||
{characterCategories.map(function (category: SelectBoxProps): React.JSX.Element | null {
|
||||
const categoryCharacters: CharacterProps[] = filteredCharacters.filter(
|
||||
function (char: CharacterProps): boolean {
|
||||
return char.category === category.value;
|
||||
}
|
||||
);
|
||||
|
||||
if (categoryCharacters.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapsableArea
|
||||
key={category.value}
|
||||
title={t(category.label)}
|
||||
icon={faUser}
|
||||
>
|
||||
<div className="space-y-2 p-2">
|
||||
{categoryCharacters.map(function (char: CharacterProps): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
key={char.id}
|
||||
onClick={function (): void {
|
||||
onCharacterClick(char);
|
||||
}}
|
||||
className="group flex items-center p-4 bg-secondary/30 rounded-xl border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md hover:scale-102 transition-all duration-200 hover:border-primary/50"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform">
|
||||
{char.image ? (
|
||||
<img
|
||||
src={char.image}
|
||||
alt={char.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-primary/10 text-primary font-bold text-lg">
|
||||
{char.name?.charAt(0)?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex-1">
|
||||
<div className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
|
||||
{char.name || t('characterList.unknown')}
|
||||
</div>
|
||||
<div className="text-text-secondary text-sm mt-0.5">
|
||||
{char.lastName || t('characterList.noLastName')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-28 px-3">
|
||||
<div className="text-primary text-sm font-semibold truncate">
|
||||
{char.title || t('characterList.noTitle')}
|
||||
</div>
|
||||
<div className="text-muted text-xs truncate mt-0.5">
|
||||
{char.role || t('characterList.noRole')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-8 flex justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredCharacters.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<FontAwesomeIcon icon={faUser} className="text-primary w-10 h-10"/>
|
||||
</div>
|
||||
<h3 className="text-text-primary font-semibold text-lg mb-2">
|
||||
{t('characterList.noCharacters')}
|
||||
</h3>
|
||||
<p className="text-muted text-sm max-w-xs">
|
||||
{t('characterList.noCharactersDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user