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