almeira-web/src/components/dashboard/EntityForm.tsx

318 lines
16 KiB
TypeScript

'use client';
import { useState, useEffect, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Save, Globe, Calendar, Tag, Image as ImageIcon, Eye, Code } from 'lucide-react';
import Link from 'next/link';
import { FieldDefinition } from '@/lib/schema';
import { MediaPickerMini } from './MediaPickerMini';
import { RichTextEditor } from './RichTextEditor';
import { QuickCreateModal } from './QuickCreateModal';
import { collectionMap } from '@/lib/schema';
import { Client, Databases } from 'appwrite';
// Minimalistic image preview for relations with 'media'
const MediaPreview = ({ fileId, bucketId, endpoint, projectId }: any) => {
if(!fileId) return null;
const url = `${endpoint}/storage/buckets/${bucketId}/files/${fileId}/view?project=${projectId}`;
return (
<div className="mt-2 rounded-xl overflow-hidden border border-slate-800 bg-slate-950 aspect-video relative group">
// eslint-disable-next-line @next/next/no-img-element
<img src={url} alt="Preview" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<span className="text-[10px] font-bold text-white uppercase tracking-widest">Imagen Seleccionada</span>
</div>
</div>
);
}
interface EntityFormProps {
entity: string;
entityName: string;
initialData?: any;
fields: FieldDefinition[];
relationData: Record<string, any[]>;
submitAction: (formData: FormData) => Promise<any>;
appwriteConfig: {
endpoint: string;
projectId: string;
}
}
export const EntityForm = ({
entity,
entityName,
initialData,
fields,
relationData,
submitAction,
appwriteConfig
}: EntityFormProps) => {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [title, setTitle] = useState(initialData?.titulo || initialData?.nombre || '');
const [slug, setSlug] = useState(initialData?.slug || '');
const [isAutoSlug, setIsAutoSlug] = useState(!initialData?.slug);
const [featuredImageId, setFeaturedImageId] = useState(initialData?.imagen_destacada_id || '');
const [editorValues, setEditorValues] = useState<Record<string, string>>({});
const [localRelationData, setLocalRelationData] = useState(relationData);
const [activeTab, setActiveTab] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalEntity, setModalEntity] = useState('');
const [activeRelationField, setActiveRelationField] = useState('');
// Initialize editor values
useEffect(() => {
const initialValues: Record<string, string> = {};
fields.forEach(f => {
if (f.type === 'textarea') {
initialValues[f.name] = initialData?.[f.name] || '';
}
});
setEditorValues(initialValues);
}, [fields, initialData]);
// Update slug automatically if auto-slug is enabled
useEffect(() => {
if (isAutoSlug) {
const generated = title
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
setSlug(generated);
}
}, [title, isAutoSlug]);
const sections = Array.from(new Set(fields.map(f => f.section || 'General')));
useEffect(() => {
if (sections.length > 0 && !activeTab) {
setActiveTab(sections[0]);
}
}, [sections, activeTab]);
const refreshRelation = async (field: string, collectionId: string) => {
const client = new Client()
.setEndpoint(appwriteConfig.endpoint)
.setProject(appwriteConfig.projectId);
const databases = new Databases(client);
try {
const res = await databases.listDocuments('almeira', collectionId, []);
setLocalRelationData(prev => ({ ...prev, [field]: res.documents }));
} catch (err) {
console.error("Error al refrescar relación:", err);
}
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
// Add editor values to formData
for (const [key, value] of Object.entries(editorValues)) {
formData.set(key, value);
}
// Ensure featured image ID is set if present
if (featuredImageId) {
formData.set('imagen_destacada_id', featuredImageId);
}
startTransition(async () => {
await submitAction(formData);
});
};
// Organize fields into Main Content and Sidebar Metadata
const sidebarFieldNames = ['estado', 'fecha_publicacion', 'fecha_finalizacion', 'slug', 'imagen_destacada_id', 'categoria_id', 'tipo_publicacion_id'];
const mainFields = fields.filter(f => !sidebarFieldNames.includes(f.name));
const sidebarFields = fields.filter(f => sidebarFieldNames.includes(f.name));
const getDefValue = (field: FieldDefinition) => {
const val = initialData ? initialData[field.name] : null;
// Set smart defaults for new items
if (!initialData) {
if (field.name === 'estado') return 'borrador';
if (field.name === 'fecha_publicacion' || field.type === 'date') {
// Get local ISO string for datetime-local input
const now = new Date();
return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
}
if (field.type === 'boolean') return 'false';
}
if (val === undefined || val === null) return '';
if (field.type === 'boolean') return val ? 'true' : 'false';
if (field.type === 'date') return new Date(val).toISOString().slice(0, 16);
if (field.type === 'relation') return typeof val === 'object' ? val.$id : val;
return val;
};
return (
<>
<form onSubmit={handleSubmit} className="max-w-7xl mx-auto space-y-8 animate-fade-in pb-20">
{/* Header Sticky */}
<div className="sticky top-4 z-40 flex items-center justify-between bg-slate-900/80 backdrop-blur-xl border border-slate-800 p-4 rounded-2xl shadow-2xl shadow-cyan-500/10">
<div className="flex items-center gap-4">
<Link href={`/dashboard/${entity}`} className="p-2 text-slate-400 hover:text-white bg-slate-800 rounded-xl transition-all">
<ArrowLeft size={20} />
</Link>
<div>
<h1 className="text-xl font-black uppercase font-outfit tracking-tighter text-white">
{initialData ? 'Editando' : 'Nuevo'}: <span className="text-cyan-400">{entityName}</span>
</h1>
{initialData && <p className="text-[10px] font-mono text-slate-500 uppercase">{initialData.$id}</p>}
</div>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => router.back()}
className="hidden md:block px-6 py-2.5 rounded-xl text-sm font-bold text-slate-400 hover:text-white transition-colors"
disabled={isPending}
>
Descartar
</button>
<button
type="submit"
disabled={isPending}
className="flex items-center gap-2 bg-gradient-to-r from-blue-600 to-cyan-500 hover:from-blue-500 hover:to-cyan-400 text-white text-sm font-black uppercase tracking-widest px-8 py-2.5 rounded-xl shadow-[0_0_20px_rgba(6,182,212,0.3)] transition-all hover:scale-105 active:scale-95 disabled:opacity-50"
>
{isPending ? <div className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></div> : <Save size={18} />}
<span>{initialData ? 'Actualizar' : 'Publicar'}</span>
</button>
</div>
</div>
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-1 flex overflow-x-auto gap-1 mb-6 sticky top-24 z-30 shadow-lg">
{sections.map(section => (
<button
key={section}
type="button"
onClick={() => setActiveTab(section)}
className={`px-6 py-2.5 rounded-2xl text-xs font-black uppercase tracking-widest transition-all ${activeTab === section ? 'bg-cyan-500 text-white shadow-lg shadow-cyan-500/20' : 'text-slate-500 hover:text-slate-300 hover:bg-slate-800'}`}
>
{section}
</button>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-1 gap-8">
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-8 shadow-xl relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-600 to-cyan-500 opacity-50"></div>
<div className="grid grid-cols-12 gap-x-8 gap-y-10">
{fields.filter(f => (f.section || 'General') === activeTab).map(field => {
const defaultValue = getDefValue(field);
const gridSpanMap: Record<number, string> = {
1: 'col-span-1', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4',
5: 'col-span-5', 6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8',
9: 'col-span-9', 10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12'
};
const gridSpan = gridSpanMap[field.gridSize || 12] || 'col-span-12';
return (
<div key={field.name} className={`${gridSpan} flex flex-col gap-2 group`}>
<div className="flex justify-between items-center mb-1">
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 group-focus-within:text-cyan-400 transition-colors">
{field.label} {field.required && <span className="text-pink-500">*</span>}
</label>
{field.type === 'relation' && (
<span className="text-[8px] font-bold text-slate-600 uppercase tracking-tighter">Double click to create quick +</span>
)}
</div>
{field.name === 'titulo' || field.name === 'nombre_entidad' ? (
<input
type="text"
name={field.name}
required={field.required}
defaultValue={defaultValue}
onChange={(e) => setTitle(e.target.value)}
placeholder={`Nombre de ${field.label}...`}
className="w-full bg-transparent border-b-2 border-slate-800 py-2 text-2xl font-bold text-white focus:outline-none focus:border-cyan-500 transition-all placeholder:text-slate-700"
/>
) : field.type === 'textarea' ? (
<RichTextEditor
label={field.label}
value={editorValues[field.name] || ''}
onChange={(val) => setEditorValues(prev => ({ ...prev, [field.name]: val }))}
/>
) : field.type === 'relation' ? (
<div className="space-y-4">
<select
name={field.name}
defaultValue={defaultValue}
required={field.required}
onDoubleClick={() => {
setModalEntity(field.relationCollection as string);
setActiveRelationField(field.name);
setIsModalOpen(true);
}}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all appearance-none pr-10 hover:border-slate-700 cursor-pointer"
>
<option value="">Seleccionar vinculación...</option>
{localRelationData[field.name]?.map(relDoc => (
<option key={relDoc.$id} value={relDoc.$id}>
{relDoc[field.relationLabel || 'titulo'] || relDoc.$id}
</option>
))}
</select>
{field.relationCollection === 'media' && (
<MediaPreview
fileId={defaultValue}
bucketId="media"
endpoint={appwriteConfig.endpoint}
projectId={appwriteConfig.projectId}
/>
)}
</div>
) : field.type === 'select' ? (
<select name={field.name} defaultValue={defaultValue} required={field.required} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all appearance-none pr-10 capitalize">
{field.options?.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
) : field.type === 'date' ? (
<input type="datetime-local" name={field.name} defaultValue={defaultValue} required={field.required} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all [color-scheme:dark]" />
) : field.type === 'boolean' ? (
<select name={field.name} defaultValue={String(defaultValue)} required={field.required} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all">
<option value="true">Sí (Activo)</option>
<option value="false">No (Inactivo)</option>
</select>
) : (
<input type={field.type === 'number' ? 'number' : 'text'} name={field.name} defaultValue={defaultValue} required={field.required} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all" />
)}
</div>
);
})}
</div>
</div>
</div>
</form>
<QuickCreateModal
entity={modalEntity}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSuccess={async (newId) => {
const relField = fields.find(f => f.name === activeRelationField);
if (relField?.relationCollection) {
await refreshRelation(activeRelationField, collectionMap[relField.relationCollection]);
// Set wait to ensure state has updated options before selecting
setTimeout(() => {
const selectElement = document.querySelector(`select[name="${activeRelationField}"]`) as HTMLSelectElement;
if (selectElement) {
selectElement.value = newId;
}
}, 1000);
}
}}
/>
</>
);
};