mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
fix: couple ux improvements
This commit is contained in:
1 parent
8450885ea1
commit
030b1553e1
14 files changed
+251
-32
No files matched your search
@@ -4,6 +4,7 @@ import { plateThumbExists, plateThumbPath } from "./services/printService";
|
||||
import { previewImageExists, previewImagePath } from "./services/previewImageService";
|
||||
import { preparedFilename } from "./services/preparedPrint";
|
||||
import { modelPreviewGlbExists, modelPreviewGlbPath } from "./services/modelPreviewCache";
|
||||
import { buildImportSourceUrl } from "./services/importService";
|
||||
|
||||
export type UserOut = {
|
||||
id: string;
|
||||
@@ -109,6 +110,11 @@ export type PrintOut = {
|
||||
view_count: number;
|
||||
print_count: number;
|
||||
is_favorite: boolean;
|
||||
// Null for uploads, zip/folder-scan imports, and anything not resolvable to a known provider
|
||||
// -- see importService.ts's identifySourceModel/buildImportSourceUrl. source_url is the
|
||||
// reconstructed original model page, for an "Open in {Provider}" link.
|
||||
source_provider: string | null;
|
||||
source_url: string | null;
|
||||
};
|
||||
|
||||
export type FolderOut = {
|
||||
@@ -261,6 +267,8 @@ export function toPrintOut(
|
||||
view_count: print.viewCount,
|
||||
print_count: print.printCount,
|
||||
is_favorite: print.favoritedAt !== null,
|
||||
source_provider: print.sourceProvider,
|
||||
source_url: buildImportSourceUrl(print.sourceProvider, print.sourceExternalId),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -433,6 +433,18 @@ export function identifySourceModel(url: string): { provider: string; externalId
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The inverse of identifySourceModel above -- rebuilds the original model page URL from the
|
||||
* stable provider + external id a Print was imported with (Print.sourceProvider/
|
||||
* sourceExternalId), for the "Open in {Provider}" link on the model card/detail menus. No raw
|
||||
* URL is stored anywhere; both provider URL shapes are simple and stable enough to reconstruct
|
||||
* (mirrors the exact same string-building already done at import time in importJobRunner.ts). */
|
||||
export function buildImportSourceUrl(provider: string | null, externalId: string | null): string | null {
|
||||
if (!provider || !externalId) return null;
|
||||
if (provider === "makerworld") return `https://makerworld.com/en/models/${externalId}`;
|
||||
if (provider === "thingiverse") return `https://www.thingiverse.com/thing:${externalId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findExistingImportedPrint(
|
||||
userId: string,
|
||||
source: { provider: string; externalId: string },
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildImportSourceUrl, identifySourceModel } from "../src/services/importService";
|
||||
import { toPrintOut } from "../src/dto";
|
||||
import { prisma } from "../src/db";
|
||||
|
||||
// "Open in {Provider}" (model card + detail menu) needs the original model page URL rebuilt
|
||||
// from Print.sourceProvider/sourceExternalId -- confirms both the pure URL-building direction
|
||||
// and that it's actually wired into the API-facing PrintOut shape.
|
||||
|
||||
describe("buildImportSourceUrl", () => {
|
||||
it("rebuilds the exact URL identifySourceModel would parse back out again", () => {
|
||||
const makerworldUrl = "https://makerworld.com/en/models/1698989-brian-griffin";
|
||||
const mwSource = identifySourceModel(makerworldUrl)!;
|
||||
expect(buildImportSourceUrl(mwSource.provider, mwSource.externalId)).toBe(
|
||||
"https://makerworld.com/en/models/1698989",
|
||||
);
|
||||
|
||||
const thingUrl = "https://www.thingiverse.com/thing:763622";
|
||||
const tvSource = identifySourceModel(thingUrl)!;
|
||||
expect(buildImportSourceUrl(tvSource.provider, tvSource.externalId)).toBe(thingUrl);
|
||||
});
|
||||
|
||||
it("returns null when there's nothing to build from", () => {
|
||||
expect(buildImportSourceUrl(null, "123")).toBeNull();
|
||||
expect(buildImportSourceUrl("makerworld", null)).toBeNull();
|
||||
expect(buildImportSourceUrl("some-unknown-provider", "123")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toPrintOut -- source_provider / source_url", () => {
|
||||
it("exposes both for an imported print", async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: { email: `source-url-test-${Date.now()}@example.com`, passwordHash: "x", displayName: "Source URL Test" },
|
||||
});
|
||||
const print = await prisma.print.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Imported Thing",
|
||||
nameNormalized: "imported thing",
|
||||
sourceProvider: "thingiverse",
|
||||
sourceExternalId: "763622",
|
||||
},
|
||||
});
|
||||
|
||||
const out = toPrintOut(print, [], [], null);
|
||||
expect(out.source_provider).toBe("thingiverse");
|
||||
expect(out.source_url).toBe("https://www.thingiverse.com/thing:763622");
|
||||
});
|
||||
|
||||
it("is null for an upload with no known source", async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: { email: `source-url-test-2-${Date.now()}@example.com`, passwordHash: "x", displayName: "Source URL Test 2" },
|
||||
});
|
||||
const print = await prisma.print.create({
|
||||
data: { userId: user.id, name: "Uploaded", nameNormalized: "uploaded" },
|
||||
});
|
||||
|
||||
const out = toPrintOut(print, [], [], null);
|
||||
expect(out.source_provider).toBeNull();
|
||||
expect(out.source_url).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,5 +6,13 @@ export default defineConfig({
|
||||
include: ["tests/**/*.test.ts"],
|
||||
testTimeout: 20000,
|
||||
hookTimeout: 20000,
|
||||
// Every test file shares one real Postgres database, and several files mutate the same
|
||||
// global rows (e.g. the Setting table's thingiverse_access_token, set/cleared in
|
||||
// beforeAll/afterEach across thingiverseImport/thingiverseLikes/thingiverseCollection/
|
||||
// importJobRunner tests) -- running files in parallel worker processes lets one file's
|
||||
// "no token configured" assertion race another file's "token is set" setup. Observed this
|
||||
// directly as an intermittent failure. Sequential file execution costs a few seconds but
|
||||
// makes the suite actually deterministic.
|
||||
fileParallelism: false,
|
||||
},
|
||||
});
|
||||
@@ -71,6 +71,10 @@ export type Print = {
|
||||
view_count: number;
|
||||
print_count: number;
|
||||
is_favorite: boolean;
|
||||
// Null for uploads, zip/folder-scan imports, and anything not resolvable to a known provider.
|
||||
// source_url is the reconstructed original model page, for an "Open in {Provider}" link.
|
||||
source_provider?: string | null;
|
||||
source_url?: string | null;
|
||||
};
|
||||
|
||||
export type ListPrintsResult = {
|
||||
|
||||
@@ -16,6 +16,7 @@ import CheckIcon from "@mui/icons-material/Check";
|
||||
import LogoutIcon from "@mui/icons-material/Logout";
|
||||
import type { AuthUser } from "../../api/auth";
|
||||
import type { ThemeSelection } from "../../constants/settingsOptions";
|
||||
import { IMPORT_PROVIDER_INFO } from "../../constants/importProviders";
|
||||
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
|
||||
|
||||
const THEME_MODES: ThemeSelection[] = ["light", "dark"];
|
||||
@@ -57,7 +58,7 @@ export function UserMenu({ user, theme, onThemeChange, onOpenSettings, onLogout,
|
||||
};
|
||||
|
||||
const services: ServiceChipDef[] = [
|
||||
{ key: "makerworld", label: "MakerWorld", color: "#F07745", connected: Boolean(makerworldCookie.trim()) },
|
||||
{ key: "makerworld", ...IMPORT_PROVIDER_INFO.makerworld, connected: Boolean(makerworldCookie.trim()) },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Display info for a Print's import source (Print.source_provider from the backend -- see
|
||||
// importService.ts's identifySourceModel). Same brand colors UserMenu already uses for its
|
||||
// MakerWorld connection chip, kept here so the model card badge and "Open in {Provider}" menu
|
||||
// item can share one source of truth instead of drifting.
|
||||
export type ImportProviderInfo = { label: string; color: string };
|
||||
|
||||
export const IMPORT_PROVIDER_INFO: Record<string, ImportProviderInfo> = {
|
||||
makerworld: { label: "MakerWorld", color: "#F07745" },
|
||||
thingiverse: { label: "Thingiverse", color: "#2B78FE" },
|
||||
};
|
||||
|
||||
export function importProviderInfo(provider: string | null | undefined): ImportProviderInfo | null {
|
||||
if (!provider) return null;
|
||||
return IMPORT_PROVIDER_INFO[provider] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/** Returns a ref to attach to a sentinel element near the end of a list -- once it scrolls into
|
||||
* view, `onLoadMore` fires automatically, no click needed. `rootMargin` starts the fetch a bit
|
||||
* before the sentinel is actually on screen so the next page is usually ready before the user
|
||||
* reaches the bottom. Re-arms itself after every load (a fresh IntersectionObserver per
|
||||
* dependency change), so scrolling straight through several pages keeps paging in. */
|
||||
export function useInfiniteScroll(onLoadMore: () => void, hasMore: boolean, loading: boolean) {
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = sentinelRef.current;
|
||||
if (!node || !hasMore || loading) return;
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0]?.isIntersecting) onLoadMore();
|
||||
},
|
||||
{ rootMargin: "400px" },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [onLoadMore, hasMore, loading]);
|
||||
|
||||
return sentinelRef;
|
||||
}
|
||||
@@ -512,7 +512,6 @@
|
||||
"pageTitle": "Models",
|
||||
"grid": {
|
||||
"empty": "No models yet.",
|
||||
"loadMore": "Load more",
|
||||
"loadingMore": "Loading…"
|
||||
},
|
||||
"card": {
|
||||
@@ -529,6 +528,7 @@
|
||||
"downloadFailed": "Download failed. Please try again.",
|
||||
"deleteFailed": "Failed to delete the model.",
|
||||
"confirmDelete": "Delete \"{{name}}\"? This cannot be undone.",
|
||||
"openInProvider": "Open in {{provider}}",
|
||||
"downloadPickerTitle": "Download",
|
||||
"downloadAllZip": "Download all {{count}} plates (.zip)",
|
||||
"orDownloadOne": "or download one plate",
|
||||
|
||||
@@ -512,7 +512,6 @@
|
||||
"pageTitle": "Modeliai",
|
||||
"grid": {
|
||||
"empty": "Kol kas nėra modelių.",
|
||||
"loadMore": "Įkelti daugiau",
|
||||
"loadingMore": "Įkeliama…"
|
||||
},
|
||||
"card": {
|
||||
@@ -530,6 +529,7 @@
|
||||
"downloadFailed": "Nepavyko atsisiųsti. Bandykite dar kartą.",
|
||||
"deleteFailed": "Nepavyko ištrinti modelio.",
|
||||
"confirmDelete": "Ištrinti „{{name}}“? Šio veiksmo nebus galima atšaukti.",
|
||||
"openInProvider": "Atverti „{{provider}}“",
|
||||
"downloadPickerTitle": "Atsisiųsti",
|
||||
"downloadAllZip": "Atsisiųsti visas {{count}} plokštes (.zip)",
|
||||
"orDownloadOne": "arba atsisiųsti vieną plokštę",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Button from "@mui/material/Button";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { type Collection, collectionsApi } from "../../api/collections";
|
||||
@@ -12,6 +11,7 @@ import { type Print, printsApi } from "../../api/prints";
|
||||
import { type PreviewMode } from "../../api/settings";
|
||||
import { type ResolvedTheme } from "../../constants/settingsOptions";
|
||||
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
|
||||
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll";
|
||||
import { collectionDisplayName } from "../../utils/collectionDisplay";
|
||||
import ModelCard from "../ModelsPage/ModelCard";
|
||||
import CollectionActionsMenu from "./CollectionActionsMenu";
|
||||
@@ -103,6 +103,8 @@ export default function CollectionDetailPage({ theme, previewMode, onUnauthorize
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreSentinelRef = useInfiniteScroll(loadMore, hasMore, loading || loadingMore);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack alignItems="center" sx={{ py: 8 }}>
|
||||
@@ -128,20 +130,24 @@ export default function CollectionDetailPage({ theme, previewMode, onUnauthorize
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", columnGap: "20px", rowGap: "20px" }}>
|
||||
{items.map(item => (
|
||||
<ModelCard key={item.id} item={item} theme={theme} previewMode={previewMode} />
|
||||
<ModelCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
theme={theme}
|
||||
previewMode={previewMode}
|
||||
onDeleted={deletedId => setItems(prev => prev.filter(i => i.id !== deletedId))}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
{hasMore && (
|
||||
<Stack direction="row" justifyContent="center">
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
startIcon={loadingMore ? <CircularProgress size={14} /> : undefined}
|
||||
>
|
||||
{loadingMore ? t("models:grid.loadingMore") : t("models:grid.loadMore")}
|
||||
</Button>
|
||||
<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>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -19,21 +19,29 @@ import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { type Plate, type Print, printsApi } from "../../api/prints";
|
||||
import { saveResponseToDisk } from "../../utils/downloadResponse";
|
||||
import { useConfirm } from "../../components/ConfirmProvider";
|
||||
import { importProviderInfo } from "../../constants/importProviders";
|
||||
|
||||
type Props = {
|
||||
print: Print;
|
||||
onUnauthorized?: () => void;
|
||||
onDeleted: () => void;
|
||||
/** Lets callers restyle the trigger button -- e.g. the Models grid's hover overlay, which
|
||||
* needs to read over an arbitrary thumbnail instead of the detail page header's plain icon. */
|
||||
triggerSx?: SxProps<Theme>;
|
||||
};
|
||||
|
||||
/** The model detail page's "..." header menu: Download (single file, or a plate picker / zip-all
|
||||
* for multi-plate models), Edit (disabled for now), and Delete (confirm, then delete + navigate
|
||||
* back to wherever the user came from). */
|
||||
export default function ModelActionsMenu({ print, onUnauthorized, onDeleted }: Props) {
|
||||
/** The "..." menu for a model: Download (single file, or a plate picker / zip-all for
|
||||
* multi-plate models), Edit (disabled for now), Delete (confirm, then delete), and -- only for
|
||||
* an imported print -- a divider then "Open in {Provider}" linking back to the original model
|
||||
* page. Shared by the model detail page's header and the Models/Collection grids' per-card
|
||||
* hover overlay. */
|
||||
export default function ModelActionsMenu({ print, onUnauthorized, onDeleted, triggerSx }: Props) {
|
||||
const { t } = useTranslation(["models", "common"]);
|
||||
const confirmDialog = useConfirm();
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
@@ -116,6 +124,7 @@ export default function ModelActionsMenu({ print, onUnauthorized, onDeleted }: P
|
||||
};
|
||||
|
||||
const sortedPlates = print.plates.toSorted((a, b) => a.position - b.position);
|
||||
const providerInfo = importProviderInfo(print.source_provider);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -124,6 +133,7 @@ export default function ModelActionsMenu({ print, onUnauthorized, onDeleted }: P
|
||||
onClick={e => setAnchorEl(e.currentTarget)}
|
||||
aria-label={t("common:more") ?? undefined}
|
||||
disabled={deleting}
|
||||
sx={triggerSx}
|
||||
>
|
||||
{deleting ? <CircularProgress size={18} /> : <MoreVertIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
@@ -140,6 +150,20 @@ export default function ModelActionsMenu({ print, onUnauthorized, onDeleted }: P
|
||||
<ListItemIcon><DeleteIcon fontSize="small" color="error" /></ListItemIcon>
|
||||
<ListItemText sx={{ color: "error.main" }}>{t("common:delete")}</ListItemText>
|
||||
</MenuItem>
|
||||
{providerInfo && print.source_url && [
|
||||
<Divider key="open-in-provider-divider" />,
|
||||
<MenuItem
|
||||
key="open-in-provider"
|
||||
component="a"
|
||||
href={print.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={closeMenu}
|
||||
>
|
||||
<ListItemIcon><OpenInNewIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>{t("models:detail.openInProvider", { provider: providerInfo.label })}</ListItemText>
|
||||
</MenuItem>,
|
||||
]}
|
||||
</Menu>
|
||||
|
||||
<Dialog open={pickerOpen} onClose={() => !downloading && setPickerOpen(false)} fullWidth maxWidth="xs">
|
||||
|
||||
@@ -11,24 +11,30 @@ import { type Print } from "../../api/prints";
|
||||
import { type PreviewMode } from "../../api/settings";
|
||||
import { type ResolvedTheme } from "../../constants/settingsOptions";
|
||||
import { renderPreviewContent } from "../../components/media/renderPreviewContent";
|
||||
import { importProviderInfo } from "../../constants/importProviders";
|
||||
import ModelActionsMenu from "../ModelDetailPage/ModelActionsMenu";
|
||||
|
||||
type Props = {
|
||||
item: Print;
|
||||
theme: ResolvedTheme;
|
||||
previewMode: PreviewMode;
|
||||
onDeleted?: (id: string) => void;
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
export default function ModelCard({ item, theme, previewMode }: Props) {
|
||||
export default function ModelCard({ item, theme, previewMode, onDeleted, onUnauthorized }: Props) {
|
||||
const { t } = useTranslation(["models", "common"]);
|
||||
const navigate = useNavigate();
|
||||
const author = item.author;
|
||||
const authorName = author?.name || author?.handle || item.creator || null;
|
||||
const providerInfo = importProviderInfo(item.source_provider);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
onClick={() => navigate(`/models/${item.id}`)}
|
||||
sx={{
|
||||
position: "relative",
|
||||
cursor: "pointer",
|
||||
overflow: "hidden",
|
||||
borderRadius: "12px",
|
||||
@@ -41,6 +47,7 @@ export default function ModelCard({ item, theme, previewMode }: Props) {
|
||||
borderColor: "divider",
|
||||
transform: "translateY(-2px)",
|
||||
},
|
||||
"&:hover .model-card-actions": { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
@@ -52,6 +59,47 @@ export default function ModelCard({ item, theme, previewMode }: Props) {
|
||||
>
|
||||
{renderPreviewContent(item, "card", theme, t, previewMode)}
|
||||
</Box>
|
||||
|
||||
{providerInfo && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
px: 1,
|
||||
py: 0.375,
|
||||
borderRadius: 1,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
color: "#fff",
|
||||
bgcolor: providerInfo.color,
|
||||
}}
|
||||
>
|
||||
{providerInfo.label}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
className="model-card-actions"
|
||||
onClick={e => e.stopPropagation()}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
opacity: 0,
|
||||
transition: "opacity .15s ease",
|
||||
bgcolor: "rgba(0, 0, 0, 0.55)",
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
>
|
||||
<ModelActionsMenu
|
||||
print={item}
|
||||
onUnauthorized={onUnauthorized}
|
||||
onDeleted={() => onDeleted?.(item.id)}
|
||||
triggerSx={{ color: "#fff" }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ p: 1.5 }}>
|
||||
<Typography variant="body2" fontWeight={600} noWrap title={item.title || item.name}>
|
||||
{item.title || item.name}
|
||||
|
||||
@@ -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 Button from "@mui/material/Button";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { type Print, printsApi } from "../../api/prints";
|
||||
@@ -11,6 +10,7 @@ import { type Folder, type FolderMetaInput, foldersApi } from "../../api/folders
|
||||
import { type PreviewMode } from "../../api/settings";
|
||||
import { type ResolvedTheme } from "../../constants/settingsOptions";
|
||||
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
|
||||
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll";
|
||||
import CategoriesPanel from "./CategoriesPanel";
|
||||
import ModelCard from "./ModelCard";
|
||||
|
||||
@@ -116,6 +116,8 @@ export default function ModelsPage({ folderId, onSelectFolder, foldersVersion, o
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreSentinelRef = useInfiniteScroll(loadMore, hasMore, loading || loadingMore);
|
||||
|
||||
const createCategory = async (name: string, parentId: string | null) => {
|
||||
try {
|
||||
await foldersApi.create(name, [], parentId || undefined);
|
||||
@@ -190,20 +192,24 @@ export default function ModelsPage({ folderId, onSelectFolder, foldersVersion, o
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", columnGap: "20px", rowGap: "20px" }}>
|
||||
{items.map(item => (
|
||||
<ModelCard key={item.id} item={item} theme={theme} previewMode={previewMode} />
|
||||
<ModelCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
theme={theme}
|
||||
previewMode={previewMode}
|
||||
onDeleted={deletedId => setItems(prev => prev.filter(i => i.id !== deletedId))}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
{hasMore && (
|
||||
<Stack direction="row" justifyContent="center">
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
startIcon={loadingMore ? <CircularProgress size={14} /> : undefined}
|
||||
>
|
||||
{loadingMore ? t("models:grid.loadingMore") : t("models:grid.loadMore")}
|
||||
</Button>
|
||||
<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>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
Reference in new issue
Block a user