Remove CharacterComponent and CharacterDetail components

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

View File

@@ -0,0 +1,975 @@
'use client'
import {useCallback, useContext, useEffect, useState} from 'react';
import {Attribute, CharacterListResponse, CharacterProps} from '@/lib/models/Character';
import {SeriesCharacterProps} from '@/lib/models/Series';
import {SessionContext} from '@/context/SessionContext';
import {BookContext} from '@/context/BookContext';
import {AlertContext} from '@/context/AlertContext';
import {LangContext, LangContextProps} from '@/context/LangContext';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from '@/context/SyncQueueContext';
import {BooksSyncContext, BooksSyncContextProps} from '@/context/BooksSyncContext';
import {SyncedBook} from '@/lib/models/SyncedBook';
import {SeriesContext, SeriesContextProps} from '@/context/SeriesContext';
import {SeriesSyncContext, SeriesSyncContextProps} from '@/context/SeriesSyncContext';
import {SyncedSeries} from '@/lib/models/SyncedSeries';
import System from '@/lib/models/System';
import {useTranslations} from 'next-intl';
import {ViewMode} from '@/shared/interface';
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<boolean>;
deleteCharacter: (characterId: string) => Promise<void>;
updateCharacterField: (key: keyof CharacterProps, value: string | number | null) => void;
addAttribute: (section: keyof CharacterProps, value: Attribute) => Promise<void>;
removeAttribute: (section: keyof CharacterProps, index: number, attrId: string) => Promise<void>;
toggleTool: (enabled: boolean) => Promise<void>;
importFromSeries: (seriesCharacterId: string) => Promise<void>;
exportToSeries: () => Promise<void>;
refreshCharacters: () => Promise<void>;
refreshSeriesCharacters: () => Promise<void>;
setSelectedCharacter: React.Dispatch<React.SetStateAction<CharacterProps | null>>;
// Navigation actions
enterDetailMode: (character: CharacterProps) => void;
enterEditMode: () => void;
exitEditMode: (save: boolean) => Promise<void>;
backToList: () => void;
}
export function useCharacters(config: UseCharactersConfig): UseCharactersReturn {
const {entityType, entityId} = config;
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {session} = useContext(SessionContext);
const {book, setBook} = useContext(BookContext);
const {errorMessage, successMessage} = useContext(AlertContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {localSeries} = useContext<SeriesContextProps>(SeriesContext);
const {localSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
const [characters, setCharacters] = useState<CharacterProps[]>([]);
const [seriesCharacters, setSeriesCharacters] = useState<SeriesCharacterProps[]>([]);
const [selectedCharacter, setSelectedCharacter] = useState<CharacterProps | null>(null);
const [toolEnabled, setToolEnabled] = useState<boolean>(entityType === 'series' || (book?.tools?.characters ?? false));
const [isLoading, setIsLoading] = useState<boolean>(true);
// Navigation state
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [characterBackup, setCharacterBackup] = useState<CharacterProps | null>(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<void> {
if (!bookSeriesId) return;
try {
let response: SeriesCharacterProps[];
// Dual logic: offline ou livre local → IPC, sinon serveur
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<SeriesCharacterProps[]>(
'db:series:character:list',
{seriesId: bookSeriesId}
);
} else {
response = await System.authGetQueryToServer<SeriesCharacterProps[]>(
'series/character/list',
userToken,
lang,
{seriesid: bookSeriesId}
);
}
if (response) {
setSeriesCharacters(response);
}
} catch (e: unknown) {
if (e instanceof Error) {
console.error('Error loading series characters:', e.message);
}
}
}, [bookSeriesId, userToken, lang, isCurrentlyOffline, book?.localBook]);
const refreshCharacters = useCallback(async function (): Promise<void> {
setIsLoading(true);
try {
if (isSeriesMode) {
// Series mode - dual logic
let response: SeriesCharacterProps[];
if (isCurrentlyOffline() || localSeries) {
response = await window.electron.invoke<SeriesCharacterProps[]>(
'db:series:character:list',
{seriesId: entityId}
);
} else {
response = await System.authGetQueryToServer<SeriesCharacterProps[]>(
'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: 'alive' as const,
category: char.category as CharacterProps['category'],
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 {
// Pattern B: GET dans contexte livre
let response: CharacterListResponse;
if (isCurrentlyOffline()) {
// Offline → IPC
response = await window.electron.invoke<CharacterListResponse>('db:character:list', {bookid: entityId});
} else if (book?.localBook) {
// Online mais livre local → IPC
response = await window.electron.invoke<CharacterListResponse>('db:character:list', {bookid: entityId});
} else {
// Online + livre serveur → Server
response = await System.authGetQueryToServer<CharacterListResponse>(
'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, isCurrentlyOffline]);
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<void> {
if (isSeriesMode) return;
try {
const requestData = {
bookId: book?.bookId,
toolName: 'characters',
enabled: enabled
};
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
// Offline OU livre local → IPC
response = await window.electron.invoke<boolean>('db:book:tool:update', requestData);
} else {
// Online + livre serveur → Server
response = await System.authPatchToServer<boolean>('book/tool-setting', requestData, userToken, lang);
// Si le livre a une copie locale → addToQueue pour sync
if (book?.bookId && localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === book.bookId)) {
addToQueue('db:book:tool:update', requestData);
}
}
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, isCurrentlyOffline, addToQueue, localSyncedBooks]);
const saveCharacter = useCallback(async function (): Promise<boolean> {
if (!selectedCharacter) return false;
if (selectedCharacter.id === null) {
return await addCharacterInternal(selectedCharacter);
} else {
return await updateCharacterInternal(selectedCharacter);
}
}, [selectedCharacter]);
async function addCharacterInternal(character: CharacterProps): Promise<boolean> {
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) {
// Series mode - dual logic
const seriesCharacterData = {
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,
}
};
if (isCurrentlyOffline() || localSeries) {
characterId = await window.electron.invoke<string>('db:series:character:add', seriesCharacterData);
} else {
characterId = await System.authPostToServer<string>(
'series/character/add',
seriesCharacterData,
userToken,
lang
);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:character:add', {...seriesCharacterData, id: characterId});
}
}
} else {
// Pattern A: mutations
const requestData = {
bookId: entityId,
character: character,
};
if (isCurrentlyOffline() || book?.localBook) {
// Offline OU livre local → IPC
characterId = await window.electron.invoke<string>('db:character:create', requestData);
} else {
// Online + livre serveur → Server
characterId = await System.authPostToServer<string>('character/add', requestData, userToken, lang);
// Si le livre a une copie locale → addToQueue pour sync
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:character:create', {...requestData, id: characterId});
}
}
}
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<boolean> {
try {
let response: boolean;
if (isSeriesMode) {
// Series mode - dual logic
const updateData = {
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,
}
};
if (isCurrentlyOffline() || localSeries) {
response = await window.electron.invoke<boolean>('db:series:character:update', updateData);
} else {
response = await System.authPatchToServer<boolean>('series/character/update', updateData, userToken, lang);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:character:update', updateData);
}
}
} else {
// Pattern A: mutations
const requestData = {
character: character,
};
if (isCurrentlyOffline() || book?.localBook) {
// Offline OU livre local → IPC
response = await window.electron.invoke<boolean>('db:character:update', requestData);
} else {
// Online + livre serveur → Server
response = await System.authPostToServer<boolean>('character/update', requestData, userToken, lang);
// Si le livre a une copie locale → addToQueue pour sync
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:character:update', requestData);
}
}
}
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<void> {
try {
let response: boolean;
const requestData = {characterId: characterId};
if (isSeriesMode) {
// Series mode - dual logic
if (isCurrentlyOffline() || localSeries) {
response = await window.electron.invoke<boolean>('db:series:character:delete', requestData);
} else {
response = await System.authDeleteToServer<boolean>('series/character/delete', requestData, userToken, lang);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:character:delete', requestData);
}
}
} else {
// Pattern A: mutations
if (isCurrentlyOffline() || book?.localBook) {
// Offline OU livre local → IPC
response = await window.electron.invoke<boolean>('db:character:delete', requestData);
} else {
// Online + livre serveur → Server
response = await System.authDeleteToServer<boolean>('character/delete', requestData, userToken, lang);
// Si le livre a une copie locale → addToQueue pour sync
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:character:delete', requestData);
}
}
}
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, isCurrentlyOffline, book, addToQueue, localSyncedBooks, entityId]);
const addAttribute = useCallback(async function (section: keyof CharacterProps, value: Attribute): Promise<void> {
if (!selectedCharacter) return;
if (selectedCharacter.id === null) {
const updatedSection: Attribute[] = [
...(selectedCharacter[section] as Attribute[]),
value,
];
setSelectedCharacter({...selectedCharacter, [section]: updatedSection});
} else {
try {
const requestData = {
characterId: selectedCharacter.id,
type: section,
name: value.name,
};
let attributeId: string;
if (isSeriesMode) {
// Series mode - dual logic
if (isCurrentlyOffline() || localSeries) {
attributeId = await window.electron.invoke<string>('db:series:character:attribute:add', requestData);
} else {
attributeId = await System.authPostToServer<string>('series/character/attribute/add', requestData, userToken, lang);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:character:attribute:add', {...requestData, id: attributeId});
}
}
} else {
// Pattern A: mutations
if (isCurrentlyOffline() || book?.localBook) {
// Offline OU livre local → IPC
attributeId = await window.electron.invoke<string>('db:character:attribute:add', requestData);
} else {
// Online + livre serveur → Server
attributeId = await System.authPostToServer<string>('character/attribute/add', requestData, userToken, lang);
// Si le livre a une copie locale → addToQueue pour sync
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:character:attribute:add', {...requestData, id: attributeId});
}
}
}
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"));
}
}
}
}, [selectedCharacter, isSeriesMode, userToken, lang, errorMessage, t, isCurrentlyOffline, book, addToQueue, localSyncedBooks, entityId]);
const removeAttribute = useCallback(async function (section: keyof CharacterProps, index: number, attrId: string): Promise<void> {
if (!selectedCharacter) return;
if (selectedCharacter.id === null) {
const updatedSection: Attribute[] = (
selectedCharacter[section] as Attribute[]
).filter(function (_: Attribute, i: number): boolean {
return i !== index;
});
setSelectedCharacter({...selectedCharacter, [section]: updatedSection});
} else {
try {
const requestData = {attributeId: attrId};
let response: boolean;
if (isSeriesMode) {
// Series mode - dual logic
if (isCurrentlyOffline() || localSeries) {
response = await window.electron.invoke<boolean>('db:series:character:attribute:delete', requestData);
} else {
response = await System.authDeleteToServer<boolean>('series/character/attribute/delete', requestData, userToken, lang);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:character:attribute:delete', requestData);
}
}
} else {
// Pattern A: mutations
if (isCurrentlyOffline() || book?.localBook) {
// Offline OU livre local → IPC
response = await window.electron.invoke<boolean>('db:character:attribute:delete', requestData);
} else {
// Online + livre serveur → Server
response = await System.authDeleteToServer<boolean>('character/attribute/delete', requestData, userToken, lang);
// Si le livre a une copie locale → addToQueue pour sync
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:character:attribute:delete', requestData);
}
}
}
if (!response) {
errorMessage(t("characterComponent.errorRemoveAttribute"));
return;
}
const updatedSection: Attribute[] = (
selectedCharacter[section] as Attribute[]
).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, isCurrentlyOffline, book, addToQueue, localSyncedBooks, entityId]);
const exportToSeries = useCallback(async function (): Promise<void> {
if (!selectedCharacter || !bookSeriesId) return;
try {
const seriesCharacterData = {
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,
}
};
let seriesCharacterId: string;
if (isCurrentlyOffline() || book?.localBook) {
// Mode offline ou livre local → IPC
seriesCharacterId = await window.electron.invoke<string>('db:series:character:add', seriesCharacterData);
} else {
// Mode online → Serveur
seriesCharacterId = await System.authPostToServer<string>(
'series/character/add',
seriesCharacterData,
userToken,
lang
);
// Si la série a une copie locale → addToQueue
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === bookSeriesId)) {
addToQueue('db:series:character:add', {...seriesCharacterData, id: seriesCharacterId});
}
}
if (seriesCharacterId) {
const updateData = {
character: {
...selectedCharacter,
seriesCharacterId: seriesCharacterId
},
};
let updateResponse: boolean;
if (isCurrentlyOffline() || book?.localBook) {
// Mode offline ou livre local → IPC
updateResponse = await window.electron.invoke<boolean>('db:character:update', updateData);
} else {
// Mode online → Serveur
updateResponse = await System.authPostToServer<boolean>('character/update', updateData, userToken, lang);
// Si le livre a une copie locale → addToQueue
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:character:update', updateData);
}
}
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, isCurrentlyOffline, book, addToQueue, localSyncedBooks, localSyncedSeries, entityId]);
const importFromSeries = useCallback(async function (seriesCharacterId: string): Promise<void> {
const seriesChar = 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 as 'alive' | 'dead' | 'unknown') || 'alive',
category: (seriesChar.category as CharacterProps['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,
};
const requestData = {
bookId: entityId,
character: characterToImport,
};
let characterId: string;
if (isCurrentlyOffline() || book?.localBook) {
// Mode offline ou livre local → IPC
characterId = await window.electron.invoke<string>('db:character:create', requestData);
} else {
// Mode online → Serveur
characterId = await System.authPostToServer<string>('character/add', requestData, userToken, lang);
// Si le livre a une copie locale → addToQueue
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:character:create', {...requestData, id: characterId});
}
}
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, isCurrentlyOffline, book, addToQueue, localSyncedBooks]);
// 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<void> {
if (save) {
const success: boolean = await saveCharacter();
if (!success) {
// Stay in edit mode on error
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,
};
}

