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/ postgres_data/
redis_data/ redis_data/
# --- CI / DEPLOY ---
.gitlab-ci.yml
# --- ENVIRONMENT VARIABLES --- # --- ENVIRONMENT VARIABLES ---
# Secret files shouldn't be committed # Secret files shouldn't be committed
.env .env
+3
View File
@@ -34,6 +34,9 @@ Core capabilities:
> [!NOTE] > [!NOTE]
> The stack is local-first with Docker, but includes production-oriented hardening (CORS policy, trusted hosts, security headers, rate limiting, etc.). > 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 ## ![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(): def _ensure_columns():
insp = inspect(engine) 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")} cols = {c["name"] for c in insp.get_columns("publications")}
if "downloaded" not in cols: if "downloaded" not in cols:
with engine.begin() as conn: with engine.begin() as conn:
conn.execute( conn.execute(
text("ALTER TABLE publications ADD COLUMN downloaded BOOLEAN NOT NULL DEFAULT FALSE") 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), api_key: str | None = Depends(api_key_header),
current: Researcher | None = Depends(get_optional_current_researcher), current: Researcher | None = Depends(get_optional_current_researcher),
) -> Researcher | None: ) -> Researcher | None:
if api_key is not None: """
if not is_valid_api_key(api_key): Allow export when the proxy supplies a valid API key and/or the user
raise HTTPException( sends a valid Bearer token. Prefer returning `current` when both are
status_code=status.HTTP_401_UNAUTHORIZED, present so per-user download tracking is recorded on export.
detail="Invalid API key", """
) if api_key is not None and not is_valid_api_key(api_key):
return current raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
if current is not None: if current is not None:
return current return current
if api_key is not None:
return None
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API key", 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 { Navigate, Route, Routes } from "react-router-dom";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
@@ -13,6 +14,23 @@ import { AuthCallbackPage } from "./pages/AuthCallbackPage";
* can wrap `<App />` with a `MemoryRouter` if needed. * can wrap `<App />` with a `MemoryRouter` if needed.
*/ */
export default function App() { 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 ( return (
<AuthProvider> <AuthProvider>
<Routes> <Routes>
@@ -24,20 +42,24 @@ export default function App() {
</Routes> </Routes>
<Toaster <Toaster
position="bottom-right" position={isMobile ? "bottom-center" : "bottom-right"}
richColors richColors
closeButton closeButton
expand
theme="light" theme="light"
visibleToasts={4}
offset={20}
mobileOffset={{ bottom: 12, left: 12, right: 12 }}
toastOptions={{ duration: 4000 }} toastOptions={{ duration: 4000 }}
style={{ style={{
/* SUCCESS — ORCID corporate green */ /* SUCCESS — ORCID corporate green */
'--success-bg': '#EAF3DE', "--success-bg": "#EAF3DE",
'--success-border': '#C0DD97', "--success-border": "#C0DD97",
'--success-text': '#3B6D11', "--success-text": "#3B6D11",
/* ERROR — hue-0° mirror of the ORCID green (same saturation & lightness) */ /* ERROR — hue-0° mirror of the ORCID green (same saturation & lightness) */
'--error-bg': '#F3DDDD', "--error-bg": "#F3DDDD",
'--error-border': '#DD9797', "--error-border": "#DD9797",
'--error-text': '#6E1111', "--error-text": "#6E1111",
}} }}
/> />
</AuthProvider> </AuthProvider>
@@ -16,6 +16,7 @@ import {
export function ExportDropdown({ export function ExportDropdown({
onExport, onExport,
exportingFormat = null, exportingFormat = null,
disabled = false,
selectedCount = 0, selectedCount = 0,
isAuthenticated = false, isAuthenticated = false,
newPublicationsCount = 0, newPublicationsCount = 0,
@@ -25,9 +26,6 @@ export function ExportDropdown({
const isBusy = Boolean(exportingFormat); const isBusy = Boolean(exportingFormat);
const hasSelection = selectedCount > 0; const hasSelection = selectedCount > 0;
const nothingToDownload =
isAuthenticated && !hasSelection && newPublicationsCount === 0;
function handleDownload() { function handleDownload() {
const { format, profile } = resolveExportFromDestination(exportDestination); const { format, profile } = resolveExportFromDestination(exportDestination);
onExport(format, profile); onExport(format, profile);
@@ -61,7 +59,7 @@ export function ExportDropdown({
<button <button
type="button" type="button"
onClick={handleDownload} 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" 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 ? ( {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" 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} /> <SparkleIcon size={9} />
Nuevo NUEVO
</span> </span>
)} )}
<p className="text-[14px] font-medium leading-relaxed text-ink-primary"> <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" 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} /> <SparkleIcon size={9} />
Nuevo NUEVO
</span> </span>
)} )}
{pub.title} {pub.title}
@@ -5,9 +5,15 @@ import { Spinner } from "../ui/Spinner";
* Primary action button on the dashboard. Swaps icon + colour scheme * Primary action button on the dashboard. Swaps icon + colour scheme
* depending on the sync lifecycle (idle → loading → success flash). * 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 isLoading = status === "loading";
const isSuccess = status === "success"; const isSuccess = status === "success";
const isDisabled = disabled || isLoading || isSuccess;
const palette = isSuccess const palette = isSuccess
? "bg-orcid-green-soft text-orcid-green-text border border-orcid-green-border" ? "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 <button
type="button" type="button"
onClick={onClick} 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()} 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 ? ( {isLoading ? (
+91
View File
@@ -80,3 +80,94 @@ body {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -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, swordXmlFilename,
} from "../utils/exportProfiles"; } from "../utils/exportProfiles";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import {
markPublicationsAsDownloaded,
publicationsNeedDownloadFlags,
} from "../utils/downloadTracking";
const SUCCESS_FLASH_MS = 3000; 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: * Researcher detail page. Owns:
@@ -42,6 +52,7 @@ export function DashboardPage() {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const initialBundleRef = useRef(location.state?.bundle ?? null); const initialBundleRef = useRef(location.state?.bundle ?? null);
const consumedInitialBundleRef = useRef(false);
const initialBundle = initialBundleRef.current; const initialBundle = initialBundleRef.current;
const [researcher, setResearcher] = useState(initialBundle?.researcher ?? null); const [researcher, setResearcher] = useState(initialBundle?.researcher ?? null);
@@ -52,7 +63,15 @@ export function DashboardPage() {
const [pubsError, setPubsError] = useState(null); const [pubsError, setPubsError] = useState(null);
const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success 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 [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( const [exportDestination, setExportDestination] = useState(
DEFAULT_EXPORT_DESTINATION, DEFAULT_EXPORT_DESTINATION,
); );
@@ -101,21 +120,80 @@ export function DashboardPage() {
useEffect(() => { useEffect(() => {
if (!isValidOrcid(orcid)) return; if (!isValidOrcid(orcid)) return;
if (initialBundleRef.current) {
const cachedBundle = initialBundleRef.current;
if (cachedBundle && !consumedInitialBundleRef.current) {
consumedInitialBundleRef.current = true;
initialBundleRef.current = null; initialBundleRef.current = null;
return; if (
!publicationsNeedDownloadFlags(
cachedBundle.publications,
isAuthenticated,
)
) {
return;
}
} }
const ctrl = new AbortController(); const ctrl = new AbortController();
loadBundle(ctrl.signal); loadBundle(ctrl.signal);
return () => ctrl.abort(); 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)) { if (!isValidOrcid(orcid)) {
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
} }
async function handleSync() { async function handleSync() {
if (
syncInFlightRef.current ||
syncStatus !== "idle" ||
Date.now() < syncCooldownUntilRef.current
) {
return;
}
syncInFlightRef.current = true;
setSyncStatus("loading"); setSyncStatus("loading");
try { try {
const bundle = await syncResearcher(orcid); const bundle = await syncResearcher(orcid);
setResearcher(bundle.researcher); setResearcher(bundle.researcher);
@@ -132,6 +210,7 @@ export function DashboardPage() {
const { newRecords, updatedRecords, totalRecords } = bundle; const { newRecords, updatedRecords, totalRecords } = bundle;
const hasChanges = newRecords > 0 || updatedRecords > 0; const hasChanges = newRecords > 0 || updatedRecords > 0;
toast.success("Sincronización completada", { toast.success("Sincronización completada", {
id: SYNC_TOAST_ID,
description: hasChanges description: hasChanges
? `${newRecords} nuevas · ${updatedRecords} actualizadas (${totalRecords} total).` ? `${newRecords} nuevas · ${updatedRecords} actualizadas (${totalRecords} total).`
: "Sin cambios desde la última sincronización.", : "Sin cambios desde la última sincronización.",
@@ -140,12 +219,25 @@ export function DashboardPage() {
} catch (err) { } catch (err) {
setSyncStatus("idle"); setSyncStatus("idle");
toast.error("Error al sincronizar con ORCID", { toast.error("Error al sincronizar con ORCID", {
id: SYNC_TOAST_ID,
description: err?.message ?? "Inténtalo de nuevo más tarde.", description: err?.message ?? "Inténtalo de nuevo más tarde.",
}); });
} finally {
syncInFlightRef.current = false;
startSyncCooldown();
} }
} }
async function handleExport(format, profile = DEFAULT_EXPORT_DESTINATION) { async function handleExport(format, profile = DEFAULT_EXPORT_DESTINATION) {
if (
exportInFlightRef.current ||
exportingFormat ||
Date.now() < exportCooldownUntilRef.current
) {
return;
}
exportInFlightRef.current = true;
setExportingFormat(format); setExportingFormat(format);
try { try {
let ids; let ids;
@@ -153,15 +245,9 @@ export function DashboardPage() {
// Manual selection takes priority // Manual selection takes priority
ids = Array.from(selectedIds); ids = Array.from(selectedIds);
} else if (isAuthenticated) { } else if (isAuthenticated) {
// Authenticated → only download publications not yet downloaded by me // Prefer undownloaded; if none left, allow re-downloading the full profile
ids = newPublicationIds; ids =
if (ids.length === 0) { newPublicationIds.length > 0 ? newPublicationIds : undefined;
toast.info("No hay publicaciones nuevas", {
description: "Ya has descargado todas las publicaciones de este investigador.",
});
setExportingFormat(null);
return;
}
} else { } else {
// Anonymous → download everything // Anonymous → download everything
ids = undefined; ids = undefined;
@@ -190,19 +276,34 @@ export function DashboardPage() {
if (selectedIds.size > 0) { if (selectedIds.size > 0) {
scope = `${selectedIds.size} publicación${selectedIds.size === 1 ? "" : "es"} seleccionada${selectedIds.size === 1 ? "" : "s"}`; scope = `${selectedIds.size} publicación${selectedIds.size === 1 ? "" : "es"} seleccionada${selectedIds.size === 1 ? "" : "s"}`;
} else if (isAuthenticated) { } 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 { } else {
scope = "todo el investigador"; 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`, { toast.success(`Exportación ${format.toUpperCase()} completada`, {
id: EXPORT_TOAST_ID,
description: scope, description: scope,
}); });
} catch (err) { } catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, { toast.error(`Error al exportar ${format.toUpperCase()}`, {
id: EXPORT_TOAST_ID,
description: err?.message ?? "No se pudo generar el fichero.", description: err?.message ?? "No se pudo generar el fichero.",
}); });
} finally { } finally {
setExportingFormat(null); setExportingFormat(null);
exportInFlightRef.current = false;
startExportCooldown();
} }
} }
@@ -227,11 +328,13 @@ export function DashboardPage() {
<SyncButton <SyncButton
onClick={handleSync} onClick={handleSync}
status={syncStatus} status={syncStatus}
disabled={syncDisabled}
className="w-full sm:w-auto" className="w-full sm:w-auto"
/> />
<ExportDropdown <ExportDropdown
onExport={handleExport} onExport={handleExport}
exportingFormat={exportingFormat} exportingFormat={exportingFormat}
disabled={exportDisabled}
selectedCount={selectedIds.size} selectedCount={selectedIds.size}
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
newPublicationsCount={newPublicationIds.length} newPublicationsCount={newPublicationIds.length}
+159 -86
View File
@@ -17,11 +17,15 @@ import { downloadExport, searchResearchersBulk } from "../services/api";
import { import {
DEFAULT_EXPORT_DESTINATION, DEFAULT_EXPORT_DESTINATION,
DEFAULT_EXPORT_PROFILE, DEFAULT_EXPORT_PROFILE,
EXPORT_ZIP_DESTINATION, resolveExportFromDestination,
swordXmlFilename, swordXmlFilename,
} from "../utils/exportProfiles"; } from "../utils/exportProfiles";
import { ExportDropdown } from "../components/dashboard/ExportDropdown"; import { ExportDropdown } from "../components/dashboard/ExportDropdown";
import { useAuth } from "../contexts/AuthContext"; 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 * Group results view: shows one summary card per researcher, plus a global
@@ -44,11 +48,23 @@ export function GroupResultsPage() {
const [globalExportDestination, setGlobalExportDestination] = useState( const [globalExportDestination, setGlobalExportDestination] = useState(
DEFAULT_EXPORT_DESTINATION, DEFAULT_EXPORT_DESTINATION,
); );
const [swordProfile, setSwordProfile] = useState(DEFAULT_EXPORT_PROFILE);
// Track per-researcher export state (format | null) // Track per-researcher export state (format | null)
const [cardExporting, setCardExporting] = useState({}); 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); const abortRef = useRef(null);
useEffect(() => { useEffect(() => {
@@ -93,7 +109,55 @@ export function GroupResultsPage() {
})(); })();
return () => ctrl.abort(); 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 // All new publication IDs across all loaded researchers
const allNewIds = useMemo(() => { const allNewIds = useMemo(() => {
@@ -110,25 +174,26 @@ export function GroupResultsPage() {
[results], [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) { async function handleGlobalExport(format, profile = DEFAULT_EXPORT_PROFILE) {
const ids = isAuthenticated ? allNewIds : allIds; if (
if (ids.length === 0) { globalExportInFlightRef.current ||
toast.info( Date.now() < globalExportCooldownUntilRef.current
isAuthenticated ) {
? "No hay publicaciones nuevas"
: "No hay publicaciones para exportar",
{ description: "No se encontraron publicaciones en los investigadores cargados." },
);
return; 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); setGlobalExporting(format);
try { try {
// For bulk export we send all IDs together, passing a placeholder orcid // For bulk export we send all IDs together, passing a placeholder orcid
@@ -150,15 +215,23 @@ export function GroupResultsPage() {
anchor.remove(); anchor.remove();
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
} }
if (isAuthenticated) {
setResults((prev) => markGroupResultsAsDownloaded(prev, ids));
}
toast.success(`Exportación ${format.toUpperCase()} completada`, { toast.success(`Exportación ${format.toUpperCase()} completada`, {
id: GLOBAL_EXPORT_TOAST_ID,
description: `${ids.length} publicaciones exportadas.`, description: `${ids.length} publicaciones exportadas.`,
}); });
} catch (err) { } catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, { toast.error(`Error al exportar ${format.toUpperCase()}`, {
id: GLOBAL_EXPORT_TOAST_ID,
description: err?.message ?? "No se pudo generar el fichero.", description: err?.message ?? "No se pudo generar el fichero.",
}); });
} finally { } finally {
setGlobalExporting(null); setGlobalExporting(null);
globalExportInFlightRef.current = false;
startGlobalExportCooldown();
} }
} }
@@ -169,11 +242,23 @@ export function GroupResultsPage() {
totalIds, totalIds,
profile = DEFAULT_EXPORT_PROFILE, profile = DEFAULT_EXPORT_PROFILE,
) { ) {
const ids = isAuthenticated ? newIds : totalIds; if (cardExportInFlightRef.current.has(orcidId)) {
if (ids.length === 0) {
toast.info("No hay publicaciones para exportar");
return; 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 })); setCardExporting((prev) => ({ ...prev, [orcidId]: format }));
try { try {
const { blob } = await downloadExport(orcidId, format, { const { blob } = await downloadExport(orcidId, format, {
@@ -193,11 +278,17 @@ export function GroupResultsPage() {
anchor.remove(); anchor.remove();
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
} }
if (isAuthenticated) {
setResults((prev) => markGroupResultsAsDownloaded(prev, ids));
}
toast.success(`Exportación ${format.toUpperCase()} completada`, { toast.success(`Exportación ${format.toUpperCase()} completada`, {
id: `group-export-card-${orcidId}`,
description: `${ids.length} publicaciones de ${orcidId}.`, description: `${ids.length} publicaciones de ${orcidId}.`,
}); });
} catch (err) { } catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, { toast.error(`Error al exportar ${format.toUpperCase()}`, {
id: `group-export-card-${orcidId}`,
description: err?.message ?? "No se pudo generar el fichero.", description: err?.message ?? "No se pudo generar el fichero.",
}); });
} finally { } finally {
@@ -206,6 +297,8 @@ export function GroupResultsPage() {
delete next[orcidId]; delete next[orcidId];
return next; return next;
}); });
cardExportInFlightRef.current.delete(orcidId);
startCardExportCooldown(orcidId);
} }
} }
@@ -250,11 +343,14 @@ export function GroupResultsPage() {
<ExportDropdown <ExportDropdown
onExport={handleGlobalExport} onExport={handleGlobalExport}
exportingFormat={globalExporting} exportingFormat={globalExporting}
disabled={
Boolean(globalExporting) || globalExportCooldownActive
}
selectedCount={0} selectedCount={0}
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
newPublicationsCount={allNewIds.length} newPublicationsCount={allNewIds.length}
exportDestination={globalExportDestination} exportDestination={globalExportDestination}
onExportDestinationChange={handleGlobalExportDestinationChange} onExportDestinationChange={setGlobalExportDestination}
/> />
)} )}
</div> </div>
@@ -281,15 +377,22 @@ export function GroupResultsPage() {
bundle={bundle} bundle={bundle}
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
exporting={cardExporting[bundle.researcher?.orcid_id] ?? null} 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( handleCardExport(
bundle.researcher?.orcid_id, bundle.researcher?.orcid_id,
fmt, format,
newIds, newIds,
totalIds, totalIds,
swordProfile, profile ?? DEFAULT_EXPORT_PROFILE,
) );
} }}
/> />
))} ))}
</div> </div>
@@ -349,6 +452,7 @@ function ResearcherResultCard({
bundle, bundle,
isAuthenticated, isAuthenticated,
exporting, exporting,
exportCooldownActive,
onExport, onExport,
}) { }) {
const researcher = bundle.researcher ?? {}; const researcher = bundle.researcher ?? {};
@@ -419,92 +523,61 @@ function ResearcherResultCard({
> >
Ver detalle Ver detalle
</Link> </Link>
<ExportFormatMenu <CardExportButton
onExport={(fmt) => onExport(fmt, newIds, allPubIds)} onClick={() => onExport(newIds, allPubIds)}
exporting={exporting} exporting={exporting}
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
hasNew={hasNew} hasNew={hasNew}
newCount={newCount} newCount={newCount}
totalCount={totalRecords} totalCount={totalRecords}
exportCooldownActive={exportCooldownActive}
/> />
</div> </div>
</div> </div>
); );
} }
/* ─────────────────────── Inline export format picker ─────────────────── */ /* ─────────────────────── Per-card download button ────────────────────── */
function ExportFormatMenu({ function CardExportButton({
onExport, onClick,
exporting, exporting,
isAuthenticated, isAuthenticated,
hasNew, hasNew,
newCount, newCount,
totalCount, 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 isBusy = Boolean(exporting);
const disabled = const disabled = isBusy || exportCooldownActive;
isBusy || (isAuthenticated && !hasNew && totalCount > 0 && newCount === 0);
let label; let label;
if (isBusy) { if (isBusy) {
label = `Exportando ${exporting.toUpperCase()}...`; label = `Descargando ${exporting.toUpperCase()}...`;
} else if (exportCooldownActive) {
label = "Espera un momento...";
} else if (isAuthenticated) { } else if (isAuthenticated) {
label = hasNew ? `Nuevo (${newCount})` : "Descargado"; label = hasNew ? `Descargar (${newCount})` : "Todo descargado";
} else { } else {
label = `Todo (${totalCount})`; label = `Descargar (${totalCount})`;
} }
return ( return (
<div className="relative" ref={ref}> <button
<button type="button"
type="button" onClick={onClick}
onClick={() => setOpen((o) => !o)} disabled={disabled}
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"
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 ? (
{isBusy ? ( <Spinner size={13} />
<Spinner size={13} /> ) : hasNew ? (
) : hasNew ? ( <SparkleIcon size={11} className="text-brand-accent" />
<SparkleIcon size={11} className="text-brand-accent" /> ) : (
) : ( <DownloadIcon size={13} />
<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>
)} )}
</div> {label}
</button>
); );
} }
+5
View File
@@ -29,6 +29,7 @@ export const MOCK_PUBLICATIONS = [
doi: "10.1038/s41567-025-xxxx", doi: "10.1038/s41567-025-xxxx",
type: "journal-article", type: "journal-article",
last_modified: "2025-09-01T10:00:00Z", last_modified: "2025-09-01T10:00:00Z",
downloaded_by_me: false,
}, },
{ {
id: "uuid-2", id: "uuid-2",
@@ -40,6 +41,7 @@ export const MOCK_PUBLICATIONS = [
doi: "10.1000/jdr.2024.12", doi: "10.1000/jdr.2024.12",
type: "review", type: "review",
last_modified: "2024-11-12T09:00:00Z", last_modified: "2024-11-12T09:00:00Z",
downloaded_by_me: false,
}, },
{ {
id: "uuid-3", id: "uuid-3",
@@ -50,6 +52,7 @@ export const MOCK_PUBLICATIONS = [
doi: "10.1007/s11192-024-04801-z", doi: "10.1007/s11192-024-04801-z",
type: "journal-article", type: "journal-article",
last_modified: "2024-06-20T15:30:00Z", last_modified: "2024-06-20T15:30:00Z",
downloaded_by_me: true,
}, },
{ {
id: "uuid-4", id: "uuid-4",
@@ -60,6 +63,7 @@ export const MOCK_PUBLICATIONS = [
doi: "10.1145/3587-dl.2023.09", doi: "10.1145/3587-dl.2023.09",
type: "conference-paper", type: "conference-paper",
last_modified: "2023-10-05T11:45:00Z", last_modified: "2023-10-05T11:45:00Z",
downloaded_by_me: false,
}, },
{ {
id: "uuid-5", id: "uuid-5",
@@ -70,6 +74,7 @@ export const MOCK_PUBLICATIONS = [
doi: "10.1016/j.ijls.2023.03.011", doi: "10.1016/j.ijls.2023.03.011",
type: "journal-article", type: "journal-article",
last_modified: "2023-04-18T08:15:00Z", 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,
),
}));
}