feat: allow toggling off author previews per user

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-21 21:36:25 +02:00
1 parent d4ea294dd9
commit 711ece0f62
11 files changed
+217 -2

No files matched your search

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "authorPreviewEnabled" BOOLEAN NOT NULL DEFAULT true;
+3
View File
@@ -40,6 +40,9 @@ model User {
// default rather than to a stored value. Server-persisted (not just localStorage) so it follows
// the account across devices/browsers, same reasoning as slicer/makerworldCookie above.
themePreference String?
// Whether hovering an author link opens the author preview card (frontend's AuthorHoverCard).
// On by default -- see services/authorPreviewPreferenceService.ts.
authorPreviewEnabled Boolean @default(true)
// Populated by linking an imported Author to this account (the Author page's "It's me!"
// button -- see AuthorLink below) rather than edited directly: the first link with bio/cover
// data fills these in if they're still empty, later links never overwrite them. Shown on this
+19
View File
@@ -32,6 +32,7 @@ import { type MakerworldCookieCheck, verifyMakerworldCookie } from "../services/
import { verifyThingiverseAccessToken } from "../services/thingiverseApi";
import { SLICER_IDS, getUserSlicer, setUserSlicer } from "../services/slicerPreferenceService";
import { THEME_SELECTIONS, getUserTheme, setUserTheme } from "../services/themePreferenceService";
import { getUserAuthorPreviewEnabled, setUserAuthorPreviewEnabled } from "../services/authorPreviewPreferenceService";
import { checkForUpdates } from "../services/versionService";
const router = Router();
@@ -358,4 +359,22 @@ router.patch(
}),
);
// Per-user on/off for the author preview card shown on hovering an author link -- see
// services/authorPreviewPreferenceService.ts. On by default.
router.get(
"/settings/author-preview",
asyncHandler(async (req, res) => {
res.json({ enabled: await getUserAuthorPreviewEnabled(req.userId!) });
}),
);
const authorPreviewSettingsSchema = z.object({ enabled: z.boolean() });
router.patch(
"/settings/author-preview",
asyncHandler(async (req, res) => {
const body = parseBody(authorPreviewSettingsSchema, req.body);
res.json({ enabled: await setUserAuthorPreviewEnabled(req.userId!, body.enabled) });
}),
);
export default router;
@@ -0,0 +1,19 @@
import { prisma } from "../db";
// Whether hovering an author link opens the author preview card (frontend's AuthorHoverCard).
// A plain per-user on/off switch, on by default (the column's own default), toggled from the
// Profile page.
export async function getUserAuthorPreviewEnabled(userId: string): Promise<boolean> {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { authorPreviewEnabled: true } });
return user?.authorPreviewEnabled ?? true;
}
export async function setUserAuthorPreviewEnabled(userId: string, enabled: boolean): Promise<boolean> {
const user = await prisma.user.update({
where: { id: userId },
data: { authorPreviewEnabled: enabled },
select: { authorPreviewEnabled: true },
});
return user.authorPreviewEnabled;
}
+21
View File
@@ -275,4 +275,25 @@ export const settingsApi = {
}
return res.json();
},
// Per-user on/off for the author preview card shown on hovering an author link (see
// hooks/useAuthorPreviewEnabled.ts). On by default server-side.
getAuthorPreview: async (): Promise<{ enabled: boolean }> => {
const res = await fetch(`${apiBase()}/settings/author-preview`, { headers: authHeaders() });
assertOk(res, "Failed to load author preview setting");
return res.json();
},
updateAuthorPreview: async (enabled: boolean): Promise<{ enabled: boolean }> => {
const res = await fetch(`${apiBase()}/settings/author-preview`, {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ enabled }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) {
throw new Error(await readErrorMessage(res, "Failed to update author preview setting"));
}
return res.json();
},
};
+5 -2
View File
@@ -14,6 +14,7 @@ import { type Print, printsApi } from "../api/prints";
import { printProviderInfo } from "../constants/importProviders";
import { SELF_AUTHOR_ID } from "../constants/selfAuthor";
import { useGravatarUrl } from "../hooks/useGravatarUrl";
import { useAuthorPreviewEnabled } from "../hooks/useAuthorPreviewEnabled";
const PREVIEW_MODEL_COUNT = 3;
// The card's width follows from its one-row model grid: PREVIEW_MODEL_COUNT squares of
@@ -215,9 +216,11 @@ type Props = CardProps & {
/** Wraps an author link so resting the pointer on it (or focusing it) opens a preview card: the
* author's cover, avatar/name/@handle, how many of their models are in this library, and their
* latest few. `authorId` may be SELF_AUTHOR_ID for the viewer's own uploads (pass `viewer`).
* Nothing is fetched until the card actually opens. */
* Nothing is fetched until the card actually opens. Off entirely when the user has turned the
* preview off on their Profile page. */
export default function AuthorHoverCard({ authorId, viewer, children, disabled }: Props) {
if (disabled) return children;
const enabled = useAuthorPreviewEnabled();
if (disabled || !enabled) return children;
return (
<Tooltip
title={<AuthorPreviewCard authorId={authorId} viewer={viewer} />}
@@ -0,0 +1,44 @@
import { useEffect, useState } from "react";
import { settingsApi } from "../api/settings";
// Module-level cache, same pattern as useSlicerPreference: every author link on a page (one per
// model card) shares a single GET /settings/author-preview, and the Profile page's switch pushes
// a freshly-saved value into all of them via setCachedAuthorPreviewEnabled below.
let cached: boolean | undefined; // undefined = not loaded yet
let inFlight: Promise<boolean> | null = null;
const listeners = new Set<(value: boolean) => void>();
function load(): Promise<boolean> {
if (cached !== undefined) return Promise.resolve(cached);
if (!inFlight) {
inFlight = settingsApi.getAuthorPreview()
.then(res => { cached = res.enabled; return cached; })
// On by default: a failed load shouldn't silently switch the feature off.
.catch(() => true)
.finally(() => { inFlight = null; });
}
return inFlight;
}
export function setCachedAuthorPreviewEnabled(value: boolean) {
cached = value;
listeners.forEach(listener => listener(value));
}
/** Whether hovering an author link should open the author preview card -- the user's Profile
* setting, true until it's loaded (the default) and kept in sync across every mounted link. */
export function useAuthorPreviewEnabled(): boolean {
const [value, setValue] = useState<boolean>(cached ?? true);
useEffect(() => {
let cancelled = false;
void load().then(v => { if (!cancelled) setValue(v); });
listeners.add(setValue);
return () => {
cancelled = true;
listeners.delete(setValue);
};
}, []);
return value;
}
+6
View File
@@ -158,6 +158,12 @@
"failed": "Failed to save -- try again.",
"bridgeRequiredPrefix": "{{slicer}} needs the Thingport Bridge helper app installed to open files from here --",
"bridgeRequiredLink": "get it from the Download page."
},
"authorPreview": {
"heading": "Author preview",
"description": "Show a preview card with the author's cover, details and latest models when you hover an author's name or avatar.",
"label": "Show author preview on hover",
"failed": "Failed to save -- try again."
}
},
"addMenu": {
+6
View File
@@ -158,6 +158,12 @@
"failed": "Nepavyko išsaugoti -- bandykite dar kartą.",
"bridgeRequiredPrefix": "{{slicer}} failams atverti iš čia reikalinga įdiegta Thingport Bridge pagalbinė programa --",
"bridgeRequiredLink": "atsisiųskite ją atsisiuntimo puslapyje."
},
"authorPreview": {
"heading": "Autoriaus peržiūra",
"description": "Užvedus pelę ant autoriaus vardo ar avataro, rodyti kortelę su autoriaus viršelio nuotrauka, informacija ir naujausiais modeliais.",
"label": "Rodyti autoriaus peržiūrą užvedus pelę",
"failed": "Nepavyko išsaugoti -- bandykite dar kartą."
}
},
"addMenu": {
@@ -0,0 +1,88 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import FormControlLabel from "@mui/material/FormControlLabel";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Switch from "@mui/material/Switch";
import Typography from "@mui/material/Typography";
import { UnauthorizedError } from "../../api/client";
import { settingsApi } from "../../api/settings";
import { setCachedAuthorPreviewEnabled } from "../../hooks/useAuthorPreviewEnabled";
type Props = {
onUnauthorized?: () => void;
};
/** Profile switch for the author preview card shown on hovering an author link (see
* components/AuthorHoverCard.tsx). Stored server-side so it follows the account. */
export default function AuthorPreviewSetting({ onUnauthorized }: Props) {
const { t } = useTranslation(["app"]);
const [enabled, setEnabled] = React.useState(true);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [status, setStatus] = React.useState<string | null>(null);
React.useEffect(() => {
let active = true;
settingsApi.getAuthorPreview()
.then(res => { if (active) setEnabled(res.enabled); })
.catch(err => {
if (active && err instanceof UnauthorizedError) onUnauthorized?.();
})
.finally(() => { if (active) setLoading(false); });
return () => { active = false; };
}, [onUnauthorized]);
const handleChange = async (next: boolean) => {
const previous = enabled;
setEnabled(next);
setSaving(true);
setStatus(null);
try {
const res = await settingsApi.updateAuthorPreview(next);
setEnabled(res.enabled);
setCachedAuthorPreviewEnabled(res.enabled);
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
setEnabled(previous);
setStatus(err instanceof Error ? err.message : t("profile.authorPreview.failed"));
} finally {
setSaving(false);
}
};
return (
<Box>
<Typography variant="subtitle1" fontWeight={600} sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}>
{t("profile.authorPreview.heading")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
{t("profile.authorPreview.description")}
</Typography>
<Paper
variant="outlined"
sx={{ p: 2.5, borderColor: (muiTheme) => (muiTheme.palette.mode === "dark" ? "transparent" : "divider") }}
>
<Stack direction="row" spacing={1.5} alignItems="center">
<FormControlLabel
control={
<Switch
checked={enabled}
disabled={loading || saving}
onChange={e => void handleChange(e.target.checked)}
/>
}
label={t("profile.authorPreview.label")}
/>
{saving && <CircularProgress size={16} />}
</Stack>
{status && <Typography variant="caption" color="error" sx={{ display: "block", mt: 1 }}>{status}</Typography>}
</Paper>
</Box>
);
}
+4
View File
@@ -12,6 +12,7 @@ import { useGravatarUrl } from "../../hooks/useGravatarUrl";
import LanguagePicker from "./LanguagePicker";
import MakerworldCookieSection from "./MakerworldCookieSection";
import SlicerPicker from "./SlicerPicker";
import AuthorPreviewSetting from "./AuthorPreviewSetting";
type Props = {
user: AuthUser | null;
@@ -70,6 +71,9 @@ export default function ProfilePage({ user, makerworldCookie, onUpdateMakerWorld
<Divider />
<LanguagePicker />
<Divider />
<AuthorPreviewSetting onUnauthorized={onUnauthorized} />
</Stack>
);
}