View File

@@ -0,0 +1,982 @@
'use client'
import {useCallback, useContext, useEffect, useState} from 'react';
import {SeriesLocationItem, SeriesLocationElement, SeriesLocationSubElement} from '@/lib/models/Series';
import {SessionContext} from '@/context/SessionContext';
import {BookContext} from '@/context/BookContext';
import {AlertContext} from '@/context/AlertContext';
import {LangContext, LangContextProps} from '@/context/LangContext';
import System from '@/lib/models/System';
import {useTranslations} from 'next-intl';
import {ViewMode} from '@/shared/interface';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from '@/context/SyncQueueContext';
import {BooksSyncContext, BooksSyncContextProps} from '@/context/BooksSyncContext';
import {SyncedBook} from '@/lib/models/SyncedBook';
import {SeriesContext, SeriesContextProps} from '@/context/SeriesContext';
import {SeriesSyncContext, SeriesSyncContextProps} from '@/context/SeriesSyncContext';
import {SyncedSeries} from '@/lib/models/SyncedSeries';
export interface SubElement {
id: string;
name: string;
description: string;
}
export interface Element {
id: string;
name: string;
description: string;
subElements: SubElement[];
}
export interface LocationProps {
id: string;
name: string;
elements: Element[];
seriesLocationId?: string | null;
}
interface LocationListResponse {
locations: LocationProps[];
enabled: boolean;
}
export interface UseLocationsConfig {
entityType: 'book' | 'series';
entityId: string;
}
export interface UseLocationsReturn {
// State
sections: LocationProps[];
seriesLocations: SeriesLocationItem[];
toolEnabled: boolean;
isLoading: boolean;
isSeriesMode: boolean;
bookSeriesId: string | null;
newSectionName: string;
newElementNames: { [key: string]: string };
newSubElementNames: { [key: string]: string };
// Navigation state
viewMode: ViewMode;
selectedSectionIndex: number;
sectionsBackup: LocationProps[] | null;
// Actions
addSection: () => Promise<void>;
addElement: (sectionId: string) => Promise<void>;
addSubElement: (sectionId: string, elementIndex: number) => Promise<void>;
removeSection: (sectionId: string) => Promise<void>;
removeElement: (sectionId: string, elementIndex: number) => Promise<void>;
removeSubElement: (sectionId: string, elementIndex: number, subElementIndex: number) => Promise<void>;
updateElement: (sectionId: string, elementIndex: number, field: keyof Element, value: string) => void;
updateSubElement: (sectionId: string, elementIndex: number, subElementIndex: number, field: keyof SubElement, value: string) => void;
saveLocations: () => Promise<boolean>;
toggleTool: (enabled: boolean) => Promise<void>;
importFromSeries: (seriesLocationId: string) => Promise<void>;
exportToSeries: (section: LocationProps) => Promise<void>;
refreshLocations: () => Promise<void>;
refreshSeriesLocations: () => Promise<void>;
setNewSectionName: (name: string) => void;
setNewElementNames: React.Dispatch<React.SetStateAction<{ [key: string]: string }>>;
setNewSubElementNames: React.Dispatch<React.SetStateAction<{ [key: string]: string }>>;
// Navigation actions
enterDetailMode: (sectionIndex: number) => void;
enterEditMode: () => void;
exitEditMode: (save: boolean) => Promise<void>;
backToList: () => void;
}
export function useLocations(config: UseLocationsConfig): UseLocationsReturn {
const {entityType, entityId} = config;
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {session} = useContext(SessionContext);
const {book, setBook} = useContext(BookContext);
const {errorMessage, successMessage} = useContext(AlertContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {localSeries} = useContext<SeriesContextProps>(SeriesContext);
const {localSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
const [sections, setSections] = useState<LocationProps[]>([]);
const [seriesLocations, setSeriesLocations] = useState<SeriesLocationItem[]>([]);
const [toolEnabled, setToolEnabled] = useState<boolean>(entityType === 'series' || (book?.tools?.locations ?? false));
const [isLoading, setIsLoading] = useState<boolean>(true);
const [newSectionName, setNewSectionName] = useState<string>('');
const [newElementNames, setNewElementNames] = useState<{ [key: string]: string }>({});
const [newSubElementNames, setNewSubElementNames] = useState<{ [key: string]: string }>({});
const [isAddingElement, setIsAddingElement] = useState<boolean>(false);
const [isAddingSubElement, setIsAddingSubElement] = useState<boolean>(false);
const [isAddingSection, setIsAddingSection] = useState<boolean>(false);
// Navigation state
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [selectedSectionIndex, setSelectedSectionIndex] = useState<number>(-1);
const [sectionsBackup, setSectionsBackup] = useState<LocationProps[] | null>(null);
const isSeriesMode: boolean = entityType === 'series';
const bookSeriesId: string | null = book?.seriesId || null;
const userToken: string = session?.accessToken || '';
// Load locations on mount
useEffect(function (): void {
if (entityId) {
refreshLocations();
}
}, [entityId]);
// Load series locations for book mode
useEffect(function (): void {
if (bookSeriesId && !isSeriesMode) {
refreshSeriesLocations();
}
}, [bookSeriesId, isSeriesMode]);
const refreshSeriesLocations = useCallback(async function (): Promise<void> {
if (!bookSeriesId) return;
try {
let response: SeriesLocationItem[];
// Dual logic: offline ou livre local → IPC, sinon serveur
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<SeriesLocationItem[]>(
'db:series:location:list',
{seriesId: bookSeriesId}
);
} else {
response = await System.authGetQueryToServer<SeriesLocationItem[]>(
'series/location/list',
userToken,
lang,
{seriesid: bookSeriesId}
);
}
if (response) {
setSeriesLocations(response);
}
} catch (e: unknown) {
if (e instanceof Error) {
console.error('Error loading series locations:', e.message);
}
}
}, [bookSeriesId, userToken, lang, isCurrentlyOffline, book?.localBook]);
const refreshLocations = useCallback(async function (): Promise<void> {
setIsLoading(true);
try {
if (isSeriesMode) {
// Series mode - dual logic
let response: SeriesLocationItem[];
if (isCurrentlyOffline() || localSeries) {
response = await window.electron.invoke<SeriesLocationItem[]>(
'db:series:location:list',
{seriesId: entityId}
);
} else {
response = await System.authGetQueryToServer<SeriesLocationItem[]>(
'series/location/list',
userToken,
lang,
{seriesid: entityId}
);
}
if (response) {
const mappedLocations: LocationProps[] = response.map(function (loc: SeriesLocationItem): LocationProps {
return {
id: loc.id,
name: loc.name,
elements: loc.elements.map(function (elem: SeriesLocationElement): Element {
return {
id: elem.id,
name: elem.name,
description: elem.description,
subElements: elem.subElements.map(function (sub: SeriesLocationSubElement): SubElement {
return {
id: sub.id,
name: sub.name,
description: sub.description,
};
}),
};
}),
};
});
setSections(mappedLocations);
}
} else {
let response: LocationListResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<LocationListResponse>('db:location:all', {bookid: entityId});
} else if (book?.localBook) {
response = await window.electron.invoke<LocationListResponse>('db:location:all', {bookid: entityId});
} else {
response = await System.authGetQueryToServer<LocationListResponse>(
'location/all',
userToken,
lang,
{bookid: entityId}
);
}
if (response) {
setSections(response.locations);
setToolEnabled(response.enabled);
if (setBook && book) {
setBook({
...book,
tools: {
characters: book.tools?.characters ?? false,
worlds: book.tools?.worlds ?? false,
locations: response.enabled,
spells: book.tools?.spells ?? false
}
});
}
}
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('locationComponent.errorUnknownFetchLocations'));
}
} finally {
setIsLoading(false);
}
}, [entityId, isSeriesMode, userToken, lang, book, setBook, errorMessage, t, isCurrentlyOffline]);
const toggleTool = useCallback(async function (enabled: boolean): Promise<void> {
if (isSeriesMode) return;
try {
const requestData = {
bookId: book?.bookId,
toolName: 'locations',
enabled: enabled
};
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:book:tool:update', requestData);
} else {
response = await System.authPatchToServer<boolean>('book/tool-setting', requestData, userToken, lang);
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === book?.bookId)) {
addToQueue('db:book:tool:update', requestData);
}
}
if (response && setBook && book) {
setToolEnabled(enabled);
setBook({
...book,
tools: {
characters: book.tools?.characters ?? false,
worlds: book.tools?.worlds ?? false,
locations: enabled,
spells: book.tools?.spells ?? false
}
});
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
}
}
}, [isSeriesMode, book, setBook, userToken, lang, errorMessage, isCurrentlyOffline, addToQueue, localSyncedBooks]);
const addSection = useCallback(async function (): Promise<void> {
if (isAddingSection) return;
if (!newSectionName.trim()) {
errorMessage(t('locationComponent.errorSectionNameEmpty'));
return;
}
setIsAddingSection(true);
try {
let sectionId: string;
if (isSeriesMode) {
// Series mode - dual logic
const addData = {
seriesId: entityId,
name: newSectionName,
};
if (isCurrentlyOffline() || localSeries) {
sectionId = await window.electron.invoke<string>('db:series:location:section:add', addData);
} else {
sectionId = await System.authPostToServer<string>(
'series/location/section/add',
addData,
userToken,
lang
);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:location:section:add', {...addData, id: sectionId});
}
}
if (!sectionId) {
errorMessage(t('locationComponent.errorUnknownAddSection'));
return;
}
} else {
const requestData = {
bookId: entityId,
locationName: newSectionName,
};
if (isCurrentlyOffline() || book?.localBook) {
sectionId = await window.electron.invoke<string>('db:location:section:add', requestData);
} else {
sectionId = await System.authPostToServer<string>('location/section/add', requestData, userToken, lang);
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:location:section:add', {...requestData, id: sectionId});
}
}
if (!sectionId) {
errorMessage(t('locationComponent.errorUnknownAddSection'));
return;
}
}
const newLocation: LocationProps = {
id: sectionId,
name: newSectionName,
elements: [],
};
setSections(function (prev: LocationProps[]): LocationProps[] {
return [...prev, newLocation];
});
setNewSectionName('');
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('locationComponent.errorUnknownAddSection'));
}
} finally {
setIsAddingSection(false);
}
}, [newSectionName, isSeriesMode, entityId, userToken, lang, errorMessage, t, isCurrentlyOffline, addToQueue, localSyncedBooks, book, isAddingSection]);
const addElement = useCallback(async function (sectionId: string): Promise<void> {
if (isAddingElement) return;
if (!newElementNames[sectionId]?.trim()) {
errorMessage(t('locationComponent.errorElementNameEmpty'));
return;
}
setIsAddingElement(true);
try {
let elementId: string;
if (isSeriesMode) {
// Series mode - dual logic
const addData = {
locationId: sectionId,
name: newElementNames[sectionId],
};
if (isCurrentlyOffline() || localSeries) {
elementId = await window.electron.invoke<string>('db:series:location:element:add', addData);
} else {
elementId = await System.authPostToServer<string>(
'series/location/element/add',
addData,
userToken,
lang
);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:location:element:add', {...addData, id: elementId});
}
}
if (!elementId) {
errorMessage(t('locationComponent.errorUnknownAddElement'));
return;
}
} else {
const requestData = {
bookId: entityId,
locationId: sectionId,
elementName: newElementNames[sectionId],
};
if (isCurrentlyOffline() || book?.localBook) {
elementId = await window.electron.invoke<string>('db:location:element:add', requestData);
} else {
elementId = await System.authPostToServer<string>('location/element/add', requestData, userToken, lang);
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:location:element:add', {...requestData, id: elementId});
}
}
if (!elementId) {
errorMessage(t('locationComponent.errorUnknownAddElement'));
return;
}
}
setSections(function (prev: LocationProps[]): LocationProps[] {
const updated: LocationProps[] = [...prev];
const sectionIndex: number = updated.findIndex(function (section: LocationProps): boolean {
return section.id === sectionId;
});
updated[sectionIndex].elements.push({
id: elementId,
name: newElementNames[sectionId],
description: '',
subElements: [],
});
return updated;
});
setNewElementNames(function (prev: { [key: string]: string }): { [key: string]: string } {
return {...prev, [sectionId]: ''};
});
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('locationComponent.errorUnknownAddElement'));
}
} finally {
setIsAddingElement(false);
}
}, [newElementNames, isSeriesMode, entityId, userToken, lang, errorMessage, t, isCurrentlyOffline, addToQueue, localSyncedBooks, book, isAddingElement]);
const addSubElement = useCallback(async function (sectionId: string, elementIndex: number): Promise<void> {
if (isAddingSubElement) return;
if (!newSubElementNames[elementIndex]?.trim()) {
errorMessage(t('locationComponent.errorSubElementNameEmpty'));
return;
}
setIsAddingSubElement(true);
const sectionIndex: number = sections.findIndex(function (section: LocationProps): boolean {
return section.id === sectionId;
});
try {
let subElementId: string;
if (isSeriesMode) {
// Series mode - dual logic
const addData = {
elementId: sections[sectionIndex].elements[elementIndex].id,
name: newSubElementNames[elementIndex],
};
if (isCurrentlyOffline() || localSeries) {
subElementId = await window.electron.invoke<string>('db:series:location:subelement:add', addData);
} else {
subElementId = await System.authPostToServer<string>(
'series/location/sub-element/add',
addData,
userToken,
lang
);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:location:subelement:add', {...addData, id: subElementId});
}
}
if (!subElementId) {
errorMessage(t('locationComponent.errorUnknownAddSubElement'));
return;
}
} else {
const requestData = {
elementId: sections[sectionIndex].elements[elementIndex].id,
subElementName: newSubElementNames[elementIndex],
};
if (isCurrentlyOffline() || book?.localBook) {
subElementId = await window.electron.invoke<string>('db:location:subelement:add', requestData);
} else {
subElementId = await System.authPostToServer<string>('location/sub-element/add', requestData, userToken, lang);
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:location:subelement:add', {...requestData, id: subElementId});
}
}
if (!subElementId) {
errorMessage(t('locationComponent.errorUnknownAddSubElement'));
return;
}
}
setSections(function (prev: LocationProps[]): LocationProps[] {
const updated: LocationProps[] = [...prev];
updated[sectionIndex].elements[elementIndex].subElements.push({
id: subElementId,
name: newSubElementNames[elementIndex],
description: '',
});
return updated;
});
setNewSubElementNames(function (prev: { [key: string]: string }): { [key: string]: string } {
return {...prev, [elementIndex]: ''};
});
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('locationComponent.errorUnknownAddSubElement'));
}
} finally {
setIsAddingSubElement(false);
}
}, [sections, newSubElementNames, isSeriesMode, userToken, lang, errorMessage, t, isCurrentlyOffline, addToQueue, localSyncedBooks, entityId, book, isAddingSubElement]);
const removeSection = useCallback(async function (sectionId: string): Promise<void> {
try {
let success: boolean;
if (isSeriesMode) {
// Series mode - dual logic
const deleteData = {locationId: sectionId};
if (isCurrentlyOffline() || localSeries) {
success = await window.electron.invoke<boolean>('db:series:location:delete', deleteData);
} else {
success = await System.authDeleteToServer<boolean>('series/location/delete', deleteData, userToken, lang);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:location:delete', deleteData);
}
}
} else {
const requestData = {
locationId: sectionId,
};
if (isCurrentlyOffline() || book?.localBook) {
success = await window.electron.invoke<boolean>('db:location:delete', requestData);
} else {
success = await System.authDeleteToServer<boolean>('location/delete', requestData, userToken, lang);
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:location:delete', requestData);
}
}
}
if (!success) {
errorMessage(t('locationComponent.errorUnknownDeleteSection'));
return;
}
setSections(function (prev: LocationProps[]): LocationProps[] {
return prev.filter(function (section: LocationProps): boolean {
return section.id !== sectionId;
});
});
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('locationComponent.errorUnknownDeleteSection'));
}
}
}, [isSeriesMode, userToken, lang, errorMessage, t, isCurrentlyOffline, addToQueue, localSyncedBooks, entityId, book]);
const removeElement = useCallback(async function (sectionId: string, elementIndex: number): Promise<void> {
try {
const elementId: string | undefined = sections.find(function (section: LocationProps): boolean {
return section.id === sectionId;
})?.elements[elementIndex].id;
let success: boolean;
if (isSeriesMode) {
// Series mode - dual logic
const deleteData = {elementId: elementId};
if (isCurrentlyOffline() || localSeries) {
success = await window.electron.invoke<boolean>('db:series:location:element:delete', deleteData);
} else {
success = await System.authDeleteToServer<boolean>('series/location/element/delete', deleteData, userToken, lang);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:location:element:delete', deleteData);
}
}
} else {
const requestData = {
elementId: elementId,
};
if (isCurrentlyOffline() || book?.localBook) {
success = await window.electron.invoke<boolean>('db:location:element:delete', requestData);
} else {
success = await System.authDeleteToServer<boolean>('location/element/delete', requestData, userToken, lang);
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:location:element:delete', requestData);
}
}
}
if (!success) {
errorMessage(t('locationComponent.errorUnknownDeleteElement'));
return;
}
setSections(function (prev: LocationProps[]): LocationProps[] {
const updated: LocationProps[] = [...prev];
const sectionIndex: number = updated.findIndex(function (section: LocationProps): boolean {
return section.id === sectionId;
});
updated[sectionIndex].elements.splice(elementIndex, 1);
return updated;
});
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('locationComponent.errorUnknownDeleteElement'));
}
}
}, [sections, isSeriesMode, userToken, lang, errorMessage, t, isCurrentlyOffline, addToQueue, localSyncedBooks, entityId, book]);
const removeSubElement = useCallback(async function (sectionId: string, elementIndex: number, subElementIndex: number): Promise<void> {
try {
const subElementId: string | undefined = sections.find(function (section: LocationProps): boolean {
return section.id === sectionId;
})?.elements[elementIndex].subElements[subElementIndex].id;
let success: boolean;
if (isSeriesMode) {
// Series mode - dual logic
const deleteData = {subElementId: subElementId};
if (isCurrentlyOffline() || localSeries) {
success = await window.electron.invoke<boolean>('db:series:location:subelement:delete', deleteData);
} else {
success = await System.authDeleteToServer<boolean>('series/location/sub-element/delete', deleteData, userToken, lang);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:location:subelement:delete', deleteData);
}
}
} else {
const requestData = {
subElementId: subElementId,
};
if (isCurrentlyOffline() || book?.localBook) {
success = await window.electron.invoke<boolean>('db:location:subelement:delete', requestData);
} else {
success = await System.authDeleteToServer<boolean>('location/sub-element/delete', requestData, userToken, lang);
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:location:subelement:delete', requestData);
}
}
}
if (!success) {
errorMessage(t('locationComponent.errorUnknownDeleteSubElement'));
return;
}
setSections(function (prev: LocationProps[]): LocationProps[] {
const updated: LocationProps[] = [...prev];
const sectionIndex: number = updated.findIndex(function (section: LocationProps): boolean {
return section.id === sectionId;
});
updated[sectionIndex].elements[elementIndex].subElements.splice(subElementIndex, 1);
return updated;
});
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('locationComponent.errorUnknownDeleteSubElement'));
}
}
}, [sections, isSeriesMode, userToken, lang, errorMessage, t, isCurrentlyOffline, addToQueue, localSyncedBooks, entityId, book]);
const updateElement = useCallback(function (sectionId: string, elementIndex: number, field: keyof Element, value: string): void {
setSections(function (prev: LocationProps[]): LocationProps[] {
const updated: LocationProps[] = [...prev];
const sectionIndex: number = updated.findIndex(function (section: LocationProps): boolean {
return section.id === sectionId;
});
// @ts-ignore
updated[sectionIndex].elements[elementIndex][field] = value;
return updated;
});
}, []);
const updateSubElement = useCallback(function (sectionId: string, elementIndex: number, subElementIndex: number, field: keyof SubElement, value: string): void {
setSections(function (prev: LocationProps[]): LocationProps[] {
const updated: LocationProps[] = [...prev];
const sectionIndex: number = updated.findIndex(function (section: LocationProps): boolean {
return section.id === sectionId;
});
updated[sectionIndex].elements[elementIndex].subElements[subElementIndex][field] = value;
return updated;
});
}, []);
const saveLocations = useCallback(async function (): Promise<boolean> {
try {
const requestData = {
locations: sections,
};
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:location:update', requestData);
} else {
response = await System.authPostToServer<boolean>('location/update', requestData, userToken, lang);
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:location:update', requestData);
}
}
if (!response) {
errorMessage(t('locationComponent.errorUnknownSave'));
return false;
}
successMessage(t('locationComponent.successSave'));
return true;
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('locationComponent.errorUnknownSave'));
}
return false;
}
}, [sections, userToken, lang, errorMessage, successMessage, t, isCurrentlyOffline, addToQueue, localSyncedBooks, entityId, book]);
const exportToSeries = useCallback(async function (section: LocationProps): Promise<void> {
if (!bookSeriesId) return;
try {
const seriesLocationData = {
seriesId: bookSeriesId,
name: section.name,
};
let seriesLocationId: string;
if (isCurrentlyOffline() || book?.localBook) {
// Mode offline ou livre local → IPC
seriesLocationId = await window.electron.invoke<string>('db:series:location:section:add', seriesLocationData);
} else {
// Mode online → Serveur
seriesLocationId = await System.authPostToServer<string>('series/location/section/add', seriesLocationData, userToken, lang);
// Si la série a une copie locale → addToQueue avec l'ID du serveur
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === bookSeriesId)) {
addToQueue('db:series:location:section:add', {...seriesLocationData, id: seriesLocationId});
}
}
if (seriesLocationId) {
const updateData = {
sectionId: section.id,
sectionName: section.name,
seriesLocationId: seriesLocationId,
};
let updateResponse: boolean;
if (isCurrentlyOffline() || book?.localBook) {
// Mode offline ou livre local → IPC
updateResponse = await window.electron.invoke<boolean>('db:location:section:update', updateData);
} else {
// Mode online → Serveur
updateResponse = await System.authPostToServer<boolean>('location/section/update', updateData, userToken, lang);
// Si le livre a une copie locale → addToQueue
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:location:section:update', updateData);
}
}
if (updateResponse) {
setSections(function (prev: LocationProps[]): LocationProps[] {
return prev.map(function (s: LocationProps): LocationProps {
return s.id === section.id ? {...s, seriesLocationId: seriesLocationId} : s;
});
});
const newSeriesLocation: SeriesLocationItem = {
id: seriesLocationId,
name: section.name,
elements: [],
};
setSeriesLocations(function (prev: SeriesLocationItem[]): SeriesLocationItem[] {
return [...prev, newSeriesLocation];
});
successMessage(t("locationComponent.exportSuccess"));
}
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
}
}
}, [bookSeriesId, userToken, lang, successMessage, errorMessage, t, isCurrentlyOffline, book, addToQueue, localSyncedBooks, localSyncedSeries, entityId]);
const importFromSeries = useCallback(async function (seriesLocationId: string): Promise<void> {
const seriesLocation: SeriesLocationItem | undefined = seriesLocations.find(function (location: SeriesLocationItem): boolean {
return location.id === seriesLocationId;
});
if (!seriesLocation) return;
try {
const sectionData = {
bookId: entityId,
locationName: seriesLocation.name,
seriesLocationId: seriesLocationId,
};
let sectionId: string;
if (isCurrentlyOffline() || book?.localBook) {
// Mode offline ou livre local → IPC
sectionId = await window.electron.invoke<string>('db:location:section:add', sectionData);
} else {
// Mode online → Serveur
sectionId = await System.authPostToServer<string>('location/section/add', sectionData, userToken, lang);
// Si le livre a une copie locale → addToQueue avec l'ID du serveur
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:location:section:add', {...sectionData, id: sectionId});
}
}
if (!sectionId) {
errorMessage(t('locationComponent.importError'));
return;
}
const importedElements: Element[] = [];
for (const seriesElement of seriesLocation.elements) {
const elementData = {
bookId: entityId,
locationId: sectionId,
elementName: seriesElement.name,
};
let elementId: string;
if (isCurrentlyOffline() || book?.localBook) {
// Mode offline ou livre local → IPC
elementId = await window.electron.invoke<string>('db:location:element:add', elementData);
} else {
// Mode online → Serveur
elementId = await System.authPostToServer<string>('location/element/add', elementData, userToken, lang);
// Si le livre a une copie locale → addToQueue avec l'ID du serveur
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:location:element:add', {...elementData, id: elementId});
}
}
if (!elementId) continue;
const importedSubElements: SubElement[] = [];
for (const seriesSubElement of seriesElement.subElements) {
const subElementData = {
elementId: elementId,
subElementName: seriesSubElement.name,
};
let subElementId: string;
if (isCurrentlyOffline() || book?.localBook) {
// Mode offline ou livre local → IPC
subElementId = await window.electron.invoke<string>('db:location:subelement:add', subElementData);
} else {
// Mode online → Serveur
subElementId = await System.authPostToServer<string>('location/sub-element/add', subElementData, userToken, lang);
// Si le livre a une copie locale → addToQueue avec l'ID du serveur
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:location:subelement:add', {...subElementData, id: subElementId});
}
}
if (subElementId) {
importedSubElements.push({
id: subElementId,
name: seriesSubElement.name,
description: seriesSubElement.description,
});
}
}
importedElements.push({
id: elementId,
name: seriesElement.name,
description: seriesElement.description,
subElements: importedSubElements,
});
}
const newLocation: LocationProps = {
id: sectionId,
name: seriesLocation.name,
elements: importedElements,
seriesLocationId: seriesLocationId,
};
setSections(function (prev: LocationProps[]): LocationProps[] {
return [...prev, newLocation];
});
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
}
}
}, [seriesLocations, entityId, userToken, lang, errorMessage, successMessage, t, isCurrentlyOffline, book, addToQueue, localSyncedBooks]);
// Navigation functions
const enterDetailMode = useCallback(function (sectionIndex: number): void {
setSelectedSectionIndex(sectionIndex);
setViewMode('detail');
setSectionsBackup(null);
}, []);
const enterEditMode = useCallback(function (): void {
setSectionsBackup(sections.map(function (section: LocationProps): LocationProps {
return {
...section,
elements: section.elements.map(function (element: Element): Element {
return {
...element,
subElements: [...element.subElements]
};
})
};
}));
setViewMode('edit');
}, [sections]);
const exitEditMode = useCallback(async function (save: boolean): Promise<void> {
if (save) {
const success: boolean = await saveLocations();
if (!success) {
// Stay in edit mode on error
return;
}
setViewMode('detail');
} else {
if (sectionsBackup) {
setSections(sectionsBackup);
setViewMode('detail');
} else {
setViewMode('list');
}
}
setSectionsBackup(null);
}, [saveLocations, sectionsBackup]);
const backToList = useCallback(function (): void {
setSelectedSectionIndex(-1);
setSectionsBackup(null);
setViewMode('list');
}, []);
return {
// State
sections,
seriesLocations,
toolEnabled,
isLoading,
isSeriesMode,
bookSeriesId,
newSectionName,
newElementNames,
newSubElementNames,
// Navigation state
viewMode,
selectedSectionIndex,
sectionsBackup,
// Actions
addSection,
addElement,
addSubElement,
removeSection,
removeElement,
removeSubElement,
updateElement,
updateSubElement,
saveLocations,
toggleTool,
importFromSeries,
exportToSeries,
refreshLocations,
refreshSeriesLocations,
setNewSectionName,
setNewElementNames,
setNewSubElementNames,
// Navigation actions
enterDetailMode,
enterEditMode,
exitEditMode,
backToList,
};
}

