diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 84f62b2..1a54571 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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); diff --git a/frontend/src/components/Layout/ConfigurationDialog/AppearancePanel.tsx b/frontend/src/components/Layout/ConfigurationDialog/AppearancePanel.tsx index cf469b0..5c15af5 100644 --- a/frontend/src/components/Layout/ConfigurationDialog/AppearancePanel.tsx +++ b/frontend/src/components/Layout/ConfigurationDialog/AppearancePanel.tsx @@ -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(null); const renderColor = useRenderColor(); const [renderColorError, setRenderColorError] = React.useState(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.resolve()); + const queueThemeSave = (patch: Parameters[0]) => { + const request = themeSaves.current.then(() => settingsApi.updateTheme(patch)); + themeSaves.current = request.catch(() => undefined); + return request; + }; const [cardPreviewError, setCardPreviewError] = React.useState(null); - const hoverSlideshow = useHoverSlideshowEnabled(); + const [hoverSlideshow, setHoverSlideshow] = useHoverSlideshowEnabled(); const [hoverSlideshowError, setHoverSlideshowError] = React.useState(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")); } diff --git a/frontend/src/hooks/useHoverSlideshowEnabled.ts b/frontend/src/hooks/useHoverSlideshowEnabled.ts index 51b0973..bcf6504 100644 --- a/frontend/src/hooks/useHoverSlideshowEnabled.ts +++ b/frontend/src/hooks/useHoverSlideshowEnabled.ts @@ -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 | 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 = Promise.resolve(); +let latestSave = 0; const listeners = new Set<(value: boolean) => void>(); function load(): Promise { 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] { const [value, setValue] = useState(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]; } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e8af555..ca921da 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index ba19ad8..b497f65 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -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", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index b75a4cb..85d18ba 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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", diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index 2ca1aa4..7d8c2b0 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -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", diff --git a/frontend/src/pages/DashboardPage/ProviderListCard.tsx b/frontend/src/pages/DashboardPage/ProviderListCard.tsx index 23e8160..4ff77ae 100644 --- a/frontend/src/pages/DashboardPage/ProviderListCard.tsx +++ b/frontend/src/pages/DashboardPage/ProviderListCard.tsx @@ -73,13 +73,15 @@ function ProviderRow({ provider, countLabel }: { provider: string | null; countL ); return openAbout ? ( - - {content} - + + + {content} + + ) : ( {content} ); diff --git a/frontend/src/pages/ModelsPage/ModelCard.tsx b/frontend/src/pages/ModelsPage/ModelCard.tsx index 9b5a7cc..9ad5559 100644 --- a/frontend/src/pages/ModelsPage/ModelCard.tsx +++ b/frontend/src/pages/ModelsPage/ModelCard.tsx @@ -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 =