'use client' import {useCallback, useContext, useEffect, useState} from 'react'; import {SeriesLocationElement, SeriesLocationItem, SeriesLocationSubElement} from '@/lib/types/series'; import {SessionContext, SessionContextProps} from '@/context/SessionContext'; import {BookContext, BookContextProps} from '@/context/BookContext'; import {AlertContext, AlertContextProps} from '@/context/AlertContext'; import {LangContext, LangContextProps} from '@/context/LangContext'; import {apiDelete, apiGet, apiPatch, apiPost} from '@/lib/api/client'; import {useTranslations} from '@/lib/i18n'; import {ViewMode} from '@/lib/types/settings'; import {isDesktop} from '@/lib/configs'; import * as tauri from '@/lib/tauri'; import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; 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; addElement: (sectionId: string) => Promise; addSubElement: (sectionId: string, elementIndex: number) => Promise; removeSection: (sectionId: string) => Promise; removeElement: (sectionId: string, elementIndex: number) => Promise; removeSubElement: (sectionId: string, elementIndex: number, subElementIndex: number) => Promise; 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; toggleTool: (enabled: boolean) => Promise; importFromSeries: (seriesLocationId: string) => Promise; exportToSeries: (section: LocationProps) => Promise; refreshLocations: () => Promise; refreshSeriesLocations: () => Promise; setNewSectionName: (name: string) => void; setNewElementNames: React.Dispatch>; setNewSubElementNames: React.Dispatch>; // Navigation actions enterDetailMode: (sectionIndex: number) => void; enterEditMode: () => void; exitEditMode: (save: boolean) => Promise; backToList: () => void; } export function useLocations(config: UseLocationsConfig): UseLocationsReturn { const {entityType, entityId} = config; const t = useTranslations(); const {lang}: LangContextProps = useContext(LangContext); const {session}: SessionContextProps = useContext(SessionContext); const {book, setBook}: BookContextProps = useContext(BookContext); const {errorMessage, successMessage}: AlertContextProps = useContext(AlertContext); const {isCurrentlyOffline}: OfflineContextType = useContext(OfflineContext); const [sections, setSections] = useState([]); const [seriesLocations, setSeriesLocations] = useState([]); const [toolEnabled, setToolEnabled] = useState(entityType === 'series' || (book?.tools?.locations ?? false)); const [isLoading, setIsLoading] = useState(true); const [newSectionName, setNewSectionName] = useState(''); const [newElementNames, setNewElementNames] = useState<{ [key: string]: string }>({}); const [newSubElementNames, setNewSubElementNames] = useState<{ [key: string]: string }>({}); // Navigation state const [viewMode, setViewMode] = useState('list'); const [selectedSectionIndex, setSelectedSectionIndex] = useState(-1); const [sectionsBackup, setSectionsBackup] = useState(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 { if (!bookSeriesId) return; try { const response: SeriesLocationItem[] = await apiGet( 'series/location/list', userToken, lang, {seriesid: bookSeriesId} ); if (response) { setSeriesLocations(response); } } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } } }, [bookSeriesId, userToken, lang]); const refreshLocations = useCallback(async function (): Promise { setIsLoading(true); try { if (isSeriesMode) { const response: SeriesLocationItem[] = await apiGet( '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 (isDesktop && (isCurrentlyOffline() || book?.localBook)) { response = await tauri.getAllLocations(entityId, book?.tools?.locations ?? false) as LocationListResponse; } else { response = await apiGet('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]); const toggleTool = useCallback(async function (enabled: boolean): Promise { if (isSeriesMode) return; try { let response: boolean; if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { response = await tauri.updateBookToolSetting(book?.bookId ?? '', 'locations', enabled); } else { response = await apiPatch('book/tool-setting', { bookId: book?.bookId, toolName: 'locations', enabled: enabled }, userToken, lang); } 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]); const addSection = useCallback(async function (): Promise { if (!newSectionName.trim()) { errorMessage(t('locationComponent.errorSectionNameEmpty')); return; } try { let sectionId: string; if (isSeriesMode) { sectionId = await apiPost( 'series/location/section/add', { seriesId: entityId, name: newSectionName, }, userToken, lang ); if (!sectionId) { errorMessage(t('locationComponent.errorUnknownAddSection')); return; } } else if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { sectionId = await tauri.addLocationSection(newSectionName, entityId); if (!sectionId) { errorMessage(t('locationComponent.errorUnknownAddSection')); return; } } else { sectionId = await apiPost('location/section/add', { bookId: entityId, locationName: newSectionName, }, userToken, lang); 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')); } } }, [newSectionName, isSeriesMode, entityId, userToken, lang, errorMessage, t]); const addElement = useCallback(async function (sectionId: string): Promise { if (!newElementNames[sectionId]?.trim()) { errorMessage(t('locationComponent.errorElementNameEmpty')); return; } try { let elementId: string; if (isSeriesMode) { elementId = await apiPost( 'series/location/element/add', { locationId: sectionId, name: newElementNames[sectionId], }, userToken, lang ); if (!elementId) { errorMessage(t('locationComponent.errorUnknownAddElement')); return; } } else if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { elementId = await tauri.addLocationElement(sectionId, newElementNames[sectionId]); if (!elementId) { errorMessage(t('locationComponent.errorUnknownAddElement')); return; } } else { elementId = await apiPost('location/element/add', { bookId: entityId, locationId: sectionId, elementName: newElementNames[sectionId], }, userToken, lang); if (!elementId) { errorMessage(t('locationComponent.errorUnknownAddElement')); return; } } setSections(function (prev: LocationProps[]): LocationProps[] { return prev.map(function (section: LocationProps): LocationProps { if (section.id !== sectionId) return section; return { ...section, elements: [...section.elements, { id: elementId, name: newElementNames[sectionId], description: '', subElements: [], }], }; }); }); 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')); } } }, [newElementNames, isSeriesMode, entityId, userToken, lang, errorMessage, t]); const addSubElement = useCallback(async function (sectionId: string, elementIndex: number): Promise { if (!newSubElementNames[elementIndex]?.trim()) { errorMessage(t('locationComponent.errorSubElementNameEmpty')); return; } const sectionIndex: number = sections.findIndex(function (section: LocationProps): boolean { return section.id === sectionId; }); try { let subElementId: string; if (isSeriesMode) { subElementId = await apiPost( 'series/location/sub-element/add', { elementId: sections[sectionIndex].elements[elementIndex].id, name: newSubElementNames[elementIndex], }, userToken, lang ); if (!subElementId) { errorMessage(t('locationComponent.errorUnknownAddSubElement')); return; } } else if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { subElementId = await tauri.addLocationSubElement(sections[sectionIndex].elements[elementIndex].id, newSubElementNames[elementIndex]); if (!subElementId) { errorMessage(t('locationComponent.errorUnknownAddSubElement')); return; } } else { subElementId = await apiPost('location/sub-element/add', { elementId: sections[sectionIndex].elements[elementIndex].id, subElementName: newSubElementNames[elementIndex], }, userToken, lang); if (!subElementId) { errorMessage(t('locationComponent.errorUnknownAddSubElement')); return; } } setSections(function (prev: LocationProps[]): LocationProps[] { return prev.map(function (section: LocationProps, i: number): LocationProps { if (i !== sectionIndex) return section; return { ...section, elements: section.elements.map(function (el, j: number) { if (j !== elementIndex) return el; return { ...el, subElements: [...el.subElements, { id: subElementId, name: newSubElementNames[elementIndex], description: '', }], }; }), }; }); }); 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')); } } }, [sections, newSubElementNames, isSeriesMode, userToken, lang, errorMessage, t]); const removeSection = useCallback(async function (sectionId: string): Promise { try { let success: boolean; if (isSeriesMode) { success = await apiDelete('series/location/delete', { locationId: sectionId }, userToken, lang); } else if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { success = await tauri.deleteLocationSection(sectionId, book?.bookId ?? '', Date.now()); } else { success = await apiDelete('location/delete', { locationId: sectionId, }, userToken, lang); } 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]); const removeElement = useCallback(async function (sectionId: string, elementIndex: number): Promise { try { const elementId: string | undefined = sections.find(function (section: LocationProps): boolean { return section.id === sectionId; })?.elements[elementIndex].id; let success: boolean; if (isSeriesMode) { success = await apiDelete('series/location/element/delete', { elementId: elementId }, userToken, lang); } else if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { success = await tauri.deleteLocationElement(elementId ?? '', book?.bookId ?? '', Date.now()); } else { success = await apiDelete('location/element/delete', { elementId: elementId, }, userToken, lang); } 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]); const removeSubElement = useCallback(async function (sectionId: string, elementIndex: number, subElementIndex: number): Promise { 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) { success = await apiDelete('series/location/sub-element/delete', { subElementId: subElementId }, userToken, lang); } else if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { success = await tauri.deleteLocationSubElement(subElementId ?? '', book?.bookId ?? '', Date.now()); } else { success = await apiDelete('location/sub-element/delete', { subElementId: subElementId, }, userToken, lang); } 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]); 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 { try { let response: boolean; if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { response = await tauri.updateLocations(sections) as boolean; } else { response = await apiPost('location/update', { locations: sections, }, userToken, lang); } if (!response) { errorMessage(t('locationComponent.errorUnknownSave')); return; } successMessage(t('locationComponent.successSave')); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t('locationComponent.errorUnknownSave')); } } }, [sections, userToken, lang, errorMessage, successMessage, t]); const exportToSeries = useCallback(async function (section: LocationProps): Promise { if (!bookSeriesId) return; try { const seriesLocationId: string = await apiPost('series/location/section/add', { seriesId: bookSeriesId, name: section.name, }, userToken, lang); if (seriesLocationId) { const updateResponse: boolean = await apiPost('location/section/update', { sectionId: section.id, sectionName: section.name, seriesLocationId: seriesLocationId, }, userToken, lang); 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]); const importFromSeries = useCallback(async function (seriesLocationId: string): Promise { const seriesLocation: SeriesLocationItem | undefined = seriesLocations.find(function (location: SeriesLocationItem): boolean { return location.id === seriesLocationId; }); if (!seriesLocation) return; try { let sectionId: string; if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { sectionId = await tauri.addLocationSection(seriesLocation.name, entityId, undefined, seriesLocationId); } else { sectionId = await apiPost('location/section/add', { bookId: entityId, locationName: seriesLocation.name, seriesLocationId: seriesLocationId, }, userToken, lang); } if (!sectionId) { errorMessage(t('locationComponent.importError')); return; } const importedElements: Element[] = []; for (const seriesElement of seriesLocation.elements) { let elementId: string; if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { elementId = await tauri.addLocationElement(sectionId, seriesElement.name); } else { elementId = await apiPost('location/element/add', { bookId: entityId, locationId: sectionId, elementName: seriesElement.name, }, userToken, lang); } if (!elementId) continue; const importedSubElements: SubElement[] = []; for (const seriesSubElement of seriesElement.subElements) { let subElementId: string; if (isDesktop && (isCurrentlyOffline() || book?.localBook)) { subElementId = await tauri.addLocationSubElement(elementId, seriesSubElement.name); } else { subElementId = await apiPost('location/sub-element/add', { elementId: elementId, subElementName: seriesSubElement.name, }, userToken, lang); } if (subElementId) { importedSubElements.push({ id: subElementId, name: seriesSubElement.name, description: seriesSubElement.description, }); } } importedElements.push({ id: elementId, name: seriesElement.name, description: seriesElement.description, subElements: importedSubElements, }); } const newLocation: LocationProps = { id: sectionId, name: seriesLocation.name, elements: importedElements, seriesLocationId: seriesLocationId, }; setSections(function (prev: LocationProps[]): LocationProps[] { return [...prev, newLocation]; }); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } } }, [seriesLocations, entityId, userToken, lang, errorMessage, successMessage, t]); // 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 { if (save) { await saveLocations(); 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, }; }