almeira-web/src/components/publications/PublicationLayout.tsx

542 lines
22 KiB
TypeScript

'use client';
/**
* @file PublicationLayout.tsx
* @description 12-column grid layout for the Post creation/edit form.
* Organizes fields into logical sections: Datos Básicos, Contenido,
* Multimedia, SEO, Configuración, and Comercial.
*
* Integrates:
* - `FormSection` for visual grouping
* - `RelationSelectField` for relation fields with quick-create
* - `RichTextEditor` for contenido field
* - `usePublicationStore` for optimistic UI and state
* - Zod validation (client-side, before submission)
* - `PermissionGuard` to ensure only editor/admin can submit
*/
import React, { useState, useTransition, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
FileText, Image as ImageIcon, Search, Settings, Briefcase,
Save, ArrowLeft, AlertCircle, CheckCircle, Loader2, Eye
} from 'lucide-react';
import Link from 'next/link';
import { PublicationLayoutProps } from '@/types/publications';
import { PostSchema, generateSlug } from '@/lib/validators/publications';
import { usePublicationStore } from '@/store/usePublicationStore';
import FormSection from './FormSection';
import RelationSelectField from './RelationSelectField';
import PermissionGuard from './PermissionGuard';
import { RichTextEditor } from '../dashboard/RichTextEditor';
import { MediaGridSelector } from '../dashboard/MediaGridSelector';
// ─────────────────────────────────────────────
// FIELD COMPONENTS
// ─────────────────────────────────────────────
function FieldWrapper({
label,
required,
error,
children,
hint,
colSpan = 1,
}: {
label: string;
required?: boolean;
error?: string;
children: React.ReactNode;
hint?: string;
colSpan?: number;
}) {
const spanClass = colSpan === 2 ? 'md:col-span-2' : '';
return (
<div className={`flex flex-col gap-1.5 ${spanClass}`}>
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500">
{label}
{required && <span className="text-cyan-400 ml-1">*</span>}
</label>
{children}
{hint && !error && <p className="text-[10px] text-slate-600">{hint}</p>}
{error && (
<p className="text-[10px] text-red-400 flex items-center gap-1 font-bold">
<AlertCircle size={10} /> {error}
</p>
)}
</div>
);
}
const inputClass =
'w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-white text-sm ' +
'focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500/30 ' +
'hover:border-slate-700 transition-all placeholder:text-slate-700';
const textareaClass = inputClass + ' min-h-[100px] resize-y';
// ─────────────────────────────────────────────
// MAIN COMPONENT
// ─────────────────────────────────────────────
export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
initialData,
relationData,
submitAction,
user,
appwriteConfig,
}) => {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const { setOptimisticState, optimisticState, errorMessage, reset, setRelationCache } = usePublicationStore();
// Form state
const [titulo, setTitulo] = useState(initialData?.titulo || '');
const [slug, setSlug] = useState(initialData?.slug || '');
const [isAutoSlug, setIsAutoSlug] = useState(!initialData?.slug);
const [contenido, setContenido] = useState(initialData?.contenido || '');
const [resumen_redes, setResumenRedes] = useState(initialData?.resumen_redes || '');
const [imagenDestacadaId, setImagenDestacadaId] = useState<string>(
typeof initialData?.imagen_destacada_id === 'object'
? initialData?.imagen_destacada_id?.$id
: initialData?.imagen_destacada_id || ''
);
// SEO Smart Autofill States
const [seoTitulo, setSeoTitulo] = useState(initialData?.seo_titulo || '');
const [seoDescripcion, setSeoDescripcion] = useState(initialData?.seo_descripcion || '');
const [seoDirty, setSeoDirty] = useState({ titulo: !!initialData?.seo_titulo, descripcion: !!initialData?.seo_descripcion });
useEffect(() => {
if (!seoDirty.titulo && titulo) {
setSeoTitulo(titulo.substring(0, 70));
}
}, [titulo, seoDirty.titulo]);
useEffect(() => {
if (!seoDirty.descripcion) {
// Strip HTML if it's the rich text
const richTextSnippet = contenido?.replace(/<[^>]*>?/gm, '').substring(0, 160) || '';
const sourceMeta = resumen_redes || richTextSnippet;
if (sourceMeta) {
setSeoDescripcion(sourceMeta.substring(0, 160));
}
}
}, [contenido, resumen_redes, seoDirty.descripcion]);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
// Populate relation cache in the store on mount
useEffect(() => {
Object.entries(relationData).forEach(([collection, data]) => {
setRelationCache(collection, data);
});
return () => reset();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Auto-generate slug from titulo
useEffect(() => {
if (isAutoSlug && titulo) {
setSlug(generateSlug(titulo));
}
}, [titulo, isAutoSlug]);
const userRole = (user?.prefs?.role as any) || 'viewer';
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
formData.set('contenido', contenido); // Ensure rich text state is attached
// Client-side Zod validation
const parseResult = PostSchema.safeParse(Object.fromEntries(formData.entries()));
if (!parseResult.success) {
const errors: Record<string, string> = {};
parseResult.error.issues.forEach((err) => {
const field = err.path[0] as string;
errors[field] = err.message;
});
setValidationErrors(errors);
return;
}
setValidationErrors({});
setOptimisticState('saving');
startTransition(async () => {
try {
const result = await submitAction(formData);
// If result is undefined/void, it likely redirected (success)
if (!result || result.success) {
setOptimisticState('saved');
// Only redirect manually if we didn't already redirect from the action
if (result) {
setTimeout(() => router.push('/dashboard/posts'), 800);
}
} else {
setOptimisticState('error', result.error || 'Error al guardar.');
}
} catch (err: unknown) {
setOptimisticState('error', err instanceof Error ? err.message : 'Error desconocido.');
}
});
};
const isLoading = isPending || optimisticState === 'saving';
const isSaved = optimisticState === 'saved';
return (
<div className="max-w-7xl mx-auto pb-16 animate-fade-in">
{/* Page Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div className="flex items-center gap-4">
<Link
href="/dashboard/posts"
className="p-2 rounded-xl border border-slate-800 text-slate-400 hover:text-white hover:border-slate-700 transition-all"
>
<ArrowLeft size={18} />
</Link>
<div>
<h1 className="text-2xl font-black font-outfit text-white uppercase tracking-tighter">
{initialData?.$id ? 'Editar Publicación' : 'Nueva Publicación'}
</h1>
<p className="text-slate-500 text-sm font-medium">
{initialData?.$id ? `ID: ${initialData.$id}` : 'Completa todos los campos requeridos.'}
</p>
</div>
</div>
{/* Status indicator */}
<div className="flex items-center gap-3">
{isSaved && (
<span className="flex items-center gap-2 text-emerald-400 text-sm font-bold bg-emerald-500/10 border border-emerald-500/30 px-4 py-2 rounded-xl">
<CheckCircle size={16} /> Guardado
</span>
)}
{optimisticState === 'error' && (
<span className="flex items-center gap-2 text-red-400 text-sm font-bold bg-red-500/10 border border-red-500/30 px-4 py-2 rounded-xl">
<AlertCircle size={16} /> {errorMessage}
</span>
)}
</div>
</div>
<form onSubmit={handleSubmit}>
{/* 12-column grid layout: left=8cols content, right=4cols sidebar */}
<div className="grid grid-cols-1 xl:grid-cols-12 gap-8">
{/* ── LEFT COLUMN (8/12) ─────────────────────── */}
<div className="xl:col-span-8 space-y-8">
{/* SECCIÓN: Datos Básicos */}
<div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6">
<FormSection title="Datos Básicos" icon={<FileText size={16} />} columns={1}>
{/* Título */}
<FieldWrapper label="Título" required error={validationErrors.titulo}>
<input
type="text"
name="titulo"
value={titulo}
onChange={(e) => setTitulo(e.target.value)}
placeholder="Escribe un título llamativo..."
required
className={inputClass}
/>
</FieldWrapper>
{/* Slug */}
<FieldWrapper
label="URL Amigable (Slug)"
required
error={validationErrors.slug}
hint="Se genera automáticamente. Edita para personalizar."
>
<div className="flex items-center gap-2">
<input
type="text"
name="slug"
value={slug}
onChange={(e) => { setSlug(e.target.value); setIsAutoSlug(false); }}
placeholder="url-de-la-publicacion"
required
className={`${inputClass} font-mono`}
/>
<button
type="button"
onClick={() => { setIsAutoSlug(true); setSlug(generateSlug(titulo)); }}
className="shrink-0 px-3 py-2.5 rounded-xl border border-slate-700 text-slate-400 text-xs font-bold hover:border-cyan-500 hover:text-cyan-400 transition-all"
title="Regenerar slug desde el título"
>
↺
</button>
{/* Inject initial Author if it's a new post (Autocompletion UX) */}
{!initialData?.$id && user?.$id && (
<input type="hidden" name="autor_id" value={user.$id} />
)}
</div>
</FieldWrapper>
{/* Resumen */}
<FieldWrapper
label="Resumen para Redes"
error={validationErrors.resumen_redes}
hint="Máximo 500 caracteres. Se usa en Twitter/Facebook cards."
>
<textarea
name="resumen_redes"
value={resumen_redes}
onChange={(e) => setResumenRedes(e.target.value)}
placeholder="Resumen corto para compartir en redes sociales..."
maxLength={500}
className={`${textareaClass} custom-scrollbar resize-none`}
style={{ minHeight: '80px', maxHeight: '150px', overflowY: 'auto' }}
/>
</FieldWrapper>
</FormSection>
</div>
{/* SECCIÓN: Contenido */}
<div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6">
<FormSection title="Contenido Principal" icon={<Eye size={16} />} columns={1}>
<FieldWrapper label="Contenido Completo" required error={validationErrors.contenido}>
<RichTextEditor
label="Contenido"
value={contenido}
onChange={(val) => setContenido(val)}
/>
</FieldWrapper>
</FormSection>
</div>
{/* SECCIÓN: SEO */}
<div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6">
<FormSection title="SEO & Metadatos" icon={<Search size={16} />} columns={2}>
<FieldWrapper
label="SEO Título"
error={validationErrors.seo_titulo}
hint="Máximo 70 caracteres."
>
<input
type="text"
name="seo_titulo"
value={seoTitulo}
onChange={(e) => {
setSeoTitulo(e.target.value);
setSeoDirty(prev => ({ ...prev, titulo: true }));
}}
maxLength={70}
placeholder="Título para buscadores..."
className={inputClass}
/>
</FieldWrapper>
<FieldWrapper
label="SEO Descripción"
error={validationErrors.seo_descripcion}
hint="Máximo 160 caracteres."
>
<input
type="text"
name="seo_descripcion"
value={seoDescripcion}
onChange={(e) => {
setSeoDescripcion(e.target.value);
setSeoDirty(prev => ({ ...prev, descripcion: true }));
}}
maxLength={160}
placeholder="Descripción para resultados de búsqueda..."
className={inputClass}
/>
</FieldWrapper>
<FieldWrapper label="OG Image URL" error={validationErrors.og_image_url}>
<input
type="url"
name="og_image_url"
defaultValue={initialData?.og_image_url || ''}
placeholder="https://..."
className={inputClass}
/>
</FieldWrapper>
</FormSection>
</div>
</div>
{/* ── RIGHT COLUMN (4/12) ─────────────────────── */}
<div className="xl:col-span-4 space-y-6">
{/* SECCIÓN: Configuración */}
<div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6">
<FormSection title="Configuración" icon={<Settings size={16} />} columns={1}>
{/* Tipo de Publicación */}
<RelationSelectField
name="tipo_publicacion_id"
label="Tipo de Publicación"
options={relationData.tipo_publicacion_id || []}
labelKey="descripcion"
value={
initialData?.tipo_publicacion_id && typeof initialData.tipo_publicacion_id === 'object'
? initialData.tipo_publicacion_id.$id
: (initialData?.tipo_publicacion_id as string) || ''
}
relationCollection="publication_types"
required
/>
{/* Estado */}
<FieldWrapper label="Estado" required error={validationErrors.estado}>
<select
name="estado"
defaultValue={initialData?.estado?.toLowerCase() || 'borrador'}
required
className={inputClass}
>
<option value="borrador">Borrador</option>
<option value="pendiente">Pendiente revisión</option>
<option value="publicado">Publicado</option>
<option value="archivado">Archivado</option>
</select>
</FieldWrapper>
{/* Fecha Publicación */}
<FieldWrapper label="Fecha de Publicación" required error={validationErrors.fecha_publicacion}>
<input
type="datetime-local"
name="fecha_publicacion"
defaultValue={
initialData?.fecha_publicacion
? new Date(initialData.fecha_publicacion).toISOString().slice(0, 16)
: new Date().toISOString().slice(0, 16)
}
required
className={inputClass}
/>
</FieldWrapper>
{/* Fecha Expiración */}
<FieldWrapper label="Fecha de Expiración" hint="Opcional. Para banners con caducidad.">
<input
type="datetime-local"
name="fecha_finalizacion"
defaultValue={
initialData?.fecha_finalizacion
? new Date(initialData.fecha_finalizacion).toISOString().slice(0, 16)
: ''
}
className={inputClass}
/>
</FieldWrapper>
</FormSection>
</div>
{/* SECCIÓN: Multimedia */}
<div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6">
<FormSection title="Multimedia" icon={<ImageIcon size={16} />} columns={1}>
<MediaGridSelector
value={imagenDestacadaId}
onChange={(val) => setImagenDestacadaId(val)}
label="Imagen Destacada"
mode="field"
/>
<FieldWrapper label="URL de Video (YouTube/Vimeo)" error={validationErrors.video_url}>
<input
type="url"
name="video_url"
defaultValue={initialData?.video_url || ''}
placeholder="https://youtube.com/watch?v=..."
className={inputClass}
/>
</FieldWrapper>
</FormSection>
</div>
{/* SECCIÓN: Comercial */}
<div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6">
<FormSection title="Comercial" icon={<Briefcase size={16} />} columns={1}>
<RelationSelectField
name="cliente_id"
label="Patrocinador / Cliente"
options={relationData.cliente_id || []}
labelKey="nombre_entidad"
value={
initialData?.cliente_id && typeof initialData.cliente_id === 'object'
? initialData.cliente_id.$id
: (initialData?.cliente_id as string) || ''
}
relationCollection="clients"
/>
<FieldWrapper label="URL Destino (Click del banner)" error={validationErrors.url_destino}>
<input
type="url"
name="url_destino"
defaultValue={initialData?.url_destino || ''}
placeholder="https://cliente.com/landing"
className={inputClass}
/>
</FieldWrapper>
</FormSection>
</div>
{/* Sticky Action Bar */}
<PermissionGuard allowedRoles={['editor', 'admin']} userRole={userRole}>
<div className="fixed bottom-0 left-0 right-0 z-50 p-4 bg-slate-950/80 backdrop-blur-xl border-t border-slate-800/50 xl:pl-64">
<div className="max-w-7xl mx-auto flex justify-end">
<button
type="submit"
disabled={isLoading || isSaved}
className="w-full md:w-auto relative flex items-center justify-center gap-3 py-3 px-8 rounded-xl
font-black uppercase tracking-widest text-sm overflow-hidden
bg-gradient-to-r from-cyan-500 to-blue-600
hover:from-cyan-400 hover:to-blue-500
text-slate-950 shadow-[0_0_20px_rgba(6,182,212,0.3)]
transition-all disabled:opacity-60 disabled:cursor-not-allowed
active:scale-98"
>
{isLoading ? (
<>
<Loader2 size={18} className="animate-spin" />
Guardando…
</>
) : isSaved ? (
<>
<CheckCircle size={18} />
¡Guardado!
</>
) : (
<>
<Save size={18} />
{initialData?.$id ? 'Actualizar Cambios' : 'Crear Publicación'}
</>
)}
</button>
</div>
</div>
</PermissionGuard>
{/* No permission notice */}
<PermissionGuard
allowedRoles={['admin']}
userRole={userRole}
fallback={
userRole !== 'editor' && userRole !== 'admin' ? (
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl text-amber-400 text-xs font-bold text-center">
Solo los editores y administradores pueden crear o editar publicaciones.
</div>
) : null
}
>
<></>
</PermissionGuard>
</div>
</div>
</form>
</div>
);
};
export default PublicationLayout;