mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
feat: preview dashboard panels as tiles and add an activity heatmap panel
This commit is contained in:
1 parent
87cbbe7166
commit
c65e712a31
14 files changed
+503
-84
No files matched your search
@@ -14,6 +14,7 @@ export const DASHBOARD_WIDGETS = [
|
||||
"clock",
|
||||
"weather",
|
||||
"featuredModel",
|
||||
"activityHeatmap",
|
||||
] as const;
|
||||
export type DashboardWidgetId = (typeof DASHBOARD_WIDGETS)[number];
|
||||
export type DashboardWidgets = Partial<Record<DashboardWidgetId, boolean>>;
|
||||
|
||||
@@ -69,7 +69,8 @@ export type DashboardWidgetId =
|
||||
| "topProviders"
|
||||
| "clock"
|
||||
| "weather"
|
||||
| "featuredModel";
|
||||
| "featuredModel"
|
||||
| "activityHeatmap";
|
||||
/** Where the dashboard's weather is for; "device" came from the browser, "manual" from a search. */
|
||||
export type WeatherLocation = { lat: number; lon: number; name: string | null; source: "device" | "manual" };
|
||||
// Kept in sync by hand with the backend's DASHBOARD_LAYOUTS.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type React from "react";
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
/** For an on/off tile: switched off, its picture fades and greys, so the user sees what they'd lose. */
|
||||
dimWhenOff?: boolean;
|
||||
/** The picture above the label. */
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/** A pressable card with a small drawing and a label, ticked while selected. */
|
||||
export default function SelectableTile({ selected, onClick, label, dimWhenOff = false, children }: Props) {
|
||||
const dimmed = dimWhenOff && !selected;
|
||||
return (
|
||||
<ButtonBase
|
||||
aria-pressed={selected}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
gap: 1,
|
||||
p: 1.25,
|
||||
borderRadius: "10px",
|
||||
border: 2,
|
||||
borderColor: selected ? "primary.main" : "divider",
|
||||
borderStyle: dimmed ? "dashed" : "solid",
|
||||
transition: "border-color 150ms",
|
||||
"&:hover": { borderColor: selected ? "primary.main" : "text.disabled" },
|
||||
"&.Mui-focusVisible": { outline: "2px solid", outlineColor: "primary.main", outlineOffset: 2 },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
opacity: dimmed ? 0.4 : 1,
|
||||
filter: dimmed ? "grayscale(1)" : "none",
|
||||
transform: dimmed ? "scale(0.94)" : "none",
|
||||
transition: "opacity 200ms ease, filter 200ms ease, transform 200ms ease",
|
||||
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ textAlign: "center", lineHeight: 1.3, color: dimmed ? "text.secondary" : "text.primary" }}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<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,
|
||||
opacity: selected ? 1 : 0,
|
||||
transform: selected ? "scale(1)" : "scale(0.4)",
|
||||
transition: "opacity 150ms ease, transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1)",
|
||||
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
|
||||
}}
|
||||
>
|
||||
<CheckIcon sx={{ fontSize: 15 }} />
|
||||
</Box>
|
||||
</ButtonBase>
|
||||
);
|
||||
}
|
||||
@@ -500,6 +500,11 @@
|
||||
"loadError": "Couldn't load today's model.",
|
||||
"hint": "Drag to spin, click to open"
|
||||
},
|
||||
"activityHeatmap": {
|
||||
"title": "Activity",
|
||||
"total_one": "{{count}} activity",
|
||||
"total_other": "{{count}} activities"
|
||||
},
|
||||
"widgets": {
|
||||
"open": "Customize dashboard",
|
||||
"title": "Customize dashboard",
|
||||
|
||||
@@ -501,6 +501,12 @@
|
||||
"loadError": "Nepavyko įkelti šiandienos modelio.",
|
||||
"hint": "Tempkite, kad pasuktumėte, spustelėkite, kad atidarytumėte"
|
||||
},
|
||||
"activityHeatmap": {
|
||||
"title": "Veikla",
|
||||
"total_one": "{{count}} veiksmas",
|
||||
"total_few": "{{count}} veiksmai",
|
||||
"total_other": "{{count}} veiksmų"
|
||||
},
|
||||
"widgets": {
|
||||
"open": "Tvarkyti valdymo skydą",
|
||||
"title": "Tvarkyti valdymo skydą",
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import InsightsIcon from "@mui/icons-material/Insights";
|
||||
import { activityApi, type ActivityDay } from "../../api/activity";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import Heatmap from "../ProfilePage/activity/Heatmap";
|
||||
import { addDays, lastYearRange, localToday, weekday, type DateRange } from "../ProfilePage/activity/calendar";
|
||||
|
||||
// The heatmap's cell and gap, and room for its weekday labels.
|
||||
const WEEK_WIDTH = 14;
|
||||
const LABELS_WIDTH = 40;
|
||||
const MIN_WEEKS = 8;
|
||||
const MAX_WEEKS = 53;
|
||||
|
||||
/** The last `weeks` whole columns up to today, each starting on `weekStart`. */
|
||||
function recentRange(today: string, weeks: number, weekStart: 0 | 1): DateRange {
|
||||
const intoWeek = (weekday(today) - weekStart + 7) % 7;
|
||||
return { from: addDays(today, -intoWeek - (weeks - 1) * 7), to: today };
|
||||
}
|
||||
|
||||
type Props = { onUnauthorized?: () => void };
|
||||
|
||||
/** The profile's heatmap, cut to as many recent weeks as the card is wide. A day opens it on the profile. */
|
||||
export default function ActivityHeatmapCard({ onUnauthorized }: Props) {
|
||||
const { t, i18n } = useTranslation("app");
|
||||
const navigate = useNavigate();
|
||||
const today = localToday();
|
||||
const weekStart = i18n.language.startsWith("lt") ? 1 : 0;
|
||||
const [days, setDays] = useState<ActivityDay[] | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [weeks, setWeeks] = useState<number | null>(null);
|
||||
const measureRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = measureRef.current;
|
||||
if (!node) return;
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const fit = Math.floor((entry.contentRect.width - LABELS_WIDTH) / WEEK_WIDTH);
|
||||
setWeeks(Math.max(MIN_WEEKS, Math.min(MAX_WEEKS, fit)));
|
||||
});
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// A whole year once, so resizing only changes how much of it shows.
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const year = lastYearRange(localToday());
|
||||
activityApi
|
||||
.get(year.from, year.to)
|
||||
.then((data) => {
|
||||
if (active) setDays(data.days);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else if (active) setFailed(true);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [onUnauthorized]);
|
||||
|
||||
const range = useMemo(() => (weeks ? recentRange(today, weeks, weekStart) : null), [today, weeks, weekStart]);
|
||||
const total = useMemo(
|
||||
() =>
|
||||
range && days
|
||||
? days
|
||||
.filter((day) => day.date >= range.from && day.date <= range.to)
|
||||
.reduce((sum, day) => sum + Object.values(day.counts).reduce((n, c) => n + (c ?? 0), 0), 0)
|
||||
: null,
|
||||
[days, range],
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2.5,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
borderColor: (theme) => (theme.palette.mode === "dark" ? "transparent" : "divider"),
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, color: "primary.main", minWidth: 0 }}>
|
||||
<InsightsIcon />
|
||||
<Typography variant="h6" fontWeight={700} sx={{ color: (theme) => theme.thingport.headingText, flex: 1 }}>
|
||||
{t("dashboard.activityHeatmap.title")}
|
||||
</Typography>
|
||||
{total !== null && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0 }}>
|
||||
{t("dashboard.activityHeatmap.total", { count: total })}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box ref={measureRef} sx={{ minWidth: 0 }}>
|
||||
{failed ? (
|
||||
<Typography color="text.secondary">{t("activity.loadError")}</Typography>
|
||||
) : (
|
||||
// Drawn straight away from the range alone, so the counts fill in without a jump.
|
||||
range && (
|
||||
<Heatmap
|
||||
range={range}
|
||||
days={days ?? []}
|
||||
selectedDay={null}
|
||||
today={today}
|
||||
onSelectDay={(date) => navigate(`/profile?day=${date}`)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import Box from "@mui/material/Box";
|
||||
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";
|
||||
@@ -15,8 +14,10 @@ import type {
|
||||
} from "../../api/settings";
|
||||
import SectionedDialog from "../../components/SectionedDialog";
|
||||
import Segmented from "../../components/controls/Segmented";
|
||||
import SelectableTile from "../../components/controls/SelectableTile";
|
||||
import { useDashboardThumbnails } from "../../hooks/useDashboardThumbnails";
|
||||
import LayoutPicker from "./LayoutPicker";
|
||||
import PanelSketch from "./PanelSketch";
|
||||
import { DASHBOARD_WIDGETS, isWidgetEnabled } from "./widgets";
|
||||
|
||||
type Section = "layout" | "panels" | "thumbnails";
|
||||
@@ -78,26 +79,26 @@ export default function DashboardCustomizeDialog({ open, layout, onLayoutChange,
|
||||
</Stack>
|
||||
)}
|
||||
{section === "panels" && (
|
||||
<Stack spacing={1}>
|
||||
<Stack spacing={2}>
|
||||
<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>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "repeat(2, 1fr)", sm: "repeat(3, 1fr)" }, gap: 1.5 }}>
|
||||
{DASHBOARD_WIDGETS.map((widget) => {
|
||||
const enabled = isWidgetEnabled(widget, choices);
|
||||
return (
|
||||
<SelectableTile
|
||||
key={widget.id}
|
||||
selected={enabled}
|
||||
dimWhenOff
|
||||
label={t(widget.labelKey)}
|
||||
onClick={() => onToggle(widget.id, !enabled)}
|
||||
>
|
||||
<PanelSketch kind={widget.sketch} />
|
||||
</SelectableTile>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
{section === "thumbnails" && (
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
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 SelectableTile from "../../components/controls/SelectableTile";
|
||||
import { DASHBOARD_LAYOUTS, PUZZLE_COLUMNS, PUZZLE_SKETCH, type LayoutDefinition } from "./layouts";
|
||||
import { DASHBOARD_WIDGETS } from "./widgets";
|
||||
|
||||
@@ -82,56 +80,16 @@ 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>
|
||||
);
|
||||
})}
|
||||
{DASHBOARD_LAYOUTS.map((layout) => (
|
||||
<SelectableTile
|
||||
key={layout.id}
|
||||
selected={layout.id === value}
|
||||
label={t(`dashboard.widgets.layouts.${layout.id}`)}
|
||||
onClick={() => onChange(layout.id)}
|
||||
>
|
||||
<LayoutSketch layout={layout} />
|
||||
</SelectableTile>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { alpha, type Theme } from "@mui/material/styles";
|
||||
import { cellColor } from "../ProfilePage/activity/Heatmap";
|
||||
import { MONO_FONT } from "./ClockCard";
|
||||
|
||||
/** Which drawing a widget gets in the Panels picker: the shape of its real card, without its data. */
|
||||
export type PanelSketchKind =
|
||||
"count" | "models" | "authors" | "providers" | "clock" | "weather" | "featured" | "heatmap";
|
||||
|
||||
const accent = (theme: Theme) => alpha(theme.palette.primary.main, 0.55);
|
||||
const ink = (theme: Theme) => alpha(theme.palette.text.primary, 0.16);
|
||||
const faint = (theme: Theme) => alpha(theme.palette.text.primary, 0.08);
|
||||
|
||||
type Fill = (theme: Theme) => string;
|
||||
|
||||
function Bar({
|
||||
width,
|
||||
height = 4,
|
||||
fill = ink,
|
||||
round = false,
|
||||
}: {
|
||||
width: string;
|
||||
height?: number;
|
||||
fill?: Fill;
|
||||
round?: boolean;
|
||||
}) {
|
||||
return <Box sx={{ width, height, flexShrink: 0, borderRadius: round ? "50%" : "2px", bgcolor: fill }} />;
|
||||
}
|
||||
|
||||
/** A card's title row: its icon in the accent colour, then the title. */
|
||||
function Header() {
|
||||
return (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: "3px", mb: "4px" }}>
|
||||
<Bar width="7px" height={7} fill={accent} round />
|
||||
<Bar width="45%" height={5} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Rows({ count, lead }: { count: number; lead: "thumb" | "avatar" | "chip" }) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
{Array.from({ length: count }, (_, index) => (
|
||||
<Box key={index} sx={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
{lead === "chip" ? (
|
||||
// Each provider chip carries its brand colour; varied shades stand in for them.
|
||||
<Bar
|
||||
width={`${34 - (index % 3) * 6}%`}
|
||||
height={7}
|
||||
fill={(theme) => alpha(theme.palette.primary.main, 0.75 - index * 0.15)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Bar width="9px" height={9} fill={faint} round={lead === "avatar"} />
|
||||
{/* Varied widths read as real names rather than a block. */}
|
||||
<Bar width={`${48 - (index % 3) * 9}%`} />
|
||||
</>
|
||||
)}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Bar width="14%" height={3} fill={faint} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// A fixed pattern, so the drawing doesn't change between renders. Levels 0-4, as the real heatmap's.
|
||||
const LEVELS = [0, 1, 0, 2, 3, 1, 0, 4, 2, 1, 0, 3];
|
||||
const HEAT = Array.from({ length: 60 }, (_, id) => ({
|
||||
id,
|
||||
level: LEVELS[(id * 5 + Math.floor(id / 5)) % LEVELS.length],
|
||||
}));
|
||||
|
||||
function sketchBody(kind: PanelSketchKind) {
|
||||
switch (kind) {
|
||||
case "count":
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", justifyContent: "center", gap: "5px", flex: 1 }}>
|
||||
<Bar width="10px" height={10} fill={accent} />
|
||||
<Bar width="38%" height={11} />
|
||||
<Bar width="55%" height={4} fill={faint} />
|
||||
</Box>
|
||||
);
|
||||
case "models":
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Rows count={3} lead="thumb" />
|
||||
</>
|
||||
);
|
||||
case "authors":
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Rows count={4} lead="avatar" />
|
||||
</>
|
||||
);
|
||||
case "providers":
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Rows count={4} lead="chip" />
|
||||
</>
|
||||
);
|
||||
case "clock":
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", justifyContent: "center", gap: "3px", flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
fontFamily: MONO_FONT,
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
letterSpacing: "-0.02em",
|
||||
color: (theme) => theme.thingport.headingText,
|
||||
}}
|
||||
>
|
||||
09:41
|
||||
</Box>
|
||||
<Bar width="50%" height={3} />
|
||||
<Bar width="35%" height={3} fill={faint} />
|
||||
</Box>
|
||||
);
|
||||
case "weather":
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", justifyContent: "space-between", flex: 1 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<Bar width="16px" height={16} fill={(theme) => alpha(theme.palette.warning.main, 0.8)} round />
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: "3px", flex: 1 }}>
|
||||
<Bar width="40%" height={9} />
|
||||
<Bar width="60%" height={3} fill={faint} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: "3px" }}>
|
||||
{[0, 1, 2, 3].map((index) => (
|
||||
<Box key={index} sx={{ flex: 1, height: 10, borderRadius: "2px", bgcolor: faint }} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
case "featured":
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", flex: 1 }}>
|
||||
<Header />
|
||||
{/* The spinning model: a cube seen from a corner. */}
|
||||
<Box sx={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: "3px",
|
||||
bgcolor: accent,
|
||||
transform: "rotate(45deg) skew(-12deg, -12deg)",
|
||||
boxShadow: (theme) => `4px 4px 0 ${alpha(theme.palette.primary.main, 0.25)}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
case "heatmap":
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateRows: "repeat(5, 1fr)",
|
||||
gridAutoFlow: "column",
|
||||
gridAutoColumns: "1fr",
|
||||
gap: "2px",
|
||||
mt: "2px",
|
||||
}}
|
||||
>
|
||||
{HEAT.map((cell) => (
|
||||
<Box
|
||||
key={cell.id}
|
||||
sx={{ aspectRatio: "1", borderRadius: "1px", bgcolor: (theme) => cellColor(theme, cell.level) }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** A small drawing of a dashboard card, for choosing which ones show. */
|
||||
export default function PanelSketch({ kind }: { kind: PanelSketchKind }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
aspectRatio: "4 / 3",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
p: "7px",
|
||||
borderRadius: "6px",
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
bgcolor: "background.paper",
|
||||
overflow: "hidden",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{sketchBody(kind)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -34,13 +34,15 @@ const BASE: DashboardWidgetId[] = [
|
||||
"topAuthors",
|
||||
"topProviders",
|
||||
];
|
||||
const EXTRAS: DashboardWidgetId[] = ["clock", "weather", "featuredModel"];
|
||||
const EXTRAS: DashboardWidgetId[] = ["clock", "weather", "featuredModel", "activityHeatmap"];
|
||||
|
||||
describe("puzzle layout", () => {
|
||||
// The extras come as a block of two rows, so they fit exactly when all or none are shown.
|
||||
// The extras come as a block of three rows, so they fit exactly when all or none are shown. The
|
||||
// heatmap is a whole row of its own, so it fits on its own too.
|
||||
it.each([
|
||||
["no extras", []],
|
||||
["every extra", EXTRAS],
|
||||
["only the activity heatmap", ["activityHeatmap"]],
|
||||
])("packs the default boxes with %s and leaves no holes", (_, extras) => {
|
||||
const pieces = puzzlePieces([...BASE, ...(extras as DashboardWidgetId[])].map((id) => ({ id })));
|
||||
expect(pieces).toHaveLength(BASE.length + (extras as DashboardWidgetId[]).length);
|
||||
|
||||
@@ -40,13 +40,14 @@ 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. The optional extras fill two rows of six exactly, and the default boxes five more.
|
||||
* big panels. The optional extras fill three rows of six exactly, and the default boxes five more.
|
||||
*/
|
||||
const PUZZLE_PIECES: { id: DashboardWidgetId; columns: number; rows: number }[] = [
|
||||
// The optional extras lead: two full rows when shown, and the rest packs below as if they weren't.
|
||||
// The optional extras lead: three full rows when shown, and the rest packs below as if they weren't.
|
||||
{ id: "featuredModel", columns: 3, rows: 2 },
|
||||
{ id: "clock", columns: 3, rows: 1 },
|
||||
{ id: "weather", columns: 3, rows: 1 },
|
||||
{ id: "activityHeatmap", columns: 6, rows: 1 },
|
||||
{ id: "topViewed", columns: 3, rows: 2 },
|
||||
{ id: "collectionCount", columns: 2, rows: 1 },
|
||||
{ id: "modelCount", columns: 1, rows: 1 },
|
||||
|
||||
@@ -17,7 +17,9 @@ import RecentlyAddedCard from "./RecentlyAddedCard";
|
||||
import ClockCard from "./ClockCard";
|
||||
import WeatherCard from "./WeatherCard";
|
||||
import FeaturedModelCard from "./FeaturedModelCard";
|
||||
import ActivityHeatmapCard from "./ActivityHeatmapCard";
|
||||
import type { DashboardSkeleton } from "./DashboardCardSkeleton";
|
||||
import type { PanelSketchKind } from "./PanelSketch";
|
||||
|
||||
type WidgetContext = {
|
||||
summary: DashboardSummary;
|
||||
@@ -34,6 +36,8 @@ export type DashboardWidget = {
|
||||
column: 0 | 1 | 2;
|
||||
/** Shown until the user switches it off. An optional widget can start hidden instead. */
|
||||
defaultEnabled: boolean;
|
||||
/** Its drawing in the settings dialog's Panels picker. */
|
||||
sketch: PanelSketchKind;
|
||||
/** Its placeholder while the summary loads. A widget without one fetches its own data, renders
|
||||
* straight away, and mustn't read `summary`. */
|
||||
skeleton?: DashboardSkeleton;
|
||||
@@ -47,6 +51,7 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
|
||||
labelKey: "dashboard.collectionCount.label",
|
||||
column: 0,
|
||||
defaultEnabled: true,
|
||||
sketch: "count",
|
||||
skeleton: { kind: "count" },
|
||||
render: ({ summary, t, navigate }) => (
|
||||
<CountCard
|
||||
@@ -62,6 +67,7 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
|
||||
labelKey: "dashboard.modelCount.label",
|
||||
column: 0,
|
||||
defaultEnabled: true,
|
||||
sketch: "count",
|
||||
skeleton: { kind: "count" },
|
||||
render: ({ summary, t, navigate }) => (
|
||||
<CountCard
|
||||
@@ -77,6 +83,7 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
|
||||
labelKey: "dashboard.authorCount.label",
|
||||
column: 0,
|
||||
defaultEnabled: true,
|
||||
sketch: "count",
|
||||
skeleton: { kind: "count" },
|
||||
render: ({ summary, t }) => (
|
||||
<CountCard
|
||||
@@ -91,6 +98,7 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
|
||||
labelKey: "dashboard.categoryCount.label",
|
||||
column: 0,
|
||||
defaultEnabled: true,
|
||||
sketch: "count",
|
||||
skeleton: { kind: "count" },
|
||||
render: ({ summary, t, navigate }) => (
|
||||
<CountCard
|
||||
@@ -106,13 +114,23 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
|
||||
labelKey: "dashboard.featuredModel.title",
|
||||
column: 1,
|
||||
defaultEnabled: false,
|
||||
sketch: "featured",
|
||||
render: ({ onUnauthorized }) => <FeaturedModelCard onUnauthorized={onUnauthorized} />,
|
||||
},
|
||||
{
|
||||
id: "activityHeatmap",
|
||||
labelKey: "dashboard.activityHeatmap.title",
|
||||
column: 1,
|
||||
defaultEnabled: false,
|
||||
sketch: "heatmap",
|
||||
render: ({ onUnauthorized }) => <ActivityHeatmapCard onUnauthorized={onUnauthorized} />,
|
||||
},
|
||||
{
|
||||
id: "topViewed",
|
||||
labelKey: "dashboard.topViewed.title",
|
||||
column: 1,
|
||||
defaultEnabled: true,
|
||||
sketch: "models",
|
||||
skeleton: { kind: "list", rows: 3, footer: true },
|
||||
render: ({ summary, t }) => (
|
||||
<ModelListCard
|
||||
@@ -132,6 +150,7 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
|
||||
labelKey: "dashboard.topPrinted.title",
|
||||
column: 1,
|
||||
defaultEnabled: true,
|
||||
sketch: "models",
|
||||
skeleton: { kind: "list", rows: 3, footer: true },
|
||||
render: ({ summary, t }) => (
|
||||
<ModelListCard
|
||||
@@ -151,16 +170,18 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
|
||||
labelKey: "dashboard.recentlyAdded.title",
|
||||
column: 1,
|
||||
defaultEnabled: true,
|
||||
sketch: "models",
|
||||
skeleton: { kind: "list", rows: 3 },
|
||||
render: ({ summary }) => <RecentlyAddedCard models={summary.recently_added} />,
|
||||
},
|
||||
// The clock, weather and featured model are optional extras, off until the user switches them on.
|
||||
// The clock, weather, featured model and activity heatmap are optional extras, off until the user switches them on.
|
||||
// Clock and weather sit together at the top of the right column, as panels rather than count boxes.
|
||||
{
|
||||
id: "clock",
|
||||
labelKey: "dashboard.clock.label",
|
||||
column: 2,
|
||||
defaultEnabled: false,
|
||||
sketch: "clock",
|
||||
render: () => <ClockCard />,
|
||||
},
|
||||
{
|
||||
@@ -168,6 +189,7 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
|
||||
labelKey: "dashboard.weather.label",
|
||||
column: 2,
|
||||
defaultEnabled: false,
|
||||
sketch: "weather",
|
||||
render: () => <WeatherCard />,
|
||||
},
|
||||
{
|
||||
@@ -175,6 +197,7 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
|
||||
labelKey: "dashboard.topAuthors.title",
|
||||
column: 2,
|
||||
defaultEnabled: true,
|
||||
sketch: "authors",
|
||||
skeleton: { kind: "list", rows: 5, footer: true },
|
||||
render: ({ summary }) => <AuthorListCard authors={summary.top_authors} />,
|
||||
},
|
||||
@@ -183,6 +206,7 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
|
||||
labelKey: "dashboard.topProviders.title",
|
||||
column: 2,
|
||||
defaultEnabled: true,
|
||||
sketch: "providers",
|
||||
skeleton: { kind: "list", rows: 4, avatar: false },
|
||||
render: ({ summary }) => <ProviderListCard providers={summary.top_providers} />,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
@@ -20,13 +21,25 @@ type Props = {
|
||||
/** The signed-in user's own activity, as a year of days. Opens on the last 12 months, like GitHub. */
|
||||
export default function ActivitySection({ onUnauthorized }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
// Null is the last 12 months; a year is that calendar year.
|
||||
const [year, setYear] = React.useState<number | null>(null);
|
||||
// A day picked on the heatmap, which the timeline starts from.
|
||||
const [selectedDay, setSelectedDay] = React.useState<string | null>(null);
|
||||
const timelineRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const today = localToday();
|
||||
const thisYear = Number(today.slice(0, 4));
|
||||
// A day picked on the dashboard's heatmap arrives as ?day=YYYY-MM-DD.
|
||||
const [searchParams] = useSearchParams();
|
||||
const [linkedDay] = React.useState(() => {
|
||||
const day = searchParams.get("day");
|
||||
return day && /^\d{4}-\d{2}-\d{2}$/.test(day) && day <= today ? day : null;
|
||||
});
|
||||
// Null is the last 12 months; a year is that calendar year. A linked day before them opens its own year.
|
||||
const [year, setYear] = React.useState<number | null>(() =>
|
||||
linkedDay && linkedDay < lastYearRange(today).from ? Number(linkedDay.slice(0, 4)) : null,
|
||||
);
|
||||
// A day picked on the heatmap, which the timeline starts from.
|
||||
const [selectedDay, setSelectedDay] = React.useState<string | null>(linkedDay);
|
||||
const timelineRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (linkedDay) timelineRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, [linkedDay]);
|
||||
// Tagged with the range it's for, so switching years never shows the last one's numbers.
|
||||
const [loaded, setLoaded] = React.useState<{ key: string; data: ActivitySummary } | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
@@ -14,7 +14,7 @@ const GAP = 3;
|
||||
export const ACTIVITY_KINDS: ActivityKind[] = ["import", "upload", "download", "slicer", "delete"];
|
||||
const LEVEL_ALPHA = [0, 0.3, 0.5, 0.75, 1];
|
||||
|
||||
function cellColor(theme: Theme, value: number): string {
|
||||
export function cellColor(theme: Theme, value: number): string {
|
||||
if (value === 0) return theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.06)" : "rgba(0, 0, 0, 0.06)";
|
||||
return alpha(theme.palette.primary.main, LEVEL_ALPHA[value]);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user