'use client' import {useCallback, useContext, useEffect, useState} from 'react'; import { Attribute, CharacterAttributeSection, CharacterListResponse, CharacterProps, isCharacterCategory, isCharacterStatus } from '@/lib/types/character'; import {SeriesCharacterProps} from '@/lib/types/series'; import {SessionContext, SessionContextProps} from '@/context/SessionContext'; import {BookContext, BookContextProps} from '@/context/BookContext'; import {AlertContext, AlertContextProps} from '@/context/AlertContext'; import {LangContext, LangContextProps} from '@/context/LangContext'; import {apiDelete, apiGet, apiPatch, apiPost} from '@/lib/api/client'; import {useTranslations} from '@/lib/i18n'; import {ViewMode} from '@/lib/types/settings'; import {isDesktop} from '@/lib/configs'; import * as tauri from '@/lib/tauri'; import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; const initialCharacterState: CharacterProps = { id: null, name: '', lastName: '', nickname: '', age: null, gender: '', species: '', nationality: '', status: 'alive', category: 'none', title: '', role: '', image: 'https://via.placeholder.com/150', biography: '', history: '', speechPattern: '', catchphrase: '', residence: '', notes: '', color: '', physical: [], psychological: [], relations: [], skills: [], weaknesses: [], strengths: [], goals: [], motivations: [], arc: [], secrets: [], fears: [], flaws: [], beliefs: [], conflicts: [], quotes: [], distinguishingMarks: [], items: [], affiliations: [], }; export interface UseCharactersConfig { entityType: 'book' | 'series'; entityId: string; } export interface UseCharactersReturn { // State characters: CharacterProps[]; seriesCharacters: SeriesCharacterProps[]; selectedCharacter: CharacterProps | null; toolEnabled: boolean; isLoading: boolean; isSeriesMode: boolean; bookSeriesId: string | null; // Navigation state viewMode: ViewMode; characterBackup: CharacterProps | null; // Actions selectCharacter: (character: CharacterProps) => void; addNewCharacter: () => void; clearSelection: () => void; saveCharacter: () => Promise; deleteCharacter: (characterId: string) => Promise; updateCharacterField: (key: keyof CharacterProps, value: string | number | null) => void; addAttribute: (section: CharacterAttributeSection, value: Attribute) => Promise; removeAttribute: (section: CharacterAttributeSection, index: number, attrId: string) => Promise; toggleTool: (enabled: boolean) => Promise; importFromSeries: (seriesCharacterId: string) => Promise; exportToSeries: () => Promise; refreshCharacters: () => Promise; refreshSeriesCharacters: () => Promise; setSelectedCharacter: React.Dispatch>; // Navigation actions enterDetailMode: (character: CharacterProps) => void; enterEditMode: () => void; exitEditMode: (save: boolean) => Promise; backToList: () => void; } export function useCharacters(config: UseCharactersConfig): UseCharactersReturn { const {entityType, entityId} = config; const t = useTranslations(); const {lang}: LangContextProps = useContext(LangContext); const {session}: SessionContextProps = useContext(SessionContext); const {book, setBook}: BookContextProps = useContext(BookContext); const {errorMessage, successMessage}: AlertContextProps = useContext(AlertContext); const {isCurrentlyOffline}: OfflineContextType = useContext(OfflineContext); const [characters, setCharacters] = useState([]); const [seriesCharacters, setSeriesCharacters] = useState([]); const [selectedCharacter, setSelectedCharacter] = useState(null); const [toolEnabled, setToolEnabled] = useState(entityType === 'series' || (book?.tools?.characters ?? false)); const [isLoading, setIsLoading] = useState(true); // Navigation state const [viewMode, setViewMode] = useState('list'); const [characterBackup, setCharacterBackup] = useState(null); const isSeriesMode: boolean = entityType === 'series'; const bookSeriesId: string | null = book?.seriesId || null; const userToken: string = session?.accessToken || ''; // Load characters on mount useEffect(function (): void { if (entityId) { refreshCharacters(); } }, [entityId]); // Load series characters for book mode useEffect(function (): void { if (bookSeriesId && !isSeriesMode) { refreshSeriesCharacters(); } }, [bookSeriesId, isSeriesMode]); const refreshSeriesCharacters = useCallback(async function (): Promise { if (!bookSeriesId) return; try { const response: SeriesCharacterProps[] = await apiGet('series/character/list', userToken, lang, { seriesid: bookSeriesId }); if (response) { setSeriesCharacters(response); } } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } } }, [bookSeriesId, userToken, lang]); const refreshCharacters = useCallback(async function (): Promise { setIsLoading(true); try { if (isSeriesMode) { const response: SeriesCharacterProps[] = await apiGet( 'series/character/list', userToken, lang, {seriesid: entityId} ); if (response) { const mappedCharacters: CharacterProps[] = response.map(function (char: SeriesCharacterProps): CharacterProps { return { id: char.id, name: char.name, lastName: char.lastName || '', nickname: '', age: char.age ?? null, gender: '', species: '', nationality: '', status: char.status && isCharacterStatus(char.status) ? char.status : 'alive', category: isCharacterCategory(char.category) ? char.category : 'none', title: '', role: char.role || '', image: char.image || 'https://via.placeholder.com/150', color: char.color || '', physical: [], psychological: [], relations: [], skills: [], weaknesses: [], strengths: [], goals: [], motivations: [], arc: [], secrets: [], fears: [], flaws: [], beliefs: [], conflicts: [], quotes: [], distinguishingMarks: [], items: [], affiliations: [], }; }); setCharacters(mappedCharacters); } } else { let response: CharacterListResponse; if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { response = await tauri.getCharacterList(entityId, book?.tools?.characters ?? false) as CharacterListResponse; } else { response = await apiGet('character/list', userToken, lang, {bookid: entityId}); } if (response) { setCharacters(response.characters); setToolEnabled(response.enabled); if (setBook && book) { setBook({ ...book, tools: { characters: response.enabled, worlds: book.tools?.worlds ?? false, locations: book.tools?.locations ?? false, spells: book.tools?.spells ?? false } }); } } } } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t("common.unknownError")); } } finally { setIsLoading(false); } }, [entityId, isSeriesMode, userToken, lang, book, setBook, errorMessage, t]); const selectCharacter = useCallback(function (character: CharacterProps): void { setSelectedCharacter({...character}); }, []); const addNewCharacter = useCallback(function (): void { setSelectedCharacter({...initialCharacterState}); setViewMode('edit'); setCharacterBackup(null); }, []); const clearSelection = useCallback(function (): void { setSelectedCharacter(null); setViewMode('list'); setCharacterBackup(null); }, []); const updateCharacterField = useCallback(function (key: keyof CharacterProps, value: string | number | null): void { setSelectedCharacter(function (prev: CharacterProps | null): CharacterProps | null { if (!prev) return null; return {...prev, [key]: value}; }); }, []); const toggleTool = useCallback(async function (enabled: boolean): Promise { if (isSeriesMode) return; try { let response: boolean; if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { response = await tauri.updateBookToolSetting(book?.bookId ?? '', 'characters', enabled); } else { response = await apiPatch('book/tool-setting', { bookId: book?.bookId, toolName: 'characters', enabled: enabled }, userToken, lang); } if (response && setBook && book) { setToolEnabled(enabled); setBook({ ...book, tools: { characters: enabled, worlds: book.tools?.worlds ?? false, locations: book.tools?.locations ?? false, spells: book.tools?.spells ?? false } }); } } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } } }, [isSeriesMode, book, setBook, userToken, lang, errorMessage]); const saveCharacter = useCallback(async function (): Promise { if (!selectedCharacter) return false; if (selectedCharacter.id === null) { return await addCharacterInternal(selectedCharacter); } else { return await updateCharacterInternal(selectedCharacter); } }, [selectedCharacter]); async function addCharacterInternal(character: CharacterProps): Promise { if (!character.name) { errorMessage(t("characterComponent.errorNameRequired")); return false; } if (character.category === 'none') { errorMessage(t("characterComponent.errorCategoryRequired")); return false; } try { let characterId: string; if (isSeriesMode) { characterId = await apiPost( 'series/character/add', { seriesId: entityId, character: { name: character.name, lastName: character.lastName || null, nickname: character.nickname || null, age: character.age || null, gender: character.gender || null, species: character.species || null, nationality: character.nationality || null, status: character.status || null, category: character.category, title: character.title || null, image: character.image || null, role: character.role || null, biography: character.biography || null, history: character.history || null, speechPattern: character.speechPattern || null, catchphrase: character.catchphrase || null, residence: character.residence || null, notes: character.notes || null, color: character.color || null, } }, userToken, lang ); } else if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { characterId = await tauri.createCharacter(character, entityId); } else { characterId = await apiPost('character/add', { bookId: entityId, character: character, }, userToken, lang); } if (!characterId) { errorMessage(t("characterComponent.errorAddCharacter")); return false; } const newCharacter: CharacterProps = {...character, id: characterId}; setCharacters(function (prev: CharacterProps[]): CharacterProps[] { return [...prev, newCharacter]; }); setSelectedCharacter(null); return true; } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t("common.unknownError")); } return false; } } async function updateCharacterInternal(character: CharacterProps): Promise { try { let response: boolean; if (isSeriesMode) { response = await apiPatch('series/character/update', { characterId: character.id, character: { id: character.id, name: character.name, lastName: character.lastName || null, nickname: character.nickname || null, age: character.age || null, gender: character.gender || null, species: character.species || null, nationality: character.nationality || null, status: character.status || null, category: character.category, title: character.title || null, image: character.image || null, role: character.role || null, biography: character.biography || null, history: character.history || null, speechPattern: character.speechPattern || null, catchphrase: character.catchphrase || null, residence: character.residence || null, notes: character.notes || null, color: character.color || null, } }, userToken, lang); } else if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { response = await tauri.updateCharacter(character); } else { response = await apiPost('character/update', { character: character, }, userToken, lang); } if (!response) { errorMessage(t("characterComponent.errorUpdateCharacter")); return false; } setCharacters(function (prev: CharacterProps[]): CharacterProps[] { return prev.map(function (c: CharacterProps): CharacterProps { return c.id === character.id ? character : c; }); }); setSelectedCharacter(null); successMessage(t("characterComponent.successUpdate")); return true; } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t("common.unknownError")); } return false; } } const deleteCharacter = useCallback(async function (characterId: string): Promise { try { let response: boolean; if (isSeriesMode) { response = await apiDelete('series/character/delete', { characterId: characterId, }, userToken, lang); } else if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { response = await tauri.deleteCharacter(characterId, book?.bookId ?? '', Date.now()); } else { response = await apiDelete('character/delete', { characterId: characterId, }, userToken, lang); } if (!response) { errorMessage(t("characterComponent.errorDeleteCharacter")); return; } setCharacters(function (prev: CharacterProps[]): CharacterProps[] { return prev.filter(function (c: CharacterProps): boolean { return c.id !== characterId; }); }); setSelectedCharacter(null); successMessage(t("characterComponent.successDelete")); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t("common.unknownError")); } } }, [isSeriesMode, userToken, lang, errorMessage, successMessage, t]); const addAttribute = useCallback(async function (section: CharacterAttributeSection, value: Attribute): Promise { if (!selectedCharacter) return; if (selectedCharacter.id === null) { const updatedSection: Attribute[] = [ ...selectedCharacter[section], value, ]; setSelectedCharacter({...selectedCharacter, [section]: updatedSection}); } else { try { let attributeId: string; if (isSeriesMode) { attributeId = await apiPost('series/character/attribute/add', { characterId: selectedCharacter.id, type: section, name: value.name, }, userToken, lang); } else if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { attributeId = await tauri.addCharacterAttribute(selectedCharacter.id, section, value.name); } else { attributeId = await apiPost('character/attribute/add', { characterId: selectedCharacter.id, type: section, name: value.name, }, userToken, lang); } if (!attributeId) { errorMessage(t("characterComponent.errorAddAttribute")); return; } const newValue: Attribute = { name: value.name, id: attributeId, }; const updatedSection: Attribute[] = [...selectedCharacter[section], newValue]; setSelectedCharacter({...selectedCharacter, [section]: updatedSection}); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t("common.unknownError")); } } } }, [selectedCharacter, isSeriesMode, userToken, lang, errorMessage, t]); const removeAttribute = useCallback(async function (section: CharacterAttributeSection, index: number, attrId: string): Promise { if (!selectedCharacter) return; if (selectedCharacter.id === null) { const updatedSection: Attribute[] = selectedCharacter[section].filter(function (_: Attribute, i: number): boolean { return i !== index; }); setSelectedCharacter({...selectedCharacter, [section]: updatedSection}); } else { try { let response: boolean; if (isSeriesMode) { response = await apiDelete('series/character/attribute/delete', { attributeId: attrId, }, userToken, lang); } else if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { response = await tauri.deleteCharacterAttribute(attrId, book?.bookId ?? '', Date.now()); } else { response = await apiDelete('character/attribute/delete', { attributeId: attrId, }, userToken, lang); } if (!response) { errorMessage(t("characterComponent.errorRemoveAttribute")); return; } const updatedSection: Attribute[] = selectedCharacter[section].filter(function (_: Attribute, i: number): boolean { return i !== index; }); setSelectedCharacter({...selectedCharacter, [section]: updatedSection}); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t("common.unknownError")); } } } }, [selectedCharacter, isSeriesMode, userToken, lang, errorMessage, t]); const exportToSeries = useCallback(async function (): Promise { if (!selectedCharacter || !bookSeriesId) return; try { const seriesCharacterId: string = await apiPost( 'series/character/add', { seriesId: bookSeriesId, character: { name: selectedCharacter.name, lastName: selectedCharacter.lastName || null, nickname: selectedCharacter.nickname || null, age: selectedCharacter.age || null, gender: selectedCharacter.gender || null, species: selectedCharacter.species || null, nationality: selectedCharacter.nationality || null, status: selectedCharacter.status || null, category: selectedCharacter.category, title: selectedCharacter.title || null, image: selectedCharacter.image || null, role: selectedCharacter.role || null, biography: selectedCharacter.biography || null, history: selectedCharacter.history || null, speechPattern: selectedCharacter.speechPattern || null, catchphrase: selectedCharacter.catchphrase || null, residence: selectedCharacter.residence || null, notes: selectedCharacter.notes || null, color: selectedCharacter.color || null, } }, userToken, lang ); if (seriesCharacterId) { const updateResponse: boolean = await apiPost('character/update', { character: { ...selectedCharacter, seriesCharacterId: seriesCharacterId }, }, userToken, lang); if (updateResponse) { setSelectedCharacter({...selectedCharacter, seriesCharacterId: seriesCharacterId}); setCharacters(function (prev: CharacterProps[]): CharacterProps[] { return prev.map(function (c: CharacterProps): CharacterProps { return c.id === selectedCharacter.id ? {...c, seriesCharacterId: seriesCharacterId} : c; }); }); const newSeriesCharacter: SeriesCharacterProps = { id: seriesCharacterId, name: selectedCharacter.name, lastName: selectedCharacter.lastName || null, nickname: selectedCharacter.nickname || null, age: selectedCharacter.age || null, gender: selectedCharacter.gender || null, species: selectedCharacter.species || null, nationality: selectedCharacter.nationality || null, status: selectedCharacter.status || null, category: selectedCharacter.category, title: selectedCharacter.title || null, image: selectedCharacter.image || null, role: selectedCharacter.role || null, biography: selectedCharacter.biography || null, history: selectedCharacter.history || null, speechPattern: selectedCharacter.speechPattern || null, catchphrase: selectedCharacter.catchphrase || null, residence: selectedCharacter.residence || null, notes: selectedCharacter.notes || null, color: selectedCharacter.color || null, }; setSeriesCharacters(function (prev: SeriesCharacterProps[]): SeriesCharacterProps[] { return [...prev, newSeriesCharacter]; }); successMessage(t("characterComponent.exportSuccess")); } } } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } } }, [selectedCharacter, bookSeriesId, userToken, lang, successMessage, errorMessage, t]); const importFromSeries = useCallback(async function (seriesCharacterId: string): Promise { const seriesChar: SeriesCharacterProps | undefined = seriesCharacters.find(function (c: SeriesCharacterProps): boolean { return c.id === seriesCharacterId; }); if (!seriesChar) return; try { const characterToImport: CharacterProps = { id: null, name: seriesChar.name, lastName: seriesChar.lastName || '', nickname: seriesChar.nickname || '', age: seriesChar.age ?? null, gender: seriesChar.gender || '', species: seriesChar.species || '', nationality: seriesChar.nationality || '', status: seriesChar.status && isCharacterStatus(seriesChar.status) ? seriesChar.status : 'alive', category: isCharacterCategory(seriesChar.category) ? seriesChar.category : 'none', title: seriesChar.title || '', role: seriesChar.role || '', image: seriesChar.image || 'https://via.placeholder.com/150', biography: seriesChar.biography || '', history: seriesChar.history || '', speechPattern: seriesChar.speechPattern || '', catchphrase: seriesChar.catchphrase || '', residence: seriesChar.residence || '', notes: seriesChar.notes || '', color: seriesChar.color || '', physical: [], psychological: [], relations: [], skills: [], weaknesses: [], strengths: [], goals: [], motivations: [], arc: [], secrets: [], fears: [], flaws: [], beliefs: [], conflicts: [], quotes: [], distinguishingMarks: [], items: [], affiliations: [], seriesCharacterId: seriesCharacterId, }; let characterId: string; if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { characterId = await tauri.createCharacter(characterToImport, entityId); } else { characterId = await apiPost('character/add', { bookId: entityId, character: characterToImport, }, userToken, lang); } if (!characterId) { errorMessage(t("characterComponent.importError")); return; } characterToImport.id = characterId; setCharacters(function (prev: CharacterProps[]): CharacterProps[] { return [...prev, characterToImport]; }); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } } }, [seriesCharacters, entityId, userToken, lang, errorMessage, successMessage, t]); // Navigation functions const enterDetailMode = useCallback(function (character: CharacterProps): void { setSelectedCharacter({...character}); setViewMode('detail'); setCharacterBackup(null); }, []); const enterEditMode = useCallback(function (): void { if (selectedCharacter) { setCharacterBackup({...selectedCharacter}); } setViewMode('edit'); }, [selectedCharacter]); const exitEditMode = useCallback(async function (save: boolean): Promise { if (save) { const success: boolean = await saveCharacter(); if (!success) return; if (characterBackup) { setViewMode('detail'); } else { setViewMode('list'); } } else { if (characterBackup) { setSelectedCharacter(characterBackup); setViewMode('detail'); } else { setSelectedCharacter(null); setViewMode('list'); } } setCharacterBackup(null); }, [saveCharacter, characterBackup]); const backToList = useCallback(function (): void { setSelectedCharacter(null); setCharacterBackup(null); setViewMode('list'); }, []); return { // State characters, seriesCharacters, selectedCharacter, toolEnabled, isLoading, isSeriesMode, bookSeriesId, // Navigation state viewMode, characterBackup, // Actions selectCharacter, addNewCharacter, clearSelection, saveCharacter, deleteCharacter, updateCharacterField, addAttribute, removeAttribute, toggleTool, importFromSeries, exportToSeries, refreshCharacters, refreshSeriesCharacters, setSelectedCharacter, // Navigation actions enterDetailMode, enterEditMode, exitEditMode, backToList, }; }