Add comprehensive spell management functionality

- Introduced spell management with creation, editing, deletion, and tagging capabilities.
- Added `Spell`, `SpellList`, `SpellTagManager` models with corresponding IPC handlers for data operations.
- Implemented `SpellList` and `SpellTagChip` components for UI interactions with spells and tags.
- Localized spell-related strings for English (e.g., error messages, tooltips, and prompts).
- Enhanced database models and repositories with encryption and decryption workflows for secure data handling.
- Updated API to include filtering, searching, and tag-based spell management options.
This commit is contained in:
natreex
2026-01-19 21:38:38 -05:00
parent c62a7eb0f7
commit fd09a5531c
14 changed files with 2717 additions and 1 deletions

View File

@@ -670,7 +670,7 @@ export default function Scribe() {
return ( return (
<LangContext.Provider value={{lang: locale, setLang: setLocale}}> <LangContext.Provider value={{lang: locale, setLang: setLocale}}>
<NextIntlClientProvider locale={locale} messages={messages}> <NextIntlClientProvider locale={locale} messages={messages} timeZone="America/Montreal">
<OfflineProvider> <OfflineProvider>
<AlertProvider> <AlertProvider>
<ScribeContent/> <ScribeContent/>

View File

@@ -98,6 +98,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
setBook({...book, tools: { setBook({...book, tools: {
characters: book.tools?.characters ?? false, characters: book.tools?.characters ?? false,
worlds: book.tools?.worlds ?? false, worlds: book.tools?.worlds ?? false,
spells: book.tools?.spells ?? false,
locations: enabled locations: enabled
}}); }});
} }
@@ -129,6 +130,7 @@ export function LocationComponent({showToggle = true}: {showToggle?: boolean}, r
setBook({...book, tools: { setBook({...book, tools: {
characters: book.tools?.characters ?? false, characters: book.tools?.characters ?? false,
worlds: book.tools?.worlds ?? false, worlds: book.tools?.worlds ?? false,
spells: book.tools?.spells ?? false,
locations: response.enabled locations: response.enabled
}}); }});
} }

View File

