fix: couple ux improvements

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-08 20:40:42 +02:00
1 parent 8450885ea1
commit 030b1553e1
14 files changed
+251 -32

No files matched your search

+8
View File
@@ -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),
};
}
+12
View File
@@ -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 },
+62
View File
@@ -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();
});
});
+8
View File
@@ -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,
},
});
+4
View File
@@ -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 = {
+2 -1
View File
@@ -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 (
+15
View File
@@ -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;
}
+25
View File
@@ -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;
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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">
+49 -1
View File
@@ -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}
+18 -12
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 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>