feat(dashboard): add clock, weather, and featured-model widgets

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-10 23:13:56 +03:00
1 parent 2813237e03
commit c71154a83e
22 files changed
+1047 -16

No files matched your search

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "weatherLocation" JSONB;
+2
View File
@@ -53,6 +53,8 @@ model User {
dashboardWidgets Json?
// How the dashboard arranges its boxes, one of DASHBOARD_LAYOUTS; null means "left".
dashboardLayout String?
// Where the dashboard's weather is for: { lat, lon, name, source }. Null until the user sets one.
weatherLocation Json?
authorPreviewEnabled Boolean @default(true)
// Filled from the first linked Author with this data; later links never overwrite it.
bio String?
+19
View File
@@ -1,8 +1,12 @@
import { Router } from "express";
import { z } from "zod";
import { requireAuth } from "../auth";
import { asyncHandler } from "../utils/asyncHandler";
import { parseBody } from "../utils/validate";
import { printOutById } from "../services/printLoader";
import {
getDashboardSummary,
getFeaturedModelId,
getTopAuthorsList,
getTopPrintedList,
getTopViewedList,
@@ -79,4 +83,19 @@ router.get(
}),
);
const featuredQuerySchema = z.object({
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
});
router.get(
"/dashboard/featured",
asyncHandler(async (req, res) => {
const { date } = parseBody(featuredQuerySchema, req.query);
const printId = await getFeaturedModelId(req.userId!, date ?? new Date().toISOString().slice(0, 10));
res.json({ print: printId ? await printOutById(req.userId!, printId) : null });
}),
);
export default router;
+18
View File
@@ -57,6 +57,7 @@ import { CONSUME_DIR } from "../config";
import { THEME_SELECTIONS, getUserTheme, setUserTheme } from "../services/themePreferenceService";
import { getUserAuthorPreviewEnabled, setUserAuthorPreviewEnabled } from "../services/authorPreviewPreferenceService";
import { checkForUpdates } from "../services/versionService";
import { getWeatherLocation, setWeatherLocation, weatherLocationSchema } from "../services/weatherLocationService";
import { dropPreviewsAffectedBySimplification } from "../services/modelPreviewCache";
import { testAiCategorizationProvider } from "../services/aiCategorizationService";
@@ -470,6 +471,23 @@ router.patch(
}),
);
router.get(
"/settings/weather-location",
asyncHandler(async (req, res) => {
res.json({ location: await getWeatherLocation(req.userId!) });
}),
);
// Null forgets the location.
const weatherLocationBodySchema = z.object({ location: weatherLocationSchema.nullable() });
router.patch(
"/settings/weather-location",
asyncHandler(async (req, res) => {
const body = parseBody(weatherLocationBodySchema, req.body);
res.json({ location: await setWeatherLocation(req.userId!, body.location) });
}),
);
router.get(
"/settings/author-preview",
asyncHandler(async (req, res) => {
+26
View File
@@ -1,5 +1,8 @@
import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
import { prisma } from "../db";
import { RENDERABLE_MODEL_EXTS } from "../config";
import { plateThumbExists, plateThumbPath } from "./printService";
const TOP_MODELS_PREVIEW = 3;
@@ -197,3 +200,26 @@ export function getTopPrintedList(userId: string): Promise<DashboardModel[]> {
export function getTopAuthorsList(userId: string): Promise<DashboardAuthor[]> {
return fetchTopAuthors(userId, TOP_AUTHORS_LIST_MAX);
}
/**
* One of the user's models whose first file can be shown in 3D, the same one all day. The day is the
* caller's own date, so the pick changes at the user's midnight rather than the server's.
*/
export async function getFeaturedModelId(userId: string, day: string): Promise<string | null> {
const plates = await prisma.plate.findMany({
where: { print: { userId } },
select: { printId: true, filename: true },
orderBy: [{ printId: "asc" }, { position: "asc" }],
});
const candidates: string[] = [];
const seen = new Set<string>();
for (const plate of plates) {
// The first plate is the one a preview shows.
if (seen.has(plate.printId)) continue;
seen.add(plate.printId);
if (RENDERABLE_MODEL_EXTS.has(path.extname(plate.filename).toLowerCase())) candidates.push(plate.printId);
}
if (!candidates.length) return null;
const seed = createHash("sha256").update(`${userId}:${day}`).digest().readUInt32BE(0);
return candidates[seed % candidates.length];
}
@@ -11,6 +11,9 @@ export const DASHBOARD_WIDGETS = [
"recentlyAdded",
"topAuthors",
"topProviders",
"clock",
"weather",
"featuredModel",
] as const;
export type DashboardWidgetId = (typeof DASHBOARD_WIDGETS)[number];
export type DashboardWidgets = Partial<Record<DashboardWidgetId, boolean>>;
@@ -0,0 +1,27 @@
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "../db";
// "device" came from the browser's location, "manual" from a place the user searched for.
export const weatherLocationSchema = z.object({
lat: z.number().min(-90).max(90),
lon: z.number().min(-180).max(180),
name: z.string().trim().max(120).nullable(),
source: z.enum(["device", "manual"]),
});
export type WeatherLocation = z.infer<typeof weatherLocationSchema>;
/** Stored per user, not per browser, so every device shows the same weather. */
export async function getWeatherLocation(userId: string): Promise<WeatherLocation | null> {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { weatherLocation: true } });
const parsed = weatherLocationSchema.safeParse(user?.weatherLocation);
return parsed.success ? parsed.data : null;
}
export async function setWeatherLocation(
userId: string,
location: WeatherLocation | null,
): Promise<WeatherLocation | null> {
await prisma.user.update({ where: { id: userId }, data: { weatherLocation: location ?? Prisma.DbNull } });
return location;
}
+78 -4
View File
@@ -1,21 +1,25 @@
import { beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
const app = createApp();
const stamp = Date.now();
let token: string;
let userId: string;
const auth = () => ({ Authorization: `Bearer ${token}` });
const get = async () => (await request(app).get("/api/settings/dashboard-widgets").set(auth())).body.widgets;
const patch = (widgets: unknown) => request(app).patch("/api/settings/dashboard-widgets").set(auth()).send({ widgets });
beforeAll(async () => {
token = (
const registered = (
await request(app)
.post("/api/register")
.send({ displayName: "Widgets", email: `widgets-${stamp}@example.com`, password: "password123" })
).body.token;
).body;
token = registered.token;
userId = registered.user.id;
});
describe("dashboard widget preferences", () => {
@@ -38,7 +42,7 @@ describe("dashboard widget preferences", () => {
});
it("rejects an unknown widget or a non-boolean", async () => {
expect((await patch({ weather: false })).status).toBe(400);
expect((await patch({ horoscope: false })).status).toBe(400);
expect((await patch({ topViewed: "no" })).status).toBe(400);
});
@@ -67,3 +71,73 @@ describe("dashboard layout preference", () => {
expect((await request(app).get("/api/settings/dashboard-layout")).status).toBe(401);
});
});
describe("weather location preference", () => {
const getLocation = async () => (await request(app).get("/api/settings/weather-location").set(auth())).body.location;
const setLocation = (location: unknown) =>
request(app).patch("/api/settings/weather-location").set(auth()).send({ location });
it("starts unset, keeps a chosen place, and forgets it", async () => {
expect(await getLocation()).toBeNull();
const vilnius = { lat: 54.69, lon: 25.28, name: "Vilnius", source: "manual" };
expect((await setLocation(vilnius)).body.location).toEqual(vilnius);
expect(await getLocation()).toEqual(vilnius);
expect((await setLocation(null)).status).toBe(200);
expect(await getLocation()).toBeNull();
});
it("rejects coordinates off the globe", async () => {
expect((await setLocation({ lat: 91, lon: 0, name: null, source: "device" })).status).toBe(400);
expect((await setLocation({ lat: 0, lon: 0, name: null, source: "elsewhere" })).status).toBe(400);
});
});
describe("featured model", () => {
const featured = async (date: string) =>
(await request(app).get(`/api/dashboard/featured?date=${date}`).set(auth())).body.print;
const createdPrints: string[] = [];
async function model(filename: string) {
const name = `Featured ${createdPrints.length} ${stamp}`;
const row = await prisma.print.create({
data: {
userId,
name,
nameNormalized: name.toLowerCase(),
plates: {
create: {
position: 0,
filename,
mime: "model/stl",
storagePath: `featured-${stamp}-${createdPrints.length}`,
},
},
},
});
createdPrints.push(row.id);
return row;
}
afterAll(async () => {
await prisma.print.deleteMany({ where: { id: { in: createdPrints } } });
});
it("has nothing to feature without a 3D model", async () => {
await model("notes.pdf");
expect(await featured("2026-10-10")).toBeNull();
});
it("features a 3D model, the same one all day", async () => {
const models = [await model("cube.stl"), await model("plate.3mf"), await model("part.step")];
const today = await featured("2026-10-10");
expect(models.map((m) => m.id)).toContain(today.id);
expect((await featured("2026-10-10")).id).toBe(today.id);
// Over a few days the pick moves between models.
const days = await Promise.all(["11", "12", "13", "14", "15", "16"].map((d) => featured(`2026-10-${d}`)));
expect(new Set(days.map((print) => print.id)).size).toBeGreaterThan(1);
});
it("rejects a malformed date", async () => {
expect((await request(app).get("/api/dashboard/featured?date=yesterday").set(auth())).status).toBe(400);
});
});
+10
View File
@@ -1,5 +1,6 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk } from "./client";
import type { Print } from "./prints";
export type DashboardModel = {
id: string;
@@ -54,6 +55,15 @@ export const dashboardApi = {
return res.json();
},
/** Today's model, by the browser's own date so it changes at the user's midnight. */
getFeatured: async (date: string): Promise<{ print: Print | null }> => {
const res = await fetch(`${apiBase()}/dashboard/featured?date=${encodeURIComponent(date)}`, {
headers: authHeaders(),
});
assertOk(res, "Failed to load the featured model");
return res.json();
},
getTopAuthors: async (): Promise<DashboardAuthor[]> => {
const res = await fetch(`${apiBase()}/dashboard/top-authors`, { headers: authHeaders() });
assertOk(res, "Failed to load authors");
+24 -1
View File
@@ -24,7 +24,12 @@ export type DashboardWidgetId =
| "topPrinted"
| "recentlyAdded"
| "topAuthors"
| "topProviders";
| "topProviders"
| "clock"
| "weather"
| "featuredModel";
/** 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.
export type DashboardLayout = "left" | "top" | "right" | "bottom" | "topTwo" | "puzzle";
/** Only the widgets the user has switched; the rest follow their default. */
@@ -398,6 +403,24 @@ export const settingsApi = {
return res.json();
},
getWeatherLocation: async (): Promise<{ location: WeatherLocation | null }> => {
const res = await fetch(`${apiBase()}/settings/weather-location`, { headers: authHeaders() });
assertOk(res, "Failed to load the weather location");
return res.json();
},
/** Null forgets the location. */
updateWeatherLocation: async (location: WeatherLocation | null): Promise<{ location: WeatherLocation | null }> => {
const res = await fetch(`${apiBase()}/settings/weather-location`, {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ location }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update the weather location"));
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");
@@ -55,6 +55,10 @@ type ModelViewerProps = {
autoRotate?: boolean;
/** Camera preset to frame the model from on load. Unset keeps per-format defaults. */
initialCameraView?: CameraView;
/** The small axis cube in the corner. */
showOrientationGizmo?: boolean;
/** Behind the model; "transparent" lets the surrounding card show through. */
background?: string;
};
// Appearance props that change without reloading the model.
@@ -130,6 +134,8 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
autoRotate = false,
buildPlateForMeshes = false,
initialCameraView,
showOrientationGizmo = true,
background = "action.hover",
},
ref,
) {
@@ -535,10 +541,14 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
camera.updateProjectionMatrix();
};
window.addEventListener("resize", onResize);
const gizmo = createOrientationGizmo(renderer);
// Its box can change size without the window doing so, e.g. a dashboard card when the layout changes.
const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(onResize);
resizeObserver?.observe(mount);
const gizmo = showOrientationGizmo ? createOrientationGizmo(renderer) : null;
teardown = () => {
window.removeEventListener("resize", onResize);
gizmo.dispose();
resizeObserver?.disconnect();
gizmo?.dispose();
};
// Eased toward over a few frames so button zooms don't jump.
@@ -576,7 +586,7 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
.add(keyLight.target.position);
}
renderer.render(scene, camera);
gizmo.render(camera, controls?.target ?? new THREE.Vector3(), width, height);
gizmo?.render(camera, controls?.target ?? new THREE.Vector3(), width, height);
requestAnimationFrame(animate);
};
animate();
@@ -661,7 +671,7 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
sx={{
width: "100%",
height: "100%",
bgcolor: "action.hover",
bgcolor: background,
borderRadius: 1,
overflow: "hidden",
position: "relative",
@@ -675,7 +685,7 @@ const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function Mod
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "action.hover",
bgcolor: background,
}}
>
<Stack alignItems="center" spacing={1.5}>
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useState } from "react";
import { settingsApi, type WeatherLocation } from "../api/settings";
// Saved on the account so every device shows the same weather. Shared by every user of the hook so
// there's one request, and a change shows everywhere at once.
let cached: WeatherLocation | null | undefined;
let inFlight: Promise<WeatherLocation | null> | null = null;
const listeners = new Set<(value: WeatherLocation | null) => void>();
function load(): Promise<WeatherLocation | null> {
if (cached !== undefined) return Promise.resolve(cached);
// A change made while this is in flight is newer than the server's answer, so it wins.
inFlight ??= settingsApi
.getWeatherLocation()
.then((res) => (cached = cached === undefined ? res.location : cached))
.catch(() => cached ?? null)
.finally(() => {
inFlight = null;
});
return inFlight;
}
function publish(value: WeatherLocation | null) {
cached = value;
listeners.forEach((listener) => listener(value));
}
/** Undefined while loading, null when the user hasn't set a location. */
export function useWeatherLocation(): [WeatherLocation | null | undefined, (location: WeatherLocation | null) => void] {
const [value, setValue] = useState<WeatherLocation | null | undefined>(cached);
useEffect(() => {
let cancelled = false;
void load().then((v) => {
if (!cancelled) setValue(cached === undefined ? v : cached);
});
listeners.add(setValue);
return () => {
cancelled = true;
listeners.delete(setValue);
};
}, []);
const update = (location: WeatherLocation | null) => {
publish(location);
// Already applied here; a failed save only means other devices won't follow.
settingsApi.updateWeatherLocation(location).catch(() => {});
};
return [value, update];
}
+44 -1
View File
@@ -349,11 +349,54 @@
"title": "Recently Added",
"empty": "Nothing added yet — uploaded and imported models will show up here."
},
"clock": {
"label": "Clock",
"greeting": {
"night": "Night owl mode",
"morning": "Good morning",
"afternoon": "Good afternoon",
"evening": "Good evening"
}
},
"weather": {
"label": "Weather",
"setLocation": "Choose where to show the weather for.",
"detect": "Detect location",
"detecting": "Asking your browser…",
"detectUnavailable": "Browsers share your location only over HTTPS or on localhost. Search for a place instead.",
"detectBlocked": "Location is blocked for this site. Allow it in the browser's site settings, or search for a place.",
"detectFailed": "Couldn't get your location. Try again, or search for a place.",
"searchLabel": "Search for a place",
"noPlaces": "No places found for “{{query}}”.",
"searchFailed": "Place search failed. Check your connection and try again.",
"cancel": "Cancel",
"forget": "Forget location",
"change": "Change location",
"yourArea": "Your area",
"loadError": "Couldn't load the weather.",
"highLow": "H {{high}}° L {{low}}°",
"later": "In {{hours}} h: {{temperature}}°, {{condition}}",
"conditions": {
"clear": "Clear",
"partly": "Partly cloudy",
"cloudy": "Cloudy",
"fog": "Fog",
"drizzle": "Drizzle",
"rain": "Rain",
"snow": "Snow",
"storm": "Thunderstorm"
}
},
"featuredModel": {
"title": "Featured model",
"empty": "No 3D models to feature yet.",
"loadError": "Couldn't load today's model.",
"hint": "Drag to spin, click to open"
},
"widgets": {
"open": "Customize dashboard",
"title": "Customize dashboard",
"subtitle": "Choose which boxes your dashboard shows.",
"close": "Close",
"allHidden": "Every dashboard box is hidden.",
"sections": {
"layout": "Layout",
+44 -1
View File
@@ -349,11 +349,54 @@
"title": "Naujausiai pridėti",
"empty": "Dar nieko nepridėta — įkelti ir importuoti modeliai atsiras čia."
},
"clock": {
"label": "Laikrodis",
"greeting": {
"night": "Naktinės pelėdos režimas",
"morning": "Labas rytas",
"afternoon": "Laba diena",
"evening": "Labas vakaras"
}
},
"weather": {
"label": "Orai",
"setLocation": "Pasirinkite, kurios vietos orus rodyti.",
"detect": "Nustatyti vietą",
"detecting": "Klausiama naršyklės…",
"detectUnavailable": "Naršyklės dalijasi vieta tik per HTTPS arba localhost. Ieškokite vietos.",
"detectBlocked": "Šiai svetainei vieta užblokuota. Leiskite ją naršyklės svetainės nustatymuose arba ieškokite vietos.",
"detectFailed": "Nepavyko nustatyti vietos. Bandykite dar kartą arba ieškokite vietos.",
"searchLabel": "Ieškoti vietos",
"noPlaces": "Pagal „{{query}}“ vietų nerasta.",
"searchFailed": "Vietos paieška nepavyko. Patikrinkite ryšį ir bandykite dar kartą.",
"cancel": "Atšaukti",
"forget": "Pamiršti vietą",
"change": "Keisti vietą",
"yourArea": "Jūsų vietovė",
"loadError": "Nepavyko įkelti orų.",
"highLow": "Aukšč. {{high}}° žem. {{low}}°",
"later": "Po {{hours}} val.: {{temperature}}°, {{condition}}",
"conditions": {
"clear": "Giedra",
"partly": "Mažai debesuota",
"cloudy": "Debesuota",
"fog": "Rūkas",
"drizzle": "Dulksna",
"rain": "Lietus",
"snow": "Sniegas",
"storm": "Perkūnija"
}
},
"featuredModel": {
"title": "Dienos modelis",
"empty": "Kol kas nėra 3D modelių.",
"loadError": "Nepavyko įkelti šiandienos modelio.",
"hint": "Tempkite, kad pasuktumėte, spustelėkite, kad atidarytumėte"
},
"widgets": {
"open": "Tvarkyti valdymo skydą",
"title": "Tvarkyti valdymo skydą",
"subtitle": "Pasirinkite, kuriuos langelius rodo jūsų valdymo skydas.",
"close": "Uždaryti",
"allHidden": "Visi valdymo skydo langeliai paslėpti.",
"sections": {
"layout": "Išdėstymas",
@@ -0,0 +1,91 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
// A status-bar look: monospace, the time as the anchor, the date small and spaced out.
export const MONO_FONT = '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
function greetingKey(hour: number): string {
if (hour < 5) return "night";
if (hour < 12) return "morning";
if (hour < 18) return "afternoon";
return "evening";
}
/** Re-renders on each new minute, lined up with the wall clock rather than drifting from mount time. */
function useMinute(): Date {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
let timer: number;
const schedule = () => {
const next = new Date();
timer = window.setTimeout(
() => {
setNow(new Date());
schedule();
},
60_000 - (next.getSeconds() * 1000 + next.getMilliseconds()),
);
};
schedule();
return () => window.clearTimeout(timer);
}, []);
return now;
}
export default function ClockCard() {
const { t, i18n } = useTranslation("app");
const now = useMinute();
const time = new Intl.DateTimeFormat(i18n.language, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(
now,
);
const weekday = new Intl.DateTimeFormat(i18n.language, { weekday: "short" }).format(now);
const day = new Intl.DateTimeFormat(i18n.language, { month: "short", day: "numeric" }).format(now);
return (
<Paper
variant="outlined"
sx={{
p: 3,
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: 0.75,
minHeight: 140,
fontFamily: MONO_FONT,
borderColor: (theme) => (theme.palette.mode === "dark" ? "transparent" : "divider"),
}}
>
<Typography
component="time"
dateTime={now.toISOString()}
sx={{
fontFamily: "inherit",
fontSize: "2.75rem",
fontWeight: 600,
lineHeight: 1,
letterSpacing: "-0.02em",
fontVariantNumeric: "tabular-nums",
color: (theme) => theme.thingport.headingText,
}}
>
{time}
</Typography>
<Typography
sx={{
fontFamily: "inherit",
fontSize: "0.8rem",
letterSpacing: "0.12em",
textTransform: "uppercase",
color: "text.secondary",
}}
>
{`${weekday} · ${day}`}
</Typography>
<Typography sx={{ fontFamily: "inherit", fontSize: "0.75rem", color: "text.disabled" }}>
{t(`dashboard.clock.greeting.${greetingKey(now.getHours())}`)}
</Typography>
</Paper>
);
}
@@ -0,0 +1,118 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@mui/material/styles";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
import ThreeDRotationIcon from "@mui/icons-material/ThreeDRotation";
import { dashboardApi } from "../../api/dashboard";
import { printsApi, type Print } from "../../api/prints";
import { UnauthorizedError } from "../../api/client";
import ModelViewer from "../../components/media/ModelViewer";
import { extOf } from "../../utils/fileExtensions";
// Further than this between press and release is a spin, not a click to open the model.
const CLICK_SLOP_PX = 6;
const pad = (n: number) => String(n).padStart(2, "0");
function localDate(date: Date): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
type Props = { onUnauthorized?: () => void };
/** One of the user's models a day, spinning slowly; drag to turn it, click to open it. */
export default function FeaturedModelCard({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
const navigate = useNavigate();
const muiTheme = useTheme();
const mode = muiTheme.palette.mode;
const [print, setPrint] = useState<Print | null | undefined>(undefined);
const [failed, setFailed] = useState(false);
const pressedAt = useRef<{ x: number; y: number } | null>(null);
useEffect(() => {
let active = true;
dashboardApi
.getFeatured(localDate(new Date()))
.then((result) => {
if (active) setPrint(result.print);
})
.catch((err: unknown) => {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else if (active) setFailed(true);
});
return () => {
active = false;
};
}, [onUnauthorized]);
const plate = print?.plates[0];
const open = () => print && navigate(`/models/${print.id}`);
let body: React.ReactNode;
if (failed) body = <Typography color="text.secondary">{t("dashboard.featuredModel.loadError")}</Typography>;
else if (print === undefined)
body = (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", flex: 1 }}>
<CircularProgress size={22} />
</Box>
);
else if (!print || !plate)
body = <Typography color="text.secondary">{t("dashboard.featuredModel.empty")}</Typography>;
else
body = (
// Fills whatever height the card is given, so the viewport follows the layout.
<Box
onPointerDown={(event) => (pressedAt.current = { x: event.clientX, y: event.clientY })}
onClick={(event) => {
const from = pressedAt.current;
if (from && Math.hypot(event.clientX - from.x, event.clientY - from.y) < CLICK_SLOP_PX) open();
}}
title={t("dashboard.featuredModel.hint")}
sx={{ position: "relative", flex: 1, minHeight: 240, cursor: "grab" }}
>
<Box sx={{ position: "absolute", inset: 0 }}>
<ModelViewer
key={plate.id}
url={printsApi.fileUrl(plate.url)}
ext={extOf(plate.filename)}
viewKey={`featured-${print.id}-${plate.id}`}
theme={mode}
previewGlbUrl={plate.preview_glb_url ? printsApi.fileUrl(plate.preview_glb_url) : null}
initialCameraView="topFront"
// The default near-white model all but vanishes against the card it now sits on.
colorOverride={muiTheme.palette.primary.main}
showBuildPlate={false}
showOrientationGizmo={false}
background="transparent"
autoRotate
/>
</Box>
</Box>
);
return (
<Paper
variant="outlined"
sx={{
p: 2.5,
display: "flex",
flexDirection: "column",
gap: 1.5,
borderColor: (theme) => (theme.palette.mode === "dark" ? "transparent" : "divider"),
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, color: "primary.main", minWidth: 0 }}>
<ThreeDRotationIcon />
<Typography variant="h6" fontWeight={700} sx={{ color: (theme) => theme.thingport.headingText }}>
{t("dashboard.featuredModel.title")}
</Typography>
</Box>
{body}
</Paper>
);
}
@@ -0,0 +1,286 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import IconButton from "@mui/material/IconButton";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemText from "@mui/material/ListItemText";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import WbSunnyIcon from "@mui/icons-material/WbSunny";
import FilterDramaIcon from "@mui/icons-material/FilterDrama";
import CloudIcon from "@mui/icons-material/Cloud";
import WbCloudyIcon from "@mui/icons-material/WbCloudy";
import GrainIcon from "@mui/icons-material/Grain";
import WaterDropIcon from "@mui/icons-material/WaterDrop";
import AcUnitIcon from "@mui/icons-material/AcUnit";
import ThunderstormIcon from "@mui/icons-material/Thunderstorm";
import MyLocationIcon from "@mui/icons-material/MyLocation";
import PlaceOutlinedIcon from "@mui/icons-material/PlaceOutlined";
import type { WeatherLocation } from "../../api/settings";
import { useWeatherLocation } from "../../hooks/useWeatherLocation";
import {
conditionOf,
fetchWeather,
placeName,
searchPlaces,
type Condition,
type Place,
type Weather,
} from "../../utils/weather";
import { MONO_FONT } from "./ClockCard";
const ICONS: Record<Condition, typeof WbSunnyIcon> = {
clear: WbSunnyIcon,
partly: FilterDramaIcon,
cloudy: CloudIcon,
fog: WbCloudyIcon,
drizzle: GrainIcon,
rain: WaterDropIcon,
snow: AcUnitIcon,
storm: ThunderstormIcon,
};
const SEARCH_DEBOUNCE_MS = 300;
const placeLabel = (place: Place) => [place.name, place.admin1, place.country].filter(Boolean).join(", ");
/** Detect from the browser, or search for a place. Browsers only share location over HTTPS or localhost. */
function LocationPicker({ onPick, onCancel }: { onPick: (location: WeatherLocation) => void; onCancel?: () => void }) {
const { t, i18n } = useTranslation("app");
const [query, setQuery] = useState("");
const [results, setResults] = useState<Place[]>([]);
const [searching, setSearching] = useState(false);
const [detecting, setDetecting] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const searchSeq = useRef(0);
const canDetect = typeof navigator !== "undefined" && "geolocation" in navigator && window.isSecureContext;
useEffect(() => {
const q = query.trim();
const seq = ++searchSeq.current;
if (q.length < 2) {
setResults([]);
setSearching(false);
return;
}
setSearching(true);
const timer = window.setTimeout(() => {
searchPlaces(q, i18n.language)
.then((found) => {
// Only the newest query's answer lands, so a slow early one can't overwrite it.
if (seq !== searchSeq.current) return;
setResults(found);
setStatus(found.length ? null : t("dashboard.weather.noPlaces", { query: q }));
})
.catch(() => seq === searchSeq.current && setStatus(t("dashboard.weather.searchFailed")))
.finally(() => seq === searchSeq.current && setSearching(false));
}, SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [query, i18n.language, t]);
const detect = () => {
setDetecting(true);
setStatus(null);
navigator.geolocation.getCurrentPosition(
async (position) => {
const { latitude: lat, longitude: lon } = position.coords;
const name = await placeName(lat, lon, i18n.language);
setDetecting(false);
onPick({ lat, lon, name, source: "device" });
},
(error) => {
setDetecting(false);
setStatus(
error.code === error.PERMISSION_DENIED
? t("dashboard.weather.detectBlocked")
: t("dashboard.weather.detectFailed"),
);
},
{ timeout: 15000 },
);
};
return (
<Stack spacing={1.25}>
<Tooltip title={canDetect ? "" : t("dashboard.weather.detectUnavailable")}>
<span>
<Button
size="small"
variant="outlined"
startIcon={detecting ? <CircularProgress size={14} color="inherit" /> : <MyLocationIcon />}
onClick={detect}
disabled={!canDetect || detecting}
>
{detecting ? t("dashboard.weather.detecting") : t("dashboard.weather.detect")}
</Button>
</span>
</Tooltip>
<TextField
size="small"
label={t("dashboard.weather.searchLabel")}
value={query}
onChange={(event) => setQuery(event.target.value)}
slotProps={{ input: { endAdornment: searching ? <CircularProgress size={14} /> : undefined } }}
/>
{results.length > 0 && (
<List dense disablePadding>
{results.map((place) => (
<ListItemButton
key={`${place.latitude},${place.longitude}`}
sx={{ borderRadius: 1 }}
onClick={() => onPick({ lat: place.latitude, lon: place.longitude, name: place.name, source: "manual" })}
>
<ListItemText primary={placeLabel(place)} primaryTypographyProps={{ noWrap: true }} />
</ListItemButton>
))}
</List>
)}
{status && (
<Typography variant="caption" color="text.secondary">
{status}
</Typography>
)}
{onCancel && (
<Button size="small" onClick={onCancel} sx={{ alignSelf: "flex-start" }}>
{t("dashboard.weather.cancel")}
</Button>
)}
</Stack>
);
}
export default function WeatherCard() {
const { t } = useTranslation("app");
const [location, setLocation] = useWeatherLocation();
const [weather, setWeather] = useState<Weather | null>(null);
const [failed, setFailed] = useState(false);
const [editing, setEditing] = useState(false);
useEffect(() => {
setWeather(null);
setFailed(false);
if (!location) return;
let active = true;
fetchWeather(location.lat, location.lon)
.then((result) => active && setWeather(result))
.catch(() => active && setFailed(true));
return () => {
active = false;
};
}, [location]);
const condition = weather ? conditionOf(weather.code) : null;
const Icon = condition ? ICONS[condition] : null;
const later = weather ? conditionOf(weather.later.code) : null;
let body: React.ReactNode;
if (location === undefined) body = <CircularProgress size={22} />;
else if (!location || editing)
body = (
<Stack spacing={1.5}>
{!location && (
<Typography variant="body2" color="text.secondary">
{t("dashboard.weather.setLocation")}
</Typography>
)}
<LocationPicker
onPick={(picked) => {
setLocation(picked);
setEditing(false);
}}
onCancel={location ? () => setEditing(false) : undefined}
/>
{location && (
<Button size="small" color="error" onClick={() => setLocation(null)} sx={{ alignSelf: "flex-start" }}>
{t("dashboard.weather.forget")}
</Button>
)}
</Stack>
);
else if (failed) body = <Typography color="text.secondary">{t("dashboard.weather.loadError")}</Typography>;
else if (!weather || !Icon || !condition || !later) body = <CircularProgress size={22} />;
else
body = (
<Stack spacing={0.75} sx={{ fontFamily: MONO_FONT }}>
<Stack direction="row" alignItems="center" spacing={1.25}>
<Icon sx={{ fontSize: 34, color: "primary.main" }} />
<Typography
sx={{
fontFamily: "inherit",
fontSize: "2.75rem",
fontWeight: 600,
lineHeight: 1,
fontVariantNumeric: "tabular-nums",
color: (theme) => theme.thingport.headingText,
}}
>
{`${weather.temperature}°`}
</Typography>
</Stack>
<Typography
sx={{
fontFamily: "inherit",
fontSize: "0.8rem",
letterSpacing: "0.12em",
textTransform: "uppercase",
color: "text.secondary",
}}
>
{`${t(`dashboard.weather.conditions.${condition}`)} · ${t("dashboard.weather.highLow", { high: weather.high, low: weather.low })}`}
</Typography>
<Typography sx={{ fontFamily: "inherit", fontSize: "0.75rem", color: "text.disabled" }}>
{t("dashboard.weather.later", {
hours: weather.later.hours,
temperature: weather.later.temperature,
condition: t(`dashboard.weather.conditions.${later}`).toLowerCase(),
})}
</Typography>
</Stack>
);
return (
<Paper
variant="outlined"
sx={{
p: 3,
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: 1,
minHeight: 140,
position: "relative",
borderColor: (theme) => (theme.palette.mode === "dark" ? "transparent" : "divider"),
}}
>
{location && !editing && (
<Box sx={{ position: "absolute", top: 8, right: 8, display: "flex", alignItems: "center", gap: 0.5 }}>
<Typography
noWrap
sx={{
fontFamily: MONO_FONT,
fontSize: "0.7rem",
letterSpacing: "0.12em",
textTransform: "uppercase",
color: "text.secondary",
maxWidth: 160,
}}
>
{location.name || t("dashboard.weather.yourArea")}
</Typography>
<Tooltip title={t("dashboard.weather.change")}>
<IconButton size="small" onClick={() => setEditing(true)} aria-label={t("dashboard.weather.change")}>
<PlaceOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
)}
{body}
</Paper>
);
}
+2 -2
View File
@@ -88,7 +88,7 @@ export default function DashboardPage({ onUnauthorized }: Props) {
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>
<React.Fragment key={widget.id}>{widget.render({ summary, t, navigate, onUnauthorized })}</React.Fragment>
);
const countsBlock = counts.length ? (
@@ -153,7 +153,7 @@ export default function DashboardPage({ onUnauthorized }: Props) {
"& > *": { flex: 1 },
}}
>
{item.render({ summary, t, navigate })}
{item.render({ summary, t, navigate, onUnauthorized })}
</Box>
))}
</Box>
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import type { DashboardWidgetId } from "../../api/settings";
import { PUZZLE_COLUMNS, puzzlePieces } from "./layouts";
// What the browser's dense grid packing does: each piece takes the first spot it fits, from the top.
function pack(pieces: { columns: number; rows: number }[]) {
const taken = new Set<string>();
let height = 0;
for (const { columns, rows } of pieces) {
for (let row = 0, placed = false; !placed; row++) {
for (let col = 0; col + columns <= PUZZLE_COLUMNS && !placed; col++) {
const cells = Array.from(
{ length: rows * columns },
(_, i) => `${row + Math.floor(i / columns)},${col + (i % columns)}`,
);
if (cells.some((cell) => taken.has(cell))) continue;
cells.forEach((cell) => taken.add(cell));
height = Math.max(height, row + rows);
placed = true;
}
}
}
return { height, holes: height * PUZZLE_COLUMNS - taken.size };
}
const BASE: DashboardWidgetId[] = [
"collectionCount",
"modelCount",
"authorCount",
"categoryCount",
"topViewed",
"topPrinted",
"recentlyAdded",
"topAuthors",
"topProviders",
];
const EXTRAS: DashboardWidgetId[] = ["clock", "weather", "featuredModel"];
describe("puzzle layout", () => {
// The extras come as a block of two rows, so they fit exactly when all or none are shown.
it.each([
["no extras", []],
["every extra", EXTRAS],
])("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);
expect(pack(pieces).holes).toBe(0);
});
});
+5 -1
View File
@@ -40,9 +40,13 @@ 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.
* big panels. The optional extras fill two 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.
{ id: "featuredModel", columns: 3, rows: 2 },
{ id: "clock", columns: 3, rows: 1 },
{ id: "weather", columns: 3, rows: 1 },
{ id: "topViewed", columns: 3, rows: 2 },
{ id: "collectionCount", columns: 2, rows: 1 },
{ id: "modelCount", columns: 1, rows: 1 },
+32 -1
View File
@@ -14,8 +14,16 @@ import ModelListCard from "./ModelListCard";
import AuthorListCard from "./AuthorListCard";
import ProviderListCard from "./ProviderListCard";
import RecentlyAddedCard from "./RecentlyAddedCard";
import ClockCard from "./ClockCard";
import WeatherCard from "./WeatherCard";
import FeaturedModelCard from "./FeaturedModelCard";
type WidgetContext = { summary: DashboardSummary; t: TFunction; navigate: NavigateFunction };
type WidgetContext = {
summary: DashboardSummary;
t: TFunction;
navigate: NavigateFunction;
onUnauthorized?: () => void;
};
export type DashboardWidget = {
id: DashboardWidgetId;
@@ -85,6 +93,13 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
/>
),
},
{
id: "featuredModel",
labelKey: "dashboard.featuredModel.title",
column: 1,
defaultEnabled: false,
render: ({ onUnauthorized }) => <FeaturedModelCard onUnauthorized={onUnauthorized} />,
},
{
id: "topViewed",
labelKey: "dashboard.topViewed.title",
@@ -128,6 +143,22 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
defaultEnabled: true,
render: ({ summary }) => <RecentlyAddedCard models={summary.recently_added} />,
},
// The clock, weather and featured model 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,
render: () => <ClockCard />,
},
{
id: "weather",
labelKey: "dashboard.weather.label",
column: 2,
defaultEnabled: false,
render: () => <WeatherCard />,
},
{
id: "topAuthors",
labelKey: "dashboard.topAuthors.title",
+101
View File
@@ -0,0 +1,101 @@
// Free, keyless services called from the browser: Open-Meteo for the forecast and place search,
// OpenStreetMap's Nominatim to name a detected location.
export type Weather = {
temperature: number;
code: number;
high: number;
low: number;
/** A few hours from now. */
later: { hours: number; temperature: number; code: number };
};
export type Place = { name: string; admin1?: string; country?: string; latitude: number; longitude: number };
const LATER_HOURS = 6;
const CACHE_KEY = "thingport.weather";
const CACHE_TTL_MS = 30 * 60 * 1000;
type Cached = { at: number; lat: number; lon: number; weather: Weather };
function readCache(lat: number, lon: number): Weather | null {
try {
const cached = JSON.parse(localStorage.getItem(CACHE_KEY) ?? "null") as Cached | null;
if (cached && cached.lat === lat && cached.lon === lon && Date.now() - cached.at < CACHE_TTL_MS)
return cached.weather;
} catch {
// An unreadable cache just means asking again.
}
return null;
}
export async function fetchWeather(lat: number, lon: number): Promise<Weather> {
const cached = readCache(lat, lon);
if (cached) return cached;
const params = new URLSearchParams({
latitude: String(lat),
longitude: String(lon),
current: "temperature_2m,weather_code",
hourly: "temperature_2m,weather_code",
daily: "temperature_2m_max,temperature_2m_min",
forecast_hours: String(LATER_HOURS + 1),
forecast_days: "1",
timezone: "auto",
});
const res = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`);
if (!res.ok) throw new Error(`Weather service returned HTTP ${res.status}`);
const data = await res.json();
const weather: Weather = {
temperature: Math.round(data.current.temperature_2m),
code: data.current.weather_code,
high: Math.round(data.daily.temperature_2m_max[0]),
low: Math.round(data.daily.temperature_2m_min[0]),
later: {
hours: LATER_HOURS,
temperature: Math.round(data.hourly.temperature_2m[LATER_HOURS]),
code: data.hourly.weather_code[LATER_HOURS],
},
};
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({ at: Date.now(), lat, lon, weather } satisfies Cached));
} catch {
// Not caching only costs a request next time.
}
return weather;
}
export async function searchPlaces(query: string, language: string): Promise<Place[]> {
const params = new URLSearchParams({ name: query, count: "5", language, format: "json" });
const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?${params}`);
if (!res.ok) throw new Error(`Place search returned HTTP ${res.status}`);
return ((await res.json()).results ?? []) as Place[];
}
/** The town or city at a point, or null when it can't be named. */
export async function placeName(lat: number, lon: number, language: string): Promise<string | null> {
try {
const params = new URLSearchParams({ lat: String(lat), lon: String(lon), format: "json", zoom: "10" });
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
headers: { "Accept-Language": language },
});
const address = (await res.json()).address ?? {};
return address.city || address.town || address.village || address.county || null;
} catch {
return null;
}
}
/** WMO weather codes, grouped into what the dashboard shows. */
export type Condition = "clear" | "partly" | "cloudy" | "fog" | "drizzle" | "rain" | "snow" | "storm";
export function conditionOf(code: number): Condition {
if (code === 0) return "clear";
if (code <= 2) return "partly";
if (code === 3) return "cloudy";
if (code === 45 || code === 48) return "fog";
if (code >= 51 && code <= 57) return "drizzle";
if ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) return "rain";
if ((code >= 71 && code <= 77) || code === 85 || code === 86) return "snow";
if (code >= 95) return "storm";
return "cloudy";
}