Dashboard: Implemented search, pagination and relation resolution across all listing pages

This commit is contained in:
Carlos (Almeira) 2026-03-26 16:16:21 -05:00
parent 4b45712d49
commit 80a6f77b74
2 changed files with 194 additions and 58 deletions

View File

@ -1,14 +1,25 @@
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
import { createSessionClient } from '@/lib/appwrite.server';
import { Query } from 'node-appwrite';
import Link from 'next/link';
import { Pencil } from 'lucide-react';
import { DeleteButton } from '@/components/DeleteButton';
import { collectionMap, SCHEMA_MAP } from '@/lib/schema';
export default async function EntityPage({ params }: { params: { entity: string } }) {
// Await the params object before using its properties
export default async function EntityPage({
params,
searchParams
}: {
params: { entity: string },
searchParams: { q?: string, page?: string }
}) {
const { entity } = await params;
const sParams = await searchParams;
const q = sParams.q || '';
const page = Number(sParams.page) || 1;
const limit = 15;
const offset = (page - 1) * limit;
const collectionId = collectionMap[entity];
const fieldsConfig = SCHEMA_MAP[entity] || [];
@ -31,18 +42,39 @@ export default async function EntityPage({ params }: { params: { entity: string
}
let documents: any[] = [];
let total = 0;
try {
const sessionClient = await createSessionClient();
if (!sessionClient) {
throw new Error("No sesión activa");
}
const queries = [
Query.limit(limit),
Query.offset(offset),
Query.orderDesc('$createdAt')
];
if (q) {
const searchableFields = fieldsConfig
.filter(f => f.type === 'text' || f.type === 'textarea')
.map(f => f.name)
.slice(0, 2); // Limit to first two text fields for performance
if (searchableFields.length > 0) {
// Note: Appwrite multiple contains are AND-ed by default.
// For a better search we might need something else but this is a good start.
queries.push(Query.contains(searchableFields[0], q));
}
}
const response = await sessionClient.databases.listDocuments(
APPWRITE_ALMEIRA_CONFIG.databaseId,
collectionId,
[]
queries
);
documents = response.documents;
total = response.total;
} catch (error) {
console.error(`Error fetching from ${collectionId}:`, error);
}
@ -60,16 +92,30 @@ export default async function EntityPage({ params }: { params: { entity: string
};
return (
<div className="space-y-6 animate-fade-in">
<div className="flex justify-between items-center bg-slate-900 border border-slate-800 p-6 rounded-2xl shadow-xl shadow-cyan-500/5">
<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">
<div>
<h1 className="text-3xl font-black capitalize font-outfit uppercase tracking-tighter text-white">
{entityName}
</h1>
<p className="text-sm font-bold tracking-widest text-cyan-500 mt-2 uppercase">
Total almacenado: <span className="text-white bg-slate-800 px-2 py-0.5 rounded-full">{documents.length}</span>
Total en sistema: <span className="text-white bg-slate-800 px-2 py-0.5 rounded-full">{total}</span>
</p>
</div>
<form className="flex-1 max-w-md w-full relative group">
<input
type="text"
name="q"
defaultValue={q}
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"
/>
<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>
</button>
</form>
<Link href={`/dashboard/${entity}/new`} className="group flex items-center gap-2 bg-gradient-to-r from-blue-600 to-cyan-500 hover:from-blue-500 hover:to-cyan-400 text-white text-sm font-bold px-6 py-3 rounded-full shadow-[0_0_20px_rgba(6,182,212,0.3)] hover:shadow-[0_0_30px_rgba(6,182,212,0.5)] transition-all hover:scale-105">
<span className="text-xl leading-none -mt-1">+</span> Nuevo
</Link>
@ -78,8 +124,8 @@ export default async function EntityPage({ params }: { params: { entity: string
<div className="bg-slate-900/50 backdrop-blur-xl border border-slate-800 rounded-2xl overflow-hidden shadow-2xl">
{documents.length === 0 ? (
<div className="p-16 text-center text-slate-500 border border-dashed border-slate-700 m-8 rounded-xl bg-slate-900/30">
<h3 className="text-xl font-bold text-white mb-2 font-outfit tracking-wider">AÚN NO HAY DATOS</h3>
Para comenzar, haz clic en el botón superior derecho llamado "Añadir Nuevo".
<h3 className="text-xl font-bold text-white mb-2 font-outfit tracking-wider">NO SE ENCONTRARON REGISTROS</h3>
Prueba ajustando tu búsqueda o crea un nuevo elemento.
</div>
) : (
<div className="overflow-x-auto p-4">
@ -99,9 +145,17 @@ export default async function EntityPage({ params }: { params: { entity: string
<tr key={doc.$id} className="hover:bg-slate-800/50 transition-colors group">
{rawHeaders.map(h => {
let value = doc[h];
const field = fieldsConfig.find(f => f.name === h);
// Resolve Relations
if (typeof value === 'object' && value !== null) {
value = Array.isArray(value) ? `[Lista (${value.length})]` : value.$id || '[Vinculado]';
if (field?.type === 'relation' && field.relationLabel) {
value = value[field.relationLabel] || value.$id || '[Vinculado]';
} else {
value = Array.isArray(value) ? `[Lista (${value.length})]` : value.$id || '[Vinculado]';
}
}
if (typeof value === 'boolean') {
value = value ? 'Sí' : 'No';
}
@ -128,6 +182,27 @@ export default async function EntityPage({ params }: { params: { entity: string
</div>
)}
</div>
{/* Pagination */}
{total > limit && (
<div className="flex justify-center items-center gap-4 mt-8">
<Link
href={`/dashboard/${entity}?page=${page - 1}${q ? `&q=${q}` : ''}`}
className={`px-6 py-2 rounded-full 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-xs uppercase">
Página {page} de {Math.ceil(total / limit)}
</span>
<Link
href={`/dashboard/${entity}?page=${page + 1}${q ? `&q=${q}` : ''}`}
className={`px-6 py-2 rounded-full font-bold text-sm transition-all border border-slate-800 ${page >= Math.ceil(total / limit) ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`}
>
SIGUIENTE
</Link>
</div>
)}
</div>
);
}

View File

@ -4,18 +4,42 @@ import Link from 'next/link';
import { Image as ImageIcon, Video, Plus, Trash2 } from 'lucide-react';
import { DeleteButton } from '@/components/DeleteButton';
export default async function MediaLibraryPage() {
import { Query } from 'node-appwrite';
export default async function MediaLibraryPage({
searchParams
}: {
searchParams: { q?: string, page?: string }
}) {
const sParams = await searchParams;
const q = sParams.q || '';
const page = Number(sParams.page) || 1;
const limit = 12;
const offset = (page - 1) * limit;
let media: any[] = [];
let total = 0;
try {
const sessionClient = await createSessionClient();
if (!sessionClient) throw new Error("No sesión activa");
const queries = [
Query.limit(limit),
Query.offset(offset),
Query.orderDesc('$createdAt')
];
if (q) {
queries.push(Query.contains('titulo', q));
}
const response = await sessionClient.databases.listDocuments(
APPWRITE_ALMEIRA_CONFIG.databaseId,
'media',
[]
queries
);
media = response.documents;
total = response.total;
} catch (error) {
console.error(`Error fetching media:`, error);
}
@ -31,15 +55,29 @@ export default async function MediaLibraryPage() {
return (
<div className="space-y-6 animate-fade-in pb-12">
<div className="flex justify-between items-center bg-slate-900 border border-slate-800 p-6 rounded-2xl shadow-xl shadow-cyan-500/5">
<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">
<div>
<h1 className="text-3xl font-black capitalize font-outfit uppercase tracking-tighter text-white">
Biblioteca de Medios
</h1>
<p className="text-sm font-bold tracking-widest text-cyan-500 mt-2 uppercase">
Total almacenado: <span className="text-white bg-slate-800 px-2 py-0.5 rounded-full">{media.length}</span>
Total en sistema: <span className="text-white bg-slate-800 px-2 py-0.5 rounded-full">{total}</span>
</p>
</div>
<form className="flex-1 max-w-md w-full relative group">
<input
type="text"
name="q"
defaultValue={q}
placeholder="Buscar en la biblioteca..."
className="w-full bg-slate-950 border border-slate-800 rounded-full px-6 py-3 text-slate-200 focus:outline-none focus:border-pink-500 transition-all font-medium placeholder:text-slate-600"
/>
<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>
</button>
</form>
<Link href={`/dashboard/media/new`} className="group flex items-center gap-2 bg-gradient-to-r from-pink-600 to-purple-500 hover:from-pink-500 hover:to-purple-400 text-white text-sm font-bold px-6 py-3 rounded-full shadow-[0_0_20px_rgba(236,72,153,0.3)] hover:shadow-[0_0_30px_rgba(236,72,153,0.5)] transition-all hover:scale-105">
<Plus size={20} />
Subir Archivo
@ -49,54 +87,77 @@ export default async function MediaLibraryPage() {
{media.length === 0 ? (
<div className="p-16 text-center text-slate-500 border border-dashed border-slate-700 m-8 rounded-xl bg-slate-900/30">
<ImageIcon size={48} className="mx-auto text-slate-700 mb-4" />
<h3 className="text-xl font-bold text-white mb-2 font-outfit tracking-wider">BIBLIOTECA VACÍA</h3>
Para comenzar, sube y optimiza tu primer recurso multimedia con el botón superior.
<h3 className="text-xl font-bold text-white mb-2 font-outfit tracking-wider">NO SE ENCONTRARON RECURSOS</h3>
Prueba ajustando tu búsqueda o sube un nuevo archivo.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-2">
{media.map((item) => {
const fileUrl = getFilePreviewUrl(item.bucket_id, item.file_id);
return (
<div key={item.$id} className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden hover:border-cyan-500/30 transition-all group shadow-xl">
<div className="h-48 w-full relative bg-slate-950 flex items-center justify-center overflow-hidden">
{item.tipo === 'video' ? (
<>
<video src={fileUrl} className="w-full h-full object-cover opacity-50 blur-sm" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-16 h-16 bg-cyan-500/20 rounded-full flex items-center justify-center border border-cyan-500/50">
<Video className="text-cyan-400" size={32} />
</div>
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-2">
{media.map((item) => {
const fileUrl = getFilePreviewUrl(item.bucket_id, item.file_id);
return (
<div key={item.$id} className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden hover:border-cyan-500/30 transition-all group shadow-xl">
<div className="h-48 w-full relative bg-slate-950 flex items-center justify-center overflow-hidden">
{item.tipo === 'video' ? (
<>
<video src={fileUrl} className="w-full h-full object-cover opacity-50 blur-sm" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-16 h-16 bg-cyan-500/20 rounded-full flex items-center justify-center border border-cyan-500/50">
<Video className="text-cyan-400" size={32} />
</div>
</div>
</>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={fileUrl} alt={item.alt_text || item.titulo} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700" />
)}
<div className="absolute top-2 right-2 flex bg-black/50 backdrop-blur-md rounded-lg p-1 opacity-0 group-hover:opacity-100 transition-opacity">
<DeleteButton collectionId={"media"} documentId={item.$id} entityAlias={"media"} />
</div>
</div>
<div className="p-4 space-y-2 relative">
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-cyan-500">
{item.tipo === 'video' ? <Video size={12} /> : <ImageIcon size={12} />}
{item.tipo}
</div>
<h3 className="text-lg font-bold text-white leading-tight truncate" title={item.titulo}>{item.titulo}</h3>
{(item.alt_text || item.descripcion_seo) ? (
<div className="text-xs text-slate-400 mt-2 space-y-1 bg-slate-950 p-2 rounded-lg border border-slate-800">
{item.alt_text && <p className="truncate"><strong className="text-slate-500 break-words">ALT:</strong> {item.alt_text}</p>}
{item.descripcion_seo && <p className="truncate"><strong className="text-slate-500">SEO:</strong> {item.descripcion_seo}</p>}
</div>
</>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={fileUrl} alt={item.alt_text || item.titulo} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700" />
)}
<div className="absolute top-2 right-2 flex bg-black/50 backdrop-blur-md rounded-lg p-1 opacity-0 group-hover:opacity-100 transition-opacity">
<DeleteButton collectionId={"media"} documentId={item.$id} entityAlias={"media"} />
</div>
</div>
<div className="p-4 space-y-2 relative">
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-cyan-500">
{item.tipo === 'video' ? <Video size={12} /> : <ImageIcon size={12} />}
{item.tipo}
</div>
<h3 className="text-lg font-bold text-white leading-tight truncate" title={item.titulo}>{item.titulo}</h3>
{(item.alt_text || item.descripcion_seo) ? (
<div className="text-xs text-slate-400 mt-2 space-y-1 bg-slate-950 p-2 rounded-lg border border-slate-800">
{item.alt_text && <p className="truncate"><strong className="text-slate-500 break-words">ALT:</strong> {item.alt_text}</p>}
{item.descripcion_seo && <p className="truncate"><strong className="text-slate-500">SEO:</strong> {item.descripcion_seo}</p>}
</div>
) : (
<div className="text-xs text-rose-500 font-bold tracking-widest uppercase mt-2">Falta SEO</div>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="text-xs text-rose-500 font-bold tracking-widest uppercase mt-2">Falta SEO</div>
)}
</div>
</div>
);
})}
</div>
{/* Pagination */}
{total > limit && (
<div className="flex justify-center items-center gap-4 mt-8">
<Link
href={`/dashboard/media?page=${page - 1}${q ? `&q=${q}` : ''}`}
className={`px-6 py-2 rounded-full 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-pink-500'}`}
>
ANTERIOR
</Link>
<span className="text-slate-400 font-bold tracking-widest text-xs uppercase">
Página {page} de {Math.ceil(total / limit)}
</span>
<Link
href={`/dashboard/media?page=${page + 1}${q ? `&q=${q}` : ''}`}
className={`px-6 py-2 rounded-full font-bold text-sm transition-all border border-slate-800 ${page >= Math.ceil(total / limit) ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-pink-500'}`}
>
SIGUIENTE
</Link>
</div>
)}
</>
)}
</div>
);