mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-20 21:59:53 -05:00
feat: add react skeleton loading bits
This commit is contained in:
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 && (
|
||||
|
||||
@@ -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} />);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1606,8 +1606,7 @@
|
||||
}
|
||||
},
|
||||
"grid": {
|
||||
"empty": "No models yet.",
|
||||
"loadingMore": "Loading…"
|
||||
"empty": "No models yet."
|
||||
},
|
||||
"sort": {
|
||||
"newest": "Newest",
|
||||
|
||||
@@ -1633,8 +1633,7 @@
|
||||
}
|
||||
},
|
||||
"grid": {
|
||||
"empty": "Kol kas nėra modelių.",
|
||||
"loadingMore": "Įkeliama…"
|
||||
"empty": "Kol kas nėra modelių."
|
||||
},
|
||||
"sort": {
|
||||
"newest": "Naujausi",
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user