Remove CharacterComponent and CharacterDetail components
- Deleted `CharacterComponent` and `CharacterDetail` files from the project. - Refactored related logic to improve code maintainability and reduce redundancy.
This commit is contained in:
@@ -1,563 +0,0 @@
|
||||
'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);
|
||||
@@ -1,310 +0,0 @@
|
||||
'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 DeleteButton from "@/components/form/DeleteButton";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faArrowLeft,
|
||||
faBolt,
|
||||
faBook,
|
||||
faEye,
|
||||
faHatWizard,
|
||||
faPlus,
|
||||
faPuzzlePiece,
|
||||
faSave,
|
||||
faStickyNote,
|
||||
faTags,
|
||||
faTriangleExclamation
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import {useTranslations} from "next-intl";
|
||||
|
||||
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]);
|
||||
|
||||
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 && (
|
||||
<DeleteButton
|
||||
onDelete={(): Promise<void> => handleDeleteSpell(selectedSpell.id as string)}
|
||||
confirmTitle={t("spellDetail.deleteTitle")}
|
||||
confirmMessage={t("spellDetail.deleteMessage", {name: selectedSpell.name})}
|
||||
confirmButtonText={t("common.delete")}
|
||||
cancelButtonText={t("common.cancel")}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
217
components/book/settings/spells/editor/SpellEditor.tsx
Normal file
217
components/book/settings/spells/editor/SpellEditor.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
import React, {useCallback, useContext, useMemo, useState} from 'react';
|
||||
import {useSpells, UseSpellsConfig} from '@/hooks/settings/useSpells';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {SpellEditState, SpellListItem} from '@/lib/models/Spell';
|
||||
import {SeriesSpellListItem} from '@/lib/models/Series';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import ToggleSwitch from '@/components/form/ToggleSwitch';
|
||||
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
|
||||
import AlertBox from '@/components/AlertBox';
|
||||
import SpellTagManager from '@/components/book/settings/spells/SpellTagManager';
|
||||
|
||||
import SpellEditorList from './SpellEditorList';
|
||||
import SpellEditorDetail from './SpellEditorDetail';
|
||||
import SpellEditorEdit from './SpellEditorEdit';
|
||||
|
||||
/**
|
||||
* SpellEditor - Orchestrateur pour ComposerRightBar
|
||||
* Mêmes fonctionnalités que SpellSettings, layout condensé
|
||||
*/
|
||||
export default function SpellEditor(): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {book} = useContext(BookContext);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
|
||||
|
||||
const config: UseSpellsConfig = useMemo(function (): UseSpellsConfig {
|
||||
return {
|
||||
entityType: 'book',
|
||||
entityId: book?.bookId || '',
|
||||
};
|
||||
}, [book?.bookId]);
|
||||
|
||||
const {
|
||||
spells,
|
||||
seriesSpells,
|
||||
tags,
|
||||
selectedSpell,
|
||||
selectedSeriesSpell,
|
||||
toolEnabled,
|
||||
isLoading,
|
||||
bookSeriesId,
|
||||
showTagManager,
|
||||
viewMode,
|
||||
saveSpell,
|
||||
deleteSpell,
|
||||
updateSpellField,
|
||||
toggleTool,
|
||||
importFromSeries,
|
||||
exportToSeries,
|
||||
setSelectedSpell,
|
||||
setShowTagManager,
|
||||
enterDetailMode,
|
||||
enterEditMode,
|
||||
exitEditMode,
|
||||
backToList,
|
||||
addNewSpell,
|
||||
createTag,
|
||||
updateTag,
|
||||
deleteTag,
|
||||
handleSyncComplete,
|
||||
} = useSpells(config);
|
||||
|
||||
const availableSeriesSpells = useMemo(function (): SeriesSpellListItem[] {
|
||||
return seriesSpells.filter(function (ss: SeriesSpellListItem): boolean {
|
||||
return !spells.some(function (s: SpellListItem): boolean {
|
||||
return s.seriesSpellId === ss.id;
|
||||
});
|
||||
});
|
||||
}, [seriesSpells, spells]);
|
||||
|
||||
const handleSpellChange = useCallback(function (key: keyof SpellEditState, value: string | string[] | null): void {
|
||||
updateSpellField(key, value);
|
||||
}, [updateSpellField]);
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
await exitEditMode(true);
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
exitEditMode(false);
|
||||
}
|
||||
|
||||
async function handleDelete(): Promise<void> {
|
||||
if (selectedSpell?.id) {
|
||||
await deleteSpell(selectedSpell.id);
|
||||
setShowDeleteConfirm(false);
|
||||
backToList();
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-6 h-6 text-primary animate-spin"/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isNew: boolean = selectedSpell?.id === null;
|
||||
const canExport: boolean = Boolean(bookSeriesId && selectedSpell?.id && !selectedSpell.seriesSpellId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ToolDetailHeader
|
||||
title={selectedSpell?.name || ''}
|
||||
defaultTitle={t('spellDetail.newSpell')}
|
||||
viewMode={viewMode}
|
||||
isNew={isNew}
|
||||
onBack={backToList}
|
||||
onEdit={enterEditMode}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onDelete={function (): void { setShowDeleteConfirm(true); }}
|
||||
onExport={canExport ? exportToSeries : undefined}
|
||||
showExport={canExport}
|
||||
showDelete={Boolean(selectedSpell?.id)}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{viewMode === 'list' && (
|
||||
<div className="space-y-3 p-2">
|
||||
{/* Toggle tool */}
|
||||
<div className="bg-secondary/20 rounded-lg p-3 border border-secondary/30">
|
||||
<InputField
|
||||
icon={faToggleOn}
|
||||
fieldName={t('spellComponent.enableTool')}
|
||||
input={
|
||||
<ToggleSwitch
|
||||
checked={toolEnabled}
|
||||
onChange={toggleTool}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{toolEnabled && (
|
||||
<>
|
||||
{/* Import from series */}
|
||||
{bookSeriesId && availableSeriesSpells.length > 0 && (
|
||||
<SeriesImportSelector
|
||||
availableItems={availableSeriesSpells.map(function (ss: SeriesSpellListItem) {
|
||||
return {
|
||||
id: ss.id,
|
||||
name: ss.name
|
||||
};
|
||||
})}
|
||||
onImport={importFromSeries}
|
||||
placeholder={t('seriesImport.selectElement')}
|
||||
label={t('seriesImport.importFromSeries')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SpellEditorList
|
||||
spells={spells}
|
||||
tags={tags}
|
||||
onSpellClick={enterDetailMode}
|
||||
onAddSpell={addNewSpell}
|
||||
onManageTags={function (): void { setShowTagManager(true); }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'detail' && selectedSpell && (
|
||||
<div className="p-4">
|
||||
<SpellEditorDetail
|
||||
spell={selectedSpell}
|
||||
availableTags={tags}
|
||||
seriesSpell={selectedSeriesSpell}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'edit' && selectedSpell && (
|
||||
<div className="p-4">
|
||||
<SpellEditorEdit
|
||||
spell={selectedSpell}
|
||||
availableTags={tags}
|
||||
onSpellChange={handleSpellChange}
|
||||
onCreateTag={createTag}
|
||||
seriesSpell={selectedSeriesSpell}
|
||||
onSyncComplete={handleSyncComplete}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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={handleDelete}
|
||||
onCancel={function (): void { setShowDeleteConfirm(false); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showTagManager && (
|
||||
<SpellTagManager
|
||||
tags={tags}
|
||||
onBack={function (): void { setShowTagManager(false); }}
|
||||
onCreateTag={createTag}
|
||||
onUpdateTag={updateTag}
|
||||
onDeleteTag={deleteTag}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
components/book/settings/spells/editor/SpellEditorDetail.tsx
Normal file
79
components/book/settings/spells/editor/SpellEditorDetail.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import {SpellEditState, spellPowerLevels, SpellTagProps} from '@/lib/models/Spell';
|
||||
import {SeriesSpellDetailResponse} from '@/lib/models/Series';
|
||||
import {SelectBoxProps} from '@/shared/interface';
|
||||
import SpellTagChip from '@/components/book/settings/spells/SpellTagChip';
|
||||
import {useTranslations} from 'next-intl';
|
||||
|
||||
interface SpellEditorDetailProps {
|
||||
spell: SpellEditState;
|
||||
availableTags: SpellTagProps[];
|
||||
seriesSpell?: SeriesSpellDetailResponse | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SpellEditorDetail - Version sidebar lecture seule
|
||||
* Layout linéaire simple, juste les infos essentielles empilées
|
||||
* PAS de CollapsableArea, PAS de grids
|
||||
*/
|
||||
export default function SpellEditorDetail({
|
||||
spell,
|
||||
availableTags,
|
||||
seriesSpell,
|
||||
}: SpellEditorDetailProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
|
||||
function getSelectedTags(): SpellTagProps[] {
|
||||
return availableTags.filter(function (tag: SpellTagProps): boolean {
|
||||
return spell.tags.includes(tag.id);
|
||||
});
|
||||
}
|
||||
|
||||
function getLocalizedPowerLevel(): string {
|
||||
if (!spell.powerLevel || spell.powerLevel === 'none') {
|
||||
return '';
|
||||
}
|
||||
const level: SelectBoxProps | undefined = spellPowerLevels.find(function (l: SelectBoxProps): boolean {
|
||||
return l.value === spell.powerLevel;
|
||||
});
|
||||
return level ? t(level.label) : spell.powerLevel;
|
||||
}
|
||||
|
||||
function renderField(label: string, value: string | null | undefined): React.JSX.Element | null {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<span className="text-text-secondary text-xs block mb-1">{label}</span>
|
||||
<p className="text-text-primary text-sm whitespace-pre-wrap">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedTags: SpellTagProps[] = getSelectedTags();
|
||||
const powerLevelText: string = getLocalizedPowerLevel();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-text-primary font-semibold text-base mb-4">{spell.name}</h3>
|
||||
|
||||
{renderField(t('spellDetail.description'), spell.description)}
|
||||
{renderField(t('spellDetail.appearance'), spell.appearance)}
|
||||
{powerLevelText && renderField(t('spellDetail.powerLevel'), powerLevelText)}
|
||||
{renderField(t('spellDetail.components'), spell.components)}
|
||||
{renderField(t('spellDetail.limitations'), spell.limitations)}
|
||||
{renderField(t('spellDetail.notes'), spell.notes)}
|
||||
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<span className="text-text-secondary text-xs block mb-1">{t('spellDetail.tags')}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedTags.map(function (tag: SpellTagProps): React.JSX.Element {
|
||||
return <SpellTagChip key={tag.id} tag={tag} size="sm"/>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
368
components/book/settings/spells/editor/SpellEditorEdit.tsx
Normal file
368
components/book/settings/spells/editor/SpellEditorEdit.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
'use client';
|
||||
import React, {ChangeEvent, useState} from 'react';
|
||||
import {defaultTagColors, SpellEditState, spellPowerLevels, SpellTagProps} from '@/lib/models/Spell';
|
||||
import {SeriesSpellDetailResponse} from '@/lib/models/Series';
|
||||
import {SelectBoxProps} from '@/shared/interface';
|
||||
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 SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faPlus} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
|
||||
interface SpellEditorEditProps {
|
||||
spell: SpellEditState;
|
||||
availableTags: SpellTagProps[];
|
||||
onSpellChange: (key: keyof SpellEditState, value: string | string[] | null) => void;
|
||||
onCreateTag: (name: string, color: string) => Promise<SpellTagProps | null>;
|
||||
seriesSpell?: SeriesSpellDetailResponse | null;
|
||||
onSyncComplete?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* SpellEditorEdit - Version sidebar édition
|
||||
* Mêmes fonctionnalités que SpellSettingsEdit, layout linéaire
|
||||
* Gestion des tags, SyncFieldWrapper, tous les champs
|
||||
*/
|
||||
export default function SpellEditorEdit({
|
||||
spell,
|
||||
availableTags,
|
||||
onSpellChange,
|
||||
onCreateTag,
|
||||
seriesSpell,
|
||||
onSyncComplete,
|
||||
}: SpellEditorEditProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
|
||||
const [tagSearchQuery, setTagSearchQuery] = useState<string>('');
|
||||
const [isCreatingTag, setIsCreatingTag] = useState<boolean>(false);
|
||||
const [newTagColor, setNewTagColor] = useState<string>(defaultTagColors[0]);
|
||||
|
||||
function handleAddTag(tagId: string): void {
|
||||
if (!spell.tags.includes(tagId)) {
|
||||
onSpellChange('tags', [...spell.tags, tagId]);
|
||||
}
|
||||
setTagSearchQuery('');
|
||||
}
|
||||
|
||||
function handleRemoveTag(tagId: string): void {
|
||||
onSpellChange('tags', spell.tags.filter(function (id: string): boolean {
|
||||
return id !== tagId;
|
||||
}));
|
||||
}
|
||||
|
||||
function getFilteredAvailableTags(): SpellTagProps[] {
|
||||
return availableTags.filter(function (tag: SpellTagProps): boolean {
|
||||
const notAlreadyAdded: boolean = !spell.tags.includes(tag.id);
|
||||
const matchesSearch: boolean = tag.name.toLowerCase().includes(tagSearchQuery.toLowerCase());
|
||||
return notAlreadyAdded && matchesSearch;
|
||||
});
|
||||
}
|
||||
|
||||
function getSelectedTags(): SpellTagProps[] {
|
||||
return availableTags.filter(function (tag: SpellTagProps): boolean {
|
||||
return spell.tags.includes(tag.id);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCreateTag(): Promise<void> {
|
||||
if (!tagSearchQuery.trim()) return;
|
||||
const newTag: SpellTagProps | null = await onCreateTag(tagSearchQuery.trim(), newTagColor);
|
||||
if (newTag) {
|
||||
handleAddTag(newTag.id);
|
||||
setIsCreatingTag(false);
|
||||
setNewTagColor(defaultTagColors[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalizedPowerLevels(): SelectBoxProps[] {
|
||||
return spellPowerLevels.map(function (level: SelectBoxProps): SelectBoxProps {
|
||||
return {
|
||||
value: level.value,
|
||||
label: t(level.label),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const filteredTags: SpellTagProps[] = getFilteredAvailableTags();
|
||||
const selectedTags: SpellTagProps[] = getSelectedTags();
|
||||
const showCreateOption: boolean = Boolean(
|
||||
tagSearchQuery.trim() &&
|
||||
!availableTags.some(function (tag: SpellTagProps): boolean {
|
||||
return tag.name.toLowerCase() === tagSearchQuery.toLowerCase();
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Informations de base */}
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.basicInfo')}</h4>
|
||||
<div className="space-y-3">
|
||||
<InputField
|
||||
fieldName={t('spellDetail.name')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
seriesValue={seriesSpell?.name || ''}
|
||||
currentValue={spell.name}
|
||||
bookElementId={spell.id || ''}
|
||||
field="name"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('name', seriesSpell?.name || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
value={spell.name}
|
||||
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
|
||||
onSpellChange('name', e.target.value);
|
||||
}}
|
||||
placeholder={t('spellDetail.namePlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('spellDetail.description')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
seriesValue={seriesSpell?.description || ''}
|
||||
currentValue={spell.description}
|
||||
bookElementId={spell.id || ''}
|
||||
field="description"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('description', seriesSpell?.description || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
value={spell.description}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('description', e.target.value);
|
||||
}}
|
||||
placeholder={t('spellDetail.descriptionPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('spellDetail.appearance')}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
seriesValue={seriesSpell?.appearance || ''}
|
||||
currentValue={spell.appearance}
|
||||
bookElementId={spell.id || ''}
|
||||
field="appearance"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('appearance', seriesSpell?.appearance || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
value={spell.appearance}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('appearance', e.target.value);
|
||||
}}
|
||||
placeholder={t('spellDetail.appearancePlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.tags')}</h4>
|
||||
<div className="space-y-3">
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTags.map(function (tag: SpellTagProps): React.JSX.Element {
|
||||
return (
|
||||
<SpellTagChip
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
onRemove={function (): void { handleRemoveTag(tag.id); }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
value={tagSearchQuery}
|
||||
setValue={function (e: ChangeEvent<HTMLInputElement>): void {
|
||||
setTagSearchQuery(e.target.value);
|
||||
}}
|
||||
placeholder={t('spellDetail.addTag')}
|
||||
/>
|
||||
|
||||
{filteredTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{filteredTags.map(function (tag: SpellTagProps): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={function (): void { handleAddTag(tag.id); }}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-all duration-200 hover:scale-105 border"
|
||||
style={{
|
||||
backgroundColor: `${tag.color || '#3B82F6'}20`,
|
||||
borderColor: `${tag.color || '#3B82F6'}50`,
|
||||
color: tag.color || '#3B82F6'
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="w-2.5 h-2.5"/>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreateOption && !isCreatingTag && (
|
||||
<button
|
||||
onClick={function (): void { setIsCreatingTag(true); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 bg-tertiary hover:bg-secondary/50 transition-colors text-left rounded-lg border border-secondary/50"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="text-primary w-3 h-3"/>
|
||||
<span className="text-primary font-medium text-sm">
|
||||
{t('spellDetail.createTag', {name: tagSearchQuery})}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isCreatingTag && (
|
||||
<div className="p-3 bg-tertiary rounded-lg border border-secondary/50">
|
||||
<p className="text-text-secondary text-xs mb-2">
|
||||
{t('spellDetail.createTag', {name: tagSearchQuery})}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{defaultTagColors.map(function (color: string): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
key={color}
|
||||
onClick={function (): void { setNewTagColor(color); }}
|
||||
className={`w-6 h-6 rounded-full transition-all duration-200 ${newTagColor === color ? 'ring-2 ring-offset-1 ring-primary scale-110' : 'hover:scale-110'}`}
|
||||
style={{backgroundColor: color}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={function (): void { setIsCreatingTag(false); }}
|
||||
className="flex-1 py-1.5 px-2 bg-secondary/50 text-text-primary rounded-lg hover:bg-secondary transition-colors text-sm"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateTag}
|
||||
className="flex-1 py-1.5 px-2 bg-primary text-text-primary rounded-lg hover:bg-primary-dark transition-colors text-sm"
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Niveau de puissance */}
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.powerLevel')}</h4>
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
seriesValue={seriesSpell?.powerLevel || 'none'}
|
||||
currentValue={spell.powerLevel || 'none'}
|
||||
bookElementId={spell.id || ''}
|
||||
field="powerLevel"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('powerLevel', seriesSpell?.powerLevel || null); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<SelectBox
|
||||
defaultValue={spell.powerLevel || 'none'}
|
||||
onChangeCallBack={function (e: ChangeEvent<HTMLSelectElement>): void {
|
||||
onSpellChange('powerLevel', e.target.value === 'none' ? null : e.target.value);
|
||||
}}
|
||||
data={getLocalizedPowerLevels()}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
</div>
|
||||
|
||||
{/* Composants */}
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.components')}</h4>
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
seriesValue={seriesSpell?.components || ''}
|
||||
currentValue={spell.components || ''}
|
||||
bookElementId={spell.id || ''}
|
||||
field="components"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('components', seriesSpell?.components || null); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
value={spell.components || ''}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('components', e.target.value || null);
|
||||
}}
|
||||
placeholder={t('spellDetail.componentsPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
</div>
|
||||
|
||||
{/* Limitations */}
|
||||
<div className="border-b border-secondary/30 pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.limitations')}</h4>
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
seriesValue={seriesSpell?.limitations || ''}
|
||||
currentValue={spell.limitations || ''}
|
||||
bookElementId={spell.id || ''}
|
||||
field="limitations"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('limitations', seriesSpell?.limitations || null); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
value={spell.limitations || ''}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('limitations', e.target.value || null);
|
||||
}}
|
||||
placeholder={t('spellDetail.limitationsPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="pb-3">
|
||||
<h4 className="text-text-primary font-medium text-sm mb-3">{t('spellDetail.notes')}</h4>
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
seriesValue={seriesSpell?.notes || ''}
|
||||
currentValue={spell.notes || ''}
|
||||
bookElementId={spell.id || ''}
|
||||
field="notes"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('notes', seriesSpell?.notes || null); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
value={spell.notes || ''}
|
||||
setValue={function (e: ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('notes', e.target.value || null);
|
||||
}}
|
||||
placeholder={t('spellDetail.notesPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
components/book/settings/spells/editor/SpellEditorList.tsx
Normal file
163
components/book/settings/spells/editor/SpellEditorList.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
'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, faHatWizard, faPlus, faTags} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
|
||||
interface SpellEditorListProps {
|
||||
spells: SpellListItem[];
|
||||
tags: SpellTagProps[];
|
||||
onSpellClick: (spell: SpellListItem) => void;
|
||||
onAddSpell: () => void;
|
||||
onManageTags: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* SpellEditorList - Liste compacte pour ComposerRightBar
|
||||
* Mêmes fonctionnalités que SpellSettingsList, layout condensé
|
||||
*/
|
||||
export default function SpellEditorList({
|
||||
spells,
|
||||
tags,
|
||||
onSpellClick,
|
||||
onAddSpell,
|
||||
onManageTags,
|
||||
}: SpellEditorListProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [selectedTagId, setSelectedTagId] = useState<string | null>(null);
|
||||
|
||||
function getFilteredSpells(): SpellListItem[] {
|
||||
return spells.filter(function (spell: SpellListItem): boolean {
|
||||
const matchesSearch: boolean = spell.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesTag: boolean = selectedTagId === null || spell.tags.some(function (tag: SpellTagProps): boolean {
|
||||
return tag.id === selectedTagId;
|
||||
});
|
||||
return matchesSearch && matchesTag;
|
||||
});
|
||||
}
|
||||
|
||||
const filteredSpells: SpellListItem[] = getFilteredSpells();
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="px-2">
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={searchQuery}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setSearchQuery(e.target.value);
|
||||
}}
|
||||
placeholder={t('spellList.search')}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionLabel={t('spellList.add')}
|
||||
addButtonCallBack={async function (): Promise<void> {
|
||||
onAddSpell();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tag filter + manage button */}
|
||||
<div className="px-2 flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={function (): void { setSelectedTagId(null); }}
|
||||
className={`px-2 py-1 text-xs rounded-full transition-colors ${
|
||||
selectedTagId === null
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-secondary/50 text-text-secondary hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
{t('spellList.allTags')}
|
||||
</button>
|
||||
{tags.slice(0, 4).map(function (tag: SpellTagProps): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={function (): void { setSelectedTagId(tag.id === selectedTagId ? null : tag.id); }}
|
||||
className={`px-2 py-1 text-xs rounded-full transition-colors ${
|
||||
selectedTagId === tag.id
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-secondary/50 text-text-secondary hover:bg-secondary'
|
||||
}`}
|
||||
style={selectedTagId === tag.id ? {} : {borderLeft: `3px solid ${tag.color}`}}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={onManageTags}
|
||||
className="px-2 py-1 text-xs rounded-full bg-secondary/30 text-text-secondary hover:bg-secondary transition-colors flex items-center gap-1"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTags} className="w-3 h-3"/>
|
||||
{t('spellList.manageTags')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-2 space-y-2">
|
||||
{filteredSpells.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-3">
|
||||
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-8 h-8"/>
|
||||
</div>
|
||||
<h3 className="text-text-primary font-semibold text-base mb-1">
|
||||
{t('spellList.noSpells')}
|
||||
</h3>
|
||||
<p className="text-muted text-sm max-w-xs">
|
||||
{t('spellList.noSpellsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredSpells.map(function (spell: SpellListItem): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
key={spell.id}
|
||||
onClick={function (): void { onSpellClick(spell); }}
|
||||
className="group flex items-center p-3 bg-secondary/30 rounded-lg border-l-4 border-primary border border-secondary/50 cursor-pointer hover:bg-secondary hover:shadow-md transition-all duration-200 hover:border-primary/50"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full border-2 border-primary overflow-hidden bg-secondary shadow-sm group-hover:scale-110 transition-transform flex items-center justify-center">
|
||||
<FontAwesomeIcon icon={faHatWizard} className="text-primary w-5 h-5"/>
|
||||
</div>
|
||||
|
||||
<div className="ml-3 flex-1 min-w-0">
|
||||
<div className="text-text-primary font-semibold text-sm group-hover:text-primary transition-colors truncate">
|
||||
{spell.name}
|
||||
</div>
|
||||
<div className="text-muted text-xs truncate">
|
||||
{spell.description}
|
||||
</div>
|
||||
{spell.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{spell.tags.slice(0, 2).map(function (tag: SpellTagProps): React.JSX.Element {
|
||||
return <SpellTagChip key={tag.id} tag={tag} size="sm"/>;
|
||||
})}
|
||||
{spell.tags.length > 2 && (
|
||||
<span className="text-muted text-xs px-1.5 py-0.5 bg-secondary/50 rounded-full">
|
||||
+{spell.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-6 flex justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
className="text-muted group-hover:text-primary group-hover:translate-x-1 transition-all w-3 h-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
243
components/book/settings/spells/settings/SpellSettings.tsx
Normal file
243
components/book/settings/spells/settings/SpellSettings.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
import React, {useCallback, useContext, useMemo, useState} from 'react';
|
||||
import {useSpells, UseSpellsConfig} from '@/hooks/settings/useSpells';
|
||||
import {useTranslations} from 'next-intl';
|
||||
import {SpellEditState, SpellListItem, SpellTagProps} from '@/lib/models/Spell';
|
||||
import {SeriesSpellListItem} from '@/lib/models/Series';
|
||||
import {BookContext} from '@/context/BookContext';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faSpinner, faToggleOn} from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import ToolDetailHeader from '@/components/book/settings/ToolDetailHeader';
|
||||
import InputField from '@/components/form/InputField';
|
||||
import ToggleSwitch from '@/components/form/ToggleSwitch';
|
||||
import SeriesImportSelector from '@/components/form/SeriesImportSelector';
|
||||
import AlertBox from '@/components/AlertBox';
|
||||
import SpellTagManager from '@/components/book/settings/spells/SpellTagManager';
|
||||
|
||||
import SpellSettingsList from './SpellSettingsList';
|
||||
import SpellSettingsDetail from './SpellSettingsDetail';
|
||||
import SpellSettingsEdit from './SpellSettingsEdit';
|
||||
|
||||
interface SpellSettingsProps {
|
||||
entityType?: 'book' | 'series';
|
||||
entityId?: string;
|
||||
showToggle?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SpellSettings - Orchestrateur pour BookSetting/SerieSetting
|
||||
* Gère le viewMode (list/detail/edit) et coordonne les sous-composants
|
||||
* Inclut: toggle tool, import from series, tag manager
|
||||
*/
|
||||
export default function SpellSettings({
|
||||
entityType = 'book',
|
||||
entityId,
|
||||
showToggle = true,
|
||||
}: SpellSettingsProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const {book} = useContext(BookContext);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
|
||||
|
||||
const resolvedEntityId: string = entityId || book?.bookId || '';
|
||||
|
||||
const config: UseSpellsConfig = useMemo(function (): UseSpellsConfig {
|
||||
return {
|
||||
entityType,
|
||||
entityId: resolvedEntityId,
|
||||
};
|
||||
}, [entityType, resolvedEntityId]);
|
||||
|
||||
const {
|
||||
spells,
|
||||
seriesSpells,
|
||||
tags,
|
||||
selectedSpell,
|
||||
selectedSeriesSpell,
|
||||
toolEnabled,
|
||||
isLoading,
|
||||
isSeriesMode,
|
||||
bookSeriesId,
|
||||
showTagManager,
|
||||
viewMode,
|
||||
saveSpell,
|
||||
deleteSpell,
|
||||
updateSpellField,
|
||||
toggleTool,
|
||||
importFromSeries,
|
||||
exportToSeries,
|
||||
refreshSeriesSpells,
|
||||
setSelectedSpell,
|
||||
setShowTagManager,
|
||||
enterDetailMode,
|
||||
enterEditMode,
|
||||
exitEditMode,
|
||||
backToList,
|
||||
addNewSpell,
|
||||
createTag,
|
||||
updateTag,
|
||||
deleteTag,
|
||||
handleSyncComplete,
|
||||
} = useSpells(config);
|
||||
|
||||
const availableSeriesSpells = useMemo(function (): SeriesSpellListItem[] {
|
||||
return seriesSpells.filter(function (ss: SeriesSpellListItem): boolean {
|
||||
return !spells.some(function (s: SpellListItem): boolean {
|
||||
return s.seriesSpellId === ss.id;
|
||||
});
|
||||
});
|
||||
}, [seriesSpells, spells]);
|
||||
|
||||
const handleSpellChange = useCallback(function (key: keyof SpellEditState, value: string | string[] | null): void {
|
||||
updateSpellField(key, value);
|
||||
}, [updateSpellField]);
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
await exitEditMode(true);
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
exitEditMode(false);
|
||||
}
|
||||
|
||||
async function handleDelete(): Promise<void> {
|
||||
if (selectedSpell?.id) {
|
||||
await deleteSpell(selectedSpell.id);
|
||||
setShowDeleteConfirm(false);
|
||||
backToList();
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<FontAwesomeIcon icon={faSpinner} className="w-8 h-8 text-primary animate-spin"/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isNew: boolean = selectedSpell?.id === null;
|
||||
const canExport: boolean = Boolean(bookSeriesId && selectedSpell?.id && !selectedSpell.seriesSpellId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header - uniquement pour detail/edit */}
|
||||
<ToolDetailHeader
|
||||
title={selectedSpell?.name || ''}
|
||||
defaultTitle={t('spellDetail.newSpell')}
|
||||
viewMode={viewMode}
|
||||
isNew={isNew}
|
||||
onBack={backToList}
|
||||
onEdit={enterEditMode}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onDelete={function (): void { setShowDeleteConfirm(true); }}
|
||||
onExport={canExport ? exportToSeries : undefined}
|
||||
showExport={canExport}
|
||||
showDelete={Boolean(selectedSpell?.id)}
|
||||
/>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{viewMode === 'list' && (
|
||||
<div className="space-y-5 p-4">
|
||||
{/* Toggle tool */}
|
||||
{showToggle && !isSeriesMode && (
|
||||
<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={toggleTool}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p className="text-muted text-sm mt-2">
|
||||
{t('spellComponent.enableToolDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contenu si outil activé */}
|
||||
{(toolEnabled || isSeriesMode) && (
|
||||
<>
|
||||
{/* Import from series */}
|
||||
{!isSeriesMode && bookSeriesId && availableSeriesSpells.length > 0 && (
|
||||
<SeriesImportSelector
|
||||
availableItems={availableSeriesSpells.map(function (ss: SeriesSpellListItem) {
|
||||
return {
|
||||
id: ss.id,
|
||||
name: ss.name
|
||||
};
|
||||
})}
|
||||
onImport={importFromSeries}
|
||||
placeholder={t('seriesImport.selectElement')}
|
||||
label={t('seriesImport.importFromSeries')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Liste des sorts */}
|
||||
<SpellSettingsList
|
||||
spells={spells}
|
||||
tags={tags}
|
||||
onSpellClick={enterDetailMode}
|
||||
onAddSpell={addNewSpell}
|
||||
onManageTags={function (): void { setShowTagManager(true); }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'detail' && selectedSpell && (
|
||||
<div className="p-4">
|
||||
<SpellSettingsDetail
|
||||
spell={selectedSpell}
|
||||
availableTags={tags}
|
||||
seriesSpell={selectedSeriesSpell}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'edit' && selectedSpell && (
|
||||
<div className="p-4">
|
||||
<SpellSettingsEdit
|
||||
spell={selectedSpell}
|
||||
availableTags={tags}
|
||||
onSpellChange={handleSpellChange}
|
||||
onCreateTag={createTag}
|
||||
seriesSpell={selectedSeriesSpell}
|
||||
onSyncComplete={handleSyncComplete}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal de confirmation de suppression */}
|
||||
{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={handleDelete}
|
||||
onCancel={function (): void { setShowDeleteConfirm(false); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tag Manager Modal */}
|
||||
{showTagManager && (
|
||||
<SpellTagManager
|
||||
tags={tags}
|
||||
onBack={function (): void { setShowTagManager(false); }}
|
||||
onCreateTag={createTag}
|
||||
onUpdateTag={updateTag}
|
||||
onDeleteTag={deleteTag}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
components/book/settings/spells/settings/SpellSettingsDetail.tsx
Normal file
150
components/book/settings/spells/settings/SpellSettingsDetail.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import {SpellEditState, spellPowerLevels, SpellTagProps} from '@/lib/models/Spell';
|
||||
import {SeriesSpellDetailResponse} from '@/lib/models/Series';
|
||||
import {SelectBoxProps} from '@/shared/interface';
|
||||
import SpellTagChip from '@/components/book/settings/spells/SpellTagChip';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBolt,
|
||||
faEye,
|
||||
faHatWizard,
|
||||
faPuzzlePiece,
|
||||
faStickyNote,
|
||||
faTags,
|
||||
faTriangleExclamation,
|
||||
faWandMagicSparkles
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
|
||||
interface SpellSettingsDetailProps {
|
||||
spell: SpellEditState;
|
||||
availableTags: SpellTagProps[];
|
||||
seriesSpell?: SeriesSpellDetailResponse | null;
|
||||
}
|
||||
|
||||
export default function SpellSettingsDetail({
|
||||
spell,
|
||||
availableTags,
|
||||
seriesSpell,
|
||||
}: SpellSettingsDetailProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
|
||||
function getSelectedTags(): SpellTagProps[] {
|
||||
return availableTags.filter(function (tag: SpellTagProps): boolean {
|
||||
return spell.tags.includes(tag.id);
|
||||
});
|
||||
}
|
||||
|
||||
function getLocalizedPowerLevel(): string {
|
||||
if (!spell.powerLevel || spell.powerLevel === 'none') {
|
||||
return t('spellPowerLevels.none');
|
||||
}
|
||||
const level: SelectBoxProps | undefined = spellPowerLevels.find(function (l: SelectBoxProps): boolean {
|
||||
return l.value === spell.powerLevel;
|
||||
});
|
||||
return level ? t(level.label) : spell.powerLevel;
|
||||
}
|
||||
|
||||
function getPowerLevelColor(): string {
|
||||
switch (spell.powerLevel) {
|
||||
case 'weak': return 'bg-green-500/20 text-green-400 border-green-500/30';
|
||||
case 'moderate': return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||
case 'strong': return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
|
||||
case 'legendary': return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
|
||||
default: return 'bg-secondary/50 text-text-secondary border-secondary/50';
|
||||
}
|
||||
}
|
||||
|
||||
const selectedTags: SpellTagProps[] = getSelectedTags();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-2 pb-4">
|
||||
{/* Hero Section */}
|
||||
<div className="p-6 bg-gradient-to-r from-primary/10 via-secondary/20 to-transparent rounded-2xl border border-secondary/30">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-primary/20 flex items-center justify-center shrink-0">
|
||||
<FontAwesomeIcon icon={faWandMagicSparkles} className="w-8 h-8 text-primary"/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-2xl font-bold text-text-primary">{spell.name || '—'}</h2>
|
||||
|
||||
{/* Power Level Badge */}
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<span className={`inline-flex items-center gap-2 px-3 py-1 rounded-lg text-sm border ${getPowerLevelColor()}`}>
|
||||
<FontAwesomeIcon icon={faBolt} className="w-3 h-3"/>
|
||||
{getLocalizedPowerLevel()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{selectedTags.map(function (tag: SpellTagProps): React.JSX.Element {
|
||||
return <SpellTagChip key={tag.id} tag={tag}/>;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description & Appearance - Side by side */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FontAwesomeIcon icon={faHatWizard} className="w-4 h-4 text-primary"/>
|
||||
<h3 className="text-text-primary font-semibold">{t('spellDetail.description')}</h3>
|
||||
</div>
|
||||
<p className={`whitespace-pre-wrap ${spell.description ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{spell.description || '—'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FontAwesomeIcon icon={faEye} className="w-4 h-4 text-primary"/>
|
||||
<h3 className="text-text-primary font-semibold">{t('spellDetail.appearance')}</h3>
|
||||
</div>
|
||||
<p className={`whitespace-pre-wrap ${spell.appearance ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{spell.appearance || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Components & Limitations - Side by side */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FontAwesomeIcon icon={faPuzzlePiece} className="w-4 h-4 text-primary"/>
|
||||
<h3 className="text-text-primary font-semibold">{t('spellDetail.components')}</h3>
|
||||
</div>
|
||||
<p className={`whitespace-pre-wrap ${spell.components ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{spell.components || '—'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-error/10 rounded-xl border border-error/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FontAwesomeIcon icon={faTriangleExclamation} className="w-4 h-4 text-error"/>
|
||||
<h3 className="text-text-primary font-semibold">{t('spellDetail.limitations')}</h3>
|
||||
</div>
|
||||
<p className={`whitespace-pre-wrap ${spell.limitations ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{spell.limitations || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes - Full width */}
|
||||
<div className="p-5 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FontAwesomeIcon icon={faStickyNote} className="w-4 h-4 text-primary"/>
|
||||
<h3 className="text-text-primary font-semibold">{t('spellDetail.notes')}</h3>
|
||||
</div>
|
||||
<p className={`whitespace-pre-wrap ${spell.notes ? 'text-text-primary' : 'text-text-secondary/50 italic'}`}>
|
||||
{spell.notes || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
384
components/book/settings/spells/settings/SpellSettingsEdit.tsx
Normal file
384
components/book/settings/spells/settings/SpellSettingsEdit.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
'use client';
|
||||
import React, {useState} from 'react';
|
||||
import {defaultTagColors, SpellEditState, spellPowerLevels, SpellTagProps} from '@/lib/models/Spell';
|
||||
import {SeriesSpellDetailResponse} from '@/lib/models/Series';
|
||||
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 SyncFieldWrapper from '@/components/form/SyncFieldWrapper';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBolt,
|
||||
faBook,
|
||||
faEye,
|
||||
faHatWizard,
|
||||
faPlus,
|
||||
faPuzzlePiece,
|
||||
faStickyNote,
|
||||
faTags,
|
||||
faTriangleExclamation
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import {useTranslations} from 'next-intl';
|
||||
|
||||
interface SpellSettingsEditProps {
|
||||
spell: SpellEditState;
|
||||
availableTags: SpellTagProps[];
|
||||
onSpellChange: (key: keyof SpellEditState, value: string | string[] | null) => void;
|
||||
onCreateTag: (name: string, color: string) => Promise<SpellTagProps | null>;
|
||||
seriesSpell?: SeriesSpellDetailResponse | null;
|
||||
onSyncComplete?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* SpellSettingsEdit - Vue édition pour BookSetting/SerieSetting
|
||||
* Tous les champs avec SyncFieldWrapper
|
||||
* Gestion des tags inline
|
||||
* PAS de scroll interne (géré par parent)
|
||||
*/
|
||||
export default function SpellSettingsEdit({
|
||||
spell,
|
||||
availableTags,
|
||||
onSpellChange,
|
||||
onCreateTag,
|
||||
seriesSpell,
|
||||
onSyncComplete,
|
||||
}: SpellSettingsEditProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
|
||||
const [tagSearchQuery, setTagSearchQuery] = useState<string>('');
|
||||
const [isCreatingTag, setIsCreatingTag] = useState<boolean>(false);
|
||||
const [newTagColor, setNewTagColor] = useState<string>(defaultTagColors[0]);
|
||||
|
||||
function handleAddTag(tagId: string): void {
|
||||
if (!spell.tags.includes(tagId)) {
|
||||
onSpellChange('tags', [...spell.tags, tagId]);
|
||||
}
|
||||
setTagSearchQuery('');
|
||||
}
|
||||
|
||||
function handleRemoveTag(tagId: string): void {
|
||||
onSpellChange('tags', spell.tags.filter(function (id: string): boolean {
|
||||
return id !== tagId;
|
||||
}));
|
||||
}
|
||||
|
||||
function getFilteredAvailableTags(): SpellTagProps[] {
|
||||
return availableTags.filter(function (tag: SpellTagProps): boolean {
|
||||
const notAlreadyAdded: boolean = !spell.tags.includes(tag.id);
|
||||
const matchesSearch: boolean = tag.name.toLowerCase().includes(tagSearchQuery.toLowerCase());
|
||||
return notAlreadyAdded && matchesSearch;
|
||||
});
|
||||
}
|
||||
|
||||
function getSelectedTags(): SpellTagProps[] {
|
||||
return availableTags.filter(function (tag: SpellTagProps): boolean {
|
||||
return spell.tags.includes(tag.id);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCreateTag(): Promise<void> {
|
||||
if (!tagSearchQuery.trim()) return;
|
||||
const newTag: SpellTagProps | null = await onCreateTag(tagSearchQuery.trim(), newTagColor);
|
||||
if (newTag) {
|
||||
handleAddTag(newTag.id);
|
||||
setIsCreatingTag(false);
|
||||
setNewTagColor(defaultTagColors[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalizedPowerLevels(): SelectBoxProps[] {
|
||||
return spellPowerLevels.map(function (level: SelectBoxProps): SelectBoxProps {
|
||||
return {
|
||||
value: level.value,
|
||||
label: t(level.label),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const filteredTags: SpellTagProps[] = getFilteredAvailableTags();
|
||||
const selectedTags: SpellTagProps[] = getSelectedTags();
|
||||
const showCreateOption: boolean = Boolean(
|
||||
tagSearchQuery.trim() &&
|
||||
!availableTags.some(function (tag: SpellTagProps): boolean {
|
||||
return tag.name.toLowerCase() === tagSearchQuery.toLowerCase();
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-2 pb-4">
|
||||
{/* Informations de base */}
|
||||
<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={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
seriesValue={seriesSpell?.name || ''}
|
||||
currentValue={spell.name}
|
||||
bookElementId={spell.id || ''}
|
||||
field="name"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('name', seriesSpell?.name || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TextInput
|
||||
value={spell.name}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
onSpellChange('name', e.target.value);
|
||||
}}
|
||||
placeholder={t('spellDetail.namePlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('spellDetail.description')}
|
||||
icon={faBook}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
seriesValue={seriesSpell?.description || ''}
|
||||
currentValue={spell.description}
|
||||
bookElementId={spell.id || ''}
|
||||
field="description"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('description', seriesSpell?.description || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
value={spell.description}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('description', e.target.value);
|
||||
}}
|
||||
placeholder={t('spellDetail.descriptionPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
fieldName={t('spellDetail.appearance')}
|
||||
icon={faEye}
|
||||
input={
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
seriesValue={seriesSpell?.appearance || ''}
|
||||
currentValue={spell.appearance}
|
||||
bookElementId={spell.id || ''}
|
||||
field="appearance"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('appearance', seriesSpell?.appearance || ''); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
value={spell.appearance}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('appearance', e.target.value);
|
||||
}}
|
||||
placeholder={t('spellDetail.appearancePlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
{/* Tags */}
|
||||
<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(function (tag: SpellTagProps): React.JSX.Element {
|
||||
return (
|
||||
<SpellTagChip
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
onRemove={function (): void { handleRemoveTag(tag.id); }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
value={tagSearchQuery}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setTagSearchQuery(e.target.value);
|
||||
}}
|
||||
placeholder={t('spellDetail.addTag')}
|
||||
/>
|
||||
|
||||
{filteredTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filteredTags.map(function (tag: SpellTagProps): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={function (): void { handleAddTag(tag.id); }}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm transition-all duration-200 hover:scale-105 hover:shadow-md border"
|
||||
style={{
|
||||
backgroundColor: `${tag.color || '#3B82F6'}20`,
|
||||
borderColor: `${tag.color || '#3B82F6'}50`,
|
||||
color: tag.color || '#3B82F6'
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="w-3 h-3"/>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreateOption && !isCreatingTag && (
|
||||
<button
|
||||
onClick={function (): void { setIsCreatingTag(true); }}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 bg-tertiary hover:bg-secondary/50 transition-colors text-left rounded-xl border border-secondary/50"
|
||||
>
|
||||
<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 bg-tertiary rounded-xl border border-secondary/50">
|
||||
<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(function (color: string): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
key={color}
|
||||
onClick={function (): void { 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={function (): void { 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>
|
||||
</CollapsableArea>
|
||||
|
||||
{/* Niveau de puissance */}
|
||||
<CollapsableArea title={t('spellDetail.powerLevel')} icon={faBolt}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
seriesValue={seriesSpell?.powerLevel || 'none'}
|
||||
currentValue={spell.powerLevel || 'none'}
|
||||
bookElementId={spell.id || ''}
|
||||
field="powerLevel"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('powerLevel', seriesSpell?.powerLevel || null); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<SelectBox
|
||||
defaultValue={spell.powerLevel || 'none'}
|
||||
onChangeCallBack={function (e: React.ChangeEvent<HTMLSelectElement>): void {
|
||||
onSpellChange('powerLevel', e.target.value === 'none' ? null : e.target.value);
|
||||
}}
|
||||
data={getLocalizedPowerLevels()}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
{/* Composants */}
|
||||
<CollapsableArea title={t('spellDetail.components')} icon={faPuzzlePiece}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
seriesValue={seriesSpell?.components || ''}
|
||||
currentValue={spell.components || ''}
|
||||
bookElementId={spell.id || ''}
|
||||
field="components"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('components', seriesSpell?.components || null); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
value={spell.components || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('components', e.target.value || null);
|
||||
}}
|
||||
placeholder={t('spellDetail.componentsPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
{/* Limitations */}
|
||||
<CollapsableArea title={t('spellDetail.limitations')} icon={faTriangleExclamation}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
seriesValue={seriesSpell?.limitations || ''}
|
||||
currentValue={spell.limitations || ''}
|
||||
bookElementId={spell.id || ''}
|
||||
field="limitations"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('limitations', seriesSpell?.limitations || null); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
value={spell.limitations || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('limitations', e.target.value || null);
|
||||
}}
|
||||
placeholder={t('spellDetail.limitationsPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
|
||||
{/* Notes */}
|
||||
<CollapsableArea title={t('spellDetail.notes')} icon={faStickyNote}>
|
||||
<div className="space-y-4 p-4 bg-secondary/20 rounded-xl border border-secondary/30">
|
||||
<SyncFieldWrapper
|
||||
seriesElementId={spell.seriesSpellId}
|
||||
seriesValue={seriesSpell?.notes || ''}
|
||||
currentValue={spell.notes || ''}
|
||||
bookElementId={spell.id || ''}
|
||||
field="notes"
|
||||
elementType="spell"
|
||||
onDownload={function (): void { onSpellChange('notes', seriesSpell?.notes || null); }}
|
||||
onSyncComplete={onSyncComplete}
|
||||
>
|
||||
<TexteAreaInput
|
||||
value={spell.notes || ''}
|
||||
setValue={function (e: React.ChangeEvent<HTMLTextAreaElement>): void {
|
||||
onSpellChange('notes', e.target.value || null);
|
||||
}}
|
||||
placeholder={t('spellDetail.notesPlaceholder')}
|
||||
/>
|
||||
</SyncFieldWrapper>
|
||||
</div>
|
||||
</CollapsableArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
components/book/settings/spells/settings/SpellSettingsList.tsx
Normal file
159
components/book/settings/spells/settings/SpellSettingsList.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'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 SpellSettingsListProps {
|
||||
spells: SpellListItem[];
|
||||
tags: SpellTagProps[];
|
||||
onSpellClick: (spell: SpellListItem) => void;
|
||||
onAddSpell: () => void;
|
||||
onManageTags: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* SpellSettingsList - Liste des sorts pour BookSetting/SerieSetting
|
||||
* Inclut recherche, filtre par tag, et gestion des tags
|
||||
* PAS de scroll interne (géré par parent)
|
||||
*/
|
||||
export default function SpellSettingsList({
|
||||
spells,
|
||||
tags,
|
||||
onSpellClick,
|
||||
onAddSpell,
|
||||
onManageTags,
|
||||
}: SpellSettingsListProps): React.JSX.Element {
|
||||
const t = useTranslations();
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [filterTag, setFilterTag] = useState<string>('all');
|
||||
|
||||
function getFilteredSpells(): SpellListItem[] {
|
||||
return spells.filter(function (spell: SpellListItem): boolean {
|
||||
const matchesSearch: boolean = spell.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesTag: boolean = filterTag === 'all' || spell.tags.some(function (tag: SpellTagProps): boolean {
|
||||
return tag.id === filterTag;
|
||||
});
|
||||
return matchesSearch && matchesTag;
|
||||
});
|
||||
}
|
||||
|
||||
const filteredSpells: SpellListItem[] = getFilteredSpells();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="px-4 space-y-3">
|
||||
<InputField
|
||||
input={
|
||||
<TextInput
|
||||
value={searchQuery}
|
||||
setValue={function (e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setSearchQuery(e.target.value);
|
||||
}}
|
||||
placeholder={t('spellList.search')}
|
||||
/>
|
||||
}
|
||||
actionIcon={faPlus}
|
||||
actionLabel={t('spellList.add')}
|
||||
addButtonCallBack={async function (): Promise<void> {
|
||||
onAddSpell();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="flex-1 min-w-[150px]">
|
||||
<select
|
||||
value={filterTag}
|
||||
onChange={function (e: React.ChangeEvent<HTMLSelectElement>): void {
|
||||
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(function (tag: SpellTagProps): React.JSX.Element {
|
||||
return (
|
||||
<option key={tag.id} value={tag.id} className="bg-tertiary text-text-primary">
|
||||
{tag.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={onManageTags}
|
||||
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="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(function (spell: SpellListItem): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
key={spell.id}
|
||||
onClick={function (): void { onSpellClick(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(function (tag: SpellTagProps): React.JSX.Element {
|
||||
return <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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user