318 lines
16 KiB
TypeScript
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);
|
|
}
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
};
|