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:
@@ -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
|
||||
|
||||
@@ -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";
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user