feat: add publication filters and sorting options

This commit is contained in:
Carlos (Almeira) 2026-08-23 08:34:18 -05:00
parent e35b8f55cf
commit 3c169eab97
2 changed files with 272 additions and 104 deletions

View File

@ -15,13 +15,14 @@ export default async function EntityPage({
searchParams,
}: {
params: Promise<{ entity: string }>;
searchParams: Promise<{ q?: string; page?: string; sort?: string; dir?: string }>;
searchParams: Promise<{ q?: string; page?: string; sort?: string; dir?: string; year?: string; month?: string; status?: string }>;
}) {
const { entity } = await params;
const sParams = await searchParams;
const q = sParams.q || '';
const page = Number(sParams.page) || 1;
const sort = sParams.sort || '$createdAt';
const defaultSort = entity === 'posts' ? 'fecha_publicacion' : '$createdAt';
const sort = sParams.sort || defaultSort;
const dir: 'asc' | 'desc' = sParams.dir === 'asc' ? 'asc' : 'desc';
const limit: number = entity === 'posts' ? 12 : 15;
const offset = (page - 1) * limit;
@ -73,46 +74,81 @@ export default async function EntityPage({
}
}
if (entity === 'posts') {
const status = sParams.status || '';
if (status && status !== 'all') {
if (status === 'publicado') {
queries.push(Query.equal('estado', ['publicado', 'Publicado']));
} else if (status === 'borrador') {
queries.push(Query.equal('estado', ['borrador', 'Borrador']));
} else if (status === 'pendiente') {
queries.push(Query.equal('estado', ['pendiente', 'Pendiente']));
} else if (status === 'archivado') {
queries.push(Query.equal('estado', ['archivado', 'Archivado']));
} else {
queries.push(Query.equal('estado', status));
}
}
const year = sParams.year ? Number(sParams.year) : null;
const month = sParams.month ? Number(sParams.month) : null;
if (year || month) {
const filterYear = year || new Date().getFullYear();
if (month) {
const startDate = new Date(Date.UTC(filterYear, month - 1, 1, 0, 0, 0)).toISOString();
const endDate = new Date(Date.UTC(filterYear, month, 1, 0, 0, 0)).toISOString();
queries.push(Query.greaterThanEqual('fecha_publicacion', startDate));
queries.push(Query.lessThan('fecha_publicacion', endDate));
} else {
const startDate = new Date(Date.UTC(filterYear, 0, 1, 0, 0, 0)).toISOString();
const endDate = new Date(Date.UTC(filterYear + 1, 0, 1, 0, 0, 0)).toISOString();
queries.push(Query.greaterThanEqual('fecha_publicacion', startDate));
queries.push(Query.lessThan('fecha_publicacion', endDate));
}
}
}
const response = await client.databases.listDocuments(
APPWRITE_ALMEIRA_CONFIG.databaseId,
collectionId,
queries
);
documents = JSON.parse(JSON.stringify(response.documents));
total = response.total;
const fields = SCHEMA_MAP[entity] || [];
await Promise.all(
fields
.filter((f) => f.type === 'relation' && f.relationCollection)
.map(async (field) => {
const relKey = field.relationCollection as string;
const relColId = collectionMap[relKey];
if (!relColId) return;
await Promise.all(
fields
.filter((f) => f.type === 'relation' && f.relationCollection)
.map(async (field) => {
const relKey = field.relationCollection as string;
const relColId = collectionMap[relKey];
if (!relColId) return;
const ids = Array.from(new Set(
response.documents
.map(d => typeof d[field.name] === 'object' && d[field.name] !== null ? d[field.name].$id : d[field.name])
.filter(id => typeof id === 'string' && id.length > 0) as string[]
));
const ids = Array.from(new Set(
response.documents
.map(d => typeof d[field.name] === 'object' && d[field.name] !== null ? d[field.name].$id : d[field.name])
.filter(id => typeof id === 'string' && id.length > 0) as string[]
));
if (ids.length === 0) {
relationData[field.name] = [];
return;
}
if (ids.length === 0) {
relationData[field.name] = [];
return;
}
try {
const relRes = await client.databases.listDocuments(
APPWRITE_ALMEIRA_CONFIG.databaseId,
relColId,
[Query.equal('$id', ids), Query.limit(100)]
);
relationData[field.name] = JSON.parse(JSON.stringify(relRes.documents));
} catch { }
})
);
try {
const relRes = await client.databases.listDocuments(
APPWRITE_ALMEIRA_CONFIG.databaseId,
relColId,
[Query.equal('$id', ids), Query.limit(100)]
);
relationData[field.name] = JSON.parse(JSON.stringify(relRes.documents));
} catch { }
})
);
// Enrich documents with resolved relation data for DataTable
documents = documents.map(doc => {
const enriched = { ...doc };
@ -137,6 +173,18 @@ export default async function EntityPage({
return updateEntityAction(entity, id, formData);
};
const buildQueryString = (p: number) => {
const paramsObj = new URLSearchParams();
paramsObj.set('page', p.toString());
if (q) paramsObj.set('q', q);
if (sort) paramsObj.set('sort', sort);
if (dir) paramsObj.set('dir', dir);
if (sParams.year) paramsObj.set('year', sParams.year);
if (sParams.month) paramsObj.set('month', sParams.month);
if (sParams.status) paramsObj.set('status', sParams.status);
return paramsObj.toString();
};
return (
<div className="space-y-6 animate-fade-in pb-10">
<div className="flex flex-col md:flex-row justify-between items-center bg-slate-900 border border-slate-800 p-6 rounded-2xl shadow-xl shadow-cyan-500/5 gap-6">
@ -149,7 +197,7 @@ export default async function EntityPage({
</p>
</div>
<form className="flex-1 max-w-md w-full relative group">
<form className="flex-1 max-w-md w-full relative group" method="GET" action={`/dashboard/${entity}`}>
<input
type="text"
name="q"
@ -157,8 +205,13 @@ export default async function EntityPage({
placeholder={`Buscar en ${entityName}...`}
className="w-full bg-slate-950 border border-slate-800 rounded-full px-6 py-3 text-slate-200 focus:outline-none focus:border-cyan-500 transition-all font-medium placeholder:text-slate-600"
/>
{sort && <input type="hidden" name="sort" value={sort} />}
{dir && <input type="hidden" name="dir" value={dir} />}
{sParams.year && <input type="hidden" name="year" value={sParams.year} />}
{sParams.month && <input type="hidden" name="month" value={sParams.month} />}
{sParams.status && <input type="hidden" name="status" value={sParams.status} />}
<button type="submit" className="absolute right-2 top-2 bg-slate-800 p-1.5 rounded-full hover:bg-slate-700 transition-colors text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></svg>
</button>
</form>
@ -173,7 +226,7 @@ export default async function EntityPage({
{entity === 'posts' ? (
<ErrorBoundary>
<PublicationsList
key={`list-${entity}-${page}-${q}-${sort}-${dir}`}
key={`list-${entity}-${page}-${q}-${sort}-${dir}-${sParams.year || ''}-${sParams.month || ''}-${sParams.status || ''}`}
initialPosts={documents as unknown as Post[]}
user={user as AuthUser}
relationData={relationData}
@ -184,6 +237,11 @@ export default async function EntityPage({
databaseId: APPWRITE_ALMEIRA_CONFIG.databaseId,
}}
entity={entity}
currentSort={sort}
currentDir={dir}
currentYear={sParams.year || ''}
currentMonth={sParams.month || ''}
currentStatus={sParams.status || 'all'}
/>
</ErrorBoundary>
) : (
@ -204,31 +262,31 @@ export default async function EntityPage({
return (
<div className="flex justify-center items-center gap-2 mt-8 flex-wrap">
<Link
href={`/dashboard/${entity}?page=1${q ? `&q=${q}` : ''}${sort ? `&sort=${sort}` : ''}${dir ? `&dir=${dir}` : ''}`}
href={`/dashboard/${entity}?${buildQueryString(1)}`}
className={`px-4 py-2 rounded-xl font-bold text-xs transition-all border border-slate-800 ${page <= 1 ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`}
title="Ir a la primera página"
>
&laquo; INICIO
</Link>
<Link
href={`/dashboard/${entity}?page=${page - 1}${q ? `&q=${q}` : ''}${sort ? `&sort=${sort}` : ''}${dir ? `&dir=${dir}` : ''}`}
href={`/dashboard/${entity}?${buildQueryString(page - 1)}`}
className={`px-5 py-2 rounded-xl font-bold text-sm transition-all border border-slate-800 ${page <= 1 ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`}
>
ANTERIOR
</Link>
<span className="text-slate-400 font-bold tracking-widest text-[10px] md:text-xs uppercase text-center px-2 md:px-4 bg-slate-900 py-2 rounded-xl border border-slate-800">
Página {page} de {totalPages}
</span>
<Link
href={`/dashboard/${entity}?page=${page + 1}${q ? `&q=${q}` : ''}${sort ? `&sort=${sort}` : ''}${dir ? `&dir=${dir}` : ''}`}
href={`/dashboard/${entity}?${buildQueryString(page + 1)}`}
className={`px-5 py-2 rounded-xl font-bold text-sm transition-all border border-slate-800 ${page >= totalPages ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`}
>
SIGUIENTE
</Link>
<Link
href={`/dashboard/${entity}?page=${totalPages}${q ? `&q=${q}` : ''}${sort ? `&sort=${sort}` : ''}${dir ? `&dir=${dir}` : ''}`}
href={`/dashboard/${entity}?${buildQueryString(totalPages)}`}
className={`px-4 py-2 rounded-xl font-bold text-xs transition-all border border-slate-800 ${page >= totalPages ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`}
title="Ir a la última página"
>

View File

@ -20,7 +20,7 @@
import React, { useState, useCallback, useMemo, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Search, Plus, Filter, Loader2, FileText } from 'lucide-react';
import { Search, Plus, Filter, Loader2, FileText, Calendar, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import Link from 'next/link';
import type { Post, AuthUser, UserRole } from '@/types/publications';
import PostCard from './PostCard';
@ -43,10 +43,13 @@ interface PublicationsListProps {
};
globalSettings?: Record<string, any>;
entity: string;
currentSort?: string;
currentDir?: 'asc' | 'desc';
currentYear?: string;
currentMonth?: string;
currentStatus?: string;
}
type SortKey = 'fecha_publicacion' | '$createdAt' | 'titulo' | 'estado';
// ─────────────────────────────────────────────
// COMPONENT
// ─────────────────────────────────────────────
@ -59,6 +62,11 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
appwriteConfig,
globalSettings,
entity,
currentSort = 'fecha_publicacion',
currentDir = 'desc',
currentYear = '',
currentMonth = '',
currentStatus = 'all',
}) => {
const router = useRouter();
const toast = useToast();
@ -67,40 +75,79 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
// 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 ────────────────────────────
// ── FILTER & SORT ACTIONS ─────────────────────
const updateFilters = useCallback((updates: {
sort?: string;
dir?: 'asc' | 'desc';
year?: string;
month?: string;
status?: string;
page?: number;
}) => {
const params = new URLSearchParams(window.location.search);
// Default page to 1 on filter changes
params.set('page', (updates.page ?? 1).toString());
const filteredAndSortedPosts = useMemo(() => {
let result = [...posts];
// Filter by status
if (statusFilter !== 'all') {
result = result.filter((p) => p.estado === statusFilter);
if (updates.sort !== undefined) {
if (updates.sort) params.set('sort', updates.sort);
else params.delete('sort');
}
if (updates.dir !== undefined) {
if (updates.dir) params.set('dir', updates.dir);
else params.delete('dir');
}
if (updates.year !== undefined) {
if (updates.year) params.set('year', updates.year);
else params.delete('year');
}
if (updates.month !== undefined) {
if (updates.month) params.set('month', updates.month);
else params.delete('month');
}
if (updates.status !== undefined) {
if (updates.status && updates.status !== 'all') params.set('status', updates.status);
else params.delete('status');
}
// 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
});
router.push(`/dashboard/${entity}?${params.toString()}`);
}, [entity, router]);
return result;
}, [posts, statusFilter, sortBy]);
const years = useMemo(() => {
const currentYear = new Date().getFullYear();
const startYear = 2024;
const list = [];
for (let y = currentYear; y >= startYear; y--) {
list.push(y);
}
return list;
}, []);
const months = useMemo(() => [
{ value: '1', label: 'Enero' },
{ value: '2', label: 'Febrero' },
{ value: '3', label: 'Marzo' },
{ value: '4', label: 'Abril' },
{ value: '5', label: 'Mayo' },
{ value: '6', label: 'Junio' },
{ value: '7', label: 'Julio' },
{ value: '8', label: 'Agosto' },
{ value: '9', label: 'Septiembre' },
{ value: '10', label: 'Octubre' },
{ value: '11', label: 'Noviembre' },
{ value: '12', label: 'Diciembre' },
], []);
const filteredAndSortedPosts = posts;
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,
published: posts.filter((p) => (p.estado || '').toLowerCase() === 'publicado').length,
draft: posts.filter((p) => (p.estado || '').toLowerCase() === 'borrador').length,
pending: posts.filter((p) => (p.estado || '').toLowerCase() === 'pendiente').length,
}), [posts]);
// ── HANDLERS ────────────────────────────────
@ -153,50 +200,113 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
</div>
{/* Toolbar */}
<div className="flex flex-col md:flex-row gap-3 justify-end">
<div className="glass-panel rounded-2xl p-4 flex flex-col xl:flex-row gap-4 justify-between items-center backdrop-blur-md">
<div className="flex flex-wrap items-center gap-3 w-full xl:w-auto">
{/* Status Filter */}
<div className="flex items-center gap-2 bg-slate-950/50 border border-white/5 rounded-xl px-3 py-1.5 focus-within:border-cyan-500/50 transition-all">
<Filter size={14} className="text-slate-400" />
<select
value={currentStatus}
onChange={(e) => updateFilters({ status: e.target.value })}
className="bg-transparent border-0 text-white text-sm outline-none cursor-pointer pr-8"
>
<option value="all" className="bg-slate-900 text-white">Todos los estados</option>
<option value="publicado" className="bg-slate-900 text-white">Publicados</option>
<option value="borrador" className="bg-slate-900 text-white">Borradores</option>
<option value="pendiente" className="bg-slate-900 text-white">Pendientes</option>
<option value="archivado" className="bg-slate-900 text-white">Archivados</option>
</select>
</div>
{/* Year Filter */}
<div className="flex items-center gap-2 bg-slate-950/50 border border-white/5 rounded-xl px-3 py-1.5 focus-within:border-cyan-500/50 transition-all">
<Calendar size={14} className="text-slate-400" />
<select
value={currentYear}
onChange={(e) => updateFilters({ year: e.target.value, month: e.target.value ? currentMonth : '' })}
className="bg-transparent border-0 text-white text-sm outline-none cursor-pointer pr-8"
>
<option value="" className="bg-slate-900 text-white">Todos los años</option>
{years.map(y => (
<option key={y} value={y.toString()} className="bg-slate-900 text-white">{y}</option>
))}
</select>
</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>
{/* Month Filter */}
<div className="flex items-center gap-2 bg-slate-950/50 border border-white/5 rounded-xl px-3 py-1.5 focus-within:border-cyan-500/50 transition-all">
<Calendar size={14} className="text-slate-400" />
<select
value={currentMonth}
onChange={(e) => updateFilters({ month: e.target.value })}
className="bg-transparent border-0 text-white text-sm outline-none cursor-pointer pr-8"
>
<option value="" className="bg-slate-900 text-white">Todos los meses</option>
{months.map(m => (
<option key={m.value} value={m.value} className="bg-slate-900 text-white">{m.label}</option>
))}
</select>
</div>
</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>
<div className="flex flex-wrap items-center gap-3 w-full xl:w-auto justify-end">
{/* Sort By Field */}
<div className="flex items-center gap-2 bg-slate-950/50 border border-white/5 rounded-xl px-3 py-1.5 focus-within:border-cyan-500/50 transition-all">
<ArrowUpDown size={14} className="text-slate-400" />
<select
value={currentSort}
onChange={(e) => updateFilters({ sort: e.target.value })}
className="bg-transparent border-0 text-white text-sm outline-none cursor-pointer pr-8"
>
<option value="fecha_publicacion" className="bg-slate-900 text-white">Fecha de Publicación</option>
<option value="$createdAt" className="bg-slate-900 text-white">Fecha de Creación</option>
<option value="titulo" className="bg-slate-900 text-white">Título</option>
<option value="estado" className="bg-slate-900 text-white">Estado</option>
</select>
</div>
{/* 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"
{/* Sort Direction Toggle Button */}
<button
onClick={() => updateFilters({ dir: currentDir === 'asc' ? 'desc' : 'asc' })}
className="flex items-center gap-2 bg-slate-950/50 border border-white/5 hover:border-cyan-500/50 text-white text-sm px-4 py-2.5 rounded-xl transition-all active:scale-95 cursor-pointer"
title={currentDir === 'asc' ? 'Orden Ascendente' : 'Orden Descendente'}
>
<Plus size={16} />
Nueva publicación
</Link>
</PermissionGuard>
{currentDir === 'asc' ? (
<>
<ArrowUp size={14} className="text-cyan-400" />
<span>Ascendente</span>
</>
) : (
<>
<ArrowDown size={14} className="text-cyan-400" />
<span>Descendente</span>
</>
)}
</button>
{/* Clear Filters Button (only show if any filter is active) */}
{(currentStatus !== 'all' || currentYear || currentMonth) && (
<button
onClick={() => updateFilters({ status: 'all', year: '', month: '', sort: 'fecha_publicacion', dir: 'desc' })}
className="text-xs text-rose-400 hover:text-rose-300 font-bold px-3 py-2 border border-rose-500/20 hover:border-rose-500/40 rounded-xl transition-all cursor-pointer"
>
Limpiar filtros
</button>
)}
{/* 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-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500
text-white font-bold text-sm rounded-xl transition-all
shadow-[0_0_20px_rgba(6,182,212,0.25)] hover:shadow-[0_0_30px_rgba(6,182,212,0.4)] hover:scale-105 active:scale-95 whitespace-nowrap"
>
<Plus size={16} />
Nueva publicación
</Link>
</PermissionGuard>
</div>
</div>
{/* Grid de publicaciones */}