almeira-web/src/components/publications/PublicationLayout.tsx
Carlos (Almeira) dda9ae0f4a feat: implement robust publications module with real-time validation and state management
- Refactor dashboard entity pages to support dynamic publications management.
- Add Publications Module with Atomic Design components (PostCard, PublicationsList, FormSection).
- Implement Zod-based real-time validation for publication forms.
- Manage state using Zustand for optimistic UI and in-place relations.
- Add RBAC protection with PermissionGuard and server-side API validation.
- Fix: Resolve TypeScript type error in AdWrappers.tsx to ensure successful build.
- Add ToastProvider and ErrorBoundary for improved user experience.
- Update dependencies for publications logic.
2026-03-28 08:47:40 -05:00

497 lines
20 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';
// ─────────────────────────────────────────────
// 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 [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);
// Client-side Zod validation
const parseResult = PostSchema.safeParse(Object.fromEntries(formData.entries()));
if (!parseResult.success) {
const errors: Record<string, string> = {};
parseResult.error.errors.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?.success) {
setOptimisticState('saved');
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>
</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"
defaultValue={initialData?.resumen_redes || ''}
placeholder="Resumen corto para compartir en redes sociales..."
maxLength={500}
className={textareaClass}
style={{ minHeight: '80px' }}
/>
</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}>
<textarea
name="contenido"
defaultValue={initialData?.contenido || ''}
placeholder="Escribe el contenido completo de la publicación..."
required
className={`${textareaClass}`}
style={{ minHeight: '250px' }}
/>
</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"
defaultValue={initialData?.seo_titulo || ''}
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"
defaultValue={initialData?.seo_descripcion || ''}
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={
typeof initialData?.tipo_publicacion_id === 'object'
? initialData.tipo_publicacion_id.$id
: initialData?.tipo_publicacion_id || ''
}
relationCollection="publication_types"
required
/>
{/* Estado */}
<FieldWrapper label="Estado" required error={validationErrors.estado}>
<select
name="estado"
defaultValue={initialData?.estado || '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}>
<RelationSelectField
name="imagen_destacada_id"
label="Imagen Destacada"
options={relationData.imagen_destacada_id || []}
labelKey="titulo"
value={
typeof initialData?.imagen_destacada_id === 'object'
? initialData.imagen_destacada_id.$id
: initialData?.imagen_destacada_id || ''
}
relationCollection="media"
/>
<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={
typeof initialData?.cliente_id === 'object'
? initialData.cliente_id.$id
: initialData?.cliente_id || ''
}
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>
{/* Submit Button */}
<PermissionGuard allowedRoles={['editor', 'admin']} userRole={userRole}>
<button
type="submit"
disabled={isLoading || isSaved}
className="w-full relative flex items-center justify-center gap-3 py-4 px-6 rounded-2xl
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_30px_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 ? 'Guardar Cambios' : 'Crear Publicación'}
</>
)}
</button>
</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;