975
hooks/settings/useSpells.ts Normal file
View File

@@ -0,0 +1,975 @@
'use client'
import {useCallback, useContext, useEffect, useState} from 'react';
import {
initialSpellState,
SpellEditState,
SpellListItem,
SpellListResponse,
SpellProps,
SpellTagProps
} from '@/lib/models/Spell';
import {SeriesSpellDetailResponse, SeriesSpellListItem, SeriesSpellListResponse} from '@/lib/models/Series';
import {SessionContext} from '@/context/SessionContext';
import {BookContext} from '@/context/BookContext';
import {AlertContext} from '@/context/AlertContext';
import {LangContext, LangContextProps} from '@/context/LangContext';
import System from '@/lib/models/System';
import {useTranslations} from 'next-intl';
import {ViewMode} from '@/shared/interface';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from '@/context/SyncQueueContext';
import {BooksSyncContext, BooksSyncContextProps} from '@/context/BooksSyncContext';
import {SyncedBook} from '@/lib/models/SyncedBook';
import {SeriesContext, SeriesContextProps} from '@/context/SeriesContext';
import {SeriesSyncContext, SeriesSyncContextProps} from '@/context/SeriesSyncContext';
import {SyncedSeries} from '@/lib/models/SyncedSeries';
export interface UseSpellsConfig {
entityType: 'book' | 'series';
entityId: string;
}
export interface UseSpellsReturn {
spells: SpellListItem[];
seriesSpells: SeriesSpellListItem[];
tags: SpellTagProps[];
selectedSpell: SpellEditState | null;
selectedSeriesSpell: SeriesSpellDetailResponse | null;
toolEnabled: boolean;
isLoading: boolean;
isSeriesMode: boolean;
bookSeriesId: string | null;
showTagManager: boolean;
viewMode: ViewMode;
spellBackup: SpellEditState | null;
selectSpell: (spell: SpellListItem) => Promise<void>;
addNewSpell: () => void;
clearSelection: () => void;
saveSpell: () => Promise<boolean>;
deleteSpell: (spellId: string) => Promise<void>;
updateSpellField: (key: keyof SpellEditState, value: string | string[] | null) => void;
toggleTool: (enabled: boolean) => Promise<void>;
importFromSeries: (seriesSpellId: string) => Promise<void>;
exportToSeries: () => Promise<void>;
refreshSeriesSpells: () => Promise<void>;
setSelectedSpell: React.Dispatch<React.SetStateAction<SpellEditState | null>>;
setShowTagManager: (show: boolean) => void;
enterDetailMode: (spell: SpellListItem) => Promise<void>;
enterEditMode: () => void;
exitEditMode: (save: boolean) => Promise<void>;
backToList: () => void;
createTag: (name: string, color: string) => Promise<SpellTagProps | null>;
updateTag: (tagId: string, name: string, color: string) => Promise<boolean>;
deleteTag: (tagId: string) => Promise<boolean>;
handleSyncComplete: () => Promise<void>;
}
export function useSpells(config: UseSpellsConfig): UseSpellsReturn {
const {entityType, entityId} = config;
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {session} = useContext(SessionContext);
const {book, setBook} = useContext(BookContext);
const {errorMessage, successMessage} = useContext(AlertContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {localSeries} = useContext<SeriesContextProps>(SeriesContext);
const {localSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
const [spells, setSpells] = useState<SpellListItem[]>([]);
const [seriesSpells, setSeriesSpells] = useState<SeriesSpellListItem[]>([]);
const [tags, setTags] = useState<SpellTagProps[]>([]);
const [selectedSpell, setSelectedSpell] = useState<SpellEditState | null>(null);
const [selectedSeriesSpell, setSelectedSeriesSpell] = useState<SeriesSpellDetailResponse | null>(null);
const [toolEnabled, setToolEnabled] = useState<boolean>(entityType === 'series' || (book?.tools?.spells ?? false));
const [isLoading, setIsLoading] = useState<boolean>(true);
const [showTagManager, setShowTagManager] = useState<boolean>(false);
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [spellBackup, setSpellBackup] = useState<SpellEditState | null>(null);
const isSeriesMode: boolean = entityType === 'series';
const bookSeriesId: string | null = book?.seriesId || null;
const userToken: string = session?.accessToken || '';
useEffect(function (): void {
if (entityId) {
refreshSpells().then();
}
}, [entityId]);
useEffect(function (): void {
if (bookSeriesId && !isSeriesMode) {
refreshSeriesSpells().then();
}
}, [bookSeriesId, isSeriesMode]);
const refreshSeriesSpells = useCallback(async function (): Promise<void> {
if (!bookSeriesId) return;
try {
let response: SeriesSpellListResponse;
// Dual logic: offline ou livre local → IPC, sinon serveur
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<SeriesSpellListResponse>(
'db:series:spell:list',
{seriesId: bookSeriesId}
);
} else {
response = await System.authGetQueryToServer<SeriesSpellListResponse>(
'series/spell/list',
userToken,
lang,
{seriesid: bookSeriesId}
);
}
if (response) {
setSeriesSpells(response.spells);
}
} catch (e: unknown) {
if (e instanceof Error) {
console.error('Error loading series spells:', e.message);
}
}
}, [bookSeriesId, userToken, lang, isCurrentlyOffline, book?.localBook]);
const refreshSpells = useCallback(async function (): Promise<void> {
setIsLoading(true);
try {
if (isSeriesMode) {
// Series mode - dual logic
let response: SeriesSpellListResponse;
if (isCurrentlyOffline() || localSeries) {
response = await window.electron.invoke<SeriesSpellListResponse>(
'db:series:spell:list',
{seriesId: entityId}
);
} else {
response = await System.authGetQueryToServer<SeriesSpellListResponse>(
'series/spell/list',
userToken,
lang,
{seriesid: entityId}
);
}
if (response) {
const mappedSpells: SpellListItem[] = response.spells.map(function (spell: SeriesSpellListItem): SpellListItem {
return {
id: spell.id,
name: spell.name,
description: spell.description,
tags: spell.tags ? spell.tags.map(function (tagId: string): SpellTagProps {
const foundTag: SpellTagProps | undefined = response.tags.find(function (t: SpellTagProps): boolean {
return t.id === tagId;
});
return foundTag || {id: tagId, name: tagId, color: null};
}) : [],
};
});
setSpells(mappedSpells);
setTags(response.tags || []);
}
} else {
let response: SpellListResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<SpellListResponse>('db:spell:list', {bookid: entityId});
} else if (book?.localBook) {
response = await window.electron.invoke<SpellListResponse>('db:spell:list', {bookid: entityId});
} else {
response = await System.authGetQueryToServer<SpellListResponse>(
'spell/list',
userToken,
lang,
{bookid: entityId}
);
}
if (response) {
setSpells(response.spells.map(function (spell: SpellListItem): SpellListItem {
return {
...spell,
tags: spell.tags || []
};
}));
setTags(response.tags || []);
setToolEnabled(response.enabled);
if (setBook && book) {
setBook({
...book,
tools: {
characters: book.tools?.characters ?? false,
worlds: book.tools?.worlds ?? false,
locations: book.tools?.locations ?? false,
spells: response.enabled
}
});
}
}
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("common.unknownError"));
}
} finally {
setIsLoading(false);
}
}, [entityId, isSeriesMode, userToken, lang, book, setBook, errorMessage, t, isCurrentlyOffline]);
const selectSpell = useCallback(async function (spell: SpellListItem): Promise<void> {
const tagIds: string[] = spell.tags ? spell.tags.map(function (tag: SpellTagProps): string {
return tag.id;
}) : [];
setSelectedSpell({
id: spell.id,
name: spell.name,
description: spell.description,
appearance: '',
tags: tagIds,
powerLevel: null,
components: null,
limitations: null,
notes: null,
seriesSpellId: spell.seriesSpellId || null,
});
setSelectedSeriesSpell(null);
try {
if (isSeriesMode) {
// Series mode - dual logic
let response: SeriesSpellDetailResponse;
if (isCurrentlyOffline() || localSeries) {
response = await window.electron.invoke<SeriesSpellDetailResponse>(
'db:series:spell:detail',
{spellId: spell.id}
);
} else {
response = await System.authGetQueryToServer<SeriesSpellDetailResponse>(
'series/spell/detail',
userToken,
lang,
{spellid: spell.id}
);
}
if (response) {
setSelectedSpell(function (prev: SpellEditState | null): SpellEditState | null {
if (!prev) return null;
return {
...prev,
appearance: response.appearance,
powerLevel: response.powerLevel,
components: response.components,
limitations: response.limitations,
notes: response.notes,
};
});
}
} else {
let response: SpellProps;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<SpellProps>('db:spell:detail', {spellid: spell.id});
} else if (book?.localBook) {
response = await window.electron.invoke<SpellProps>('db:spell:detail', {spellid: spell.id});
} else {
response = await System.authGetQueryToServer<SpellProps>(
'spell/detail',
userToken,
lang,
{spellid: spell.id}
);
}
if (response) {
setSelectedSpell(function (prev: SpellEditState | null): SpellEditState | null {
if (!prev) return null;
return {
...prev,
appearance: response.appearance,
powerLevel: response.powerLevel,
components: response.components,
limitations: response.limitations,
notes: response.notes,
seriesSpellId: response.seriesSpellId || null,
};
});
if (response.seriesSpellId) {
const seriesSpellResponse: SeriesSpellDetailResponse = await System.authGetQueryToServer<SeriesSpellDetailResponse>(
'series/spell/detail',
userToken,
lang,
{spellid: response.seriesSpellId}
);
if (seriesSpellResponse) {
setSelectedSeriesSpell(seriesSpellResponse);
}
}
}
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
}
}
}, [isSeriesMode, userToken, lang, errorMessage, isCurrentlyOffline, book?.localBook]);
const addNewSpell = useCallback(function (): void {
setSelectedSpell({...initialSpellState});
setSelectedSeriesSpell(null);
setViewMode('edit');
setSpellBackup(null);
}, []);
const clearSelection = useCallback(function (): void {
setSelectedSpell(null);
setSelectedSeriesSpell(null);
setViewMode('list');
setSpellBackup(null);
}, []);
const updateSpellField = useCallback(function (key: keyof SpellEditState, value: string | string[] | null): void {
if (selectedSpell) {
setSelectedSpell({...selectedSpell, [key]: value});
}
}, [selectedSpell]);
const toggleTool = useCallback(async function (enabled: boolean): Promise<void> {
if (isSeriesMode) return;
try {
const requestData = {
bookId: book?.bookId,
toolName: 'spells',
enabled: enabled
};
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:book:tool:update', requestData);
} else {
response = await System.authPatchToServer<boolean>('book/tool-setting', requestData, userToken, lang);
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === book?.bookId)) {
addToQueue('db:book:tool:update', requestData);
}
}
if (response && setBook && book) {
setToolEnabled(enabled);
setBook({
...book,
tools: {
characters: book.tools?.characters ?? false,
worlds: book.tools?.worlds ?? false,
locations: book.tools?.locations ?? false,
spells: enabled
}
});
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
}
}
}, [isSeriesMode, book, setBook, userToken, lang, errorMessage, isCurrentlyOffline, localSyncedBooks, addToQueue]);
const saveSpell = useCallback(async function (): Promise<boolean> {
if (!selectedSpell) return false;
if (selectedSpell.id === null) {
return await addSpellInternal(selectedSpell);
} else {
return await updateSpellInternal(selectedSpell);
}
}, [selectedSpell]);
async function addSpellInternal(spell: SpellEditState): Promise<boolean> {
if (!spell.name) {
errorMessage(t("spellComponent.errorNameRequired"));
return false;
}
try {
let newSpellId: string;
if (isSeriesMode) {
// Series mode - dual logic
const data = {
seriesId: entityId,
name: spell.name,
description: spell.description,
appearance: spell.appearance,
tags: spell.tags,
powerLevel: spell.powerLevel,
components: spell.components,
limitations: spell.limitations,
notes: spell.notes,
};
if (isCurrentlyOffline() || localSeries) {
newSpellId = await window.electron.invoke<string>('db:series:spell:add', data);
} else {
newSpellId = await System.authPostToServer<string>('series/spell/add', data, userToken, lang);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:spell:add', {...data, id: newSpellId});
}
}
} else {
const data = {
bookId: entityId,
spell: {
name: spell.name,
description: spell.description,
appearance: spell.appearance,
tags: spell.tags,
powerLevel: spell.powerLevel,
components: spell.components,
limitations: spell.limitations,
notes: spell.notes,
}
};
if (isCurrentlyOffline() || book?.localBook) {
newSpellId = await window.electron.invoke<string>('db:spell:create', data);
} else {
newSpellId = await System.authPostToServer<string>('spell/add', data, userToken, lang);
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:spell:create', {...data, id: newSpellId});
}
}
}
if (!newSpellId) {
errorMessage(t("spellComponent.errorAddSpell"));
return false;
}
const resolvedTags: SpellTagProps[] = tags.filter(function (tag: SpellTagProps): boolean {
return spell.tags.includes(tag.id);
});
const newSpellListItem: SpellListItem = {
id: newSpellId,
name: spell.name,
description: spell.description.length > 150
? spell.description.substring(0, 150) + '...'
: spell.description,
tags: resolvedTags,
};
setSpells(function (prev: SpellListItem[]): SpellListItem[] {
return [...prev, newSpellListItem];
});
setSelectedSpell(null);
return true;
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("common.unknownError"));
}
return false;
}
}
async function updateSpellInternal(spellToUpdate: SpellEditState): Promise<boolean> {
if (!spellToUpdate.id) return false;
if (!spellToUpdate.name) {
errorMessage(t("spellComponent.errorNameRequired"));
return false;
}
try {
let success: boolean;
const data = {
id: spellToUpdate.id,
name: spellToUpdate.name,
description: spellToUpdate.description,
appearance: spellToUpdate.appearance,
tags: spellToUpdate.tags,
powerLevel: spellToUpdate.powerLevel,
components: spellToUpdate.components,
limitations: spellToUpdate.limitations,
notes: spellToUpdate.notes,
};
if (isSeriesMode) {
// Series mode - dual logic
if (isCurrentlyOffline() || localSeries) {
success = await window.electron.invoke<boolean>('db:series:spell:update', data);
} else {
success = await System.authPutToServer<boolean>('series/spell/update', data, userToken, lang);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:spell:update', data);
}
}
} else {
if (isCurrentlyOffline() || book?.localBook) {
success = await window.electron.invoke<boolean>('db:spell:update', data);
} else {
success = await System.authPutToServer<boolean>('spell/update', data, userToken, lang);
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:spell:update', data);
}
}
}
if (!success) {
errorMessage(t("spellComponent.errorUpdateSpell"));
return false;
}
const resolvedTags: SpellTagProps[] = tags.filter(function (tag: SpellTagProps): boolean {
return spellToUpdate.tags.includes(tag.id);
});
setSpells(function (prev: SpellListItem[]): SpellListItem[] {
return prev.map(function (spell: SpellListItem): SpellListItem {
return spell.id === spellToUpdate.id ? {
id: spellToUpdate.id,
name: spellToUpdate.name,
description: spellToUpdate.description.length > 150
? spellToUpdate.description.substring(0, 150) + '...'
: spellToUpdate.description,
tags: resolvedTags,
} : spell;
});
});
setSelectedSpell(null);
successMessage(t("spellComponent.successUpdate"));
return true;
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("common.unknownError"));
}
return false;
}
}
const deleteSpell = useCallback(async function (spellId: string): Promise<void> {
try {
let success: boolean;
const requestData = {spellId};
if (isSeriesMode) {
// Series mode - dual logic
if (isCurrentlyOffline() || localSeries) {
success = await window.electron.invoke<boolean>('db:series:spell:delete', requestData);
} else {
success = await System.authDeleteToServer<boolean>('series/spell/delete', requestData, userToken, lang);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:spell:delete', requestData);
}
}
} else {
if (isCurrentlyOffline() || book?.localBook) {
success = await window.electron.invoke<boolean>('db:spell:delete', requestData);
} else {
success = await System.authDeleteToServer<boolean>('spell/delete', requestData, userToken, lang);
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:spell:delete', requestData);
}
}
}
if (!success) {
errorMessage(t("spellComponent.errorDeleteSpell"));
return;
}
setSpells(function (prev: SpellListItem[]): SpellListItem[] {
return prev.filter(function (s: SpellListItem): boolean {
return s.id !== spellId;
});
});
setSelectedSpell(null);
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("common.unknownError"));
}
}
}, [isSeriesMode, userToken, lang, errorMessage, t, isCurrentlyOffline, book?.localBook, entityId, localSyncedBooks, addToQueue]);
const exportToSeries = useCallback(async function (): Promise<void> {
if (!selectedSpell || !selectedSpell.id || !bookSeriesId) return;
try {
const seriesSpellData = {
seriesId: bookSeriesId,
name: selectedSpell.name,
description: selectedSpell.description,
appearance: selectedSpell.appearance || '',
tags: [],
powerLevel: selectedSpell.powerLevel || null,
components: selectedSpell.components || null,
limitations: selectedSpell.limitations || null,
notes: selectedSpell.notes || null,
};
let seriesSpellId: string;
if (isCurrentlyOffline() || book?.localBook) {
// Mode offline ou livre local → IPC
seriesSpellId = await window.electron.invoke<string>('db:series:spell:add', seriesSpellData);
} else {
// Mode online → Serveur
seriesSpellId = await System.authPostToServer<string>(
'series/spell/add',
seriesSpellData,
userToken,
lang
);
// Si la série a une copie locale → addToQueue
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === bookSeriesId)) {
addToQueue('db:series:spell:add', {...seriesSpellData, id: seriesSpellId});
}
}
if (seriesSpellId) {
const updateData = {
id: selectedSpell.id,
name: selectedSpell.name,
description: selectedSpell.description,
appearance: selectedSpell.appearance,
tags: selectedSpell.tags,
powerLevel: selectedSpell.powerLevel,
components: selectedSpell.components,
limitations: selectedSpell.limitations,
notes: selectedSpell.notes,
seriesSpellId: seriesSpellId
};
let updateSuccess: boolean;
if (isCurrentlyOffline() || book?.localBook) {
// Mode offline ou livre local → IPC
updateSuccess = await window.electron.invoke<boolean>('db:spell:update', updateData);
} else {
// Mode online → Serveur
updateSuccess = await System.authPutToServer<boolean>('spell/update', updateData, userToken, lang);
// Si le livre a une copie locale → addToQueue
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:spell:update', updateData);
}
}
if (updateSuccess) {
setSelectedSpell({...selectedSpell, seriesSpellId: seriesSpellId});
setSpells(function (prev: SpellListItem[]): SpellListItem[] {
return prev.map(function (s: SpellListItem): SpellListItem {
return s.id === selectedSpell.id ? {...s, seriesSpellId: seriesSpellId} : s;
});
});
const newSeriesSpell: SeriesSpellListItem = {
id: seriesSpellId,
name: selectedSpell.name,
description: selectedSpell.description.length > 150
? selectedSpell.description.substring(0, 150) + '...'
: selectedSpell.description,
tags: null,
};
setSeriesSpells(function (prev: SeriesSpellListItem[]): SeriesSpellListItem[] {
return [...prev, newSeriesSpell];
});
successMessage(t("spellComponent.exportSuccess"));
}
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
}
}
}, [selectedSpell, bookSeriesId, userToken, lang, successMessage, errorMessage, t, isCurrentlyOffline, book, addToQueue, localSyncedBooks, localSyncedSeries, entityId]);
const importFromSeries = useCallback(async function (seriesSpellId: string): Promise<void> {
try {
// 1. Récupérer les détails du sort de la série
let seriesSpellDetail: SeriesSpellDetailResponse;
if (isCurrentlyOffline() || book?.localBook) {
// Mode offline → IPC pour récupérer les détails du sort de la série locale
seriesSpellDetail = await window.electron.invoke<SeriesSpellDetailResponse>(
'db:series:spell:detail',
{spellId: seriesSpellId}
);
} else {
// Mode online → Serveur
seriesSpellDetail = await System.authGetQueryToServer<SeriesSpellDetailResponse>(
'series/spell/detail',
userToken,
lang,
{spellid: seriesSpellId}
);
}
if (!seriesSpellDetail) return;
// 2. Créer le sort dans le livre
const spellData = {
bookId: entityId,
spell: {
name: seriesSpellDetail.name,
description: seriesSpellDetail.description,
appearance: seriesSpellDetail.appearance || '',
tags: [],
powerLevel: seriesSpellDetail.powerLevel,
components: seriesSpellDetail.components,
limitations: seriesSpellDetail.limitations,
notes: seriesSpellDetail.notes,
seriesSpellId: seriesSpellId
}
};
let createdSpellId: string;
if (isCurrentlyOffline() || book?.localBook) {
// Mode offline ou livre local → IPC
createdSpellId = await window.electron.invoke<string>('db:spell:create', spellData);
} else {
// Mode online → Serveur
createdSpellId = await System.authPostToServer<string>('spell/add', spellData, userToken, lang);
// Si le livre a une copie locale → addToQueue
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:spell:create', {...spellData, id: createdSpellId});
}
}
if (createdSpellId) {
const newSpellListItem: SpellListItem = {
id: createdSpellId,
name: seriesSpellDetail.name,
description: seriesSpellDetail.description.length > 150
? seriesSpellDetail.description.substring(0, 150) + '...'
: seriesSpellDetail.description,
tags: [],
seriesSpellId: seriesSpellId,
};
setSpells(function (prev: SpellListItem[]): SpellListItem[] {
return [...prev, newSpellListItem];
});
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
}
}
}, [entityId, userToken, lang, errorMessage, successMessage, t, isCurrentlyOffline, book, addToQueue, localSyncedBooks]);
const createTag = useCallback(async function (name: string, color: string): Promise<SpellTagProps | null> {
try {
if (isSeriesMode) {
// Series mode - dual logic
const addData = {
seriesId: entityId,
name: name,
color: color,
};
let tagId: string;
if (isCurrentlyOffline() || localSeries) {
tagId = await window.electron.invoke<string>('db:series:spell:tag:add', addData);
} else {
tagId = await System.authPostToServer<string>(
'series/spell/tag/add',
addData,
userToken,
lang
);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:spell:tag:add', {...addData, id: tagId});
}
}
if (tagId) {
const newTag: SpellTagProps = {id: tagId, name: name, color: color};
setTags(function (prev: SpellTagProps[]): SpellTagProps[] {
return [...prev, newTag];
});
return newTag;
}
return null;
} else {
const requestData = {
bookId: entityId,
name: name,
color: color,
};
let newTag: SpellTagProps;
if (isCurrentlyOffline() || book?.localBook) {
newTag = await window.electron.invoke<SpellTagProps>('db:spell:tag:create', requestData);
} else {
newTag = await System.authPostToServer<SpellTagProps>('spell/tag/add', requestData, userToken, lang);
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:spell:tag:create', {...requestData, id: newTag?.id});
}
}
if (newTag && newTag.id) {
setTags(function (prev: SpellTagProps[]): SpellTagProps[] {
return [...prev, newTag];
});
return newTag;
}
return null;
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
}
return null;
}
}, [isSeriesMode, entityId, userToken, lang, errorMessage, isCurrentlyOffline, book?.localBook, localSyncedBooks, addToQueue]);
const updateTag = useCallback(async function (tagId: string, name: string, color: string): Promise<boolean> {
try {
let success: boolean;
const requestData = {tagId, name, color};
if (isSeriesMode) {
// Series mode - dual logic
if (isCurrentlyOffline() || localSeries) {
success = await window.electron.invoke<boolean>('db:series:spell:tag:update', requestData);
} else {
success = await System.authPutToServer<boolean>('series/spell/tag/update', requestData, userToken, lang);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:spell:tag:update', requestData);
}
}
} else {
if (isCurrentlyOffline() || book?.localBook) {
success = await window.electron.invoke<boolean>('db:spell:tag:update', requestData);
} else {
success = await System.authPutToServer<boolean>('spell/tag/update', requestData, userToken, lang);
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:spell:tag:update', requestData);
}
}
}
if (!success) {
errorMessage(t("spellComponent.updateSuccess"));
return false;
}
setTags(function (prev: SpellTagProps[]): SpellTagProps[] {
return prev.map(function (tag: SpellTagProps): SpellTagProps {
return tag.id === tagId ? {id: tagId, name: name, color: color} : tag;
});
});
return true;
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
}
return false;
}
}, [isSeriesMode, userToken, lang, errorMessage, t, isCurrentlyOffline, book?.localBook, entityId, localSyncedBooks, addToQueue]);
const deleteTag = useCallback(async function (tagId: string): Promise<boolean> {
try {
let success: boolean;
if (isSeriesMode) {
// Series mode - dual logic
const deleteData = {tagId};
if (isCurrentlyOffline() || localSeries) {
success = await window.electron.invoke<boolean>('db:series:spell:tag:delete', deleteData);
} else {
success = await System.authDeleteToServer<boolean>('series/spell/tag/delete', deleteData, userToken, lang);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:spell:tag:delete', deleteData);
}
}
} else {
const requestData = {tagId, bookId: entityId};
if (isCurrentlyOffline() || book?.localBook) {
success = await window.electron.invoke<boolean>('db:spell:tag:delete', requestData);
} else {
success = await System.authDeleteToServer<boolean>('spell/tag/delete', requestData, userToken, lang);
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:spell:tag:delete', requestData);
}
}
}
if (success) {
setTags(function (prev: SpellTagProps[]): SpellTagProps[] {
return prev.filter(function (tag: SpellTagProps): boolean {
return tag.id !== tagId;
});
});
return true;
}
return false;
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
}
return false;
}
}, [isSeriesMode, entityId, userToken, lang, errorMessage, isCurrentlyOffline, book?.localBook, localSyncedBooks, addToQueue]);
const handleSyncComplete = useCallback(async function (): Promise<void> {
if (selectedSpell?.seriesSpellId) {
const seriesSpellResponse: SeriesSpellDetailResponse = await System.authGetQueryToServer<SeriesSpellDetailResponse>(
'series/spell/detail',
userToken,
lang,
{spellid: selectedSpell.seriesSpellId}
);
if (seriesSpellResponse) {
setSelectedSeriesSpell(seriesSpellResponse);
}
}
}, [selectedSpell?.seriesSpellId, userToken, lang]);
const enterDetailMode = useCallback(async function (spell: SpellListItem): Promise<void> {
await selectSpell(spell);
setViewMode('detail');
setSpellBackup(null);
}, [selectSpell]);
const enterEditMode = useCallback(function (): void {
if (selectedSpell) {
setSpellBackup({...selectedSpell});
}
setViewMode('edit');
}, [selectedSpell]);
const exitEditMode = useCallback(async function (save: boolean): Promise<void> {
if (save) {
const success: boolean = await saveSpell();
if (!success) return;
if (spellBackup) {
setViewMode('detail');
} else {
setViewMode('list');
}
} else {
if (spellBackup) {
setSelectedSpell(spellBackup);
setViewMode('detail');
} else {
setSelectedSpell(null);
setViewMode('list');
}
}
setSpellBackup(null);
}, [saveSpell, spellBackup]);
const backToList = useCallback(function (): void {
setSelectedSpell(null);
setSelectedSeriesSpell(null);
setSpellBackup(null);
setViewMode('list');
}, []);
return {
spells,
seriesSpells,
tags,
selectedSpell,
selectedSeriesSpell,
toolEnabled,
isLoading,
isSeriesMode,
bookSeriesId,
showTagManager,
viewMode,
spellBackup,
selectSpell,
addNewSpell,
clearSelection,
saveSpell,
deleteSpell,
updateSpellField,
toggleTool,
importFromSeries,
exportToSeries,
refreshSeriesSpells,
setSelectedSpell,
setShowTagManager,
enterDetailMode,
enterEditMode,
exitEditMode,
backToList,
createTag,
updateTag,
deleteTag,
handleSyncComplete,
};
}

