- 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.
326 lines
17 KiB
TypeScript
326 lines
17 KiB
TypeScript
'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>
|
|
);
|
|
}
|