diff --git a/app/page.tsx b/app/page.tsx index ec71dd3..ff5f7f6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -670,7 +670,7 @@ export default function Scribe() { return ( - + diff --git a/components/book/settings/locations/LocationComponent.tsx b/components/book/settings/locations/LocationComponent.tsx index 0909719..2251eac 100644 --- a/components/book/settings/locations/LocationComponent.tsx +++ b/components/book/settings/locations/LocationComponent.tsx @@ -98,6 +98,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r setBook({...book, tools: { characters: book.tools?.characters ?? false, worlds: book.tools?.worlds ?? false, + spells: book.tools?.spells ?? false, locations: enabled }}); } @@ -129,6 +130,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r setBook({...book, tools: { characters: book.tools?.characters ?? false, worlds: book.tools?.worlds ?? false, + spells: book.tools?.spells ?? false, locations: response.enabled }}); } diff --git a/components/spells/SpellComponent.tsx b/components/spells/SpellComponent.tsx new file mode 100644 index 0000000..58265ce --- /dev/null +++ b/components/spells/SpellComponent.tsx @@ -0,0 +1,563 @@ +'use client'; +import React, {forwardRef, useContext, useEffect, useImperativeHandle, useState} from 'react'; +import { + initialSpellState, + SpellEditState, + SpellListItem, + SpellListResponse, + SpellProps, + SpellPropsPost, + SpellTagProps +} from "@/lib/models/Spell"; +import {SessionContext} from "@/context/SessionContext"; +import {AlertContext} from "@/context/AlertContext"; +import {BookContext} from "@/context/BookContext"; +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 System from '@/lib/models/System'; +import {useTranslations} from "next-intl"; +import ToggleSwitch from "@/components/form/ToggleSwitch"; +import InputField from "@/components/form/InputField"; +import {faToggleOn} from "@fortawesome/free-solid-svg-icons"; +import SpellList from "@/components/book/settings/spells/SpellList"; +import SpellDetail from "@/components/book/settings/spells/SpellDetail"; +import SpellTagManager from "@/components/book/settings/spells/SpellTagManager"; + +interface SpellComponentProps { + showToggle?: boolean; +} + +export function SpellComponent(props: SpellComponentProps, ref: React.Ref<{ handleSave: () => Promise }>) { + const {showToggle = true} = 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 {book, setBook} = useContext(BookContext); + const {errorMessage, successMessage} = useContext(AlertContext); + + const bookId: string | undefined = book?.bookId; + const token: string = session.accessToken; + + const [spells, setSpells] = useState([]); + const [tags, setTags] = useState([]); + const [selectedSpell, setSelectedSpell] = useState(null); + const [toolEnabled, setToolEnabled] = useState(book?.tools?.spells ?? false); + const [showTagManager, setShowTagManager] = useState(false); + + useImperativeHandle(ref, function () { + return { + handleSave: handleSaveSpell, + }; + }); + + useEffect((): void => { + getSpells().then(); + }, []); + + async function handleToggleTool(enabled: boolean): Promise { + try { + let response: boolean; + if (isCurrentlyOffline() || book?.localBook) { + response = await window.electron.invoke('db:book:tool:update', { + bookId: bookId, + toolName: 'spells', + enabled: enabled + }); + } else { + response = await System.authPatchToServer('book/tool-setting', { + bookId: bookId, + toolName: 'spells', + enabled: enabled + }, token, lang); + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { + addToQueue('db:book:tool:update', { + bookId: bookId, + toolName: 'spells', + enabled: enabled + }); + } + } + 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); + } + } + } + + async function getSpells(): Promise { + try { + let response: SpellListResponse; + if (isCurrentlyOffline()) { + response = await window.electron.invoke('db:spell:list', {bookid: bookId}); + } else { + if (book?.localBook) { + response = await window.electron.invoke('db:spell:list', {bookid: bookId}); + } else { + response = await System.authGetQueryToServer('spell/list', token, lang, { + bookid: bookId, + }); + } + } + if (response) { + setSpells(response.spells); + 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")); + } + } + } + + async function handleSpellClick(spell: SpellListItem): Promise { + // Convertir les tags de SpellTagProps[] vers string[] (IDs) + const tagIds: string[] = spell.tags.map((tag: SpellTagProps): string => tag.id); + + // D'abord afficher avec les données de la liste + setSelectedSpell({ + id: spell.id, + name: spell.name, + description: spell.description, + appearance: '', + tags: tagIds, + powerLevel: null, + components: null, + limitations: null, + notes: null, + }); + try { + let response: SpellProps; + if (isCurrentlyOffline()) { + response = await window.electron.invoke('db:spell:detail', {spellid: spell.id}); + } else { + if (book?.localBook) { + response = await window.electron.invoke('db:spell:detail', {spellid: spell.id}); + } else { + response = await System.authGetQueryToServer('spell/detail', token, lang, { + spellid: spell.id, + }); + } + } + if (response) { + setSelectedSpell((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, + // Garder les tags de la liste, pas ceux de l'API + }; + }); + } + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + } + } + + function handleAddSpell(): void { + setSelectedSpell({...initialSpellState}); + } + + function handleSpellChange(key: keyof SpellEditState, value: string | string[] | null): void { + if (selectedSpell) { + setSelectedSpell({...selectedSpell, [key]: value}); + } + } + + async function handleSaveSpell(): Promise { + if (selectedSpell) { + if (selectedSpell.id === null) { + await addNewSpell(selectedSpell); + } else { + await updateSpell(selectedSpell); + } + } + } + + async function addNewSpell(spell: SpellEditState): Promise { + if (!spell.name) { + errorMessage(t("spellComponent.errorNameRequired")); + return; + } + if (!spell.description) { + errorMessage(t("spellComponent.errorDescriptionRequired")); + return; + } + if (!spell.appearance) { + errorMessage(t("spellComponent.errorAppearanceRequired")); + return; + } + try { + const spellPost: SpellPropsPost = { + name: spell.name, + description: spell.description, + appearance: spell.appearance, + tags: spell.tags, + powerLevel: spell.powerLevel, + components: spell.components, + limitations: spell.limitations, + notes: spell.notes, + }; + let spellId: string; + if (isCurrentlyOffline() || book?.localBook) { + spellId = await window.electron.invoke('db:spell:create', { + bookId: bookId, + spell: spellPost, + }); + } else { + const createdSpell: SpellProps = await System.authPostToServer('spell/add', { + bookId: bookId, + spell: spellPost, + }, token, lang); + if (!createdSpell || !createdSpell.id) { + errorMessage(t("spellComponent.errorAddSpell")); + return; + } + spellId = createdSpell.id; + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { + addToQueue('db:spell:create', { + bookId: bookId, + spell: {...spellPost, id: spellId}, + }); + } + } + if (!spellId) { + errorMessage(t("spellComponent.errorAddSpell")); + return; + } + // Ajouter à la liste avec les tags résolus + const resolvedTags: SpellTagProps[] = tags.filter((tag: SpellTagProps) => spell.tags.includes(tag.id)); + const newSpellListItem: SpellListItem = { + id: spellId, + name: spell.name, + description: spell.description.length > 150 + ? spell.description.substring(0, 150) + '...' + : spell.description, + tags: resolvedTags, + }; + setSpells([...spells, newSpellListItem]); + setSelectedSpell(null); + successMessage(t("spellComponent.successAdd")); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("common.unknownError")); + } + } + } + + async function updateSpell(spell: SpellEditState): Promise { + if (!spell.id) return; + if (!spell.name) { + errorMessage(t("spellComponent.errorNameRequired")); + return; + } + if (!spell.description) { + errorMessage(t("spellComponent.errorDescriptionRequired")); + return; + } + if (!spell.appearance) { + errorMessage(t("spellComponent.errorAppearanceRequired")); + return; + } + try { + const spellPost: SpellPropsPost = { + name: spell.name, + description: spell.description, + appearance: spell.appearance, + tags: spell.tags, + powerLevel: spell.powerLevel, + components: spell.components, + limitations: spell.limitations, + notes: spell.notes, + }; + let response: boolean; + if (isCurrentlyOffline() || book?.localBook) { + response = await window.electron.invoke('db:spell:update', { + spellId: spell.id, + spell: spellPost, + }); + } else { + response = await System.authPutToServer('spell/update', { + spellId: spell.id, + spell: spellPost, + }, token, lang); + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { + addToQueue('db:spell:update', { + spellId: spell.id, + spell: spellPost, + }); + } + } + if (!response) { + errorMessage(t("spellComponent.errorUpdateSpell")); + return; + } + // Mettre à jour la liste avec les tags résolus + const resolvedTags: SpellTagProps[] = tags.filter((tag: SpellTagProps) => spell.tags.includes(tag.id)); + setSpells(spells.map((s: SpellListItem): SpellListItem => + s.id === spell.id ? { + id: spell.id, + name: spell.name, + description: spell.description.length > 150 + ? spell.description.substring(0, 150) + '...' + : spell.description, + tags: resolvedTags, + } : s + )); + setSelectedSpell(null); + successMessage(t("spellComponent.successUpdate")); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("common.unknownError")); + } + } + } + + async function handleDeleteSpell(spellId: string): Promise { + try { + let response: boolean; + if (isCurrentlyOffline() || book?.localBook) { + response = await window.electron.invoke('db:spell:delete', { + spellId: spellId, + }); + } else { + response = await System.authDeleteToServer('spell/delete', { + spellId: spellId, + }, token, lang); + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { + addToQueue('db:spell:delete', { + spellId: spellId, + }); + } + } + if (!response) { + errorMessage(t("spellComponent.errorDeleteSpell")); + return; + } + setSpells(spells.filter((s: SpellListItem) => s.id !== spellId)); + setSelectedSpell(null); + successMessage(t("spellComponent.successDelete")); + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } else { + errorMessage(t("common.unknownError")); + } + } + } + + function handleManageTags(): void { + setShowTagManager(true); + } + + function handleBackFromTagManager(): void { + setShowTagManager(false); + } + + async function handleCreateTag(name: string, color: string): Promise { + try { + let tagId: string; + if (isCurrentlyOffline() || book?.localBook) { + tagId = await window.electron.invoke('db:spell:tag:create', { + bookId: bookId, + name: name, + color: color, + }); + } else { + const newTag: SpellTagProps = await System.authPostToServer('spell/tag/add', { + bookId: bookId, + name: name, + color: color, + }, token, lang); + if (!newTag || !newTag.id) { + return null; + } + tagId = newTag.id; + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { + addToQueue('db:spell:tag:create', { + bookId: bookId, + name: name, + color: color, + tagId: tagId, + }); + } + } + if (tagId) { + const createdTag: SpellTagProps = {id: tagId, name: name, color: color}; + setTags([...tags, createdTag]); + return createdTag; + } + return null; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + return null; + } + } + + async function handleUpdateTag(tagId: string, name: string, color: string): Promise { + try { + let response: boolean; + if (isCurrentlyOffline() || book?.localBook) { + response = await window.electron.invoke('db:spell:tag:update', { + tagId: tagId, + name: name, + color: color, + }); + } else { + response = await System.authPutToServer('spell/tag/update', { + tagId: tagId, + name: name, + color: color, + }, token, lang); + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { + addToQueue('db:spell:tag:update', { + tagId: tagId, + name: name, + color: color, + }); + } + } + if (response) { + setTags(tags.map((tag: SpellTagProps): SpellTagProps => + tag.id === tagId ? {id: tagId, name: name, color: color} : tag + )); + return true; + } + return false; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + return false; + } + } + + async function handleDeleteTag(tagId: string): Promise { + try { + let response: boolean; + if (isCurrentlyOffline() || book?.localBook) { + response = await window.electron.invoke('db:spell:tag:delete', { + tagId: tagId, + bookId: bookId, + }); + } else { + response = await System.authDeleteToServer('spell/tag/delete', { + tagId: tagId, + bookId: bookId, + }, token, lang); + if (localSyncedBooks.find((syncedBook: SyncedBook): boolean => syncedBook.id === bookId)) { + addToQueue('db:spell:tag:delete', { + tagId: tagId, + bookId: bookId, + }); + } + } + if (response) { + setTags(tags.filter((tag: SpellTagProps): boolean => tag.id !== tagId)); + return true; + } + return false; + } catch (e: unknown) { + if (e instanceof Error) { + errorMessage(e.message); + } + return false; + } + } + + return ( +
+ {showToggle && ( +
+ => handleToggleTool(checked)} + /> + } + /> +

+ {t('spellComponent.enableToolDescription')} +

+
+ )} + {toolEnabled && ( + <> + {showTagManager ? ( + + ) : selectedSpell ? ( + + ) : ( + + )} + + )} +
+ ); +} + +export default forwardRef(SpellComponent); diff --git a/components/spells/SpellDetail.tsx b/components/spells/SpellDetail.tsx new file mode 100644 index 0000000..71dc118 --- /dev/null +++ b/components/spells/SpellDetail.tsx @@ -0,0 +1,325 @@ +'use client'; +import React, {Dispatch, SetStateAction, useState} from 'react'; +import {defaultTagColors, SpellEditState, spellPowerLevels, SpellTagProps} from "@/lib/models/Spell"; +import {SelectBoxProps} from "@/shared/interface"; +import CollapsableArea from "@/components/CollapsableArea"; +import InputField from "@/components/form/InputField"; +import TextInput from "@/components/form/TextInput"; +import TexteAreaInput from "@/components/form/TexteAreaInput"; +import SelectBox from "@/components/form/SelectBox"; +import SpellTagChip from "@/components/book/settings/spells/SpellTagChip"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import { + faArrowLeft, + faBolt, + faBook, + faEye, + faHatWizard, + faPlus, + faPuzzlePiece, + faSave, + faStickyNote, + faTags, + faTrash, + faTriangleExclamation +} from "@fortawesome/free-solid-svg-icons"; +import {useTranslations} from "next-intl"; +import AlertBox from "@/components/AlertBox"; + +interface SpellDetailProps { + selectedSpell: SpellEditState; + setSelectedSpell: Dispatch>; + availableTags: SpellTagProps[]; + handleSpellChange: (key: keyof SpellEditState, value: string | string[] | null) => void; + handleSaveSpell: () => Promise; + handleDeleteSpell: (spellId: string) => Promise; + handleCreateTagInline: (name: string, color: string) => Promise; +} + +export default function SpellDetail( + { + selectedSpell, + setSelectedSpell, + availableTags, + handleSpellChange, + handleSaveSpell, + handleDeleteSpell, + handleCreateTagInline, + }: SpellDetailProps) { + const t = useTranslations(); + + const [tagSearchQuery, setTagSearchQuery] = useState(''); + const [showTagDropdown, setShowTagDropdown] = useState(false); + const [isCreatingTag, setIsCreatingTag] = useState(false); + const [newTagColor, setNewTagColor] = useState(defaultTagColors[0]); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + function handleAddTag(tagId: string): void { + if (!selectedSpell.tags.includes(tagId)) { + handleSpellChange('tags', [...selectedSpell.tags, tagId]); + } + setTagSearchQuery(''); + setShowTagDropdown(false); + } + + function handleRemoveTag(tagId: string): void { + handleSpellChange('tags', selectedSpell.tags.filter((id: string) => id !== tagId)); + } + + function getFilteredAvailableTags(): SpellTagProps[] { + return availableTags.filter((tag: SpellTagProps) => { + const notAlreadyAdded = !selectedSpell.tags.includes(tag.id); + const matchesSearch = tag.name.toLowerCase().includes(tagSearchQuery.toLowerCase()); + return notAlreadyAdded && matchesSearch; + }); + } + + function getSelectedTags(): SpellTagProps[] { + return availableTags.filter((tag: SpellTagProps) => selectedSpell.tags.includes(tag.id)); + } + + async function handleCreateTag(): Promise { + if (!tagSearchQuery.trim()) { + return; + } + const newTag: SpellTagProps | null = await handleCreateTagInline(tagSearchQuery.trim(), newTagColor); + if (newTag) { + handleAddTag(newTag.id); + setIsCreatingTag(false); + setNewTagColor(defaultTagColors[0]); + } + } + + function getLocalizedPowerLevels(): SelectBoxProps[] { + return spellPowerLevels.map((level: SelectBoxProps): SelectBoxProps => ({ + value: level.value, + label: t(level.label), + })); + } + + const filteredTags = getFilteredAvailableTags(); + const selectedTags = getSelectedTags(); + const showCreateOption = tagSearchQuery.trim() && !availableTags.some((tag: SpellTagProps) => tag.name.toLowerCase() === tagSearchQuery.toLowerCase()); + + + return ( +
+
+ + + {selectedSpell.name || t("spellDetail.newSpell")} + +
+ {selectedSpell.id && ( + + )} + +
+ {showDeleteConfirm && selectedSpell.id && ( + => { + await handleDeleteSpell(selectedSpell.id as string); + setShowDeleteConfirm(false); + }} + onCancel={(): void => setShowDeleteConfirm(false)} + /> + )} +
+ +
+ +
+ handleSpellChange('name', e.target.value)} + placeholder={t("spellDetail.namePlaceholder")} + /> + } + /> + + handleSpellChange('description', e.target.value)} + placeholder={t("spellDetail.descriptionPlaceholder")} + /> + } + /> + + handleSpellChange('appearance', e.target.value)} + placeholder={t("spellDetail.appearancePlaceholder")} + /> + } + /> +
+
+ + +
+ {selectedTags.length > 0 && ( +
+ {selectedTags.map((tag: SpellTagProps) => ( + handleRemoveTag(tag.id)} + /> + ))} +
+ )} + +
+ { + setTagSearchQuery(e.target.value); + setShowTagDropdown(true); + }} + placeholder={t("spellDetail.addTag")} + onFocus={() => setShowTagDropdown(true)} + /> + + {showTagDropdown && (tagSearchQuery || filteredTags.length > 0) && ( +
+ {filteredTags.map((tag: SpellTagProps) => ( + + ))} + + {showCreateOption && !isCreatingTag && ( + + )} + + {isCreatingTag && ( +
+

+ {t("spellDetail.createTag", {name: tagSearchQuery})} +

+
+ {defaultTagColors.map((color: string) => ( +
+
+ + +
+
+ )} +
+ )} +
+
+
+ + +
+ handleSpellChange('powerLevel', e.target.value === 'none' ? null : e.target.value)} + data={getLocalizedPowerLevels()} + /> +
+
+ + +
+ handleSpellChange('components', e.target.value || null)} + placeholder={t("spellDetail.componentsPlaceholder")} + /> +
+
+ + +
+ handleSpellChange('limitations', e.target.value || null)} + placeholder={t("spellDetail.limitationsPlaceholder")} + /> +
+
+ + +
+ handleSpellChange('notes', e.target.value || null)} + placeholder={t("spellDetail.notesPlaceholder")} + /> +
+
+
+
+ ); +} diff --git a/components/spells/SpellList.tsx b/components/spells/SpellList.tsx new file mode 100644 index 0000000..871a1fd --- /dev/null +++ b/components/spells/SpellList.tsx @@ -0,0 +1,141 @@ +'use client'; +import React, {useState} from 'react'; +import {SpellListItem, SpellTagProps} from "@/lib/models/Spell"; +import InputField from "@/components/form/InputField"; +import TextInput from "@/components/form/TextInput"; +import SpellTagChip from "@/components/book/settings/spells/SpellTagChip"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faChevronRight, faCog, faHatWizard, faPlus} from "@fortawesome/free-solid-svg-icons"; +import {useTranslations} from "next-intl"; + +interface SpellListProps { + spells: SpellListItem[]; + tags: SpellTagProps[]; + handleSpellClick: (spell: SpellListItem) => void; + handleAddSpell: () => void; + handleManageTags: () => void; +} + +export default function SpellList( + { + spells, + tags, + handleSpellClick, + handleAddSpell, + handleManageTags, + }: SpellListProps) { + const t = useTranslations(); + const [searchQuery, setSearchQuery] = useState(''); + const [filterTag, setFilterTag] = useState('all'); + + function getFilteredSpells(): SpellListItem[] { + return spells.filter((spell: SpellListItem) => { + const matchesSearch = spell.name.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesTag = filterTag === 'all' || spell.tags.some((tag: SpellTagProps) => tag.id === filterTag); + return matchesSearch && matchesTag; + }); + } + + const filteredSpells = getFilteredSpells(); + + return ( +
+
+ setSearchQuery(e.target.value)} + placeholder={t("spellList.search")} + /> + } + actionIcon={faPlus} + actionLabel={t("spellList.add")} + addButtonCallBack={async () => handleAddSpell()} + /> + +
+
+ +
+ +
+
+ +
+ {filteredSpells.length === 0 ? ( +
+
+ +
+

{t("spellList.noSpells")}

+

{t("spellList.noSpellsDescription")}

+
+ ) : ( +
+ {filteredSpells.map((spell: SpellListItem) => ( +
handleSpellClick(spell)} + className="group flex items-center p-4 bg-secondary/30 rounded-xl border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md hover:scale-102 transition-all duration-200 hover:border-primary/50" + > +
+ +
+ +
+
+ {spell.name} +
+
+ {spell.description} +
+ {spell.tags.length > 0 && ( +
+ {spell.tags.slice(0, 3).map((tag: SpellTagProps) => ( + + ))} + {spell.tags.length > 3 && ( + + +{spell.tags.length - 3} + + )} +
+ )} +
+ +
+ +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/components/spells/SpellTagChip.tsx b/components/spells/SpellTagChip.tsx new file mode 100644 index 0000000..b6eba57 --- /dev/null +++ b/components/spells/SpellTagChip.tsx @@ -0,0 +1,66 @@ +'use client'; +import React from 'react'; +import {SpellTagProps} from "@/lib/models/Spell"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faX} from "@fortawesome/free-solid-svg-icons"; + +interface SpellTagChipProps { + tag: SpellTagProps; + onRemove?: () => void; + onClick?: () => void; + size?: 'sm' | 'md'; +} + +export default function SpellTagChip( + { + tag, + onRemove, + onClick, + size = 'md' + }: SpellTagChipProps) { + + function getContrastColor(hexColor: string | null): string { + if (!hexColor) return '#FFFFFF'; + const hex = hexColor.replace('#', ''); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5 ? '#1F2023' : '#FFFFFF'; + } + + const sizeClasses = size === 'sm' + ? 'px-2 py-0.5 text-xs' + : 'px-3 py-1 text-sm'; + + const chipClasses = ` + inline-flex items-center gap-1.5 rounded-full font-medium + transition-all duration-200 + ${sizeClasses} + ${onClick ? 'cursor-pointer hover:scale-105 hover:shadow-md' : ''} + `; + + return ( + + {tag.name} + {onRemove && ( + + )} + + ); +} diff --git a/components/spells/SpellTagManager.tsx b/components/spells/SpellTagManager.tsx new file mode 100644 index 0000000..ff576a3 --- /dev/null +++ b/components/spells/SpellTagManager.tsx @@ -0,0 +1,250 @@ +'use client'; +import React, {useContext, useState} from 'react'; +import {defaultTagColors, SpellTagProps} from "@/lib/models/Spell"; +import SpellTagChip from "@/components/book/settings/spells/SpellTagChip"; +import InputField from "@/components/form/InputField"; +import TextInput from "@/components/form/TextInput"; +import Modal from "@/components/Modal"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faArrowLeft, faEdit, faPlus, faTags, faTrash} from "@fortawesome/free-solid-svg-icons"; +import {useTranslations} from "next-intl"; +import {AlertContext} from "@/context/AlertContext"; + +interface SpellTagManagerProps { + tags: SpellTagProps[]; + onBack: () => void; + onCreateTag: (name: string, color: string) => Promise; + onUpdateTag: (tagId: string, name: string, color: string) => Promise; + onDeleteTag: (tagId: string) => Promise; +} + +export default function SpellTagManager( + { + tags, + onBack, + onCreateTag, + onUpdateTag, + onDeleteTag, + }: SpellTagManagerProps) { + const t = useTranslations(); + const {successMessage} = useContext(AlertContext); + + const [newTagName, setNewTagName] = useState(''); + const [newTagColor, setNewTagColor] = useState(defaultTagColors[0]); + const [editingTag, setEditingTag] = useState(null); + const [editTagName, setEditTagName] = useState(''); + const [editTagColor, setEditTagColor] = useState(''); + const [showEditModal, setShowEditModal] = useState(false); + const [tagToDelete, setTagToDelete] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + async function handleAddTag(): Promise { + if (!newTagName.trim()) { + return; + } + const newTag: SpellTagProps | null = await onCreateTag(newTagName.trim(), newTagColor); + if (newTag) { + setNewTagName(''); + setNewTagColor(defaultTagColors[0]); + successMessage(t("spellTagManager.successAdd")); + } + } + + function handleEditClick(tag: SpellTagProps): void { + setEditingTag(tag); + setEditTagName(tag.name); + setEditTagColor(tag.color || defaultTagColors[0]); + setShowEditModal(true); + } + + async function handleUpdateTag(): Promise { + if (!editingTag || !editTagName.trim()) { + return; + } + const success: boolean = await onUpdateTag(editingTag.id, editTagName.trim(), editTagColor); + if (success) { + setShowEditModal(false); + setEditingTag(null); + successMessage(t("spellTagManager.successUpdate")); + } + } + + function handleDeleteClick(tag: SpellTagProps): void { + setTagToDelete(tag); + setShowDeleteConfirm(true); + } + + async function handleDeleteTag(): Promise { + if (!tagToDelete) { + return; + } + const success: boolean = await onDeleteTag(tagToDelete.id); + if (success) { + setShowDeleteConfirm(false); + setTagToDelete(null); + successMessage(t("spellTagManager.successDelete")); + } + } + + return ( +
+
+ + + + {t("spellTagManager.title")} + +
+
+ +
+
+

{t("spellTagManager.addTag")}

+
+ setNewTagName(e.target.value)} + placeholder={t("spellTagManager.tagNamePlaceholder")} + /> + } + /> +
+ {t("spellTagManager.tagColor")} +
+ {defaultTagColors.map((color: string) => ( +
+
+ +
+
+ +
+ {tags.length === 0 ? ( +
+
+ +
+

{t("spellTagManager.noTags")}

+
+ ) : ( +
+ {tags.map((tag: SpellTagProps) => ( +
+ +
+ + +
+
+ ))} +
+ )} +
+
+ + {showEditModal && editingTag && ( + setShowEditModal(false)} + onConfirm={handleUpdateTag} + confirmText={t("common.confirm")} + cancelText={t("common.cancel")} + > +
+ setEditTagName(e.target.value)} + placeholder={t("spellTagManager.tagNamePlaceholder")} + /> + } + /> +
+ {t("spellTagManager.tagColor")} +
+ {defaultTagColors.map((color: string) => ( +
+
+
+ {t("spellTagManager.preview")} +
+ +
+
+
+
+ )} + + {showDeleteConfirm && tagToDelete && ( + setShowDeleteConfirm(false)} + onConfirm={handleDeleteTag} + confirmText={t("spellTagManager.delete")} + cancelText={t("common.cancel")} + > +
+

{t("spellTagManager.confirmDelete")}

+
+ +
+
+
+ )} +
+ ); +} diff --git a/electron/database/models/Spell.ts b/electron/database/models/Spell.ts new file mode 100644 index 0000000..25454e4 --- /dev/null +++ b/electron/database/models/Spell.ts @@ -0,0 +1,359 @@ +import SpellRepo, { SpellResult } from '../repositories/spell.repo.js'; +import SpellTagRepo, { SpellTagResult } from '../repositories/spelltag.repo.js'; +import BookRepo, { BookToolsTable } from '../repositories/book.repository.js'; +import System from '../System.js'; +import { getUserEncryptionKey } from '../keyManager.js'; + +export interface SpellTagProps { + id: string; + name: string; + color: string | null; +} + +export interface SpellProps { + id: string; + name: string; + description: string; + appearance: string; + tags: string[]; + powerLevel: string | null; + components: string | null; + limitations: string | null; + notes: string | null; +} + +export interface SpellListItem { + id: string; + name: string; + description: string; + tags: SpellTagProps[]; +} + +export interface SpellListResponse { + enabled: boolean; + spells: SpellListItem[]; + tags: SpellTagProps[]; +} + +export interface SyncedSpell { + id: string; + name: string; + lastUpdate: number; +} + +export interface SyncedSpellTag { + id: string; + name: string; + lastUpdate: number; +} + +export default class Spell { + /** + * Retrieves all spell tags for a specific book. + * @param userId - The unique identifier of the user + * @param bookId - The unique identifier of the book + * @param lang - The language for error messages ('fr' or 'en') + * @returns An array of spell tag props + */ + static getSpellTags(userId: string, bookId: string, lang: 'fr' | 'en' = 'fr'): SpellTagProps[] { + const userKey: string = getUserEncryptionKey(userId); + const spellTags: SpellTagResult[] = SpellTagRepo.fetchSpellTags(userId, bookId, lang); + + return spellTags.map((tag: SpellTagResult): SpellTagProps => ({ + id: tag.tag_id, + name: System.decryptDataWithUserKey(tag.name, userKey), + color: tag.color, + })); + } + + /** + * Adds a new spell tag to a book. + * @param userId - The unique identifier of the user + * @param bookId - The unique identifier of the book + * @param name - The name of the tag + * @param color - The optional color hex code + * @param existingTagId - Optional existing tag ID for sync + * @param lang - The language for error messages ('fr' or 'en') + * @returns The created spell tag props + */ + static addSpellTag(userId: string, bookId: string, name: string, color: string | null, existingTagId?: string, lang: 'fr' | 'en' = 'fr'): SpellTagProps { + const userKey: string = getUserEncryptionKey(userId); + const tagId: string = existingTagId || System.createUniqueId(); + const encryptedName: string = System.encryptDataWithUserKey(name, userKey); + const nameHash: string = System.hashElement(name); + + SpellTagRepo.insertSpellTag(tagId, bookId, userId, encryptedName, nameHash, color, lang); + + return { + id: tagId, + name: name, + color: color, + }; + } + + /** + * Updates an existing spell tag. + * @param userId - The unique identifier of the user + * @param tagId - The unique identifier of the tag + * @param name - The new name of the tag + * @param color - The new optional color hex code + * @param lang - The language for error messages ('fr' or 'en') + * @returns True if the update was successful + */ + static updateSpellTag(userId: string, tagId: string, name: string, color: string | null, lang: 'fr' | 'en' = 'fr'): boolean { + const userKey: string = getUserEncryptionKey(userId); + const encryptedName: string = System.encryptDataWithUserKey(name, userKey); + const nameHash: string = System.hashElement(name); + + return SpellTagRepo.updateSpellTag(userId, tagId, encryptedName, nameHash, color, lang); + } + + /** + * Deletes a spell tag and removes its references from all spells in the book. + * @param userId - The unique identifier of the user + * @param tagId - The unique identifier of the tag to delete + * @param bookId - The unique identifier of the book + * @param lang - The language for error messages ('fr' or 'en') + * @returns True if the deletion was successful + */ + static deleteSpellTag(userId: string, tagId: string, bookId: string, lang: 'fr' | 'en' = 'fr'): boolean { + const userKey: string = getUserEncryptionKey(userId); + + const spells: SpellResult[] = SpellRepo.fetchSpells(userId, bookId, lang); + + for (const spell of spells) { + const decryptedTags: string = System.decryptDataWithUserKey(spell.tags, userKey); + let tagsArray: string[] = []; + try { + tagsArray = JSON.parse(decryptedTags) as string[]; + } catch { + tagsArray = []; + } + + if (tagsArray.includes(tagId)) { + const updatedTags: string[] = tagsArray.filter((t: string): boolean => t !== tagId); + const encryptedTags: string = System.encryptDataWithUserKey(JSON.stringify(updatedTags), userKey); + SpellRepo.updateSpellTags(userId, spell.spell_id, encryptedTags, lang); + } + } + + // Then delete the tag + return SpellTagRepo.deleteSpellTag(userId, tagId, lang); + } + + /** + * Retrieves the spell list with tags for a specific book. + * @param userId - The unique identifier of the user + * @param bookId - The unique identifier of the book + * @param lang - The language for error messages ('fr' or 'en') + * @returns The spell list response with enabled status, spells, and tags + */ + static getSpellList(userId: string, bookId: string, lang: 'fr' | 'en' = 'fr'): SpellListResponse { + const userKey: string = getUserEncryptionKey(userId); + + const bookTools: BookToolsTable | null = BookRepo.fetchBookTools(userId, bookId, lang); + const enabled: boolean = bookTools ? bookTools.spells_enabled === 1 : false; + + const spellTags: SpellTagResult[] = SpellTagRepo.fetchSpellTags(userId, bookId, lang); + const tags: SpellTagProps[] = spellTags.map((tag: SpellTagResult): SpellTagProps => ({ + id: tag.tag_id, + name: System.decryptDataWithUserKey(tag.name, userKey), + color: tag.color, + })); + + const tagMap: Map = new Map(); + for (const tag of tags) { + tagMap.set(tag.id, tag); + } + + const spellResults: SpellResult[] = SpellRepo.fetchSpells(userId, bookId, lang); + + const spells: SpellListItem[] = spellResults.map((spell: SpellResult): SpellListItem => { + const decryptedName: string = System.decryptDataWithUserKey(spell.name, userKey); + const decryptedDescription: string = System.decryptDataWithUserKey(spell.description, userKey); + const decryptedTags: string = System.decryptDataWithUserKey(spell.tags, userKey); + + let tagIds: string[]; + try { + tagIds = JSON.parse(decryptedTags) as string[]; + } catch { + tagIds = []; + } + + const resolvedTags: SpellTagProps[] = tagIds + .map((tagId: string): SpellTagProps | undefined => tagMap.get(tagId)) + .filter((tag: SpellTagProps | undefined): tag is SpellTagProps => tag !== undefined); + + const truncatedDescription: string = decryptedDescription.length > 150 + ? decryptedDescription.substring(0, 150) + '...' + : decryptedDescription; + + return { + id: spell.spell_id, + name: decryptedName, + description: truncatedDescription, + tags: resolvedTags, + }; + }); + + return { + enabled, + spells, + tags, + }; + } + + /** + * Retrieves the full details of a specific spell. + * @param userId - The unique identifier of the user + * @param spellId - The unique identifier of the spell + * @param lang - The language for error messages ('fr' or 'en') + * @returns The spell props with all details + */ + static getSpellDetail(userId: string, spellId: string, lang: 'fr' | 'en' = 'fr'): SpellProps { + const userKey: string = getUserEncryptionKey(userId); + + const spell: SpellResult | null = SpellRepo.fetchSpellById(userId, spellId, lang); + if (!spell) { + throw new Error(lang === 'fr' ? 'Sort non trouvé.' : 'Spell not found.'); + } + + const decryptedName: string = System.decryptDataWithUserKey(spell.name, userKey); + const decryptedDescription: string = System.decryptDataWithUserKey(spell.description, userKey); + const decryptedAppearance: string = System.decryptDataWithUserKey(spell.appearance, userKey); + const decryptedTags: string = System.decryptDataWithUserKey(spell.tags, userKey); + + let tagIds: string[]; + try { + tagIds = JSON.parse(decryptedTags) as string[]; + } catch { + tagIds = []; + } + + return { + id: spell.spell_id, + name: decryptedName, + description: decryptedDescription, + appearance: decryptedAppearance, + tags: tagIds, + powerLevel: spell.power_level ? System.decryptDataWithUserKey(spell.power_level, userKey) : null, + components: spell.components ? System.decryptDataWithUserKey(spell.components, userKey) : null, + limitations: spell.limitations ? System.decryptDataWithUserKey(spell.limitations, userKey) : null, + notes: spell.notes ? System.decryptDataWithUserKey(spell.notes, userKey) : null, + }; + } + + /** + * Adds a new spell to a book. + * @param userId - The unique identifier of the user + * @param bookId - The unique identifier of the book + * @param name - The name of the spell + * @param description - The description of the spell + * @param appearance - The appearance of the spell + * @param tags - The tag IDs array + * @param powerLevel - The optional power level + * @param components - The optional components + * @param limitations - The optional limitations + * @param notes - The optional notes + * @param existingSpellId - Optional existing spell ID for sync + * @param lang - The language for error messages ('fr' or 'en') + * @returns The created spell props + */ + static addSpell(userId: string, bookId: string, name: string, description: string, appearance: string, tags: string[], powerLevel: string | null, components: string | null, limitations: string | null, notes: string | null, existingSpellId?: string, lang: 'fr' | 'en' = 'fr'): SpellProps { + const userKey: string = getUserEncryptionKey(userId); + const spellId: string = existingSpellId || System.createUniqueId(); + + const encryptedName: string = System.encryptDataWithUserKey(name, userKey); + const nameHash: string = System.hashElement(name); + const encryptedDescription: string = System.encryptDataWithUserKey(description, userKey); + const encryptedAppearance: string = System.encryptDataWithUserKey(appearance, userKey); + const encryptedTags: string = System.encryptDataWithUserKey(JSON.stringify(tags), userKey); + const encryptedPowerLevel: string | null = powerLevel ? System.encryptDataWithUserKey(powerLevel, userKey) : null; + const encryptedComponents: string | null = components ? System.encryptDataWithUserKey(components, userKey) : null; + const encryptedLimitations: string | null = limitations ? System.encryptDataWithUserKey(limitations, userKey) : null; + const encryptedNotes: string | null = notes ? System.encryptDataWithUserKey(notes, userKey) : null; + + SpellRepo.insertSpell( + spellId, + bookId, + userId, + encryptedName, + nameHash, + encryptedDescription, + encryptedAppearance, + encryptedTags, + encryptedPowerLevel, + encryptedComponents, + encryptedLimitations, + encryptedNotes, + lang, + ); + + return { + id: spellId, + name, + description, + appearance, + tags, + powerLevel, + components, + limitations, + notes, + }; + } + + /** + * Updates an existing spell. + * @param userId - The unique identifier of the user + * @param spellId - The unique identifier of the spell + * @param name - The name of the spell + * @param description - The description of the spell + * @param appearance - The appearance of the spell + * @param tags - The tag IDs array + * @param powerLevel - The optional power level + * @param components - The optional components + * @param limitations - The optional limitations + * @param notes - The optional notes + * @param lang - The language for error messages ('fr' or 'en') + * @returns True if the update was successful + */ + static updateSpell(userId: string, spellId: string, name: string, description: string, appearance: string, tags: string[], powerLevel: string | null, components: string | null, limitations: string | null, notes: string | null, lang: 'fr' | 'en' = 'fr'): boolean { + const userKey: string = getUserEncryptionKey(userId); + + const encryptedName: string = System.encryptDataWithUserKey(name, userKey); + const nameHash: string = System.hashElement(name); + const encryptedDescription: string = System.encryptDataWithUserKey(description, userKey); + const encryptedAppearance: string = System.encryptDataWithUserKey(appearance, userKey); + const encryptedTags: string = System.encryptDataWithUserKey(JSON.stringify(tags), userKey); + const encryptedPowerLevel: string | null = powerLevel ? System.encryptDataWithUserKey(powerLevel, userKey) : null; + const encryptedComponents: string | null = components ? System.encryptDataWithUserKey(components, userKey) : null; + const encryptedLimitations: string | null = limitations ? System.encryptDataWithUserKey(limitations, userKey) : null; + const encryptedNotes: string | null = notes ? System.encryptDataWithUserKey(notes, userKey) : null; + + return SpellRepo.updateSpell( + userId, + spellId, + encryptedName, + nameHash, + encryptedDescription, + encryptedAppearance, + encryptedTags, + encryptedPowerLevel, + encryptedComponents, + encryptedLimitations, + encryptedNotes, + lang, + ); + } + + /** + * Deletes a spell. + * @param userId - The unique identifier of the user + * @param spellId - The unique identifier of the spell + * @param lang - The language for error messages ('fr' or 'en') + * @returns True if the deletion was successful + */ + static deleteSpell(userId: string, spellId: string, lang: 'fr' | 'en' = 'fr'): boolean { + return SpellRepo.deleteSpell(userId, spellId, lang); + } +} diff --git a/electron/database/repositories/spell.repo.ts b/electron/database/repositories/spell.repo.ts new file mode 100644 index 0000000..23f42cb --- /dev/null +++ b/electron/database/repositories/spell.repo.ts @@ -0,0 +1,368 @@ +import { Database, RunResult, SQLiteValue } from 'node-sqlite3-wasm'; +import System from '../System.js'; + +export interface SpellResult extends Record { + spell_id: string; + book_id: string; + name: string; + description: string; + appearance: string; + tags: string; + power_level: string | null; + components: string | null; + limitations: string | null; + notes: string | null; +} + +export interface BookSpellsTable extends Record { + spell_id: string; + book_id: string; + user_id: string; + name: string; + name_hash: string; + description: string; + appearance: string; + tags: string; + power_level: string | null; + components: string | null; + limitations: string | null; + notes: string | null; + last_update: number; +} + +export interface SyncedSpellResult extends Record { + spell_id: string; + book_id: string; + name: string; + last_update: number; +} + +export default class SpellRepo { + /** + * Fetches all spells for a specific book owned by the user. + * @param userId - The unique identifier of the user + * @param bookId - The unique identifier of the book + * @param lang - The language for error messages ('fr' or 'en') + * @returns An array of spell results + */ + static fetchSpells(userId: string, bookId: string, lang: 'fr' | 'en' = 'fr'): SpellResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT spell_id, book_id, name, description, appearance, tags, power_level, components, limitations, notes FROM book_spells WHERE user_id=? AND book_id=?'; + const params: SQLiteValue[] = [userId, bookId]; + return db.all(query, params) as SpellResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellRepo] DB Error: ${error.message}`); + } else { + console.error('[SpellRepo] An unknown error occurred.'); + } + throw new Error(lang === 'fr' ? `Impossible de récupérer les sorts.` : `Unable to retrieve spells.`); + } + } + + /** + * Fetches a single spell by its ID. + * @param userId - The unique identifier of the user + * @param spellId - The unique identifier of the spell + * @param lang - The language for error messages ('fr' or 'en') + * @returns The spell result or null if not found + */ + static fetchSpellById(userId: string, spellId: string, lang: 'fr' | 'en' = 'fr'): SpellResult | null { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT spell_id, book_id, name, description, appearance, tags, power_level, components, limitations, notes FROM book_spells WHERE user_id=? AND spell_id=?'; + const params: SQLiteValue[] = [userId, spellId]; + const spells: SpellResult[] = db.all(query, params) as SpellResult[]; + return spells.length > 0 ? spells[0] : null; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellRepo] DB Error: ${error.message}`); + } else { + console.error('[SpellRepo] An unknown error occurred.'); + } + throw new Error(lang === 'fr' ? `Impossible de récupérer le sort.` : `Unable to retrieve spell.`); + } + } + + /** + * Inserts a new spell. + * @param spellId - The unique identifier for the new spell + * @param bookId - The unique identifier of the book + * @param userId - The unique identifier of the user + * @param name - The encrypted name + * @param nameHash - The hashed name for duplicate detection + * @param description - The encrypted description + * @param appearance - The encrypted appearance + * @param tags - The encrypted JSON tags array + * @param powerLevel - The encrypted power level (nullable) + * @param components - The encrypted components (nullable) + * @param limitations - The encrypted limitations (nullable) + * @param notes - The encrypted notes (nullable) + * @param lang - The language for error messages ('fr' or 'en') + * @returns The spell ID if successful + */ + static insertSpell(spellId: string, bookId: string, userId: string, name: string, nameHash: string, description: string, appearance: string, tags: string, powerLevel: string | null, components: string | null, limitations: string | null, notes: string | null, lang: 'fr' | 'en' = 'fr'): string { + try { + const db: Database = System.getDb(); + const query: string = 'INSERT INTO book_spells (spell_id, book_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)'; + const params: SQLiteValue[] = [spellId, bookId, userId, name, nameHash, description, appearance, tags, powerLevel, components, limitations, notes, System.timeStampInSeconds()]; + const result: RunResult = db.run(query, params); + if (!result || result.changes === 0) { + throw new Error(lang === 'fr' ? `Une erreur s'est produite lors de l'ajout du sort.` : `Error adding spell.`); + } + return spellId; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellRepo] DB Error: ${error.message}`); + } else { + console.error('[SpellRepo] An unknown error occurred.'); + } + throw new Error(lang === 'fr' ? `Impossible d'ajouter le sort.` : `Unable to add spell.`); + } + } + + /** + * Updates an existing spell. + * @param userId - The unique identifier of the user + * @param spellId - The unique identifier of the spell + * @param name - The encrypted name + * @param nameHash - The hashed name + * @param description - The encrypted description + * @param appearance - The encrypted appearance + * @param tags - The encrypted JSON tags array + * @param powerLevel - The encrypted power level (nullable) + * @param components - The encrypted components (nullable) + * @param limitations - The encrypted limitations (nullable) + * @param notes - The encrypted notes (nullable) + * @param lang - The language for error messages ('fr' or 'en') + * @returns True if the update was successful + */ + static updateSpell(userId: string, spellId: string, name: string, nameHash: string, description: string, appearance: string, tags: string, powerLevel: string | null, components: string | null, limitations: string | null, notes: string | null, lang: 'fr' | 'en' = 'fr'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'UPDATE book_spells SET name=?, name_hash=?, description=?, appearance=?, tags=?, power_level=?, components=?, limitations=?, notes=?, last_update=? WHERE spell_id=? AND user_id=?'; + const params: SQLiteValue[] = [name, nameHash, description, appearance, tags, powerLevel, components, limitations, notes, System.timeStampInSeconds(), spellId, userId]; + const result: RunResult = db.run(query, params); + return result.changes > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellRepo] DB Error: ${error.message}`); + } else { + console.error('[SpellRepo] An unknown error occurred.'); + } + throw new Error(lang === 'fr' ? `Impossible de mettre à jour le sort.` : `Unable to update spell.`); + } + } + + /** + * Deletes a spell. + * @param userId - The unique identifier of the user + * @param spellId - The unique identifier of the spell + * @param lang - The language for error messages ('fr' or 'en') + * @returns True if the deletion was successful + */ + static deleteSpell(userId: string, spellId: string, lang: 'fr' | 'en' = 'fr'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'DELETE FROM book_spells WHERE spell_id=? AND user_id=?'; + const params: SQLiteValue[] = [spellId, userId]; + const result: RunResult = db.run(query, params); + return result.changes > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellRepo] DB Error: ${error.message}`); + } else { + console.error('[SpellRepo] An unknown error occurred.'); + } + throw new Error(lang === 'fr' ? `Impossible de supprimer le sort.` : `Unable to delete spell.`); + } + } + + /** + * Updates the tags field of a spell. + * @param userId - The unique identifier of the user + * @param spellId - The unique identifier of the spell + * @param tags - The new encrypted JSON tags array + * @param lang - The language for error messages ('fr' or 'en') + * @returns True if the update was successful + */ + static updateSpellTags(userId: string, spellId: string, tags: string, lang: 'fr' | 'en' = 'fr'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'UPDATE book_spells SET tags=?, last_update=? WHERE spell_id=? AND user_id=?'; + const params: SQLiteValue[] = [tags, System.timeStampInSeconds(), spellId, userId]; + const result: RunResult = db.run(query, params); + return result.changes > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellRepo] DB Error: ${error.message}`); + } else { + console.error('[SpellRepo] An unknown error occurred.'); + } + throw new Error(lang === 'fr' ? `Impossible de mettre à jour les tags du sort.` : `Unable to update spell tags.`); + } + } + + /** + * Fetches all spells for a book with full table data for sync. + * @param userId - The unique identifier of the user + * @param bookId - The unique identifier of the book + * @param lang - The language for error messages ('fr' or 'en') + * @returns An array of book spells table records + */ + static fetchBookSpellsTable(userId: string, bookId: string, lang: 'fr' | 'en' = 'fr'): BookSpellsTable[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT spell_id, book_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update FROM book_spells WHERE user_id=? AND book_id=?'; + const params: SQLiteValue[] = [userId, bookId]; + return db.all(query, params) as BookSpellsTable[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellRepo] DB Error: ${error.message}`); + } else { + console.error('[SpellRepo] An unknown error occurred.'); + } + throw new Error(lang === 'fr' ? `Impossible de récupérer les sorts.` : `Unable to retrieve spells.`); + } + } + + /** + * Fetches a complete spell record by its ID. + * @param userId - The unique identifier of the user + * @param spellId - The unique identifier of the spell + * @param lang - The language for error messages ('fr' or 'en') + * @returns The spell table record or null + */ + static fetchSpellTableById(userId: string, spellId: string, lang: 'fr' | 'en' = 'fr'): BookSpellsTable | null { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT spell_id, book_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update FROM book_spells WHERE user_id=? AND spell_id=?'; + const params: SQLiteValue[] = [userId, spellId]; + const spells: BookSpellsTable[] = db.all(query, params) as BookSpellsTable[]; + return spells.length > 0 ? spells[0] : null; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellRepo] DB Error: ${error.message}`); + } else { + console.error('[SpellRepo] An unknown error occurred.'); + } + throw new Error(lang === 'fr' ? `Impossible de récupérer le sort.` : `Unable to retrieve spell.`); + } + } + + /** + * Fetches all synced spells for a user. + * @param userId - The unique identifier of the user + * @param lang - The language for error messages ('fr' or 'en') + * @returns An array of synced spell results + */ + static fetchSyncedSpells(userId: string, lang: 'fr' | 'en' = 'fr'): SyncedSpellResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT spell_id, book_id, name, last_update FROM book_spells WHERE user_id=?'; + const params: SQLiteValue[] = [userId]; + return db.all(query, params) as SyncedSpellResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellRepo] DB Error: ${error.message}`); + } else { + console.error('[SpellRepo] An unknown error occurred.'); + } + throw new Error(lang === 'fr' ? `Impossible de récupérer les sorts synchronisés.` : `Unable to retrieve synced spells.`); + } + } + + /** + * Inserts or updates a spell from synchronization data. + * @param spellId - The unique identifier for the spell + * @param bookId - The unique identifier of the book + * @param userId - The unique identifier of the user + * @param name - The encrypted name + * @param nameHash - The hashed name + * @param description - The encrypted description + * @param appearance - The encrypted appearance + * @param tags - The encrypted JSON tags array + * @param powerLevel - The encrypted power level (nullable) + * @param components - The encrypted components (nullable) + * @param limitations - The encrypted limitations (nullable) + * @param notes - The encrypted notes (nullable) + * @param lastUpdate - The timestamp of the last update + * @param lang - The language for error messages ('fr' or 'en') + * @returns True if the insertion was successful + */ + static insertSyncSpell(spellId: string, bookId: string, userId: string, name: string, nameHash: string, description: string, appearance: string, tags: string, powerLevel: string | null, components: string | null, limitations: string | null, notes: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'INSERT OR REPLACE INTO book_spells (spell_id, book_id, user_id, name, name_hash, description, appearance, tags, power_level, components, limitations, notes, last_update) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)'; + const params: SQLiteValue[] = [spellId, bookId, userId, name, nameHash, description, appearance, tags, powerLevel, components, limitations, notes, lastUpdate]; + const result: RunResult = db.run(query, params); + return result.changes > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellRepo] DB Error: ${error.message}`); + } else { + console.error('[SpellRepo] An unknown error occurred.'); + } + throw new Error(lang === 'fr' ? `Impossible d'insérer le sort.` : `Unable to insert spell.`); + } + } + + /** + * Checks if a spell exists. + * @param userId - The unique identifier of the user + * @param spellId - The unique identifier of the spell + * @param lang - The language for error messages ('fr' or 'en') + * @returns True if the spell exists + */ + static isSpellExist(userId: string, spellId: string, lang: 'fr' | 'en' = 'fr'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT 1 FROM book_spells WHERE spell_id=? AND user_id=?'; + const params: SQLiteValue[] = [spellId, userId]; + const existenceCheck = db.all(query, params); + return existenceCheck.length > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellRepo] DB Error: ${error.message}`); + } else { + console.error('[SpellRepo] An unknown error occurred.'); + } + throw new Error(lang === 'fr' ? `Impossible de vérifier l'existence du sort.` : `Unable to check spell existence.`); + } + } + + /** + * Updates a spell with timestamp for sync. + * @param userId - The unique identifier of the user + * @param spellId - The unique identifier of the spell + * @param name - The encrypted name + * @param nameHash - The hashed name + * @param description - The encrypted description + * @param appearance - The encrypted appearance + * @param tags - The encrypted JSON tags array + * @param powerLevel - The encrypted power level (nullable) + * @param components - The encrypted components (nullable) + * @param limitations - The encrypted limitations (nullable) + * @param notes - The encrypted notes (nullable) + * @param lastUpdate - The timestamp of the last update + * @param lang - The language for error messages ('fr' or 'en') + * @returns True if the update was successful + */ + static updateSyncSpell(userId: string, spellId: string, name: string, nameHash: string, description: string, appearance: string, tags: string, powerLevel: string | null, components: string | null, limitations: string | null, notes: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'UPDATE book_spells SET name=?, name_hash=?, description=?, appearance=?, tags=?, power_level=?, components=?, limitations=?, notes=?, last_update=? WHERE spell_id=? AND user_id=?'; + const params: SQLiteValue[] = [name, nameHash, description, appearance, tags, powerLevel, components, limitations, notes, lastUpdate, spellId, userId]; + const result: RunResult = db.run(query, params); + return result.changes > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellRepo] DB Error: ${error.message}`); + } else { + console.error('[SpellRepo] An unknown error occurred.'); + } + throw new Error(lang === 'fr' ? `Impossible de mettre à jour le sort.` : `Unable to update spell.`); + } + } +} diff --git a/electron/database/repositories/spelltag.repo.ts b/electron/database/repositories/spelltag.repo.ts new file mode 100644 index 0000000..b6fe803 --- /dev/null +++ b/electron/database/repositories/spelltag.repo.ts @@ -0,0 +1,292 @@ +import { Database, RunResult, SQLiteValue } from 'node-sqlite3-wasm'; +import System from '../System.js'; + +export interface SpellTagResult extends Record { + tag_id: string; + book_id: string; + name: string; + color: string | null; +} + +export interface BookSpellTagsTable extends Record { + tag_id: string; + book_id: string; + user_id: string; + name: string; + name_hash: string; + color: string | null; + last_update: number; +} + +export interface SyncedSpellTagResult extends Record { + tag_id: string; + book_id: string; + name: string; + last_update: number; +} + +export default class SpellTagRepo { + /** + * Fetches all spell tags for a specific book owned by the user. + * @param userId - The unique identifier of the user + * @param bookId - The unique identifier of the book + * @param lang - The language for error messages ('fr' or 'en') + * @returns An array of spell tag results + */ + static fetchSpellTags(userId: string, bookId: string, lang: 'fr' | 'en' = 'fr'): SpellTagResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT tag_id, book_id, name, color FROM book_spell_tags WHERE user_id=? AND book_id=?'; + const params: SQLiteValue[] = [userId, bookId]; + return db.all(query, params) as SpellTagResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellTagRepo] DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer les tags de sorts.` : `Unable to retrieve spell tags.`); + } else { + console.error('[SpellTagRepo] An unknown error occurred.'); + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : 'An unknown error occurred.'); + } + } + } + + /** + * Inserts a new spell tag. + * @param tagId - The unique identifier for the new tag + * @param bookId - The unique identifier of the book + * @param userId - The unique identifier of the user + * @param name - The encrypted name of the tag + * @param nameHash - The hashed name for duplicate detection + * @param color - The optional color hex code + * @param lang - The language for error messages ('fr' or 'en') + * @returns The tag ID if successful + */ + static insertSpellTag(tagId: string, bookId: string, userId: string, name: string, nameHash: string, color: string | null, lang: 'fr' | 'en' = 'fr'): string { + try { + const db: Database = System.getDb(); + const query: string = 'INSERT INTO book_spell_tags (tag_id, book_id, user_id, name, name_hash, color, last_update) VALUES (?,?,?,?,?,?,?)'; + const params: SQLiteValue[] = [tagId, bookId, userId, name, nameHash, color, System.timeStampInSeconds()]; + const result: RunResult = db.run(query, params); + if (!result || result.changes === 0) { + throw new Error(lang === 'fr' ? `Une erreur s'est produite lors de l'ajout du tag.` : `Error adding tag.`); + } + return tagId; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellTagRepo] DB Error: ${error.message}`); + } else { + console.error('[SpellTagRepo] An unknown error occurred.'); + } + throw new Error(lang === 'fr' ? `Impossible d'ajouter le tag de sort.` : `Unable to add spell tag.`); + } + } + + /** + * Updates an existing spell tag. + * @param userId - The unique identifier of the user + * @param tagId - The unique identifier of the tag + * @param name - The encrypted name of the tag + * @param nameHash - The hashed name + * @param color - The optional color hex code + * @param lang - The language for error messages ('fr' or 'en') + * @returns True if the update was successful + */ + static updateSpellTag(userId: string, tagId: string, name: string, nameHash: string, color: string | null, lang: 'fr' | 'en' = 'fr'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'UPDATE book_spell_tags SET name=?, name_hash=?, color=?, last_update=? WHERE tag_id=? AND user_id=?'; + const params: SQLiteValue[] = [name, nameHash, color, System.timeStampInSeconds(), tagId, userId]; + const result: RunResult = db.run(query, params); + return result.changes > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellTagRepo] DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de mettre à jour le tag de sort.` : `Unable to update spell tag.`); + } else { + console.error('[SpellTagRepo] An unknown error occurred.'); + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : 'An unknown error occurred.'); + } + } + } + + /** + * Deletes a spell tag. + * @param userId - The unique identifier of the user + * @param tagId - The unique identifier of the tag + * @param lang - The language for error messages ('fr' or 'en') + * @returns True if the deletion was successful + */ + static deleteSpellTag(userId: string, tagId: string, lang: 'fr' | 'en' = 'fr'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'DELETE FROM book_spell_tags WHERE tag_id=? AND user_id=?'; + const params: SQLiteValue[] = [tagId, userId]; + const result: RunResult = db.run(query, params); + return result.changes > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellTagRepo] DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de supprimer le tag de sort.` : `Unable to delete spell tag.`); + } else { + console.error('[SpellTagRepo] An unknown error occurred.'); + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : 'An unknown error occurred.'); + } + } + } + + /** + * Fetches all spell tags for a book with full table data for sync. + * @param userId - The unique identifier of the user + * @param bookId - The unique identifier of the book + * @param lang - The language for error messages ('fr' or 'en') + * @returns An array of book spell tags table records + */ + static fetchBookSpellTagsTable(userId: string, bookId: string, lang: 'fr' | 'en' = 'fr'): BookSpellTagsTable[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT tag_id, book_id, user_id, name, name_hash, color, last_update FROM book_spell_tags WHERE user_id=? AND book_id=?'; + const params: SQLiteValue[] = [userId, bookId]; + return db.all(query, params) as BookSpellTagsTable[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellTagRepo] DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer les tags de sorts.` : `Unable to retrieve spell tags.`); + } else { + console.error('[SpellTagRepo] An unknown error occurred.'); + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : 'An unknown error occurred.'); + } + } + } + + /** + * Fetches a complete spell tag record by its ID. + * @param userId - The unique identifier of the user + * @param tagId - The unique identifier of the tag + * @param lang - The language for error messages ('fr' or 'en') + * @returns The spell tag table record or null + */ + static fetchSpellTagTableById(userId: string, tagId: string, lang: 'fr' | 'en' = 'fr'): BookSpellTagsTable | null { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT tag_id, book_id, user_id, name, name_hash, color, last_update FROM book_spell_tags WHERE user_id=? AND tag_id=?'; + const params: SQLiteValue[] = [userId, tagId]; + const spellTags: BookSpellTagsTable[] = db.all(query, params) as BookSpellTagsTable[]; + return spellTags.length > 0 ? spellTags[0] : null; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellTagRepo] DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer le tag de sort.` : `Unable to retrieve spell tag.`); + } else { + console.error('[SpellTagRepo] An unknown error occurred.'); + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : 'An unknown error occurred.'); + } + } + } + + /** + * Fetches all synced spell tags for a user. + * @param userId - The unique identifier of the user + * @param lang - The language for error messages ('fr' or 'en') + * @returns An array of synced spell tag results + */ + static fetchSyncedSpellTags(userId: string, lang: 'fr' | 'en' = 'fr'): SyncedSpellTagResult[] { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT tag_id, book_id, name, last_update FROM book_spell_tags WHERE user_id=?'; + const params: SQLiteValue[] = [userId]; + return db.all(query, params) as SyncedSpellTagResult[]; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellTagRepo] DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de récupérer les tags de sorts synchronisés.` : `Unable to retrieve synced spell tags.`); + } else { + console.error('[SpellTagRepo] An unknown error occurred.'); + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : 'An unknown error occurred.'); + } + } + } + + /** + * Inserts or updates a spell tag from synchronization data. + * @param tagId - The unique identifier for the tag + * @param bookId - The unique identifier of the book + * @param userId - The unique identifier of the user + * @param name - The encrypted name + * @param nameHash - The hashed name + * @param color - The optional color hex code + * @param lastUpdate - The timestamp of the last update + * @param lang - The language for error messages ('fr' or 'en') + * @returns True if the insertion was successful + */ + static insertSyncSpellTag(tagId: string, bookId: string, userId: string, name: string, nameHash: string, color: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'INSERT OR REPLACE INTO book_spell_tags (tag_id, book_id, user_id, name, name_hash, color, last_update) VALUES (?,?,?,?,?,?,?)'; + const params: SQLiteValue[] = [tagId, bookId, userId, name, nameHash, color, lastUpdate]; + const result: RunResult = db.run(query, params); + return result.changes > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellTagRepo] DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible d'insérer le tag de sort.` : `Unable to insert spell tag.`); + } else { + console.error('[SpellTagRepo] An unknown error occurred.'); + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : 'An unknown error occurred.'); + } + } + } + + /** + * Checks if a spell tag exists. + * @param userId - The unique identifier of the user + * @param tagId - The unique identifier of the tag + * @param lang - The language for error messages ('fr' or 'en') + * @returns True if the tag exists + */ + static isSpellTagExist(userId: string, tagId: string, lang: 'fr' | 'en' = 'fr'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'SELECT 1 FROM book_spell_tags WHERE tag_id=? AND user_id=?'; + const params: SQLiteValue[] = [tagId, userId]; + const existenceCheck = db.all(query, params); + return existenceCheck.length > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellTagRepo] DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de vérifier l'existence du tag.` : `Unable to check tag existence.`); + } else { + console.error('[SpellTagRepo] An unknown error occurred.'); + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : 'An unknown error occurred.'); + } + } + } + + /** + * Updates a spell tag with timestamp for sync. + * @param userId - The unique identifier of the user + * @param tagId - The unique identifier of the tag + * @param name - The encrypted name + * @param nameHash - The hashed name + * @param color - The optional color hex code + * @param lastUpdate - The timestamp of the last update + * @param lang - The language for error messages ('fr' or 'en') + * @returns True if the update was successful + */ + static updateSyncSpellTag(userId: string, tagId: string, name: string, nameHash: string, color: string | null, lastUpdate: number, lang: 'fr' | 'en' = 'fr'): boolean { + try { + const db: Database = System.getDb(); + const query: string = 'UPDATE book_spell_tags SET name=?, name_hash=?, color=?, last_update=? WHERE tag_id=? AND user_id=?'; + const params: SQLiteValue[] = [name, nameHash, color, lastUpdate, tagId, userId]; + const result: RunResult = db.run(query, params); + return result.changes > 0; + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`[SpellTagRepo] DB Error: ${error.message}`); + throw new Error(lang === 'fr' ? `Impossible de mettre à jour le tag de sort.` : `Unable to update spell tag.`); + } else { + console.error('[SpellTagRepo] An unknown error occurred.'); + throw new Error(lang === 'fr' ? "Une erreur inconnue s'est produite." : 'An unknown error occurred.'); + } + } + } +} diff --git a/electron/ipc/spell.ipc.ts b/electron/ipc/spell.ipc.ts new file mode 100644 index 0000000..b07f118 --- /dev/null +++ b/electron/ipc/spell.ipc.ts @@ -0,0 +1,201 @@ +import { ipcMain } from 'electron'; +import { createHandler } from '../database/LocalSystem.js'; +import Spell from '../database/models/Spell.js'; +import type { + SpellProps, + SpellListResponse, + SpellTagProps, +} from '../database/models/Spell.js'; + +// ==================== INTERFACES ==================== + +interface SpellPost { + id?: string; + name: string; + description: string; + appearance: string; + tags: string[]; + powerLevel?: string | null; + components?: string | null; + limitations?: string | null; + notes?: string | null; +} + +interface GetSpellListData { + bookid: string; +} + +interface GetSpellTagsData { + bookid: string; +} + +interface GetSpellDetailData { + spellid: string; +} + +interface CreateSpellData { + bookId: string; + spell: SpellPost; +} + +interface UpdateSpellData { + spellId: string; + spell: SpellPost; +} + +interface DeleteSpellData { + spellId: string; +} + +interface CreateTagData { + bookId: string; + name: string; + color?: string | null; +} + +interface UpdateTagData { + tagId: string; + name: string; + color?: string | null; +} + +interface DeleteTagData { + tagId: string; + bookId: string; +} + +// ==================== SPELL HANDLERS ==================== + +// GET /spell/list +ipcMain.handle( + 'db:spell:list', + createHandler( + function (userId: string, data: GetSpellListData, lang: 'fr' | 'en'): SpellListResponse { + return Spell.getSpellList(userId, data.bookid, lang); + }, + ), +); + +// GET /spell/tags +ipcMain.handle( + 'db:spell:tags', + createHandler( + function (userId: string, data: GetSpellTagsData, lang: 'fr' | 'en'): SpellTagProps[] { + return Spell.getSpellTags(userId, data.bookid, lang); + }, + ), +); + +// GET /spell/detail +ipcMain.handle( + 'db:spell:detail', + createHandler( + function (userId: string, data: GetSpellDetailData, lang: 'fr' | 'en'): SpellProps { + return Spell.getSpellDetail(userId, data.spellid, lang); + }, + ), +); + +// POST /spell/add +ipcMain.handle( + 'db:spell:create', + createHandler( + function (userId: string, data: CreateSpellData, lang: 'fr' | 'en'): string { + const spell: SpellPost = data.spell; + const result: SpellProps = Spell.addSpell( + userId, + data.bookId, + spell.name, + spell.description, + spell.appearance, + spell.tags || [], + spell.powerLevel || null, + spell.components || null, + spell.limitations || null, + spell.notes || null, + spell.id, + lang, + ); + return result.id; + }, + ), +); + +// PUT /spell/update +ipcMain.handle( + 'db:spell:update', + createHandler( + function (userId: string, data: UpdateSpellData, lang: 'fr' | 'en'): boolean { + const spell: SpellPost = data.spell; + return Spell.updateSpell( + userId, + data.spellId, + spell.name, + spell.description, + spell.appearance, + spell.tags || [], + spell.powerLevel || null, + spell.components || null, + spell.limitations || null, + spell.notes || null, + lang, + ); + }, + ), +); + +// DELETE /spell/delete +ipcMain.handle( + 'db:spell:delete', + createHandler( + function (userId: string, data: DeleteSpellData, lang: 'fr' | 'en'): boolean { + return Spell.deleteSpell(userId, data.spellId, lang); + }, + ), +); + +// ==================== SPELL TAG HANDLERS ==================== + +// POST /spell/tag/add +ipcMain.handle( + 'db:spell:tag:create', + createHandler( + function (userId: string, data: CreateTagData, lang: 'fr' | 'en'): string { + const result: SpellTagProps = Spell.addSpellTag( + userId, + data.bookId, + data.name, + data.color || null, + undefined, + lang, + ); + return result.id; + }, + ), +); + +// PUT /spell/tag/update +ipcMain.handle( + 'db:spell:tag:update', + createHandler( + function (userId: string, data: UpdateTagData, lang: 'fr' | 'en'): boolean { + return Spell.updateSpellTag( + userId, + data.tagId, + data.name, + data.color || null, + lang, + ); + }, + ), +); + +// DELETE /spell/tag/delete +ipcMain.handle( + 'db:spell:tag:delete', + createHandler( + function (userId: string, data: DeleteTagData, lang: 'fr' | 'en'): boolean { + return Spell.deleteSpellTag(userId, data.tagId, data.bookId, lang); + }, + ), +); diff --git a/lib/locales/en.json b/lib/locales/en.json index 0833e10..04ebfa1 100644 --- a/lib/locales/en.json +++ b/lib/locales/en.json @@ -456,6 +456,80 @@ "characterSectionElement": { "newItem": "New {item}" }, + "spellComponent": { + "enableTool": "Enable spell book", + "enableToolDescription": "Manage the spells and magic of your universe.", + "errorNameRequired": "Spell name is required.", + "errorDescriptionRequired": "Spell description is required.", + "errorAppearanceRequired": "Spell appearance is required.", + "errorAddSpell": "Error adding spell.", + "errorUpdateSpell": "Error updating spell.", + "errorDeleteSpell": "Error deleting spell.", + "successAdd": "Spell added successfully.", + "successUpdate": "Spell updated successfully.", + "successDelete": "Spell deleted successfully." + }, + "spellList": { + "search": "Search for a spell...", + "add": "Add a spell", + "manageTags": "Manage tags", + "filterByTag": "Filter by tag", + "filterByLevel": "Filter by level", + "allTags": "All tags", + "allLevels": "All levels", + "noSpells": "No spells created", + "noSpellsDescription": "Add your first spell to get started." + }, + "spellDetail": { + "back": "Back", + "newSpell": "New spell", + "save": "Save", + "delete": "Delete", + "deleteTitle": "Delete spell", + "deleteMessage": "You are about to permanently delete the spell \"{name}\".", + "basicInfo": "Basic information", + "name": "Spell name", + "namePlaceholder": "Enter spell name", + "description": "Description", + "descriptionPlaceholder": "Describe the effects and nature of the spell", + "appearance": "Appearance", + "appearancePlaceholder": "Describe the visual appearance of the spell", + "tags": "Tags", + "addTag": "Add a tag...", + "createTag": "Create \"{name}\"", + "powerLevel": "Power level", + "components": "Components", + "componentsPlaceholder": "Ingredients, gestures, incantations required...", + "limitations": "Limitations", + "limitationsPlaceholder": "Restrictions, side effects, conditions of use...", + "notes": "Notes", + "notesPlaceholder": "Additional notes about the spell..." + }, + "spellTagManager": { + "title": "Tag management", + "back": "Back", + "addTag": "Add a tag", + "tagName": "Tag name", + "tagNamePlaceholder": "Tag name...", + "tagColor": "Color", + "editTag": "Edit tag", + "preview": "Preview", + "deleteTagTitle": "Delete tag", + "delete": "Delete", + "confirmDelete": "Delete this tag? It will be removed from all spells.", + "noTags": "No tags created", + "successAdd": "Tag added successfully.", + "successUpdate": "Tag updated successfully.", + "successDelete": "Tag deleted successfully." + }, + "spellPowerLevels": { + "none": "None", + "minor": "Minor", + "moderate": "Moderate", + "major": "Major", + "legendary": "Legendary", + "divine": "Divine" + }, "aboutEditors": { "title": "About Scribe", "version": "Version", diff --git a/lib/locales/fr.json b/lib/locales/fr.json index 14c528c..c2b7a00 100644 --- a/lib/locales/fr.json +++ b/lib/locales/fr.json @@ -456,6 +456,80 @@ "characterSectionElement": { "newItem": "Nouveau {item}" }, + "spellComponent": { + "enableTool": "Activer le grimoire de sorts", + "enableToolDescription": "Gérez les sorts et la magie de votre univers.", + "errorNameRequired": "Le nom du sort est requis.", + "errorDescriptionRequired": "La description du sort est requise.", + "errorAppearanceRequired": "L'apparence du sort est requise.", + "errorAddSpell": "Erreur lors de l'ajout du sort.", + "errorUpdateSpell": "Erreur lors de la mise à jour du sort.", + "errorDeleteSpell": "Erreur lors de la suppression du sort.", + "successAdd": "Sort ajouté avec succès.", + "successUpdate": "Sort mis à jour avec succès.", + "successDelete": "Sort supprimé avec succès." + }, + "spellList": { + "search": "Rechercher un sort...", + "add": "Ajouter un sort", + "manageTags": "Gérer les tags", + "filterByTag": "Filtrer par tag", + "filterByLevel": "Filtrer par niveau", + "allTags": "Tous les tags", + "allLevels": "Tous les niveaux", + "noSpells": "Aucun sort créé", + "noSpellsDescription": "Ajoutez votre premier sort pour commencer." + }, + "spellDetail": { + "back": "Retour", + "newSpell": "Nouveau sort", + "save": "Enregistrer", + "delete": "Supprimer", + "deleteTitle": "Supprimer le sort", + "deleteMessage": "Vous êtes sur le point de supprimer le sort « {name} » définitivement.", + "basicInfo": "Informations de base", + "name": "Nom du sort", + "namePlaceholder": "Entrez le nom du sort", + "description": "Description", + "descriptionPlaceholder": "Décrivez les effets et la nature du sort", + "appearance": "Apparence", + "appearancePlaceholder": "Décrivez l'apparence visuelle du sort", + "tags": "Tags", + "addTag": "Ajouter un tag...", + "createTag": "Créer \"{name}\"", + "powerLevel": "Niveau de puissance", + "components": "Composantes", + "componentsPlaceholder": "Ingrédients, gestes, incantations nécessaires...", + "limitations": "Limitations", + "limitationsPlaceholder": "Restrictions, effets secondaires, conditions d'utilisation...", + "notes": "Notes", + "notesPlaceholder": "Notes supplémentaires sur le sort..." + }, + "spellTagManager": { + "title": "Gestion des tags", + "back": "Retour", + "addTag": "Ajouter un tag", + "tagName": "Nom du tag", + "tagNamePlaceholder": "Nom du tag...", + "tagColor": "Couleur", + "editTag": "Modifier le tag", + "preview": "Aperçu", + "deleteTagTitle": "Supprimer le tag", + "delete": "Supprimer", + "confirmDelete": "Supprimer ce tag? Il sera retiré de tous les sorts.", + "noTags": "Aucun tag créé", + "successAdd": "Tag ajouté avec succès.", + "successUpdate": "Tag mis à jour avec succès.", + "successDelete": "Tag supprimé avec succès." + }, + "spellPowerLevels": { + "none": "Aucun", + "minor": "Mineur", + "moderate": "Modéré", + "major": "Majeur", + "legendary": "Légendaire", + "divine": "Divin" + }, "aboutEditors": { "title": "À propos de Scribe", "version": "Version", diff --git a/lib/models/Book.ts b/lib/models/Book.ts index 0eadcf1..5055669 100644 --- a/lib/models/Book.ts +++ b/lib/models/Book.ts @@ -61,6 +61,7 @@ export interface BookToolsSettings { characters: boolean; worlds: boolean; locations: boolean; + spells: boolean; } export interface BookProps {