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

Se actualiza el componente ExportDropdown para incluir un selector de destino que permite elegir entre diferentes perfiles de exportación, incluyendo la opción de ZIP. Se mejora la lógica de descarga y se ajusta el componente SwordProfileSelect para manejar la selección de perfiles de exportación. Además, se realizan cambios en la página Dashboard para integrar el nuevo sistema de exportación.
This commit is contained in:
Alexis
2026-06-01 13:12:55 +02:00
parent aa2e7280dc
commit 02c65bb710
13 changed files with 309 additions and 120 deletions
@@ -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>
);
}
@@ -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,103 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronDownIcon } from "../ui/Icons";
import { ExportProfileIcon } from "../ui/destination-logos/ExportProfileIcon";
import {
DEFAULT_EXPORT_PROFILE,
EXPORT_PROFILE_OPTIONS,
ZIP_DESTINATION_OPTION,
} from "../../utils/exportProfiles";
/**
* Selector de destino para exportación SWORD XML (DSpace, EPrints, Dublin Core…).
* Selector de destino para exportación (perfiles SWORD XML y, opcionalmente, ZIP).
*/
export function SwordProfileSelect({
value = DEFAULT_EXPORT_PROFILE,
onChange,
id = "sword-export-profile",
className = "",
includeZip = false,
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef(null);
const options = useMemo(() => {
const items = [...EXPORT_PROFILE_OPTIONS];
if (includeZip) {
items.push(ZIP_DESTINATION_OPTION);
}
return items;
}, [includeZip]);
const selected = options.find((opt) => opt.value === value) ?? options[0];
useEffect(() => {
function handleClick(event) {
if (rootRef.current && !rootRef.current.contains(event.target)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
function handlePick(optionValue) {
onChange(optionValue);
setOpen(false);
}
return (
<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-[260px] 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="text-xs text-ink-tertiary">{desc}</div>
</div>
</button>
))}
</div>
)}
</div>
</div>
);
}