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