722 lines
30 KiB
TypeScript
722 lines
30 KiB
TypeScript
import React, { useState, useMemo, useEffect } from 'react';
|
|
import { Entity, EntityType, CharacterAttributes, EntityTemplate, CustomFieldDefinition, CustomFieldType } from '../types';
|
|
import { Plus, Trash2, Save, X, Sparkles, User, Activity, Brain, Ruler, Settings, Layout, List, ToggleLeft } from 'lucide-react';
|
|
import { ENTITY_ICONS, ENTITY_COLORS, HAIR_COLORS, EYE_COLORS, ARCHETYPES } from '../constants';
|
|
|
|
interface WorldBuilderProps {
|
|
entities: Entity[];
|
|
onCreate: (entity: Omit<Entity, 'id'>) => Promise<string | null>;
|
|
onUpdate: (id: string, updates: Partial<Entity>) => void;
|
|
onDelete: (id: string) => void;
|
|
templates: EntityTemplate[];
|
|
onUpdateTemplates: (templates: EntityTemplate[]) => void;
|
|
initialSelectedId?: string | null;
|
|
}
|
|
|
|
const DEFAULT_CHAR_ATTRIBUTES: CharacterAttributes = {
|
|
age: 30,
|
|
height: 175,
|
|
hair: 'Brun',
|
|
eyes: 'Marron',
|
|
archetype: 'Le Héros',
|
|
role: 'support',
|
|
personality: {
|
|
spectrumIntrovertExtravert: 50,
|
|
spectrumEmotionalRational: 50,
|
|
spectrumChaoticLawful: 50,
|
|
},
|
|
physicalQuirk: '',
|
|
behavioralQuirk: ''
|
|
};
|
|
|
|
const WorldBuilder: React.FC<WorldBuilderProps> = ({ entities, onCreate, onUpdate, onDelete, templates, onUpdateTemplates, initialSelectedId }) => {
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [tempEntity, setTempEntity] = useState<Entity | null>(null);
|
|
const [mode, setMode] = useState<'entities' | 'templates'>('entities');
|
|
|
|
// Template Editor State
|
|
const [activeTemplateType, setActiveTemplateType] = useState<EntityType>(EntityType.CHARACTER);
|
|
|
|
// Handle external navigation request (deep link)
|
|
useEffect(() => {
|
|
if (initialSelectedId) {
|
|
const entity = entities.find(e => e.id === initialSelectedId);
|
|
if (entity) {
|
|
handleEdit(entity);
|
|
setMode('entities');
|
|
}
|
|
}
|
|
}, [initialSelectedId, entities]);
|
|
|
|
// Dynamic Archetypes List
|
|
const allArchetypes = useMemo(() => {
|
|
const existing = entities
|
|
.filter(e => e.type === EntityType.CHARACTER && e.attributes?.archetype)
|
|
.map(e => e.attributes!.archetype);
|
|
return Array.from(new Set([...ARCHETYPES, ...existing])).sort();
|
|
}, [entities]);
|
|
|
|
// --- ENTITY ACTIONS ---
|
|
|
|
const handleAdd = (type: EntityType) => {
|
|
const newEntity: Entity = {
|
|
id: Date.now().toString(), // Helper ID for UI
|
|
type,
|
|
name: '',
|
|
description: '',
|
|
details: '',
|
|
storyContext: '',
|
|
attributes: type === EntityType.CHARACTER ? { ...DEFAULT_CHAR_ATTRIBUTES } : undefined,
|
|
customValues: {}
|
|
};
|
|
setTempEntity(newEntity);
|
|
setEditingId('NEW');
|
|
};
|
|
|
|
const handleEdit = (entity: Entity) => {
|
|
// Ensure attributes exist if it's a character (backward compatibility)
|
|
const entityToEdit = { ...entity };
|
|
if (entity.type === EntityType.CHARACTER && !entity.attributes) {
|
|
entityToEdit.attributes = { ...DEFAULT_CHAR_ATTRIBUTES };
|
|
}
|
|
if (!entity.customValues) {
|
|
entityToEdit.customValues = {};
|
|
}
|
|
setTempEntity(entityToEdit);
|
|
setEditingId(entity.id);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!tempEntity || !tempEntity.name) return;
|
|
|
|
if (editingId === 'NEW') {
|
|
const { id, ...entityData } = tempEntity;
|
|
await onCreate(entityData);
|
|
} else {
|
|
onUpdate(tempEntity.id, tempEntity);
|
|
}
|
|
setEditingId(null);
|
|
setTempEntity(null);
|
|
};
|
|
|
|
const handleDelete = (id: string) => {
|
|
if (confirm('Supprimer cet élément ?')) {
|
|
onDelete(id);
|
|
if (editingId === id) {
|
|
setEditingId(null);
|
|
setTempEntity(null);
|
|
}
|
|
}
|
|
};
|
|
|
|
const updateAttribute = (key: keyof CharacterAttributes, value: any) => {
|
|
if (tempEntity && tempEntity.attributes) {
|
|
setTempEntity({
|
|
...tempEntity,
|
|
attributes: { ...tempEntity.attributes, [key]: value }
|
|
});
|
|
}
|
|
};
|
|
|
|
const updatePersonality = (key: keyof CharacterAttributes['personality'], value: number) => {
|
|
if (tempEntity && tempEntity.attributes) {
|
|
setTempEntity({
|
|
...tempEntity,
|
|
attributes: {
|
|
...tempEntity.attributes,
|
|
personality: { ...tempEntity.attributes.personality, [key]: value }
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
const updateCustomValue = (fieldId: string, value: any) => {
|
|
if (tempEntity) {
|
|
setTempEntity({
|
|
...tempEntity,
|
|
customValues: {
|
|
...tempEntity.customValues,
|
|
[fieldId]: value
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// --- TEMPLATE ACTIONS ---
|
|
|
|
const addCustomField = (type: EntityType) => {
|
|
const newField: CustomFieldDefinition = {
|
|
id: `field-${Date.now()}`,
|
|
label: 'Nouveau Champ',
|
|
type: 'text',
|
|
placeholder: ''
|
|
};
|
|
|
|
// Correct immutable update
|
|
const updatedTemplates = templates.map(t => {
|
|
if (t.entityType === type) {
|
|
return {
|
|
...t,
|
|
fields: [...t.fields, newField]
|
|
};
|
|
}
|
|
return t;
|
|
});
|
|
|
|
// If template didn't exist (unlikely given App.tsx init, but safe)
|
|
if (!updatedTemplates.some(t => t.entityType === type)) {
|
|
updatedTemplates.push({ entityType: type, fields: [newField] });
|
|
}
|
|
|
|
onUpdateTemplates(updatedTemplates);
|
|
};
|
|
|
|
const updateCustomField = (type: EntityType, fieldId: string, updates: Partial<CustomFieldDefinition>) => {
|
|
const updatedTemplates = templates.map(t => {
|
|
if (t.entityType !== type) return t;
|
|
return {
|
|
...t,
|
|
fields: t.fields.map(f => f.id === fieldId ? { ...f, ...updates } : f)
|
|
};
|
|
});
|
|
onUpdateTemplates(updatedTemplates);
|
|
};
|
|
|
|
const deleteCustomField = (type: EntityType, fieldId: string) => {
|
|
const updatedTemplates = templates.map(t => {
|
|
if (t.entityType !== type) return t;
|
|
return {
|
|
...t,
|
|
fields: t.fields.filter(f => f.id !== fieldId)
|
|
};
|
|
});
|
|
onUpdateTemplates(updatedTemplates);
|
|
};
|
|
|
|
const filterByType = (type: EntityType) => entities.filter(e => e.type === type);
|
|
|
|
// --- RENDER HELPERS ---
|
|
|
|
const renderCharacterEditor = () => {
|
|
if (!tempEntity?.attributes) return null;
|
|
const attrs = tempEntity.attributes;
|
|
|
|
return (
|
|
<div className="space-y-8 border-t border-slate-100 pt-6 mt-4">
|
|
|
|
{/* SECTION 1: ROLE & ARCHETYPE */}
|
|
<div className="bg-[#eef2ff] p-4 rounded-lg border border-indigo-100">
|
|
<h3 className="text-sm font-bold text-slate-700 uppercase mb-4 flex items-center gap-2">
|
|
<User size={16} /> Identité Narrative
|
|
</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-500 mb-2">Archétype</label>
|
|
<input
|
|
type="text"
|
|
list="archetype-suggestions"
|
|
value={attrs.archetype}
|
|
onChange={(e) => updateAttribute('archetype', e.target.value)}
|
|
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm outline-none focus:border-blue-500"
|
|
placeholder="Ex: Le Héros, Le Sage..."
|
|
/>
|
|
<datalist id="archetype-suggestions">
|
|
{allArchetypes.map(a => <option key={a} value={a} />)}
|
|
</datalist>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-500 mb-2">Rôle dans l'histoire</label>
|
|
<div className="flex gap-2 flex-wrap">
|
|
{[
|
|
{ val: 'protagonist', label: 'Protagoniste' },
|
|
{ val: 'antagonist', label: 'Antagoniste' },
|
|
{ val: 'support', label: 'Secondaire' },
|
|
{ val: 'extra', label: 'Figurant' }
|
|
].map(opt => (
|
|
<label key={opt.val} className={`cursor-pointer px-3 py-1.5 rounded text-xs border transition-colors ${attrs.role === opt.val ? 'bg-indigo-100 border-indigo-300 text-indigo-700 font-bold' : 'bg-[#eef2ff] border-slate-200 text-slate-600 hover:bg-slate-100'}`}>
|
|
<input
|
|
type="radio"
|
|
name="role"
|
|
value={opt.val}
|
|
checked={attrs.role === opt.val}
|
|
onChange={() => updateAttribute('role', opt.val)}
|
|
className="hidden"
|
|
/>
|
|
{opt.label}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SECTION 2: PHYSIQUE */}
|
|
<div className="bg-[#eef2ff] p-4 rounded-lg border border-indigo-100">
|
|
<h3 className="text-sm font-bold text-slate-700 uppercase mb-4 flex items-center gap-2">
|
|
<Ruler size={16} /> Apparence Physique
|
|
</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="space-y-6">
|
|
<div>
|
|
<div className="flex justify-between text-xs mb-1">
|
|
<label className="font-semibold text-slate-600">Âge (ans)</label>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="range" min="1" max="100"
|
|
value={Math.min(attrs.age, 100)}
|
|
onChange={(e) => updateAttribute('age', parseInt(e.target.value))}
|
|
className="flex-1 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
|
|
/>
|
|
<input
|
|
type="number"
|
|
value={attrs.age}
|
|
onChange={(e) => updateAttribute('age', parseInt(e.target.value))}
|
|
className="w-20 p-1 text-right text-sm border border-slate-300 rounded font-mono text-indigo-700 bg-[#eef2ff] focus:border-indigo-500 outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex justify-between text-xs mb-1">
|
|
<label className="font-semibold text-slate-600">Taille (cm)</label>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="range" min="50" max="250"
|
|
value={Math.min(attrs.height, 250)}
|
|
onChange={(e) => updateAttribute('height', parseInt(e.target.value))}
|
|
className="flex-1 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
|
|
/>
|
|
<input
|
|
type="number"
|
|
value={attrs.height}
|
|
onChange={(e) => updateAttribute('height', parseInt(e.target.value))}
|
|
className="w-20 p-1 text-right text-sm border border-slate-300 rounded font-mono text-indigo-700 bg-[#eef2ff] focus:border-indigo-500 outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-500 mb-1">Cheveux</label>
|
|
<select
|
|
value={attrs.hair}
|
|
onChange={(e) => updateAttribute('hair', e.target.value)}
|
|
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm"
|
|
>
|
|
{HAIR_COLORS.map(c => <option key={c} value={c}>{c}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-500 mb-1">Yeux</label>
|
|
<select
|
|
value={attrs.eyes}
|
|
onChange={(e) => updateAttribute('eyes', e.target.value)}
|
|
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm"
|
|
>
|
|
{EYE_COLORS.map(c => <option key={c} value={c}>{c}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-500 mb-1">Signe distinctif</label>
|
|
<input
|
|
type="text"
|
|
value={attrs.physicalQuirk}
|
|
onChange={(e) => updateAttribute('physicalQuirk', e.target.value)}
|
|
placeholder="Cicatrice, tatouage..."
|
|
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm outline-none focus:border-indigo-400"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SECTION 3: PSYCHOLOGIE */}
|
|
<div className="bg-[#eef2ff] p-4 rounded-lg border border-indigo-100">
|
|
<h3 className="text-sm font-bold text-slate-700 uppercase mb-4 flex items-center gap-2">
|
|
<Brain size={16} /> Psychologie & Comportement
|
|
</h3>
|
|
|
|
<div className="space-y-6">
|
|
<div className="space-y-4 px-2">
|
|
<div className="relative pt-1">
|
|
<div className="flex justify-between text-[10px] uppercase font-bold text-slate-500 mb-1">
|
|
<span>Introverti</span>
|
|
<span>Extraverti</span>
|
|
</div>
|
|
<input
|
|
type="range" min="0" max="100"
|
|
value={attrs.personality.spectrumIntrovertExtravert}
|
|
onChange={(e) => updatePersonality('spectrumIntrovertExtravert', parseInt(e.target.value))}
|
|
className="w-full h-2 bg-gradient-to-r from-slate-300 via-indigo-200 to-slate-300 rounded-lg appearance-none cursor-pointer accent-indigo-600"
|
|
/>
|
|
</div>
|
|
<div className="relative pt-1">
|
|
<div className="flex justify-between text-[10px] uppercase font-bold text-slate-500 mb-1">
|
|
<span>Émotionnel</span>
|
|
<span>Rationnel</span>
|
|
</div>
|
|
<input
|
|
type="range" min="0" max="100"
|
|
value={attrs.personality.spectrumEmotionalRational}
|
|
onChange={(e) => updatePersonality('spectrumEmotionalRational', parseInt(e.target.value))}
|
|
className="w-full h-2 bg-gradient-to-r from-red-200 via-purple-200 to-blue-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
|
|
/>
|
|
</div>
|
|
<div className="relative pt-1">
|
|
<div className="flex justify-between text-[10px] uppercase font-bold text-slate-500 mb-1">
|
|
<span>Chaotique</span>
|
|
<span>Loyal</span>
|
|
</div>
|
|
<input
|
|
type="range" min="0" max="100"
|
|
value={attrs.personality.spectrumChaoticLawful}
|
|
onChange={(e) => updatePersonality('spectrumChaoticLawful', parseInt(e.target.value))}
|
|
className="w-full h-2 bg-gradient-to-r from-orange-200 via-yellow-100 to-green-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-slate-200 pt-4">
|
|
<label className="block text-xs font-semibold text-slate-500 mb-1">Toc ou habitude comportementale</label>
|
|
<input
|
|
type="text"
|
|
value={attrs.behavioralQuirk}
|
|
onChange={(e) => updateAttribute('behavioralQuirk', e.target.value)}
|
|
placeholder="Joue avec sa bague, bégaie quand il ment..."
|
|
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm outline-none focus:border-indigo-400"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderCustomFieldsEditor = () => {
|
|
const currentTemplate = templates.find(t => t.entityType === tempEntity?.type);
|
|
if (!currentTemplate || currentTemplate.fields.length === 0) return null;
|
|
|
|
return (
|
|
<div className="bg-[#eef2ff] p-4 rounded-lg border border-indigo-100 mt-6">
|
|
<h3 className="text-sm font-bold text-slate-700 uppercase mb-4 flex items-center gap-2">
|
|
<List size={16} /> Champs Personnalisés
|
|
</h3>
|
|
<div className="grid grid-cols-1 gap-4">
|
|
{currentTemplate.fields.map(field => {
|
|
const value = tempEntity?.customValues?.[field.id] ?? '';
|
|
|
|
return (
|
|
<div key={field.id}>
|
|
<label className="block text-xs font-semibold text-slate-500 mb-1">{field.label}</label>
|
|
|
|
{field.type === 'textarea' ? (
|
|
<textarea
|
|
value={value}
|
|
onChange={(e) => updateCustomValue(field.id, e.target.value)}
|
|
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm outline-none focus:border-indigo-400"
|
|
placeholder={field.placeholder}
|
|
/>
|
|
) : field.type === 'select' ? (
|
|
<select
|
|
value={value}
|
|
onChange={(e) => updateCustomValue(field.id, e.target.value)}
|
|
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm outline-none focus:border-indigo-400"
|
|
>
|
|
<option value="">Sélectionner...</option>
|
|
{field.options?.map(opt => (
|
|
<option key={opt} value={opt}>{opt}</option>
|
|
))}
|
|
</select>
|
|
) : field.type === 'boolean' ? (
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={!!value}
|
|
onChange={(e) => updateCustomValue(field.id, e.target.checked)}
|
|
className="w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500"
|
|
/>
|
|
<span className="text-sm text-slate-700">Activé / Oui</span>
|
|
</label>
|
|
) : (
|
|
<input
|
|
type={field.type === 'number' ? 'number' : 'text'}
|
|
value={value}
|
|
onChange={(e) => updateCustomValue(field.id, e.target.value)}
|
|
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm outline-none focus:border-indigo-400"
|
|
placeholder={field.placeholder}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderTemplateManager = () => {
|
|
const template = templates.find(t => t.entityType === activeTemplateType) || { entityType: activeTemplateType, fields: [] };
|
|
|
|
return (
|
|
<div className="flex-1 bg-white rounded-xl shadow-lg border border-slate-200 p-8 overflow-y-auto">
|
|
<div className="flex justify-between items-start mb-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
|
|
<Layout size={24} className="text-indigo-600" /> Éditeur de Modèles
|
|
</h2>
|
|
<p className="text-slate-500 text-sm mt-1">
|
|
Configurez les champs personnalisés pour chaque type de fiche.
|
|
</p>
|
|
</div>
|
|
<button onClick={() => setMode('entities')} className="p-2 text-slate-500 hover:bg-slate-100 rounded-full">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex gap-2 mb-8 border-b border-slate-200 pb-1">
|
|
{Object.values(EntityType).map(type => (
|
|
<button
|
|
key={type}
|
|
onClick={() => setActiveTemplateType(type)}
|
|
className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${activeTemplateType === type
|
|
? 'bg-indigo-50 text-indigo-700 border-b-2 border-indigo-600'
|
|
: 'text-slate-500 hover:text-slate-800 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
{type}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{template.fields.map((field, idx) => (
|
|
<div key={field.id} className="bg-[#eef2ff] border border-indigo-100 rounded-lg p-4 flex gap-4 items-start group">
|
|
<div className="flex-1 grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-500 mb-1">Nom du champ</label>
|
|
<input
|
|
type="text"
|
|
value={field.label}
|
|
onChange={(e) => updateCustomField(activeTemplateType, field.id, { label: e.target.value })}
|
|
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-500 mb-1">Type</label>
|
|
<select
|
|
value={field.type}
|
|
onChange={(e) => updateCustomField(activeTemplateType, field.id, { type: e.target.value as CustomFieldType })}
|
|
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm"
|
|
>
|
|
<option value="text">Texte court</option>
|
|
<option value="textarea">Texte long</option>
|
|
<option value="number">Nombre</option>
|
|
<option value="boolean">Case à cocher</option>
|
|
<option value="select">Liste déroulante</option>
|
|
</select>
|
|
</div>
|
|
{field.type === 'select' && (
|
|
<div className="col-span-2">
|
|
<label className="block text-xs font-semibold text-slate-500 mb-1">Options (séparées par des virgules)</label>
|
|
<input
|
|
type="text"
|
|
value={field.options?.join(',') || ''}
|
|
onChange={(e) => updateCustomField(activeTemplateType, field.id, { options: e.target.value.split(',').map(s => s.trim()) })}
|
|
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded text-sm"
|
|
placeholder="Option A, Option B, Option C"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => deleteCustomField(activeTemplateType, field.id)}
|
|
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded mt-5"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
|
|
<button
|
|
onClick={() => addCustomField(activeTemplateType)}
|
|
className="w-full py-3 border-2 border-dashed border-slate-300 rounded-lg text-slate-500 hover:border-indigo-400 hover:text-indigo-600 hover:bg-indigo-50 transition-all flex items-center justify-center gap-2"
|
|
>
|
|
<Plus size={20} /> Ajouter un champ
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
if (mode === 'templates') {
|
|
return (
|
|
<div className="flex h-full gap-6 p-6 bg-[#eef2ff]">
|
|
<div className="w-1/3 opacity-50 pointer-events-none filter blur-[1px]">
|
|
<div className="bg-white rounded-lg p-6 shadow-sm border border-slate-200">
|
|
<h3 className="font-bold text-slate-700 mb-4">Aperçu Fiches</h3>
|
|
<div className="space-y-2">
|
|
<div className="h-10 bg-indigo-50 rounded"></div>
|
|
<div className="h-10 bg-indigo-50 rounded"></div>
|
|
<div className="h-10 bg-indigo-50 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{renderTemplateManager()}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full gap-6 p-6 bg-[#eef2ff]">
|
|
<div className="w-1/3 flex flex-col gap-4">
|
|
<div className="flex justify-between items-center px-1">
|
|
<h2 className="text-lg font-bold text-slate-700">Explorateur</h2>
|
|
<button
|
|
onClick={() => setMode('templates')}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-100 text-indigo-700 hover:bg-indigo-200 rounded text-xs font-medium transition-colors"
|
|
title="Gérer les modèles de fiches"
|
|
>
|
|
<Settings size={14} /> Modèles
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-6 overflow-y-auto pr-2 pb-4 flex-1">
|
|
{Object.values(EntityType).map(type => (
|
|
<div key={type} className="bg-white rounded-lg shadow-sm border border-slate-200 overflow-hidden">
|
|
<div className="bg-indigo-50 p-3 border-b border-indigo-100 flex justify-between items-center">
|
|
<h3 className="font-semibold text-slate-700 flex items-center gap-2">
|
|
<span>{ENTITY_ICONS[type]}</span> {type}s
|
|
</h3>
|
|
<button
|
|
onClick={() => handleAdd(type)}
|
|
className="p-1 hover:bg-indigo-100 rounded text-indigo-600 transition-colors"
|
|
>
|
|
<Plus size={16} />
|
|
</button>
|
|
</div>
|
|
<div className="divide-y divide-slate-100">
|
|
{filterByType(type).length === 0 && (
|
|
<p className="p-4 text-sm text-slate-400 italic text-center">Aucun élément</p>
|
|
)}
|
|
{filterByType(type).map(entity => (
|
|
<div
|
|
key={entity.id}
|
|
onClick={() => handleEdit(entity)}
|
|
className={`p-3 cursor-pointer hover:bg-blue-50 transition-colors flex justify-between group ${editingId === entity.id ? 'bg-blue-50 border-l-4 border-blue-500' : ''}`}
|
|
>
|
|
<div>
|
|
<div className="font-medium text-slate-800">{entity.name}</div>
|
|
<div className="text-xs text-slate-500 truncate">{entity.description}</div>
|
|
</div>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleDelete(entity.id); }}
|
|
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 transition-opacity"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 bg-white rounded-xl shadow-lg border border-slate-200 p-8 overflow-y-auto">
|
|
{editingId && tempEntity ? (
|
|
<div className="space-y-6 animate-in fade-in duration-200">
|
|
<div className="flex justify-between items-start">
|
|
<div className="space-y-1">
|
|
<span className={`inline-block px-2 py-1 rounded text-xs font-bold uppercase tracking-wider ${ENTITY_COLORS[tempEntity.type]}`}>
|
|
{tempEntity.type}
|
|
</span>
|
|
<h2 className="text-2xl font-bold text-slate-800">
|
|
{tempEntity.type === EntityType.CHARACTER ? 'Fiche Personnage' : 'Édition de la fiche'}
|
|
</h2>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button onClick={() => setEditingId(null)} className="p-2 text-slate-500 hover:bg-slate-100 rounded-full">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Nom</label>
|
|
<input
|
|
type="text"
|
|
value={tempEntity.name}
|
|
onChange={e => setTempEntity({ ...tempEntity, name: e.target.value })}
|
|
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded focus:ring-2 focus:ring-blue-500 outline-none font-serif text-lg"
|
|
placeholder="Ex: Gandalf le Gris"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Description Courte (pour l'IA)</label>
|
|
<textarea
|
|
value={tempEntity.description}
|
|
onChange={e => setTempEntity({ ...tempEntity, description: e.target.value })}
|
|
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded focus:ring-2 focus:ring-blue-500 outline-none text-sm h-20"
|
|
placeholder="Un magicien puissant qui guide la communauté..."
|
|
/>
|
|
</div>
|
|
|
|
{tempEntity.type === EntityType.CHARACTER && renderCharacterEditor()}
|
|
|
|
{renderCustomFieldsEditor()}
|
|
|
|
<div className="mt-6 border-t border-slate-100 pt-6">
|
|
<div>
|
|
<label className="block text-sm font-medium text-indigo-700 mb-1 flex items-center gap-2">
|
|
<Sparkles size={14} /> Contexte Narratif (Auto-généré)
|
|
</label>
|
|
<textarea
|
|
value={tempEntity.storyContext || ''}
|
|
onChange={e => setTempEntity({ ...tempEntity, storyContext: e.target.value })}
|
|
className="w-full p-2 border border-indigo-200 bg-indigo-50 rounded focus:ring-2 focus:ring-blue-500 outline-none text-sm h-24 italic text-slate-600"
|
|
placeholder="Les événements vécus par ce personnage apparaîtront ici..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Notes & Biographie Complète</label>
|
|
<textarea
|
|
value={tempEntity.details}
|
|
onChange={e => setTempEntity({ ...tempEntity, details: e.target.value })}
|
|
className="w-full p-2 bg-[#eef2ff] border border-slate-300 rounded focus:ring-2 focus:ring-blue-500 outline-none h-48 font-serif"
|
|
placeholder="Histoire détaillée, secrets, origines..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4 flex justify-end">
|
|
<button
|
|
onClick={handleSave}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg flex items-center gap-2 transition-colors shadow-md"
|
|
>
|
|
<Save size={18} />
|
|
Enregistrer la fiche
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="h-full flex flex-col items-center justify-center text-slate-400">
|
|
<div className="text-6xl mb-4 opacity-20">🌍</div>
|
|
<p className="text-lg">Sélectionnez ou créez une fiche pour commencer.</p>
|
|
<p className="text-sm">Ces informations aideront l'IA à rester cohérente.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default WorldBuilder; |