mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-22 14:42:29 -05:00
feat: dashboard implementation
This commit is contained in:
1 parent
75de79c4bc
commit
cf5c8a9584
15 files changed
+980
-17
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user