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