From 02c65bb710ba3385c95b8e02aab713551239b92f Mon Sep 17 00:00:00 2001 From: Alexis Date: Mon, 1 Jun 2026 13:12:55 +0200 Subject: [PATCH 1/3] feat(export): mejora en el selector de destino y manejo de exportaciones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Se actualiza el componente ExportDropdown para incluir un selector de destino que permite elegir entre diferentes perfiles de exportación, incluyendo la opción de ZIP. Se mejora la lógica de descarga y se ajusta el componente SwordProfileSelect para manejar la selección de perfiles de exportación. Además, se realizan cambios en la página Dashboard para integrar el nuevo sistema de exportación. --- backend/app/security/jwt.py | 28 ++++- frontend/public/eprints-logo.svg | 1 + .../components/dashboard/ExportDropdown.jsx | 112 ++++-------------- .../components/dashboard/ResearcherCard.jsx | 2 +- .../dashboard/SwordProfileSelect.jsx | 100 +++++++++++++--- .../ui/destination-logos/DSpaceLogo.jsx | 18 +++ .../ui/destination-logos/DublinCoreLogo.jsx | 48 ++++++++ .../ui/destination-logos/EPrintsLogo.jsx | 19 +++ .../destination-logos/ExportProfileIcon.jsx | 26 ++++ .../ui/destination-logos/dspace-path.js | 5 + frontend/src/pages/DashboardPage.jsx | 15 ++- frontend/src/services/api.js | 14 ++- frontend/src/utils/exportProfiles.js | 41 ++++++- 13 files changed, 309 insertions(+), 120 deletions(-) create mode 100644 frontend/public/eprints-logo.svg create mode 100644 frontend/src/components/ui/destination-logos/DSpaceLogo.jsx create mode 100644 frontend/src/components/ui/destination-logos/DublinCoreLogo.jsx create mode 100644 frontend/src/components/ui/destination-logos/EPrintsLogo.jsx create mode 100644 frontend/src/components/ui/destination-logos/ExportProfileIcon.jsx create mode 100644 frontend/src/components/ui/destination-logos/dspace-path.js 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 ( -
+
-
- - {open && ( -
- {FORMATS.map(({ format, icon, label, desc }, idx) => ( - - ))} -
- )} -
); } 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..ce8e4f4 100644 --- a/frontend/src/components/dashboard/SwordProfileSelect.jsx +++ b/frontend/src/components/dashboard/SwordProfileSelect.jsx @@ -1,35 +1,103 @@ +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 ( - +
+ + + {open && ( +
+ {options.map(({ value: optionValue, label, desc }, idx) => ( + + ))} +
+ )} +
+
); } 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 = From 552254d4a82ced46f6becdb31e8c5a6943587038 Mon Sep 17 00:00:00 2001 From: Alexis Date: Mon, 1 Jun 2026 13:33:36 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(ui):=20mejora=20en=20el=20selector=20d?= =?UTF-8?q?e=20a=C3=B1os=20en=20PublicationsTable=20y=20ajustes=20en=20Swo?= =?UTF-8?q?rdProfileSelect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Se reemplaza el elemento 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" - > - - {availableYears.map((y) => ( - - ))} - + options={availableYears.map((y) => ({ + value: String(y), + label: String(y), + }))} + />
- + options={availableYears.map((y) => ({ + value: String(y), + label: String(y), + }))} + />
{hasYearFilter && ( ))} 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 `