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.
This commit is contained in:
Alexis
2026-06-02 13:30:04 +02:00
parent 9edf0306bb
commit d58e56aeb1
4 changed files with 173 additions and 9 deletions
+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>
@@ -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;
}
}
+45
View File
@@ -23,6 +23,9 @@ import {
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
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";
/** /**
* Researcher detail page. Owns: * Researcher detail page. Owns:
@@ -52,6 +55,10 @@ 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 [exportDestination, setExportDestination] = useState( const [exportDestination, setExportDestination] = useState(
DEFAULT_EXPORT_DESTINATION, DEFAULT_EXPORT_DESTINATION,
@@ -110,12 +117,44 @@ export function DashboardPage() {
return () => ctrl.abort(); return () => ctrl.abort();
}, [orcid, loadBundle]); }, [orcid, loadBundle]);
useEffect(() => {
return () => {
if (syncCooldownTimerRef.current) {
clearTimeout(syncCooldownTimerRef.current);
}
};
}, []);
const syncDisabled = syncStatus !== "idle" || syncCooldownActive;
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);
}
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 +171,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,8 +180,12 @@ 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();
} }
} }
@@ -227,6 +271,7 @@ 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