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

269 lines
10 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 { useRouter } from 'next/navigation';
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: (id: string, formData: FormData) => Promise<{ success: boolean; error?: string; data?: unknown } | void>;
appwriteConfig: {
endpoint: string;
projectId: string;
databaseId: string;
};
globalSettings?: Record<string, any>;
entity: string;
}
type SortKey = 'fecha_publicacion' | '$createdAt' | 'titulo' | 'estado';
// ─────────────────────────────────────────────
// COMPONENT
// ─────────────────────────────────────────────
export const PublicationsList: React.FC<PublicationsListProps> = ({
initialPosts,
user,
relationData,
submitEditAction,
appwriteConfig,
globalSettings,
entity,
}) => {
const router = useRouter();
const toast = useToast();
const [isPending, startTransition] = useTransition();
// Local state
const [posts, setPosts] = useState<Post[]>(initialPosts);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<SortKey>('fecha_publicacion');
const canEdit = user?.$id === 'admin-emergency' || user?.labels?.includes('admin') || user?.labels?.includes('editor');
const userRole = canEdit ? 'admin' : 'viewer';
// ── DERIVED STATE ────────────────────────────
const filteredAndSortedPosts = useMemo(() => {
let result = [...posts];
// 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);
if (sortBy === 'estado') return (a.estado || '').localeCompare(b.estado || '');
const aDate = new Date(a[sortBy] || a.fecha_publicacion || a.$createdAt || 0).getTime();
const bDate = new Date(b[sortBy] || b.fecha_publicacion || b.$createdAt || 0).getTime();
return bDate - aDate; // Strictly Newest first
});
return result;
}, [posts, 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((post: Post) => {
router.push(`/dashboard/${entity}/${post.$id}`);
}, [entity, router]);
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]);
// ── 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 justify-end">
{/* 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-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{filteredAndSortedPosts.map((post) => {
const enrichedPost = { ...post };
if (typeof enrichedPost.imagen_destacada_id === 'string' && relationData['imagen_destacada_id']) {
const mediaDoc = relationData['imagen_destacada_id'].find(m => m.$id === enrichedPost.imagen_destacada_id);
if (mediaDoc) enrichedPost.imagen_destacada_id = mediaDoc as any;
}
if (typeof enrichedPost.tipo_publicacion_id === 'string' && relationData['tipo_publicacion_id']) {
const typeDoc = relationData['tipo_publicacion_id'].find(t => t.$id === enrichedPost.tipo_publicacion_id);
if (typeDoc) enrichedPost.tipo_publicacion_id = typeDoc as any;
}
return (
<PostCard
key={post.$id}
post={enrichedPost}
userRole={userRole}
onEdit={handleEdit}
onDelete={handleDelete}
appwriteEndpoint={appwriteConfig.endpoint}
appwriteProjectId={appwriteConfig.projectId}
globalSettings={globalSettings}
/>
);
})}
</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">
{'No se encontraron publicaciones. ¡Intenta con otra búsqueda o crea una nueva!'}
</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>
)}
</div>
);
};
export default PublicationsList;