300 lines
11 KiB
TypeScript
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 } | void>;
|
|
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;
|