almeira-web/src/components/publications/PublicationsList.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

300 lines
11 KiB
TypeScript

'use client';
/**
* @file PublicationsList.tsx
* @description Optimized, RBAC-aware publications list for the dashboard.
*
* Architecture:
* - Uses React.memo on PostCard to avoid re-renders on unrelated state changes
* - useCallback for all event handlers
* - useMemo for expensive derived state (filtered/sorted posts)
* - Integrates PublicationDrawer for inline editing
* - Integrates useToast for user feedback
* - Wrapped by ErrorBoundary at usage site
*
* RBAC:
* - Editor/Admin: can open drawer to edit posts on click
* - Admin only: can delete posts
* - Viewer: read-only list
*/
import React, { useState, useCallback, useMemo, useTransition } from 'react';
import { Search, Plus, Filter, Loader2, FileText } from 'lucide-react';
import Link from 'next/link';
import type { Post, AuthUser, UserRole } from '@/types/publications';
import PostCard from './PostCard';
import PermissionGuard from './PermissionGuard';
import { useToast } from './ToastProvider';
// ─────────────────────────────────────────────
// TYPES
// ─────────────────────────────────────────────
interface PublicationsListProps {
initialPosts: Post[];
user: AuthUser;
relationData: Record<string, Array<{ $id: string; [key: string]: unknown }>>;
submitEditAction: (formData: FormData) => Promise<{ success: boolean; error?: string; data?: unknown }>;
appwriteConfig: {
endpoint: string;
projectId: string;
databaseId: string;
};
entity: string;
}
type SortKey = 'fecha_publicacion' | '$createdAt' | 'titulo' | 'estado';
// ─────────────────────────────────────────────
// COMPONENT
// ─────────────────────────────────────────────
export const PublicationsList: React.FC<PublicationsListProps> = ({
initialPosts,
user,
relationData,
submitEditAction,
appwriteConfig,
entity,
}) => {
const toast = useToast();
const [isPending, startTransition] = useTransition();
// Local state
const [posts, setPosts] = useState<Post[]>(initialPosts);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<SortKey>('fecha_publicacion');
const [drawerPost, setDrawerPost] = useState<Post | null>(null);
// Lazy-load Drawer to avoid shipping it unless needed
const [DrawerComponent, setDrawerComponent] = useState<React.ComponentType<import('./PublicationDrawer').PublicationDrawerProps> | null>(null);
const userRole = (user?.prefs?.role as UserRole) ?? 'viewer';
// ── DERIVED STATE ────────────────────────────
const filteredAndSortedPosts = useMemo(() => {
let result = [...posts];
// Filter by search
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(p) =>
p.titulo.toLowerCase().includes(q) ||
p.slug.toLowerCase().includes(q) ||
(p.resumen_redes || '').toLowerCase().includes(q)
);
}
// Filter by status
if (statusFilter !== 'all') {
result = result.filter((p) => p.estado === statusFilter);
}
// Sort
result.sort((a, b) => {
if (sortBy === 'titulo') return a.titulo.localeCompare(b.titulo);
const aDate = new Date(a[sortBy] || a.$createdAt).getTime();
const bDate = new Date(b[sortBy] || b.$createdAt).getTime();
return bDate - aDate; // Newest first
});
return result;
}, [posts, searchQuery, statusFilter, sortBy]);
const stats = useMemo(() => ({
total: posts.length,
published: posts.filter((p) => p.estado === 'Publicado').length,
draft: posts.filter((p) => p.estado === 'Borrador').length,
pending: posts.filter((p) => p.estado === 'Pendiente').length,
}), [posts]);
// ── HANDLERS ────────────────────────────────
const handleEdit = useCallback(async (post: Post) => {
// Lazy-load the drawer component
if (!DrawerComponent) {
const mod = await import('./PublicationDrawer');
setDrawerComponent(() => mod.PublicationDrawer);
}
setDrawerPost(post);
}, [DrawerComponent]);
const handleCloseDrawer = useCallback(() => {
setDrawerPost(null);
}, []);
const handleDelete = useCallback((postId: string) => {
if (!confirm('¿Eliminar esta publicación permanentemente?')) return;
startTransition(async () => {
try {
const res = await fetch(`/api/publications?id=${postId}`, { method: 'DELETE' });
const json = await res.json() as { success: boolean; error?: string };
if (json.success) {
// Optimistic UI: remove from local state immediately
setPosts((prev) => prev.filter((p) => p.$id !== postId));
toast.success('Publicación eliminada correctamente.');
} else {
toast.error(json.error || 'Error al eliminar la publicación.');
}
} catch {
toast.error('Error de red. Intenta de nuevo.');
}
});
}, [toast]);
const handleEditSuccess = useCallback(() => {
setDrawerPost(null);
toast.success('Publicación actualizada correctamente.');
// Note: For a full refresh of the list, the parent Server Component should
// call revalidatePath. Here we rely on Next.js router refresh.
window.location.reload();
}, [toast]);
// ── RENDER ──────────────────────────────────
return (
<div className="space-y-6 animate-fade-in">
{/* Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: 'Total', value: stats.total, color: 'text-white' },
{ label: 'Publicados', value: stats.published, color: 'text-emerald-400' },
{ label: 'Borradores', value: stats.draft, color: 'text-slate-400' },
{ label: 'Pendientes', value: stats.pending, color: 'text-amber-400' },
].map((s) => (
<div key={s.label} className="bg-slate-900/60 border border-white/8 rounded-2xl p-4">
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest mb-1">{s.label}</p>
<p className={`text-3xl font-black ${s.color}`}>{s.value}</p>
</div>
))}
</div>
{/* Toolbar */}
<div className="flex flex-col md:flex-row gap-3">
{/* Search */}
<div className="relative flex-1">
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="search"
placeholder="Buscar publicaciones..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-slate-900/60 border border-white/8 rounded-xl pl-10 pr-4 py-2.5 text-white text-sm
focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500/30 transition-all"
/>
</div>
{/* Status Filter */}
<div className="flex items-center gap-2">
<Filter size={14} className="text-slate-500" />
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="bg-slate-900/60 border border-white/8 rounded-xl px-3 py-2.5 text-white text-sm
focus:border-cyan-500 focus:outline-none transition-all"
>
<option value="all">Todos los estados</option>
<option value="Publicado">Publicados</option>
<option value="Borrador">Borradores</option>
<option value="Pendiente">Pendientes</option>
<option value="Archivado">Archivados</option>
</select>
</div>
{/* Sort */}
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortKey)}
className="bg-slate-900/60 border border-white/8 rounded-xl px-3 py-2.5 text-white text-sm
focus:border-cyan-500 focus:outline-none transition-all"
>
<option value="fecha_publicacion">Más recientes</option>
<option value="titulo">Título A-Z</option>
<option value="estado">Estado</option>
</select>
{/* New post button */}
<PermissionGuard allowedRoles={['editor', 'admin']} userRole={userRole}>
<Link
href={`/dashboard/${entity}/new`}
className="flex items-center gap-2 px-5 py-2.5 bg-cyan-500 hover:bg-cyan-400
text-slate-950 font-black text-sm rounded-xl transition-all
shadow-[0_0_20px_rgba(6,182,212,0.25)] whitespace-nowrap"
>
<Plus size={16} />
Nueva publicación
</Link>
</PermissionGuard>
</div>
{/* Grid de publicaciones */}
{isPending && (
<div className="flex items-center gap-2 text-slate-400 text-sm py-2">
<Loader2 size={14} className="animate-spin" />
Procesando…
</div>
)}
{filteredAndSortedPosts.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredAndSortedPosts.map((post) => (
<PostCard
key={post.$id}
post={post}
userRole={userRole}
onEdit={handleEdit}
onDelete={handleDelete}
appwriteEndpoint={appwriteConfig.endpoint}
appwriteProjectId={appwriteConfig.projectId}
/>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center gap-4 py-20 border-2 border-dashed border-slate-800 rounded-2xl">
<div className="p-4 bg-slate-800/50 rounded-full">
<FileText size={32} className="text-slate-600" />
</div>
<div className="text-center">
<h3 className="text-white font-bold mb-1">Sin resultados</h3>
<p className="text-slate-500 text-sm">
{searchQuery ? 'No hay publicaciones que coincidan con tu búsqueda.' : 'Aún no hay publicaciones. ¡Crea la primera!'}
</p>
</div>
<PermissionGuard allowedRoles={['editor', 'admin']} userRole={userRole}>
<Link
href={`/dashboard/${entity}/new`}
className="flex items-center gap-2 px-5 py-2.5 bg-slate-800 hover:bg-slate-700
border border-slate-700 text-white text-sm font-bold rounded-xl transition-all"
>
<Plus size={14} />
Crear publicación
</Link>
</PermissionGuard>
</div>
)}
{/* SideDrawer — lazy loaded */}
{DrawerComponent && (
<DrawerComponent
isOpen={!!drawerPost}
post={drawerPost}
user={user}
relationData={relationData}
submitAction={submitEditAction}
appwriteConfig={appwriteConfig}
onClose={handleCloseDrawer}
onSuccess={handleEditSuccess}
/>
)}
</div>
);
};
export default PublicationsList;