Compare commits

...

10 Commits

Author SHA1 Message Date
Mireya Cueto Garrido e5d5d5d5c4 Update README with image and tech stack information
Added an image and updated tech stack section.
2026-06-04 12:55:07 +02:00
Mireya Cueto Garrido f2e9b432a6 chore: trigger GitHub contributor graph refresh 2026-06-04 12:34:31 +02:00
Mireya Cueto Garrido 4b2126b780 chore: excluir secretos y CI de GitLab del repositorio
Elimina .env y .gitlab-ci.yml del control de versiones para evitar filtrar credenciales al publicar en GitHub.
2026-06-04 11:54:41 +02:00
alexis bc67cc798f Merge branch 'fix/already-downloaded' into 'main'
fix(export): optimizar lógica de descarga y manejo de estado en componentes de exportación

See merge request fjmimbre/orcid_system!6
2026-06-03 10:46:26 +00:00
Alexis e08fa17b7b fix(export): optimizar lógica de descarga y manejo de estado en componentes de exportación
Se eliminan condiciones innecesarias en el componente ExportDropdown y se mejora la lógica de selección de publicaciones en DashboardPage y GroupResultsPage. Se asegura que la desactivación de botones se base únicamente en el estado de carga y cooldown, mejorando la experiencia del usuario al evitar mensajes de notificación redundantes.
2026-06-03 12:44:34 +02:00
alexis 15eeee52d1 Merge branch 'fix/new-downloads' into 'main'
feat(download-tracking): implementar seguimiento de descargas por usuario en el dashboard

See merge request fjmimbre/orcid_system!5
2026-06-03 10:31:09 +00:00
Alexis 58f164b036 feat(download-tracking): implementar seguimiento de descargas por usuario en el dashboard
Se añaden funciones para marcar publicaciones y resultados de grupo como descargados en los componentes DashboardPage y GroupResultsPage. Se optimiza la lógica de carga de publicaciones para incluir un control de estado que evita la descarga innecesaria. Además, se actualizan los mocks de publicaciones para reflejar el estado de descarga. Se mejora la presentación del texto en el componente PublicationsTable.
2026-06-03 12:27:57 +02:00
alexis 2f48dab109 Merge branch 'style/toasts' into 'main'
feat(ui): mejorar responsividad y experiencia de usuario en el dashboard

