Merge branch 'style/rework-dashboard-header' into 'main'

feat(export): mejora en el selector de destino y manejo de exportaciones

See merge request fjmimbre/orcid_system!2
This commit is contained in:
alexis
2026-06-01 11:48:28 +00:00
15 changed files with 429 additions and 162 deletions
+24 -4
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
<svg id="Capa_3" data-name="Capa 3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 497"><defs><style>.cls-1{fill:#1b4a73;}.cls-2{fill:#f47d21;}</style></defs><title>eprints-logo</title><polygon class="cls-1" points="214 0 0 0 0 497 360 497 360 131 326 131 326 465 34 465 34 33 214 33 214 0"/><polygon class="cls-2" points="245 0 245 106 360 106 245 0"/><path class="cls-1" d="M198,267.55c.93,58,35.71,81.84,77,81.84,29.21,0,47.29-5.36,62.13-12.18l7.42,30.69c-14.37,6.82-39.41,15.1-75.12,15.1C200.27,383,159,334.77,159,263.65S198.88,137,264.26,137C338,137,357,204.22,357,247.58c0,8.77-.46,15.1-1.39,20Zm119.64-30.69c.46-26.79-10.67-69.17-56.58-69.17-41.73,0-59.35,39.46-62.6,69.17Z" transform="translate(-81.5 -7.5)"/></svg>

After

Width:  |  Height:  |  Size: 728 B

@@ -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: <DocumentIcon size={20} className="shrink-0 text-ink-secondary" />,
label: "SWORD XML",
desc: "Según destino seleccionado",
},
{
format: "zip",
icon: <PackageIcon size={20} className="shrink-0 text-ink-secondary" />,
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 (
<div className="flex flex-wrap items-center justify-end gap-2">
<div className="flex flex-wrap items-center justify-end gap-2 sm:flex-nowrap">
<SwordProfileSelect
id="dashboard-sword-profile"
value={swordProfile}
onChange={onSwordProfileChange}
id="dashboard-export-destination"
value={exportDestination}
onChange={onExportDestinationChange}
includeZip
/>
<div className="relative" ref={rootRef}>
<button
type="button"
onClick={() => 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({
) : (
<DownloadIcon />
)}
{isBusy
? `Exportando ${exportingFormat.toUpperCase()}...`
: idleLabel}
{!isBusy && <ChevronDownIcon />}
{isBusy ? `Descargando ${exportingFormat.toUpperCase()}...` : idleLabel}
</button>
{open && (
<div className="absolute right-0 top-[calc(100%+6px)] z-50 min-w-[210px] overflow-hidden rounded-xl border border-surface-border-strong bg-surface-primary shadow-lg">
{FORMATS.map(({ format, icon, label, desc }, idx) => (
<button
key={format}
type="button"
onClick={() => 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}
<div>
<div className="text-sm font-medium text-ink-primary">
{label}
</div>
<div className="text-xs text-ink-tertiary">{desc}</div>
</div>
</button>
))}
</div>
)}
</div>
</div>
);
}
@@ -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
</label>
<select
<CustomSelect
id="year-from"
value={yearFrom}
onChange={(e) => 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"
>
<option value="">Cualquiera</option>
{availableYears.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
options={availableYears.map((y) => ({
value: String(y),
label: String(y),
}))}
/>
</div>
<div className="flex flex-col gap-1">
<label
@@ -336,20 +330,16 @@ export function PublicationsTable({
>
Hasta año
</label>
<select
<CustomSelect
id="year-to"
value={yearTo}
onChange={(e) => 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"
>
<option value="">Cualquiera</option>
{availableYears.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
options={availableYears.map((y) => ({
value: String(y),
label: String(y),
}))}
/>
</div>
{hasYearFilter && (
<button
@@ -385,10 +375,10 @@ export function PublicationsTable({
onClick={(e) => e.stopPropagation()}
>
<TriStateCheckbox
checked={selectionStats.allChecked}
indeterminate={selectionStats.anyChecked}
onChange={toggleAllFiltered}
ariaLabel="Seleccionar todas las publicaciones del filtro actual"
checked={pageSelectionStats.allChecked}
indeterminate={pageSelectionStats.anyChecked}
onChange={toggleCurrentPage}
ariaLabel="Seleccionar todas las publicaciones de esta página"
/>
</th>
{COLUMNS.map((col) => (
@@ -44,7 +44,7 @@ export function ResearcherCard({ researcher, actions = null }) {
</div>
{actions && (
<div className="flex shrink-0 flex-wrap items-center gap-2.5">
<div className="ml-auto flex shrink-0 flex-col items-end gap-2.5">
{actions}
</div>
)}
@@ -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 (
<label
htmlFor={id}
<div
ref={rootRef}
className={`flex items-center gap-2 text-sm ${className}`.trim()}
>
<span className="whitespace-nowrap text-ink-tertiary">Destino:</span>
<select
id={id}
value={value}
onChange={(event) => 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 }) => (
<option key={optionValue} value={optionValue}>
{label}
</option>
))}
</select>
</label>
<div className="relative">
<button
type="button"
id={id}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => 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"
>
<ExportProfileIcon profile={selected.value} size={20} />
<span className="truncate">{selected.label}</span>
<ChevronDownIcon className="ml-auto shrink-0" />
</button>
{open && (
<div
role="listbox"
aria-labelledby={id}
className="absolute left-0 top-[calc(100%+6px)] z-50 min-w-80 overflow-hidden rounded-xl border border-surface-border-strong bg-surface-primary shadow-lg"
>
{options.map(({ value: optionValue, label, desc }, idx) => (
<button
key={optionValue}
type="button"
role="option"
aria-selected={optionValue === value}
onClick={() => 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"
: ""
}`}
>
<ExportProfileIcon profile={optionValue} size={20} />
<div className="min-w-0">
<div className="text-sm font-medium text-ink-primary">
{label}
</div>
<div className="whitespace-nowrap text-xs text-ink-tertiary">
{desc}
</div>
</div>
</button>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,86 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronDownIcon } from "./Icons";
/**
* Desplegable personalizado (sustituto del `<select>` 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 (
<div ref={rootRef} className={`relative ${className}`.trim()}>
<button
type="button"
id={id}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => !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"
>
<span className="truncate">{selected.label}</span>
<ChevronDownIcon
className={`ml-auto shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
{open && !disabled && (
<div
role="listbox"
aria-labelledby={id}
className={`absolute left-0 top-[calc(100%+4px)] z-50 max-h-56 min-w-full overflow-auto rounded-xl border border-surface-border-strong bg-surface-primary py-1 shadow-lg ${menuClassName}`.trim()}
>
{allOptions.map((opt) => (
<button
key={opt.value || "__any__"}
type="button"
role="option"
aria-selected={opt.value === value}
onClick={() => 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}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,18 @@
import { DSPACE_LOGO_PATH, DSPACE_VIEWBOX } from "./dspace-path";
/** Logotipo oficial DSpace (#92C642). */
export function DSpaceLogo({ size = 20, className = "" }) {
return (
<svg
viewBox={DSPACE_VIEWBOX}
width={size}
height={size}
className={className}
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="DSpace"
>
<path fill="#92C642" d={DSPACE_LOGO_PATH} />
</svg>
);
}
@@ -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 (
<svg
viewBox="0 0 100 100"
width={size}
height={size}
className={className}
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Dublin Core"
>
<rect width="100" height="100" fill={ORANGE} />
<circle cx={CENTER} cy={CENTER} r={CORE_R} fill="#fff" />
{innerDots.map(({ key, cx, cy, r }) => (
<circle key={key} cx={cx} cy={cy} r={r} fill="#fff" />
))}
{outerDots.map(({ key, cx, cy, r }) => (
<circle key={key} cx={cx} cy={cy} r={r} fill="#fff" />
))}
</svg>
);
}
@@ -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 (
<img
src={EPRINTS_LOGO_SRC}
width={size}
height={size}
className={className}
alt=""
aria-label="EPrints"
decoding="async"
draggable={false}
/>
);
}
@@ -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 <OrcidLogo size={size} className={className} />;
case "dublin_core":
return <DublinCoreLogo size={size} className={className} />;
case "dspace":
return <DSpaceLogo size={size} className={className} />;
case "eprints":
return <EPrintsLogo size={15} className={className} />;
case EXPORT_ZIP_DESTINATION:
return <PackageIcon size={size} className={`text-ink-secondary ${className}`} />;
default:
return <DocumentIcon size={size} className={`text-ink-secondary ${className}`} />;
}
}
@@ -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";
+10 -5
View File
@@ -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}
/>
</>
}
+13 -1
View File
@@ -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,
});
+37 -4
View File
@@ -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 =