diff --git a/web/src/app/Session.js b/web/src/app/Session.js index 976e9f3c..58c69ceb 100644 --- a/web/src/app/Session.js +++ b/web/src/app/Session.js @@ -1,4 +1,5 @@ import Dexie from "dexie"; +import { fadeOut } from "./transition"; /** * Manages the logged-in user's session and access token. @@ -42,7 +43,13 @@ class Session { localStorage.setItem("token", token); } - async resetAndRedirect(url) { + async resetAndRedirect(url, { fade = false } = {}) { + // For user-initiated exits (logout, account deletion) fade the app out first -- while the data + // is still intact -- so the wipe + reload below doesn't flash a broken/empty UI. Error-driven + // redirects (e.g. an expired session) pass no options and cut straight to the target page. + if (fade) { + await fadeOut(); + } await this.db.delete(); localStorage.removeItem("user"); localStorage.removeItem("token"); diff --git a/web/src/app/transition.js b/web/src/app/transition.js index 641d22a7..173348c5 100644 --- a/web/src/app/transition.js +++ b/web/src/app/transition.js @@ -4,15 +4,20 @@ const FADE_MS = 150; -// Fade #root out and return it (or null if not found / no document). -const fadeOutRoot = () => { - const node = document.getElementById("root"); - if (node) { +// Fade #root out over FADE_MS, resolving once the fade has finished (immediately if #root isn't in +// the document). Exported so callers that need to run their own teardown between the fade-out and +// the reload -- e.g. session.resetAndRedirect() wiping IndexedDB -- can await it first. +export const fadeOut = () => + new Promise((resolve) => { + const node = document.getElementById("root"); + if (!node) { + resolve(); + return; + } node.style.transition = `opacity ${FADE_MS}ms ease-out`; node.style.opacity = "0"; - } - return node; -}; + setTimeout(resolve, FADE_MS); + }); // Fade #root back in, then remove the inline styles we added so nothing is left behind on the // element -- otherwise the lingering `transition` would silently animate any future opacity change @@ -33,24 +38,16 @@ const fadeInRoot = () => { // Fade the app out, run a client-side navigation, then fade the new page back in. Used for // app -> login/signup, which stay within the same document (no reload). export const fadeNavigate = (navigate, to) => { - if (!fadeOutRoot()) { - navigate(to); - return; - } - setTimeout(() => { + fadeOut().then(() => { navigate(to); fadeInRoot(); - }, FADE_MS); + }); }; // Fade the app out, then do a full page reload to `url`. Used for login/signup -> app, which must // reload (the per-user IndexedDB changes). The splash screen fades the reloaded app back in. export const fadeReload = (url) => { - if (!fadeOutRoot()) { + fadeOut().then(() => { window.location.href = url; - return; - } - setTimeout(() => { - window.location.href = url; - }, FADE_MS); + }); }; diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index b22d0167..be0a4a51 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -1396,7 +1396,7 @@ const DeleteAccountDialog = (props) => { await accountApi.delete(password); await db().delete(); console.debug(`[Account] Account deleted`); - await session.resetAndRedirect(routes.app); + await session.resetAndRedirect(routes.app, { fade: true }); } catch (e) { console.log(`[Account] Error deleting account`, e); if (e instanceof IncorrectPasswordError) { diff --git a/web/src/components/ActionBar.jsx b/web/src/components/ActionBar.jsx index d3afe2c6..13483f9c 100644 --- a/web/src/components/ActionBar.jsx +++ b/web/src/components/ActionBar.jsx @@ -173,7 +173,7 @@ const ProfileIcon = () => { await accountApi.logout(); await db().delete(); } finally { - await session.resetAndRedirect(routes.app); + await session.resetAndRedirect(routes.app, { fade: true }); } }; diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx index e617deed..f057ed31 100644 --- a/web/src/components/Preferences.jsx +++ b/web/src/components/Preferences.jsx @@ -43,7 +43,7 @@ import accountApi, { Permission, Role } from "../app/AccountApi"; import { Pref, PrefGroup } from "./Pref"; import AccountContext from "./AccountContext"; import { Paragraph } from "./styles"; -import prefs, { THEME } from "../app/Prefs"; +import prefs, { THEME, THEME_LOCALSTORAGE_KEY } from "../app/Prefs"; import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; import { UnauthorizedError } from "../app/errors"; @@ -230,7 +230,10 @@ const DeleteAfter = () => { const Theme = () => { const { t } = useTranslation(); const labelId = "prefTheme"; - const theme = useCachedPref(() => prefs.theme(), "pref.theme", THEME.SYSTEM); + // Reuse the same localStorage key Prefs already mirrors for the splash (THEME_LOCALSTORAGE_KEY), + // rather than a second cache key, so the value isn't stored twice. The cached value renders the + // dropdown instantly; the live query then confirms it from IndexedDB. + const theme = useLiveQuery(() => prefs.theme()) ?? localStorage.getItem(THEME_LOCALSTORAGE_KEY) ?? THEME.SYSTEM; const handleChange = async (ev) => { await prefs.setTheme(ev.target.value); };