mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat(dashboard): make dashboard panes toggleable
This commit is contained in:
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;
|
||||
@@ -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?
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user