@@ -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<void> }>) {
const {showToggle = true} = props;
const t = useTranslations();
const {lang} = useContext<LangContextProps>(LangContext);
const {isCurrentlyOffline} = useContext<OfflineContextType>(OfflineContext);
const {addToQueue} = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks} = useContext<BooksSyncContextProps>(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<SpellListItem[]>([]);
const [tags, setTags] = useState<SpellTagProps[]>([]);
const [selectedSpell, setSelectedSpell] = useState<SpellEditState | null>(null);
const [toolEnabled, setToolEnabled] = useState<boolean>(book?.tools?.spells ?? false);
const [showTagManager, setShowTagManager] = useState<boolean>(false);
useImperativeHandle(ref, function () {
return {
handleSave: handleSaveSpell,
};
});
useEffect((): void => {
getSpells().then();
}, []);
async function handleToggleTool(enabled: boolean): Promise<void> {
try {
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:book:tool:update', {
bookId: bookId,
toolName: 'spells',
enabled: enabled
});
} else {
response = await System.authPatchToServer<boolean>('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<void> {
try {
let response: SpellListResponse;
if (isCurrentlyOffline()) {
response = await window.electron.invoke<SpellListResponse>('db:spell:list', {bookid: bookId});
} else {
if (book?.localBook) {
response = await window.electron.invoke<SpellListResponse>('db:spell:list', {bookid: bookId});
} else {
response = await System.authGetQueryToServer<SpellListResponse>('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<void> {
// 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<SpellProps>('db:spell:detail', {spellid: spell.id});
} else {
if (book?.localBook) {
response = await window.electron.invoke<SpellProps>('db:spell:detail', {spellid: spell.id});
} else {
response = await System.authGetQueryToServer<SpellProps>('spell/detail', 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<void> {
if (selectedSpell) {
if (selectedSpell.id === null) {
await addNewSpell(selectedSpell);
} else {
await updateSpell(selectedSpell);
}
}
}
async function addNewSpell(spell: SpellEditState): Promise<void> {
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<string>('db:spell:create', {
bookId: bookId,
spell: spellPost,
});
} else {
const createdSpell: SpellProps = await System.authPostToServer<SpellProps>('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<void> {
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<boolean>('db:spell:update', {
spellId: spell.id,
spell: spellPost,
});
} else {
response = await System.authPutToServer<boolean>('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<void> {
try {
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:spell:delete', {
spellId: spellId,
});
} else {
response = await System.authDeleteToServer<boolean>('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<SpellTagProps | null> {
try {
let tagId: string;
if (isCurrentlyOffline() || book?.localBook) {
tagId = await window.electron.invoke<string>('db:spell:tag:create', {
bookId: bookId,
name: name,
color: color,
});
} else {
const newTag: SpellTagProps = await System.authPostToServer<SpellTagProps>('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<boolean> {
try {
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:spell:tag:update', {
tagId: tagId,
name: name,
color: color,
});
} else {
response = await System.authPutToServer<boolean>('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<boolean> {
try {
let response: boolean;
if (isCurrentlyOffline() || book?.localBook) {
response = await window.electron.invoke<boolean>('db:spell:tag:delete', {
tagId: tagId,
bookId: bookId,
});
} else {
response = await System.authDeleteToServer<boolean>('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 (
<div className="space-y-5">
{showToggle && (
<div className="bg-secondary/20 rounded-xl p-5 shadow-inner border border-secondary/30">
<InputField
icon={faToggleOn}
fieldName={t('spellComponent.enableTool')}
input={
<ToggleSwitch
checked={toolEnabled}
onChange={async (checked: boolean): Promise<void> => handleToggleTool(checked)}
/>
}
/>
<p className="text-muted text-sm mt-2">
{t('spellComponent.enableToolDescription')}
</p>
</div>
)}
{toolEnabled && (
<>
{showTagManager ? (
<SpellTagManager
tags={tags}
onBack={handleBackFromTagManager}
onCreateTag={handleCreateTag}
onUpdateTag={handleUpdateTag}
onDeleteTag={handleDeleteTag}
/>
) : selectedSpell ? (
<SpellDetail
selectedSpell={selectedSpell}
setSelectedSpell={setSelectedSpell}
availableTags={tags}
handleSpellChange={handleSpellChange}
handleSaveSpell={handleSaveSpell}
handleDeleteSpell={handleDeleteSpell}
handleCreateTagInline={handleCreateTag}
/>
) : (
<SpellList
spells={spells}
tags={tags}
handleSpellClick={handleSpellClick}
handleAddSpell={handleAddSpell}
handleManageTags={handleManageTags}
/>
)}
</>
)}
</div>
);
}
export default forwardRef(SpellComponent);

View File

@@ -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<SetStateAction<SpellEditState | null>>;
availableTags: SpellTagProps[];
handleSpellChange: (key: keyof SpellEditState, value: string | string[] | null) => void;
handleSaveSpell: () => Promise<void>;
handleDeleteSpell: (spellId: string) => Promise<void>;
handleCreateTagInline: (name: string, color: string) => Promise<SpellTagProps | null>;
}
export default function SpellDetail(
{
selectedSpell,
setSelectedSpell,
availableTags,
handleSpellChange,
handleSaveSpell,
handleDeleteSpell,
handleCreateTagInline,
}: SpellDetailProps) {
const t = useTranslations();
const [tagSearchQuery, setTagSearchQuery] = useState<string>('');
const [showTagDropdown, setShowTagDropdown] = useState<boolean>(false);
const [isCreatingTag, setIsCreatingTag] = useState<boolean>(false);
const [newTagColor, setNewTagColor] = useState<string>(defaultTagColors[0]);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(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<void> {
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 (
<div className="space-y-4">
<div
className="flex justify-between items-center p-4 border-b border-secondary/50 bg-tertiary/50 backdrop-blur-sm">
<button
onClick={() => setSelectedSpell(null)}
className="flex items-center gap-2 bg-secondary/50 py-2 px-4 rounded-xl border border-secondary/50 hover:bg-secondary hover:border-secondary hover:shadow-md hover:scale-105 transition-all duration-200"
>
<FontAwesomeIcon icon={faArrowLeft} className="text-primary w-4 h-4"/>
<span className="text-text-primary font-medium">{t("spellDetail.back")}</span>
</button>
<span className="text-text-primary font-semibold text-lg">
{selectedSpell.name || t("spellDetail.newSpell")}
</span>
<div className="flex items-center gap-2">
{selectedSpell.id && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center justify-center bg-error/90 hover:bg-error w-10 h-10 rounded-xl border border-error shadow-md hover:shadow-lg hover:scale-110 transition-all duration-200"
>
<FontAwesomeIcon icon={faTrash} className="text-text-primary w-5 h-5"/>
</button>
)}
<button
onClick={handleSaveSpell}
className="flex items-center justify-center bg-primary w-10 h-10 rounded-xl border border-primary-dark shadow-md hover:shadow-lg hover:scale-110 transition-all duration-200"
>
<FontAwesomeIcon icon={selectedSpell.id ? faSave : faPlus}
className="text-text-primary w-5 h-5"/>
</button>
</div>
{showDeleteConfirm && selectedSpell.id && (
<AlertBox
title={t("spellDetail.deleteTitle")}
message={t("spellDetail.deleteMessage", {name: selectedSpell.name})}
type="danger"
confirmText={t("common.delete")}
cancelText={t("common.cancel")}
onConfirm={async (): Promise<void> => {
await handleDeleteSpell(selectedSpell.id as string);
setShowDeleteConfirm(false);
}}
onCancel={(): void => setShowDeleteConfirm(false)}
/>
)}
</div>
<div className="overflow-y-auto max-h-[calc(100vh-350px)] space-y-4 px-2 pb-4">
<CollapsableArea title={t("spellDetail.basicInfo")} icon={faHatWizard}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<InputField
fieldName={t("spellDetail.name")}
input={
<TextInput
value={selectedSpell.name}
setValue={(e) => handleSpellChange('name', e.target.value)}
placeholder={t("spellDetail.namePlaceholder")}
/>
}
/>
<InputField
fieldName={t("spellDetail.description")}
icon={faBook}
input={
<TexteAreaInput
value={selectedSpell.description}
setValue={(e) => handleSpellChange('description', e.target.value)}
placeholder={t("spellDetail.descriptionPlaceholder")}
/>
}
/>
<InputField
fieldName={t("spellDetail.appearance")}
icon={faEye}
input={
<TexteAreaInput
value={selectedSpell.appearance}
setValue={(e) => handleSpellChange('appearance', e.target.value)}
placeholder={t("spellDetail.appearancePlaceholder")}
/>
}
/>
</div>
</CollapsableArea>
<CollapsableArea title={t("spellDetail.tags")} icon={faTags}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{selectedTags.map((tag: SpellTagProps) => (
<SpellTagChip
key={tag.id}
tag={tag}
onRemove={() => handleRemoveTag(tag.id)}
/>
))}
</div>
)}
<div className="relative">
<TextInput
value={tagSearchQuery}
setValue={(e) => {
setTagSearchQuery(e.target.value);
setShowTagDropdown(true);
}}
placeholder={t("spellDetail.addTag")}
onFocus={() => setShowTagDropdown(true)}
/>
{showTagDropdown && (tagSearchQuery || filteredTags.length > 0) && (
<div
className="absolute z-10 w-full mt-1 bg-tertiary border border-secondary/50 rounded-xl shadow-lg max-h-48 overflow-y-auto">
{filteredTags.map((tag: SpellTagProps) => (
<button
key={tag.id}
onClick={() => handleAddTag(tag.id)}
className="w-full flex items-center gap-2 px-4 py-2 hover:bg-secondary/50 transition-colors text-left"
>
<span
className="w-3 h-3 rounded-full"
style={{backgroundColor: tag.color || '#3B82F6'}}
/>
<span className="text-text-primary">{tag.name}</span>
</button>
))}
{showCreateOption && !isCreatingTag && (
<button
onClick={() => setIsCreatingTag(true)}
className="w-full flex items-center gap-2 px-4 py-2 hover:bg-secondary/50 transition-colors text-left border-t border-secondary/30"
>
<FontAwesomeIcon icon={faPlus} className="text-primary w-3 h-3"/>
<span className="text-primary font-medium">
{t("spellDetail.createTag", {name: tagSearchQuery})}
</span>
</button>
)}
{isCreatingTag && (
<div className="p-4 border-t border-secondary/30">
<p className="text-text-secondary text-sm mb-3">
{t("spellDetail.createTag", {name: tagSearchQuery})}
</p>
<div className="flex flex-wrap gap-2 mb-3">
{defaultTagColors.map((color: string) => (
<button
key={color}
onClick={() => setNewTagColor(color)}
className={`w-8 h-8 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-2 ring-primary scale-110' : 'hover:scale-110'}`}
style={{backgroundColor: color}}
/>
))}
</div>
<div className="flex gap-2">
<button
onClick={() => setIsCreatingTag(false)}
className="flex-1 py-2 px-3 bg-secondary/50 text-text-primary rounded-lg hover:bg-secondary transition-colors"
>
{t("common.cancel")}
</button>
<button
onClick={handleCreateTag}
className="flex-1 py-2 px-3 bg-primary text-text-primary rounded-lg hover:bg-primary-dark transition-colors"
>
{t("common.confirm")}
</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
</CollapsableArea>
<CollapsableArea title={t("spellDetail.powerLevel")} icon={faBolt}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<SelectBox
defaultValue={selectedSpell.powerLevel || 'none'}
onChangeCallBack={(e) => handleSpellChange('powerLevel', e.target.value === 'none' ? null : e.target.value)}
data={getLocalizedPowerLevels()}
/>
</div>
</CollapsableArea>
<CollapsableArea title={t("spellDetail.components")} icon={faPuzzlePiece}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<TexteAreaInput
value={selectedSpell.components || ''}
setValue={(e) => handleSpellChange('components', e.target.value || null)}
placeholder={t("spellDetail.componentsPlaceholder")}
/>
</div>
</CollapsableArea>
<CollapsableArea title={t("spellDetail.limitations")} icon={faTriangleExclamation}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<TexteAreaInput
value={selectedSpell.limitations || ''}
setValue={(e) => handleSpellChange('limitations', e.target.value || null)}
placeholder={t("spellDetail.limitationsPlaceholder")}
/>
</div>
</CollapsableArea>
<CollapsableArea title={t("spellDetail.notes")} icon={faStickyNote}>
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
<TexteAreaInput
value={selectedSpell.notes || ''}
setValue={(e) => handleSpellChange('notes', e.target.value || null)}
placeholder={t("spellDetail.notesPlaceholder")}
/>
</div>
</CollapsableArea>
</div>
</div>
);
}

View File

@@ -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<string>('');
const [filterTag, setFilterTag] = useState<string>('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 (
<div className="space-y-4">
<div className="px-4 space-y-3">
<InputField
input={
<TextInput
value={searchQuery}
setValue={(e) => setSearchQuery(e.target.value)}
placeholder={t("spellList.search")}
/>
}
actionIcon={faPlus}
actionLabel={t("spellList.add")}
addButtonCallBack={async () => handleAddSpell()}
/>
<div className="flex flex-wrap gap-3 items-center">
<div className="flex-1 min-w-[150px]">
<select
value={filterTag}
onChange={(e) => setFilterTag(e.target.value)}
className="w-full text-text-primary bg-secondary/50 hover:bg-secondary px-4 py-2.5 rounded-xl border border-secondary/50 focus:border-primary focus:ring-4 focus:ring-primary/20 focus:bg-secondary hover:border-secondary outline-none transition-all duration-200 cursor-pointer"
>
<option value="all"
className="bg-tertiary text-text-primary">{t("spellList.allTags")}</option>
{tags.map((tag: SpellTagProps) => (
<option key={tag.id} value={tag.id} className="bg-tertiary text-text-primary">
{tag.name}
</option>
))}
</select>
</div>
<button
onClick={handleManageTags}
className="flex items-center gap-2 px-4 py-2.5 bg-secondary/50 rounded-xl border border-secondary/50 hover:bg-secondary hover:border-secondary hover:shadow-md hover:scale-105 transition-all duration-200"
>
<FontAwesomeIcon icon={faCog} className="text-primary w-4 h-4"/>
<span className="text-text-primary text-sm font-medium">{t("spellList.manageTags")}</span>
</button>
</div>
</div>
<div className="overflow-y-auto max-h-[calc(100vh-450px)] px-2">
{filteredSpells.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-10 h-10"/>
</div>
<h3 className="text-text-primary font-semibold text-lg mb-2">{t("spellList.noSpells")}</h3>
<p className="text-muted text-sm max-w-xs">{t("spellList.noSpellsDescription")}</p>
</div>
) : (
<div className="space-y-2 p-2">
{filteredSpells.map((spell: SpellListItem) => (
<div
key={spell.id}
onClick={() => 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"
>
<div
className="w-12 h-12 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-md group-hover:scale-110 transition-transform flex items-center justify-center">
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-6 h-6"/>
</div>
<div className="ml-4 flex-1 min-w-0">
<div
className="text-text-primary font-bold text-base group-hover:text-primary transition-colors">
{spell.name}
</div>
<div className="text-text-secondary text-sm mt-0.5 truncate">
{spell.description}
</div>
{spell.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{spell.tags.slice(0, 3).map((tag: SpellTagProps) => (
<SpellTagChip key={tag.id} tag={tag} size="sm"/>
))}
{spell.tags.length > 3 && (
<span
className="text-muted text-xs px-2 py-0.5 bg-secondary/50 rounded-full">
+{spell.tags.length - 3}
</span>
)}
</div>
)}
</div>
<div className="w-8 flex justify-center">
<FontAwesomeIcon
icon={faChevronRight}
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-4 h-4"
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -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 (
<span
className={chipClasses}
style={{
backgroundColor: tag.color || '#3B82F6',
color: getContrastColor(tag.color)
}}
onClick={onClick}
>
{tag.name}
{onRemove && (
<button
onClick={(e: React.MouseEvent): void => {
e.stopPropagation();
onRemove();
}}
className="ml-0.5 hover:bg-white/20 rounded-full p-0.5 transition-all duration-200 hover:scale-110"
>
<FontAwesomeIcon icon={faX} className={size === 'sm' ? 'w-2 h-2' : 'w-2.5 h-2.5'}/>
</button>
)}
</span>
);
}

View File

@@ -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<SpellTagProps | null>;
onUpdateTag: (tagId: string, name: string, color: string) => Promise<boolean>;
onDeleteTag: (tagId: string) => Promise<boolean>;
}
export default function SpellTagManager(
{
tags,
onBack,
onCreateTag,
onUpdateTag,
onDeleteTag,
}: SpellTagManagerProps) {
const t = useTranslations();
const {successMessage} = useContext(AlertContext);
const [newTagName, setNewTagName] = useState<string>('');
const [newTagColor, setNewTagColor] = useState<string>(defaultTagColors[0]);
const [editingTag, setEditingTag] = useState<SpellTagProps | null>(null);
const [editTagName, setEditTagName] = useState<string>('');
const [editTagColor, setEditTagColor] = useState<string>('');
const [showEditModal, setShowEditModal] = useState<boolean>(false);
const [tagToDelete, setTagToDelete] = useState<SpellTagProps | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
async function handleAddTag(): Promise<void> {
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<void> {
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<void> {
if (!tagToDelete) {
return;
}
const success: boolean = await onDeleteTag(tagToDelete.id);
if (success) {
setShowDeleteConfirm(false);
setTagToDelete(null);
successMessage(t("spellTagManager.successDelete"));
}
}
return (
<div className="space-y-4">
<div
className="flex justify-between items-center p-4 border-b border-secondary/50 bg-tertiary/50 backdrop-blur-sm">
<button
onClick={onBack}
className="flex items-center gap-2 bg-secondary/50 py-2 px-4 rounded-xl border border-secondary/50 hover:bg-secondary hover:border-secondary hover:shadow-md hover:scale-105 transition-all duration-200"
>
<FontAwesomeIcon icon={faArrowLeft} className="text-primary w-4 h-4"/>
<span className="text-text-primary font-medium">{t("spellTagManager.back")}</span>
</button>
<span className="text-text-primary font-semibold text-lg flex items-center gap-2">
<FontAwesomeIcon icon={faTags} className="text-primary w-5 h-5"/>
{t("spellTagManager.title")}
</span>
<div className="w-24"/>
</div>
<div className="px-4 space-y-4">
<div className="bg-secondary/20 rounded-xl p-4 border border-secondary/30">
<h3 className="text-text-primary font-semibold mb-3">{t("spellTagManager.addTag")}</h3>
<div className="space-y-3">
<InputField
fieldName={t("spellTagManager.tagName")}
input={
<TextInput
value={newTagName}
setValue={(e) => setNewTagName(e.target.value)}
placeholder={t("spellTagManager.tagNamePlaceholder")}
/>
}
/>
<div>
<span
className="text-text-primary text-sm font-medium mb-2 block">{t("spellTagManager.tagColor")}</span>
<div className="flex flex-wrap gap-2">
{defaultTagColors.map((color: string) => (
<button
key={color}
onClick={() => setNewTagColor(color)}
className={`w-10 h-10 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-2 ring-primary scale-110' : 'hover:scale-110'}`}
style={{backgroundColor: color}}
/>
))}
</div>
</div>
<button
onClick={handleAddTag}
disabled={!newTagName.trim()}
className="w-full flex items-center justify-center gap-2 py-2.5 bg-primary text-text-primary rounded-xl hover:bg-primary-dark transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<FontAwesomeIcon icon={faPlus} className="w-4 h-4"/>
{t("spellTagManager.addTag")}
</button>
</div>
</div>
<div className="overflow-y-auto max-h-[calc(100vh-500px)]">
{tags.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<FontAwesomeIcon icon={faTags} className="text-primary w-8 h-8"/>
</div>
<p className="text-muted text-sm">{t("spellTagManager.noTags")}</p>
</div>
) : (
<div className="space-y-2">
{tags.map((tag: SpellTagProps) => (
<div
key={tag.id}
className="flex items-center justify-between p-3 bg-secondary/30 rounded-xl border border-secondary/50"
>
<SpellTagChip tag={tag}/>
<div className="flex items-center gap-2">
<button
onClick={() => handleEditClick(tag)}
className="p-2 bg-secondary/50 rounded-lg hover:bg-secondary hover:scale-110 transition-all duration-200"
>
<FontAwesomeIcon icon={faEdit} className="text-primary w-4 h-4"/>
</button>
<button
onClick={() => handleDeleteClick(tag)}
className="p-2 bg-error/10 rounded-lg hover:bg-error/20 hover:scale-110 transition-all duration-200"
>
<FontAwesomeIcon icon={faTrash} className="text-error w-4 h-4"/>
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
{showEditModal && editingTag && (
<Modal
title={t("spellTagManager.editTag")}
size="small"
onClose={() => setShowEditModal(false)}
onConfirm={handleUpdateTag}
confirmText={t("common.confirm")}
cancelText={t("common.cancel")}
>
<div className="p-6 space-y-4">
<InputField
fieldName={t("spellTagManager.tagName")}
input={
<TextInput
value={editTagName}
setValue={(e) => setEditTagName(e.target.value)}
placeholder={t("spellTagManager.tagNamePlaceholder")}
/>
}
/>
<div>
<span
className="text-text-primary text-sm font-medium mb-2 block">{t("spellTagManager.tagColor")}</span>
<div className="flex flex-wrap gap-2">
{defaultTagColors.map((color: string) => (
<button
key={color}
onClick={() => setEditTagColor(color)}
className={`w-10 h-10 rounded-full transition-all duration-200 ${editTagColor === color ? 'ring-2 ring-offset-2 ring-primary scale-110' : 'hover:scale-110'}`}
style={{backgroundColor: color}}
/>
))}
</div>
</div>
<div className="pt-2">
<span className="text-text-secondary text-sm">{t("spellTagManager.preview")}</span>
<div className="mt-2">
<SpellTagChip tag={{
id: editingTag.id,
name: editTagName || editingTag.name,
color: editTagColor
}}/>
</div>
</div>
</div>
</Modal>
)}
{showDeleteConfirm && tagToDelete && (
<Modal
title={t("spellTagManager.deleteTagTitle")}
size="small"
onClose={() => setShowDeleteConfirm(false)}
onConfirm={handleDeleteTag}
confirmText={t("spellTagManager.delete")}
cancelText={t("common.cancel")}
>
<div className="p-6">
<p className="text-text-secondary">{t("spellTagManager.confirmDelete")}</p>
<div className="mt-4">
<SpellTagChip tag={tagToDelete}/>
</div>
</div>
</Modal>
)}
</div>
);
}

View File

@@ -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<string, SpellTagProps> = 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);
}
}

View File

@@ -0,0 +1,368 @@
import { Database, RunResult, SQLiteValue } from 'node-sqlite3-wasm';
import System from '../System.js';
export interface SpellResult extends Record<string, SQLiteValue> {
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<string, SQLiteValue> {
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<string, SQLiteValue> {
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.`);
}
}
}

View File

@@ -0,0 +1,292 @@
import { Database, RunResult, SQLiteValue } from 'node-sqlite3-wasm';
import System from '../System.js';
export interface SpellTagResult extends Record<string, SQLiteValue> {
tag_id: string;
book_id: string;
name: string;
color: string | null;
}
export interface BookSpellTagsTable extends Record<string, SQLiteValue> {
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<string, SQLiteValue> {
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.');
}
}
}
}

201
electron/ipc/spell.ipc.ts Normal file
View File

@@ -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<GetSpellListData, SpellListResponse>(
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<GetSpellTagsData, SpellTagProps[]>(
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<GetSpellDetailData, SpellProps>(
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<CreateSpellData, string>(
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<UpdateSpellData, boolean>(
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<DeleteSpellData, boolean>(
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<CreateTagData, string>(
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<UpdateTagData, boolean>(
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<DeleteTagData, boolean>(
function (userId: string, data: DeleteTagData, lang: 'fr' | 'en'): boolean {
return Spell.deleteSpellTag(userId, data.tagId, data.bookId, lang);
},
),
);

View File

@@ -456,6 +456,80 @@
"characterSectionElement": { "characterSectionElement": {
"newItem": "New {item}" "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": { "aboutEditors": {
"title": "About Scribe", "title": "About Scribe",
"version": "Version", "version": "Version",

View File

@@ -456,6 +456,80 @@
"characterSectionElement": { "characterSectionElement": {
"newItem": "Nouveau {item}" "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": { "aboutEditors": {
"title": "À propos de Scribe", "title": "À propos de Scribe",
"version": "Version", "version": "Version",

View File

@@ -61,6 +61,7 @@ export interface BookToolsSettings {
characters: boolean; characters: boolean;
worlds: boolean; worlds: boolean;
locations: boolean; locations: boolean;
spells: boolean;
} }
export interface BookProps { export interface BookProps {