feat: add react skeleton loading bits

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-11 19:09:06 +02:00
1 parent b13d0bf597
commit b15adb087b
22 files changed
+401 -217

No files matched your search

@@ -44,6 +44,7 @@ import { bestScore, minScoreFor } from "./match";
import { matchSystemCollections } from "./systemCollections";
import { matchPages, SEARCH_PAGES, type PageId, type SearchPage } from "./pages";
import { addRecent, readRecents, RECENTS_SHOWN, type RecentEntry, type RecentKind } from "./recents";
import { ListRowSkeletons } from "../../ListSkeleton";
const MIN_QUERY_LENGTH = 2;
const DEBOUNCE_MS = 250;
@@ -726,9 +727,12 @@ export default function GlobalSearch({
</Box>
))}
{searching && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1.25, py: 1.5, color: "text.secondary" }}>
<CircularProgress size={12} color="inherit" />
<Typography variant="body2">{t("app:search.searching")}</Typography>
<Box
component="output"
sx={{ display: "block", py: 1 }}
aria-label={t("app:search.searching") ?? undefined}
>
<ListRowSkeletons count={3} lead="rounded" leadSize={20} />
</Box>
)}
{emptyText && (
+68
View File
@@ -0,0 +1,68 @@
import Box from "@mui/material/Box";
import Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import TableCell from "@mui/material/TableCell";
import TableRow from "@mui/material/TableRow";
// Text lines narrow and widen from row to row, so a placeholder list reads as names, not a block.
const lineWidth = (index: number, base = 70) => `${base - (index % 3) * 12}%`;
type RowProps = {
count: number;
/** What leads each row: a round avatar, a rounded thumbnail, or nothing. */
lead?: "circle" | "rounded" | null;
leadSize?: number;
/** A second, shorter line under the first. */
twoLines?: boolean;
/** A short value at the end, e.g. a count or a date. */
trailing?: boolean;
};
/** Rows of a list while it loads: an avatar or thumbnail, a line or two, maybe a value at the end. */
export function ListRowSkeletons({ count, lead = "rounded", leadSize = 40, twoLines, trailing }: RowProps) {
return (
<Stack spacing={1.25} aria-hidden>
{Array.from({ length: count }, (_, index) => (
<Stack key={index} direction="row" alignItems="center" spacing={1.5} sx={{ px: 1 }}>
{lead && (
<Skeleton
variant={lead === "circle" ? "circular" : "rounded"}
width={leadSize}
height={leadSize}
sx={{ flexShrink: 0 }}
/>
)}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Skeleton variant="text" sx={{ width: lineWidth(index) }} />
{twoLines && <Skeleton variant="text" sx={{ fontSize: "0.75rem", width: lineWidth(index + 1, 45) }} />}
</Box>
{trailing && <Skeleton variant="text" sx={{ width: 56, flexShrink: 0 }} />}
</Stack>
))}
</Stack>
);
}
/** Placeholder table rows under a table's real header, so the columns don't jump when data lands. */
export function TableRowSkeletons({ rows, columns }: { rows: number; columns: number }) {
return Array.from({ length: rows }, (_, row) => (
<TableRow key={row} aria-hidden>
{Array.from({ length: columns }, (_cell, column) => (
<TableCell key={column}>
<Skeleton variant="text" sx={{ width: lineWidth(row + column, 80) }} />
</TableCell>
))}
</TableRow>
));
}
/** Pill-shaped placeholders for a wrap of chips, such as the tag list. */
export function ChipSkeletons({ count }: { count: number }) {
return (
<Stack direction="row" flexWrap="wrap" useFlexGap spacing={1} aria-hidden>
{Array.from({ length: count }, (_, index) => (
<Skeleton key={index} variant="rounded" height={24} width={56 + ((index * 37) % 70)} sx={{ borderRadius: 4 }} />
))}
</Stack>
);
}
@@ -0,0 +1,49 @@
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import type { Theme } from "@mui/material/styles";
const cardBackground = (theme: Theme) =>
theme.palette.mode === "dark" ? theme.thingport.pageBackground : theme.palette.grey[100];
// Collection cards have two cards peeking out below, this deep, so a placeholder takes the same room.
const COLLECTION_STACK_PX = 12;
type Props = {
/** "model" has the author and counts row; "collection" a name and item count, over a stack. */
variant?: "model" | "collection";
};
/** A model or collection card's shape while it loads: the preview, then its text lines. */
export function CardSkeleton({ variant = "model" }: Props) {
return (
<Box sx={{ pb: variant === "collection" ? `${COLLECTION_STACK_PX}px` : 0 }} aria-hidden>
<Paper
variant="outlined"
sx={{ overflow: "hidden", borderRadius: "12px", borderColor: "transparent", bgcolor: cardBackground }}
>
<Skeleton variant="rectangular" sx={{ width: "100%", height: "auto", aspectRatio: "4 / 3" }} />
<Box sx={{ px: 1.5, pt: 0.75, pb: 1.5 }}>
<Skeleton variant="text" sx={{ fontSize: "0.875rem", width: "75%" }} />
{variant === "model" ? (
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 0.75 }}>
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ flex: 1, minWidth: 0 }}>
<Skeleton variant="circular" width={20} height={20} />
<Skeleton variant="text" sx={{ fontSize: "0.75rem", width: "45%" }} />
</Stack>
<Skeleton variant="text" sx={{ fontSize: "0.75rem", width: 48 }} />
</Stack>
) : (
<Skeleton variant="text" sx={{ fontSize: "0.75rem", width: "35%", mt: 0.5 }} />
)}
</Box>
</Paper>
</Box>
);
}
/** Placeholder cards to fill a grid, e.g. while the first page loads or the next one is fetched. */
export function CardSkeletons({ count, variant }: Props & { count: number }) {
return Array.from({ length: count }, (_, index) => <CardSkeleton key={index} variant={variant} />);
}
+19
View File
@@ -0,0 +1,19 @@
import type { SxProps, Theme } from "@mui/material/styles";
// Where the card grids drop a column, widest first.
const BREAKPOINTS_PX = [1979, 1684, 1404, 1124, 860];
/** The model and collection card grids: `maxColumns` across on the widest screens, one fewer at each step down. */
export function cardGridSx(maxColumns: 5 | 6): SxProps<Theme> {
const sx: Record<string, unknown> = {
display: "grid",
gridTemplateColumns: `repeat(${maxColumns}, 1fr)`,
columnGap: "20px",
rowGap: "20px",
};
// The first step keeps the full count; each one after drops a column.
BREAKPOINTS_PX.forEach((width, step) => {
sx[`@media (max-width: ${width}px)`] = { gridTemplateColumns: `repeat(${Math.max(1, maxColumns - step)}, 1fr)` };
});
return sx as SxProps<Theme>;
}
+1 -2
View File
@@ -1606,8 +1606,7 @@
}
},
"grid": {
"empty": "No models yet.",
"loadingMore": "Loading…"
"empty": "No models yet."
},
"sort": {
"newest": "Newest",
+1 -2
View File
@@ -1633,8 +1633,7 @@
}
},
"grid": {
"empty": "Kol kas nėra modelių.",
"loadingMore": "Įkeliama…"
"empty": "Kol kas nėra modelių."
},
"sort": {
"newest": "Naujausi",
+21 -26
View File
@@ -7,7 +7,6 @@ import Typography from "@mui/material/Typography";
import Avatar from "@mui/material/Avatar";
import Button from "@mui/material/Button";
import Chip from "@mui/material/Chip";
import CircularProgress from "@mui/material/CircularProgress";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import PersonIcon from "@mui/icons-material/Person";
import { UnauthorizedError } from "../../api/client";
@@ -25,6 +24,9 @@ import { importProviderInfo, printProviderInfo } from "../../constants/importPro
import { authorProfileUrl } from "../../utils/authorProfileUrl";
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
import ModelCard from "../ModelsPage/ModelCard";
import Skeleton from "@mui/material/Skeleton";
import { CardSkeletons } from "../../components/media/CardSkeleton";
import { cardGridSx } from "../../components/media/cardGrid";
const PAGE_SIZE = 24;
const COVER_HEIGHT = 184;
@@ -176,9 +178,21 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized, viewer,
if (loading) {
return (
<Stack alignItems="center" sx={{ py: 8 }}>
<CircularProgress size={22} />
</Stack>
<Box
sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "320px 1fr" }, gap: "32px", pt: 3, pb: 3 }}
aria-hidden
>
<Stack spacing={1.5} alignItems={{ xs: "center", md: "flex-start" }}>
<Skeleton variant="circular" width={112} height={112} />
<Skeleton variant="text" sx={{ fontSize: "1.5rem", width: "60%" }} />
<Skeleton variant="text" sx={{ width: "40%" }} />
<Skeleton variant="text" sx={{ width: "90%" }} />
<Skeleton variant="text" sx={{ width: "80%" }} />
</Stack>
<Box sx={cardGridSx(5)}>
<CardSkeletons count={10} />
</Box>
</Box>
);
}
@@ -318,18 +332,7 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized, viewer,
</Typography>
{items.length ? (
<Stack spacing={2}>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(5, 1fr)",
columnGap: "20px",
rowGap: "20px",
"@media (max-width: 1684px)": { gridTemplateColumns: "repeat(4, 1fr)" },
"@media (max-width: 1404px)": { gridTemplateColumns: "repeat(3, 1fr)" },
"@media (max-width: 1124px)": { gridTemplateColumns: "repeat(2, 1fr)" },
"@media (max-width: 860px)": { gridTemplateColumns: "repeat(1, 1fr)" },
}}
>
<Box sx={cardGridSx(5)}>
{items.map((item) => (
<ModelCard
key={item.id}
@@ -345,17 +348,9 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized, viewer,
viewer={viewer}
/>
))}
{loadingMore && <CardSkeletons count={5} />}
</Box>
{hasMore && (
<Stack ref={loadMoreSentinelRef} direction="row" justifyContent="center" sx={{ py: 1 }}>
{loadingMore && (
<Stack direction="row" alignItems="center" spacing={1} sx={{ color: "text.secondary" }}>
<CircularProgress size={14} />
<Typography variant="caption">{t("models:grid.loadingMore")}</Typography>
</Stack>
)}
</Stack>
)}
{hasMore && <Box ref={loadMoreSentinelRef} sx={{ height: 1 }} />}
</Stack>
) : (
<Typography variant="body2" color="text.secondary">
@@ -4,7 +4,6 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import CircularProgress from "@mui/material/CircularProgress";
import { UnauthorizedError } from "../../api/client";
import { type Collection, collectionsApi } from "../../api/collections";
import { type Print, type PrintSortMode, printsApi } from "../../api/prints";
@@ -20,6 +19,8 @@ import CollectionActionsMenu from "./CollectionActionsMenu";
import CollectionBookmarkButton from "./CollectionBookmarkButton";
import CollectionSyncDialog from "./CollectionSyncDialog";
import CollectionSyncIndicator from "../../components/CollectionSyncIndicator";
import { CardSkeletons } from "../../components/media/CardSkeleton";
import { cardGridSx } from "../../components/media/cardGrid";
const PAGE_SIZE = 24;
@@ -155,9 +156,11 @@ export default function CollectionDetailPage({
if (loading) {
return (
<Stack alignItems="center" sx={{ py: 8 }}>
<CircularProgress size={22} />
</Stack>
<Box sx={{ maxWidth: "1920px", mx: "auto", pt: 7 }}>
<Box sx={cardGridSx(6)}>
<CardSkeletons count={12} />
</Box>
</Box>
);
}
@@ -192,19 +195,7 @@ export default function CollectionDetailPage({
)}
{items.length ? (
<Stack spacing={2}>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(6, 1fr)",
columnGap: "20px",
rowGap: "20px",
"@media (max-width: 1979px)": { gridTemplateColumns: "repeat(6, 1fr)" },
"@media (max-width: 1684px)": { gridTemplateColumns: "repeat(5, 1fr)" },
"@media (max-width: 1404px)": { gridTemplateColumns: "repeat(4, 1fr)" },
"@media (max-width: 1124px)": { gridTemplateColumns: "repeat(3, 1fr)" },
"@media (max-width: 860px)": { gridTemplateColumns: "repeat(2, 1fr)" },
}}
>
<Box sx={cardGridSx(6)}>
{items.map((item) => (
<ModelCard
key={item.id}
@@ -227,17 +218,9 @@ export default function CollectionDetailPage({
viewer={viewer}
/>
))}
{loadingMore && <CardSkeletons count={6} />}
</Box>
{hasMore && (
<Stack ref={loadMoreSentinelRef} direction="row" justifyContent="center" sx={{ py: 1 }}>
{loadingMore && (
<Stack direction="row" alignItems="center" spacing={1} sx={{ color: "text.secondary" }}>
<CircularProgress size={14} />
<Typography variant="caption">{t("models:grid.loadingMore")}</Typography>
</Stack>
)}
</Stack>
)}
{hasMore && <Box ref={loadMoreSentinelRef} sx={{ height: 1 }} />}
</Stack>
) : (
<Stack alignItems="center" spacing={1} sx={{ py: 8, color: "text.secondary" }}>
+6 -17
View File
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import CircularProgress from "@mui/material/CircularProgress";
import { UnauthorizedError } from "../../api/client";
import { type Collection, type CollectionInput, collectionsApi } from "../../api/collections";
import { type PreviewMode } from "../../api/settings";
@@ -12,6 +11,8 @@ import { usePageHeader } from "../../components/Layout/PageHeaderContext";
import CollectionCard from "./CollectionCard";
import CollectionFormModal from "./CollectionFormModal";
import CollectionsActionsMenu from "./CollectionsActionsMenu";
import { CardSkeletons } from "../../components/media/CardSkeleton";
import { cardGridSx } from "../../components/media/cardGrid";
type Props = {
onUnauthorized?: () => void;
@@ -72,23 +73,11 @@ export default function CollectionsPage({ onUnauthorized, onBookmarksChanged, th
return (
<Stack spacing={2}>
{loading ? (
<Stack alignItems="center" sx={{ py: 8 }}>
<CircularProgress size={22} />
</Stack>
<Box sx={cardGridSx(6)}>
<CardSkeletons count={12} variant="collection" />
</Box>
) : collections.length ? (
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(6, 1fr)",
columnGap: "20px",
rowGap: "20px",
"@media (max-width: 1979px)": { gridTemplateColumns: "repeat(6, 1fr)" },
"@media (max-width: 1684px)": { gridTemplateColumns: "repeat(5, 1fr)" },
"@media (max-width: 1404px)": { gridTemplateColumns: "repeat(4, 1fr)" },
"@media (max-width: 1124px)": { gridTemplateColumns: "repeat(3, 1fr)" },
"@media (max-width: 860px)": { gridTemplateColumns: "repeat(2, 1fr)" },
}}
>
<Box sx={cardGridSx(6)}>
{collections.map((collection) => (
<CollectionCard
key={collection.id}
@@ -0,0 +1,69 @@
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import type { Theme } from "@mui/material/styles";
/** How a widget's placeholder looks: a count box, or a titled list of `rows`. */
export type DashboardSkeleton =
| { kind: "count" }
| {
kind: "list";
rows: number;
/** Rows lead with an avatar or thumbnail, as model and author lists do. */
avatar?: boolean;
/** Ends with a "See more" link. */
footer?: boolean;
};
const cardBorder = (theme: Theme) => (theme.palette.mode === "dark" ? "transparent" : "divider");
/** A dashboard card's shape while the summary loads, matching CountCard and the list cards. */
export default function DashboardCardSkeleton({ skeleton }: { skeleton: DashboardSkeleton }) {
if (skeleton.kind === "count") {
return (
<Paper
variant="outlined"
aria-hidden
sx={{
p: 3,
minHeight: 140,
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: 1,
borderColor: cardBorder,
}}
>
<Skeleton variant="rounded" width={32} height={32} />
<Skeleton variant="text" sx={{ fontSize: "3rem", width: "40%" }} />
<Skeleton variant="text" sx={{ width: "55%" }} />
</Paper>
);
}
const { rows, avatar = true, footer = false } = skeleton;
return (
<Paper variant="outlined" aria-hidden sx={{ p: 2.5, borderColor: cardBorder }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
<Skeleton variant="circular" width={24} height={24} />
<Skeleton variant="text" sx={{ fontSize: "1.25rem", width: "45%" }} />
</Stack>
<Stack spacing={1.25}>
{Array.from({ length: rows }, (_, index) => (
<Stack key={index} direction="row" alignItems="center" spacing={1.5} sx={{ px: 1 }}>
{avatar ? (
<Skeleton variant="rounded" width={40} height={40} sx={{ flexShrink: 0 }} />
) : (
<Skeleton variant="rounded" width={88} height={22} sx={{ flexShrink: 0 }} />
)}
{/* Varied widths read as real names rather than a block. */}
<Skeleton variant="text" sx={{ flex: 1, maxWidth: `${70 - (index % 3) * 12}%` }} />
<Box sx={{ flex: 1 }} />
<Skeleton variant="text" sx={{ width: 56, flexShrink: 0 }} />
</Stack>
))}
</Stack>
{footer && <Skeleton variant="text" sx={{ width: 72, mt: 1.5, ml: 1 }} />}
</Paper>
);
}
@@ -1,15 +1,15 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
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";
import { ListRowSkeletons } from "../../components/ListSkeleton";
type Props<T> = {
open: boolean;
@@ -44,9 +44,9 @@ export default function SeeMoreDialog<T>({ open, onClose, title, emptyText, fetc
{error ? (
<Alert severity="error">{error}</Alert>
) : items === null ? (
<Stack alignItems="center" sx={{ py: 3 }}>
<CircularProgress size={28} />
</Stack>
<Box sx={{ py: 1 }}>
<ListRowSkeletons count={6} trailing />
</Box>
) : items.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 2 }}>
{emptyText}
+11 -3
View File
@@ -18,6 +18,7 @@ import { useDashboardLayout } from "../../hooks/useDashboardLayout";
import DashboardCustomizeDialog from "./DashboardCustomizeDialog";
import { DASHBOARD_WIDGETS, isWidgetEnabled, type DashboardWidget } from "./widgets";
import { PUZZLE_COLUMNS, layoutDefinition, panelColumns, puzzlePieces } from "./layouts";
import DashboardCardSkeleton from "./DashboardCardSkeleton";
// Beside the panels, the count column is narrower than each panel column.
const COUNTS_FLEX = 1;
@@ -70,7 +71,7 @@ export default function DashboardPage({ onUnauthorized }: Props) {
{error}
</Alert>
);
} else if (!summary || !choices || !layout) {
} else if (!choices || !layout) {
content = (
<Stack alignItems="center" justifyContent="center" sx={{ py: 10 }}>
<CircularProgress />
@@ -87,8 +88,15 @@ export default function DashboardPage({ onUnauthorized }: Props) {
);
const sideways = definition.counts === "left" || definition.counts === "right";
const countsFirst = definition.counts === "left" || definition.counts === "top";
// Until the summary arrives, widgets that need it show their placeholder; the rest render already.
const widgetContent = (widget: DashboardWidget) =>
!summary && widget.skeleton ? (
<DashboardCardSkeleton skeleton={widget.skeleton} />
) : (
widget.render({ summary: summary as DashboardSummary, t, navigate, onUnauthorized })
);
const renderWidget = (widget: DashboardWidget) => (
<React.Fragment key={widget.id}>{widget.render({ summary, t, navigate, onUnauthorized })}</React.Fragment>
<React.Fragment key={widget.id}>{widgetContent(widget)}</React.Fragment>
);
const countsBlock = counts.length ? (
@@ -153,7 +161,7 @@ export default function DashboardPage({ onUnauthorized }: Props) {
"& > *": { flex: 1 },
}}
>
{item.render({ summary, t, navigate, onUnauthorized })}
{widgetContent(item)}
</Box>
))}
</Box>
@@ -17,6 +17,7 @@ import RecentlyAddedCard from "./RecentlyAddedCard";
import ClockCard from "./ClockCard";
import WeatherCard from "./WeatherCard";
import FeaturedModelCard from "./FeaturedModelCard";
import type { DashboardSkeleton } from "./DashboardCardSkeleton";
type WidgetContext = {
summary: DashboardSummary;
@@ -33,6 +34,9 @@ export type DashboardWidget = {
column: 0 | 1 | 2;
/** Shown until the user switches it off. An optional widget can start hidden instead. */
defaultEnabled: boolean;
/** Its placeholder while the summary loads. A widget without one fetches its own data, renders
* straight away, and mustn't read `summary`. */
skeleton?: DashboardSkeleton;
render: (ctx: WidgetContext) => React.ReactNode;
};
@@ -43,6 +47,7 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
labelKey: "dashboard.collectionCount.label",
column: 0,
defaultEnabled: true,
skeleton: { kind: "count" },
render: ({ summary, t, navigate }) => (
<CountCard
icon={<CollectionsIcon sx={{ fontSize: 32 }} />}
@@ -57,6 +62,7 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
labelKey: "dashboard.modelCount.label",
column: 0,
defaultEnabled: true,
skeleton: { kind: "count" },
render: ({ summary, t, navigate }) => (
<CountCard
icon={<ViewInArIcon sx={{ fontSize: 32 }} />}
@@ -71,6 +77,7 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
labelKey: "dashboard.authorCount.label",
column: 0,
defaultEnabled: true,
skeleton: { kind: "count" },
render: ({ summary, t }) => (
<CountCard
icon={<PersonIcon sx={{ fontSize: 32 }} />}
@@ -84,6 +91,7 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
labelKey: "dashboard.categoryCount.label",
column: 0,
defaultEnabled: true,
skeleton: { kind: "count" },
render: ({ summary, t, navigate }) => (
<CountCard
icon={<FolderIcon sx={{ fontSize: 32 }} />}
@@ -105,6 +113,7 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
labelKey: "dashboard.topViewed.title",
column: 1,
defaultEnabled: true,
skeleton: { kind: "list", rows: 3, footer: true },
render: ({ summary, t }) => (
<ModelListCard
icon={<VisibilityIcon />}
@@ -123,6 +132,7 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
labelKey: "dashboard.topPrinted.title",
column: 1,
defaultEnabled: true,
skeleton: { kind: "list", rows: 3, footer: true },
render: ({ summary, t }) => (
<ModelListCard
icon={<PrintIcon />}
@@ -141,6 +151,7 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
labelKey: "dashboard.recentlyAdded.title",
column: 1,
defaultEnabled: true,
skeleton: { kind: "list", rows: 3 },
render: ({ summary }) => <RecentlyAddedCard models={summary.recently_added} />,
},
// The clock, weather and featured model are optional extras, off until the user switches them on.
@@ -164,6 +175,7 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
labelKey: "dashboard.topAuthors.title",
column: 2,
defaultEnabled: true,
skeleton: { kind: "list", rows: 5, footer: true },
render: ({ summary }) => <AuthorListCard authors={summary.top_authors} />,
},
{
@@ -171,6 +183,7 @@ export const DASHBOARD_WIDGETS: DashboardWidget[] = [
labelKey: "dashboard.topProviders.title",
column: 2,
defaultEnabled: true,
skeleton: { kind: "list", rows: 4, avatar: false },
render: ({ summary }) => <ProviderListCard providers={summary.top_providers} />,
},
];
+27 -27
View File
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import Paper from "@mui/material/Paper";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import Alert from "@mui/material/Alert";
import Chip from "@mui/material/Chip";
import FormControl from "@mui/material/FormControl";
@@ -20,6 +19,7 @@ import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import { UnauthorizedError } from "../../api/client";
import { adminApi, type AdminUser, type LogAction, type LogEntry } from "../../api/admin";
import { TableRowSkeletons } from "../../components/ListSkeleton";
type Props = {
onUnauthorized?: () => void;
@@ -187,11 +187,7 @@ export default function LogsPage({ onUnauthorized }: Props) {
</Alert>
)}
{loading ? (
<Stack alignItems="center" sx={{ py: 2 }}>
<CircularProgress size={20} />
</Stack>
) : logs.length === 0 ? (
{!loading && logs.length === 0 ? (
<Paper variant="outlined" sx={{ p: 3 }}>
<Typography variant="body2" color="text.secondary">
{t("adminSettings.logs.empty")}
@@ -210,27 +206,31 @@ export default function LogsPage({ onUnauthorized }: Props) {
</TableRow>
</TableHead>
<TableBody>
{logs.map((log) => (
<TableRow key={log.id} hover>
<TableCell>{new Date(log.created_at).toLocaleString(i18n.language)}</TableCell>
<TableCell>
<Box>
<Typography variant="body2">{log.user_display_name}</Typography>
<Typography variant="caption" color="text.secondary">
{log.user_email}
</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={t(`adminSettings.logs.action.${log.action}`)}
size="small"
color={ACTION_COLORS[log.action] ?? "default"}
/>
</TableCell>
<TableCell>{formatDetails(log)}</TableCell>
</TableRow>
))}
{loading ? (
<TableRowSkeletons rows={8} columns={4} />
) : (
logs.map((log) => (
<TableRow key={log.id} hover>
<TableCell>{new Date(log.created_at).toLocaleString(i18n.language)}</TableCell>
<TableCell>
<Box>
<Typography variant="body2">{log.user_display_name}</Typography>
<Typography variant="caption" color="text.secondary">
{log.user_email}
</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={t(`adminSettings.logs.action.${log.action}`)}
size="small"
color={ACTION_COLORS[log.action] ?? "default"}
/>
</TableCell>
<TableCell>{formatDetails(log)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
+34 -4
View File
@@ -9,7 +9,6 @@ import Button from "@mui/material/Button";
import ButtonBase from "@mui/material/ButtonBase";
import IconButton from "@mui/material/IconButton";
import Chip from "@mui/material/Chip";
import CircularProgress from "@mui/material/CircularProgress";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
@@ -29,6 +28,7 @@ import ModelActionsMenu from "./ModelActionsMenu";
import FavoriteButton from "./FavoriteButton";
import ModelSidePanel from "./ModelSidePanel";
import MarkdownDescription from "../../components/MarkdownDescription";
import Skeleton from "@mui/material/Skeleton";
type Props = {
theme: ResolvedTheme;
@@ -97,9 +97,39 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
if (loading) {
return (
<Stack alignItems="center" sx={{ py: 8 }}>
<CircularProgress size={22} />
</Stack>
<Box sx={{ maxWidth: "1390px", mx: "auto" }} aria-hidden>
<Box
sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr" }, gap: "24px", alignItems: "start" }}
>
<Box sx={{ minWidth: 0 }}>
<Skeleton
variant="rounded"
sx={{ width: "100%", height: "auto", aspectRatio: "16 / 10", borderRadius: "12px" }}
/>
<Stack direction="row" spacing={1} sx={{ mt: 1.5 }}>
{[0, 1, 2, 3].map((index) => (
<Skeleton key={index} variant="rounded" width={84} height={63} />
))}
</Stack>
</Box>
<Stack spacing={1.5}>
<Skeleton variant="text" sx={{ fontSize: "2rem", width: "85%" }} />
<Stack direction="row" alignItems="center" spacing={1}>
<Skeleton variant="circular" width={28} height={28} />
<Skeleton variant="text" sx={{ width: "40%" }} />
</Stack>
<Stack direction="row" spacing={1}>
{[64, 80, 52].map((width) => (
<Skeleton key={width} variant="rounded" width={width} height={24} sx={{ borderRadius: 4 }} />
))}
</Stack>
<Skeleton variant="rounded" height={40} />
{[90, 75, 82, 60].map((width) => (
<Skeleton key={width} variant="text" sx={{ width: `${width}%` }} />
))}
</Stack>
</Box>
</Box>
);
}
@@ -23,6 +23,7 @@ import { UnauthorizedError } from "../../api/client";
import { aiCategorizationApi, type AiRun, type AiRunCounts, type AiRunScope } from "../../api/aiCategorization";
import type { Print } from "../../api/prints";
import { useToast } from "../../components/ToastProvider";
import { ListRowSkeletons } from "../../components/ListSkeleton";
type Props = {
onUnauthorized?: () => void;
@@ -276,7 +277,7 @@ export default function AiCategorizationPanel({ onUnauthorized, onPrintUpdated,
</Typography>
<Box>
{suggestionsLoading && !items.length ? (
<CircularProgress size={22} />
<ListRowSkeletons count={3} lead="rounded" leadSize={24} twoLines trailing />
) : items.length ? (
<>
<Stack direction="row" spacing={1} sx={{ mb: 1 }}>
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import type { Theme } from "@mui/material/styles";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
@@ -10,13 +11,13 @@ import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemText from "@mui/material/ListItemText";
import Collapse from "@mui/material/Collapse";
import CircularProgress from "@mui/material/CircularProgress";
import SettingsIcon from "@mui/icons-material/Settings";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import type { Category, CategoryKind } from "../../api/categories";
import { translateCategoryDisplay } from "../../utils/translateCategoryDisplay";
import { dividerBorderColor } from "../../theme";
import { ancestorPath, buildCategoryTree } from "../../utils/categoryTree";
import { ListRowSkeletons } from "../../components/ListSkeleton";
type Props = {
/** Which box this is; `categories` holds only that kind. */
@@ -146,9 +147,9 @@ export default function CategoriesPanel({ kind, categories, loading, selectedId,
</ListItemButton>
{loading && (
<Stack alignItems="center" sx={{ py: 2 }}>
<CircularProgress size={18} />
</Stack>
<Box sx={{ pt: 1 }}>
<ListRowSkeletons count={7} lead={null} />
</Box>
)}
{!loading && tree.roots.map((root) => renderCategory(root, 0))}
+8 -27
View File
@@ -4,7 +4,6 @@ import { useSearchParams } from "react-router-dom";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import CircularProgress from "@mui/material/CircularProgress";
import { UnauthorizedError } from "../../api/client";
import { type Print, type PrintSortMode, printsApi } from "../../api/prints";
import { type Category, type CategoryKind, type CategoryMetaInput, categoriesApi } from "../../api/categories";
@@ -21,6 +20,8 @@ import CategoryBanner from "./CategoryBanner";
import ModelCard from "./ModelCard";
import SortTabs from "./SortTabs";
import ModelsManagerDialog, { type ModelsManagerSection } from "./ModelsManagerDialog";
import { CardSkeletons } from "../../components/media/CardSkeleton";
import { cardGridSx } from "../../components/media/cardGrid";
const PAGE_SIZE = 24;
@@ -295,24 +296,12 @@ export default function ModelsPage({
</Box>
)}
{loading ? (
<Stack alignItems="center" sx={{ py: 8 }}>
<CircularProgress size={22} />
</Stack>
<Box sx={cardGridSx(5)}>
<CardSkeletons count={10} />
</Box>
) : items.length ? (
<Stack spacing={2}>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(5, 1fr)",
columnGap: "20px",
rowGap: "20px",
"@media (max-width: 1979px)": { gridTemplateColumns: "repeat(5, 1fr)" },
"@media (max-width: 1684px)": { gridTemplateColumns: "repeat(4, 1fr)" },
"@media (max-width: 1404px)": { gridTemplateColumns: "repeat(3, 1fr)" },
"@media (max-width: 1124px)": { gridTemplateColumns: "repeat(2, 1fr)" },
"@media (max-width: 860px)": { gridTemplateColumns: "repeat(1, 1fr)" },
}}
>
<Box sx={cardGridSx(5)}>
{items.map((item) => (
<ModelCard
key={item.id}
@@ -328,17 +317,9 @@ export default function ModelsPage({
viewer={viewer}
/>
))}
{loadingMore && <CardSkeletons count={5} />}
</Box>
{hasMore && (
<Stack ref={loadMoreSentinelRef} direction="row" justifyContent="center" sx={{ py: 1 }}>
{loadingMore && (
<Stack direction="row" alignItems="center" spacing={1} sx={{ color: "text.secondary" }}>
<CircularProgress size={14} />
<Typography variant="caption">{t("models:grid.loadingMore")}</Typography>
</Stack>
)}
</Stack>
)}
{hasMore && <Box ref={loadMoreSentinelRef} sx={{ height: 1 }} />}
</Stack>
) : (
<Stack alignItems="center" spacing={1} sx={{ py: 8, color: "text.secondary" }}>
@@ -5,7 +5,6 @@ import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Chip from "@mui/material/Chip";
import CircularProgress from "@mui/material/CircularProgress";
import Divider from "@mui/material/Divider";
import Link from "@mui/material/Link";
import Stack from "@mui/material/Stack";
@@ -21,6 +20,7 @@ import { UnauthorizedError } from "../../../api/client";
import { activityApi, type ActivityItem, type ActivityKind, type ActivityMonth } from "../../../api/activity";
import { printsApi } from "../../../api/prints";
import { useInfiniteScroll } from "../../../hooks/useInfiniteScroll";
import { ListRowSkeletons } from "../../../components/ListSkeleton";
const KIND_ICONS: Record<ActivityKind, React.ReactNode> = {
import: <CloudDownloadOutlinedIcon fontSize="small" />,
@@ -130,9 +130,10 @@ export default function Timeline({ startMonth, fromDay, onClearDay, onUnauthoriz
</Alert>
)}
{loading && (
<Stack alignItems="center" sx={{ py: 2 }}>
<CircularProgress size={20} />
</Stack>
// A first load fills the space a month takes; a later one only hints at the next.
<Box sx={{ py: 1 }}>
<ListRowSkeletons count={months.length ? 2 : 4} lead="circle" leadSize={32} twoLines />
</Box>
)}
{next === null && months.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", textAlign: "center", py: 2 }}>
+10 -27
View File
@@ -4,7 +4,6 @@ import { useParams, useSearchParams } from "react-router-dom";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import CircularProgress from "@mui/material/CircularProgress";
import { UnauthorizedError } from "../../api/client";
import { type Print, type PrintSortMode, printsApi } from "../../api/prints";
import { type Collection, collectionsApi } from "../../api/collections";
@@ -19,6 +18,8 @@ import SortTabs from "../ModelsPage/SortTabs";
import CollectionCard from "../CollectionsPage/CollectionCard";
import TagBookmarkButton from "./TagBookmarkButton";
import TagActionsMenu from "./TagActionsMenu";
import { CardSkeletons } from "../../components/media/CardSkeleton";
import { cardGridSx } from "../../components/media/cardGrid";
const PAGE_SIZE = 24;
@@ -163,9 +164,11 @@ export default function TagDetailPage({ theme, previewMode, onUnauthorized, onBo
if (loading) {
return (
<Stack alignItems="center" sx={{ py: 8 }}>
<CircularProgress size={22} />
</Stack>
<Box sx={{ maxWidth: "1920px", mx: "auto", pt: 7 }}>
<Box sx={cardGridSx(6)}>
<CardSkeletons count={12} />
</Box>
</Box>
);
}
@@ -176,19 +179,7 @@ export default function TagDetailPage({ theme, previewMode, onUnauthorized, onBo
</Box>
{collections.length || items.length ? (
<Stack spacing={2}>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(6, 1fr)",
columnGap: "20px",
rowGap: "20px",
"@media (max-width: 1979px)": { gridTemplateColumns: "repeat(6, 1fr)" },
"@media (max-width: 1684px)": { gridTemplateColumns: "repeat(5, 1fr)" },
"@media (max-width: 1404px)": { gridTemplateColumns: "repeat(4, 1fr)" },
"@media (max-width: 1124px)": { gridTemplateColumns: "repeat(3, 1fr)" },
"@media (max-width: 860px)": { gridTemplateColumns: "repeat(2, 1fr)" },
}}
>
<Box sx={cardGridSx(6)}>
{collections.map((collection) => (
<CollectionCard
key={collection.id}
@@ -214,17 +205,9 @@ export default function TagDetailPage({ theme, previewMode, onUnauthorized, onBo
viewer={viewer}
/>
))}
{loadingMore && <CardSkeletons count={6} />}
</Box>
{hasMore && (
<Stack ref={loadMoreSentinelRef} direction="row" justifyContent="center" sx={{ py: 1 }}>
{loadingMore && (
<Stack direction="row" alignItems="center" spacing={1} sx={{ color: "text.secondary" }}>
<CircularProgress size={14} />
<Typography variant="caption">{t("models:grid.loadingMore")}</Typography>
</Stack>
)}
</Stack>
)}
{hasMore && <Box ref={loadMoreSentinelRef} sx={{ height: 1 }} />}
</Stack>
) : (
<Stack alignItems="center" spacing={1} sx={{ py: 8, color: "text.secondary" }}>
+4 -10
View File
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import CircularProgress from "@mui/material/CircularProgress";
import Chip from "@mui/material/Chip";
import Switch from "@mui/material/Switch";
import FormControlLabel from "@mui/material/FormControlLabel";
@@ -13,6 +12,7 @@ import { UnauthorizedError } from "../../api/client";
import { type TagSortMode, type TagSummary, tagsApi } from "../../api/tags";
import { dividerBorderColor } from "../../theme";
import TagSortTabs from "./TagSortTabs";
import { ChipSkeletons } from "../../components/ListSkeleton";
type Props = {
onUnauthorized?: () => void;
@@ -81,14 +81,6 @@ export default function TagsPage({ onUnauthorized, onBookmarksChanged }: Props)
}
};
if (loading) {
return (
<Stack alignItems="center" sx={{ py: 8 }}>
<CircularProgress size={22} />
</Stack>
);
}
return (
<Stack spacing={2}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
@@ -105,7 +97,9 @@ export default function TagsPage({ onUnauthorized, onBookmarksChanged }: Props)
<TagSortTabs value={sortMode} onChange={setSortMode} />
</Stack>
{visibleTags.length ? (
{loading ? (
<ChipSkeletons count={28} />
) : visibleTags.length ? (
<Stack direction="row" flexWrap="wrap" useFlexGap spacing={1}>
{visibleTags.map((tag) => (
<Chip
+26 -28
View File
@@ -2,7 +2,6 @@ import React from "react";
import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import Paper from "@mui/material/Paper";
import CircularProgress from "@mui/material/CircularProgress";
import Alert from "@mui/material/Alert";
import Chip from "@mui/material/Chip";
import Table from "@mui/material/Table";
@@ -14,6 +13,7 @@ import TableRow from "@mui/material/TableRow";
import { UnauthorizedError } from "../../api/client";
import { adminApi, type AdminUser } from "../../api/admin";
import RegistrationsPanel from "./RegistrationsPanel";
import { TableRowSkeletons } from "../../components/ListSkeleton";
type Props = {
onUnauthorized?: () => void;
@@ -58,27 +58,25 @@ export default function UsersPage({ onUnauthorized }: Props) {
</Alert>
)}
{loading ? (
<Stack alignItems="center" sx={{ py: 2 }}>
<CircularProgress size={20} />
</Stack>
) : (
<Paper variant="outlined">
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t("adminSettings.users.columnEmail")}</TableCell>
<TableCell>{t("adminSettings.users.columnCreated")}</TableCell>
<TableCell>{t("adminSettings.users.columnDisplayName")}</TableCell>
<TableCell>{t("adminSettings.users.columnRole")}</TableCell>
<TableCell align="right">{t("adminSettings.users.columnModels")}</TableCell>
<TableCell align="right">{t("adminSettings.users.columnCollections")}</TableCell>
<TableCell>{t("adminSettings.users.columnMakerworld")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((u) => (
<Paper variant="outlined">
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t("adminSettings.users.columnEmail")}</TableCell>
<TableCell>{t("adminSettings.users.columnCreated")}</TableCell>
<TableCell>{t("adminSettings.users.columnDisplayName")}</TableCell>
<TableCell>{t("adminSettings.users.columnRole")}</TableCell>
<TableCell align="right">{t("adminSettings.users.columnModels")}</TableCell>
<TableCell align="right">{t("adminSettings.users.columnCollections")}</TableCell>
<TableCell>{t("adminSettings.users.columnMakerworld")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRowSkeletons rows={5} columns={7} />
) : (
users.map((u) => (
<TableRow key={u.id} hover>
<TableCell>{u.email}</TableCell>
<TableCell>{formatDate(u.created_at)}</TableCell>
@@ -108,12 +106,12 @@ export default function UsersPage({ onUnauthorized }: Props) {
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
)}
))
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Stack>
);
}