fix: address card preview review comments

Retry thumbnail adoption for a cached render, reset card and model-page
render state per print, scope the card-preview choice to the signed-in
account and put it back when saving fails.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-20 11:37:44 +02:00
1 parent 908ae80b5b
commit 678c4802d7
9 files changed
+73 -15

No files matched your search

+12 -2
View File
@@ -316,11 +316,21 @@ export async function generateModelImage(
): Promise<void> {
const errorPath = modelImageErrorPath(plateId, kind);
const key = `${kind}:${plateId}`;
if (!canRenderModelImage(filename) || fsSync.existsSync(modelImagePath(plateId, kind))) return;
if (!canRenderModelImage(filename)) return;
const cachedPath = modelImagePath(plateId, kind);
if (fsSync.existsSync(cachedPath)) {
// Still run onReady, so a step that failed after an earlier render gets another go.
try {
await onReady?.(cachedPath);
} catch (err) {
console.error(`Model picture post-processing failed for plate ${plateId}:`, err);
}
return;
}
if (imagesInFlight.has(key) || recentlyFailed(plateId, errorPath)) return;
imagesInFlight.add(key);
await scheduleRender("image", async () => {
const dest = modelImagePath(plateId, kind);
const dest = cachedPath;
const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`;
try {
const outcome = await renderPlateFile(plateId, srcPath, (from) =>
+2
View File
@@ -47,6 +47,7 @@ import { clearToken, clearUser, readToken, readUser, storeToken, storeUser } fro
import { type AppSettings, type MakerWorldSettings, loadSettings, saveSettings } from "./utils/settings";
import { useResolvedTheme } from "./hooks/useResolvedTheme";
import { setCachedAccentColor, useAccentColor } from "./hooks/useAccentColor";
import { resetCardPreview } from "./hooks/useCardPreview";
import type { ResolvedTheme, ThemeSelection } from "./constants/settingsOptions";
import { buildTheme } from "./theme";
@@ -353,6 +354,7 @@ export default function App() {
const authRequired = health?.auth_required ?? true;
const handleLogin = (tok: string, ttl: number, loggedInUser: AuthUser) => {
resetCardPreview();
storeToken(tok);
storeUser(loggedInUser);
setToken(tok);
@@ -5,7 +5,7 @@ import Select from "@mui/material/Select";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { UnauthorizedError } from "../../../api/client";
import { settingsApi } from "../../../api/settings";
import { settingsApi, type CardPreview } from "../../../api/settings";
import { matchLanguage, SUPPORTED_LANGUAGES } from "../../../constants/languages";
import { ACCENT_COLORS, type AccentColor, type ThemeSelection } from "../../../constants/settingsOptions";
import { setCachedAccentColor, useAccentColor } from "../../../hooks/useAccentColor";
@@ -36,6 +36,17 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
const accent = useAccentColor();
const [cardPreview, setCardPreview] = useCardPreview();
const [accentError, setAccentError] = React.useState<string | null>(null);
const [cardPreviewError, setCardPreviewError] = React.useState<string | null>(null);
const changeCardPreview = async (next: CardPreview) => {
setCardPreviewError(null);
try {
await setCardPreview(next);
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setCardPreviewError(t("appearance.cardPreview.failed"));
}
};
const changeAccent = async (next: AccentColor) => {
const previous = accent;
@@ -104,12 +115,17 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
label={t("appearance.cardPreview.heading")}
value={cardPreview ?? "images"}
disabled={cardPreview === null}
onChange={setCardPreview}
onChange={(next) => void changeCardPreview(next)}
options={[
{ value: "images", label: t("appearance.cardPreview.images") },
{ value: "renders", label: t("appearance.cardPreview.renders") },
]}
/>
{cardPreviewError && (
<Typography variant="caption" color="error" sx={{ display: "block", mt: 1 }}>
{cardPreviewError}
</Typography>
)}
</div>
<div>
<SectionLabel>{t("profile.language.heading")}</SectionLabel>
@@ -16,7 +16,7 @@ type Props<T extends string> = {
bare?: boolean;
};
/** Round colour swatches on Segmented's track; the chosen one is ringed and ticked. */
/** Round colour swatches on Segmented's track, or bare; the chosen one is ringed and ticked. */
export default function ColorSwatches<T extends string>({ value, options, onChange, label, bare }: Props<T>) {
return (
<Box
@@ -118,7 +118,14 @@ export function renderPreviewContent(
} else {
fallback = <PreviewPlaceholder text={t("library:modelViewer.noPreview")} />;
}
return <CardPicture print={print} previewMode={previewMode} fallback={fallback} />;
return (
<CardPicture
key={`${print.id}:${print.render_url ?? ""}`}
print={print}
previewMode={previewMode}
fallback={fallback}
/>
);
}
if (is3d) {
+28 -9
View File
@@ -5,19 +5,24 @@ import { settingsApi, type CardPreview } from "../api/settings";
// switch shows everywhere at once.
let cached: CardPreview | undefined;
let inFlight: Promise<CardPreview> | null = null;
// Bumped on sign-in, so an answer still on its way for the previous account is dropped.
let account = 0;
const listeners = new Set<(value: CardPreview) => void>();
function load(): Promise<CardPreview> {
if (cached !== undefined) return Promise.resolve(cached);
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.
inFlight ??= settingsApi
const request = settingsApi
.getCardPreview()
.then((res) => (cached ??= res.preview))
.then((res) => (forAccount === account ? (cached ??= res.preview) : res.preview))
.catch((): CardPreview => cached ?? "images")
.finally(() => {
inFlight = null;
if (inFlight === request) inFlight = null;
});
return inFlight;
inFlight = request;
return request;
}
function publish(value: CardPreview) {
@@ -25,8 +30,16 @@ function publish(value: CardPreview) {
listeners.forEach((listener) => listener(value));
}
/** Null until the saved choice is known, so cards don't flash the wrong picture first. */
export function useCardPreview(): [CardPreview | null, (preview: CardPreview) => void] {
/** Forgets the previous account's choice; call on sign-in. */
export function resetCardPreview() {
account++;
cached = undefined;
inFlight = null;
}
/** Null until the saved choice is known, so cards don't flash the wrong picture first. The setter shows the switch
* straight away, puts it back if the save fails, and rejects with the save's error. */
export function useCardPreview(): [CardPreview | null, (preview: CardPreview) => Promise<void>] {
const [value, setValue] = useState<CardPreview | null>(cached ?? null);
useEffect(() => {
@@ -41,10 +54,16 @@ export function useCardPreview(): [CardPreview | null, (preview: CardPreview) =>
};
}, []);
const update = (preview: CardPreview) => {
const update = async (preview: CardPreview) => {
const previous = cached ?? "images";
publish(preview);
// The switch already happened here; a failed save only means other devices won't follow.
settingsApi.updateCardPreview(preview).catch(() => {});
try {
await settingsApi.updateCardPreview(preview);
} catch (err) {
// Only if nothing newer was picked meanwhile.
if (cached === preview) publish(previous);
throw err;
}
};
return [value, update];
+1
View File
@@ -1589,6 +1589,7 @@
"heading": "Model cards",
"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."
}
},
+1
View File
@@ -1655,6 +1655,7 @@
"heading": "Modelių kortelės",
"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."
}
},
@@ -83,6 +83,7 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
if (cancelled) return;
setPrint(data);
setActiveImageIndex(0);
setRenderUnavailable(false);
} catch (err) {
if (cancelled) return;
if (err instanceof UnauthorizedError) {
@@ -230,6 +231,7 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
</>
) : showRender ? (
<ModelRender
key={print.id}
url={print.render_url!}
alt={print.title || print.name}
onUnavailable={onRenderUnavailable}