'use client' import {MapPin, Plus, Share2, ToggleRight, Trash2} from 'lucide-react'; import React, {ChangeEvent, forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react'; import {SessionContext, SessionContextProps} from "@/context/SessionContext"; import {AlertContext, AlertContextProps} from "@/context/AlertContext"; import {BookContext, BookContextProps} from "@/context/BookContext"; import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; import {BooksSyncContext, BooksSyncContextProps} from '@/context/BooksSyncContext'; import {SyncedBook} from '@/lib/types/synced-book'; import {LocalSyncQueueContext, LocalSyncQueueContextProps} from '@/context/SyncQueueContext'; import {isDesktop} from '@/lib/configs'; import {apiDelete, apiGet, apiPatch, apiPost} from '@/lib/api/client'; import * as tauri from '@/lib/tauri'; import InputField from "@/components/form/InputField"; import TextInput from '@/components/form/TextInput'; import TextAreaInput from "@/components/form/TextAreaInput"; import {useTranslations} from '@/lib/i18n'; import {LangContext, LangContextProps} from "@/context/LangContext"; import ToggleSwitch from "@/components/form/ToggleSwitch"; import {SeriesLocationElement, SeriesLocationItem, SeriesLocationSubElement} from "@/lib/types/series"; import SeriesImportSelector from "@/components/form/SeriesImportSelector"; import IconButton from "@/components/ui/IconButton"; 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}: LangContextProps = useContext(LangContext); const {session}: SessionContextProps = useContext(SessionContext); const {successMessage, errorMessage}: AlertContextProps = useContext(AlertContext); const {book, setBook}: BookContextProps = useContext(BookContext); const {isCurrentlyOffline} = useContext(OfflineContext); const {addToQueue}: LocalSyncQueueContextProps = useContext(LocalSyncQueueContext); const {localSyncedBooks}: BooksSyncContextProps = useContext(BooksSyncContext); const currentEntityId: string = entityId || book?.bookId || ''; const useLocal: boolean = isDesktop && (isCurrentlyOffline() || !!book?.localBook); 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[] = useLocal ? await tauri.getSeriesLocationList(bookSeriesId) as SeriesLocationItem[] : await apiGet( 'series/location/list', token, lang, {seriesid: bookSeriesId} ); if (response) { setSeriesLocations(response); } } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } } } async function handleToggleTool(enabled: boolean): Promise { if (isSeriesMode) return; try { const response: boolean = useLocal ? await tauri.updateBookToolSetting(currentEntityId, 'locations', enabled) : await apiPatch('book/tool-setting', { bookId: currentEntityId, toolName: 'locations', enabled: enabled }, token, lang); if (!useLocal && isDesktop && localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === currentEntityId)) { addToQueue('update_book_tool_setting', {bookId: currentEntityId, toolName: 'locations', enabled}); } 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); } } } async function getAllLocations(): Promise { try { if (isSeriesMode) { const response: SeriesLocationItem[] = useLocal ? await tauri.getSeriesLocationList(currentEntityId) as SeriesLocationItem[] : await apiGet( '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 { const response: LocationListResponse = useLocal ? await tauri.getAllLocations(currentEntityId, true) as LocationListResponse : await apiGet( '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, locations: response.enabled, spells: book.tools?.spells ?? false } }); } } } } 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) { sectionId = useLocal ? await tauri.addSeriesLocationSection({seriesId: currentEntityId, name: newSectionName}) : await apiPost('series/location/section/add', {seriesId: currentEntityId, name: newSectionName}, token, lang); if (!sectionId) { errorMessage(t('locationComponent.errorUnknownAddSection')); return; } } else { sectionId = useLocal ? await tauri.addLocationSection(newSectionName, currentEntityId) : await apiPost('location/section/add', {bookId: currentEntityId, locationName: newSectionName}, token, lang); if (!useLocal && isDesktop && localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === currentEntityId)) { addToQueue('add_location_section', {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) { elementId = useLocal ? await tauri.addSeriesLocationElement({locationId: sectionId, name: newElementNames[sectionId]}) : await apiPost('series/location/element/add', {locationId: sectionId, name: newElementNames[sectionId]}, token, lang); if (!elementId) { errorMessage(t('locationComponent.errorUnknownAddElement')); return; } } else { elementId = useLocal ? await tauri.addLocationElement(sectionId, newElementNames[sectionId]) : await apiPost('location/element/add', {bookId: currentEntityId, locationId: sectionId, elementName: newElementNames[sectionId]}, token, lang); if (!useLocal && isDesktop && localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === currentEntityId)) { addToQueue('add_location_element', {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 parentElementId: string = sections[sectionIndex].elements[elementIndex].id; if (isSeriesMode) { subElementId = useLocal ? await tauri.addSeriesLocationSubElement({elementId: parentElementId, name: newSubElementNames[elementIndex]}) : await apiPost('series/location/sub-element/add', {elementId: parentElementId, name: newSubElementNames[elementIndex]}, token, lang); if (!subElementId) { errorMessage(t('locationComponent.errorUnknownAddSubElement')); return; } } else { subElementId = useLocal ? await tauri.addLocationSubElement(parentElementId, newSubElementNames[elementIndex]) : await apiPost('location/sub-element/add', {elementId: parentElementId, subElementName: newSubElementNames[elementIndex]}, token, lang); if (!useLocal && isDesktop && localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === currentEntityId)) { addToQueue('add_location_subelement', {elementId: parentElementId, 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 { const elementId: string | undefined = sections.find((section: LocationProps): boolean => section.id === sectionId) ?.elements[elementIndex].id; const deletedAt: number = Math.floor(Date.now() / 1000); let success: boolean; if (isSeriesMode) { success = useLocal ? await tauri.deleteSeriesLocationElement(elementId!, deletedAt) : await apiDelete('series/location/element/delete', {elementId: elementId}, token, lang); } else { success = useLocal ? await tauri.deleteLocationElement(elementId!, currentEntityId, deletedAt) : await apiDelete('location/element/delete', {elementId: elementId}, token, lang); if (!useLocal && isDesktop && localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === currentEntityId)) { addToQueue('delete_location_element', {elementId, bookId: currentEntityId, deletedAt}); } } if (!success) { 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 { const subElementId: string | undefined = sections.find((section: LocationProps): boolean => section.id === sectionId) ?.elements[elementIndex].subElements[subElementIndex].id; const deletedAt: number = Math.floor(Date.now() / 1000); let success: boolean; if (isSeriesMode) { success = useLocal ? await tauri.deleteSeriesLocationSubElement(subElementId!, deletedAt) : await apiDelete('series/location/sub-element/delete', {subElementId: subElementId}, token, lang); } else { success = useLocal ? await tauri.deleteLocationSubElement(subElementId!, currentEntityId, deletedAt) : await apiDelete('location/sub-element/delete', {subElementId: subElementId}, token, lang); if (!useLocal && isDesktop && localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === currentEntityId)) { addToQueue('delete_location_subelement', {subElementId, bookId: currentEntityId, deletedAt}); } } if (!success) { 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 { const deletedAt: number = Math.floor(Date.now() / 1000); let success: boolean; if (isSeriesMode) { success = useLocal ? await tauri.deleteSeriesLocation(sectionId, deletedAt) : await apiDelete('series/location/delete', {locationId: sectionId}, token, lang); } else { success = useLocal ? await tauri.deleteLocationSection(sectionId, currentEntityId, deletedAt) : await apiDelete('location/delete', {locationId: sectionId}, token, lang); if (!useLocal && isDesktop && localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === currentEntityId)) { addToQueue('delete_location', {locationId: sectionId, bookId: currentEntityId, deletedAt}); } } if (!success) { 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 { const response: boolean = useLocal ? await tauri.updateLocations(sections) as boolean : await apiPost(`location/update`, {locations: sections}, token, lang); if (!useLocal && isDesktop && localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === currentEntityId)) { addToQueue('update_locations', {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 = useLocal ? await tauri.addSeriesLocationSection({seriesId: bookSeriesId, name: section.name}) : await apiPost('series/location/section/add', {seriesId: bookSeriesId, name: section.name}, token, lang); if (seriesLocationId) { const updateResponse: boolean = useLocal ? await tauri.updateLocationSectionWithSeriesLink(section.id, section.name, seriesLocationId) : await apiPost('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 = useLocal ? await tauri.addLocationSection(seriesLocation.name, currentEntityId, undefined, seriesLocationId) : await apiPost('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 = useLocal ? await tauri.addLocationElement(sectionId, seriesElement.name) : await apiPost('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 = useLocal ? await tauri.addLocationSubElement(elementId, seriesSubElement.name) : await apiPost('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={Plus} actionLabel={t("locationComponent.addSectionLabel")} addButtonCallBack={handleAddSection} />
{sections.length > 0 ? ( sections.map((section: LocationProps) => (

{section.name} {section.elements.length || 0}
{!isSeriesMode && bookSeriesId && !section.seriesLocationId && ( => handleExportToSeries(section)}/> )} => handleRemoveSection(section.id)}/>

{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);