diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 76683e6..e6534ea 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 `` 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 ( @@ -24,20 +42,24 @@ export default function App() { diff --git a/frontend/src/components/dashboard/ExportDropdown.jsx b/frontend/src/components/dashboard/ExportDropdown.jsx index 88e38d8..49729c9 100644 --- a/frontend/src/components/dashboard/ExportDropdown.jsx +++ b/frontend/src/components/dashboard/ExportDropdown.jsx @@ -16,6 +16,7 @@ import { export function ExportDropdown({ onExport, exportingFormat = null, + disabled = false, selectedCount = 0, isAuthenticated = false, newPublicationsCount = 0, @@ -61,7 +62,7 @@ export function ExportDropdown({ {isBusy ? ( diff --git a/frontend/src/components/dashboard/SyncButton.jsx b/frontend/src/components/dashboard/SyncButton.jsx index bca8420..0a95dd2 100644 --- a/frontend/src/components/dashboard/SyncButton.jsx +++ b/frontend/src/components/dashboard/SyncButton.jsx @@ -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 = "" }) { {isLoading ? ( diff --git a/frontend/src/index.css b/frontend/src/index.css index de2e2b1..c155ede 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; + } +} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index b0c60a4..23b19ee 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -23,6 +23,12 @@ import { import { useAuth } from "../contexts/AuthContext"; 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: @@ -52,7 +58,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, ); @@ -110,12 +124,60 @@ export function DashboardPage() { return () => ctrl.abort(); }, [orcid, loadBundle]); + 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 ; } 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 +194,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 +203,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; @@ -157,9 +233,9 @@ export function DashboardPage() { ids = newPublicationIds; if (ids.length === 0) { toast.info("No hay publicaciones nuevas", { + id: EXPORT_TOAST_ID, description: "Ya has descargado todas las publicaciones de este investigador.", }); - setExportingFormat(null); return; } } else { @@ -195,14 +271,18 @@ export function DashboardPage() { scope = "todo el investigador"; } 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 +307,13 @@ export function DashboardPage() { { @@ -95,6 +110,54 @@ export function GroupResultsPage() { return () => ctrl.abort(); }, [orcidIds, navigate]); + 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(() => { if (!isAuthenticated) return []; @@ -110,25 +173,30 @@ 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) { + if ( + globalExportInFlightRef.current || + Date.now() < globalExportCooldownUntilRef.current + ) { + return; + } + 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." }, + { + 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 @@ -151,14 +219,18 @@ export function GroupResultsPage() { URL.revokeObjectURL(objectUrl); } 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 +241,22 @@ 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 : 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, { @@ -194,10 +277,12 @@ export function GroupResultsPage() { URL.revokeObjectURL(objectUrl); } 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 +291,8 @@ export function GroupResultsPage() { delete next[orcidId]; return next; }); + cardExportInFlightRef.current.delete(orcidId); + startCardExportCooldown(orcidId); } } @@ -250,11 +337,14 @@ export function GroupResultsPage() { )} @@ -281,15 +371,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, + ); + }} /> ))} @@ -349,6 +446,7 @@ function ResearcherResultCard({ bundle, isAuthenticated, exporting, + exportCooldownActive, onExport, }) { const researcher = bundle.researcher ?? {}; @@ -419,92 +517,64 @@ function ResearcherResultCard({ > Ver detalle - onExport(fmt, newIds, allPubIds)} + onExport(newIds, allPubIds)} exporting={exporting} isAuthenticated={isAuthenticated} hasNew={hasNew} newCount={newCount} totalCount={totalRecords} + exportCooldownActive={exportCooldownActive} /> ); } -/* ─────────────────────── 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); + isBusy || + exportCooldownActive || + (isAuthenticated && !hasNew && totalCount > 0 && newCount === 0); 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 ( - - 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 ? ( - - ) : hasNew ? ( - - ) : ( - - )} - {label} - - - {open && ( - - {["xml", "zip"].map((fmt, idx) => ( - { - 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" : "" - }`} - > - - {fmt.toUpperCase()} - - ))} - + + {isBusy ? ( + + ) : hasNew ? ( + + ) : ( + )} - + {label} + ); }