See merge request fjmimbre/orcid_system!4
2026-06-03 08:13:45 +00:00
Alexis 4262520203 feat(export): implementar cooldown y manejo de estado en exportaciones
Se añade un sistema de cooldown para las solicitudes de exportación en los componentes DashboardPage y GroupResultsPage, evitando el spam de notificaciones. Se optimiza el componente ExportDropdown para manejar el estado de desactivación basado en el cooldown y el estado de exportación en curso. Además, se mejora la lógica de manejo de exportaciones para asegurar una mejor experiencia de usuario.
2026-06-03 10:08:38 +02:00
Alexis d58e56aeb1 feat(ui): mejorar responsividad y experiencia de usuario en el dashboard
Se implementa un sistema de detección de dispositivos móviles para ajustar la posición del toaster. Se añaden estilos para mejorar la presentación del toaster en dispositivos móviles y se optimiza el botón de sincronización para manejar estados de carga y desactivación. Además, se establece un cooldown para las solicitudes de sincronización, evitando el spam de notificaciones.
2026-06-02 13:30:04 +02:00
13 changed files with 472 additions and 122 deletions
+3
View File
@@ -44,6 +44,9 @@ docker-data/
postgres_data/
redis_data/
# --- CI / DEPLOY ---
.gitlab-ci.yml
# --- ENVIRONMENT VARIABLES ---
# Secret files shouldn't be committed
.env
+3
View File
@@ -34,6 +34,9 @@ Core capabilities:
> [!NOTE]
> The stack is local-first with Docker, but includes production-oriented hardening (CORS policy, trusted hosts, security headers, rate limiting, etc.).
<img width="1343" height="862" alt="image" src="https://github.com/user-attachments/assets/7e788d71-54b8-47fa-9586-e0e7ba575b92" />
---
## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20). Tech Stack
+9 -1
View File
@@ -64,10 +64,18 @@ def init_db():
def _ensure_columns():
insp = inspect(engine)
if "publications" in insp.get_table_names():
table_names = set(insp.get_table_names())
if "publications" in table_names:
cols = {c["name"] for c in insp.get_columns("publications")}
if "downloaded" not in cols:
with engine.begin() as conn:
conn.execute(
text("ALTER TABLE publications ADD COLUMN downloaded BOOLEAN NOT NULL DEFAULT FALSE")
)
# Per-user download tracking (PublicationDownload model).
if "publication_downloads" not in table_names:
from app.db.models import PublicationDownload # noqa: F401
PublicationDownload.__table__.create(bind=engine, checkfirst=True)
+13 -7
View File
@@ -18,17 +18,23 @@ def require_export_access(
api_key: str | None = Depends(api_key_header),
current: Researcher | None = Depends(get_optional_current_researcher),
) -> Researcher | None:
if api_key is not None:
if not is_valid_api_key(api_key):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
return current
"""
Allow export when the proxy supplies a valid API key and/or the user
sends a valid Bearer token. Prefer returning `current` when both are
present so per-user download tracking is recorded on export.
"""
if api_key is not None and not is_valid_api_key(api_key):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
if current is not None:
return current
if api_key is not None:
return None
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API key",
+29 -7
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { Toaster } from "sonner";
@@ -13,6 +14,23 @@ import { AuthCallbackPage } from "./pages/AuthCallbackPage";
* can wrap `<App />` with a `MemoryRouter` if needed.
*/
export default function App() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia("(max-width: 640px)");
const updateViewport = () => {
setIsMobile(mediaQuery.matches);
};
updateViewport();
mediaQuery.addEventListener("change", updateViewport);
return () => {
mediaQuery.removeEventListener("change", updateViewport);
};
}, []);
return (
<AuthProvider>
<Routes>
@@ -24,20 +42,24 @@ export default function App() {
</Routes>
<Toaster
position="bottom-right"
position={isMobile ? "bottom-center" : "bottom-right"}
richColors
closeButton
expand
theme="light"
visibleToasts={4}
offset={20}
mobileOffset={{ bottom: 12, left: 12, right: 12 }}
toastOptions={{ duration: 4000 }}
style={{
/* SUCCESS — ORCID corporate green */
'--success-bg': '#EAF3DE',
'--success-border': '#C0DD97',
'--success-text': '#3B6D11',
"--success-bg": "#EAF3DE",
"--success-border": "#C0DD97",
"--success-text": "#3B6D11",
/* ERROR — hue-0° mirror of the ORCID green (same saturation & lightness) */
'--error-bg': '#F3DDDD',
'--error-border': '#DD9797',
'--error-text': '#6E1111',
"--error-bg": "#F3DDDD",
"--error-border": "#DD9797",
"--error-text": "#6E1111",
}}
/>
</AuthProvider>
@@ -16,6 +16,7 @@ import {
export function ExportDropdown({
onExport,
exportingFormat = null,
disabled = false,
selectedCount = 0,
isAuthenticated = false,
newPublicationsCount = 0,
@@ -25,9 +26,6 @@ export function ExportDropdown({
const isBusy = Boolean(exportingFormat);
const hasSelection = selectedCount > 0;
const nothingToDownload =
isAuthenticated && !hasSelection && newPublicationsCount === 0;
function handleDownload() {
const { format, profile } = resolveExportFromDestination(exportDestination);
onExport(format, profile);
@@ -61,7 +59,7 @@ export function ExportDropdown({
<button
type="button"
onClick={handleDownload}
disabled={isBusy || nothingToDownload}
disabled={disabled || isBusy}
className="inline-flex w-full items-center justify-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 sm:w-auto"
>
{isBusy ? (
@@ -444,7 +444,7 @@ export function PublicationsTable({
className="mt-0.5 inline-flex shrink-0 items-center gap-0.5 rounded-full bg-brand-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent"
>
<SparkleIcon size={9} />
Nuevo
NUEVO
</span>
)}
<p className="text-[14px] font-medium leading-relaxed text-ink-primary">
@@ -570,7 +570,7 @@ export function PublicationsTable({
className="mt-0.5 inline-flex shrink-0 items-center gap-0.5 rounded-full bg-brand-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent"
>
<SparkleIcon size={9} />
Nuevo
NUEVO
</span>
)}
{pub.title}
@@ -5,9 +5,15 @@ import { Spinner } from "../ui/Spinner";
* Primary action button on the dashboard. Swaps icon + colour scheme
* depending on the sync lifecycle (idle → loading → success flash).
*/
export function SyncButton({ onClick, status = "idle", className = "" }) {
export function SyncButton({
onClick,
status = "idle",
disabled = false,
className = "",
}) {
const isLoading = status === "loading";
const isSuccess = status === "success";
const isDisabled = disabled || isLoading || isSuccess;
const palette = isSuccess
? "bg-orcid-green-soft text-orcid-green-text border border-orcid-green-border"
@@ -19,7 +25,7 @@ export function SyncButton({ onClick, status = "idle", className = "" }) {
<button
type="button"
onClick={onClick}
disabled={isLoading}
disabled={isDisabled}
className={`inline-flex items-center justify-center gap-2 rounded-lg px-[18px] py-2.5 text-sm font-medium transition-colors disabled:cursor-not-allowed ${palette} ${className}`.trim()}
>
{isLoading ? (
+91
View File
@@ -80,3 +80,94 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Sonner toaster UX polish (desktop + mobile). */
[data-sonner-toaster] {
--normal-width: min(92vw, 560px);
--width: min(92vw, 560px);
--border-radius: 16px;
}
[data-sonner-toaster][data-y-position="top"] {
top: max(12px, env(safe-area-inset-top));
}
[data-sonner-toast] {
padding: 14px 16px;
border: 1px solid var(--color-surface-border-strong);
box-shadow:
0 14px 34px rgba(16, 24, 40, 0.14),
0 3px 10px rgba(16, 24, 40, 0.08);
}
[data-sonner-toast] [data-title] {
font-size: 1rem;
font-weight: 700;
line-height: 1.25;
letter-spacing: -0.01em;
}
[data-sonner-toast] [data-description] {
margin-top: 4px;
font-size: 0.94rem;
line-height: 1.45;
color: var(--color-ink-secondary);
}
/* Large hit-area + clear visual state for close button. */
[data-sonner-toast] [data-close-button] {
width: 36px;
height: 36px;
border-radius: 999px;
border: 1px solid var(--color-surface-border-strong);
background: #fff;
color: var(--color-ink-secondary);
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.08);
}
[data-sonner-toast] [data-close-button]:hover {
background: #f8f7f3;
color: var(--color-ink-primary);
}
[data-sonner-toast] [data-close-button]:focus-visible {
outline: 2px solid var(--color-brand-accent);
outline-offset: 2px;
}
[data-sonner-toast] [data-button] {
min-height: 36px;
border-radius: 10px;
font-weight: 600;
}
@media (max-width: 640px) {
[data-sonner-toaster] {
--normal-width: min(90vw, 380px);
--width: min(90vw, 380px);
}
[data-sonner-toast] {
padding: 12px 12px 10px;
}
[data-sonner-toast] [data-title] {
font-size: 0.95rem;
line-height: 1.3;
}
[data-sonner-toast] [data-description] {
font-size: 0.9rem;
line-height: 1.4;
}
/* 44x44 touch target for thumbs on mobile. */
[data-sonner-toast] [data-close-button] {
width: 40px;
height: 40px;
}
[data-sonner-toast] [data-button] {
min-height: 40px;
}
}
+116 -13
View File
@@ -21,8 +21,18 @@ import {
swordXmlFilename,
} from "../utils/exportProfiles";
import { useAuth } from "../contexts/AuthContext";
import {
markPublicationsAsDownloaded,
publicationsNeedDownloadFlags,
} from "../utils/downloadTracking";
const SUCCESS_FLASH_MS = 3000;
/** Minimum gap between sync requests (protects backend + avoids toast spam). */
const SYNC_COOLDOWN_MS = 5000;
const SYNC_TOAST_ID = "researcher-sync";
/** Minimum gap between export requests (protects backend + avoids toast spam). */
const EXPORT_COOLDOWN_MS = 5000;
const EXPORT_TOAST_ID = "researcher-export";
/**
* Researcher detail page. Owns:
@@ -42,6 +52,7 @@ export function DashboardPage() {
const { isAuthenticated } = useAuth();
const initialBundleRef = useRef(location.state?.bundle ?? null);
const consumedInitialBundleRef = useRef(false);
const initialBundle = initialBundleRef.current;
const [researcher, setResearcher] = useState(initialBundle?.researcher ?? null);
@@ -52,7 +63,15 @@ export function DashboardPage() {
const [pubsError, setPubsError] = useState(null);
const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success
const [syncCooldownActive, setSyncCooldownActive] = useState(false);
const syncInFlightRef = useRef(false);
const syncCooldownUntilRef = useRef(0);
const syncCooldownTimerRef = useRef(null);
const [exportingFormat, setExportingFormat] = useState(null);
const [exportCooldownActive, setExportCooldownActive] = useState(false);
const exportInFlightRef = useRef(false);
const exportCooldownUntilRef = useRef(0);
const exportCooldownTimerRef = useRef(null);
const [exportDestination, setExportDestination] = useState(
DEFAULT_EXPORT_DESTINATION,
);
@@ -101,21 +120,80 @@ export function DashboardPage() {
useEffect(() => {
if (!isValidOrcid(orcid)) return;
if (initialBundleRef.current) {
const cachedBundle = initialBundleRef.current;
if (cachedBundle && !consumedInitialBundleRef.current) {
consumedInitialBundleRef.current = true;
initialBundleRef.current = null;
return;
if (
!publicationsNeedDownloadFlags(
cachedBundle.publications,
isAuthenticated,
)
) {
return;
}
}
const ctrl = new AbortController();
loadBundle(ctrl.signal);
return () => ctrl.abort();
}, [orcid, loadBundle]);
}, [orcid, loadBundle, isAuthenticated]);
useEffect(() => {
return () => {
if (syncCooldownTimerRef.current) {
clearTimeout(syncCooldownTimerRef.current);
}
if (exportCooldownTimerRef.current) {
clearTimeout(exportCooldownTimerRef.current);
}
};
}, []);
const syncDisabled = syncStatus !== "idle" || syncCooldownActive;
const exportDisabled = Boolean(exportingFormat) || exportCooldownActive;
function startSyncCooldown() {
syncCooldownUntilRef.current = Date.now() + SYNC_COOLDOWN_MS;
setSyncCooldownActive(true);
if (syncCooldownTimerRef.current) {
clearTimeout(syncCooldownTimerRef.current);
}
syncCooldownTimerRef.current = setTimeout(() => {
setSyncCooldownActive(false);
syncCooldownTimerRef.current = null;
}, SYNC_COOLDOWN_MS);
}
function startExportCooldown() {
exportCooldownUntilRef.current = Date.now() + EXPORT_COOLDOWN_MS;
setExportCooldownActive(true);
if (exportCooldownTimerRef.current) {
clearTimeout(exportCooldownTimerRef.current);
}
exportCooldownTimerRef.current = setTimeout(() => {
setExportCooldownActive(false);
exportCooldownTimerRef.current = null;
}, EXPORT_COOLDOWN_MS);
}
if (!isValidOrcid(orcid)) {
return <Navigate to="/" replace />;
}
async function handleSync() {
if (
syncInFlightRef.current ||
syncStatus !== "idle" ||
Date.now() < syncCooldownUntilRef.current
) {
return;
}
syncInFlightRef.current = true;
setSyncStatus("loading");
try {
const bundle = await syncResearcher(orcid);
setResearcher(bundle.researcher);
@@ -132,6 +210,7 @@ export function DashboardPage() {
const { newRecords, updatedRecords, totalRecords } = bundle;
const hasChanges = newRecords > 0 || updatedRecords > 0;
toast.success("Sincronización completada", {
id: SYNC_TOAST_ID,
description: hasChanges
? `${newRecords} nuevas · ${updatedRecords} actualizadas (${totalRecords} total).`
: "Sin cambios desde la última sincronización.",
@@ -140,12 +219,25 @@ export function DashboardPage() {
} catch (err) {
setSyncStatus("idle");
toast.error("Error al sincronizar con ORCID", {
id: SYNC_TOAST_ID,
description: err?.message ?? "Inténtalo de nuevo más tarde.",
});
} finally {
syncInFlightRef.current = false;
startSyncCooldown();
}
}
async function handleExport(format, profile = DEFAULT_EXPORT_DESTINATION) {
if (
exportInFlightRef.current ||
exportingFormat ||
Date.now() < exportCooldownUntilRef.current
) {
return;
}
exportInFlightRef.current = true;
setExportingFormat(format);
try {
let ids;
@@ -153,15 +245,9 @@ export function DashboardPage() {
// Manual selection takes priority
ids = Array.from(selectedIds);
} else if (isAuthenticated) {
// Authenticated → only download publications not yet downloaded by me
ids = newPublicationIds;
if (ids.length === 0) {
toast.info("No hay publicaciones nuevas", {
description: "Ya has descargado todas las publicaciones de este investigador.",
});
setExportingFormat(null);
return;
}
// Prefer undownloaded; if none left, allow re-downloading the full profile
ids =
newPublicationIds.length > 0 ? newPublicationIds : undefined;
} else {
// Anonymous → download everything
ids = undefined;
@@ -190,19 +276,34 @@ export function DashboardPage() {
if (selectedIds.size > 0) {
scope = `${selectedIds.size} publicación${selectedIds.size === 1 ? "" : "es"} seleccionada${selectedIds.size === 1 ? "" : "s"}`;
} else if (isAuthenticated) {
scope = `${newPublicationIds.length} publicación${newPublicationIds.length === 1 ? "" : "es"} nueva${newPublicationIds.length === 1 ? "" : "s"}`;
scope =
newPublicationIds.length > 0
? `${newPublicationIds.length} publicación${newPublicationIds.length === 1 ? "" : "es"} nueva${newPublicationIds.length === 1 ? "" : "s"}`
: "todo el investigador";
} else {
scope = "todo el investigador";
}
if (isAuthenticated) {
const downloadedIds =
ids?.length > 0 ? ids : publications.map((p) => p.id);
setPublications((prev) =>
markPublicationsAsDownloaded(prev, downloadedIds),
);
}
toast.success(`Exportación ${format.toUpperCase()} completada`, {
id: EXPORT_TOAST_ID,
description: scope,
});
} catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, {
id: EXPORT_TOAST_ID,
description: err?.message ?? "No se pudo generar el fichero.",
});
} finally {
setExportingFormat(null);
exportInFlightRef.current = false;
startExportCooldown();
}
}
@@ -227,11 +328,13 @@ export function DashboardPage() {
<SyncButton
onClick={handleSync}
status={syncStatus}
disabled={syncDisabled}
className="w-full sm:w-auto"
/>
<ExportDropdown
onExport={handleExport}
exportingFormat={exportingFormat}
disabled={exportDisabled}
selectedCount={selectedIds.size}
isAuthenticated={isAuthenticated}
newPublicationsCount={newPublicationIds.length}
+159 -86
View File
@@ -17,11 +17,15 @@ import { downloadExport, searchResearchersBulk } from "../services/api";
import {
DEFAULT_EXPORT_DESTINATION,
DEFAULT_EXPORT_PROFILE,
EXPORT_ZIP_DESTINATION,
resolveExportFromDestination,
swordXmlFilename,
} from "../utils/exportProfiles";
import { ExportDropdown } from "../components/dashboard/ExportDropdown";
import { useAuth } from "../contexts/AuthContext";
import { markGroupResultsAsDownloaded } from "../utils/downloadTracking";
const EXPORT_COOLDOWN_MS = 5000;
const GLOBAL_EXPORT_TOAST_ID = "group-export-global";
/**
* Group results view: shows one summary card per researcher, plus a global
@@ -44,11 +48,23 @@ export function GroupResultsPage() {
const [globalExportDestination, setGlobalExportDestination] = useState(
DEFAULT_EXPORT_DESTINATION,
);
const [swordProfile, setSwordProfile] = useState(DEFAULT_EXPORT_PROFILE);
// Track per-researcher export state (format | null)
const [cardExporting, setCardExporting] = useState({});
const [globalExportCooldownActive, setGlobalExportCooldownActive] =
useState(false);
const globalExportInFlightRef = useRef(false);
const globalExportCooldownUntilRef = useRef(0);
const globalExportCooldownTimerRef = useRef(null);
const [cardExportCooldownActive, setCardExportCooldownActive] = useState(
{},
);
const cardExportInFlightRef = useRef(new Set());
const cardExportCooldownUntilRef = useRef({});
const cardExportCooldownTimerRef = useRef({});
const abortRef = useRef(null);
useEffect(() => {
@@ -93,7 +109,55 @@ export function GroupResultsPage() {
})();
return () => ctrl.abort();
}, [orcidIds, navigate]);
}, [orcidIds, navigate, isAuthenticated]);
useEffect(() => {
const cardTimersObj = cardExportCooldownTimerRef.current;
return () => {
const globalTimer = globalExportCooldownTimerRef.current;
if (globalTimer) {
clearTimeout(globalTimer);
}
for (const t of Object.values(cardTimersObj)) {
clearTimeout(t);
}
};
}, []);
function startGlobalExportCooldown() {
globalExportCooldownUntilRef.current = Date.now() + EXPORT_COOLDOWN_MS;
setGlobalExportCooldownActive(true);
if (globalExportCooldownTimerRef.current) {
clearTimeout(globalExportCooldownTimerRef.current);
}
globalExportCooldownTimerRef.current = setTimeout(() => {
setGlobalExportCooldownActive(false);
globalExportCooldownUntilRef.current = 0;
globalExportCooldownTimerRef.current = null;
}, EXPORT_COOLDOWN_MS);
}
function startCardExportCooldown(orcidId) {
const until = Date.now() + EXPORT_COOLDOWN_MS;
cardExportCooldownUntilRef.current[orcidId] = until;
setCardExportCooldownActive((prev) => ({
...prev,
[orcidId]: true,
}));
if (cardExportCooldownTimerRef.current[orcidId]) {
clearTimeout(cardExportCooldownTimerRef.current[orcidId]);
}
cardExportCooldownTimerRef.current[orcidId] = setTimeout(() => {
setCardExportCooldownActive((prev) => ({
...prev,
[orcidId]: false,
}));
cardExportCooldownUntilRef.current[orcidId] = 0;
cardExportCooldownTimerRef.current[orcidId] = null;
}, EXPORT_COOLDOWN_MS);
}
// All new publication IDs across all loaded researchers
const allNewIds = useMemo(() => {
@@ -110,25 +174,26 @@ export function GroupResultsPage() {
[results],
);
function handleGlobalExportDestinationChange(nextDestination) {
setGlobalExportDestination(nextDestination);
// Keep last XML profile for card-level exports.
if (nextDestination !== EXPORT_ZIP_DESTINATION) {
setSwordProfile(nextDestination);
}
}
async function handleGlobalExport(format, profile = DEFAULT_EXPORT_PROFILE) {
const ids = isAuthenticated ? allNewIds : allIds;
if (ids.length === 0) {
toast.info(
isAuthenticated
? "No hay publicaciones nuevas"
: "No hay publicaciones para exportar",
{ description: "No se encontraron publicaciones en los investigadores cargados." },
);
if (
globalExportInFlightRef.current ||
Date.now() < globalExportCooldownUntilRef.current
) {
return;
}
const ids =
isAuthenticated && allNewIds.length > 0 ? allNewIds : allIds;
if (ids.length === 0) {
toast.info("No hay publicaciones para exportar", {
id: GLOBAL_EXPORT_TOAST_ID,
description:
"No se encontraron publicaciones en los investigadores cargados.",
});
return;
}
globalExportInFlightRef.current = true;
setGlobalExporting(format);
try {
// For bulk export we send all IDs together, passing a placeholder orcid
@@ -150,15 +215,23 @@ export function GroupResultsPage() {
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
if (isAuthenticated) {
setResults((prev) => markGroupResultsAsDownloaded(prev, ids));
}
toast.success(`Exportación ${format.toUpperCase()} completada`, {
id: GLOBAL_EXPORT_TOAST_ID,
description: `${ids.length} publicaciones exportadas.`,
});
} catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, {
id: GLOBAL_EXPORT_TOAST_ID,
description: err?.message ?? "No se pudo generar el fichero.",
});
} finally {
setGlobalExporting(null);
globalExportInFlightRef.current = false;
startGlobalExportCooldown();
}
}
@@ -169,11 +242,23 @@ export function GroupResultsPage() {
totalIds,
profile = DEFAULT_EXPORT_PROFILE,
) {
const ids = isAuthenticated ? newIds : totalIds;
if (ids.length === 0) {
toast.info("No hay publicaciones para exportar");
if (cardExportInFlightRef.current.has(orcidId)) {
return;
}
const now = Date.now();
const until = cardExportCooldownUntilRef.current[orcidId] ?? 0;
if (now < until) return;
const ids =
isAuthenticated && newIds.length > 0 ? newIds : totalIds;
if (ids.length === 0) {
toast.info("No hay publicaciones para exportar", {
id: `group-export-card-${orcidId}`,
});
return;
}
cardExportInFlightRef.current.add(orcidId);
setCardExporting((prev) => ({ ...prev, [orcidId]: format }));
try {
const { blob } = await downloadExport(orcidId, format, {
@@ -193,11 +278,17 @@ export function GroupResultsPage() {
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
if (isAuthenticated) {
setResults((prev) => markGroupResultsAsDownloaded(prev, ids));
}
toast.success(`Exportación ${format.toUpperCase()} completada`, {
id: `group-export-card-${orcidId}`,
description: `${ids.length} publicaciones de ${orcidId}.`,
});
} catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, {
id: `group-export-card-${orcidId}`,
description: err?.message ?? "No se pudo generar el fichero.",
});
} finally {
@@ -206,6 +297,8 @@ export function GroupResultsPage() {
delete next[orcidId];
return next;
});
cardExportInFlightRef.current.delete(orcidId);
startCardExportCooldown(orcidId);
}
}
@@ -250,11 +343,14 @@ export function GroupResultsPage() {
<ExportDropdown
onExport={handleGlobalExport}
exportingFormat={globalExporting}
disabled={
Boolean(globalExporting) || globalExportCooldownActive
}
selectedCount={0}
isAuthenticated={isAuthenticated}
newPublicationsCount={allNewIds.length}
exportDestination={globalExportDestination}
onExportDestinationChange={handleGlobalExportDestinationChange}
onExportDestinationChange={setGlobalExportDestination}
/>
)}
</div>
@@ -281,15 +377,22 @@ export function GroupResultsPage() {
bundle={bundle}
isAuthenticated={isAuthenticated}
exporting={cardExporting[bundle.researcher?.orcid_id] ?? null}
onExport={(fmt, newIds, totalIds) =>
exportCooldownActive={
cardExportCooldownActive[bundle.researcher?.orcid_id] ??
false
}
onExport={(newIds, totalIds) => {
const { format, profile } = resolveExportFromDestination(
globalExportDestination,
);
handleCardExport(
bundle.researcher?.orcid_id,
fmt,
format,
newIds,
totalIds,
swordProfile,
)
}
profile ?? DEFAULT_EXPORT_PROFILE,
);
}}
/>
))}
</div>
@@ -349,6 +452,7 @@ function ResearcherResultCard({
bundle,
isAuthenticated,
exporting,
exportCooldownActive,
onExport,
}) {
const researcher = bundle.researcher ?? {};
@@ -419,92 +523,61 @@ function ResearcherResultCard({
>
Ver detalle
</Link>
<ExportFormatMenu
onExport={(fmt) => onExport(fmt, newIds, allPubIds)}
<CardExportButton
onClick={() => onExport(newIds, allPubIds)}
exporting={exporting}
isAuthenticated={isAuthenticated}
hasNew={hasNew}
newCount={newCount}
totalCount={totalRecords}
exportCooldownActive={exportCooldownActive}
/>
</div>
</div>
);
}
/* ─────────────────────── Inline export format picker ─────────────────── */
/* ─────────────────────── Per-card download button ────────────────────── */
function ExportFormatMenu({
onExport,
function CardExportButton({
onClick,
exporting,
isAuthenticated,
hasNew,
newCount,
totalCount,
exportCooldownActive,
}) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
function handleClick(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const isBusy = Boolean(exporting);
const disabled =
isBusy || (isAuthenticated && !hasNew && totalCount > 0 && newCount === 0);
const disabled = isBusy || exportCooldownActive;
let label;
if (isBusy) {
label = `Exportando ${exporting.toUpperCase()}...`;
label = `Descargando ${exporting.toUpperCase()}...`;
} else if (exportCooldownActive) {
label = "Espera un momento...";
} else if (isAuthenticated) {
label = hasNew ? `Nuevo (${newCount})` : "Descargado";
label = hasNew ? `Descargar (${newCount})` : "Todo descargado";
} else {
label = `Todo (${totalCount})`;
label = `Descargar (${totalCount})`;
}
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
disabled={disabled}
className="inline-flex items-center gap-1.5 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-60"
>
{isBusy ? (
<Spinner size={13} />
) : hasNew ? (
<SparkleIcon size={11} className="text-brand-accent" />
) : (
<DownloadIcon size={13} />
)}
{label}
</button>
{open && (
<div className="absolute right-0 top-[calc(100%+4px)] z-50 min-w-[160px] overflow-hidden rounded-xl border border-surface-border-strong bg-surface-primary shadow-lg">
{["xml", "zip"].map((fmt, idx) => (
<button
key={fmt}
type="button"
onClick={() => {
setOpen(false);
onExport(fmt);
}}
className={`flex w-full items-center gap-2 px-3 py-2.5 text-left text-[13px] font-medium text-ink-primary transition-colors hover:bg-surface-secondary ${
idx === 0 ? "border-b border-surface-border/60" : ""
}`}
>
<DownloadIcon size={13} />
{fmt.toUpperCase()}
</button>
))}
</div>
<button
type="button"
onClick={onClick}
disabled={disabled}
className="inline-flex flex-1 items-center justify-center gap-1.5 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-60 sm:flex-none"
>
{isBusy ? (
<Spinner size={13} />
) : hasNew ? (
<SparkleIcon size={11} className="text-brand-accent" />
) : (
<DownloadIcon size={13} />
)}
</div>
{label}
</button>
);
}
+5
View File
@@ -29,6 +29,7 @@ export const MOCK_PUBLICATIONS = [
doi: "10.1038/s41567-025-xxxx",
type: "journal-article",
last_modified: "2025-09-01T10:00:00Z",
downloaded_by_me: false,
},
{
id: "uuid-2",
@@ -40,6 +41,7 @@ export const MOCK_PUBLICATIONS = [
doi: "10.1000/jdr.2024.12",
type: "review",
last_modified: "2024-11-12T09:00:00Z",
downloaded_by_me: false,
},
{
id: "uuid-3",
@@ -50,6 +52,7 @@ export const MOCK_PUBLICATIONS = [
doi: "10.1007/s11192-024-04801-z",
type: "journal-article",
last_modified: "2024-06-20T15:30:00Z",
downloaded_by_me: true,
},
{
id: "uuid-4",
@@ -60,6 +63,7 @@ export const MOCK_PUBLICATIONS = [
doi: "10.1145/3587-dl.2023.09",
type: "conference-paper",
last_modified: "2023-10-05T11:45:00Z",
downloaded_by_me: false,
},
{
id: "uuid-5",
@@ -70,6 +74,7 @@ export const MOCK_PUBLICATIONS = [
doi: "10.1016/j.ijls.2023.03.011",
type: "journal-article",
last_modified: "2023-04-18T08:15:00Z",
downloaded_by_me: true,
},
];
+32
View File
@@ -0,0 +1,32 @@
/**
* Helpers for per-user "new publication" (not yet downloaded) tracking.
* Backend sets `downloaded_by_me` when a Bearer token is present; these
* utilities keep the dashboard in sync after login and export.
*/
/** True when an authenticated user needs a refetch to obtain download flags. */
export function publicationsNeedDownloadFlags(publications, isAuthenticated) {
if (!isAuthenticated || !publications?.length) return false;
return publications.some((p) => p.downloaded_by_me == null);
}
/** Mark the given publication IDs as downloaded in local state. */
export function markPublicationsAsDownloaded(publications, downloadedIds) {
if (!downloadedIds?.length || !publications?.length) return publications;
const ids = new Set(downloadedIds);
return publications.map((p) =>
ids.has(p.id) ? { ...p, downloaded_by_me: true } : p,
);
}
/** Apply download flags across group-search result bundles. */
export function markGroupResultsAsDownloaded(results, downloadedIds) {
if (!downloadedIds?.length || !results?.length) return results;
const ids = new Set(downloadedIds);
return results.map((bundle) => ({
...bundle,
publications: (bundle.publications ?? []).map((p) =>
ids.has(p.id) ? { ...p, downloaded_by_me: true } : p,
),
}));
}