269 lines
10 KiB
TypeScript
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;
|