connection base prisma + postgres + login ok
This commit is contained in:
724
src/components/WorldBuilder.tsx
Normal file
724
src/components/WorldBuilder.tsx
Normal file
@@ -0,0 +1,724 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { Entity, EntityType, CharacterAttributes, EntityTemplate, CustomFieldDefinition, CustomFieldType } from '@/lib/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 '@/lib/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;
|
||||
Reference in New Issue
Block a user