ac3e7061ac
Rediseño visual: - Nueva identidad UABC/editorial (DESIGN.md), tipografía Mulish, tokens verde/dorado/negro sobre bone-white, patrón de puntos verde en canvas, radios 8/12/30/9999, sin sombras, hairline 1px, tarjetas con leve transparencia + blur suave + borde verde muy sutil. - 45 archivos .astro/.tsx alineados al nuevo sistema (Track A/B/C/D/E). - Limpieza post-critique: eliminado triple CTA en home alumno, .card anidados en admin, aria-live decorativo, 26 aliases legacy en global.css, border-l verde en nav (patrón AI-slop), classList.remove obsoletos. Auditoría de llamadas a BD (para 20-50 usuarios concurrentes): - P0-1: middleware con cache in-memory de profile (30s) + is_banned (30s) por uid; exclusión de assets estáticos (/_astro/*, /audio/*, favicon). Invalidación en signout, PATCH /profile, baneos. - P0-2: eliminado count pendientes duplicado en AppLayout; badge se hidrata client-side (BadgeSolicitudes con fetch inicial). - P0-3: migración 0008 REPLICA IDENTITY FULL en solicitudes + delta preciso en BadgeSolicitudes (con fallback a refetch). - P1: admin/index dedupe (4 queries → 2 con recientes.filter), catalogo reusa profile del middleware (3 queries → 1), inventario paralelo, estadisticas con cache 60s en RAM (stats-cache.ts), migración 0009 con 3 índices (fecha, vencidos parcial, audit compuesto). Fixes P0 de UI post-critique: - estadisticas.astro: ReferenceError DIAS restaurado. - solicitudes (3 vistas): error rendering sanitizado (no expone mensaje Postgres crudo al admin). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014xaxs8cGcQe9NwbMqA1gvn
94 lines
2.9 KiB
TypeScript
94 lines
2.9 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
|
|
type Cat = { id: number; nombre: string };
|
|
type Props = { categorias: Cat[] };
|
|
|
|
const ALL = 'all';
|
|
|
|
function applyFilter(value: string) {
|
|
const cards = document.querySelectorAll<HTMLElement>('[data-cat]');
|
|
cards.forEach((el) => {
|
|
const cat = el.dataset.cat;
|
|
const show = value === ALL || cat === value;
|
|
if (show) delete el.dataset.hiddenByCat;
|
|
else el.dataset.hiddenByCat = 'true';
|
|
});
|
|
// El buscador (BuscadorCatalogo, client:load) define este helper y unifica
|
|
// ambos flags (cat + search). Fallback si aún no montó: aplicar directo.
|
|
if (typeof window !== 'undefined' && window.__labreFiltrar) {
|
|
window.__labreFiltrar();
|
|
} else {
|
|
let anyVisible = false;
|
|
cards.forEach((el) => {
|
|
el.hidden = el.dataset.hiddenByCat === 'true';
|
|
if (!el.hidden) anyVisible = true;
|
|
});
|
|
const empty = document.getElementById('catalogo-empty-filter');
|
|
if (empty) empty.hidden = anyVisible || cards.length === 0;
|
|
}
|
|
}
|
|
|
|
export default function FiltroCategorias({ categorias }: Props) {
|
|
const [active, setActive] = useState<string>(ALL);
|
|
|
|
useEffect(() => {
|
|
const url = new URL(location.href);
|
|
const cat = url.searchParams.get('cat');
|
|
const initial = cat && categorias.some((c) => String(c.id) === cat) ? cat : ALL;
|
|
setActive(initial);
|
|
applyFilter(initial);
|
|
}, [categorias]);
|
|
|
|
const pick = (value: string) => {
|
|
setActive(value);
|
|
const url = new URL(location.href);
|
|
if (value === ALL) url.searchParams.delete('cat');
|
|
else url.searchParams.set('cat', value);
|
|
history.replaceState(null, '', url.toString());
|
|
applyFilter(value);
|
|
};
|
|
|
|
const chipClass = (v: string) =>
|
|
v === active
|
|
? 'bg-verde-institucional text-bone-white rounded-[8px] px-3 py-1 border border-verde-institucional inline-flex items-center whitespace-nowrap text-xs font-medium cursor-pointer transition-colors'
|
|
: 'chip cursor-pointer hover:bg-linen hover:border-dorado-uabc transition-colors';
|
|
|
|
return (
|
|
<div
|
|
role="tablist"
|
|
aria-label="Filtrar por categoría"
|
|
className="flex gap-2 overflow-x-auto -mx-4 px-4 pb-1"
|
|
style={{ scrollbarWidth: 'thin' }}
|
|
>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={active === ALL}
|
|
className={chipClass(ALL)}
|
|
data-cat-value={ALL}
|
|
data-cat-active={active === ALL ? 'true' : undefined}
|
|
onClick={() => pick(ALL)}
|
|
>
|
|
Todas
|
|
</button>
|
|
{categorias.map((c) => {
|
|
const v = String(c.id);
|
|
return (
|
|
<button
|
|
key={c.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={active === v}
|
|
className={chipClass(v)}
|
|
data-cat-value={v}
|
|
data-cat-active={active === v ? 'true' : undefined}
|
|
onClick={() => pick(v)}
|
|
>
|
|
{c.nombre}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|