Merge pull request #7 from uja-dev-practices/feature/frontend-v1

Feature/frontend v1
This commit is contained in:
Alexis López
2026-04-28 12:04:00 +02:00
committed by GitHub
10 changed files with 690 additions and 139 deletions
+16 -6
View File
@@ -1,7 +1,17 @@
# Base URL of the FastAPI backend (no trailing slash). # URL base del backend FastAPI (sin barra final).
# Example for local dev: http://localhost:8000 #
VITE_API_URL=http://localhost:8000 # 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. # Solo para dev: destino al que el proxy de Vite reenvía las peticiones.
# All API calls will be served by src/services/mocks.js instead of `fetch`. # Cambia a http://backend:8000 si ejecutas el frontend dentro de docker-compose.
VITE_USE_MOCKS=true 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 { useEffect, useRef, useState } from "react";
import { import {
ChevronDownIcon, ChevronDownIcon,
DocumentIcon,
DownloadIcon, DownloadIcon,
PackageIcon,
} from "../ui/Icons"; } from "../ui/Icons";
import { Spinner } from "../ui/Spinner"; import { Spinner } from "../ui/Spinner";
const FORMATS = [ const FORMATS = [
{ {
format: "xml", format: "xml",
icon: "📄", icon: <DocumentIcon size={20} className="shrink-0 text-ink-secondary" />,
label: "SWORD XML", label: "SWORD XML",
desc: "Metadatos en formato Atom", desc: "Metadatos en formato Atom",
}, },
{ {
format: "zip", format: "zip",
icon: "📦", icon: <PackageIcon size={20} className="shrink-0 text-ink-secondary" />,
label: "Paquete ZIP", label: "Paquete ZIP",
desc: "XML + ficheros adjuntos", desc: "XML + ficheros adjuntos",
}, },
@@ -28,7 +30,11 @@ const FORMATS = [
* `exportingFormat` (optional) lets the parent keep the button in a loading * `exportingFormat` (optional) lets the parent keep the button in a loading
* state between clicks (e.g. while waiting for the backend blob). * 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 [open, setOpen] = useState(false);
const rootRef = useRef(null); const rootRef = useRef(null);
@@ -43,12 +49,17 @@ export function ExportDropdown({ onExport, exportingFormat = null }) {
}, []); }, []);
const isBusy = Boolean(exportingFormat); const isBusy = Boolean(exportingFormat);
const hasSelection = selectedCount > 0;
function handlePick(format) { function handlePick(format) {
setOpen(false); setOpen(false);
onExport(format); onExport(format);
} }
const idleLabel = hasSelection
? `Exportar seleccionadas (${selectedCount})`
: "Exportar todas";
return ( return (
<div className="relative" ref={rootRef}> <div className="relative" ref={rootRef}>
<button <button
@@ -60,7 +71,7 @@ export function ExportDropdown({ onExport, exportingFormat = null }) {
{isBusy ? <Spinner size={15} /> : <DownloadIcon />} {isBusy ? <Spinner size={15} /> : <DownloadIcon />}
{isBusy {isBusy
? `Exportando ${exportingFormat.toUpperCase()}...` ? `Exportando ${exportingFormat.toUpperCase()}...`
: "Exportar SWORD"} : idleLabel}
{!isBusy && <ChevronDownIcon />} {!isBusy && <ChevronDownIcon />}
</button> </button>
@@ -77,9 +88,7 @@ export function ExportDropdown({ onExport, exportingFormat = null }) {
: "" : ""
}`} }`}
> >
<span className="text-xl" aria-hidden> {icon}
{icon}
</span>
<div> <div>
<div className="text-sm font-medium text-ink-primary"> <div className="text-sm font-medium text-ink-primary">
{label} {label}
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { AlertIcon, SearchIcon } from "../ui/Icons"; import { AlertIcon, ChevronDownIcon, FilterIcon, SearchIcon } from "../ui/Icons";
import { Spinner } from "../ui/Spinner"; import { Spinner } from "../ui/Spinner";
import { Badge } from "../ui/Badge"; import { Badge } from "../ui/Badge";
@@ -11,6 +11,9 @@ const COLUMNS = [
{ key: "type", label: "Tipo" }, { key: "type", label: "Tipo" },
]; ];
const PAGE_SIZE = 15;
const EMPTY_SELECTION = new Set();
function SortIcon({ active, direction }) { function SortIcon({ active, direction }) {
const path = const path =
direction === "asc" || !active ? "M6 8L3 5h6z" : "M6 4l3 3H3z"; direction === "asc" || !active ? "M6 8L3 5h6z" : "M6 4l3 3H3z";
@@ -40,66 +43,329 @@ function sortPublications(rows, key, direction) {
} }
/** /**
* Publications table. Owns only UI-state (filter + sort). Data, loading and * Tri-state checkbox. We can't express `indeterminate` via React props, so
* error states are driven by the parent page so retries and toasts can be * we set it imperatively on the DOM node whenever the flag changes.
* handled in one place. */
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({ export function PublicationsTable({
publications, publications,
loading = false, loading = false,
error = null, error = null,
onRetry, onRetry,
selectedIds = EMPTY_SELECTION,
onSelectedIdsChange,
}) { }) {
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
const [sortKey, setSortKey] = useState("publication_year"); const [sortKey, setSortKey] = useState("publication_year");
const [sortDir, setSortDir] = useState("desc"); 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 filtered = useMemo(() => {
const needle = filter.trim().toLowerCase(); const needle = filter.trim().toLowerCase();
const rows = needle let rows = publications;
? publications.filter(
(p) => if (needle) {
p.title.toLowerCase().includes(needle) || rows = rows.filter(
p.journal.toLowerCase().includes(needle) || (p) =>
String(p.publication_year).includes(needle), (p.title ?? "").toLowerCase().includes(needle) ||
) (p.journal ?? "").toLowerCase().includes(needle) ||
: publications; 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); 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) { function toggleSort(key) {
if (sortKey === key) { if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc")); setSortDir((d) => (d === "asc" ? "desc" : "asc"));
setPage(1);
} else { } else {
setSortKey(key); setSortKey(key);
setSortDir("desc"); 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 ( return (
<section className="overflow-hidden rounded-2xl border border-surface-border/60 bg-surface-primary"> <section className="overflow-hidden rounded-2xl border border-surface-border/60 bg-surface-primary">
{/* Toolbar */} {/* 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> <div className="flex flex-wrap items-center justify-between gap-3 px-5 py-4">
<h3 className="text-base font-medium text-ink-primary"> <div>
Publicaciones <h3 className="text-base font-medium text-ink-primary">
</h3> Publicaciones
<p className="mt-0.5 text-xs text-ink-tertiary"> </h3>
{filtered.length} de {publications.length} resultados <p className="mt-0.5 text-xs text-ink-tertiary">
</p> {filtered.length} de {publications.length} resultados
</div> {yearFilterSummary && (
<div className="relative"> <>
<input {" · "}
type="text" <span className="text-ink-secondary">{yearFilterSummary}</span>
placeholder="Filtrar publicaciones..." </>
value={filter} )}
onChange={(e) => setFilter(e.target.value)} {selectedIds.size > 0 && (
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"> <span className="font-medium text-brand-accent">
<SearchIcon /> {selectedIds.size} seleccionada
</span> {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);
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">
<SearchIcon />
</span>
</div>
</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> </div>
{/* Body */} {/* Body */}
@@ -109,9 +375,21 @@ export function PublicationsTable({
) : loading ? ( ) : loading ? (
<LoadingState /> <LoadingState />
) : ( ) : (
<table className="w-full min-w-[640px] border-collapse"> <table className="w-full min-w-[720px] border-collapse">
<thead> <thead>
<tr className="bg-surface-secondary"> <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) => ( {COLUMNS.map((col) => (
<th <th
key={col.key} key={col.key}
@@ -133,55 +411,160 @@ export function PublicationsTable({
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<tr> <tr>
<td <td
colSpan={COLUMNS.length} colSpan={COLUMNS.length + 1}
className="p-10 text-center text-sm text-ink-tertiary" 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> </td>
</tr> </tr>
) : ( ) : (
filtered.map((pub, i) => ( pageRows.map((pub, i) => {
<tr const isSelected = selectedIds.has(pub.id);
key={pub.id} return (
className={`transition-colors hover:bg-surface-secondary/70 ${ <tr
i < filtered.length - 1 key={pub.id}
? "border-b border-surface-border/60" className={`transition-colors ${
: "" isSelected
}`} ? "bg-tag-article-bg/70 hover:bg-tag-article-bg"
> : "hover:bg-surface-secondary/70"
<td className="max-w-[280px] px-4 py-3.5 text-[13px] font-medium leading-relaxed text-ink-primary"> } ${
{pub.title} i < pageRows.length - 1
</td> ? "border-b border-surface-border/60"
<td className="whitespace-nowrap 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"> <td
{pub.publication_year} className="w-10 cursor-pointer px-4 py-3.5"
</td> onClick={(e) => {
<td className="px-4 py-3.5"> e.stopPropagation();
<a toggleRow(pub.id);
href={`https://doi.org/${pub.doi}`} }}
target="_blank"
rel="noopener noreferrer"
className="whitespace-nowrap font-mono text-xs text-brand-accent hover:underline"
> >
{pub.doi} <TriStateCheckbox
</a> checked={isSelected}
</td> onChange={() => toggleRow(pub.id)}
<td className="px-4 py-3.5"> ariaLabel={`Seleccionar publicación ${pub.title}`}
<Badge type={pub.type} /> />
</td> </td>
</tr> <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 || "—"}
</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> </tbody>
</table> </table>
)} )}
</div> </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> </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() { function LoadingState() {
return ( return (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-ink-tertiary"> <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"> <div className="min-w-[200px] flex-1">
<h2 className="mb-1 text-[22px] font-semibold text-ink-primary"> <h2 className="mb-1 text-[22px] font-semibold text-ink-primary">
{researcher.name} {researcher.name || "Investigador sin nombre"}
</h2> </h2>
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex flex-wrap items-center gap-2.5">
<div className="inline-flex items-center gap-1.5"> <div className="inline-flex items-center gap-1.5">
@@ -25,10 +25,14 @@ export function ResearcherCard({ researcher, actions = null }) {
{researcher.orcid_id} {researcher.orcid_id}
</span> </span>
</div> </div>
<span className="text-surface-border-strong">·</span> {researcher.affiliation && (
<span className="text-[13px] text-ink-secondary"> <>
{researcher.affiliation} <span className="text-surface-border-strong">·</span>
</span> <span className="text-[13px] text-ink-secondary">
{researcher.affiliation}
</span>
</>
)}
</div> </div>
<div className="mt-2 inline-flex items-center gap-1.5 text-ink-tertiary"> <div className="mt-2 inline-flex items-center gap-1.5 text-ink-tertiary">
<ClockIcon /> <ClockIcon />
+18
View File
@@ -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 = "" }) { export function AlertIcon({ size = 16, className = "" }) {
return ( return (
<svg {...base} width={size} height={size} className={className}> <svg {...base} width={size} height={size} className={className}>
@@ -102,3 +110,13 @@ export function AlertIcon({ size = 16, className = "" }) {
</svg> </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>
);
}
+34 -6
View File
@@ -36,6 +36,8 @@ export function DashboardPage() {
const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success
const [exportingFormat, setExportingFormat] = useState(null); const [exportingFormat, setExportingFormat] = useState(null);
const [selectedIds, setSelectedIds] = useState(() => new Set());
const loadResearcher = useCallback( const loadResearcher = useCallback(
async (signal) => { async (signal) => {
try { try {
@@ -57,7 +59,16 @@ export function DashboardPage() {
setPubsError(null); setPubsError(null);
try { try {
const data = await getPublications(orcid, { signal }); 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) { } catch (err) {
if (signal?.aborted) return; if (signal?.aborted) return;
setPubsError(err); setPubsError(err);
@@ -83,12 +94,23 @@ export function DashboardPage() {
async function handleSync() { async function handleSync() {
setSyncStatus("loading"); setSyncStatus("loading");
try { try {
const updated = await syncResearcher(orcid); const summary = await syncResearcher(orcid);
if (updated) setResearcher(updated);
await loadPublications(); if (summary?.status === "error") {
throw new Error(summary.message || "El backend rechazó la sincronización.");
}
await Promise.all([loadResearcher(), loadPublications()]);
setSyncStatus("success"); setSyncStatus("success");
const total = summary?.total ?? 0;
const nuevos = summary?.new_records ?? 0;
const actualizados = summary?.updated_records ?? 0;
toast.success("Sincronización completada", { 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); setTimeout(() => setSyncStatus("idle"), SUCCESS_FLASH_MS);
} catch (err) { } catch (err) {
@@ -102,7 +124,10 @@ export function DashboardPage() {
async function handleExport(format) { async function handleExport(format) {
setExportingFormat(format); setExportingFormat(format);
try { 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) { if (blob) {
const objectUrl = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a"); const anchor = document.createElement("a");
@@ -139,6 +164,7 @@ export function DashboardPage() {
<ExportDropdown <ExportDropdown
onExport={handleExport} onExport={handleExport}
exportingFormat={exportingFormat} exportingFormat={exportingFormat}
selectedCount={selectedIds.size}
/> />
</> </>
} }
@@ -154,6 +180,8 @@ export function DashboardPage() {
loading={pubsLoading} loading={pubsLoading}
error={pubsError} error={pubsError}
onRetry={() => loadPublications()} onRetry={() => loadPublications()}
selectedIds={selectedIds}
onSelectedIdsChange={setSelectedIds}
/> />
<footer className="mt-4 flex flex-wrap items-center justify-between gap-2 px-1"> <footer className="mt-4 flex flex-wrap items-center justify-between gap-2 px-1">
+109 -37
View File
@@ -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 * Cada función devuelve el JSON ya parseado (o un Blob para descargas)
* an `ApiError` on non-2xx responses so callers can decide how to surface it * y lanza `ApiError` en respuestas no 2xx, de forma que cada pantalla
* (toast, inline error, retry, etc.). * decide cómo mostrarlo (toast, error inline, reintento, …).
* *
* The base URL is injected at build time via `VITE_API_URL` * La URL base se inyecta en build via `VITE_API_URL` (ver `.env.example`).
* (see `.env.example`). During development, leaving it blank falls back to * En desarrollo la dejamos en blanco para que las peticiones pasen por
* same-origin requests, which plays well with a Vite proxy. * 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 { import {
@@ -19,11 +28,6 @@ import {
const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, ""); 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"; const USE_MOCKS = import.meta.env.VITE_USE_MOCKS === "true";
export class ApiError extends Error { export class ApiError extends Error {
@@ -42,7 +46,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
signal, signal,
headers: { headers: {
Accept: "application/json", Accept: "application/json",
...(body ? { "Content-Type": "application/json" } : {}), ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
...headers, ...headers,
}, },
}; };
@@ -52,6 +56,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
try { try {
response = await fetch(url, init); response = await fetch(url, init);
} catch (cause) { } catch (cause) {
if (cause?.name === "AbortError") throw cause;
throw new ApiError("No se pudo contactar con el servidor.", { throw new ApiError("No se pudo contactar con el servidor.", {
status: 0, status: 0,
payload: { cause: String(cause) }, payload: { cause: String(cause) },
@@ -63,7 +68,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
try { try {
payload = await response.json(); payload = await response.json();
} catch { } catch {
/* response had no JSON body */ /* sin cuerpo JSON */
} }
const detail = const detail =
payload?.detail ?? payload?.message ?? response.statusText ?? "Error"; payload?.detail ?? payload?.message ?? response.statusText ?? "Error";
@@ -79,67 +84,134 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
return response; 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 ─────────────────────────────── */ /* ───────────────────────────── 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); if (USE_MOCKS) return mockValidateOrcid(orcidId);
return request("/api/orcid/validate", {
method: "POST", await request(
body: { orcid_id: orcidId }, `/researchers/?orcid_id=${encodeURIComponent(orcidId)}`,
signal, { method: "POST", signal },
}); );
return request(`/researchers/${encodeURIComponent(orcidId)}`, { signal });
} }
/** GET /api/researchers/{orcid}/publications — lists ORCID works. */ /** GET /researchers/{orcid}/publications — normalizado para la UI. */
export function getPublications(orcidId, { signal } = {}) { export async function getPublications(orcidId, { signal } = {}) {
if (USE_MOCKS) return mockGetPublications(orcidId); if (USE_MOCKS) return mockGetPublications(orcidId);
return request(
`/api/researchers/${encodeURIComponent(orcidId)}/publications`, const raw = await request(
`/researchers/${encodeURIComponent(orcidId)}/publications`,
{ signal }, { 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 } = {}) { export function syncResearcher(orcidId, { signal } = {}) {
if (USE_MOCKS) return mockSyncResearcher(orcidId); if (USE_MOCKS) return mockSyncResearcher(orcidId);
return request(`/api/researchers/${encodeURIComponent(orcidId)}/sync`, {
return request(`/researchers/${encodeURIComponent(orcidId)}/sync`, {
method: "POST", method: "POST",
signal, signal,
}); });
} }
/** /**
* Builds the public export URL so links/anchors can download files directly * Construye la URL pública de exportación para enlaces directos
* without going through `fetch`. Used by the export dropdown. * (sin pasar por `fetch`). La usa el dropdown de exportación.
*/ */
export function getExportUrl(orcidId, format) { 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 * Descarga una exportación como Blob (para forzar descarga programática).
* programmatic file download). Falls back to `ApiError` on failure. *
* `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) { if (USE_MOCKS) {
await mockExport(format); await mockExport(format);
return { blob: null, url: getExportUrl(orcidId, format) }; return { blob: null, url: getExportUrl(orcidId, format) };
} }
const url = getExportUrl(orcidId, format); const url = getExportUrl(orcidId, format);
const ids =
Array.isArray(publicationIds) && publicationIds.length > 0
? publicationIds
: null;
let response; let response;
try { 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) { } catch (cause) {
if (cause?.name === "AbortError") throw cause;
throw new ApiError("No se pudo contactar con el servidor.", { throw new ApiError("No se pudo contactar con el servidor.", {
status: 0, status: 0,
payload: { cause: String(cause) }, payload: { cause: String(cause) },
}); });
} }
if (!response.ok) { if (!response.ok) {
throw new ApiError(`No se pudo exportar el fichero ${format.toUpperCase()}.`, { throw new ApiError(
status: response.status, `No se pudo exportar el fichero ${format.toUpperCase()}.`,
}); { status: response.status },
);
} }
const blob = await response.blob(); const blob = await response.blob();
return { blob, url }; return { blob, url };
+7 -3
View File
@@ -69,10 +69,14 @@ export async function mockGetPublications(/* orcidId */) {
export async function mockSyncResearcher(orcidId) { export async function mockSyncResearcher(orcidId) {
await delay(1800); await delay(1800);
// Imita el payload real del backend (resumen del SyncJob, no el researcher).
return { return {
...MOCK_RESEARCHER, status: "ok",
orcid_id: orcidId, message: "Sincronización completada correctamente.",
last_sync_at: new Date().toISOString(), researcher: orcidId,
new_records: 0,
updated_records: MOCK_PUBLICATIONS.length,
total: MOCK_PUBLICATIONS.length,
}; };
} }
+7 -3
View File
@@ -16,10 +16,14 @@ export function formatDate(iso) {
/** /**
* Builds researcher initials (max 2 chars) from a full name. * 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 = "") { export function getInitials(name) {
return name if (!name || typeof name !== "string") return "";
.trim() const trimmed = name.trim();
if (!trimmed) return "";
return trimmed
.split(/\s+/) .split(/\s+/)
.map((w) => w[0] ?? "") .map((w) => w[0] ?? "")
.slice(0, 2) .slice(0, 2)
+22 -3
View File
@@ -1,8 +1,27 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [react(), tailwindcss()], 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,
},
},
},
}
}) })