diff --git a/backend/app/security/jwt.py b/backend/app/security/jwt.py
index 7edab3d..e49042a 100644
--- a/backend/app/security/jwt.py
+++ b/backend/app/security/jwt.py
@@ -129,10 +129,30 @@ def get_optional_current_researcher(
db: Session = Depends(get_db),
) -> Researcher | None:
"""
- Devuelve el investigador autenticado si hay Bearer válido.
- Si no hay Bearer, devuelve None.
- Si hay Bearer inválido, lanza 401 (no se acepta como anónimo).
+ Devuelve el investigador autenticado si hay Bearer válido y la sesión sigue activa.
+
+ Sin Bearer, token inválido/expirado o investigador no autenticado → None.
+ Las rutas públicas (p. ej. búsqueda) deben seguir funcionando aunque el navegador
+ conserve un JWT caducado en localStorage.
"""
if not creds or not creds.credentials:
return None
- return get_current_researcher(request=request, creds=creds, db=db)
+
+ try:
+ payload = _decode_token(creds.credentials)
+ except HTTPException:
+ return None
+
+ if payload.get("typ") != "access":
+ return None
+
+ orcid_id = payload.get("sub")
+ if not isinstance(orcid_id, str) or not is_valid_orcid(orcid_id):
+ return None
+
+ researcher = db.query(Researcher).filter(Researcher.orcid_id == orcid_id).first()
+ if not researcher or not researcher.authenticated:
+ return None
+
+ request.state.researcher = researcher
+ return researcher
diff --git a/frontend/public/eprints-logo.svg b/frontend/public/eprints-logo.svg
new file mode 100644
index 0000000..1ef81d4
--- /dev/null
+++ b/frontend/public/eprints-logo.svg
@@ -0,0 +1 @@
+eprints-logo
\ No newline at end of file
diff --git a/frontend/src/components/dashboard/ExportDropdown.jsx b/frontend/src/components/dashboard/ExportDropdown.jsx
index 954d071..d9a5c23 100644
--- a/frontend/src/components/dashboard/ExportDropdown.jsx
+++ b/frontend/src/components/dashboard/ExportDropdown.jsx
@@ -1,40 +1,17 @@
-import { useEffect, useRef, useState } from "react";
import {
- ChevronDownIcon,
- DocumentIcon,
DownloadIcon,
- PackageIcon,
SparkleIcon,
} from "../ui/Icons";
import { Spinner } from "../ui/Spinner";
import { SwordProfileSelect } from "./SwordProfileSelect";
-import { DEFAULT_EXPORT_PROFILE } from "../../utils/exportProfiles";
-
-const FORMATS = [
- {
- format: "xml",
- icon: ,
- label: "SWORD XML",
- desc: "Según destino seleccionado",
- },
- {
- format: "zip",
- icon: ,
- label: "Paquete ZIP",
- desc: "XML + ficheros adjuntos",
- },
-];
+import {
+ DEFAULT_EXPORT_DESTINATION,
+ resolveExportFromDestination,
+} from "../../utils/exportProfiles";
/**
- * SWORD export dropdown. Delegatea the actual download to `onExport(format)`.
- *
- * Props:
- * - `isAuthenticated` → cambia el texto del botón principal.
- * - `newPublicationsCount` → cuántas publicaciones tiene downloaded_by_me=false.
- * - `selectedCount` → publicaciones seleccionadas manualmente.
- * - `exportingFormat` → formato en curso (pone el botón en loading).
- * - `swordProfile` → perfil SWORD (dublin_core, dspace, eprints…).
- * - `onSwordProfileChange` → callback al cambiar destino.
+ * Controles de exportación: selector de destino + botón único de descarga.
+ * Delega la descarga en `onExport(format, profile)`.
*/
export function ExportDropdown({
onExport,
@@ -42,38 +19,24 @@ export function ExportDropdown({
selectedCount = 0,
isAuthenticated = false,
newPublicationsCount = 0,
- swordProfile = DEFAULT_EXPORT_PROFILE,
- onSwordProfileChange,
+ exportDestination = DEFAULT_EXPORT_DESTINATION,
+ onExportDestinationChange,
}) {
- const [open, setOpen] = useState(false);
- const rootRef = useRef(null);
-
- useEffect(() => {
- function handleClick(event) {
- if (rootRef.current && !rootRef.current.contains(event.target)) {
- setOpen(false);
- }
- }
- document.addEventListener("mousedown", handleClick);
- return () => document.removeEventListener("mousedown", handleClick);
- }, []);
-
const isBusy = Boolean(exportingFormat);
const hasSelection = selectedCount > 0;
- function handlePick(format) {
- setOpen(false);
- onExport(format, format === "xml" ? swordProfile : undefined);
+ const nothingToDownload =
+ isAuthenticated && !hasSelection && newPublicationsCount === 0;
+
+ function handleDownload() {
+ const { format, profile } = resolveExportFromDestination(exportDestination);
+ onExport(format, profile);
}
- // Label logic:
- // manual selection → always "Exportar seleccionadas (N)"
- // logged in, no selection → "Descargar lo nuevo (N)" or "Todo descargado"
- // not logged in, no selection → "Descargar todo"
let idleLabel;
let showSparkle = false;
if (hasSelection) {
- idleLabel = `Exportar seleccionadas (${selectedCount})`;
+ idleLabel = `Descargar selección (${selectedCount})`;
} else if (isAuthenticated) {
if (newPublicationsCount > 0) {
idleLabel = `Descargar lo nuevo (${newPublicationsCount})`;
@@ -86,18 +49,18 @@ export function ExportDropdown({
}
return (
-
+
-
setOpen((o) => !o)}
- disabled={isBusy || (isAuthenticated && !hasSelection && newPublicationsCount === 0)}
+ onClick={handleDownload}
+ disabled={isBusy || nothingToDownload}
className="inline-flex items-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-[18px] py-2.5 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-70"
>
{isBusy ? (
@@ -107,37 +70,8 @@ export function ExportDropdown({
) : (
)}
- {isBusy
- ? `Exportando ${exportingFormat.toUpperCase()}...`
- : idleLabel}
- {!isBusy && }
+ {isBusy ? `Descargando ${exportingFormat.toUpperCase()}...` : idleLabel}
-
- {open && (
-
- {FORMATS.map(({ format, icon, label, desc }, idx) => (
-
handlePick(format)}
- className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-surface-secondary ${
- idx < FORMATS.length - 1
- ? "border-b border-surface-border/60"
- : ""
- }`}
- >
- {icon}
-
-
- ))}
-
- )}
-
);
}
diff --git a/frontend/src/components/dashboard/PublicationsTable.jsx b/frontend/src/components/dashboard/PublicationsTable.jsx
index 63509f4..3f7191e 100644
--- a/frontend/src/components/dashboard/PublicationsTable.jsx
+++ b/frontend/src/components/dashboard/PublicationsTable.jsx
@@ -1,5 +1,6 @@
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";
@@ -70,11 +71,9 @@ function TriStateCheckbox({ checked, indeterminate = false, onChange, ariaLabel
* 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.
+ * - 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,
@@ -151,20 +150,19 @@ export function PublicationsTable({
return filtered.slice(start, start + PAGE_SIZE);
}, [filtered, currentPage]);
- const selectionStats = useMemo(() => {
- if (filtered.length === 0) {
- return { allChecked: false, anyChecked: false, selectedInFiltered: 0 };
+ const pageSelectionStats = useMemo(() => {
+ if (pageRows.length === 0) {
+ return { allChecked: false, anyChecked: false };
}
let count = 0;
- for (const pub of filtered) {
+ for (const pub of pageRows) {
if (selectedIds.has(pub.id)) count += 1;
}
return {
- allChecked: count === filtered.length,
+ allChecked: count === pageRows.length,
anyChecked: count > 0,
- selectedInFiltered: count,
};
- }, [filtered, selectedIds]);
+ }, [pageRows, selectedIds]);
function toggleSort(key) {
if (sortKey === key) {
@@ -188,12 +186,12 @@ export function PublicationsTable({
emit(next);
}
- function toggleAllFiltered() {
+ function toggleCurrentPage() {
const next = new Set(selectedIds);
- if (selectionStats.allChecked) {
- for (const pub of filtered) next.delete(pub.id);
+ if (pageSelectionStats.allChecked) {
+ for (const pub of pageRows) next.delete(pub.id);
} else {
- for (const pub of filtered) next.add(pub.id);
+ for (const pub of pageRows) next.add(pub.id);
}
emit(next);
}
@@ -314,20 +312,16 @@ export function PublicationsTable({
>
Desde año
-
handleYearFromChange(e.target.value)}
+ onChange={handleYearFromChange}
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"
- >
- Cualquiera
- {availableYears.map((y) => (
-
- {y}
-
- ))}
-
+ options={availableYears.map((y) => ({
+ value: String(y),
+ label: String(y),
+ }))}
+ />
Hasta año
- handleYearToChange(e.target.value)}
+ onChange={handleYearToChange}
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"
- >
- Cualquiera
- {availableYears.map((y) => (
-
- {y}
-
- ))}
-
+ options={availableYears.map((y) => ({
+ value: String(y),
+ label: String(y),
+ }))}
+ />
{hasYearFilter && (
e.stopPropagation()}
>
{COLUMNS.map((col) => (
diff --git a/frontend/src/components/dashboard/ResearcherCard.jsx b/frontend/src/components/dashboard/ResearcherCard.jsx
index 7328cfd..c869fbf 100644
--- a/frontend/src/components/dashboard/ResearcherCard.jsx
+++ b/frontend/src/components/dashboard/ResearcherCard.jsx
@@ -44,7 +44,7 @@ export function ResearcherCard({ researcher, actions = null }) {
{actions && (
-
+
{actions}
)}
diff --git a/frontend/src/components/dashboard/SwordProfileSelect.jsx b/frontend/src/components/dashboard/SwordProfileSelect.jsx
index 5cf4dbf..77c7fc1 100644
--- a/frontend/src/components/dashboard/SwordProfileSelect.jsx
+++ b/frontend/src/components/dashboard/SwordProfileSelect.jsx
@@ -1,35 +1,105 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { ChevronDownIcon } from "../ui/Icons";
+import { ExportProfileIcon } from "../ui/destination-logos/ExportProfileIcon";
import {
DEFAULT_EXPORT_PROFILE,
EXPORT_PROFILE_OPTIONS,
+ ZIP_DESTINATION_OPTION,
} from "../../utils/exportProfiles";
/**
- * Selector de destino para exportación SWORD XML (DSpace, EPrints, Dublin Core…).
+ * Selector de destino para exportación (perfiles SWORD XML y, opcionalmente, ZIP).
*/
export function SwordProfileSelect({
value = DEFAULT_EXPORT_PROFILE,
onChange,
id = "sword-export-profile",
className = "",
+ includeZip = false,
}) {
+ const [open, setOpen] = useState(false);
+ const rootRef = useRef(null);
+
+ const options = useMemo(() => {
+ const items = [...EXPORT_PROFILE_OPTIONS];
+ if (includeZip) {
+ items.push(ZIP_DESTINATION_OPTION);
+ }
+ return items;
+ }, [includeZip]);
+
+ const selected = options.find((opt) => opt.value === value) ?? options[0];
+
+ useEffect(() => {
+ function handleClick(event) {
+ if (rootRef.current && !rootRef.current.contains(event.target)) {
+ setOpen(false);
+ }
+ }
+ document.addEventListener("mousedown", handleClick);
+ return () => document.removeEventListener("mousedown", handleClick);
+ }, []);
+
+ function handlePick(optionValue) {
+ onChange(optionValue);
+ setOpen(false);
+ }
+
return (
-
Destino:
- onChange(event.target.value)}
- className="min-w-[9.5rem] rounded-lg border border-surface-border-strong bg-surface-primary px-2.5 py-2 text-sm font-medium text-ink-primary transition-colors hover:bg-surface-secondary focus:border-brand-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
- >
- {EXPORT_PROFILE_OPTIONS.map(({ value: optionValue, label }) => (
-
- {label}
-
- ))}
-
-
+
+
setOpen((o) => !o)}
+ className="inline-flex min-w-44 items-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-3 py-2.5 text-sm font-medium text-ink-primary transition-colors hover:bg-surface-secondary"
+ >
+
+ {selected.label}
+
+
+
+ {open && (
+
+ {options.map(({ value: optionValue, label, desc }, idx) => (
+
handlePick(optionValue)}
+ className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-surface-secondary ${
+ optionValue === value ? "bg-surface-secondary/70" : ""
+ } ${
+ idx < options.length - 1
+ ? "border-b border-surface-border/60"
+ : ""
+ }`}
+ >
+
+
+
+ {label}
+
+
+ {desc}
+
+
+
+ ))}
+
+ )}
+
+
);
}
diff --git a/frontend/src/components/ui/CustomSelect.jsx b/frontend/src/components/ui/CustomSelect.jsx
new file mode 100644
index 0000000..8597d01
--- /dev/null
+++ b/frontend/src/components/ui/CustomSelect.jsx
@@ -0,0 +1,86 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { ChevronDownIcon } from "./Icons";
+
+/**
+ * Desplegable personalizado (sustituto del `` nativo).
+ */
+export function CustomSelect({
+ id,
+ value,
+ onChange,
+ options = [],
+ disabled = false,
+ emptyLabel = "Cualquiera",
+ className = "",
+ menuClassName = "",
+}) {
+ const [open, setOpen] = useState(false);
+ const rootRef = useRef(null);
+
+ const allOptions = useMemo(
+ () => [{ value: "", label: emptyLabel }, ...options],
+ [options, emptyLabel],
+ );
+
+ const selected =
+ allOptions.find((opt) => opt.value === value) ?? allOptions[0];
+
+ useEffect(() => {
+ function handleClick(event) {
+ if (rootRef.current && !rootRef.current.contains(event.target)) {
+ setOpen(false);
+ }
+ }
+ document.addEventListener("mousedown", handleClick);
+ return () => document.removeEventListener("mousedown", handleClick);
+ }, []);
+
+ function handlePick(optionValue) {
+ onChange(optionValue);
+ setOpen(false);
+ }
+
+ return (
+
+
!disabled && setOpen((o) => !o)}
+ className="inline-flex w-full min-w-[7.5rem] items-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-3 py-2 text-[13px] font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-50"
+ >
+ {selected.label}
+
+
+
+ {open && !disabled && (
+
+ {allOptions.map((opt) => (
+ handlePick(opt.value)}
+ className={`flex w-full px-3 py-2 text-left text-[13px] transition-colors hover:bg-surface-secondary ${
+ opt.value === value
+ ? "bg-surface-secondary/80 font-medium text-ink-primary"
+ : "text-ink-secondary"
+ }`}
+ >
+ {opt.label}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ui/destination-logos/DSpaceLogo.jsx b/frontend/src/components/ui/destination-logos/DSpaceLogo.jsx
new file mode 100644
index 0000000..68d9e7a
--- /dev/null
+++ b/frontend/src/components/ui/destination-logos/DSpaceLogo.jsx
@@ -0,0 +1,18 @@
+import { DSPACE_LOGO_PATH, DSPACE_VIEWBOX } from "./dspace-path";
+
+/** Logotipo oficial DSpace (#92C642). */
+export function DSpaceLogo({ size = 20, className = "" }) {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/ui/destination-logos/DublinCoreLogo.jsx b/frontend/src/components/ui/destination-logos/DublinCoreLogo.jsx
new file mode 100644
index 0000000..2880590
--- /dev/null
+++ b/frontend/src/components/ui/destination-logos/DublinCoreLogo.jsx
@@ -0,0 +1,48 @@
+/**
+ * Logotipo DCMI Dublin Core: círculo central y anillos de puntos sobre fondo naranja.
+ * Réplica del icono oficial (sunburst).
+ */
+
+const ORANGE = "#FF6600";
+const CENTER = 50;
+const INNER = { count: 12, radius: 22, dotR: 4.8 };
+const OUTER = { count: 16, radius: 36, dotR: 3.6 };
+const CORE_R = 11.5;
+
+function ringDots({ count, radius, dotR }) {
+ return Array.from({ length: count }, (_, i) => {
+ const angle = (i / count) * Math.PI * 2 - Math.PI / 2;
+ return {
+ key: `${radius}-${i}`,
+ cx: CENTER + radius * Math.cos(angle),
+ cy: CENTER + radius * Math.sin(angle),
+ r: dotR,
+ };
+ });
+}
+
+export function DublinCoreLogo({ size = 20, className = "" }) {
+ const innerDots = ringDots(INNER);
+ const outerDots = ringDots(OUTER);
+
+ return (
+
+
+
+ {innerDots.map(({ key, cx, cy, r }) => (
+
+ ))}
+ {outerDots.map(({ key, cx, cy, r }) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/ui/destination-logos/EPrintsLogo.jsx b/frontend/src/components/ui/destination-logos/EPrintsLogo.jsx
new file mode 100644
index 0000000..cee3131
--- /dev/null
+++ b/frontend/src/components/ui/destination-logos/EPrintsLogo.jsx
@@ -0,0 +1,19 @@
+/**
+ * Logotipo EPrints (vectorizado en `public/eprints-logo.svg`).
+ */
+const EPRINTS_LOGO_SRC = `${import.meta.env.BASE_URL}eprints-logo.svg`;
+
+export function EPrintsLogo({ size = 20, className = "" }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/ui/destination-logos/ExportProfileIcon.jsx b/frontend/src/components/ui/destination-logos/ExportProfileIcon.jsx
new file mode 100644
index 0000000..5baff7a
--- /dev/null
+++ b/frontend/src/components/ui/destination-logos/ExportProfileIcon.jsx
@@ -0,0 +1,26 @@
+import { DocumentIcon, PackageIcon } from "../Icons";
+import { OrcidLogo } from "../OrcidLogo";
+import { DublinCoreLogo } from "./DublinCoreLogo";
+import { DSpaceLogo } from "./DSpaceLogo";
+import { EPrintsLogo } from "./EPrintsLogo";
+import { EXPORT_ZIP_DESTINATION } from "../../../utils/exportProfiles";
+
+/**
+ * Icono del destino de exportación (logos de repositorio o genérico).
+ */
+export function ExportProfileIcon({ profile, size = 20, className = "shrink-0" }) {
+ switch (profile) {
+ case "generic":
+ return ;
+ case "dublin_core":
+ return ;
+ case "dspace":
+ return ;
+ case "eprints":
+ return ;
+ case EXPORT_ZIP_DESTINATION:
+ return ;
+ default:
+ return ;
+ }
+}
diff --git a/frontend/src/components/ui/destination-logos/dspace-path.js b/frontend/src/components/ui/destination-logos/dspace-path.js
new file mode 100644
index 0000000..1f87ed6
--- /dev/null
+++ b/frontend/src/components/ui/destination-logos/dspace-path.js
@@ -0,0 +1,5 @@
+/** Path oficial del logotipo DSpace (ver logos_preview_dropdown.html). */
+export const DSPACE_LOGO_PATH =
+ "M120.726,58.569l0.11-0.006l0.116-0.01l0.106-0.013l0.111-0.01l0.11-0.023l0.109-0.019l0.107-0.023l0.106-0.029l0.106-0.023l0.106-0.033l0.103-0.034l0.096-0.035l0.104-0.04l0.101-0.042l0.099-0.042v-0.001l0.096-0.045v0l0.095-0.044l0.096-0.049l0.091-0.056v-0.001l0.094-0.05v-0.002l0.09-0.056v-0.001l0.092-0.06l0.083-0.056v-0.001l0.085-0.063l0.088-0.065v-0.002l0.087-0.063v-0.001c0.817-0.683,1.393-1.646,1.561-2.738l0.012-0.104v-0.009l0.014-0.101v-0.011l0.009-0.098v-0.012l0.009-0.101V54.38l0.005-0.095v-0.016l0.002-0.105v-16.46l-0.002-0.105v-0.016l-0.005-0.095v-0.013l-0.009-0.101v-0.012l-0.009-0.098v-0.011l-0.014-0.1v-0.01l-0.012-0.104c-0.167-1.092-0.744-2.057-1.561-2.738v-0.001l-0.087-0.063v-0.002l-0.088-0.065l-0.085-0.063v-0.001l-0.083-0.056l-0.092-0.061v0l-0.09-0.056v-0.003l-0.094-0.05v-0.001l-0.091-0.056l-0.096-0.049l-0.095-0.043v-0.001l-0.096-0.045v-0.001l-0.099-0.043l-0.101-0.042l-0.104-0.04l-0.096-0.035l-0.103-0.031l-0.106-0.036l-0.106-0.023l-0.106-0.028l-0.107-0.024l-0.109-0.019l-0.11-0.023l-0.111-0.009l-0.106-0.014l-0.116-0.01l-0.11-0.006l-0.114-0.005h-7.89c-9.715,0-15.858-7.838-15.858-17.15V6.92c0-3.812-3.102-6.915-6.914-6.915H74.085c-3.814,0-6.92,3.106-6.92,6.915v16.682c0,3.806,3.104,6.909,6.92,6.909h8.414c9.169,0,16.906,5.95,17.146,15.403v0.04c-0.24,9.453-7.977,15.402-17.146,15.402h-8.414c-3.816,0-6.92,3.103-6.92,6.909v16.682c0,3.809,3.106,6.915,6.92,6.915H89.95c3.812,0,6.914-3.104,6.914-6.915v-9.223c0-9.312,6.144-17.149,15.858-17.149h7.89L120.726,58.569z M154.772,9.956C148.631,3.814,140.15,0,130.816,0h-15.024v17.424h15.024c4.527,0,8.648,1.858,11.64,4.849c2.99,2.99,4.848,7.112,4.848,11.639v24.042c0,4.538-1.852,8.665-4.832,11.655l-0.016-0.016c-2.991,2.991-7.113,4.849-11.64,4.849h-15.024v17.424h15.024c9.333,0,17.815-3.814,23.956-9.956v-0.033c6.142-6.143,9.955-14.614,9.955-23.923V33.912C164.727,24.578,160.914,16.097,154.772,9.956z";
+
+export const DSPACE_VIEWBOX = "67 0 98 93";
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx
index 2fd3e27..bedf4dd 100644
--- a/frontend/src/pages/DashboardPage.jsx
+++ b/frontend/src/pages/DashboardPage.jsx
@@ -16,7 +16,10 @@ import {
syncResearcher,
} from "../services/api";
import { isValidOrcid } from "../utils/orcid";
-import { DEFAULT_EXPORT_PROFILE, swordXmlFilename } from "../utils/exportProfiles";
+import {
+ DEFAULT_EXPORT_DESTINATION,
+ swordXmlFilename,
+} from "../utils/exportProfiles";
import { useAuth } from "../contexts/AuthContext";
const SUCCESS_FLASH_MS = 3000;
@@ -50,7 +53,9 @@ export function DashboardPage() {
const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success
const [exportingFormat, setExportingFormat] = useState(null);
- const [swordProfile, setSwordProfile] = useState(DEFAULT_EXPORT_PROFILE);
+ const [exportDestination, setExportDestination] = useState(
+ DEFAULT_EXPORT_DESTINATION,
+ );
const [selectedIds, setSelectedIds] = useState(() => new Set());
@@ -140,7 +145,7 @@ export function DashboardPage() {
}
}
- async function handleExport(format, profile = DEFAULT_EXPORT_PROFILE) {
+ async function handleExport(format, profile = DEFAULT_EXPORT_DESTINATION) {
setExportingFormat(format);
try {
let ids;
@@ -226,8 +231,8 @@ export function DashboardPage() {
selectedCount={selectedIds.size}
isAuthenticated={isAuthenticated}
newPublicationsCount={newPublicationIds.length}
- swordProfile={swordProfile}
- onSwordProfileChange={setSwordProfile}
+ exportDestination={exportDestination}
+ onExportDestinationChange={setExportDestination}
/>
>
}
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
index e6da373..2045fa0 100644
--- a/frontend/src/services/api.js
+++ b/frontend/src/services/api.js
@@ -129,7 +129,19 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
const detail =
payload?.detail ?? payload?.message ?? response.statusText ?? "Error";
- throw new ApiError(typeof detail === "string" ? detail : "Error de API", {
+ const detailText = typeof detail === "string" ? detail : "Error de API";
+
+ // Sesión caducada: no bloquear rutas públicas; el backend ya ignora Bearer inválido
+ // en búsqueda, pero otras rutas pueden seguir devolviendo 401.
+ if (
+ response.status === 401 &&
+ /invalid|expired|token/i.test(detailText) &&
+ localStorage.getItem("orcid_auth_token")
+ ) {
+ localStorage.removeItem("orcid_auth_token");
+ }
+
+ throw new ApiError(detailText, {
status: response.status,
payload,
});
diff --git a/frontend/src/utils/exportProfiles.js b/frontend/src/utils/exportProfiles.js
index 1ab647d..71fa4a3 100644
--- a/frontend/src/utils/exportProfiles.js
+++ b/frontend/src/utils/exportProfiles.js
@@ -1,12 +1,45 @@
/** Perfiles de exportación SWORD XML (query `profile` en el backend). */
export const EXPORT_PROFILE_OPTIONS = [
- { value: "generic", label: "Genérico (ORCID)" },
- { value: "dublin_core", label: "Dublin Core" },
- { value: "dspace", label: "DSpace" },
- { value: "eprints", label: "EPrints" },
+ {
+ value: "generic",
+ label: "Genérico (ORCID)",
+ desc: "Metadatos ORCID en SWORD XML",
+ },
+ {
+ value: "dublin_core",
+ label: "Dublin Core",
+ desc: "Esquema Dublin Core",
+ },
+ {
+ value: "dspace",
+ label: "DSpace",
+ desc: "Compatible con repositorio DSpace",
+ },
+ {
+ value: "eprints",
+ label: "EPrints",
+ desc: "Compatible con repositorio EPrints",
+ },
];
+export const EXPORT_ZIP_DESTINATION = "zip";
+
+export const ZIP_DESTINATION_OPTION = {
+ value: EXPORT_ZIP_DESTINATION,
+ label: "Paquete ZIP",
+ desc: "Todos los formatos en un único paquete",
+};
+
export const DEFAULT_EXPORT_PROFILE = "generic";
+export const DEFAULT_EXPORT_DESTINATION = DEFAULT_EXPORT_PROFILE;
+
+/** Convierte el valor del selector de destino en formato + perfil SWORD. */
+export function resolveExportFromDestination(destination) {
+ if (destination === EXPORT_ZIP_DESTINATION) {
+ return { format: "zip", profile: undefined };
+ }
+ return { format: "xml", profile: destination };
+}
export function swordXmlFilename(baseName, profile = DEFAULT_EXPORT_PROFILE) {
const suffix =