fix: order theme saves, reset hover slideshow per account, address review

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-20 13:51:54 +02:00
1 parent 4c301bb460
commit 6dac5555e7
9 files changed
+85 -45

No files matched your search

+2
View File
@@ -48,6 +48,7 @@ import { type AppSettings, type MakerWorldSettings, loadSettings, saveSettings }
import { useResolvedTheme } from "./hooks/useResolvedTheme";
import { setCachedAccentColor, setCachedRenderColor, useAccentColor } from "./hooks/useAccentColor";
import { resetCardPreview } from "./hooks/useCardPreview";
import { resetHoverSlideshowEnabled } from "./hooks/useHoverSlideshowEnabled";
import { DEFAULT_ACCENT, type ResolvedTheme, type ThemeSelection } from "./constants/settingsOptions";
import { buildTheme } from "./theme";
@@ -367,6 +368,7 @@ export default function App() {
const handleLogin = (tok: string, ttl: number, loggedInUser: AuthUser) => {
resetCardPreview();
resetHoverSlideshowEnabled();
storeToken(tok);
storeUser(loggedInUser);
setToken(tok);
@@ -24,7 +24,7 @@ import {
} from "../../../hooks/useAccentColor";
import { setCachedAuthorPreviewEnabled, useAuthorPreviewEnabled } from "../../../hooks/useAuthorPreviewEnabled";
import { useCardPreview } from "../../../hooks/useCardPreview";
import { setCachedHoverSlideshowEnabled, useHoverSlideshowEnabled } from "../../../hooks/useHoverSlideshowEnabled";
import { useHoverSlideshowEnabled } from "../../../hooks/useHoverSlideshowEnabled";
import { accentSwatch, renderSwatch } from "../../../theme";
import ColorSwatches from "../../controls/ColorSwatches";
import Segmented from "../../controls/Segmented";
@@ -52,12 +52,19 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
const [accentError, setAccentError] = React.useState<string | null>(null);
const renderColor = useRenderColor();
const [renderColorError, setRenderColorError] = React.useState<string | null>(null);
// Like the accent's: only the latest pick's failure may put the colour back.
const latestRenderColorSave = React.useRef(0);
// Picks can overlap; only the latest one's failure may put the accent back.
// Picks can overlap; only the latest one's failure may put the accent, or the render colour, back.
const latestAccentSave = React.useRef(0);
const latestRenderColorSave = React.useRef(0);
// Accent and render colour saves go one at a time and in order, as a new accent clears the render colour on the
// server: one sent first but answered later would otherwise undo a newer pick.
const themeSaves = React.useRef<Promise<unknown>>(Promise.resolve());
const queueThemeSave = (patch: Parameters<typeof settingsApi.updateTheme>[0]) => {
const request = themeSaves.current.then(() => settingsApi.updateTheme(patch));
themeSaves.current = request.catch(() => undefined);
return request;
};
const [cardPreviewError, setCardPreviewError] = React.useState<string | null>(null);
const hoverSlideshow = useHoverSlideshowEnabled();
const [hoverSlideshow, setHoverSlideshow] = useHoverSlideshowEnabled();
const [hoverSlideshowError, setHoverSlideshowError] = React.useState<string | null>(null);
const changeCardPreview = async (next: CardPreview) => {
@@ -74,17 +81,19 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
const previous = accent;
const previousRender = getCachedRenderColor();
const save = ++latestAccentSave.current;
const renderSave = latestRenderColorSave.current;
setAccentError(null);
// The whole app recolours straight away, renders included, as the server puts the render colour back to
// following the accent; put both back if the save fails.
setCachedAccentColor(next);
setCachedRenderColor(null);
try {
await settingsApi.updateTheme({ accent: next });
await queueThemeSave({ accent: next });
} catch (err) {
if (save !== latestAccentSave.current) return;
setCachedAccentColor(previous);
setCachedRenderColor(previousRender);
// A render colour picked since is newer than the one this save cleared.
if (renderSave === latestRenderColorSave.current) setCachedRenderColor(previousRender);
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setAccentError(t("appearance.accentFailed"));
}
@@ -96,7 +105,7 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
setRenderColorError(null);
setCachedRenderColor(next);
try {
await settingsApi.updateTheme({ renderColor: next });
await queueThemeSave({ renderColor: next });
} catch (err) {
if (save !== latestRenderColorSave.current) return;
setCachedRenderColor(previous);
@@ -107,13 +116,9 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
const toggleHoverSlideshow = async (enabled: boolean) => {
setHoverSlideshowError(null);
// Shown straight away; put back if the save fails.
setCachedHoverSlideshowEnabled(enabled);
try {
const res = await settingsApi.updateHoverSlideshow(enabled);
setCachedHoverSlideshowEnabled(res.enabled);
await setHoverSlideshow(enabled);
} catch (err) {
setCachedHoverSlideshowEnabled(!enabled);
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setHoverSlideshowError(t("appearance.hoverSlideshow.failed"));
}
+51 -20
View File
@@ -1,41 +1,55 @@
import { useEffect, useState } from "react";
import { settingsApi } from "../api/settings";
// Shared by every model card so there's one request, and saves propagate to all of them.
let cached: boolean | undefined; // undefined = not loaded yet
// Shared by every model card so there's one request, and a switch shows everywhere at once.
let cached: boolean | undefined;
let inFlight: Promise<boolean> | null = null;
// Bumped on sign-in, so an answer still on its way for the previous account is dropped.
let account = 0;
// Saves go one at a time, so the last pick is the one that sticks.
let saving: Promise<unknown> = Promise.resolve();
let latestSave = 0;
const listeners = new Set<(value: boolean) => void>();
function load(): Promise<boolean> {
if (cached !== undefined) return Promise.resolve(cached);
if (!inFlight) {
inFlight = settingsApi
.getHoverSlideshow()
.then((res) => {
cached = res.enabled;
return cached;
})
// A failed load shouldn't silently switch the feature off.
.catch(() => true)
.finally(() => {
inFlight = null;
});
}
return inFlight;
if (inFlight) return inFlight;
const forAccount = account;
// A switch made while this is in flight is newer than the server's answer, so it wins.
const request = settingsApi
.getHoverSlideshow()
.then((res) => (forAccount === account ? (cached ??= res.enabled) : res.enabled))
// A failed load shouldn't silently switch the feature off.
.catch(() => cached ?? true)
.finally(() => {
if (inFlight === request) inFlight = null;
});
inFlight = request;
return request;
}
export function setCachedHoverSlideshowEnabled(value: boolean) {
function publish(value: boolean) {
cached = value;
listeners.forEach((listener) => listener(value));
}
export function useHoverSlideshowEnabled(): boolean {
/** Forgets the previous account's choice; call on sign-in. */
export function resetHoverSlideshowEnabled() {
account++;
cached = undefined;
inFlight = null;
saving = Promise.resolve();
}
/** On until the saved choice says otherwise. The setter shows the switch straight away, puts it back if the latest
* save fails, and rejects with that save's error. */
export function useHoverSlideshowEnabled(): [boolean, (enabled: boolean) => Promise<void>] {
const [value, setValue] = useState<boolean>(cached ?? true);
useEffect(() => {
let cancelled = false;
void load().then((v) => {
if (!cancelled) setValue(v);
if (!cancelled) setValue(cached ?? v);
});
listeners.add(setValue);
return () => {
@@ -44,5 +58,22 @@ export function useHoverSlideshowEnabled(): boolean {
};
}, []);
return value;
const update = async (enabled: boolean) => {
const previous = cached ?? true;
const save = ++latestSave;
const forAccount = account;
publish(enabled);
const request = saving.then(() => (forAccount === account ? settingsApi.updateHoverSlideshow(enabled) : undefined));
saving = request.catch(() => undefined);
try {
await request;
} catch (err) {
// A newer pick, or another account, has taken over; this failure is no longer theirs to hear about.
if (save !== latestSave || forAccount !== account) return;
publish(previous);
throw err;
}
};
return [value, update];
}
+1 -1
View File
@@ -1579,7 +1579,7 @@
"images": "Images",
"renders": "3D renders",
"failed": "Couldn't save the model card choice.",
"help": "What model cards show first. Images: the pictures a model came with, or a 3D render where it has none. 3D renders: a render of every model in your accent color, or its pictures where the file can't be drawn."
"help": "What model cards show first. Images: the pictures a model came with, or a 3D render where it has none. 3D renders: a render of every model in your render color, or its pictures where the file can't be drawn."
},
"hoverSlideshow": {
"heading": "Hover slideshow",
+1 -1
View File
@@ -1579,7 +1579,7 @@
"images": "Imágenes",
"renders": "Renders 3D",
"failed": "No se pudo guardar la opción de las tarjetas de modelos.",
"help": "Lo que muestran primero las tarjetas de modelos. Imágenes: las imágenes que trae el modelo, o un render 3D si no tiene ninguna. Renders 3D: un render de cada modelo en tu color de acento, o sus imágenes si el archivo no se puede dibujar."
"help": "Lo que muestran primero las tarjetas de modelos. Imágenes: las imágenes que trae el modelo, o un render 3D si no tiene ninguna. Renders 3D: un render de cada modelo en tu color del render, o sus imágenes si el archivo no se puede dibujar."
},
"hoverSlideshow": {
"heading": "Presentación al pasar el cursor",
+1 -1
View File
@@ -1592,7 +1592,7 @@
"images": "Images",
"renders": "Rendus 3D",
"failed": "Impossible d’enregistrer le choix pour les cartes des modèles.",
"help": "Ce que les cartes des modèles affichent en premier. Images : les images fournies avec le modèle, ou un rendu 3D s’il n’en a pas. Rendus 3D : un rendu de chaque modèle dans votre couleur d’accent, ou ses images si le fichier ne peut pas être dessiné."
"help": "Ce que les cartes des modèles affichent en premier. Images : les images fournies avec le modèle, ou un rendu 3D s’il n’en a pas. Rendus 3D : un rendu de chaque modèle dans votre couleur de rendu, ou ses images si le fichier ne peut pas être dessiné."
},
"hoverSlideshow": {
"heading": "Diaporama au survol",
+1 -1
View File
@@ -1645,7 +1645,7 @@
"images": "Nuotraukos",
"renders": "3D vaizdai",
"failed": "Nepavyko išsaugoti modelių kortelių pasirinkimo.",
"help": "Ką modelių kortelės rodo pirmiausia. Nuotraukos: paveikslėliai, su kuriais modelis atkeliavo, arba 3D vaizdas, jei jų nėra. 3D vaizdai: kiekvieno modelio vaizdas jūsų akcento spalva arba jo nuotraukos, jei failo nupiešti nepavyksta."
"help": "Ką modelių kortelės rodo pirmiausia. Nuotraukos: paveikslėliai, su kuriais modelis atkeliavo, arba 3D vaizdas, jei jų nėra. 3D vaizdai: kiekvieno modelio vaizdas jūsų atvaizdo spalva arba jo nuotraukos, jei failo nupiešti nepavyksta."
},
"hoverSlideshow": {
"heading": "Skaidrės užvedus",
@@ -73,13 +73,15 @@ function ProviderRow({ provider, countLabel }: { provider: string | null; countL
</>
);
return openAbout ? (
<ListItemButton
onClick={openAbout}
aria-label={`${name}, ${countLabel}. ${t("providerAbout.aboutName", { name })}`}
sx={{ borderRadius: 1, px: 1 }}
>
{content}
</ListItemButton>
<ListItem disablePadding>
<ListItemButton
onClick={openAbout}
aria-label={`${name}, ${countLabel}. ${t("providerAbout.aboutName", { name })}`}
sx={{ borderRadius: 1, px: 1 }}
>
{content}
</ListItemButton>
</ListItem>
) : (
<ListItem sx={{ px: 1 }}>{content}</ListItem>
);
+1 -1
View File
@@ -98,7 +98,7 @@ export default function ModelCard({
const authorAvatarUrl = author?.avatar_url || (showViewerAsAuthor ? viewerAvatarUrl : undefined);
// Only mounted while hovered, so idle cards don't load images or tick.
const [hovered, setHovered] = useState(false);
const slideshowEnabled = useHoverSlideshowEnabled();
const [slideshowEnabled] = useHoverSlideshowEnabled();
const contextMenu = useCardContextMenu();
const toggleSelect = (event: React.MouseEvent | React.KeyboardEvent) => onToggleSelect?.(event.shiftKey);
const slideshowImages =