Dashboard: Implemented search, pagination and relation resolution across all listing pages
This commit is contained in:
parent
4b45712d49
commit
80a6f77b74
@ -1,14 +1,25 @@
|
|||||||
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
|
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
|
||||||
import { createSessionClient } from '@/lib/appwrite.server';
|
import { createSessionClient } from '@/lib/appwrite.server';
|
||||||
|
import { Query } from 'node-appwrite';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Pencil } from 'lucide-react';
|
import { Pencil } from 'lucide-react';
|
||||||
import { DeleteButton } from '@/components/DeleteButton';
|
import { DeleteButton } from '@/components/DeleteButton';
|
||||||
|
|
||||||
import { collectionMap, SCHEMA_MAP } from '@/lib/schema';
|
import { collectionMap, SCHEMA_MAP } from '@/lib/schema';
|
||||||
|
|
||||||
export default async function EntityPage({ params }: { params: { entity: string } }) {
|
export default async function EntityPage({
|
||||||
// Await the params object before using its properties
|
params,
|
||||||
|
searchParams
|
||||||
|
}: {
|
||||||
|
params: { entity: string },
|
||||||
|
searchParams: { q?: string, page?: string }
|
||||||
|
}) {
|
||||||
const { entity } = await params;
|
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 collectionId = collectionMap[entity];
|
||||||
const fieldsConfig = SCHEMA_MAP[entity] || [];
|
const fieldsConfig = SCHEMA_MAP[entity] || [];
|
||||||
@ -31,18 +42,39 @@ export default async function EntityPage({ params }: { params: { entity: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
let documents: any[] = [];
|
let documents: any[] = [];
|
||||||
|
let total = 0;
|
||||||
try {
|
try {
|
||||||
const sessionClient = await createSessionClient();
|
const sessionClient = await createSessionClient();
|
||||||
if (!sessionClient) {
|
if (!sessionClient) {
|
||||||
throw new Error("No sesión activa");
|
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(
|
const response = await sessionClient.databases.listDocuments(
|
||||||
APPWRITE_ALMEIRA_CONFIG.databaseId,
|
APPWRITE_ALMEIRA_CONFIG.databaseId,
|
||||||
collectionId,
|
collectionId,
|
||||||
[]
|
queries
|
||||||
);
|
);
|
||||||
documents = response.documents;
|
documents = response.documents;
|
||||||
|
total = response.total;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching from ${collectionId}:`, error);
|
console.error(`Error fetching from ${collectionId}:`, error);
|
||||||
}
|
}
|
||||||
@ -60,16 +92,30 @@ export default async function EntityPage({ params }: { params: { entity: string
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in pb-10">
|
||||||
<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>
|
<div>
|
||||||
<h1 className="text-3xl font-black capitalize font-outfit uppercase tracking-tighter text-white">
|
<h1 className="text-3xl font-black capitalize font-outfit uppercase tracking-tighter text-white">
|
||||||
{entityName}
|
{entityName}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm font-bold tracking-widest text-cyan-500 mt-2 uppercase">
|
<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>
|
</p>
|
||||||
</div>
|
</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">
|
<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
|
<span className="text-xl leading-none -mt-1">+</span> Nuevo
|
||||||
</Link>
|
</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">
|
<div className="bg-slate-900/50 backdrop-blur-xl border border-slate-800 rounded-2xl overflow-hidden shadow-2xl">
|
||||||
{documents.length === 0 ? (
|
{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">
|
<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>
|
<h3 className="text-xl font-bold text-white mb-2 font-outfit tracking-wider">NO SE ENCONTRARON REGISTROS</h3>
|
||||||
Para comenzar, haz clic en el botón superior derecho llamado "Añadir Nuevo".
|
Prueba ajustando tu búsqueda o crea un nuevo elemento.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto p-4">
|
<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">
|
<tr key={doc.$id} className="hover:bg-slate-800/50 transition-colors group">
|
||||||
{rawHeaders.map(h => {
|
{rawHeaders.map(h => {
|
||||||
let value = doc[h];
|
let value = doc[h];
|
||||||
|
const field = fieldsConfig.find(f => f.name === h);
|
||||||
|
|
||||||
|
// Resolve Relations
|
||||||
if (typeof value === 'object' && value !== null) {
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
if (field?.type === 'relation' && field.relationLabel) {
|
||||||
|
value = value[field.relationLabel] || value.$id || '[Vinculado]';
|
||||||
|
} else {
|
||||||
value = Array.isArray(value) ? `[Lista (${value.length})]` : value.$id || '[Vinculado]';
|
value = Array.isArray(value) ? `[Lista (${value.length})]` : value.$id || '[Vinculado]';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof value === 'boolean') {
|
if (typeof value === 'boolean') {
|
||||||
value = value ? 'Sí' : 'No';
|
value = value ? 'Sí' : 'No';
|
||||||
}
|
}
|
||||||
@ -128,6 +182,27 @@ export default async function EntityPage({ params }: { params: { entity: string
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,18 +4,42 @@ import Link from 'next/link';
|
|||||||
import { Image as ImageIcon, Video, Plus, Trash2 } from 'lucide-react';
|
import { Image as ImageIcon, Video, Plus, Trash2 } from 'lucide-react';
|
||||||
import { DeleteButton } from '@/components/DeleteButton';
|
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 media: any[] = [];
|
||||||
|
let total = 0;
|
||||||
try {
|
try {
|
||||||
const sessionClient = await createSessionClient();
|
const sessionClient = await createSessionClient();
|
||||||
if (!sessionClient) throw new Error("No sesión activa");
|
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(
|
const response = await sessionClient.databases.listDocuments(
|
||||||
APPWRITE_ALMEIRA_CONFIG.databaseId,
|
APPWRITE_ALMEIRA_CONFIG.databaseId,
|
||||||
'media',
|
'media',
|
||||||
[]
|
queries
|
||||||
);
|
);
|
||||||
media = response.documents;
|
media = response.documents;
|
||||||
|
total = response.total;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching media:`, error);
|
console.error(`Error fetching media:`, error);
|
||||||
}
|
}
|
||||||
@ -31,15 +55,29 @@ export default async function MediaLibraryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in pb-12">
|
<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>
|
<div>
|
||||||
<h1 className="text-3xl font-black capitalize font-outfit uppercase tracking-tighter text-white">
|
<h1 className="text-3xl font-black capitalize font-outfit uppercase tracking-tighter text-white">
|
||||||
Biblioteca de Medios
|
Biblioteca de Medios
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm font-bold tracking-widest text-cyan-500 mt-2 uppercase">
|
<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>
|
</p>
|
||||||
</div>
|
</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">
|
<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} />
|
<Plus size={20} />
|
||||||
Subir Archivo
|
Subir Archivo
|
||||||
@ -49,10 +87,11 @@ export default async function MediaLibraryPage() {
|
|||||||
{media.length === 0 ? (
|
{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">
|
<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" />
|
<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>
|
<h3 className="text-xl font-bold text-white mb-2 font-outfit tracking-wider">NO SE ENCONTRARON RECURSOS</h3>
|
||||||
Para comenzar, sube y optimiza tu primer recurso multimedia con el botón superior.
|
Prueba ajustando tu búsqueda o sube un nuevo archivo.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-2">
|
<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) => {
|
{media.map((item) => {
|
||||||
const fileUrl = getFilePreviewUrl(item.bucket_id, item.file_id);
|
const fileUrl = getFilePreviewUrl(item.bucket_id, item.file_id);
|
||||||
@ -97,6 +136,28 @@ export default async function MediaLibraryPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user