- 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.
497 lines
20 KiB
TypeScript
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;
|