feat(dashboard): make dashboard panes toggleable

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-07 18:13:24 +02:00
1 parent 0cc27c9eb6
commit 3d1c851ad7
12 files changed
+499 -77

No files matched your search

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "dashboardWidgets" JSONB;
+3
View File
@@ -34,6 +34,9 @@ model User {
themePreference String?
// Which tree the models page shows: "categories" | "folders"; null means categories.
categoriesView String?
// The dashboard widgets the user has switched on or off, as { [widgetId]: boolean }. A widget
// missing from it shows or hides by its own default, so new widgets need no migration.
dashboardWidgets Json?
authorPreviewEnabled Boolean @default(true)
// Filled from the first linked Author with this data; later links never overwrite it.
bio String?
+18
View File
@@ -35,6 +35,7 @@ import { type MakerworldCookieCheck, verifyMakerworldCookie } from "../services/
import { verifyThingiverseAccessToken } from "../services/thingiverseApi";
import { SLICER_IDS, getUserSlicer, setUserSlicer } from "../services/slicerPreferenceService";
import { CATEGORIES_VIEWS, getCategoriesView, setCategoriesView } from "../services/categoriesViewService";
import { DASHBOARD_WIDGETS, getDashboardWidgets, setDashboardWidgets } from "../services/dashboardWidgetsService";
import {
CONSUME_MODES,
NOT_IMPORTED_DIR,
@@ -426,6 +427,23 @@ router.patch(
}),
);
router.get(
"/settings/dashboard-widgets",
asyncHandler(async (req, res) => {
res.json({ widgets: await getDashboardWidgets(req.userId!) });
}),
);
// Only the widgets being changed; the rest keep what's saved.
const dashboardWidgetsSchema = z.object({ widgets: z.record(z.enum(DASHBOARD_WIDGETS), z.boolean()) });
router.patch(
"/settings/dashboard-widgets",
asyncHandler(async (req, res) => {
const body = parseBody(dashboardWidgetsSchema, req.body);
res.json({ widgets: await setDashboardWidgets(req.userId!, body.widgets) });
}),
);
router.get(
"/settings/author-preview",
asyncHandler(async (req, res) => {
@@ -0,0 +1,47 @@
import { prisma } from "../db";
// Kept in sync by hand with the frontend's DashboardWidgetId.
export const DASHBOARD_WIDGETS = [
"collectionCount",
"modelCount",
"authorCount",
"categoryCount",
"topViewed",
"topPrinted",
"recentlyAdded",
"topAuthors",
"topProviders",
] as const;
export type DashboardWidgetId = (typeof DASHBOARD_WIDGETS)[number];
export type DashboardWidgets = Partial<Record<DashboardWidgetId, boolean>>;
const KNOWN = new Set<string>(DASHBOARD_WIDGETS);
/** Drops anything that isn't a current widget's on/off, e.g. a widget since removed. */
function clean(value: unknown): DashboardWidgets {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const out: DashboardWidgets = {};
for (const [id, enabled] of Object.entries(value)) {
if (KNOWN.has(id) && typeof enabled === "boolean") out[id as DashboardWidgetId] = enabled;
}
return out;
}
/** Only what the user has changed; the frontend knows each widget's default. Stored per user, not
* per browser, so every device shows the same dashboard. */
export async function getDashboardWidgets(userId: string): Promise<DashboardWidgets> {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { dashboardWidgets: true } });
return clean(user?.dashboardWidgets);
}
export async function setDashboardWidgets(userId: string, changes: DashboardWidgets): Promise<DashboardWidgets> {
// Merged in the database in one statement, so two quick toggles can't drop each other's change.
const rows = await prisma.$queryRaw<{ dashboardWidgets: unknown }[]>`
UPDATE "User"
SET "dashboardWidgets" = COALESCE("dashboardWidgets", '{}'::jsonb) || ${JSON.stringify(changes)}::jsonb,
-- Raw SQL skips Prisma's @updatedAt.
"updatedAt" = NOW()
WHERE "id" = ${userId}
RETURNING "dashboardWidgets"`;
return clean(rows[0]?.dashboardWidgets);
}
+48
View File
@@ -0,0 +1,48 @@
import { beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
const app = createApp();
const stamp = Date.now();
let token: string;
const auth = () => ({ Authorization: `Bearer ${token}` });
const get = async () => (await request(app).get("/api/settings/dashboard-widgets").set(auth())).body.widgets;
const patch = (widgets: unknown) => request(app).patch("/api/settings/dashboard-widgets").set(auth()).send({ widgets });
beforeAll(async () => {
token = (
await request(app)
.post("/api/register")
.send({ displayName: "Widgets", email: `widgets-${stamp}@example.com`, password: "password123" })
).body.token;
});
describe("dashboard widget preferences", () => {
it("starts with nothing changed", async () => {
expect(await get()).toEqual({});
});
it("merges each change into what's saved", async () => {
expect((await patch({ topViewed: false })).body.widgets).toEqual({ topViewed: false });
expect((await patch({ topProviders: false, topViewed: true })).body.widgets).toEqual({
topViewed: true,
topProviders: false,
});
expect(await get()).toEqual({ topViewed: true, topProviders: false });
});
it("keeps both of two toggles sent at once", async () => {
await Promise.all([patch({ modelCount: false }), patch({ authorCount: false })]);
expect(await get()).toMatchObject({ modelCount: false, authorCount: false });
});
it("rejects an unknown widget or a non-boolean", async () => {
expect((await patch({ weather: false })).status).toBe(400);
expect((await patch({ topViewed: "no" })).status).toBe(400);
});
it("needs a signed-in user", async () => {
expect((await request(app).get("/api/settings/dashboard-widgets")).status).toBe(401);
});
});
+31
View File
@@ -13,6 +13,19 @@ export type StorageSettings = {
/** Which tree the models page shows. Kept in sync by hand with the backend's CATEGORIES_VIEWS. */
export type CategoriesView = "categories" | "folders";
// Kept in sync by hand with the backend's DASHBOARD_WIDGETS.
export type DashboardWidgetId =
| "collectionCount"
| "modelCount"
| "authorCount"
| "categoryCount"
| "topViewed"
| "topPrinted"
| "recentlyAdded"
| "topAuthors"
| "topProviders";
/** Only the widgets the user has switched; the rest follow their default. */
export type DashboardWidgetChoices = Partial<Record<DashboardWidgetId, boolean>>;
export type PreviewMode = "automatic" | "on-demand" | "disabled";
@@ -315,6 +328,24 @@ export const settingsApi = {
return res.json();
},
getDashboardWidgets: async (): Promise<{ widgets: DashboardWidgetChoices }> => {
const res = await fetch(`${apiBase()}/settings/dashboard-widgets`, { headers: authHeaders() });
assertOk(res, "Failed to load the dashboard widget settings");
return res.json();
},
/** Sends only the changed widgets; the server merges them into what's saved. */
updateDashboardWidgets: async (widgets: DashboardWidgetChoices): Promise<{ widgets: DashboardWidgetChoices }> => {
const res = await fetch(`${apiBase()}/settings/dashboard-widgets`, {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ widgets }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update the dashboard widget settings"));
return res.json();
},
getConsume: async (): Promise<ConsumeSettings> => {
const res = await fetch(`${apiBase()}/settings/consume`, { headers: authHeaders() });
assertOk(res, "Failed to load consume folder settings");
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useState } from "react";
import { settingsApi, type DashboardWidgetChoices, type DashboardWidgetId } from "../api/settings";
// Saved on the account so every device shows the same dashboard. Shared by every user of the hook
// so there's one request, and a toggle shows everywhere at once.
let cached: DashboardWidgetChoices | undefined;
let inFlight: Promise<DashboardWidgetChoices> | null = null;
const listeners = new Set<(value: DashboardWidgetChoices) => void>();
function load(): Promise<DashboardWidgetChoices> {
if (cached !== undefined) return Promise.resolve(cached);
inFlight ??= settingsApi
.getDashboardWidgets()
// A toggle made while this is in flight is newer than the server's answer, so it wins.
.then((res) => (cached = { ...res.widgets, ...cached }))
// A failed load shows every widget at its default rather than an empty dashboard.
.catch((): DashboardWidgetChoices => cached ?? {})
.finally(() => {
inFlight = null;
});
return inFlight;
}
function publish(value: DashboardWidgetChoices) {
cached = value;
listeners.forEach((listener) => listener(value));
}
/** Null until the saved choices are known, so the page doesn't flash widgets the user hid. */
export function useDashboardWidgets(): [
DashboardWidgetChoices | null,
(id: DashboardWidgetId, enabled: boolean) => void,
] {
const [value, setValue] = useState<DashboardWidgetChoices | null>(cached ?? null);
useEffect(() => {
let cancelled = false;
void load().then((v) => {
if (!cancelled) setValue(cached ?? v);
});
listeners.add(setValue);
return () => {
cancelled = true;
listeners.delete(setValue);
};
}, []);
const setEnabled = (id: DashboardWidgetId, enabled: boolean) => {
publish({ ...cached, [id]: enabled });
// The toggle already happened here; a failed save only means other devices won't follow.
settingsApi.updateDashboardWidgets({ [id]: enabled }).catch(() => {});
};
return [value, setEnabled];
}
+7
View File
@@ -311,6 +311,13 @@
"recentlyAdded": {
"title": "Recently Added",
"empty": "Nothing added yet — uploaded and imported models will show up here."
},
"widgets": {
"open": "Customize dashboard",
"title": "Customize dashboard",
"subtitle": "Choose which boxes your dashboard shows.",
"close": "Close",
"allHidden": "Every dashboard box is hidden."
}
},
"adminSettings": {
+7
View File
@@ -311,6 +311,13 @@
"recentlyAdded": {
"title": "Naujausiai pridėti",
"empty": "Dar nieko nepridėta — įkelti ir importuoti modeliai atsiras čia."
},
"widgets": {
"open": "Tvarkyti valdymo skydą",
"title": "Tvarkyti valdymo skydą",
"subtitle": "Pasirinkite, kuriuos langelius rodo jūsų valdymo skydas.",
"close": "Uždaryti",
"allHidden": "Visi valdymo skydo langeliai paslėpti."
}
},
"adminSettings": {
@@ -0,0 +1,57 @@
import { useTranslation } from "react-i18next";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import FormControlLabel from "@mui/material/FormControlLabel";
import IconButton from "@mui/material/IconButton";
import Stack from "@mui/material/Stack";
import Switch from "@mui/material/Switch";
import Typography from "@mui/material/Typography";
import CloseIcon from "@mui/icons-material/Close";
import type { DashboardWidgetChoices, DashboardWidgetId } from "../../api/settings";
import { DASHBOARD_WIDGETS, isWidgetEnabled } from "./widgets";
type Props = {
open: boolean;
choices: DashboardWidgetChoices;
onToggle: (id: DashboardWidgetId, enabled: boolean) => void;
onClose: () => void;
};
/** Each switch applies straight away, so the dashboard behind updates as the user goes. */
export default function DashboardWidgetsDialog({ open, choices, onToggle, onClose }: Props) {
const { t } = useTranslation("app");
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2 }}>
<Stack>
{t("dashboard.widgets.title")}
<Typography variant="body2" color="text.secondary">
{t("dashboard.widgets.subtitle")}
</Typography>
</Stack>
<IconButton size="small" onClick={onClose} aria-label={t("dashboard.widgets.close") ?? undefined}>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<Stack>
{DASHBOARD_WIDGETS.map((widget) => (
<FormControlLabel
key={widget.id}
labelPlacement="start"
label={t(widget.labelKey)}
control={
<Switch
checked={isWidgetEnabled(widget, choices)}
onChange={(event) => onToggle(widget.id, event.target.checked)}
/>
}
sx={{ mx: 0, justifyContent: "space-between" }}
/>
))}
</Stack>
</DialogContent>
</Dialog>
);
}
+75 -77
View File
@@ -2,22 +2,23 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import CircularProgress from "@mui/material/CircularProgress";
import Alert from "@mui/material/Alert";
import CollectionsIcon from "@mui/icons-material/Collections";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import PersonIcon from "@mui/icons-material/Person";
import FolderIcon from "@mui/icons-material/Folder";
import VisibilityIcon from "@mui/icons-material/Visibility";
import PrintIcon from "@mui/icons-material/Print";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import { UnauthorizedError } from "../../api/client";
import { dashboardApi, type DashboardSummary } from "../../api/dashboard";
import CountCard from "./CountCard";
import ModelListCard from "./ModelListCard";
import AuthorListCard from "./AuthorListCard";
import ProviderListCard from "./ProviderListCard";
import RecentlyAddedCard from "./RecentlyAddedCard";
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
import { useDashboardWidgets } from "../../hooks/useDashboardWidgets";
import DashboardWidgetsDialog from "./DashboardWidgetsDialog";
import { DASHBOARD_WIDGETS, isWidgetEnabled } from "./widgets";
// Relative widths of the three columns on a wide screen: counts, then the two list columns.
const COLUMN_FLEX = [1, 1.4, 1.4];
type Props = {
onUnauthorized?: () => void;
@@ -28,6 +29,18 @@ export default function DashboardPage({ onUnauthorized }: Props) {
const navigate = useNavigate();
const [summary, setSummary] = React.useState<DashboardSummary | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [choices, setWidgetEnabled] = useDashboardWidgets();
const [settingsOpen, setSettingsOpen] = React.useState(false);
usePageHeader({
actions: (
<Tooltip title={t("dashboard.widgets.open")}>
<IconButton size="small" onClick={() => setSettingsOpen(true)} aria-label={t("dashboard.widgets.open")}>
<SettingsOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
),
});
React.useEffect(() => {
let active = true;
@@ -46,83 +59,68 @@ export default function DashboardPage({ onUnauthorized }: Props) {
};
}, [onUnauthorized, t]);
let content: React.ReactNode;
if (error) {
return (
content = (
<Alert severity="error" sx={{ m: 3 }}>
{error}
</Alert>
);
}
if (!summary) {
return (
} else if (!summary || !choices) {
content = (
<Stack alignItems="center" justifyContent="center" sx={{ py: 10 }}>
<CircularProgress />
</Stack>
);
} else {
const enabled = DASHBOARD_WIDGETS.filter((widget) => isWidgetEnabled(widget, choices));
// A column with nothing left in it is dropped, so the others widen into its space.
const columns = COLUMN_FLEX.map((flex, column) => ({
column,
flex,
widgets: enabled.filter((widget) => widget.column === column),
})).filter((column) => column.widgets.length > 0);
content = columns.length ? (
<Box sx={{ p: { xs: 0, md: 1 } }}>
<Box sx={{ display: "flex", flexDirection: { xs: "column", md: "row" }, gap: 2, alignItems: "flex-start" }}>
{columns.map((column) => (
<Box
key={column.column}
sx={{
display: "flex",
flexDirection: "column",
gap: 2,
width: { xs: "100%", md: 0 },
flex: { md: column.flex },
}}
>
{column.widgets.map((widget) => (
<React.Fragment key={widget.id}>{widget.render({ summary, t, navigate })}</React.Fragment>
))}
</Box>
))}
</Box>
</Box>
) : (
<Stack alignItems="center" spacing={1.5} sx={{ py: 10, textAlign: "center" }}>
<Typography color="text.secondary">{t("dashboard.widgets.allHidden")}</Typography>
<Button variant="outlined" startIcon={<SettingsOutlinedIcon />} onClick={() => setSettingsOpen(true)}>
{t("dashboard.widgets.open")}
</Button>
</Stack>
);
}
return (
<Box sx={{ p: { xs: 0, md: 1 } }}>
<Box sx={{ display: "flex", flexDirection: { xs: "column", md: "row" }, gap: 2, alignItems: "flex-start" }}>
{/* Column 1: stat counts */}
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, width: { xs: "100%", md: 0 }, flex: { md: 1 } }}>
<CountCard
icon={<CollectionsIcon sx={{ fontSize: 32 }} />}
count={summary.collection_count}
label={t("dashboard.collectionCount.label")}
onClick={() => navigate("/models/collections")}
/>
<CountCard
icon={<ViewInArIcon sx={{ fontSize: 32 }} />}
count={summary.model_count}
label={t("dashboard.modelCount.label")}
onClick={() => navigate("/models")}
/>
<CountCard
icon={<PersonIcon sx={{ fontSize: 32 }} />}
count={summary.author_count}
label={t("dashboard.authorCount.label")}
/>
<CountCard
icon={<FolderIcon sx={{ fontSize: 32 }} />}
count={summary.category_count}
label={t("dashboard.categoryCount.label")}
onClick={() => navigate("/models")}
/>
</Box>
{/* Column 2: top viewed / top printed */}
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, width: { xs: "100%", md: 0 }, flex: { md: 1.4 } }}>
<ModelListCard
icon={<VisibilityIcon />}
title={t("dashboard.topViewed.title")}
models={summary.top_viewed}
valueOf={(m) => m.view_count}
valueLabel={(count) => t("dashboard.viewCount", { count })}
emptyText={t("dashboard.topViewed.empty")}
seeMoreLabel={t("dashboard.seeMore")}
fetchMore={dashboardApi.getTopViewed}
/>
<ModelListCard
icon={<PrintIcon />}
title={t("dashboard.topPrinted.title")}
models={summary.top_printed}
valueOf={(m) => m.print_count}
valueLabel={(count) => t("dashboard.printCount", { count })}
emptyText={t("dashboard.topPrinted.empty")}
seeMoreLabel={t("dashboard.seeMore")}
fetchMore={dashboardApi.getTopPrinted}
/>
<RecentlyAddedCard models={summary.recently_added} />
</Box>
{/* Column 3: top authors / top providers */}
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, width: { xs: "100%", md: 0 }, flex: { md: 1.4 } }}>
<AuthorListCard authors={summary.top_authors} />
<ProviderListCard providers={summary.top_providers} />
</Box>
</Box>
</Box>
<>
{content}
<DashboardWidgetsDialog
open={settingsOpen}
choices={choices ?? {}}
onToggle={setWidgetEnabled}
onClose={() => setSettingsOpen(false)}
/>
</>
);
}
@@ -0,0 +1,149 @@
import type React from "react";
import type { TFunction } from "i18next";
import type { NavigateFunction } from "react-router-dom";
import CollectionsIcon from "@mui/icons-material/Collections";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import PersonIcon from "@mui/icons-material/Person";
import FolderIcon from "@mui/icons-material/Folder";
import VisibilityIcon from "@mui/icons-material/Visibility";
import PrintIcon from "@mui/icons-material/Print";
import { dashboardApi, type DashboardSummary } from "../../api/dashboard";
import type { DashboardWidgetChoices, DashboardWidgetId } from "../../api/settings";
import CountCard from "./CountCard";
import ModelListCard from "./ModelListCard";
import AuthorListCard from "./AuthorListCard";
import ProviderListCard from "./ProviderListCard";
import RecentlyAddedCard from "./RecentlyAddedCard";
type WidgetContext = { summary: DashboardSummary; t: TFunction; navigate: NavigateFunction };
export type DashboardWidget = {
id: DashboardWidgetId;
/** The widget's name in the settings dialog. */
labelKey: string;
/** Which of the three columns it sits in on a wide screen. */
column: 0 | 1 | 2;
/** Shown until the user switches it off. An optional widget can start hidden instead. */
defaultEnabled: boolean;
render: (ctx: WidgetContext) => React.ReactNode;
};
/** In page order. A new widget is one entry here, plus its id in DashboardWidgetId and the backend. */
export const DASHBOARD_WIDGETS: DashboardWidget[] = [
{
id: "collectionCount",
labelKey: "dashboard.collectionCount.label",
column: 0,
defaultEnabled: true,
render: ({ summary, t, navigate }) => (
<CountCard
icon={<CollectionsIcon sx={{ fontSize: 32 }} />}
count={summary.collection_count}
label={t("dashboard.collectionCount.label")}
onClick={() => navigate("/models/collections")}
/>
),
},
{
id: "modelCount",
labelKey: "dashboard.modelCount.label",
column: 0,
defaultEnabled: true,
render: ({ summary, t, navigate }) => (
<CountCard
icon={<ViewInArIcon sx={{ fontSize: 32 }} />}
count={summary.model_count}
label={t("dashboard.modelCount.label")}
onClick={() => navigate("/models")}
/>
),
},
{
id: "authorCount",
labelKey: "dashboard.authorCount.label",
column: 0,
defaultEnabled: true,
render: ({ summary, t }) => (
<CountCard
icon={<PersonIcon sx={{ fontSize: 32 }} />}
count={summary.author_count}
label={t("dashboard.authorCount.label")}
/>
),
},
{
id: "categoryCount",
labelKey: "dashboard.categoryCount.label",
column: 0,
defaultEnabled: true,
render: ({ summary, t, navigate }) => (
<CountCard
icon={<FolderIcon sx={{ fontSize: 32 }} />}
count={summary.category_count}
label={t("dashboard.categoryCount.label")}
onClick={() => navigate("/models")}
/>
),
},
{
id: "topViewed",
labelKey: "dashboard.topViewed.title",
column: 1,
defaultEnabled: true,
render: ({ summary, t }) => (
<ModelListCard
icon={<VisibilityIcon />}
title={t("dashboard.topViewed.title")}
models={summary.top_viewed}
valueOf={(m) => m.view_count}
valueLabel={(count) => t("dashboard.viewCount", { count })}
emptyText={t("dashboard.topViewed.empty")}
seeMoreLabel={t("dashboard.seeMore")}
fetchMore={dashboardApi.getTopViewed}
/>
),
},
{
id: "topPrinted",
labelKey: "dashboard.topPrinted.title",
column: 1,
defaultEnabled: true,
render: ({ summary, t }) => (
<ModelListCard
icon={<PrintIcon />}
title={t("dashboard.topPrinted.title")}
models={summary.top_printed}
valueOf={(m) => m.print_count}
valueLabel={(count) => t("dashboard.printCount", { count })}
emptyText={t("dashboard.topPrinted.empty")}
seeMoreLabel={t("dashboard.seeMore")}
fetchMore={dashboardApi.getTopPrinted}
/>
),
},
{
id: "recentlyAdded",
labelKey: "dashboard.recentlyAdded.title",
column: 1,
defaultEnabled: true,
render: ({ summary }) => <RecentlyAddedCard models={summary.recently_added} />,
},
{
id: "topAuthors",
labelKey: "dashboard.topAuthors.title",
column: 2,
defaultEnabled: true,
render: ({ summary }) => <AuthorListCard authors={summary.top_authors} />,
},
{
id: "topProviders",
labelKey: "dashboard.topProviders.title",
column: 2,
defaultEnabled: true,
render: ({ summary }) => <ProviderListCard providers={summary.top_providers} />,
},
];
export function isWidgetEnabled(widget: DashboardWidget, choices: DashboardWidgetChoices): boolean {
return choices[widget.id] ?? widget.defaultEnabled;
}