feat: dashboard implementation

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-09 11:24:35 +02:00
1 parent 75de79c4bc
commit cf5c8a9584
15 files changed
+980 -17

No files matched your search

+2
View File
@@ -14,6 +14,7 @@ import importsRoutes from "./routes/imports";
import collectionsRoutes from "./routes/collections";
import notificationsRoutes from "./routes/notifications";
import adminRoutes from "./routes/admin";
import dashboardRoutes from "./routes/dashboard";
export function createApp(): Express {
const app = express();
@@ -42,6 +43,7 @@ export function createApp(): Express {
app.use("/api", collectionsRoutes);
app.use("/api", notificationsRoutes);
app.use("/api", adminRoutes);
app.use("/api", dashboardRoutes);
// 404 fallback for unmatched routes.
app.use((_req: Request, res: Response) => {
+84
View File
@@ -0,0 +1,84 @@
import { Router } from "express";
import { requireAuth } from "../auth";
import { asyncHandler } from "../utils/asyncHandler";
import {
getDashboardSummary,
getTopAuthorsList,
getTopPrintedList,
getTopViewedList,
type DashboardAuthor,
type DashboardModel,
type DashboardProvider,
} from "../services/dashboardService";
const router = Router();
router.use(requireAuth);
function modelOut(m: DashboardModel) {
return {
id: m.id,
name: m.name,
thumb_url: m.thumbUrl,
view_count: m.viewCount,
print_count: m.printCount,
created_at: m.createdAt,
};
}
function authorOut(a: DashboardAuthor) {
return {
id: a.id,
name: a.name,
handle: a.handle,
avatar_url: a.avatarUrl,
model_count: a.modelCount,
};
}
function providerOut(p: DashboardProvider) {
return { provider: p.provider, model_count: p.modelCount };
}
// Single combined call powers the dashboard's default view; the three routes below back each
// list card's "see more" dialog with an uncapped (but ceiling-bounded) version of the same list,
// fetched lazily only when the admin actually opens one.
router.get(
"/dashboard/summary",
asyncHandler(async (req, res) => {
const summary = await getDashboardSummary(req.userId!);
res.json({
collection_count: summary.collectionCount,
model_count: summary.modelCount,
author_count: summary.authorCount,
category_count: summary.categoryCount,
top_viewed: summary.topViewed.map(modelOut),
top_printed: summary.topPrinted.map(modelOut),
top_authors: summary.topAuthors.map(authorOut),
top_providers: summary.topProviders.map(providerOut),
recently_added: summary.recentlyAdded.map(modelOut),
});
}),
);
router.get(
"/dashboard/top-viewed",
asyncHandler(async (req, res) => {
res.json((await getTopViewedList(req.userId!)).map(modelOut));
}),
);
router.get(
"/dashboard/top-printed",
asyncHandler(async (req, res) => {
res.json((await getTopPrintedList(req.userId!)).map(modelOut));
}),
);
router.get(
"/dashboard/top-authors",
asyncHandler(async (req, res) => {
res.json((await getTopAuthorsList(req.userId!)).map(authorOut));
}),
);
export default router;
+198
View File
@@ -0,0 +1,198 @@
import fs from "node:fs";
import { prisma } from "../db";
import { plateThumbExists, plateThumbPath } from "./printService";
const TOP_MODELS_PREVIEW = 3;
const TOP_MODELS_LIST_MAX = 50;
const TOP_AUTHORS_PREVIEW = 5;
const TOP_AUTHORS_LIST_MAX = 50;
const RECENTLY_ADDED_LIMIT = 3;
export type DashboardModel = {
id: string;
name: string;
thumbUrl: string | null;
viewCount: number;
printCount: number;
createdAt: Date;
};
export type DashboardAuthor = {
id: string;
name: string | null;
handle: string | null;
avatarUrl: string | null;
modelCount: number;
};
export type DashboardProvider = {
provider: string;
modelCount: number;
};
export type DashboardSummary = {
collectionCount: number;
modelCount: number;
authorCount: number;
categoryCount: number;
topViewed: DashboardModel[];
topPrinted: DashboardModel[];
topAuthors: DashboardAuthor[];
topProviders: DashboardProvider[];
recentlyAdded: DashboardModel[];
};
type PrintForModelSummary = {
id: string;
name: string;
viewCount: number;
printCount: number;
createdAt: Date;
plates: { id: string }[];
};
function toModelSummary(print: PrintForModelSummary): DashboardModel {
const plateId = print.plates[0]?.id ?? null;
let thumbUrl: string | null = null;
if (plateId && plateThumbExists(plateId)) {
const mtime = fs.statSync(plateThumbPath(plateId)).mtimeMs;
thumbUrl = `/plate/${plateId}/thumb.jpg?v=${mtime}`;
}
return {
id: print.id,
name: print.name,
thumbUrl,
viewCount: print.viewCount,
printCount: print.printCount,
createdAt: print.createdAt,
};
}
const MODEL_SUMMARY_SELECT = {
id: true,
name: true,
viewCount: true,
printCount: true,
createdAt: true,
plates: { take: 1, orderBy: { position: "asc" as const }, select: { id: true } },
};
async function fetchTopViewed(userId: string, limit: number): Promise<DashboardModel[]> {
const prints = await prisma.print.findMany({
where: { userId, viewCount: { gt: 0 } },
orderBy: { viewCount: "desc" },
take: limit,
select: MODEL_SUMMARY_SELECT,
});
return prints.map(toModelSummary);
}
async function fetchTopPrinted(userId: string, limit: number): Promise<DashboardModel[]> {
const prints = await prisma.print.findMany({
where: { userId, printCount: { gt: 0 } },
orderBy: { printCount: "desc" },
take: limit,
select: MODEL_SUMMARY_SELECT,
});
return prints.map(toModelSummary);
}
async function fetchRecentlyAdded(userId: string, limit: number): Promise<DashboardModel[]> {
const prints = await prisma.print.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
take: limit,
select: MODEL_SUMMARY_SELECT,
});
return prints.map(toModelSummary);
}
// Ranked by how many models this user has from each author, not by anything the author
// themselves did -- groupBy can't orderBy an aggregate reliably here, so the sort happens in JS
// (the same pattern print-stash's admin/adminService.ts and youtube-mp3-vault's dashboard both
// use for "top X by count").
async function fetchTopAuthors(userId: string, limit: number): Promise<DashboardAuthor[]> {
const grouped = await prisma.print.groupBy({
by: ["authorId"],
where: { userId, authorId: { not: null } },
_count: true,
});
const top = grouped
.toSorted((a, b) => b._count - a._count)
.slice(0, limit);
if (!top.length) return [];
const authorIds = top.map((g) => g.authorId as string);
const authors = await prisma.author.findMany({ where: { id: { in: authorIds } } });
const byId = new Map(authors.map((a) => [a.id, a]));
return top
.map((g) => {
const author = byId.get(g.authorId as string);
if (!author) return null;
return {
id: author.id,
name: author.name,
handle: author.handle,
avatarUrl: author.avatarUrl,
modelCount: g._count,
};
})
.filter((a): a is DashboardAuthor => a !== null);
}
// A fixed, small set in practice (today: "makerworld", "thingiverse", or null for anything
// uploaded/zip-imported directly, bucketed here as "printstash") -- no "see more" needed on the
// frontend for this one.
async function fetchTopProviders(userId: string): Promise<DashboardProvider[]> {
const grouped = await prisma.print.groupBy({
by: ["sourceProvider"],
where: { userId },
_count: true,
});
const buckets = new Map<string, number>();
for (const g of grouped) {
const key = g.sourceProvider ?? "printstash";
buckets.set(key, (buckets.get(key) ?? 0) + g._count);
}
return [...buckets.entries()]
.map(([provider, modelCount]) => ({ provider, modelCount }))
.toSorted((a, b) => b.modelCount - a.modelCount);
}
export async function getDashboardSummary(userId: string): Promise<DashboardSummary> {
const [collectionCount, modelCount, authorGroups, categoryCount, topViewed, topPrinted, topAuthors, topProviders, recentlyAdded] =
await Promise.all([
prisma.collection.count({ where: { userId } }),
prisma.print.count({ where: { userId } }),
prisma.print.groupBy({ by: ["authorId"], where: { userId, authorId: { not: null } } }),
prisma.folder.count({ where: { userId } }),
fetchTopViewed(userId, TOP_MODELS_PREVIEW),
fetchTopPrinted(userId, TOP_MODELS_PREVIEW),
fetchTopAuthors(userId, TOP_AUTHORS_PREVIEW),
fetchTopProviders(userId),
fetchRecentlyAdded(userId, RECENTLY_ADDED_LIMIT),
]);
return {
collectionCount,
modelCount,
authorCount: authorGroups.length,
categoryCount,
topViewed,
topPrinted,
topAuthors,
topProviders,
recentlyAdded,
};
}
export function getTopViewedList(userId: string): Promise<DashboardModel[]> {
return fetchTopViewed(userId, TOP_MODELS_LIST_MAX);
}
export function getTopPrintedList(userId: string): Promise<DashboardModel[]> {
return fetchTopPrinted(userId, TOP_MODELS_LIST_MAX);
}
export function getTopAuthorsList(userId: string): Promise<DashboardAuthor[]> {
return fetchTopAuthors(userId, TOP_AUTHORS_LIST_MAX);
}
+1 -1
View File
@@ -93,7 +93,7 @@ function AppShell({
onThemeChange={selected => setSettings(prev => ({ ...prev, theme: { selected } }))}
>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/" element={<DashboardPage onUnauthorized={onUnauthorized} />} />
<Route
path="/models"
element={
+62
View File
@@ -0,0 +1,62 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk } from "./client";
export type DashboardModel = {
id: string;
name: string;
thumb_url: string | null;
view_count: number;
print_count: number;
created_at: string;
};
export type DashboardAuthor = {
id: string;
name: string | null;
handle: string | null;
avatar_url: string | null;
model_count: number;
};
export type DashboardProvider = {
provider: string;
model_count: number;
};
export type DashboardSummary = {
collection_count: number;
model_count: number;
author_count: number;
category_count: number;
top_viewed: DashboardModel[];
top_printed: DashboardModel[];
top_authors: DashboardAuthor[];
top_providers: DashboardProvider[];
recently_added: DashboardModel[];
};
export const dashboardApi = {
getSummary: async (): Promise<DashboardSummary> => {
const res = await fetch(`${apiBase()}/dashboard/summary`, { headers: authHeaders() });
assertOk(res, "Failed to load dashboard");
return res.json();
},
getTopViewed: async (): Promise<DashboardModel[]> => {
const res = await fetch(`${apiBase()}/dashboard/top-viewed`, { headers: authHeaders() });
assertOk(res, "Failed to load models");
return res.json();
},
getTopPrinted: async (): Promise<DashboardModel[]> => {
const res = await fetch(`${apiBase()}/dashboard/top-printed`, { headers: authHeaders() });
assertOk(res, "Failed to load models");
return res.json();
},
getTopAuthors: async (): Promise<DashboardAuthor[]> => {
const res = await fetch(`${apiBase()}/dashboard/top-authors`, { headers: authHeaders() });
assertOk(res, "Failed to load authors");
return res.json();
},
};
+5 -5
View File
@@ -70,19 +70,19 @@ function useRouteChrome() {
onBack = () => navigate(-1);
} else if (path.startsWith("/admin-settings")) {
title = t("adminSettings.pageTitle");
onBack = () => navigate(-1);
onBack = () => navigate("/");
} else if (path.startsWith("/admin-users")) {
title = t("adminSettings.users.heading");
onBack = () => navigate(-1);
onBack = () => navigate("/");
} else if (path.startsWith("/admin-logs")) {
title = t("adminSettings.logs.heading");
onBack = () => navigate(-1);
onBack = () => navigate("/");
} else if (path.startsWith("/admin-triggers")) {
title = t("adminSettings.triggers.heading");
onBack = () => navigate(-1);
onBack = () => navigate("/");
} else if (path.startsWith("/admin-connections")) {
title = t("adminSettings.connections.heading");
onBack = () => navigate(-1);
onBack = () => navigate("/");
}
return { title, onBack };
+38 -1
View File
@@ -252,7 +252,44 @@
"tooltip": "{{imported}} imported, {{alreadyInLibrary}} already in your library, {{failedCount}} failed"
},
"dashboard": {
"comingSoon": "Dashboard panels are coming soon."
"loadError": "Failed to load your dashboard.",
"seeMore": "See more",
"viewCount": "{{count}} views",
"printCount": "{{count}} prints",
"collectionCount": {
"label": "Collections"
},
"modelCount": {
"label": "Models"
},
"authorCount": {
"label": "Unique authors"
},
"categoryCount": {
"label": "Categories"
},
"topViewed": {
"title": "Top Viewed Models",
"empty": "Nothing viewed yet — models you open will show up here."
},
"topPrinted": {
"title": "Top Printed Models",
"empty": "Nothing printed yet — models you mark as printed will show up here."
},
"topAuthors": {
"title": "Top Authors",
"empty": "No authors yet — import a model to see your top authors here.",
"modelCount": "{{count}} models"
},
"topProviders": {
"title": "Top Providers",
"empty": "No models yet.",
"modelCount": "{{count}} models"
},
"recentlyAdded": {
"title": "Recently Added",
"empty": "Nothing added yet — uploaded and imported models will show up here."
}
},
"adminSettings": {
"pageTitle": "Admin Settings",
+38 -1
View File
@@ -252,7 +252,44 @@
"tooltip": "{{imported}} importuota, {{alreadyInLibrary}} jau bibliotekoje, {{failedCount}} nepavyko"
},
"dashboard": {
"comingSoon": "Valdymo skydo panelės netrukus atsiras."
"loadError": "Nepavyko įkelti valdymo skydo.",
"seeMore": "Rodyti daugiau",
"viewCount": "{{count}} peržiūrų",
"printCount": "{{count}} spausdinimų",
"collectionCount": {
"label": "Kolekcijos"
},
"modelCount": {
"label": "Modeliai"
},
"authorCount": {
"label": "Unikalūs autoriai"
},
"categoryCount": {
"label": "Kategorijos"
},
"topViewed": {
"title": "Dažniausiai žiūrimi modeliai",
"empty": "Dar nieko nežiūrėta — atidaryti modeliai atsiras čia."
},
"topPrinted": {
"title": "Dažniausiai spausdinami modeliai",
"empty": "Dar nieko neišspausdinta — pažymėti kaip išspausdinti modeliai atsiras čia."
},
"topAuthors": {
"title": "Populiariausi autoriai",
"empty": "Autorių dar nėra — importuokite modelį, kad pamatytumėte populiariausius autorius čia.",
"modelCount": "{{count}} modelių"
},
"topProviders": {
"title": "Populiariausi šaltiniai",
"empty": "Modelių dar nėra.",
"modelCount": "{{count}} modelių"
},
"recentlyAdded": {
"title": "Naujausiai pridėti",
"empty": "Dar nieko nepridėta — įkelti ir importuoti modeliai atsiras čia."
}
},
"adminSettings": {
"pageTitle": "Administravimo nustatymai",
@@ -0,0 +1,98 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Paper from "@mui/material/Paper";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemAvatar from "@mui/material/ListItemAvatar";
import ListItemText from "@mui/material/ListItemText";
import Avatar from "@mui/material/Avatar";
import StarIcon from "@mui/icons-material/Star";
import PersonIcon from "@mui/icons-material/Person";
import { dashboardApi, type DashboardAuthor } from "../../api/dashboard";
import SeeMoreDialog from "./SeeMoreDialog";
type Props = {
authors: DashboardAuthor[];
};
function AuthorRow({ author, rank, modelCountLabel, onClick }: {
author: DashboardAuthor;
rank: number;
modelCountLabel: (count: number) => string;
onClick: () => void;
}) {
return (
<ListItemButton onClick={onClick} sx={{ borderRadius: 1, px: 1 }}>
<Typography sx={{ width: 24, flexShrink: 0, color: "text.secondary", fontWeight: 600 }}>{rank}</Typography>
<ListItemAvatar sx={{ minWidth: 48 }}>
<Avatar src={author.avatar_url ?? undefined} sx={{ width: 40, height: 40 }}>
<PersonIcon fontSize="small" />
</Avatar>
</ListItemAvatar>
<ListItemText primary={author.name || author.handle || "—"} primaryTypographyProps={{ noWrap: true }} />
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, pl: 1 }}>
{modelCountLabel(author.model_count)}
</Typography>
</ListItemButton>
);
}
/** Top Authors -- authors this user has the most models from, ranked by model count. 5-row
* preview with a See more dialog. Every row navigates to the author's page. */
export default function AuthorListCard({ authors }: Props) {
const { t } = useTranslation("app");
const navigate = useNavigate();
const [dialogOpen, setDialogOpen] = React.useState(false);
const modelCountLabel = (count: number) => t("dashboard.topAuthors.modelCount", { count });
return (
<Paper variant="outlined" sx={{ p: 2.5, display: "flex", flexDirection: "column" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1, color: "primary.main" }}>
<StarIcon />
<Typography variant="h6" fontWeight={700} color="text.primary">{t("dashboard.topAuthors.title")}</Typography>
</Box>
{authors.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 2 }}>{t("dashboard.topAuthors.empty")}</Typography>
) : (
<List dense disablePadding>
{authors.map((author, idx) => (
<AuthorRow
key={author.id}
author={author}
rank={idx + 1}
modelCountLabel={modelCountLabel}
onClick={() => navigate(`/authors/${author.id}`)}
/>
))}
</List>
)}
<Button onClick={() => setDialogOpen(true)} sx={{ alignSelf: "flex-start", mt: 1 }}>
{t("dashboard.seeMore")}
</Button>
<SeeMoreDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
title={t("dashboard.topAuthors.title")}
emptyText={t("dashboard.topAuthors.empty")}
fetcher={dashboardApi.getTopAuthors}
getKey={(author) => author.id}
renderItem={(author, idx) => (
<AuthorRow
author={author}
rank={idx + 1}
modelCountLabel={modelCountLabel}
onClick={() => { setDialogOpen(false); navigate(`/authors/${author.id}`); }}
/>
)}
/>
</Paper>
);
}
@@ -0,0 +1,40 @@
import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
type Props = {
icon: React.ReactNode;
count: number;
label: string;
onClick?: () => void;
};
/** A single stat card in the dashboard's first column (Collections/Models/Authors/Categories).
* When `onClick` is given, the whole card is a link -- hover background + pointer cursor are
* the only affordance, no arrow icon, matching youtube-mp3-vault's dashboard count cards.
* Omitting `onClick` (Authors, for now) renders a plain static card. */
export default function CountCard({ icon, count, label, onClick }: Props) {
return (
<Paper
variant="outlined"
onClick={onClick}
sx={{
p: 3,
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "center",
gap: 1,
minHeight: 140,
...(onClick && {
cursor: "pointer",
transition: (theme) => theme.transitions.create("background-color", { duration: theme.transitions.duration.shortest }),
"&:hover": { bgcolor: "action.hover" },
}),
}}
>
<Typography component="div" sx={{ color: "primary.main", display: "flex" }}>{icon}</Typography>
<Typography variant="h3" fontWeight={700}>{count}</Typography>
<Typography variant="body2" color="text.secondary">{label}</Typography>
</Paper>
);
}
@@ -0,0 +1,102 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import Paper from "@mui/material/Paper";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemAvatar from "@mui/material/ListItemAvatar";
import ListItemText from "@mui/material/ListItemText";
import Avatar from "@mui/material/Avatar";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import { printsApi } from "../../api/prints";
import type { DashboardModel } from "../../api/dashboard";
import SeeMoreDialog from "./SeeMoreDialog";
type Props = {
icon: React.ReactNode;
title: string;
models: DashboardModel[];
valueOf: (model: DashboardModel) => number;
valueLabel: (count: number) => string;
emptyText: string;
seeMoreLabel: string;
fetchMore: () => Promise<DashboardModel[]>;
};
function ModelRow({ model, rank, valueOf, valueLabel, onClick }: {
model: DashboardModel;
rank: number;
valueOf: (model: DashboardModel) => number;
valueLabel: (count: number) => string;
onClick: () => void;
}) {
return (
<ListItemButton onClick={onClick} sx={{ borderRadius: 1, px: 1 }}>
<Typography sx={{ width: 24, flexShrink: 0, color: "text.secondary", fontWeight: 600 }}>{rank}</Typography>
<ListItemAvatar sx={{ minWidth: 48 }}>
<Avatar src={model.thumb_url ? printsApi.fileUrl(model.thumb_url) : undefined} variant="rounded" sx={{ width: 40, height: 40 }}>
<ViewInArIcon fontSize="small" />
</Avatar>
</ListItemAvatar>
<ListItemText primary={model.name} primaryTypographyProps={{ noWrap: true }} />
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, pl: 1 }}>
{valueLabel(valueOf(model))}
</Typography>
</ListItemButton>
);
}
/** Top Viewed / Top Printed models -- 3-row preview with a See more dialog (fetchMore) showing
* an expanded list. Every row navigates to the model's detail page. */
export default function ModelListCard({ icon, title, models, valueOf, valueLabel, emptyText, seeMoreLabel, fetchMore }: Props) {
const navigate = useNavigate();
const [dialogOpen, setDialogOpen] = React.useState(false);
return (
<Paper variant="outlined" sx={{ p: 2.5, display: "flex", flexDirection: "column" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1, color: "primary.main" }}>
{icon}
<Typography variant="h6" fontWeight={700} color="text.primary">{title}</Typography>
</Box>
{models.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 2 }}>{emptyText}</Typography>
) : (
<List dense disablePadding>
{models.map((model, idx) => (
<ModelRow
key={model.id}
model={model}
rank={idx + 1}
valueOf={valueOf}
valueLabel={valueLabel}
onClick={() => navigate(`/models/${model.id}`)}
/>
))}
</List>
)}
<Button onClick={() => setDialogOpen(true)} sx={{ alignSelf: "flex-start", mt: 1 }}>{seeMoreLabel}</Button>
<SeeMoreDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
title={title}
emptyText={emptyText}
fetcher={fetchMore}
getKey={(model) => model.id}
renderItem={(model, idx) => (
<ModelRow
model={model}
rank={idx + 1}
valueOf={valueOf}
valueLabel={valueLabel}
onClick={() => { setDialogOpen(false); navigate(`/models/${model.id}`); }}
/>
)}
/>
</Paper>
);
}
@@ -0,0 +1,59 @@
import { useTranslation } from "react-i18next";
import Paper from "@mui/material/Paper";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import PublicIcon from "@mui/icons-material/Public";
import type { DashboardProvider } from "../../api/dashboard";
type Props = {
providers: DashboardProvider[];
};
const PROVIDER_LABELS: Record<string, string> = {
makerworld: "MakerWorld",
thingiverse: "Thingiverse",
printstash: "PrintStash",
};
function providerLabel(provider: string): string {
return PROVIDER_LABELS[provider] ?? provider;
}
/** Top Providers -- how many of this user's models came from each source (MakerWorld,
* Thingiverse, or a direct PrintStash upload). A fixed, small set in practice, so unlike the
* other list cards this one has no "see more" dialog and its rows aren't clickable -- there's
* no per-provider filtered view to link to yet. */
export default function ProviderListCard({ providers }: Props) {
const { t } = useTranslation("app");
return (
<Paper variant="outlined" sx={{ p: 2.5, display: "flex", flexDirection: "column" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1, color: "primary.main" }}>
<PublicIcon />
<Typography variant="h6" fontWeight={700} color="text.primary">{t("dashboard.topProviders.title")}</Typography>
</Box>
{providers.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 2 }}>{t("dashboard.topProviders.empty")}</Typography>
) : (
<List dense disablePadding>
{providers.map((p) => (
<ListItem key={p.provider} sx={{ px: 1 }}>
<ListItemIcon sx={{ minWidth: 32 }}>
<PublicIcon fontSize="small" color="disabled" />
</ListItemIcon>
<ListItemText primary={providerLabel(p.provider)} />
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, pl: 1 }}>
{t("dashboard.topProviders.modelCount", { count: p.model_count })}
</Typography>
</ListItem>
))}
</List>
)}
</Paper>
);
}
@@ -0,0 +1,68 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Paper from "@mui/material/Paper";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemAvatar from "@mui/material/ListItemAvatar";
import ListItemText from "@mui/material/ListItemText";
import Avatar from "@mui/material/Avatar";
import NewReleasesIcon from "@mui/icons-material/NewReleases";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import { printsApi } from "../../api/prints";
import type { DashboardModel } from "../../api/dashboard";
type Props = {
models: DashboardModel[];
};
// Same relative-time formatting as NotificationBell.tsx (kept local rather than shared since
// it's a 6-line function) -- reuses its i18n keys since the phrasing isn't notification-specific.
function relativeTime(iso: string, t: (key: string, opts?: Record<string, unknown>) => string): string {
const diffMs = Date.now() - new Date(iso).getTime();
const minutes = Math.round(diffMs / 60_000);
if (minutes < 1) return t("notifications.justNow");
if (minutes < 60) return t("notifications.minutesAgo", { count: minutes });
const hours = Math.round(minutes / 60);
if (hours < 24) return t("notifications.hoursAgo", { count: hours });
const days = Math.round(hours / 24);
return t("notifications.daysAgo", { count: days });
}
/** Recently added models -- full-width, bottom of the dashboard. No "see more": this card only
* ever shows a fixed handful (see backend's dashboardService.ts), same as
* youtube-mp3-vault's RecentlyAddedCard. */
export default function RecentlyAddedCard({ models }: Props) {
const { t } = useTranslation("app");
const navigate = useNavigate();
return (
<Paper variant="outlined" sx={{ p: 2.5, display: "flex", flexDirection: "column" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1, color: "primary.main" }}>
<NewReleasesIcon />
<Typography variant="h6" fontWeight={700} color="text.primary">{t("dashboard.recentlyAdded.title")}</Typography>
</Box>
{models.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 2 }}>{t("dashboard.recentlyAdded.empty")}</Typography>
) : (
<List dense disablePadding>
{models.map((model) => (
<ListItemButton key={model.id} onClick={() => navigate(`/models/${model.id}`)} sx={{ borderRadius: 1, px: 1 }}>
<ListItemAvatar sx={{ minWidth: 48 }}>
<Avatar src={model.thumb_url ? printsApi.fileUrl(model.thumb_url) : undefined} variant="rounded" sx={{ width: 40, height: 40 }}>
<ViewInArIcon fontSize="small" />
</Avatar>
</ListItemAvatar>
<ListItemText primary={model.name} primaryTypographyProps={{ noWrap: true }} />
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, pl: 1 }}>
{relativeTime(model.created_at, t)}
</Typography>
</ListItemButton>
))}
</List>
)}
</Paper>
);
}
@@ -0,0 +1,68 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import Button from "@mui/material/Button";
import List from "@mui/material/List";
import Stack from "@mui/material/Stack";
import CircularProgress from "@mui/material/CircularProgress";
import Typography from "@mui/material/Typography";
import Alert from "@mui/material/Alert";
type Props<T> = {
open: boolean;
onClose: () => void;
title: string;
emptyText: string;
fetcher: () => Promise<T[]>;
getKey: (item: T) => string;
renderItem: (item: T, index: number) => React.ReactNode;
};
/** Shared "see more" dialog behind every list card's See more button -- fetches an expanded
* (but still capped) version of that card's list lazily, only once opened. Generic over the row
* type so ModelListCard and AuthorListCard can reuse it with their own row rendering. */
export default function SeeMoreDialog<T>({ open, onClose, title, emptyText, fetcher, getKey, renderItem }: Props<T>) {
const { t } = useTranslation("common");
const [items, setItems] = React.useState<T[] | null>(null);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
if (!open) return;
setItems(null);
setError(null);
fetcher()
.then(setItems)
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load"));
// Only re-fetch when the dialog opens, not on every render (fetcher isn't stable).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{title}</DialogTitle>
<DialogContent dividers sx={{ maxHeight: 480 }}>
{error ? (
<Alert severity="error">{error}</Alert>
) : items === null ? (
<Stack alignItems="center" sx={{ py: 3 }}>
<CircularProgress size={28} />
</Stack>
) : items.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 2 }}>{emptyText}</Typography>
) : (
<List dense disablePadding>
{items.map((item, index) => (
<React.Fragment key={getKey(item)}>{renderItem(item, index)}</React.Fragment>
))}
</List>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t("close")}</Button>
</DialogActions>
</Dialog>
);
}
+117 -9
View File
@@ -1,17 +1,125 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import SpaceDashboardIcon from "@mui/icons-material/SpaceDashboard";
import CircularProgress from "@mui/material/CircularProgress";
import Alert from "@mui/material/Alert";
import CollectionsIcon from "@mui/icons-material/Collections";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import PersonIcon from "@mui/icons-material/Person";
import FolderIcon from "@mui/icons-material/Folder";
import VisibilityIcon from "@mui/icons-material/Visibility";
import PrintIcon from "@mui/icons-material/Print";
import { UnauthorizedError } from "../../api/client";
import { dashboardApi, type DashboardSummary } from "../../api/dashboard";
import CountCard from "./CountCard";
import ModelListCard from "./ModelListCard";
import AuthorListCard from "./AuthorListCard";
import ProviderListCard from "./ProviderListCard";
import RecentlyAddedCard from "./RecentlyAddedCard";
// The new default landing page. Empty for now -- dashboard panels (recent prints, storage
// usage, import activity, etc.) come in a follow-up; this just gives the route somewhere to go.
export default function DashboardPage() {
type Props = {
onUnauthorized?: () => void;
};
export default function DashboardPage({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
const navigate = useNavigate();
const [summary, setSummary] = React.useState<DashboardSummary | null>(null);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let active = true;
void (async () => {
try {
const data = await dashboardApi.getSummary();
if (active) setSummary(data);
} catch (err) {
if (!active) return;
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(t("dashboard.loadError"));
}
})();
return () => { active = false; };
}, [onUnauthorized, t]);
if (error) {
return <Alert severity="error" sx={{ m: 3 }}>{error}</Alert>;
}
if (!summary) {
return (
<Stack alignItems="center" justifyContent="center" sx={{ py: 10 }}>
<CircularProgress />
</Stack>
);
}
return (
<Stack alignItems="center" justifyContent="center" spacing={1.5} sx={{ py: 10, color: "text.secondary" }}>
<SpaceDashboardIcon sx={{ fontSize: 40 }} />
<Typography variant="body2">{t("dashboard.comingSoon")}</Typography>
</Stack>
<Box sx={{ p: { xs: 0, md: 1 } }}>
<Box sx={{ display: "flex", flexDirection: { xs: "column", md: "row" }, gap: 2, alignItems: "flex-start" }}>
{/* Column 1: stat counts */}
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, width: { xs: "100%", md: 0 }, flex: { md: 1 } }}>
<CountCard
icon={<CollectionsIcon sx={{ fontSize: 32 }} />}
count={summary.collection_count}
label={t("dashboard.collectionCount.label")}
onClick={() => navigate("/models/collections")}
/>
<CountCard
icon={<ViewInArIcon sx={{ fontSize: 32 }} />}
count={summary.model_count}
label={t("dashboard.modelCount.label")}
onClick={() => navigate("/models")}
/>
<CountCard
icon={<PersonIcon sx={{ fontSize: 32 }} />}
count={summary.author_count}
label={t("dashboard.authorCount.label")}
/>
<CountCard
icon={<FolderIcon sx={{ fontSize: 32 }} />}
count={summary.category_count}
label={t("dashboard.categoryCount.label")}
onClick={() => navigate("/models")}
/>
</Box>
{/* Column 2: top viewed / top printed */}
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, width: { xs: "100%", md: 0 }, flex: { md: 1.4 } }}>
<ModelListCard
icon={<VisibilityIcon />}
title={t("dashboard.topViewed.title")}
models={summary.top_viewed}
valueOf={(m) => m.view_count}
valueLabel={(count) => t("dashboard.viewCount", { count })}
emptyText={t("dashboard.topViewed.empty")}
seeMoreLabel={t("dashboard.seeMore")}
fetchMore={dashboardApi.getTopViewed}
/>
<ModelListCard
icon={<PrintIcon />}
title={t("dashboard.topPrinted.title")}
models={summary.top_printed}
valueOf={(m) => m.print_count}
valueLabel={(count) => t("dashboard.printCount", { count })}
emptyText={t("dashboard.topPrinted.empty")}
seeMoreLabel={t("dashboard.seeMore")}
fetchMore={dashboardApi.getTopPrinted}
/>
</Box>
{/* Column 3: top authors / top providers */}
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, width: { xs: "100%", md: 0 }, flex: { md: 1.4 } }}>
<AuthorListCard authors={summary.top_authors} />
<ProviderListCard providers={summary.top_providers} />
</Box>
</Box>
<Box sx={{ mt: 2 }}>
<RecentlyAddedCard models={summary.recently_added} />
</Box>
</Box>
);
}