720
hooks/settings/useWorlds.ts Normal file
View File

@@ -0,0 +1,720 @@
'use client'
import {useCallback, useContext, useEffect, useState} from 'react';
import {WorldListResponse, WorldProps} from '@/lib/models/World';
import {SeriesWorldProps} from '@/lib/models/Series';
import {SessionContext} from '@/context/SessionContext';
import {BookContext} from '@/context/BookContext';
import {AlertContext} from '@/context/AlertContext';
import {LangContext, LangContextProps} from '@/context/LangContext';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from '@/context/SyncQueueContext';
import {BooksSyncContext, BooksSyncContextProps} from '@/context/BooksSyncContext';
import {SyncedBook} from '@/lib/models/SyncedBook';
import {SeriesContext, SeriesContextProps} from '@/context/SeriesContext';
import {SeriesSyncContext, SeriesSyncContextProps} from '@/context/SeriesSyncContext';
import {SyncedSeries} from '@/lib/models/SyncedSeries';
import System from '@/lib/models/System';
import {useTranslations} from 'next-intl';
import {SelectBoxProps} from '@/shared/interface';
import {ViewMode} from '@/shared/interface';
const initialWorldState: WorldProps = {
id: '',
name: '',
history: '',
politics: '',
economy: '',
religion: '',
languages: '',
laws: [],
biomes: [],
issues: [],
customs: [],
kingdoms: [],
climate: [],
resources: [],
wildlife: [],
arts: [],
ethnicGroups: [],
socialClasses: [],
importantCharacters: [],
};
export interface UseWorldsConfig {
entityType: 'book' | 'series';
entityId: string;
}
export interface UseWorldsReturn {
// State
worlds: WorldProps[];
seriesWorlds: SeriesWorldProps[];
selectedWorldIndex: number;
worldsSelector: SelectBoxProps[];
toolEnabled: boolean;
isLoading: boolean;
isSeriesMode: boolean;
bookSeriesId: string | null;
showAddNewWorld: boolean;
newWorldName: string;
// Navigation state
viewMode: ViewMode;
worldBackup: WorldProps | null;
// Actions
selectWorld: (worldId: string) => void;
addNewWorld: () => Promise<void>;
saveWorld: () => Promise<boolean>;
updateWorldField: (field: keyof WorldProps, value: string) => void;
updateWorldArrayField: (field: keyof WorldProps, value: unknown) => void;
toggleTool: (enabled: boolean) => Promise<void>;
importFromSeries: (seriesWorldId: string) => Promise<void>;
exportToSeries: () => Promise<void>;
refreshWorlds: () => Promise<void>;
refreshSeriesWorlds: () => Promise<void>;
setShowAddNewWorld: (show: boolean) => void;
setNewWorldName: (name: string) => void;
setWorlds: React.Dispatch<React.SetStateAction<WorldProps[]>>;
getSeriesWorldForCurrentWorld: () => SeriesWorldProps | null;
// Navigation actions
enterDetailMode: (worldId: string) => void;
enterEditMode: () => void;
exitEditMode: (save: boolean) => Promise<void>;
backToList: () => void;
}
export function useWorlds(config: UseWorldsConfig): UseWorldsReturn {
const {entityType, entityId} = config;
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {session} = useContext(SessionContext);
const {book, setBook} = useContext(BookContext);
const {errorMessage, successMessage} = useContext(AlertContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
const {localSeries} = useContext<SeriesContextProps>(SeriesContext);
const {localSyncedSeries} = useContext<SeriesSyncContextProps>(SeriesSyncContext);
const [worlds, setWorlds] = useState<WorldProps[]>([]);
const [seriesWorlds, setSeriesWorlds] = useState<SeriesWorldProps[]>([]);
const [selectedWorldIndex, setSelectedWorldIndex] = useState<number>(0);
const [worldsSelector, setWorldsSelector] = useState<SelectBoxProps[]>([]);
const [toolEnabled, setToolEnabled] = useState<boolean>(entityType === 'series' || (book?.tools?.worlds ?? false));
const [isLoading, setIsLoading] = useState<boolean>(true);
const [showAddNewWorld, setShowAddNewWorld] = useState<boolean>(false);
const [newWorldName, setNewWorldName] = useState<string>('');
// Navigation state
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [worldBackup, setWorldBackup] = useState<WorldProps | null>(null);
const isSeriesMode: boolean = entityType === 'series';
const bookSeriesId: string | null = book?.seriesId || null;
const userToken: string = session?.accessToken || '';
// Load worlds on mount
useEffect(function (): void {
if (entityId) {
refreshWorlds();
}
}, [entityId]);
// Load series worlds for book mode
useEffect(function (): void {
if (bookSeriesId && !isSeriesMode) {
refreshSeriesWorlds();
}
}, [bookSeriesId, isSeriesMode]);
const refreshSeriesWorlds = useCallback(async function (): Promise<void> {
if (!bookSeriesId) return;
try {
let response: SeriesWorldProps[];
// Dual logic: offline ou livre local → IPC, sinon serveur
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<SeriesWorldProps[]>(
'db:series:world:list',
{seriesId: bookSeriesId}
);
} else {
response = await System.authGetQueryToServer<SeriesWorldProps[]>(
'series/world/list',
userToken,
lang,
{seriesid: bookSeriesId}
);
}
if (response) {
setSeriesWorlds(response);
}
} catch (e: unknown) {
if (e instanceof Error) {
console.error('Error loading series worlds:', e.message);
}
}
}, [bookSeriesId, userToken, lang, isCurrentlyOffline, book?.localBook]);
const refreshWorlds = useCallback(async function (): Promise<void> {
setIsLoading(true);
try {
if (isSeriesMode) {
// Series mode - dual logic
let response: SeriesWorldProps[];
if (isCurrentlyOffline() || localSeries) {
response = await window.electron.invoke<SeriesWorldProps[]>(
'db:series:world:list',
{seriesId: entityId}
);
} else {
response = await System.authGetQueryToServer<SeriesWorldProps[]>(
'series/world/list',
userToken,
lang,
{seriesid: entityId}
);
}
if (response) {
const mappedWorlds: WorldProps[] = response.map(function (world: SeriesWorldProps): WorldProps {
return {
id: world.id,
name: world.name,
history: world.history || '',
politics: world.politics || '',
economy: world.economy || '',
religion: world.religion || '',
languages: world.languages || '',
laws: world.laws || [],
biomes: world.biomes || [],
issues: world.issues || [],
customs: world.customs || [],
kingdoms: world.kingdoms || [],
climate: world.climate || [],
resources: world.resources || [],
wildlife: world.wildlife || [],
arts: world.arts || [],
ethnicGroups: world.ethnicGroups || [],
socialClasses: world.socialClasses || [],
importantCharacters: world.importantCharacters || [],
};
});
setWorlds(mappedWorlds);
const formattedWorlds: SelectBoxProps[] = response.map(function (world: SeriesWorldProps): SelectBoxProps {
return {
label: world.name,
value: world.id,
};
});
setWorldsSelector(formattedWorlds);
}
} else {
let response: WorldListResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<WorldListResponse>('db:book:worlds:get', {bookid: entityId});
} else if (book?.localBook) {
response = await window.electron.invoke<WorldListResponse>('db:book:worlds:get', {bookid: entityId});
} else {
response = await System.authGetQueryToServer<WorldListResponse>(
'book/worlds',
userToken,
lang,
{bookid: entityId}
);
}
if (response) {
setWorlds(response.worlds);
setToolEnabled(response.enabled);
if (setBook && book) {
setBook({
...book,
tools: {
characters: book.tools?.characters ?? false,
worlds: response.enabled,
locations: book.tools?.locations ?? false,
spells: book.tools?.spells ?? false
}
});
}
const formattedWorlds: SelectBoxProps[] = response.worlds.map(function (world: WorldProps): SelectBoxProps {
return {
label: world.name,
value: world.id.toString(),
};
});
setWorldsSelector(formattedWorlds);
}
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("worldSetting.unknownError"));
}
} finally {
setIsLoading(false);
}
}, [entityId, isSeriesMode, userToken, lang, book, setBook, errorMessage, t, isCurrentlyOffline]);
const selectWorld = useCallback(function (worldId: string): void {
const index: number = worlds.findIndex(function (world: WorldProps): boolean {
return world.id === worldId;
});
if (index !== -1) {
setSelectedWorldIndex(index);
}
}, [worlds]);
const updateWorldField = useCallback(function (field: keyof WorldProps, value: string): void {
setWorlds(function (prev: WorldProps[]): WorldProps[] {
const updated: WorldProps[] = [...prev];
(updated[selectedWorldIndex][field] as string) = value;
return updated;
});
}, [selectedWorldIndex]);
const updateWorldArrayField = useCallback(function (field: keyof WorldProps, value: unknown): void {
setWorlds(function (prev: WorldProps[]): WorldProps[] {
const updated: WorldProps[] = [...prev];
// @ts-ignore - Le type dépend du champ
updated[selectedWorldIndex][field] = value;
return updated;
});
}, [selectedWorldIndex]);
const toggleTool = useCallback(async function (enabled: boolean): Promise<void> {
if (isSeriesMode) return;
try {
const requestData = {
bookId: book?.bookId,
toolName: 'worlds',
enabled: enabled
};
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:book:tool:update', requestData);
} else {
response = await System.authPatchToServer<boolean>('book/tool-setting', requestData, userToken, lang);
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === book?.bookId)) {
addToQueue('db:book:tool:update', requestData);
}
}
if (response && setBook && book) {
setToolEnabled(enabled);
setBook({
...book,
tools: {
characters: book.tools?.characters ?? false,
worlds: enabled,
locations: book.tools?.locations ?? false,
spells: book.tools?.spells ?? false
}
});
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
}
}
}, [isSeriesMode, book, setBook, userToken, lang, errorMessage, isCurrentlyOffline, localSyncedBooks, addToQueue]);
const addNewWorld = useCallback(async function (): Promise<void> {
if (newWorldName.trim() === '') {
errorMessage(t("worldSetting.newWorldNameError"));
return;
}
try {
let newWorldId: string;
if (isSeriesMode) {
// Series mode - dual logic
const addData = {
seriesId: entityId,
name: newWorldName,
};
if (isCurrentlyOffline() || localSeries) {
newWorldId = await window.electron.invoke<string>('db:series:world:add', addData);
} else {
newWorldId = await System.authPostToServer<string>(
'series/world/add',
addData,
userToken,
lang
);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:world:add', {...addData, id: newWorldId});
}
}
if (!newWorldId) {
errorMessage(t("worldSetting.addWorldError"));
return;
}
} else {
const requestData = {
worldName: newWorldName,
bookId: entityId,
};
if (isCurrentlyOffline() || book?.localBook) {
newWorldId = await window.electron.invoke<string>('db:book:world:add', requestData);
} else {
newWorldId = await System.authPostToServer<string>('book/world/add', requestData, userToken, lang);
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:book:world:add', {...requestData, id: newWorldId});
}
}
if (!newWorldId) {
errorMessage(t("worldSetting.addWorldError"));
return;
}
}
const newWorld: WorldProps = {
...initialWorldState,
id: newWorldId,
name: newWorldName,
};
setWorlds(function (prev: WorldProps[]): WorldProps[] {
return [...prev, newWorld];
});
setWorldsSelector(function (prev: SelectBoxProps[]): SelectBoxProps[] {
return [...prev, {label: newWorldName, value: newWorldId}];
});
setNewWorldName('');
setShowAddNewWorld(false);
// Sélectionner le nouveau monde et passer en mode edit
setSelectedWorldIndex(worlds.length); // Le nouveau monde est à la fin
setViewMode('edit');
setWorldBackup(null);
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("worldSetting.unknownError"));
}
}
}, [newWorldName, isSeriesMode, entityId, userToken, lang, errorMessage, t, isCurrentlyOffline, book?.localBook, localSyncedBooks, addToQueue]);
const saveWorld = useCallback(async function (): Promise<boolean> {
if (worlds.length === 0) return false;
try {
const currentWorld: WorldProps = worlds[selectedWorldIndex];
if (isSeriesMode) {
// Series mode - dual logic
const updateData = {
worldId: currentWorld.id,
name: currentWorld.name,
history: currentWorld.history,
politics: currentWorld.politics,
economy: currentWorld.economy,
religion: currentWorld.religion,
languages: currentWorld.languages,
};
let response: boolean;
if (isCurrentlyOffline() || localSeries) {
response = await window.electron.invoke<boolean>('db:series:world:update', updateData);
} else {
response = await System.authPatchToServer<boolean>('series/world/update', updateData, userToken, lang);
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === entityId)) {
addToQueue('db:series:world:update', updateData);
}
}
if (!response) {
errorMessage(t("worldSetting.updateWorldError"));
return false;
}
} else {
const requestData = {
world: currentWorld,
bookId: entityId,
};
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:book:world:update', requestData);
} else {
response = await System.authPatchToServer<boolean>('book/world/update', requestData, userToken, lang);
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:book:world:update', requestData);
}
}
if (!response) {
errorMessage(t("worldSetting.updateWorldError"));
return false;
}
}
successMessage(t("worldSetting.updateWorldSuccess"));
return true;
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t("worldSetting.unknownError"));
}
return false;
}
}, [worlds, selectedWorldIndex, isSeriesMode, entityId, userToken, lang, errorMessage, successMessage, t, isCurrentlyOffline, book?.localBook, localSyncedBooks, addToQueue]);
const exportToSeries = useCallback(async function (): Promise<void> {
const selectedWorld: WorldProps | undefined = worlds[selectedWorldIndex];
if (!selectedWorld || !bookSeriesId) return;
try {
const seriesWorldData = {
seriesId: bookSeriesId,
name: selectedWorld.name,
history: selectedWorld.history || null,
politics: selectedWorld.politics || null,
economy: selectedWorld.economy || null,
religion: selectedWorld.religion || null,
languages: selectedWorld.languages || null,
};
let seriesWorldId: string;
if (isCurrentlyOffline() || book?.localBook) {
// Mode offline ou livre local → IPC
seriesWorldId = await window.electron.invoke<string>('db:series:world:add', seriesWorldData);
} else {
// Mode online → Serveur
seriesWorldId = await System.authPostToServer<string>('series/world/add', {
seriesId: bookSeriesId,
world: {
name: selectedWorld.name,
history: selectedWorld.history || null,
politics: selectedWorld.politics || null,
economy: selectedWorld.economy || null,
religion: selectedWorld.religion || null,
languages: selectedWorld.languages || null,
}
}, userToken, lang);
// Si la série a une copie locale → addToQueue
if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === bookSeriesId)) {
addToQueue('db:series:world:add', {...seriesWorldData, id: seriesWorldId});
}
}
if (seriesWorldId) {
const updateData = {
world: {
...selectedWorld,
seriesWorldId: seriesWorldId
},
bookId: entityId,
};
let updateResponse: boolean;
if (isCurrentlyOffline() || book?.localBook) {
// Mode offline ou livre local → IPC
updateResponse = await window.electron.invoke<boolean>('db:book:world:update', updateData);
} else {
// Mode online → Serveur
updateResponse = await System.authPostToServer<boolean>('book/world/update', updateData, userToken, lang);
// Si le livre a une copie locale → addToQueue
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:book:world:update', updateData);
}
}
if (updateResponse) {
setWorlds(function (prev: WorldProps[]): WorldProps[] {
const updated: WorldProps[] = [...prev];
updated[selectedWorldIndex] = {...selectedWorld, seriesWorldId: seriesWorldId};
return updated;
});
const newSeriesWorld: SeriesWorldProps = {
id: seriesWorldId,
name: selectedWorld.name,
history: selectedWorld.history || '',
politics: selectedWorld.politics || '',
economy: selectedWorld.economy || '',
religion: selectedWorld.religion || '',
languages: selectedWorld.languages || '',
laws: [],
biomes: [],
issues: [],
customs: [],
kingdoms: [],
climate: [],
resources: [],
wildlife: [],
arts: [],
ethnicGroups: [],
socialClasses: [],
importantCharacters: [],
};
setSeriesWorlds(function (prev: SeriesWorldProps[]): SeriesWorldProps[] {
return [...prev, newSeriesWorld];
});
successMessage(t("worldSetting.exportSuccess"));
}
}
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
}
}
}, [worlds, selectedWorldIndex, bookSeriesId, userToken, lang, successMessage, errorMessage, t, isCurrentlyOffline, book, addToQueue, localSyncedBooks, localSyncedSeries, entityId]);
const importFromSeries = useCallback(async function (seriesWorldId: string): Promise<void> {
const seriesWorld: SeriesWorldProps | undefined = seriesWorlds.find(function (w: SeriesWorldProps): boolean {
return w.id === seriesWorldId;
});
if (!seriesWorld) return;
try {
const requestData = {
worldName: seriesWorld.name,
bookId: entityId,
seriesWorldId: seriesWorldId,
};
let worldId: string;
if (isCurrentlyOffline() || book?.localBook) {
// Mode offline ou livre local → IPC
worldId = await window.electron.invoke<string>('db:book:world:add', requestData);
} else {
// Mode online → Serveur
worldId = await System.authPostToServer<string>('book/world/add', requestData, userToken, lang);
// Si le livre a une copie locale → addToQueue
if (localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === entityId)) {
addToQueue('db:book:world:add', {...requestData, id: worldId});
}
}
if (!worldId) {
errorMessage(t("worldSetting.importError"));
return;
}
const newWorld: WorldProps = {
id: worldId,
name: seriesWorld.name,
history: seriesWorld.history || '',
politics: seriesWorld.politics || '',
economy: seriesWorld.economy || '',
religion: seriesWorld.religion || '',
languages: seriesWorld.languages || '',
laws: [],
biomes: [],
issues: [],
customs: [],
kingdoms: [],
climate: [],
resources: [],
wildlife: [],
arts: [],
ethnicGroups: [],
socialClasses: [],
importantCharacters: [],
seriesWorldId: seriesWorldId,
};
setWorlds(function (prev: WorldProps[]): WorldProps[] {
return [...prev, newWorld];
});
setWorldsSelector(function (prev: SelectBoxProps[]): SelectBoxProps[] {
return [...prev, {label: seriesWorld.name, value: worldId}];
});
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
}
}
}, [seriesWorlds, entityId, userToken, lang, errorMessage, t, isCurrentlyOffline, book, addToQueue, localSyncedBooks]);
const getSeriesWorldForCurrentWorld = useCallback(function (): SeriesWorldProps | null {
const currentWorld: WorldProps | undefined = worlds[selectedWorldIndex];
if (!currentWorld?.seriesWorldId) return null;
return seriesWorlds.find(function (world: SeriesWorldProps): boolean {
return world.id === currentWorld.seriesWorldId;
}) || null;
}, [worlds, selectedWorldIndex, seriesWorlds]);
// Navigation functions
const enterDetailMode = useCallback(function (worldId: string): void {
const index: number = worlds.findIndex(function (world: WorldProps): boolean {
return world.id === worldId;
});
if (index !== -1) {
setSelectedWorldIndex(index);
setViewMode('detail');
setWorldBackup(null);
}
}, [worlds]);
const enterEditMode = useCallback(function (): void {
if (worlds.length > 0 && selectedWorldIndex >= 0) {
setWorldBackup({...worlds[selectedWorldIndex]});
}
setViewMode('edit');
}, [worlds, selectedWorldIndex]);
const exitEditMode = useCallback(async function (save: boolean): Promise<void> {
if (save) {
const success: boolean = await saveWorld();
if (!success) {
// Stay in edit mode on error
return;
}
if (worldBackup) {
setViewMode('detail');
} else {
setViewMode('list');
}
} else {
if (worldBackup && selectedWorldIndex >= 0) {
setWorlds(function (prev: WorldProps[]): WorldProps[] {
const updated: WorldProps[] = [...prev];
updated[selectedWorldIndex] = worldBackup;
return updated;
});
setViewMode('detail');
} else {
setViewMode('list');
}
}
setWorldBackup(null);
}, [saveWorld, worldBackup, selectedWorldIndex]);
const backToList = useCallback(function (): void {
setSelectedWorldIndex(0);
setWorldBackup(null);
setViewMode('list');
}, []);
return {
// State
worlds,
seriesWorlds,
selectedWorldIndex,
worldsSelector,
toolEnabled,
isLoading,
isSeriesMode,
bookSeriesId,
showAddNewWorld,
newWorldName,
// Navigation state
viewMode,
worldBackup,
// Actions
selectWorld,
addNewWorld,
saveWorld,
updateWorldField,
updateWorldArrayField,
toggleTool,
importFromSeries,
exportToSeries,
refreshWorlds,
refreshSeriesWorlds,
setShowAddNewWorld,
setNewWorldName,
setWorlds,
getSeriesWorldForCurrentWorld,
// Navigation actions
enterDetailMode,
enterEditMode,
exitEditMode,
backToList,
};
}

