Merge pull request #7 from uja-dev-practices/feature/frontend-v1
Feature/frontend v1
This commit is contained in:
+16
-6
@@ -1,7 +1,17 @@
|
||||
# Base URL of the FastAPI backend (no trailing slash).
|
||||
# Example for local dev: http://localhost:8000
|
||||
VITE_API_URL=http://localhost:8000
|
||||
# URL base del backend FastAPI (sin barra final).
|
||||
#
|
||||
# Déjalo VACÍO en desarrollo para que las peticiones pasen por el proxy
|
||||
# de Vite (ver vite.config.js). El proxy reenvía /researchers y /health
|
||||
# al destino indicado en VITE_API_PROXY_TARGET.
|
||||
#
|
||||
# En producción apunta directamente al backend, p. ej.
|
||||
# VITE_API_URL=https://api.midominio.com
|
||||
VITE_API_URL=
|
||||
|
||||
# Set to "true" while the backend is not yet implemented.
|
||||
# All API calls will be served by src/services/mocks.js instead of `fetch`.
|
||||
VITE_USE_MOCKS=true
|
||||
# Solo para dev: destino al que el proxy de Vite reenvía las peticiones.
|
||||
# Cambia a http://backend:8000 si ejecutas el frontend dentro de docker-compose.
|
||||
VITE_API_PROXY_TARGET=http://localhost:8000
|
||||
|
||||
# Pon "true" SOLO si el backend no está disponible y quieres trabajar
|
||||
# con los fixtures de src/services/mocks.js. En producción debe estar a "false".
|
||||
VITE_USE_MOCKS=false
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
DocumentIcon,
|
||||
DownloadIcon,
|
||||
PackageIcon,
|
||||
} from "../ui/Icons";
|
||||
import { Spinner } from "../ui/Spinner";
|
||||
|
||||
const FORMATS = [
|
||||
{
|
||||
format: "xml",
|
||||
icon: "📄",
|
||||
icon: <DocumentIcon size={20} className="shrink-0 text-ink-secondary" />,
|
||||
label: "SWORD XML",
|
||||
desc: "Metadatos en formato Atom",
|
||||
},
|
||||
{
|
||||
format: "zip",
|
||||
icon: "📦",
|
||||
icon: <PackageIcon size={20} className="shrink-0 text-ink-secondary" />,
|
||||
label: "Paquete ZIP",
|
||||
desc: "XML + ficheros adjuntos",
|
||||
},
|
||||
@@ -28,7 +30,11 @@ const FORMATS = [
|
||||
* `exportingFormat` (optional) lets the parent keep the button in a loading
|
||||
* state between clicks (e.g. while waiting for the backend blob).
|
||||
*/
|
||||
export function ExportDropdown({ onExport, exportingFormat = null }) {
|
||||
export function ExportDropdown({
|
||||
onExport,
|
||||
exportingFormat = null,
|
||||
selectedCount = 0,
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef(null);
|
||||
|
||||
@@ -43,12 +49,17 @@ export function ExportDropdown({ onExport, exportingFormat = null }) {
|
||||
}, []);
|
||||
|
||||
const isBusy = Boolean(exportingFormat);
|
||||
const hasSelection = selectedCount > 0;
|
||||
|
||||
function handlePick(format) {
|
||||
setOpen(false);
|
||||
onExport(format);
|
||||
}
|
||||
|
||||
const idleLabel = hasSelection
|
||||
? `Exportar seleccionadas (${selectedCount})`
|
||||
: "Exportar todas";
|
||||
|
||||
return (
|
||||
<div className="relative" ref={rootRef}>
|
||||
<button
|
||||
@@ -60,7 +71,7 @@ export function ExportDropdown({ onExport, exportingFormat = null }) {
|
||||
{isBusy ? <Spinner size={15} /> : <DownloadIcon />}
|
||||
{isBusy
|
||||
? `Exportando ${exportingFormat.toUpperCase()}...`
|
||||
: "Exportar SWORD"}
|
||||
: idleLabel}
|
||||
{!isBusy && <ChevronDownIcon />}
|
||||
</button>
|
||||
|
||||
@@ -77,9 +88,7 @@ export function ExportDropdown({ onExport, exportingFormat = null }) {
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-xl" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-ink-primary">
|
||||
{label}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { AlertIcon, SearchIcon } from "../ui/Icons";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertIcon, ChevronDownIcon, FilterIcon, SearchIcon } from "../ui/Icons";
|
||||
import { Spinner } from "../ui/Spinner";
|
||||
import { Badge } from "../ui/Badge";
|
||||
|
||||
@@ -11,6 +11,9 @@ const COLUMNS = [
|
||||
{ key: "type", label: "Tipo" },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
const EMPTY_SELECTION = new Set();
|
||||
|
||||
function SortIcon({ active, direction }) {
|
||||
const path =
|
||||
direction === "asc" || !active ? "M6 8L3 5h6z" : "M6 4l3 3H3z";
|
||||
@@ -40,60 +43,255 @@ function sortPublications(rows, key, direction) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Publications table. Owns only UI-state (filter + sort). Data, loading and
|
||||
* error states are driven by the parent page so retries and toasts can be
|
||||
* handled in one place.
|
||||
* 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 the WHOLE currently-filtered set (not
|
||||
* just the visible page). This matches the user mental model of
|
||||
* "filtrar por 2024 → marcar todas de 2024".
|
||||
* - Selection survives filter changes: the stored IDs remain even if
|
||||
* those rows are no longer visible.
|
||||
*/
|
||||
export function PublicationsTable({
|
||||
publications,
|
||||
loading = false,
|
||||
error = null,
|
||||
onRetry,
|
||||
selectedIds = EMPTY_SELECTION,
|
||||
onSelectedIdsChange,
|
||||
}) {
|
||||
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();
|
||||
const rows = needle
|
||||
? publications.filter(
|
||||
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),
|
||||
)
|
||||
: publications;
|
||||
(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, 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 selectionStats = useMemo(() => {
|
||||
if (filtered.length === 0) {
|
||||
return { allChecked: false, anyChecked: false, selectedInFiltered: 0 };
|
||||
}
|
||||
let count = 0;
|
||||
for (const pub of filtered) {
|
||||
if (selectedIds.has(pub.id)) count += 1;
|
||||
}
|
||||
return {
|
||||
allChecked: count === filtered.length,
|
||||
anyChecked: count > 0,
|
||||
selectedInFiltered: count,
|
||||
};
|
||||
}, [filtered, 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 toggleAllFiltered() {
|
||||
const next = new Set(selectedIds);
|
||||
if (selectionStats.allChecked) {
|
||||
for (const pub of filtered) next.delete(pub.id);
|
||||
} else {
|
||||
for (const pub of filtered) 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="flex flex-wrap items-center justify-between gap-3 border-b border-surface-border/60 px-5 py-4">
|
||||
<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 flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFiltersOpen((o) => !o)}
|
||||
aria-expanded={filtersOpen}
|
||||
aria-controls="pubs-advanced-filters"
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-[13px] font-medium transition-colors ${
|
||||
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 />
|
||||
Filtros
|
||||
{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">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filtrar publicaciones..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-[220px] 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"
|
||||
/>
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-tertiary/70">
|
||||
@@ -101,6 +299,74 @@ export function PublicationsTable({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtersOpen && (
|
||||
<div
|
||||
id="pubs-advanced-filters"
|
||||
className="flex flex-wrap items-end gap-4 border-t border-surface-border/60 bg-surface-secondary/40 px-5 py-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="year-from"
|
||||
className="text-[11px] font-medium uppercase tracking-wide text-ink-tertiary"
|
||||
>
|
||||
Desde año
|
||||
</label>
|
||||
<select
|
||||
id="year-from"
|
||||
value={yearFrom}
|
||||
onChange={(e) => handleYearFromChange(e.target.value)}
|
||||
disabled={availableYears.length === 0}
|
||||
className="rounded-md border border-surface-border-strong bg-surface-primary px-2.5 py-1.5 text-[13px] text-ink-primary outline-none focus:border-brand-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">Cualquiera</option>
|
||||
{availableYears.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="year-to"
|
||||
className="text-[11px] font-medium uppercase tracking-wide text-ink-tertiary"
|
||||
>
|
||||
Hasta año
|
||||
</label>
|
||||
<select
|
||||
id="year-to"
|
||||
value={yearTo}
|
||||
onChange={(e) => handleYearToChange(e.target.value)}
|
||||
disabled={availableYears.length === 0}
|
||||
className="rounded-md border border-surface-border-strong bg-surface-primary px-2.5 py-1.5 text-[13px] text-ink-primary outline-none focus:border-brand-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">Cualquiera</option>
|
||||
{availableYears.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{hasYearFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearYearFilter}
|
||||
className="mb-[2px] rounded-md px-2.5 py-1.5 text-xs font-medium text-ink-tertiary transition-colors hover:bg-surface-primary hover:text-ink-primary"
|
||||
>
|
||||
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">
|
||||
@@ -109,9 +375,21 @@ export function PublicationsTable({
|
||||
) : loading ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<table className="w-full min-w-[640px] border-collapse">
|
||||
<table className="w-full min-w-[720px] border-collapse">
|
||||
<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={selectionStats.allChecked}
|
||||
indeterminate={selectionStats.anyChecked}
|
||||
onChange={toggleAllFiltered}
|
||||
ariaLabel="Seleccionar todas las publicaciones del filtro actual"
|
||||
/>
|
||||
</th>
|
||||
{COLUMNS.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
@@ -133,55 +411,160 @@ export function PublicationsTable({
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={COLUMNS.length}
|
||||
colSpan={COLUMNS.length + 1}
|
||||
className="p-10 text-center text-sm text-ink-tertiary"
|
||||
>
|
||||
No se encontraron publicaciones con ese filtro.
|
||||
No se encontraron publicaciones con los filtros aplicados.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((pub, i) => (
|
||||
pageRows.map((pub, i) => {
|
||||
const isSelected = selectedIds.has(pub.id);
|
||||
return (
|
||||
<tr
|
||||
key={pub.id}
|
||||
className={`transition-colors hover:bg-surface-secondary/70 ${
|
||||
i < filtered.length - 1
|
||||
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">
|
||||
{pub.title}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] text-ink-secondary">
|
||||
{pub.journal}
|
||||
{pub.journal || "—"}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] font-medium text-ink-primary">
|
||||
{pub.publication_year}
|
||||
{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">
|
||||
|
||||
@@ -16,7 +16,7 @@ export function ResearcherCard({ researcher, actions = null }) {
|
||||
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<h2 className="mb-1 text-[22px] font-semibold text-ink-primary">
|
||||
{researcher.name}
|
||||
{researcher.name || "Investigador sin nombre"}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
@@ -25,10 +25,14 @@ export function ResearcherCard({ researcher, actions = null }) {
|
||||
{researcher.orcid_id}
|
||||
</span>
|
||||
</div>
|
||||
{researcher.affiliation && (
|
||||
<>
|
||||
<span className="text-surface-border-strong">·</span>
|
||||
<span className="text-[13px] text-ink-secondary">
|
||||
{researcher.affiliation}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 inline-flex items-center gap-1.5 text-ink-tertiary">
|
||||
<ClockIcon />
|
||||
|
||||
@@ -94,6 +94,14 @@ export function SearchIcon({ size = 14, className = "" }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterIcon({ size = 14, className = "" }) {
|
||||
return (
|
||||
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
|
||||
<path d="M3 5h18M6 12h12M10 19h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertIcon({ size = 16, className = "" }) {
|
||||
return (
|
||||
<svg {...base} width={size} height={size} className={className}>
|
||||
@@ -102,3 +110,13 @@ export function AlertIcon({ size = 16, className = "" }) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PackageIcon({ size = 18, className = "" }) {
|
||||
return (
|
||||
<svg {...base} width={size} height={size} className={className}>
|
||||
<path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z" />
|
||||
<path d="M3.27 6.96L12 12.01l8.73-5.05M12 22.08V12" />
|
||||
<path d="M7.5 4.21l9 5.16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ export function DashboardPage() {
|
||||
const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success
|
||||
const [exportingFormat, setExportingFormat] = useState(null);
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
||||
|
||||
const loadResearcher = useCallback(
|
||||
async (signal) => {
|
||||
try {
|
||||
@@ -57,7 +59,16 @@ export function DashboardPage() {
|
||||
setPubsError(null);
|
||||
try {
|
||||
const data = await getPublications(orcid, { signal });
|
||||
if (!signal?.aborted) setPublications(data);
|
||||
if (!signal?.aborted) {
|
||||
setPublications(data);
|
||||
setSelectedIds((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const alive = new Set(data.map((p) => p.id));
|
||||
const next = new Set();
|
||||
for (const id of prev) if (alive.has(id)) next.add(id);
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return;
|
||||
setPubsError(err);
|
||||
@@ -83,12 +94,23 @@ export function DashboardPage() {
|
||||
async function handleSync() {
|
||||
setSyncStatus("loading");
|
||||
try {
|
||||
const updated = await syncResearcher(orcid);
|
||||
if (updated) setResearcher(updated);
|
||||
await loadPublications();
|
||||
const summary = await syncResearcher(orcid);
|
||||
|
||||
if (summary?.status === "error") {
|
||||
throw new Error(summary.message || "El backend rechazó la sincronización.");
|
||||
}
|
||||
|
||||
await Promise.all([loadResearcher(), loadPublications()]);
|
||||
|
||||
setSyncStatus("success");
|
||||
const total = summary?.total ?? 0;
|
||||
const nuevos = summary?.new_records ?? 0;
|
||||
const actualizados = summary?.updated_records ?? 0;
|
||||
toast.success("Sincronización completada", {
|
||||
description: "Las publicaciones se han actualizado desde ORCID.",
|
||||
description:
|
||||
total > 0
|
||||
? `${nuevos} nuevas · ${actualizados} actualizadas (${total} total).`
|
||||
: summary?.message ?? "Sin cambios desde la última sincronización.",
|
||||
});
|
||||
setTimeout(() => setSyncStatus("idle"), SUCCESS_FLASH_MS);
|
||||
} catch (err) {
|
||||
@@ -102,7 +124,10 @@ export function DashboardPage() {
|
||||
async function handleExport(format) {
|
||||
setExportingFormat(format);
|
||||
try {
|
||||
const { blob, url } = await downloadExport(orcid, format);
|
||||
const ids = Array.from(selectedIds);
|
||||
const { blob, url } = await downloadExport(orcid, format, {
|
||||
publicationIds: ids.length > 0 ? ids : undefined,
|
||||
});
|
||||
if (blob) {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
@@ -139,6 +164,7 @@ export function DashboardPage() {
|
||||
<ExportDropdown
|
||||
onExport={handleExport}
|
||||
exportingFormat={exportingFormat}
|
||||
selectedCount={selectedIds.size}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
@@ -154,6 +180,8 @@ export function DashboardPage() {
|
||||
loading={pubsLoading}
|
||||
error={pubsError}
|
||||
onRetry={() => loadPublications()}
|
||||
selectedIds={selectedIds}
|
||||
onSelectedIdsChange={setSelectedIds}
|
||||
/>
|
||||
|
||||
<footer className="mt-4 flex flex-wrap items-center justify-between gap-2 px-1">
|
||||
|
||||
+109
-37
@@ -1,13 +1,22 @@
|
||||
/**
|
||||
* Thin API client for the FastAPI backend.
|
||||
* Cliente HTTP del frontend contra la API FastAPI.
|
||||
*
|
||||
* Every call returns parsed JSON (or a Blob for file downloads) and throws
|
||||
* an `ApiError` on non-2xx responses so callers can decide how to surface it
|
||||
* (toast, inline error, retry, etc.).
|
||||
* Cada función devuelve el JSON ya parseado (o un Blob para descargas)
|
||||
* y lanza `ApiError` en respuestas no 2xx, de forma que cada pantalla
|
||||
* decide cómo mostrarlo (toast, error inline, reintento, …).
|
||||
*
|
||||
* The base URL is injected at build time via `VITE_API_URL`
|
||||
* (see `.env.example`). During development, leaving it blank falls back to
|
||||
* same-origin requests, which plays well with a Vite proxy.
|
||||
* La URL base se inyecta en build via `VITE_API_URL` (ver `.env.example`).
|
||||
* En desarrollo la dejamos en blanco para que las peticiones pasen por
|
||||
* el proxy de Vite (ver `vite.config.js`) y así eludir CORS mientras el
|
||||
* backend no lo tenga configurado.
|
||||
*
|
||||
* Contrato real del backend (prefijo de router: `/researchers`):
|
||||
* - POST /researchers/?orcid_id=XXXX-XXXX-XXXX-XXXX (crea/upsert)
|
||||
* - GET /researchers/{orcid_id}
|
||||
* - POST /researchers/{orcid_id}/sync
|
||||
* - GET /researchers/{orcid_id}/publications
|
||||
* - GET /researchers/{orcid_id}/export/sword.xml
|
||||
* - GET /researchers/{orcid_id}/export/sword.zip
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -19,11 +28,6 @@ import {
|
||||
|
||||
const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
|
||||
|
||||
/**
|
||||
* When the backend is not available yet, set `VITE_USE_MOCKS=true` in your
|
||||
* `.env.local` to route every call through `mocks.js`. In production this
|
||||
* flag MUST be unset.
|
||||
*/
|
||||
const USE_MOCKS = import.meta.env.VITE_USE_MOCKS === "true";
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -42,7 +46,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
|
||||
signal,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(body ? { "Content-Type": "application/json" } : {}),
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
...headers,
|
||||
},
|
||||
};
|
||||
@@ -52,6 +56,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
|
||||
try {
|
||||
response = await fetch(url, init);
|
||||
} catch (cause) {
|
||||
if (cause?.name === "AbortError") throw cause;
|
||||
throw new ApiError("No se pudo contactar con el servidor.", {
|
||||
status: 0,
|
||||
payload: { cause: String(cause) },
|
||||
@@ -63,7 +68,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
/* response had no JSON body */
|
||||
/* sin cuerpo JSON */
|
||||
}
|
||||
const detail =
|
||||
payload?.detail ?? payload?.message ?? response.statusText ?? "Error";
|
||||
@@ -79,67 +84,134 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
|
||||
return response;
|
||||
}
|
||||
|
||||
/* ───────────────────────────── Mapeos ────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Adapta el esquema del backend (`pub_year`, campos opcionalmente `null`)
|
||||
* al que espera la UI (`publication_year`, strings seguras para filtrar).
|
||||
*/
|
||||
function normalizePublication(p) {
|
||||
return {
|
||||
id: p.id,
|
||||
put_code: p.put_code ?? null,
|
||||
title: p.title || "Sin título",
|
||||
journal: p.journal || "",
|
||||
doi: p.doi || "",
|
||||
publication_year: p.pub_year ?? null,
|
||||
type: p.type || null,
|
||||
hash_fingerprint: p.hash_fingerprint ?? null,
|
||||
last_modified: p.last_modified ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/* ───────────────────────────── Endpoints ─────────────────────────────── */
|
||||
|
||||
/** POST /api/orcid/validate — validates an ORCID iD and returns the researcher. */
|
||||
export function validateOrcid(orcidId, { signal } = {}) {
|
||||
/**
|
||||
* Asegura que el investigador existe en el backend y devuelve su ficha
|
||||
* completa.
|
||||
*
|
||||
* Como el backend no expone un endpoint de validación puro, hacemos:
|
||||
* 1. POST /researchers/?orcid_id=... (idempotente: crea o devuelve el
|
||||
* existente; valida formato + dígito de control en el servidor).
|
||||
* 2. GET /researchers/{orcid_id} (para recuperar el objeto completo:
|
||||
* name, last_sync_at, etc.).
|
||||
*/
|
||||
export async function validateOrcid(orcidId, { signal } = {}) {
|
||||
if (USE_MOCKS) return mockValidateOrcid(orcidId);
|
||||
return request("/api/orcid/validate", {
|
||||
method: "POST",
|
||||
body: { orcid_id: orcidId },
|
||||
signal,
|
||||
});
|
||||
|
||||
await request(
|
||||
`/researchers/?orcid_id=${encodeURIComponent(orcidId)}`,
|
||||
{ method: "POST", signal },
|
||||
);
|
||||
return request(`/researchers/${encodeURIComponent(orcidId)}`, { signal });
|
||||
}
|
||||
|
||||
/** GET /api/researchers/{orcid}/publications — lists ORCID works. */
|
||||
export function getPublications(orcidId, { signal } = {}) {
|
||||
/** GET /researchers/{orcid}/publications — normalizado para la UI. */
|
||||
export async function getPublications(orcidId, { signal } = {}) {
|
||||
if (USE_MOCKS) return mockGetPublications(orcidId);
|
||||
return request(
|
||||
`/api/researchers/${encodeURIComponent(orcidId)}/publications`,
|
||||
|
||||
const raw = await request(
|
||||
`/researchers/${encodeURIComponent(orcidId)}/publications`,
|
||||
{ signal },
|
||||
);
|
||||
return Array.isArray(raw) ? raw.map(normalizePublication) : [];
|
||||
}
|
||||
|
||||
/** POST /api/researchers/{orcid}/sync — triggers ORCID re-harvest. */
|
||||
/**
|
||||
* POST /researchers/{orcid}/sync — dispara el re-harvest desde ORCID.
|
||||
*
|
||||
* El backend devuelve un resumen del job (`{status, message, new_records,
|
||||
* updated_records, total, researcher}`), no el researcher completo.
|
||||
* El caller debe refetch-ear el researcher y sus publicaciones.
|
||||
*/
|
||||
export function syncResearcher(orcidId, { signal } = {}) {
|
||||
if (USE_MOCKS) return mockSyncResearcher(orcidId);
|
||||
return request(`/api/researchers/${encodeURIComponent(orcidId)}/sync`, {
|
||||
|
||||
return request(`/researchers/${encodeURIComponent(orcidId)}/sync`, {
|
||||
method: "POST",
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the public export URL so links/anchors can download files directly
|
||||
* without going through `fetch`. Used by the export dropdown.
|
||||
* Construye la URL pública de exportación para enlaces directos
|
||||
* (sin pasar por `fetch`). La usa el dropdown de exportación.
|
||||
*/
|
||||
export function getExportUrl(orcidId, format) {
|
||||
return `${BASE_URL}/api/researchers/${encodeURIComponent(orcidId)}/export/sword.${format}`;
|
||||
return `${BASE_URL}/researchers/${encodeURIComponent(orcidId)}/export/sword.${format}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an export as a Blob (useful when we want to trigger a
|
||||
* programmatic file download). Falls back to `ApiError` on failure.
|
||||
* Descarga una exportación como Blob (para forzar descarga programática).
|
||||
*
|
||||
* `publicationIds` es opcional; si se pasa un array no vacío, el backend
|
||||
* filtra el export a sólo esas publicaciones (exportación selectiva). Si
|
||||
* se omite o va vacío/null, se exporta el conjunto completo.
|
||||
*
|
||||
* Usamos POST (no GET) porque los IDs pueden ser cientos y no caben
|
||||
* cómodamente en la query-string.
|
||||
*
|
||||
* Lanza `ApiError` en fallo.
|
||||
*/
|
||||
export async function downloadExport(orcidId, format, { signal } = {}) {
|
||||
export async function downloadExport(
|
||||
orcidId,
|
||||
format,
|
||||
{ signal, publicationIds } = {},
|
||||
) {
|
||||
if (USE_MOCKS) {
|
||||
await mockExport(format);
|
||||
return { blob: null, url: getExportUrl(orcidId, format) };
|
||||
}
|
||||
|
||||
const url = getExportUrl(orcidId, format);
|
||||
const ids =
|
||||
Array.isArray(publicationIds) && publicationIds.length > 0
|
||||
? publicationIds
|
||||
: null;
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, { signal });
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
signal,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "*/*",
|
||||
},
|
||||
body: JSON.stringify({ publication_ids: ids }),
|
||||
});
|
||||
} catch (cause) {
|
||||
if (cause?.name === "AbortError") throw cause;
|
||||
throw new ApiError("No se pudo contactar con el servidor.", {
|
||||
status: 0,
|
||||
payload: { cause: String(cause) },
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ApiError(`No se pudo exportar el fichero ${format.toUpperCase()}.`, {
|
||||
status: response.status,
|
||||
});
|
||||
throw new ApiError(
|
||||
`No se pudo exportar el fichero ${format.toUpperCase()}.`,
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return { blob, url };
|
||||
|
||||
@@ -69,10 +69,14 @@ export async function mockGetPublications(/* orcidId */) {
|
||||
|
||||
export async function mockSyncResearcher(orcidId) {
|
||||
await delay(1800);
|
||||
// Imita el payload real del backend (resumen del SyncJob, no el researcher).
|
||||
return {
|
||||
...MOCK_RESEARCHER,
|
||||
orcid_id: orcidId,
|
||||
last_sync_at: new Date().toISOString(),
|
||||
status: "ok",
|
||||
message: "Sincronización completada correctamente.",
|
||||
researcher: orcidId,
|
||||
new_records: 0,
|
||||
updated_records: MOCK_PUBLICATIONS.length,
|
||||
total: MOCK_PUBLICATIONS.length,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,14 @@ export function formatDate(iso) {
|
||||
|
||||
/**
|
||||
* Builds researcher initials (max 2 chars) from a full name.
|
||||
* Si el backend aún no conoce el nombre, devolvemos un guion como
|
||||
* placeholder para no dejar el avatar vacío.
|
||||
*/
|
||||
export function getInitials(name = "") {
|
||||
return name
|
||||
.trim()
|
||||
export function getInitials(name) {
|
||||
if (!name || typeof name !== "string") return "–";
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return "–";
|
||||
return trimmed
|
||||
.split(/\s+/)
|
||||
.map((w) => w[0] ?? "")
|
||||
.slice(0, 2)
|
||||
|
||||
+21
-2
@@ -1,8 +1,27 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, import.meta.dirname, '')
|
||||
const proxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:8000'
|
||||
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/researchers': {
|
||||
target: proxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/health': {
|
||||
target: proxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user