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:
+29
-7
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ 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";
|
||||
|
||||
/**
|
||||
* Researcher detail page. Owns:
|
||||
@@ -52,6 +55,10 @@ 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 [exportDestination, setExportDestination] = useState(
|
||||
DEFAULT_EXPORT_DESTINATION,
|
||||
@@ -110,12 +117,44 @@ export function DashboardPage() {
|
||||
return () => ctrl.abort();
|
||||
}, [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)) {
|
||||
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 +171,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,8 +180,12 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +271,7 @@ export function DashboardPage() {
|
||||
<SyncButton
|
||||
onClick={handleSync}
|
||||
status={syncStatus}
|
||||
disabled={syncDisabled}
|
||||
className="w-full sm:w-auto"
|
||||
/>
|
||||
<ExportDropdown
|
||||
|
||||
Reference in New Issue
Block a user