mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat: allow toggling off author previews per user
This commit is contained in:
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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user