feat: extend dashboard settings with layouts

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-10 21:50:29 +03:00
1 parent 708a93822a
commit 2813237e03
14 files changed
+570 -90

No files matched your search

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "dashboardLayout" TEXT;
+2
View File
@@ -51,6 +51,8 @@ model User {
// 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?
// How the dashboard arranges its boxes, one of DASHBOARD_LAYOUTS; null means "left".
dashboardLayout String?
authorPreviewEnabled Boolean @default(true)
// Filled from the first linked Author with this data; later links never overwrite it.
bio String?
+24 -1
View File
@@ -37,7 +37,14 @@ 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 {
DASHBOARD_LAYOUTS,
DASHBOARD_WIDGETS,
getDashboardLayout,
getDashboardWidgets,
setDashboardLayout,
setDashboardWidgets,
} from "../services/dashboardWidgetsService";
import {
CONSUME_MODES,
NOT_IMPORTED_DIR,
@@ -447,6 +454,22 @@ router.patch(
}),
);
router.get(
"/settings/dashboard-layout",
asyncHandler(async (req, res) => {
res.json({ layout: await getDashboardLayout(req.userId!) });
}),
);
const dashboardLayoutSchema = z.object({ layout: z.enum(DASHBOARD_LAYOUTS) });
router.patch(
"/settings/dashboard-layout",
asyncHandler(async (req, res) => {
const body = parseBody(dashboardLayoutSchema, req.body);
res.json({ layout: await setDashboardLayout(req.userId!, body.layout) });
}),
);
router.get(
"/settings/author-preview",
asyncHandler(async (req, res) => {
@@ -45,3 +45,18 @@ export async function setDashboardWidgets(userId: string, changes: DashboardWidg
RETURNING "dashboardWidgets"`;
return clean(rows[0]?.dashboardWidgets);
}
// Kept in sync by hand with the frontend's DashboardLayout. Each names where the count boxes go.
export const DASHBOARD_LAYOUTS = ["left", "top", "right", "bottom", "topTwo", "puzzle"] as const;
export type DashboardLayout = (typeof DASHBOARD_LAYOUTS)[number];
/** A layout since removed falls back to the default rather than breaking the page. */
export async function getDashboardLayout(userId: string): Promise<DashboardLayout> {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { dashboardLayout: true } });
return DASHBOARD_LAYOUTS.find((layout) => layout === user?.dashboardLayout) ?? "left";
}
export async function setDashboardLayout(userId: string, layout: DashboardLayout): Promise<DashboardLayout> {
await prisma.user.update({ where: { id: userId }, data: { dashboardLayout: layout } });
return layout;
}
+21
View File
@@ -46,3 +46,24 @@ describe("dashboard widget preferences", () => {
expect((await request(app).get("/api/settings/dashboard-widgets")).status).toBe(401);
});
});
describe("dashboard layout preference", () => {
const getLayout = async () => (await request(app).get("/api/settings/dashboard-layout").set(auth())).body.layout;
const setLayout = (layout: unknown) =>
request(app).patch("/api/settings/dashboard-layout").set(auth()).send({ layout });
it("starts on the default layout and keeps a chosen one", async () => {
expect(await getLayout()).toBe("left");
expect((await setLayout("top")).body.layout).toBe("top");
expect(await getLayout()).toBe("top");
});
it("rejects an unknown layout", async () => {
expect((await setLayout("diagonal")).status).toBe(400);
expect(await getLayout()).toBe("top");
});
it("needs a signed-in user", async () => {
expect((await request(app).get("/api/settings/dashboard-layout")).status).toBe(401);
});
});
+19
View File
@@ -25,6 +25,8 @@ export type DashboardWidgetId =
| "recentlyAdded"
| "topAuthors"
| "topProviders";
// Kept in sync by hand with the backend's DASHBOARD_LAYOUTS.
export type DashboardLayout = "left" | "top" | "right" | "bottom" | "topTwo" | "puzzle";
/** Only the widgets the user has switched; the rest follow their default. */
export type DashboardWidgetChoices = Partial<Record<DashboardWidgetId, boolean>>;
@@ -396,6 +398,23 @@ export const settingsApi = {
return res.json();
},
getDashboardLayout: async (): Promise<{ layout: DashboardLayout }> => {
const res = await fetch(`${apiBase()}/settings/dashboard-layout`, { headers: authHeaders() });
assertOk(res, "Failed to load the dashboard layout setting");
return res.json();
},
updateDashboardLayout: async (layout: DashboardLayout): Promise<{ layout: DashboardLayout }> => {
const res = await fetch(`${apiBase()}/settings/dashboard-layout`, {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ layout }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update the dashboard layout setting"));
return res.json();
},
getConsume: async (): Promise<ConsumeSettings> => {
const res = await fetch(`${apiBase()}/settings/consume`, { headers: authHeaders() });
assertOk(res, "Failed to load consume folder settings");
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useState } from "react";
import { settingsApi, type DashboardLayout } from "../api/settings";
// Saved on the account so every device lays the dashboard out the same way. Shared by every user of
// the hook so there's one request, and a switch shows everywhere at once.
let cached: DashboardLayout | undefined;
let inFlight: Promise<DashboardLayout> | null = null;
const listeners = new Set<(value: DashboardLayout) => void>();
function load(): Promise<DashboardLayout> {
if (cached !== undefined) return Promise.resolve(cached);
// A switch made while this is in flight is newer than the server's answer, so it wins.
inFlight ??= settingsApi
.getDashboardLayout()
.then((res) => (cached ??= res.layout))
.catch((): DashboardLayout => cached ?? "left")
.finally(() => {
inFlight = null;
});
return inFlight;
}
function publish(value: DashboardLayout) {
cached = value;
listeners.forEach((listener) => listener(value));
}
/** Null until the saved choice is known, so the dashboard doesn't flash the wrong layout. */
export function useDashboardLayout(): [DashboardLayout | null, (layout: DashboardLayout) => void] {
const [value, setValue] = useState<DashboardLayout | 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 update = (layout: DashboardLayout) => {
publish(layout);
// The switch already happened here; a failed save only means other devices won't follow.
settingsApi.updateDashboardLayout(layout).catch(() => {});
};
return [value, update];
}
+14 -1
View File
@@ -354,7 +354,20 @@
"title": "Customize dashboard",
"subtitle": "Choose which boxes your dashboard shows.",
"close": "Close",
"allHidden": "Every dashboard box is hidden."
"allHidden": "Every dashboard box is hidden.",
"sections": {
"layout": "Layout",
"panels": "Panels"
},
"layoutSubtitle": "Choose how your dashboard arranges its boxes. The coloured part shows where the counts go.",
"layouts": {
"left": "Counts on the left",
"top": "Counts on top, three columns",
"right": "Counts on the right",
"bottom": "Counts at the bottom, three columns",
"topTwo": "Counts on top, two columns",
"puzzle": "Puzzle"
}
}
},
"adminSettings": {
+14 -1
View File
@@ -354,7 +354,20 @@
"title": "Tvarkyti valdymo skydą",
"subtitle": "Pasirinkite, kuriuos langelius rodo jūsų valdymo skydas.",
"close": "Uždaryti",
"allHidden": "Visi valdymo skydo langeliai paslėpti."
"allHidden": "Visi valdymo skydo langeliai paslėpti.",
"sections": {
"layout": "Išdėstymas",
"panels": "Langeliai"
},
"layoutSubtitle": "Pasirinkite, kaip išdėstyti valdymo skydo langelius. Spalvota dalis rodo, kur bus skaičiai.",
"layouts": {
"left": "Skaičiai kairėje",
"top": "Skaičiai viršuje, trys stulpeliai",
"right": "Skaičiai dešinėje",
"bottom": "Skaičiai apačioje, trys stulpeliai",
"topTwo": "Skaičiai viršuje, du stulpeliai",
"puzzle": "Dėlionė"
}
}
},
"adminSettings": {
@@ -0,0 +1,90 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import FormControlLabel from "@mui/material/FormControlLabel";
import Stack from "@mui/material/Stack";
import Switch from "@mui/material/Switch";
import Typography from "@mui/material/Typography";
import ViewQuiltOutlinedIcon from "@mui/icons-material/ViewQuiltOutlined";
import ToggleOnOutlinedIcon from "@mui/icons-material/ToggleOnOutlined";
import type { DashboardLayout, DashboardWidgetChoices, DashboardWidgetId } from "../../api/settings";
import SectionedDialog from "../../components/SectionedDialog";
import LayoutPicker from "./LayoutPicker";
import { DASHBOARD_WIDGETS, isWidgetEnabled } from "./widgets";
type Section = "layout" | "panels";
type Props = {
open: boolean;
layout: DashboardLayout;
onLayoutChange: (layout: DashboardLayout) => void;
choices: DashboardWidgetChoices;
onToggle: (id: DashboardWidgetId, enabled: boolean) => void;
onClose: () => void;
};
/** Every choice applies straight away, so the dashboard behind updates as the user goes. */
export default function DashboardCustomizeDialog({ open, layout, onLayoutChange, choices, onToggle, onClose }: Props) {
const { t } = useTranslation("app");
const [section, setSection] = useState<Section>("layout");
// Each opening starts on Layout; kept while closing, so the section doesn't switch during the fade.
useEffect(() => {
if (open) setSection("layout");
}, [open]);
const sections = [
{
id: "layout" as const,
label: t("dashboard.widgets.sections.layout"),
icon: <ViewQuiltOutlinedIcon fontSize="small" />,
},
{
id: "panels" as const,
label: t("dashboard.widgets.sections.panels"),
icon: <ToggleOnOutlinedIcon fontSize="small" />,
},
];
return (
<SectionedDialog
open={open}
onClose={onClose}
title={t("dashboard.widgets.title")}
sections={sections}
section={section}
onSectionChange={setSection}
>
{section === "layout" && (
<Stack spacing={2}>
<Typography variant="body2" color="text.secondary">
{t("dashboard.widgets.layoutSubtitle")}
</Typography>
<LayoutPicker value={layout} onChange={onLayoutChange} />
</Stack>
)}
{section === "panels" && (
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary">
{t("dashboard.widgets.subtitle")}
</Typography>
<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>
</Stack>
)}
</SectionedDialog>
);
}
@@ -1,57 +0,0 @@
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>
);
}
@@ -0,0 +1,137 @@
import { useTranslation } from "react-i18next";
import { alpha, type Theme } from "@mui/material/styles";
import Box from "@mui/material/Box";
import ButtonBase from "@mui/material/ButtonBase";
import Typography from "@mui/material/Typography";
import CheckIcon from "@mui/icons-material/Check";
import type { DashboardLayout } from "../../api/settings";
import { DASHBOARD_LAYOUTS, PUZZLE_COLUMNS, PUZZLE_SKETCH, type LayoutDefinition } from "./layouts";
import { DASHBOARD_WIDGETS } from "./widgets";
const COUNT_IDS = new Set(DASHBOARD_WIDGETS.filter((widget) => widget.column === 0).map((widget) => widget.id));
const countColor = (theme: Theme) => alpha(theme.palette.primary.main, 0.55);
type Props = {
value: DashboardLayout;
onChange: (layout: DashboardLayout) => void;
};
/** A small drawing of the layout: the count boxes in the accent colour, the larger panels in grey. */
function LayoutSketch({ layout }: { layout: LayoutDefinition }) {
if (layout.counts === "mixed")
return (
<Box
sx={{
display: "grid",
gridTemplateColumns: `repeat(${PUZZLE_COLUMNS}, 1fr)`,
gridAutoRows: "1fr",
gridAutoFlow: "dense",
gap: "4px",
width: "100%",
aspectRatio: "16 / 10",
}}
>
{PUZZLE_SKETCH.map((piece) => (
<Box
key={piece.id}
sx={{
gridColumn: `span ${piece.columns}`,
gridRow: `span ${piece.rows}`,
borderRadius: "3px",
bgcolor: COUNT_IDS.has(piece.id) ? countColor : "action.selected",
}}
/>
))}
</Box>
);
const counts = (
<Box
sx={{
flex: layout.counts === "left" || layout.counts === "right" ? 0.7 : "0 0 22%",
borderRadius: "3px",
bgcolor: countColor,
}}
/>
);
const panels = (
<Box sx={{ flex: 1, display: "flex", gap: "4px" }}>
{Array.from({ length: layout.columns }, (_, index) => (
<Box key={index} sx={{ flex: 1, borderRadius: "3px", bgcolor: "action.selected" }} />
))}
</Box>
);
const sideways = layout.counts === "left" || layout.counts === "right";
const countsFirst = layout.counts === "left" || layout.counts === "top";
return (
<Box
sx={{
display: "flex",
flexDirection: sideways ? "row" : "column",
gap: "4px",
width: "100%",
aspectRatio: "16 / 10",
}}
>
{countsFirst ? counts : panels}
{countsFirst ? panels : counts}
</Box>
);
}
export default function LayoutPicker({ value, onChange }: Props) {
const { t } = useTranslation("app");
return (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "repeat(2, 1fr)", sm: "repeat(3, 1fr)" }, gap: 1.5 }}>
{DASHBOARD_LAYOUTS.map((layout) => {
const selected = layout.id === value;
const label = t(`dashboard.widgets.layouts.${layout.id}`);
return (
<ButtonBase
key={layout.id}
aria-pressed={selected}
aria-label={label}
onClick={() => onChange(layout.id)}
sx={{
position: "relative",
display: "flex",
flexDirection: "column",
alignItems: "stretch",
gap: 1,
p: 1.25,
borderRadius: "10px",
border: 2,
borderColor: selected ? "primary.main" : "divider",
transition: "border-color 150ms",
"&:hover": { borderColor: selected ? "primary.main" : "text.disabled" },
}}
>
<LayoutSketch layout={layout} />
<Typography variant="caption" sx={{ textAlign: "center", lineHeight: 1.3 }}>
{label}
</Typography>
{selected && (
<Box
sx={{
position: "absolute",
top: -9,
right: -9,
width: 22,
height: 22,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "success.main",
color: "success.contrastText",
boxShadow: 1,
}}
>
<CheckIcon sx={{ fontSize: 15 }} />
</Box>
)}
</ButtonBase>
);
})}
</Box>
);
}
+107 -30
View File
@@ -14,11 +14,14 @@ import { UnauthorizedError } from "../../api/client";
import { dashboardApi, type DashboardSummary } from "../../api/dashboard";
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
import { useDashboardWidgets } from "../../hooks/useDashboardWidgets";
import DashboardWidgetsDialog from "./DashboardWidgetsDialog";
import { DASHBOARD_WIDGETS, isWidgetEnabled } from "./widgets";
import { useDashboardLayout } from "../../hooks/useDashboardLayout";
import DashboardCustomizeDialog from "./DashboardCustomizeDialog";
import { DASHBOARD_WIDGETS, isWidgetEnabled, type DashboardWidget } from "./widgets";
import { PUZZLE_COLUMNS, layoutDefinition, panelColumns, puzzlePieces } from "./layouts";
// Relative widths of the three columns on a wide screen: counts, then the two list columns.
const COLUMN_FLEX = [1, 1.4, 1.4];
// Beside the panels, the count column is narrower than each panel column.
const COUNTS_FLEX = 1;
const PANEL_FLEX = 1.4;
type Props = {
onUnauthorized?: () => void;
@@ -30,6 +33,7 @@ export default function DashboardPage({ onUnauthorized }: Props) {
const [summary, setSummary] = React.useState<DashboardSummary | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [choices, setWidgetEnabled] = useDashboardWidgets();
const [layout, setLayout] = useDashboardLayout();
const [settingsOpen, setSettingsOpen] = React.useState(false);
usePageHeader({
@@ -66,7 +70,7 @@ export default function DashboardPage({ onUnauthorized }: Props) {
{error}
</Alert>
);
} else if (!summary || !choices) {
} else if (!summary || !choices || !layout) {
content = (
<Stack alignItems="center" justifyContent="center" sx={{ py: 10 }}>
<CircularProgress />
@@ -74,49 +78,122 @@ export default function DashboardPage({ onUnauthorized }: Props) {
);
} 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);
const definition = layoutDefinition(layout);
const counts = enabled.filter((widget) => widget.column === 0);
// An empty column is dropped, so the others widen into its space.
const columns = panelColumns(
definition,
enabled.filter((widget) => widget.column !== 0),
);
const sideways = definition.counts === "left" || definition.counts === "right";
const countsFirst = definition.counts === "left" || definition.counts === "top";
const renderWidget = (widget: DashboardWidget) => (
<React.Fragment key={widget.id}>{widget.render({ summary, t, navigate })}</React.Fragment>
);
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={{
const countsBlock = counts.length ? (
<Box
sx={
sideways
? {
display: "flex",
flexDirection: "column",
gap: 2,
width: { xs: "100%", md: 0 },
flex: { md: column.flex },
flex: { md: COUNTS_FLEX },
}
: // Above or below the panels, the counts share one row.
{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 2 }
}
>
{counts.map(renderWidget)}
</Box>
) : null;
const panelsBlock = columns.length ? (
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", md: "row" },
gap: 2,
alignItems: "flex-start",
...(sideways && { width: { xs: "100%", md: 0 }, flex: { md: columns.length * PANEL_FLEX } }),
}}
>
{columns.map((column) => (
<Box
key={column[0].id}
sx={{ display: "flex", flexDirection: "column", gap: 2, width: { xs: "100%", md: 0 }, flex: { md: 1 } }}
>
{column.map(renderWidget)}
</Box>
))}
</Box>
) : null;
// Every box in one dense grid: pieces of different sizes, the small ones packed into the gaps.
const puzzle = (
<Box sx={{ p: { xs: 0, md: 1 } }}>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", md: `repeat(${PUZZLE_COLUMNS}, 1fr)` },
gridAutoFlow: "dense",
gap: 2,
}}
>
{puzzlePieces(enabled).map(({ item, columns: span, rows }) => (
<Box
key={item.id}
sx={{
gridColumn: { sm: `span ${Math.min(span, 2)}`, md: `span ${span}` },
gridRow: { md: `span ${rows}` },
// A piece stretches to its whole area, so the puzzle has no holes inside it.
display: "flex",
flexDirection: "column",
"& > *": { flex: 1 },
}}
>
{column.widgets.map((widget) => (
<React.Fragment key={widget.id}>{widget.render({ summary, t, navigate })}</React.Fragment>
))}
{item.render({ summary, t, navigate })}
</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>
);
content =
definition.counts === "mixed" && enabled.length ? (
puzzle
) : countsBlock || panelsBlock ? (
<Box sx={{ p: { xs: 0, md: 1 } }}>
<Box
sx={{
display: "flex",
flexDirection: sideways ? { xs: "column", md: "row" } : "column",
gap: 2,
alignItems: sideways ? "flex-start" : "stretch",
}}
>
{countsFirst ? countsBlock : panelsBlock}
{countsFirst ? panelsBlock : countsBlock}
</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 (
<>
{content}
<DashboardWidgetsDialog
<DashboardCustomizeDialog
open={settingsOpen}
layout={layout ?? "left"}
onLayoutChange={setLayout}
choices={choices ?? {}}
onToggle={setWidgetEnabled}
onClose={() => setSettingsOpen(false)}
@@ -0,0 +1,74 @@
import type { DashboardLayout, DashboardWidgetId } from "../../api/settings";
import type { DashboardWidget } from "./widgets";
export type LayoutDefinition = {
id: DashboardLayout;
/** Where the count boxes (the widgets in column 0) sit; "mixed" fits every box together as a puzzle. */
counts: "left" | "right" | "top" | "bottom" | "mixed";
/** How many columns the larger panels share. Unused by the puzzle. */
columns: 1 | 2 | 3;
};
/** In the order the picker shows them; the first is the default. */
export const DASHBOARD_LAYOUTS: LayoutDefinition[] = [
{ id: "left", counts: "left", columns: 2 },
{ id: "top", counts: "top", columns: 3 },
{ id: "right", counts: "right", columns: 2 },
{ id: "bottom", counts: "bottom", columns: 3 },
{ id: "topTwo", counts: "top", columns: 2 },
{ id: "puzzle", counts: "mixed", columns: 1 },
];
export function layoutDefinition(id: DashboardLayout): LayoutDefinition {
return DASHBOARD_LAYOUTS.find((layout) => layout.id === id) ?? DASHBOARD_LAYOUTS[0];
}
/** Splits the enabled panels into the layout's columns. Empty columns are dropped, so the rest widen. */
export function panelColumns(layout: LayoutDefinition, panels: DashboardWidget[]): DashboardWidget[][] {
let columns: DashboardWidget[][];
if (layout.columns === 1) columns = [panels];
// Two columns keep each widget's own column.
else if (layout.columns === 2) columns = [1, 2].map((column) => panels.filter((widget) => widget.column === column));
// Three are filled across, so the first row holds the first three panels.
else columns = [0, 1, 2].map((index) => panels.filter((_, position) => position % 3 === index));
return columns.filter((column) => column.length > 0);
}
/** The puzzle's grid width on a wide screen; on smaller ones pieces shrink to fit two columns, then one. */
export const PUZZLE_COLUMNS = 6;
/**
* A fixed jumble rather than a fresh shuffle, so the dashboard doesn't rearrange on every visit.
* Listed in placement order: with dense packing the small counts drop into the gaps beside the
* big panels, and with every box shown the pieces fill five rows of six exactly.
*/
const PUZZLE_PIECES: { id: DashboardWidgetId; columns: number; rows: number }[] = [
{ id: "topViewed", columns: 3, rows: 2 },
{ id: "collectionCount", columns: 2, rows: 1 },
{ id: "modelCount", columns: 1, rows: 1 },
{ id: "recentlyAdded", columns: 3, rows: 2 },
{ id: "authorCount", columns: 1, rows: 1 },
{ id: "categoryCount", columns: 2, rows: 1 },
{ id: "topPrinted", columns: 2, rows: 2 },
{ id: "topAuthors", columns: 2, rows: 2 },
{ id: "topProviders", columns: 2, rows: 2 },
];
export type PuzzlePiece<T> = { item: T; columns: number; rows: number };
/** Orders the given widgets into puzzle pieces. A widget the puzzle doesn't know yet goes last. */
export function puzzlePieces<T extends { id: DashboardWidgetId }>(widgets: T[]): PuzzlePiece<T>[] {
const rank = (id: DashboardWidgetId) => {
const index = PUZZLE_PIECES.findIndex((piece) => piece.id === id);
return index === -1 ? PUZZLE_PIECES.length : index;
};
return widgets
.toSorted((a, b) => rank(a.id) - rank(b.id))
.map((item) => {
const piece = PUZZLE_PIECES.find((p) => p.id === item.id);
return { item, columns: piece?.columns ?? 2, rows: piece?.rows ?? 1 };
});
}
/** Every piece, for drawing the puzzle's own tile in the layout picker. */
export const PUZZLE_SKETCH = PUZZLE_PIECES;