Add spell management to book settings
- Moved spell-related components to the `book/settings/spells` directory for better organization. - Added "Spells" as a new tool in book settings and composer sidebar with localization support. - Integrated spell-related UI elements (`SpellComponent`, `SpellList`, `SpellTagManager`) into settings and sidebars. - Updated logic to handle enabling/disabling of the spells tool per book.
This commit is contained in:
@@ -7,6 +7,7 @@ import {RefObject, useRef} from "react";
|
||||
import PanelHeader from "@/components/PanelHeader";
|
||||
import LocationComponent from "@/components/book/settings/locations/LocationComponent";
|
||||
import CharacterComponent from "@/components/book/settings/characters/CharacterComponent";
|
||||
import SpellComponent from "@/components/book/settings/spells/SpellComponent";
|
||||
import QuillSenseSetting from "@/components/book/settings/quillsense/QuillSenseSetting";
|
||||
import {useTranslations} from "next-intl"; // Ajouté pour la traduction
|
||||
|
||||
@@ -36,6 +37,9 @@ export default function BookSettingOption(
|
||||
const characterRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
|
||||
handleSave: () => Promise<void>
|
||||
}>(null);
|
||||
const spellRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
|
||||
handleSave: () => Promise<void>
|
||||
}>(null);
|
||||
const quillSenseRef: RefObject<{ handleSave: () => Promise<void> } | null> = useRef<{
|
||||
handleSave: () => Promise<void>
|
||||
}>(null);
|
||||
@@ -54,6 +58,8 @@ export default function BookSettingOption(
|
||||
return t("bookSettingOption.yourLocations");
|
||||
case 'characters':
|
||||
return t("bookSettingOption.characters");
|
||||
case 'spells':
|
||||
return t("bookSettingOption.spells");
|
||||
case 'objects':
|
||||
return t("bookSettingOption.objectsList");
|
||||
case 'goals':
|
||||
@@ -85,6 +91,9 @@ export default function BookSettingOption(
|
||||
case 'characters':
|
||||
characterRef.current?.handleSave();
|
||||
break;
|
||||
case 'spells':
|
||||
spellRef.current?.handleSave();
|
||||
break;
|
||||
case 'quillsense':
|
||||
quillSenseRef.current?.handleSave();
|
||||
break;
|
||||
@@ -118,6 +127,8 @@ export default function BookSettingOption(
|
||||
<LocationComponent ref={locationRef}/>
|
||||
) : setting === 'characters' ? (
|
||||
<CharacterComponent ref={characterRef}/>
|
||||
) : setting === 'spells' ? (
|
||||
<SpellComponent ref={spellRef}/>
|
||||
) : setting === 'quillsense' ? (
|
||||
<QuillSenseSetting ref={quillSenseRef}/>
|
||||
) : <div
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
'use client'
|
||||
// Removed Next.js Link import for Electron
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faBook, faFeather, faGlobe, faListAlt, faMapMarkedAlt, faPencilAlt, faUser} from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faBook,
|
||||
faGlobe,
|
||||
faHatWizard,
|
||||
faListAlt,
|
||||
faMapMarkedAlt,
|
||||
faPencilAlt,
|
||||
faUser,
|
||||
faWandMagicSparkles
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import {Dispatch, SetStateAction} from "react";
|
||||
import {IconDefinition} from "@fortawesome/fontawesome-svg-core";
|
||||
import {useTranslations} from "next-intl";
|
||||
@@ -53,10 +62,15 @@ export default function BookSettingSidebar(
|
||||
name: 'bookSetting.characters',
|
||||
icon: faUser
|
||||
},
|
||||
{
|
||||
id: 'spells',
|
||||
name: 'bookSetting.spells',
|
||||
icon: faHatWizard
|
||||
},
|
||||
{
|
||||
id: 'quillsense',
|
||||
name: 'bookSetting.quillsense',
|
||||
icon: faFeather
|
||||
icon: faWandMagicSparkles
|
||||
},
|
||||
// {
|
||||
// id: 'objects',
|
||||
|
||||
563
components/book/settings/spells/SpellComponent.tsx
Normal file
563
components/book/settings/spells/SpellComponent.tsx
Normal 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);
|
||||
325
components/book/settings/spells/SpellDetail.tsx
Normal file
325
components/book/settings/spells/SpellDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
components/book/settings/spells/SpellList.tsx
Normal file
141
components/book/settings/spells/SpellList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
components/book/settings/spells/SpellTagChip.tsx
Normal file
66
components/book/settings/spells/SpellTagChip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
250
components/book/settings/spells/SpellTagManager.tsx
Normal file
250
components/book/settings/spells/SpellTagManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user