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 { 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 ? (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user