'use client' import {faMapMarkerAlt, faPlus, faShare, faToggleOn, faTrash} from '@fortawesome/free-solid-svg-icons'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import React, {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react'; import {SessionContext} from "@/context/SessionContext"; import {AlertContext} from "@/context/AlertContext"; import {BookContext} from "@/context/BookContext"; import System from '@/lib/models/System'; import InputField from "@/components/form/InputField"; import TextInput from '@/components/form/TextInput'; import TexteAreaInput from "@/components/form/TexteAreaInput"; import {useTranslations} from "next-intl"; import {LangContext, LangContextProps} from "@/context/LangContext"; import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; import {LocalSyncQueueContext, LocalSyncQueueContextProps} from "@/context/SyncQueueContext"; import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext"; import {SyncedBook} from "@/lib/models/SyncedBook"; import {SeriesContext, SeriesContextProps} from "@/context/SeriesContext"; import {SeriesSyncContext, SeriesSyncContextProps} from "@/context/SeriesSyncContext"; import {SyncedSeries} from "@/lib/models/SyncedSeries"; import ToggleSwitch from "@/components/form/ToggleSwitch"; import {SeriesLocationElement, SeriesLocationItem, SeriesLocationSubElement} from "@/lib/models/Series"; import SeriesImportSelector from "@/components/form/SeriesImportSelector"; import * as tauri from '@/lib/tauri'; interface SubElement { id: string; name: string; description: string; } interface Element { id: string; name: string; description: string; subElements: SubElement[]; } interface LocationProps { id: string; name: string; elements: Element[]; seriesLocationId?: string | null; } interface LocationListResponse { locations: LocationProps[]; enabled: boolean; } interface LocationComponentProps { showToggle?: boolean; entityType?: 'book' | 'series'; entityId?: string; } export function LocationComponent(props: LocationComponentProps, ref: React.Ref<{ handleSave: () => Promise }>) { const {showToggle = true, entityType = 'book', entityId} = props; const t = useTranslations(); const {lang} = useContext(LangContext); const {isCurrentlyOffline} = useContext(OfflineContext); const {addToQueue} = useContext(LocalSyncQueueContext); const {localSyncedBooks} = useContext(BooksSyncContext); const {session} = useContext(SessionContext); const {successMessage, errorMessage} = useContext(AlertContext); const {book, setBook} = useContext(BookContext); const {seriesId, localSeries} = useContext(SeriesContext); const {localSyncedSeries} = useContext(SeriesSyncContext); const currentEntityId: string = entityId || book?.bookId || ''; const isSeriesMode: boolean = entityType === 'series'; const token: string = session.accessToken; const [sections, setSections] = useState([]); const [seriesLocations, setSeriesLocations] = useState([]); const [newSectionName, setNewSectionName] = useState(''); const [newElementNames, setNewElementNames] = useState<{ [key: string]: string }>({}); const [newSubElementNames, setNewSubElementNames] = useState<{ [key: string]: string }>({}); const [toolEnabled, setToolEnabled] = useState(isSeriesMode ? true : (book?.tools?.locations ?? false)); const bookSeriesId: string | null = book?.seriesId || null; useImperativeHandle(ref, function () { return { handleSave: handleSave, }; }); useEffect((): void => { if (currentEntityId) { getAllLocations().then(); } }, [currentEntityId]); useEffect((): void => { if (bookSeriesId && !isSeriesMode) { getSeriesLocations().then(); } }, [bookSeriesId]); async function getSeriesLocations(): Promise { if (!bookSeriesId) return; try { const response: SeriesLocationItem[] = await System.authGetQueryToServer( 'series/location/list', token, lang, {seriesid: bookSeriesId} ); if (response) { setSeriesLocations(response); } } catch (e: unknown) { if (e instanceof Error) { console.error('Error loading series locations:', e.message); } } } async function handleToggleTool(enabled: boolean): Promise { if (isSeriesMode) return; try { let response: boolean; if (isCurrentlyOffline() || book?.localBook) { response = await tauri.updateBookToolSetting(currentEntityId, 'locations', enabled); } else { response = await System.authPatchToServer('book/tool-setting', { bookId: currentEntityId, toolName: 'locations', enabled: enabled }, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { addToQueue('update_book_tool_setting', {data: { bookId: currentEntityId, toolName: 'locations', enabled: enabled }}); } } if (response && setBook && book) { setToolEnabled(enabled); setBook({ ...book, tools: { characters: book.tools?.characters ?? false, worlds: book.tools?.worlds ?? false, spells: book.tools?.spells ?? false, locations: enabled } }); } } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } } } async function getAllLocations(): Promise { try { if (isSeriesMode) { let response: SeriesLocationItem[]; if (isCurrentlyOffline() || localSeries) { response = await tauri.getSeriesLocationList(currentEntityId) as SeriesLocationItem[]; } else { response = await System.authGetQueryToServer( 'series/location/list', token, lang, {seriesid: currentEntityId} ); } if (response) { const mappedLocations: LocationProps[] = response.map((loc: SeriesLocationItem): LocationProps => ({ id: loc.id, name: loc.name, elements: loc.elements.map((elem: SeriesLocationElement) => ({ id: elem.id, name: elem.name, description: elem.description, subElements: elem.subElements.map((sub: SeriesLocationSubElement) => ({ id: sub.id, name: sub.name, description: sub.description, })), })), })); setSections(mappedLocations); } } else { let response: LocationListResponse; if (isCurrentlyOffline() || book?.localBook) { response = await tauri.getAllLocations(currentEntityId, true) as LocationListResponse; } else { response = await System.authGetQueryToServer(`location/all`, token, lang, { bookid: currentEntityId, }); } if (response) { setSections(response.locations); setToolEnabled(response.enabled); if (setBook && book) { setBook({ ...book, tools: { characters: book.tools?.characters ?? false, worlds: book.tools?.worlds ?? false, spells: book.tools?.spells ?? false, locations: response.enabled } }); } } } } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t('locationComponent.errorUnknownFetchLocations')); } } } async function handleAddSection(): Promise { if (!newSectionName.trim()) { errorMessage(t('locationComponent.errorSectionNameEmpty')) return } try { let sectionId: string; if (isSeriesMode) { const addData = { seriesId: currentEntityId, name: newSectionName, }; if (isCurrentlyOffline() || localSeries) { sectionId = await tauri.addSeriesLocationSection(addData); } else { sectionId = await System.authPostToServer( 'series/location/section/add', addData, token, lang ); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { addToQueue('add_series_location_section', {data: addData}); } } if (!sectionId) { errorMessage(t('locationComponent.errorUnknownAddSection')); return; } } else if (isCurrentlyOffline() || book?.localBook) { sectionId = await tauri.addLocationSection(newSectionName, currentEntityId); } else { sectionId = await System.authPostToServer(`location/section/add`, { bookId: currentEntityId, locationName: newSectionName, }, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { addToQueue('add_location_section', {data: { bookId: currentEntityId, sectionId, locationName: newSectionName, }}); } } if (!sectionId) { errorMessage(t('locationComponent.errorUnknownAddSection')); return; } const newLocation: LocationProps = { id: sectionId, name: newSectionName, elements: [], }; setSections([...sections, newLocation]); setNewSectionName(''); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t('locationComponent.errorUnknownAddSection')); } } } async function handleAddElement(sectionId: string): Promise { if (!newElementNames[sectionId]?.trim()) { errorMessage(t('locationComponent.errorElementNameEmpty')) return } try { let elementId: string; if (isSeriesMode) { const addData = { locationId: sectionId, name: newElementNames[sectionId], }; if (isCurrentlyOffline() || localSeries) { elementId = await tauri.addSeriesLocationElement(addData); } else { elementId = await System.authPostToServer( 'series/location/element/add', addData, token, lang ); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { addToQueue('add_series_location_element', {data: addData}); } } if (!elementId) { errorMessage(t('locationComponent.errorUnknownAddElement')); return; } } else if (isCurrentlyOffline() || book?.localBook) { elementId = await tauri.addLocationElement(sectionId, newElementNames[sectionId]); } else { elementId = await System.authPostToServer(`location/element/add`, { bookId: currentEntityId, locationId: sectionId, elementName: newElementNames[sectionId], }, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { addToQueue('add_location_element', {data: { bookId: currentEntityId, locationId: sectionId, elementId, elementName: newElementNames[sectionId], }}); } } if (!elementId) { errorMessage(t('locationComponent.errorUnknownAddElement')); return; } const updatedSections: LocationProps[] = [...sections]; const sectionIndex: number = updatedSections.findIndex( (section: LocationProps): boolean => section.id === sectionId, ); updatedSections[sectionIndex].elements.push({ id: elementId, name: newElementNames[sectionId], description: '', subElements: [], }); setSections(updatedSections); setNewElementNames({...newElementNames, [sectionId]: ''}); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t('locationComponent.errorUnknownAddElement')); } } } function handleElementChange( sectionId: string, elementIndex: number, field: keyof Element, value: string, ): void { const updatedSections: LocationProps[] = [...sections]; const sectionIndex: number = updatedSections.findIndex( (section: LocationProps): boolean => section.id === sectionId, ); // @ts-ignore updatedSections[sectionIndex].elements[elementIndex][field] = value; setSections(updatedSections); } async function handleAddSubElement( sectionId: string, elementIndex: number, ): Promise { if (!newSubElementNames[elementIndex]?.trim()) { errorMessage(t('locationComponent.errorSubElementNameEmpty')) return } const sectionIndex: number = sections.findIndex( (section: LocationProps): boolean => section.id === sectionId, ); try { let subElementId: string; const elementId = sections[sectionIndex].elements[elementIndex].id; if (isSeriesMode) { const addData = { elementId: elementId, name: newSubElementNames[elementIndex], }; if (isCurrentlyOffline() || localSeries) { subElementId = await tauri.addSeriesLocationSubElement(addData); } else { subElementId = await System.authPostToServer( 'series/location/sub-element/add', addData, token, lang ); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { addToQueue('add_series_location_sub_element', {data: addData}); } } if (!subElementId) { errorMessage(t('locationComponent.errorUnknownAddSubElement')); return; } } else if (isCurrentlyOffline() || book?.localBook) { subElementId = await tauri.addLocationSubElement(elementId, newSubElementNames[elementIndex]); } else { subElementId = await System.authPostToServer(`location/sub-element/add`, { elementId: elementId, subElementName: newSubElementNames[elementIndex], }, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { addToQueue('add_location_sub_element', {data: { elementId: elementId, subElementId, subElementName: newSubElementNames[elementIndex], }}); } } if (!subElementId) { errorMessage(t('locationComponent.errorUnknownAddSubElement')); return; } const updatedSections: LocationProps[] = [...sections]; updatedSections[sectionIndex].elements[elementIndex].subElements.push({ id: subElementId, name: newSubElementNames[elementIndex], description: '', }); setSections(updatedSections); setNewSubElementNames({...newSubElementNames, [elementIndex]: ''}); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t('locationComponent.errorUnknownAddSubElement')); } } } function handleSubElementChange( sectionId: string, elementIndex: number, subElementIndex: number, field: keyof SubElement, value: string, ): void { const updatedSections: LocationProps[] = [...sections]; const sectionIndex: number = updatedSections.findIndex( (section: LocationProps): boolean => section.id === sectionId, ); updatedSections[sectionIndex].elements[elementIndex].subElements[ subElementIndex ][field] = value; setSections(updatedSections); } async function handleRemoveElement( sectionId: string, elementIndex: number, ): Promise { try { let response: boolean; const elementId = sections.find((section: LocationProps): boolean => section.id === sectionId) ?.elements[elementIndex].id; const deletedAt: number = System.timeStampInSeconds(); if (isSeriesMode) { const deleteData = {elementId: elementId, deletedAt}; if (isCurrentlyOffline() || localSeries) { response = await tauri.deleteSeriesLocationElement(deleteData.elementId!, deleteData.deletedAt); } else { response = await System.authDeleteToServer('series/location/element/delete', deleteData, token, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { addToQueue('delete_series_location_element', {data: deleteData}); } } } else if (isCurrentlyOffline() || book?.localBook) { response = await tauri.deleteLocationElement(elementId!, currentEntityId, deletedAt); } else { response = await System.authDeleteToServer(`location/element/delete`, { elementId: elementId, bookId: currentEntityId, deletedAt, }, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { addToQueue('delete_location_element', {data: { elementId: elementId, bookId: currentEntityId, deletedAt, }}); } } if (!response) { errorMessage(t('locationComponent.errorUnknownDeleteElement')); return; } const updatedSections: LocationProps[] = [...sections]; const sectionIndex: number = updatedSections.findIndex((section: LocationProps): boolean => section.id === sectionId,); updatedSections[sectionIndex].elements.splice(elementIndex, 1); setSections(updatedSections); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t('locationComponent.errorUnknownDeleteElement')); } } } async function handleRemoveSubElement( sectionId: string, elementIndex: number, subElementIndex: number, ): Promise { try { let response: boolean; const subElementId = sections.find((section: LocationProps): boolean => section.id === sectionId)?.elements[elementIndex].subElements[subElementIndex].id; const deletedAt: number = System.timeStampInSeconds(); if (isSeriesMode) { const deleteData = {subElementId: subElementId, deletedAt}; if (isCurrentlyOffline() || localSeries) { response = await tauri.deleteSeriesLocationSubElement(deleteData.subElementId!, deleteData.deletedAt); } else { response = await System.authDeleteToServer('series/location/sub-element/delete', deleteData, token, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { addToQueue('delete_series_location_sub_element', {data: deleteData}); } } } else if (isCurrentlyOffline() || book?.localBook) { response = await tauri.deleteLocationSubElement(subElementId!, currentEntityId, deletedAt); } else { response = await System.authDeleteToServer(`location/sub-element/delete`, { subElementId: subElementId, bookId: currentEntityId, deletedAt, }, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { addToQueue('delete_location_sub_element', {data: { subElementId: subElementId, bookId: currentEntityId, deletedAt, }}); } } if (!response) { errorMessage(t('locationComponent.errorUnknownDeleteSubElement')); return; } const updatedSections: LocationProps[] = [...sections]; const sectionIndex: number = updatedSections.findIndex((section: LocationProps): boolean => section.id === sectionId,); updatedSections[sectionIndex].elements[elementIndex].subElements.splice(subElementIndex, 1,); setSections(updatedSections); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t('locationComponent.errorUnknownDeleteSubElement')); } } } async function handleRemoveSection(sectionId: string): Promise { try { let response: boolean; const deletedAt: number = System.timeStampInSeconds(); if (isSeriesMode) { const deleteData = {locationId: sectionId, deletedAt}; if (isCurrentlyOffline() || localSeries) { response = await tauri.deleteSeriesLocation(deleteData.locationId, deleteData.deletedAt); } else { response = await System.authDeleteToServer('series/location/delete', deleteData, token, lang); if (localSyncedSeries.find((s: SyncedSeries): boolean => s.id === seriesId)) { addToQueue('delete_series_location', {data: deleteData}); } } } else if (isCurrentlyOffline() || book?.localBook) { response = await tauri.deleteLocationSection(sectionId, currentEntityId, deletedAt); } else { response = await System.authDeleteToServer(`location/delete`, { locationId: sectionId, bookId: currentEntityId, deletedAt, }, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { addToQueue('delete_location_section', {data: { locationId: sectionId, bookId: currentEntityId, deletedAt, }}); } } if (!response) { errorMessage(t('locationComponent.errorUnknownDeleteSection')); return; } const updatedSections: LocationProps[] = sections.filter((section: LocationProps): boolean => section.id !== sectionId,); setSections(updatedSections); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t('locationComponent.errorUnknownDeleteSection')); } } } async function handleSave(): Promise { try { let response: boolean; if (isCurrentlyOffline() || book?.localBook) { response = await tauri.updateLocations(sections) as boolean; } else { response = await System.authPostToServer(`location/update`, { locations: sections, }, token, lang); if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === currentEntityId)) { addToQueue('update_locations', {data: { locations: sections, }}); } } 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')); } } } async function handleExportToSeries(section: LocationProps): Promise { if (!bookSeriesId) return; try { const seriesLocationId: string = await System.authPostToServer('series/location/section/add', { seriesId: bookSeriesId, name: section.name, }, token, lang); if (seriesLocationId) { const updateResponse: boolean = await System.authPostToServer('location/section/update', { sectionId: section.id, sectionName: section.name, seriesLocationId: seriesLocationId, }, token, lang); if (updateResponse) { setSections(sections.map((s: LocationProps): LocationProps => s.id === section.id ? {...s, seriesLocationId: seriesLocationId} : s )); await getSeriesLocations(); successMessage(t("locationComponent.exportSuccess")); } } } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } } } async function handleImportFromSeries(seriesLocationId: string): Promise { const seriesLocation: SeriesLocationItem | undefined = seriesLocations.find((location: SeriesLocationItem): boolean => location.id === seriesLocationId); if (!seriesLocation) return; try { const sectionId: string = await System.authPostToServer('location/section/add', { bookId: currentEntityId, locationName: seriesLocation.name, seriesLocationId: seriesLocationId, }, token, lang); if (!sectionId) { errorMessage(t('locationComponent.importError')); return; } const importedElements: Element[] = []; for (const seriesElement of seriesLocation.elements) { const elementId: string = await System.authPostToServer('location/element/add', { bookId: currentEntityId, locationId: sectionId, elementName: seriesElement.name, }, token, lang); if (!elementId) continue; const importedSubElements: SubElement[] = []; for (const seriesSubElement of seriesElement.subElements) { const subElementId: string = await System.authPostToServer('location/sub-element/add', { elementId: elementId, subElementName: seriesSubElement.name, }, token, lang); if (subElementId) { importedSubElements.push({ id: subElementId, name: seriesSubElement.name, description: seriesSubElement.description, }); } } importedElements.push({ id: elementId, name: seriesElement.name, description: seriesElement.description, subElements: importedSubElements, }); } const newLocation: LocationProps = { id: sectionId, name: seriesLocation.name, elements: importedElements, seriesLocationId: seriesLocationId, }; setSections([...sections, newLocation]); successMessage(t('locationComponent.importSuccess')); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } } } return (
{showToggle && !isSeriesMode && (
=> handleToggleTool(checked)} /> } />

{t('locationComponent.enableToolDescription')}

)} {(toolEnabled || isSeriesMode) && ( <> {!isSeriesMode && bookSeriesId && seriesLocations.filter((seriesLocation: SeriesLocationItem): boolean => !sections.some((section: LocationProps): boolean => section.seriesLocationId === seriesLocation.id)).length > 0 && ( !sections.some((section: LocationProps): boolean => section.seriesLocationId === seriesLocation.id)) .map((seriesLocation: SeriesLocationItem) => ({id: seriesLocation.id, name: seriesLocation.name}))} onImport={handleImportFromSeries} placeholder={t("seriesImport.selectElement")} label={t("seriesImport.importFromSeries")} /> )}
) => setNewSectionName(e.target.value)} placeholder={t("locationComponent.newSectionPlaceholder")} /> } actionIcon={faPlus} actionLabel={t("locationComponent.addSectionLabel")} addButtonCallBack={handleAddSection} />
{sections.length > 0 ? ( sections.map((section: LocationProps) => (

{section.name} {section.elements.length || 0}
{!isSeriesMode && bookSeriesId && !section.seriesLocationId && ( )}

{section.elements.length > 0 ? ( section.elements.map((element, elementIndex) => (
) => handleElementChange(section.id, elementIndex, 'name', e.target.value) } placeholder={t("locationComponent.elementNamePlaceholder")} /> } removeButtonCallBack={(): Promise => handleRemoveElement(section.id, elementIndex)} />
): void => handleElementChange(section.id, elementIndex, 'description', e.target.value)} placeholder={t("locationComponent.elementDescriptionPlaceholder")} />
{element.subElements.length > 0 && (

{t("locationComponent.subElementsHeading")}

)} {element.subElements.map((subElement: SubElement, subElementIndex: number) => (
): void => handleSubElementChange(section.id, elementIndex, subElementIndex, 'name', e.target.value) } placeholder={t("locationComponent.subElementNamePlaceholder")} /> } removeButtonCallBack={(): Promise => handleRemoveSubElement(section.id, elementIndex, subElementIndex)} />
handleSubElementChange(section.id, elementIndex, subElementIndex, 'description', e.target.value) } placeholder={t("locationComponent.subElementDescriptionPlaceholder")} />
))} ) => setNewSubElementNames({ ...newSubElementNames, [elementIndex]: e.target.value }) } placeholder={t("locationComponent.newSubElementPlaceholder")} /> } addButtonCallBack={(): Promise => handleAddSubElement(section.id, elementIndex)} />
)) ) : (
{t("locationComponent.noElementAvailable")}
)} ) => setNewElementNames({...newElementNames, [section.id]: e.target.value}) } placeholder={t("locationComponent.newElementPlaceholder")} /> } addButtonCallBack={(): Promise => handleAddElement(section.id)} />
)) ) : (

{t("locationComponent.noSectionAvailable")}

)} )}
); } export default forwardRef(LocationComponent);