Files
ORCID2SWORD/frontend/src/components/dashboard/PublicationsTable.jsx
T
Alexis 02725dfa2c feat(ui): mejorar la estructura y estilos en el Footer y LandingPage
Se ajustan los estilos y la estructura del componente Footer para mejorar la presentación y la alineación de los elementos. Además, se modifica el texto en LandingPage para una mejor claridad en la comunicación. Se optimizan las clases de Tailwind para una mejor responsividad y experiencia de usuario.
2026-06-02 12:38:04 +02:00

731 lines
27 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from "react";
import { AlertIcon, ChevronDownIcon, FilterIcon, SearchIcon, SparkleIcon } from "../ui/Icons";
import { CustomSelect } from "../ui/CustomSelect";
import { Spinner } from "../ui/Spinner";
import { Badge } from "../ui/Badge";
const COLUMNS = [
{ key: "title", label: "Título", thClass: "w-[35%]" },
{ key: "journal", label: "Revista / Fuente", thClass: "w-[28%]", tdClass: "break-words" },
{ key: "publication_year", label: "Año", thClass: "w-16" },
{ key: "doi", label: "DOI" },
{ key: "type", label: "Tipo", thClass: "w-20" },
];
const PAGE_SIZE = 15;
const EMPTY_SELECTION = new Set();
function SortIcon({ active, direction }) {
const path =
direction === "asc" || !active ? "M6 8L3 5h6z" : "M6 4l3 3H3z";
return (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
className={`ml-1 ${active ? "opacity-100" : "opacity-30"}`}
aria-hidden
>
<path d={path} fill="currentColor" />
</svg>
);
}
function sortPublications(rows, key, direction) {
const sorted = [...rows].sort((a, b) => {
const va = a[key];
const vb = b[key];
const cmp =
typeof va === "string" ? va.localeCompare(vb) : (va ?? 0) - (vb ?? 0);
return direction === "asc" ? cmp : -cmp;
});
return sorted;
}
/**
* Tri-state checkbox. We can't express `indeterminate` via React props, so
* we set it imperatively on the DOM node whenever the flag changes.
*/
function TriStateCheckbox({ checked, indeterminate = false, onChange, ariaLabel }) {
const ref = useRef(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate && !checked;
}, [indeterminate, checked]);
return (
<input
ref={ref}
type="checkbox"
checked={checked}
onChange={onChange}
aria-label={ariaLabel}
className="h-4 w-4 cursor-pointer accent-brand-accent"
/>
);
}
/**
* Publications table. UI-state (filter, sort, pagination) lives here; the
* *selection* set is lifted to the parent so export / bulk actions can see
* it. Data, loading and error states are also driven by the parent so
* retries and toasts can be handled in one place.
*
* Selection semantics:
* - The master checkbox toggles only the rows on the current page.
* - Selection is stored by ID in the parent and persists across pages,
* filters and sorts so the user can select page by page.
*/
export function PublicationsTable({
publications,
loading = false,
error = null,
onRetry,
selectedIds = EMPTY_SELECTION,
onSelectedIdsChange,
isAuthenticated = false,
}) {
const [filter, setFilter] = useState("");
const [sortKey, setSortKey] = useState("publication_year");
const [sortDir, setSortDir] = useState("desc");
const [page, setPage] = useState(1);
const [filtersOpen, setFiltersOpen] = useState(false);
const [yearFrom, setYearFrom] = useState("");
const [yearTo, setYearTo] = useState("");
const availableYears = useMemo(() => {
const years = publications
.map((p) => p.publication_year)
.filter((y) => typeof y === "number" && Number.isFinite(y));
if (years.length === 0) return [];
const min = Math.min(...years);
const max = Math.max(...years, new Date().getFullYear());
const list = [];
for (let y = max; y >= min; y -= 1) list.push(y);
return list;
}, [publications]);
const hasYearFilter = yearFrom !== "" || yearTo !== "";
useEffect(() => {
if (availableYears.length === 0) return;
if (yearFrom && !availableYears.includes(Number(yearFrom))) setYearFrom("");
if (yearTo && !availableYears.includes(Number(yearTo))) setYearTo("");
}, [availableYears, yearFrom, yearTo]);
const filtered = useMemo(() => {
const needle = filter.trim().toLowerCase();
let rows = publications;
if (needle) {
rows = rows.filter(
(p) =>
(p.title ?? "").toLowerCase().includes(needle) ||
(p.journal ?? "").toLowerCase().includes(needle) ||
String(p.publication_year ?? "").includes(needle) ||
(p.doi ?? "").toLowerCase().includes(needle),
);
}
if (hasYearFilter) {
const from = yearFrom ? Number(yearFrom) : null;
const to = yearTo ? Number(yearTo) : null;
rows = rows.filter((p) => {
const year = p.publication_year;
if (typeof year !== "number" || !Number.isFinite(year)) return false;
if (from !== null && year < from) return false;
if (to !== null && year > to) return false;
return true;
});
}
return sortPublications(rows, sortKey, sortDir);
}, [publications, filter, yearFrom, yearTo, hasYearFilter, sortKey, sortDir]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const pageRows = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return filtered.slice(start, start + PAGE_SIZE);
}, [filtered, currentPage]);
const pageSelectionStats = useMemo(() => {
if (pageRows.length === 0) {
return { allChecked: false, anyChecked: false };
}
let count = 0;
for (const pub of pageRows) {
if (selectedIds.has(pub.id)) count += 1;
}
return {
allChecked: count === pageRows.length,
anyChecked: count > 0,
};
}, [pageRows, selectedIds]);
function toggleSort(key) {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
setPage(1);
} else {
setSortKey(key);
setSortDir("desc");
setPage(1);
}
}
function emit(nextSet) {
onSelectedIdsChange?.(nextSet);
}
function toggleRow(id) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
emit(next);
}
function toggleCurrentPage() {
const next = new Set(selectedIds);
if (pageSelectionStats.allChecked) {
for (const pub of pageRows) next.delete(pub.id);
} else {
for (const pub of pageRows) next.add(pub.id);
}
emit(next);
}
function handleYearFromChange(value) {
setYearFrom(value);
// Si el usuario elige un "Desde" mayor que el "Hasta" actual,
// auto-corregimos el "Hasta" para preservar un rango coherente.
if (value && yearTo && Number(value) > Number(yearTo)) {
setYearTo(value);
}
setPage(1);
}
function handleYearToChange(value) {
setYearTo(value);
if (value && yearFrom && Number(value) < Number(yearFrom)) {
setYearFrom(value);
}
setPage(1);
}
function clearYearFilter() {
setYearFrom("");
setYearTo("");
setPage(1);
}
const pageStart =
filtered.length === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1;
const pageEnd = Math.min(currentPage * PAGE_SIZE, filtered.length);
const yearFilterSummary = hasYearFilter
? yearFrom && yearTo && yearFrom === yearTo
? `Año: ${yearFrom}`
: `Años: ${yearFrom || "…"} ${yearTo || "…"}`
: null;
return (
<section className="overflow-hidden rounded-2xl border border-surface-border/60 bg-surface-primary">
{/* Toolbar */}
<div className="border-b border-surface-border/60">
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-4">
<div>
<h3 className="text-base font-medium text-ink-primary">
Publicaciones
</h3>
<p className="mt-0.5 text-xs text-ink-tertiary">
{filtered.length} de {publications.length} resultados
{yearFilterSummary && (
<>
{" · "}
<span className="text-ink-secondary">{yearFilterSummary}</span>
</>
)}
{selectedIds.size > 0 && (
<>
{" · "}
<span className="font-medium text-brand-accent">
{selectedIds.size} seleccionada
{selectedIds.size === 1 ? "" : "s"}
</span>
</>
)}
</p>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center">
<button
type="button"
onClick={() => setFiltersOpen((o) => !o)}
aria-expanded={filtersOpen}
aria-controls="pubs-advanced-filters"
className={`order-2 inline-flex w-full items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-[13px] font-medium transition-colors sm:order-1 sm:w-auto sm:justify-start ${
filtersOpen || hasYearFilter
? "border-brand-accent/50 bg-brand-accent/10 text-brand-accent"
: "border-surface-border-strong bg-surface-secondary text-ink-secondary hover:bg-surface-primary"
}`}
>
<FilterIcon />
<span className="sm:hidden">Filtros</span>
<span className="hidden sm:inline">Filtros avanzados</span>
{hasYearFilter && (
<span
className="ml-0.5 inline-block h-1.5 w-1.5 rounded-full bg-brand-accent"
aria-hidden
/>
)}
<ChevronDownIcon
className={`transition-transform ${filtersOpen ? "rotate-180" : ""}`}
/>
</button>
<div className="relative order-1 w-full sm:order-2 sm:w-auto">
<input
type="text"
placeholder="Filtrar publicaciones..."
value={filter}
onChange={(e) => {
setFilter(e.target.value);
setPage(1);
}}
className="w-full rounded-lg border border-surface-border-strong bg-surface-secondary py-2 pl-9 pr-3.5 text-[13px] text-ink-primary outline-none focus:border-brand-accent sm:w-[220px]"
/>
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-tertiary/70">
<SearchIcon />
</span>
</div>
</div>
</div>
{filtersOpen && (
<div
id="pubs-advanced-filters"
className="border-t border-surface-border/60 bg-surface-secondary/40 px-5 py-3"
>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<p className="text-xs font-medium text-ink-secondary">
Filtrar por año de publicación
</p>
{yearFilterSummary && (
<span className="rounded-full border border-brand-accent/30 bg-brand-accent/10 px-2 py-0.5 text-[11px] font-medium text-brand-accent">
{yearFilterSummary}
</span>
)}
</div>
<div className="grid grid-cols-1 items-end gap-3 sm:grid-cols-[1fr_auto_1fr_auto]">
<div className="flex min-w-[140px] flex-1 flex-col gap-1">
<label
htmlFor="year-from"
className="text-[11px] font-medium uppercase tracking-wide text-ink-tertiary"
>
Desde
</label>
<CustomSelect
id="year-from"
value={yearFrom}
onChange={handleYearFromChange}
disabled={availableYears.length === 0}
options={availableYears.map((y) => ({
value: String(y),
label: String(y),
}))}
className="w-full"
/>
</div>
<div className="hidden pb-2 text-center text-sm text-ink-tertiary sm:block">
</div>
<div className="flex min-w-[140px] flex-1 flex-col gap-1">
<label
htmlFor="year-to"
className="text-[11px] font-medium uppercase tracking-wide text-ink-tertiary"
>
Hasta
</label>
<CustomSelect
id="year-to"
value={yearTo}
onChange={handleYearToChange}
disabled={availableYears.length === 0}
options={availableYears.map((y) => ({
value: String(y),
label: String(y),
}))}
className="w-full"
/>
</div>
{hasYearFilter && (
<button
type="button"
onClick={clearYearFilter}
className="rounded-lg border border-surface-border-strong bg-surface-primary px-3 py-2 text-[13px] font-medium text-ink-secondary transition-colors hover:bg-surface-secondary"
>
Limpiar
</button>
)}
</div>
{hasYearFilter && (
<button
type="button"
onClick={clearYearFilter}
className="sr-only"
>
Limpiar rango
</button>
)}
{availableYears.length === 0 && (
<p className="mb-[2px] text-xs text-ink-tertiary">
Aún no hay años disponibles.
</p>
)}
</div>
)}
</div>
{/* Body */}
<div className="overflow-x-auto">
{error ? (
<ErrorState error={error} onRetry={onRetry} />
) : loading ? (
<LoadingState />
) : (
<>
<div className="md:hidden">
{filtered.length === 0 ? (
<p className="p-8 text-center text-sm text-ink-tertiary">
No se encontraron publicaciones con los filtros aplicados.
</p>
) : (
<>
<div className="border-b border-surface-border/60 bg-surface-secondary px-4 py-2.5">
<TriStateCheckbox
checked={pageSelectionStats.allChecked}
indeterminate={pageSelectionStats.anyChecked}
onChange={toggleCurrentPage}
ariaLabel="Seleccionar todas las publicaciones de esta página"
/>
</div>
<div>
{pageRows.map((pub, i) => {
const isSelected = selectedIds.has(pub.id);
return (
<article
key={pub.id}
className={`px-4 py-3.5 transition-colors ${
isSelected
? "bg-tag-article-bg/70 hover:bg-tag-article-bg"
: "hover:bg-surface-secondary/70"
} ${
i < pageRows.length - 1
? "border-b border-surface-border/60"
: ""
}`}
>
<div className="mb-2 flex items-start gap-2.5">
<TriStateCheckbox
checked={isSelected}
onChange={() => toggleRow(pub.id)}
ariaLabel={`Seleccionar publicación ${pub.title}`}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-start gap-1.5">
{isAuthenticated && pub.downloaded_by_me === false && (
<span
title="No descargada aún por ti"
className="mt-0.5 inline-flex shrink-0 items-center gap-0.5 rounded-full bg-brand-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent"
>
<SparkleIcon size={9} />
Nuevo
</span>
)}
<p className="text-[14px] font-medium leading-relaxed text-ink-primary">
{pub.title}
</p>
</div>
</div>
</div>
<div className="space-y-1.5 pl-6.5 text-[12px] text-ink-secondary">
<p>
<span className="font-medium text-ink-primary">Revista:</span>{" "}
{pub.journal || "—"}
</p>
<p>
<span className="font-medium text-ink-primary">Año:</span>{" "}
{pub.publication_year ?? "—"}
</p>
<p>
<span className="font-medium text-ink-primary">DOI:</span>{" "}
{pub.doi ? (
<a
href={`https://doi.org/${pub.doi}`}
target="_blank"
rel="noopener noreferrer"
className="break-all font-mono text-[11px] text-brand-accent hover:underline"
>
{pub.doi}
</a>
) : (
<span className="font-mono text-[11px] text-ink-tertiary">
</span>
)}
</p>
<div>
<Badge type={pub.type} />
</div>
</div>
</article>
);
})}
</div>
</>
)}
</div>
<table className="hidden w-full border-collapse md:table">
<thead>
<tr className="bg-surface-secondary">
<th
scope="col"
className="w-10 border-b border-surface-border/60 px-4 py-2.5 text-left"
onClick={(e) => e.stopPropagation()}
>
<TriStateCheckbox
checked={pageSelectionStats.allChecked}
indeterminate={pageSelectionStats.anyChecked}
onChange={toggleCurrentPage}
ariaLabel="Seleccionar todas las publicaciones de esta página"
/>
</th>
{COLUMNS.map((col) => (
<th
key={col.key}
onClick={() => toggleSort(col.key)}
className={`select-none border-b border-surface-border/60 px-4 py-2.5 text-left text-xs font-medium tracking-wide text-ink-secondary${col.thClass ? ` ${col.thClass}` : ""}`}
>
<span className="flex cursor-pointer items-center">
{col.label.toUpperCase()}
<SortIcon
active={sortKey === col.key}
direction={sortDir}
/>
</span>
</th>
))}
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td
colSpan={COLUMNS.length + 1}
className="p-10 text-center text-sm text-ink-tertiary"
>
No se encontraron publicaciones con los filtros aplicados.
</td>
</tr>
) : (
pageRows.map((pub, i) => {
const isSelected = selectedIds.has(pub.id);
return (
<tr
key={pub.id}
className={`transition-colors ${
isSelected
? "bg-tag-article-bg/70 hover:bg-tag-article-bg"
: "hover:bg-surface-secondary/70"
} ${
i < pageRows.length - 1
? "border-b border-surface-border/60"
: ""
}`}
>
<td
className="w-10 cursor-pointer px-4 py-3.5"
onClick={(e) => {
e.stopPropagation();
toggleRow(pub.id);
}}
>
<TriStateCheckbox
checked={isSelected}
onChange={() => toggleRow(pub.id)}
ariaLabel={`Seleccionar publicación ${pub.title}`}
/>
</td>
<td className="max-w-[280px] px-4 py-3.5 text-[13px] font-medium leading-relaxed text-ink-primary">
<span className="flex flex-wrap items-start gap-1.5">
{isAuthenticated && pub.downloaded_by_me === false && (
<span
title="No descargada aún por ti"
className="mt-0.5 inline-flex shrink-0 items-center gap-0.5 rounded-full bg-brand-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent"
>
<SparkleIcon size={9} />
Nuevo
</span>
)}
{pub.title}
</span>
</td>
<td className="px-4 py-3.5 text-[13px] text-ink-secondary">
{pub.journal || "—"}
</td>
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] font-medium text-ink-primary">
{pub.publication_year ?? "—"}
</td>
<td className="px-4 py-3.5">
{pub.doi ? (
<a
href={`https://doi.org/${pub.doi}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="whitespace-nowrap font-mono text-xs text-brand-accent hover:underline"
>
{pub.doi}
</a>
) : (
<span className="whitespace-nowrap font-mono text-xs text-ink-tertiary">
</span>
)}
</td>
<td className="px-4 py-3.5">
<Badge type={pub.type} />
</td>
</tr>
);
})
)}
</tbody>
</table>
</>
)}
</div>
{/* Pagination */}
{!loading && !error && filtered.length > 0 && (
<PaginationBar
page={currentPage}
totalPages={totalPages}
pageStart={pageStart}
pageEnd={pageEnd}
total={filtered.length}
onPrev={() => setPage((p) => Math.max(1, Math.min(p, totalPages) - 1))}
onNext={() => setPage((p) => Math.min(totalPages, Math.min(p, totalPages) + 1))}
/>
)}
</section>
);
}
function PaginationBar({
page,
totalPages,
pageStart,
pageEnd,
total,
onPrev,
onNext,
}) {
const hasPrev = page > 1;
const hasNext = page < totalPages;
const isSinglePage = totalPages <= 1;
const noun = total === 1 ? "publicación" : "publicaciones";
const visibleCount = pageEnd - pageStart + 1;
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-surface-border/60 px-5 py-3">
{isSinglePage ? (
// Caso "todo entra en una página": evitamos el "del X al Y"
// porque coincide con el total y resulta ruidoso. Un simple
// "Mostrando N de N publicaciones" comunica lo mismo con menos
// palabras.
<p className="text-xs text-ink-tertiary">
Mostrando{" "}
<span className="font-medium text-ink-secondary">{visibleCount}</span>{" "}
de <span className="font-medium text-ink-secondary">{total}</span>{" "}
{noun}
</p>
) : (
<p className="text-xs text-ink-tertiary">
Mostrando del{" "}
<span className="font-medium text-ink-secondary">{pageStart}</span>{" "}
al <span className="font-medium text-ink-secondary">{pageEnd}</span>{" "}
de un total de{" "}
<span className="font-medium text-ink-secondary">{total}</span> {noun}
</p>
)}
{!isSinglePage && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={onPrev}
disabled={!hasPrev}
className="inline-flex items-center rounded-md border border-surface-border-strong bg-surface-primary px-3 py-1.5 text-xs font-medium text-ink-secondary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-40"
>
Anterior
</button>
<span className="text-xs text-ink-tertiary">
Página <span className="font-medium text-ink-primary">{page}</span>{" "}
de <span className="font-medium text-ink-primary">{totalPages}</span>
</span>
<button
type="button"
onClick={onNext}
disabled={!hasNext}
className="inline-flex items-center rounded-md border border-surface-border-strong bg-surface-primary px-3 py-1.5 text-xs font-medium text-ink-secondary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-40"
>
Siguiente
</button>
</div>
)}
</div>
);
}
function LoadingState() {
return (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-ink-tertiary">
<Spinner size={22} />
<p className="text-sm">Cargando publicaciones</p>
</div>
);
}
function ErrorState({ error, onRetry }) {
return (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-16 text-center">
<span className="text-ink-danger">
<AlertIcon size={28} />
</span>
<div>
<p className="text-sm font-medium text-ink-primary">
No se pudieron cargar las publicaciones
</p>
<p className="mt-1 text-xs text-ink-tertiary">
{error?.message ?? "Error desconocido."}
</p>
</div>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-1 inline-flex items-center gap-1.5 rounded-md bg-brand-primary px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-brand-primary-hover"
>
Reintentar
</button>
)}
</div>
);
}