View File

@@ -9,6 +9,20 @@ import {CompleteBook} from '@/lib/models/Book';
import {BookSyncCompare, SyncedBook} from '@/lib/models/SyncedBook';
import {useTranslations} from 'next-intl';
interface RemovedItemRecord {
removal_id: string;
table_name: string;
entity_id: string;
book_id: string | null;
user_id: string;
deleted_at: number;
}
interface SyncedBooksResponse {
books: SyncedBook[];
tombstones: RemovedItemRecord[];
}
export default function useSyncBooks() {
const t = useTranslations();
const {session} = useContext(SessionContext);
@@ -23,7 +37,9 @@ export default function useSyncBooks() {
setLocalOnlyBooks,
setServerOnlyBooks,
setServerSyncedBooks,
setLocalSyncedBooks
setLocalSyncedBooks,
setBooksToSyncFromServer,
setBooksToSyncToServer
} = useContext(BooksSyncContext);
async function upload(bookId: string): Promise<boolean> {
@@ -115,6 +131,9 @@ export default function useSyncBooks() {
errorMessage(t('bookCard.syncFromServerError'));
return false;
}
setBooksToSyncFromServer((prev: BookSyncCompare[]): BookSyncCompare[] =>
prev.filter((book: BookSyncCompare): boolean => book.id !== bookId)
);
return true;
} catch (e: unknown) {
if (e instanceof Error) {
@@ -147,6 +166,9 @@ export default function useSyncBooks() {
errorMessage(t('bookCard.syncToServerError'));
return false;
}
setBooksToSyncToServer((prev: BookSyncCompare[]): BookSyncCompare[] =>
prev.filter((book: BookSyncCompare): boolean => book.id !== bookId)
);
return true;
} catch (e: unknown) {
if (e instanceof Error) {
@@ -172,8 +194,39 @@ export default function useSyncBooks() {
if (!isCurrentlyOffline()) {
if (offlineMode.isDatabaseInitialized) {
localBooksResponse = await window.electron.invoke<SyncedBook[]>('db:books:synced');
// Get lastOnlineTimestamp from localStorage (or 0 if not set)
const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp');
const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0;
// Get local tombstones since lastOnlineTimestamp via IPC
const localTombstones: RemovedItemRecord[] = await window.electron.invoke<RemovedItemRecord[]>(
'db:tombstones:since',
lastOnlineTimestamp
);
// Call server with POST and tombstones
const serverResponse: SyncedBooksResponse = await System.authPostToServer<SyncedBooksResponse>(
'books/synced',
{ lastOnlineTimestamp, tombstones: localTombstones },
session.accessToken,
lang
);
serverBooksResponse = serverResponse.books;
// Apply server tombstones locally via IPC
await window.electron.invoke<void>('db:tombstones:apply:books', serverResponse.tombstones);
} else {
// No local DB but online - just get server books without tombstones
const serverResponse: SyncedBooksResponse = await System.authPostToServer<SyncedBooksResponse>(
'books/synced',
{ lastOnlineTimestamp: 0, tombstones: [] },
session.accessToken,
lang
);
serverBooksResponse = serverResponse.books;
}
serverBooksResponse = await System.authGetQueryToServer<SyncedBook[]>('books/synced', session.accessToken, lang);
} else {
if (offlineMode.isDatabaseInitialized) {
localBooksResponse = await window.electron.invoke<SyncedBook[]>('db:books:synced');

352
hooks/useSyncSeries.ts Normal file
View File

@@ -0,0 +1,352 @@
import { useContext } from 'react';
import System from '@/lib/models/System';
import { SessionContext } from '@/context/SessionContext';
import { LangContext } from '@/context/LangContext';
import { AlertContext } from '@/context/AlertContext';
import OfflineContext from '@/context/OfflineContext';
import { SeriesSyncContext } from '@/context/SeriesSyncContext';
import { SeriesSyncCompare, SyncedSeries } from '@/lib/models/SyncedSeries';
import { useTranslations } from 'next-intl';
interface RemovedItemRecord {
removal_id: string;
table_name: string;
entity_id: string;
book_id: string | null;
user_id: string;
deleted_at: number;
}
interface SyncedSeriesResponse {
series: SyncedSeries[];
tombstones: RemovedItemRecord[];
}
/**
* Complete series data structure for full upload/download operations.
* Mirrors the backend CompleteSeries interface.
*/
interface CompleteSeries {
series: unknown[];
seriesBooks: unknown[];
seriesCharacters: unknown[];
seriesCharacterAttributes: unknown[];
seriesWorlds: unknown[];
seriesWorldElements: unknown[];
seriesLocations: unknown[];
seriesLocationElements: unknown[];
seriesLocationSubElements: unknown[];
seriesSpells: unknown[];
seriesSpellTags: unknown[];
}
/**
* Hook for managing series synchronization between local database and server.
* Provides methods for upload, download, and partial sync operations.
*/
export default function useSyncSeries() {
const t = useTranslations();
const { session } = useContext(SessionContext);
const { lang } = useContext(LangContext);
const { errorMessage } = useContext(AlertContext);
const { isCurrentlyOffline, offlineMode } = useContext(OfflineContext);
const {
seriesToSyncToServer,
seriesToSyncFromServer,
localOnlySeries,
serverOnlySeries,
setLocalOnlySeries,
setServerOnlySeries,
setServerSyncedSeries,
setLocalSyncedSeries,
setSeriesToSyncFromServer,
setSeriesToSyncToServer
} = useContext(SeriesSyncContext);
/**
* Uploads a local-only series to the server.
* @param seriesId - The ID of the series to upload
* @returns True if upload was successful, false otherwise
*/
async function upload(seriesId: string): Promise<boolean> {
if (isCurrentlyOffline()) return false;
try {
const seriesToSync: CompleteSeries = await window.electron.invoke<CompleteSeries>('db:series:uploadToServer', seriesId);
if (!seriesToSync) {
errorMessage(t('seriesCard.uploadError'));
return false;
}
const response: boolean = await System.authPostToServer('series/sync/upload', {
series: seriesToSync
}, session.accessToken, lang);
if (!response) {
errorMessage(t('seriesCard.uploadError'));
return false;
}
// Move series from local-only to synced
const uploadedSeries: SyncedSeries | undefined = localOnlySeries.find(
(series: SyncedSeries): boolean => series.id === seriesId
);
setLocalOnlySeries((prevSeries: SyncedSeries[]): SyncedSeries[] => {
return prevSeries.filter((series: SyncedSeries): boolean => series.id !== seriesId);
});
if (uploadedSeries) {
setLocalSyncedSeries((prev: SyncedSeries[]): SyncedSeries[] => [...prev, uploadedSeries]);
setServerSyncedSeries((prev: SyncedSeries[]): SyncedSeries[] => [...prev, uploadedSeries]);
}
return true;
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('seriesCard.uploadError'));
}
return false;
}
}
/**
* Downloads a server-only series to the local database.
* @param seriesId - The ID of the series to download
* @returns True if download was successful, false otherwise
*/
async function download(seriesId: string): Promise<boolean> {
if (isCurrentlyOffline()) return false;
try {
const response: CompleteSeries = await System.authGetQueryToServer(
'series/sync/download',
session.accessToken,
lang,
{ seriesId }
);
if (!response) {
errorMessage(t('seriesCard.downloadError'));
return false;
}
const syncStatus: boolean = await window.electron.invoke<boolean>('db:series:syncSave', response);
if (!syncStatus) {
errorMessage(t('seriesCard.downloadError'));
return false;
}
// Move series from server-only to synced
const downloadedSeries: SyncedSeries | undefined = serverOnlySeries.find(
(series: SyncedSeries): boolean => series.id === seriesId
);
setServerOnlySeries((prevSeries: SyncedSeries[]): SyncedSeries[] => {
return prevSeries.filter((series: SyncedSeries): boolean => series.id !== seriesId);
});
if (downloadedSeries) {
setLocalSyncedSeries((prev: SyncedSeries[]): SyncedSeries[] => [...prev, downloadedSeries]);
setServerSyncedSeries((prev: SyncedSeries[]): SyncedSeries[] => [...prev, downloadedSeries]);
}
return true;
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('seriesCard.downloadError'));
}
return false;
}
}
/**
* Syncs changes from server to local database for a specific series.
* Only transfers entities that have changed based on the comparison.
* @param seriesId - The ID of the series to sync
* @returns True if sync was successful, false otherwise
*/
async function syncFromServer(seriesId: string): Promise<boolean> {
if (isCurrentlyOffline()) return false;
try {
const seriesToFetch: SeriesSyncCompare | undefined = seriesToSyncFromServer.find(
(series: SeriesSyncCompare): boolean => series.id === seriesId
);
if (!seriesToFetch) {
errorMessage(t('seriesCard.syncFromServerError'));
return false;
}
const response: CompleteSeries = await System.authPostToServer(
'series/sync/server-to-client',
{ seriesToSync: seriesToFetch },
session.accessToken,
lang
);
if (!response) {
errorMessage(t('seriesCard.syncFromServerError'));
return false;
}
const syncStatus: boolean = await window.electron.invoke<boolean>('db:series:sync:toClient', response);
if (!syncStatus) {
errorMessage(t('seriesCard.syncFromServerError'));
return false;
}
// Remove from pending sync list
setSeriesToSyncFromServer((prev: SeriesSyncCompare[]): SeriesSyncCompare[] =>
prev.filter((series: SeriesSyncCompare): boolean => series.id !== seriesId)
);
return true;
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('seriesCard.syncFromServerError'));
}
return false;
}
}
/**
* Syncs local changes to the server for a specific series.
* Only transfers entities that have changed based on the comparison.
* @param seriesId - The ID of the series to sync
* @returns True if sync was successful, false otherwise
*/
async function syncToServer(seriesId: string): Promise<boolean> {
if (isCurrentlyOffline()) {
return false;
}
try {
const seriesToFetch: SeriesSyncCompare | undefined = seriesToSyncToServer.find(
(series: SeriesSyncCompare): boolean => series.id === seriesId
);
if (!seriesToFetch) {
// La série n'est plus dans la liste - probablement déjà sync par AutoSyncOnReconnect
// Retourner true car ce n'est pas une erreur, juste déjà fait
return true;
}
const seriesToSync: CompleteSeries = await window.electron.invoke<CompleteSeries>(
'db:series:sync:toServer',
seriesToFetch
);
if (!seriesToSync) {
errorMessage(t('seriesCard.syncToServerError'));
return false;
}
const response: boolean = await System.authPatchToServer(
'series/sync/client-to-server',
{ series: seriesToSync },
session.accessToken,
lang
);
if (!response) {
errorMessage(t('seriesCard.syncToServerError'));
return false;
}
// Remove from pending sync list
setSeriesToSyncToServer((prev: SeriesSyncCompare[]): SeriesSyncCompare[] =>
prev.filter((series: SeriesSyncCompare): boolean => series.id !== seriesId)
);
return true;
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('seriesCard.syncToServerError'));
}
return false;
}
}
/**
* Syncs all series that have local changes to the server.
*/
async function syncAllToServer(): Promise<void> {
for (const diff of seriesToSyncToServer) {
await syncToServer(diff.id);
}
}
/**
* Refreshes the sync status of all series by comparing local and server data.
* Updates the context with the latest sync information.
*/
async function refreshSeries(): Promise<void> {
try {
let localSeriesResponse: SyncedSeries[] = [];
let serverSeriesResponse: SyncedSeries[] = [];
if (!isCurrentlyOffline()) {
if (offlineMode.isDatabaseInitialized) {
localSeriesResponse = await window.electron.invoke<SyncedSeries[]>('db:series:synced');
// Get lastOnlineTimestamp from localStorage (or 0 if not set)
const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp');
const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0;
// Get local tombstones since lastOnlineTimestamp via IPC
const localTombstones: RemovedItemRecord[] = await window.electron.invoke<RemovedItemRecord[]>(
'db:tombstones:since',
lastOnlineTimestamp
);
// Call server with POST and tombstones
const serverResponse: SyncedSeriesResponse = await System.authPostToServer<SyncedSeriesResponse>(
'series/synced',
{ lastOnlineTimestamp, tombstones: localTombstones },
session.accessToken,
lang
);
serverSeriesResponse = serverResponse.series;
// Apply server tombstones locally via IPC
await window.electron.invoke<void>('db:tombstones:apply:series', serverResponse.tombstones);
} else {
// No local DB but online - just get server series without tombstones
const serverResponse: SyncedSeriesResponse = await System.authPostToServer<SyncedSeriesResponse>(
'series/synced',
{ lastOnlineTimestamp: 0, tombstones: [] },
session.accessToken,
lang
);
serverSeriesResponse = serverResponse.series;
}
} else {
if (offlineMode.isDatabaseInitialized) {
localSeriesResponse = await window.electron.invoke<SyncedSeries[]>('db:series:synced');
}
}
setServerSyncedSeries(serverSeriesResponse);
setLocalSyncedSeries(localSeriesResponse);
} catch (e: unknown) {
if (e instanceof Error) {
errorMessage(e.message);
} else {
errorMessage(t('seriesCard.refreshError'));
}
}
}
return {
upload,
download,
syncFromServer,
syncToServer,
syncAllToServer,
refreshSeries,
localOnlySeries,
serverOnlySeries,
seriesToSyncToServer,
seriesToSyncFromServer
};
}