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), db: Session = Depends(get_db),
) -> Researcher | None: ) -> Researcher | None:
""" """
Devuelve el investigador autenticado si hay Bearer válido. Devuelve el investigador autenticado si hay Bearer válido y la sesión sigue activa.
Si no hay Bearer, devuelve None.
Si hay Bearer inválido, lanza 401 (no se acepta como anónimo). 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: if not creds or not creds.credentials:
return None 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 { import {
ChevronDownIcon,
DocumentIcon,
DownloadIcon, DownloadIcon,
PackageIcon,
SparkleIcon, SparkleIcon,
} from "../ui/Icons"; } from "../ui/Icons";
import { Spinner } from "../ui/Spinner"; import { Spinner } from "../ui/Spinner";
import { SwordProfileSelect } from "./SwordProfileSelect"; import { SwordProfileSelect } from "./SwordProfileSelect";
import { DEFAULT_EXPORT_PROFILE } from "../../utils/exportProfiles"; import {
DEFAULT_EXPORT_DESTINATION,
const FORMATS = [ resolveExportFromDestination,
{ } from "../../utils/exportProfiles";
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",
},
];
/** /**
* SWORD export dropdown. Delegatea the actual download to `onExport(format)`. * Controles de exportación: selector de destino + botón único de descarga.
* * Delega la descarga en `onExport(format, profile)`.
* 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.
*/ */
export function ExportDropdown({ export function ExportDropdown({
onExport, onExport,
@@ -42,38 +19,24 @@ export function ExportDropdown({
selectedCount = 0, selectedCount = 0,
isAuthenticated = false, isAuthenticated = false,
newPublicationsCount = 0, newPublicationsCount = 0,
swordProfile = DEFAULT_EXPORT_PROFILE, exportDestination = DEFAULT_EXPORT_DESTINATION,
onSwordProfileChange, 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 isBusy = Boolean(exportingFormat);
const hasSelection = selectedCount > 0; const hasSelection = selectedCount > 0;
function handlePick(format) { const nothingToDownload =
setOpen(false); isAuthenticated && !hasSelection && newPublicationsCount === 0;
onExport(format, format === "xml" ? swordProfile : undefined);
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 idleLabel;
let showSparkle = false; let showSparkle = false;
if (hasSelection) { if (hasSelection) {
idleLabel = `Exportar seleccionadas (${selectedCount})`; idleLabel = `Descargar selección (${selectedCount})`;
} else if (isAuthenticated) { } else if (isAuthenticated) {
if (newPublicationsCount > 0) { if (newPublicationsCount > 0) {
idleLabel = `Descargar lo nuevo (${newPublicationsCount})`; idleLabel = `Descargar lo nuevo (${newPublicationsCount})`;
@@ -86,18 +49,18 @@ export function ExportDropdown({
} }
return ( 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 <SwordProfileSelect
id="dashboard-sword-profile" id="dashboard-export-destination"
value={swordProfile} value={exportDestination}
onChange={onSwordProfileChange} onChange={onExportDestinationChange}
includeZip
/> />
<div className="relative" ref={rootRef}>
<button <button
type="button" type="button"
onClick={() => setOpen((o) => !o)} onClick={handleDownload}
disabled={isBusy || (isAuthenticated && !hasSelection && newPublicationsCount === 0)} 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" 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 ? ( {isBusy ? (
@@ -107,37 +70,8 @@ export function ExportDropdown({
) : ( ) : (
<DownloadIcon /> <DownloadIcon />
)} )}
{isBusy {isBusy ? `Descargando ${exportingFormat.toUpperCase()}...` : idleLabel}
? `Exportando ${exportingFormat.toUpperCase()}...`
: idleLabel}
{!isBusy && <ChevronDownIcon />}
</button> </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> </div>
); );
} }
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { AlertIcon, ChevronDownIcon, FilterIcon, SearchIcon, SparkleIcon } from "../ui/Icons"; import { AlertIcon, ChevronDownIcon, FilterIcon, SearchIcon, SparkleIcon } from "../ui/Icons";
import { CustomSelect } from "../ui/CustomSelect";
import { Spinner } from "../ui/Spinner"; import { Spinner } from "../ui/Spinner";
import { Badge } from "../ui/Badge"; 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. * retries and toasts can be handled in one place.
* *
* Selection semantics: * Selection semantics:
* - The master checkbox toggles the WHOLE currently-filtered set (not * - The master checkbox toggles only the rows on the current page.
* just the visible page). This matches the user mental model of * - Selection is stored by ID in the parent and persists across pages,
* "filtrar por 2024 → marcar todas de 2024". * filters and sorts so the user can select page by page.
* - Selection survives filter changes: the stored IDs remain even if
* those rows are no longer visible.
*/ */
export function PublicationsTable({ export function PublicationsTable({
publications, publications,
@@ -151,20 +150,19 @@ export function PublicationsTable({
return filtered.slice(start, start + PAGE_SIZE); return filtered.slice(start, start + PAGE_SIZE);
}, [filtered, currentPage]); }, [filtered, currentPage]);
const selectionStats = useMemo(() => { const pageSelectionStats = useMemo(() => {
if (filtered.length === 0) { if (pageRows.length === 0) {
return { allChecked: false, anyChecked: false, selectedInFiltered: 0 }; return { allChecked: false, anyChecked: false };
} }
let count = 0; let count = 0;
for (const pub of filtered) { for (const pub of pageRows) {
if (selectedIds.has(pub.id)) count += 1; if (selectedIds.has(pub.id)) count += 1;
} }
return { return {
allChecked: count === filtered.length, allChecked: count === pageRows.length,
anyChecked: count > 0, anyChecked: count > 0,
selectedInFiltered: count,
}; };
}, [filtered, selectedIds]); }, [pageRows, selectedIds]);
function toggleSort(key) { function toggleSort(key) {
if (sortKey === key) { if (sortKey === key) {
@@ -188,12 +186,12 @@ export function PublicationsTable({
emit(next); emit(next);
} }
function toggleAllFiltered() { function toggleCurrentPage() {
const next = new Set(selectedIds); const next = new Set(selectedIds);
if (selectionStats.allChecked) { if (pageSelectionStats.allChecked) {
for (const pub of filtered) next.delete(pub.id); for (const pub of pageRows) next.delete(pub.id);
} else { } else {
for (const pub of filtered) next.add(pub.id); for (const pub of pageRows) next.add(pub.id);
} }
emit(next); emit(next);
} }
@@ -314,20 +312,16 @@ export function PublicationsTable({
> >
Desde año Desde año
</label> </label>
<select <CustomSelect
id="year-from" id="year-from"
value={yearFrom} value={yearFrom}
onChange={(e) => handleYearFromChange(e.target.value)} onChange={handleYearFromChange}
disabled={availableYears.length === 0} 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" options={availableYears.map((y) => ({
> value: String(y),
<option value="">Cualquiera</option> label: String(y),
{availableYears.map((y) => ( }))}
<option key={y} value={y}> />
{y}
</option>
))}
</select>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label <label
@@ -336,20 +330,16 @@ export function PublicationsTable({
> >
Hasta año Hasta año
</label> </label>
<select <CustomSelect
id="year-to" id="year-to"
value={yearTo} value={yearTo}
onChange={(e) => handleYearToChange(e.target.value)} onChange={handleYearToChange}
disabled={availableYears.length === 0} 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" options={availableYears.map((y) => ({
> value: String(y),
<option value="">Cualquiera</option> label: String(y),
{availableYears.map((y) => ( }))}
<option key={y} value={y}> />
{y}
</option>
))}
</select>
</div> </div>
{hasYearFilter && ( {hasYearFilter && (
<button <button
@@ -385,10 +375,10 @@ export function PublicationsTable({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<TriStateCheckbox <TriStateCheckbox
checked={selectionStats.allChecked} checked={pageSelectionStats.allChecked}
indeterminate={selectionStats.anyChecked} indeterminate={pageSelectionStats.anyChecked}
onChange={toggleAllFiltered} onChange={toggleCurrentPage}
ariaLabel="Seleccionar todas las publicaciones del filtro actual" ariaLabel="Seleccionar todas las publicaciones de esta página"
/> />
</th> </th>
{COLUMNS.map((col) => ( {COLUMNS.map((col) => (
@@ -44,7 +44,7 @@ export function ResearcherCard({ researcher, actions = null }) {
</div> </div>
{actions && ( {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} {actions}
</div> </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 { import {
DEFAULT_EXPORT_PROFILE, DEFAULT_EXPORT_PROFILE,
EXPORT_PROFILE_OPTIONS, EXPORT_PROFILE_OPTIONS,
ZIP_DESTINATION_OPTION,
} from "../../utils/exportProfiles"; } 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({ export function SwordProfileSelect({
value = DEFAULT_EXPORT_PROFILE, value = DEFAULT_EXPORT_PROFILE,
onChange, onChange,
id = "sword-export-profile", id = "sword-export-profile",
className = "", 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 ( return (
<label <div
htmlFor={id} ref={rootRef}
className={`flex items-center gap-2 text-sm ${className}`.trim()} className={`flex items-center gap-2 text-sm ${className}`.trim()}
> >
<span className="whitespace-nowrap text-ink-tertiary">Destino:</span> <span className="whitespace-nowrap text-ink-tertiary">Destino:</span>
<select <div className="relative">
<button
type="button"
id={id} id={id}
value={value} aria-haspopup="listbox"
onChange={(event) => onChange(event.target.value)} aria-expanded={open}
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" 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"
> >
{EXPORT_PROFILE_OPTIONS.map(({ value: optionValue, label }) => ( <ExportProfileIcon profile={selected.value} size={20} />
<option key={optionValue} value={optionValue}> <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} {label}
</option> </div>
<div className="whitespace-nowrap text-xs text-ink-tertiary">
{desc}
</div>
</div>
</button>
))} ))}
</select> </div>
</label> )}
</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, syncResearcher,
} from "../services/api"; } from "../services/api";
import { isValidOrcid } from "../utils/orcid"; 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"; import { useAuth } from "../contexts/AuthContext";
const SUCCESS_FLASH_MS = 3000; const SUCCESS_FLASH_MS = 3000;
@@ -50,7 +53,9 @@ 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 [swordProfile, setSwordProfile] = useState(DEFAULT_EXPORT_PROFILE); const [exportDestination, setExportDestination] = useState(
DEFAULT_EXPORT_DESTINATION,
);
const [selectedIds, setSelectedIds] = useState(() => new Set()); 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); setExportingFormat(format);
try { try {
let ids; let ids;
@@ -226,8 +231,8 @@ export function DashboardPage() {
selectedCount={selectedIds.size} selectedCount={selectedIds.size}
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
newPublicationsCount={newPublicationIds.length} newPublicationsCount={newPublicationIds.length}
swordProfile={swordProfile} exportDestination={exportDestination}
onSwordProfileChange={setSwordProfile} onExportDestinationChange={setExportDestination}
/> />
</> </>
} }
+13 -1
View File
@@ -129,7 +129,19 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
const detail = const detail =
payload?.detail ?? payload?.message ?? response.statusText ?? "Error"; 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, status: response.status,
payload, payload,
}); });
+37 -4
View File
@@ -1,12 +1,45 @@
/** Perfiles de exportación SWORD XML (query `profile` en el backend). */ /** Perfiles de exportación SWORD XML (query `profile` en el backend). */
export const EXPORT_PROFILE_OPTIONS = [ export const EXPORT_PROFILE_OPTIONS = [
{ value: "generic", label: "Genérico (ORCID)" }, {
{ value: "dublin_core", label: "Dublin Core" }, value: "generic",
{ value: "dspace", label: "DSpace" }, label: "Genérico (ORCID)",
{ value: "eprints", label: "EPrints" }, 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_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) { export function swordXmlFilename(baseName, profile = DEFAULT_EXPORT_PROFILE) {
const suffix = const suffix =