feat: preview dashboard panels as tiles and add an activity heatmap panel

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-20 07:01:16 +02:00
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>>;
+2 -1
View File
@@ -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>
);
}
+5
View File
@@ -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",
+6
View File
@@ -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);
+3 -2
View File
@@ -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 },
+25 -1
View File
@@ -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]);
}