fix: various small UI issues

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-12 13:55:30 +02:00
1 parent 45b6bac574
commit 8ba368e209
21 files changed
+320 -92

No files matched your search

+5
View File
@@ -45,6 +45,11 @@ export const AUTH_ALGO = "HS256" as const;
export const INITIAL_ADMIN_EMAIL = (process.env.INITIAL_ADMIN_EMAIL || "").trim().toLowerCase();
export const IMPORT_ALLOWED_EXTS = new Set([".stl", ".3mf", ".step", ".stp", ".obj", ".lbrn", ".lbrn2", ".zip"]);
// Extensions the 3D viewer can actually render as a Plate -- mirrors frontend's MODEL_EXTS
// (constants/fileTypes.ts). A multiplate upload splits on this: only these become Plates, and
// anything else (e.g. a bundled .f3d source file) is attached as a SUPPORTING PrintFile instead
// of being force-rendered as an unviewable "plate".
export const RENDERABLE_MODEL_EXTS = new Set([".stl", ".3mf", ".step", ".stp", ".obj"]);
export const IMPORT_EXT_PRIORITY = [".3mf", ".stl", ".step", ".stp", ".lbrn2", ".lbrn", ".zip"];
export const IMPORT_BLOCKED_EXTS = new Set([
".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg", ".jfif", ".tif", ".tiff",
+23 -4
View File
@@ -14,7 +14,8 @@ import { previewImagePath, deleteAllPreviewImages } from "../services/previewIma
import { deleteAuthorIfOrphaned } from "../services/authorService";
import { toPrintOut } from "../dto";
import { loadFullPrint, printOutById } from "../services/printLoader";
import { deleteAllPrintFiles } from "../services/printFileService";
import { deleteAllPrintFiles, saveFileFromTemp } from "../services/printFileService";
import { RENDERABLE_MODEL_EXTS } from "../config";
import { sendPrintsZip } from "../services/downloadZip";
import { systemCollectionKeyForId } from "../services/collectionService";
import { createLog } from "../services/auditLog";
@@ -42,6 +43,10 @@ function matchesTagList(printTags: string[], tagList: string[]): boolean {
return tagList.every((t) => printTagsLower.has(t.toLowerCase()));
}
function isRenderableUpload(f: Express.Multer.File): boolean {
return RENDERABLE_MODEL_EXTS.has(path.extname(f.originalname).toLowerCase());
}
function buildPrintWhere(req: Request): Prisma.PrintWhereInput {
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
const categoryIdParam = typeof req.query.category_id === "string" ? req.query.category_id : "";
@@ -111,13 +116,27 @@ router.post(
void createLog({ userId: req.userId!, action: "model_uploaded", targetId: print.id, details: { name: print.name } });
}
} else {
const plateInputs: NewPlateInput[] = files.map((f) => {
// Only files the 3D viewer can actually render become Plates -- anything else (e.g. a
// bundled .f3d source) is attached as a SUPPORTING file below instead, so it doesn't show
// up as an unviewable "plate" in the plate switcher. If nothing in the batch is
// renderable, fall back to the old all-as-plates behavior so createPrint still gets at
// least one plate.
const renderableFiles = files.filter(isRenderableUpload);
const supportingFiles = renderableFiles.length ? files.filter((f) => !isRenderableUpload(f)) : [];
const plateFiles = renderableFiles.length ? renderableFiles : files;
const plateInputs: NewPlateInput[] = plateFiles.map((f) => {
const safeName = sanitizeFilename(f.originalname);
return { filename: safeName, mime: mimeFromContentType(f.mimetype, safeName), tempFilePath: f.path };
});
const nameHint = path.parse(plateInputs[0].filename).name;
const { print, plates } = await createPrint(req.userId!, meta, nameHint, plateInputs);
printsOut.push(toPrintOut(print, plates, [], null));
const { print } = await createPrint(req.userId!, meta, nameHint, plateInputs);
for (const f of supportingFiles) {
await saveFileFromTemp(req.userId!, print.id, f.path, f.originalname, f.mimetype);
}
printsOut.push(await printOutById(req.userId!, print.id));
void createLog({ userId: req.userId!, action: "model_uploaded", targetId: print.id, details: { name: print.name } });
}
res.json({ prints: printsOut });
+4 -3
View File
@@ -118,6 +118,7 @@ function AppShell({
onUnauthorized={onUnauthorized}
theme={resolvedTheme}
previewMode={previewMode}
viewer={user}
/>
}
/>
@@ -127,15 +128,15 @@ function AppShell({
/>
<Route
path="/models/collections/:collectionId"
element={<CollectionDetailPage theme={resolvedTheme} previewMode={previewMode} onUnauthorized={onUnauthorized} />}
element={<CollectionDetailPage theme={resolvedTheme} previewMode={previewMode} onUnauthorized={onUnauthorized} viewer={user} />}
/>
<Route
path="/models/tags/:tagName"
element={<TagDetailPage theme={resolvedTheme} previewMode={previewMode} onUnauthorized={onUnauthorized} />}
element={<TagDetailPage theme={resolvedTheme} previewMode={previewMode} onUnauthorized={onUnauthorized} viewer={user} />}
/>
<Route
path="/models/:printId"
element={<ModelDetailPage theme={resolvedTheme} onSelectCategory={setCategoryId} onUnauthorized={onUnauthorized} />}
element={<ModelDetailPage theme={resolvedTheme} onSelectCategory={setCategoryId} onUnauthorized={onUnauthorized} viewer={user} />}
/>
<Route
path="/authors/:authorId"
+16 -12
View File
@@ -12,6 +12,7 @@ import BackToTopButton from "./BackToTopButton";
import { ConfirmProvider } from "../ConfirmProvider";
import { ToastProvider } from "../ToastProvider";
import { PageHeaderContext, type PageHeader } from "./PageHeaderContext";
import { NavigationHistoryProvider, useSmartBack } from "./NavigationHistoryContext";
import { ImportJobProvider } from "./ImportJobContext";
import { NotificationsProvider, useNotifications } from "./NotificationsContext";
import type { ThemeSelection } from "../../constants/settingsOptions";
@@ -48,6 +49,7 @@ function useRouteChrome() {
const { t } = useTranslation(["app", "models", "common"]);
const location = useLocation();
const navigate = useNavigate();
const goBack = useSmartBack();
const path = location.pathname;
let title = t("sidebar.dashboard");
@@ -60,23 +62,23 @@ function useRouteChrome() {
} else if (path.startsWith("/models/collections/")) {
// Overridden by CollectionDetailPage's usePageHeader once the collection loads.
title = t("models:collections.pageTitle");
onBack = () => navigate(-1);
onBack = goBack;
} else if (path.startsWith("/models/tags/")) {
// The tag name is already known from the URL (unlike a collection, a tag isn't a fetched
// entity), so this is set directly rather than waiting on TagDetailPage's usePageHeader --
// avoids a "Models" title flash before that effect runs.
const tagName = path.slice("/models/tags/".length);
title = tagName ? t("models:tags.detail.title", { name: decodeURIComponent(tagName) }) : t("models:pageTitle");
onBack = () => navigate(-1);
onBack = goBack;
} else if (path.startsWith("/models/")) {
title = t("models:pageTitle");
onBack = () => navigate(-1);
onBack = goBack;
} else if (path === "/models") {
title = t("models:pageTitle");
onBack = () => navigate("/");
} else if (path.startsWith("/authors/")) {
title = t("models:author.pageTitle");
onBack = () => navigate(-1);
onBack = goBack;
} else if (path === "/profile/email") {
title = t("profile.changeEmailTitle");
onBack = () => navigate("/profile");
@@ -85,7 +87,7 @@ function useRouteChrome() {
onBack = () => navigate("/profile");
} else if (path === "/profile") {
title = t("profile.title");
onBack = () => navigate(-1);
onBack = goBack;
} else if (path === "/downloads") {
// Overridden by DownloadPage's usePageHeader once translations resolve.
title = t("sidebar.downloads");
@@ -188,13 +190,15 @@ export default function AppLayout({ muiTheme, onUnauthorized, ...shellProps }: A
return (
<ThemeProvider theme={muiTheme}>
<CssBaseline />
<ConfirmProvider>
<ToastProvider>
<NotificationsProvider onUnauthorized={onUnauthorized}>
<AppLayoutShell onUnauthorized={onUnauthorized} {...shellProps} />
</NotificationsProvider>
</ToastProvider>
</ConfirmProvider>
<NavigationHistoryProvider>
<ConfirmProvider>
<ToastProvider>
<NotificationsProvider onUnauthorized={onUnauthorized}>
<AppLayoutShell onUnauthorized={onUnauthorized} {...shellProps} />
</NotificationsProvider>
</ToastProvider>
</ConfirmProvider>
</NavigationHistoryProvider>
</ThemeProvider>
);
}
@@ -0,0 +1,81 @@
import React from "react";
import { useLocation, useNavigate, useNavigationType } from "react-router-dom";
type LogEntry = { key: string; pathname: string };
type NavigationHistoryValue = {
/** Retraces history like `navigate(-1)`, but first skips over any run of consecutive entries
* that share the current page's pathname (only the search string differs -- e.g. a sort-tab
* change on a list page pushes a new entry for the same route) so one click always actually
* leaves the page instead of landing back on what looks like the exact same screen. */
goBack: () => void;
};
const NavigationHistoryContext = React.createContext<NavigationHistoryValue | null>(null);
/** Tracks the SPA's own navigation log (keyed by react-router's per-location `key`, which stays
* stable across a real POP back/forward too) so `goBack` can look at what pathname actually
* precedes the current one -- something the browser History API doesn't expose directly. Mount
* once, near the app root and inside the Router, so every route change is observed. */
export function NavigationHistoryProvider({ children }: { children: React.ReactNode }) {
const location = useLocation();
const navigationType = useNavigationType();
const navigate = useNavigate();
const logRef = React.useRef<LogEntry[]>([]);
const posRef = React.useRef(-1);
React.useEffect(() => {
const log = logRef.current;
const entry: LogEntry = { key: location.key, pathname: location.pathname };
if (navigationType === "PUSH") {
log.splice(posRef.current + 1);
log.push(entry);
posRef.current = log.length - 1;
} else if (navigationType === "REPLACE") {
if (posRef.current >= 0) log[posRef.current] = entry;
else {
log.push(entry);
posRef.current = 0;
}
} else {
// POP -- either the browser's own back/forward button, or a `navigate(-n)` call this
// provider made itself. Either way the target entry was already logged earlier, so find it
// by key rather than assuming a single-step move.
const idx = log.findIndex((e) => e.key === entry.key);
if (idx !== -1) {
posRef.current = idx;
} else {
log.length = 0;
log.push(entry);
posRef.current = 0;
}
}
}, [location.key, location.pathname, navigationType]);
const goBack = React.useCallback(() => {
const log = logRef.current;
const pos = posRef.current;
if (pos <= 0) {
navigate("/");
return;
}
const currentPathname = log[pos].pathname;
let steps = 1;
while (pos - steps > 0 && log[pos - steps].pathname === currentPathname) {
steps += 1;
}
navigate(-steps);
}, [navigate]);
const value = React.useMemo(() => ({ goBack }), [goBack]);
return <NavigationHistoryContext.Provider value={value}>{children}</NavigationHistoryContext.Provider>;
}
/** `goBack()` in place of `navigate(-1)` for any "top bar back arrow" style control -- see
* NavigationHistoryProvider's doc comment for why a plain `navigate(-1)` isn't always enough. */
export function useSmartBack(): () => void {
const ctx = React.useContext(NavigationHistoryContext);
if (!ctx) throw new Error("useSmartBack must be used within a NavigationHistoryProvider");
return ctx.goBack;
}
@@ -145,6 +145,8 @@ export default function Sidebar({ isAdmin, onSelectCategory }: Props) {
top: 0,
display: "flex",
flexDirection: "column",
borderRight: "1px solid",
borderColor: (theme) => (theme.palette.mode === "dark" ? "transparent" : "divider"),
bgcolor: "background.paper",
overflow: "hidden",
transition: (theme) => theme.transitions.create("width", { duration: theme.transitions.duration.shortest }),
@@ -1,8 +1,9 @@
import React, { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { UnauthorizedError } from "../../api/client";
import { importsApi } from "../../api/imports";
import { printsApi } from "../../api/prints";
import { printsApi, type Print } from "../../api/prints";
import { entriesFromFileList, uploadEntriesToCategory } from "../../utils/uploadTree";
import { buildUploadEntriesFromZip, isZipFile, readZipEntries } from "../../utils/zipUtils";
import { useZipImportPrompt } from "./ZipImportModal";
@@ -119,6 +120,7 @@ type Props = {
export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUnauthorized }: Props) {
const { t } = useTranslation("app");
const showToast = useToast();
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement | null>(null);
const [uploading, setUploading] = useState(false);
const [importing, setImporting] = useState(false);
@@ -134,14 +136,21 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
} = useImportJob();
const isBusy = uploading || importing || zipPrompt.isOpen || collectionPrompt.isOpen || importModePrompt.isOpen;
// Sends the user straight into editing a just-created model (per spec: after an upload
// finishes, open it and drop into edit mode so the rest of its details can be filled in) --
// reuses the same `?edit=<id>` URL param ModelActionsMenu's own "Edit" menu item drives.
const openForEditing = (print: Print) => {
navigate(`/models/${print.id}?edit=${print.id}`);
};
const uploadFlatAsMultiplate = async (files: File[]) => {
try {
const result = await printsApi.upload(files, { category_id: categoryId || undefined, mode: "multiplate" });
return { uploaded: result.prints.length, failed: [] as string[] };
return { uploaded: result.prints.length, failed: [] as string[], prints: result.prints };
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return { uploaded: 0, failed: [] as string[] };
return { uploaded: 0, failed: [] as string[], prints: [] as Print[] };
}
const message = err instanceof Error ? err.message.trim() : "";
return {
@@ -151,6 +160,7 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
? t("uploadBar.multiplateImportFailedWithMessage", { message })
: t("uploadBar.multiplateImportFailed"),
],
prints: [] as Print[],
};
}
};
@@ -162,9 +172,11 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
const zipEntries = entries.filter(entry => isZipFile(entry.file.name));
let uploaded = 0;
const failed: string[] = [];
const applyResult = (result: { uploaded: number; failed: string[] }) => {
const prints: Print[] = [];
const applyResult = (result: { uploaded: number; failed: string[]; prints?: Print[] }) => {
uploaded += result.uploaded;
failed.push(...result.failed);
if (result.prints) prints.push(...result.prints);
};
if (normalEntries.length) {
if (isFlatFileSet(normalEntries)) {
@@ -211,7 +223,15 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
},
});
}
if (uploaded) onUploaded();
if (uploaded) {
onUploaded();
if (uploaded === 1 && prints.length === 1) {
showToast({ message: t("uploadBar.uploaded", { name: prints[0].title || prints[0].name }) });
openForEditing(prints[0]);
} else {
showToast({ message: t("uploadBar.uploadedMultiple", { count: uploaded }) });
}
}
if (failed.length) {
alert(t("uploadBar.uploadFailed", { files: failed.join(", ") }));
}
@@ -232,7 +252,7 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
type="file"
onChange={onFilePick}
multiple
accept=".png,.jpg,.jpeg,.webp,.bmp,.gif,.svg,.stl,.step,.stp,.3mf,.lbrn,.lbrn2,.zip"
accept=".png,.jpg,.jpeg,.webp,.bmp,.gif,.svg,.stl,.step,.stp,.3mf,.obj,.f3d,.lbrn,.lbrn2,.zip"
hidden
/>
);
@@ -366,6 +386,7 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
const imported = await importsApi.fromLink(payload);
showToast({ message: t("uploadBar.imported", { name: imported.title || imported.name }) });
onUploaded();
openForEditing(imported);
return;
}
@@ -374,6 +395,7 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
const imported = await importsApi.fromLink(payload);
showToast({ message: t("uploadBar.imported", { name: imported.title || imported.name }) });
onUploaded();
openForEditing(imported);
return;
}
setImporting(false);
@@ -383,6 +405,8 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
try {
const imported = await importsApi.fromLink(payload);
showToast({ message: t("uploadBar.imported", { name: imported.title || imported.name }) });
onUploaded();
openForEditing(imported);
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
@@ -390,7 +414,6 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
}
throw err;
}
onUploaded();
},
loadEntries: async () => {
try {
+22 -1
View File
@@ -2,15 +2,36 @@
// 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 };
//
// `color`/`textColor` are plain sx palette-token strings (e.g. "text.primary",
// "background.paper") for "thingport", or fixed hex brand colors for everything else -- both
// forms resolve the same way through MUI's sx `bgcolor`/`color`, so call sites don't need to
// care which kind a given provider uses.
export type ImportProviderInfo = { label: string; color: string; textColor?: string };
export const IMPORT_PROVIDER_INFO: Record<string, ImportProviderInfo> = {
makerworld: { label: "MakerWorld", color: "#00B800" },
thingiverse: { label: "Thingiverse", color: "#2B78FE" },
printables: { label: "Printables", color: "#FA6831" },
// Not an external site -- a direct upload/zip import (Print.source_provider is null; this key
// only ever comes from printProviderInfo's fallback below, ID never stored this literally).
// Deliberately monochrome (the app's own heading-text/panel colors, inverted) rather than a
// brand hue, so it reads as "this is us" instead of competing with the real provider badges.
thingport: { label: "Thingport", color: "text.primary", textColor: "background.paper" },
};
/** Looks up a KNOWN external provider only -- null for anything else, including a plain upload
* (Print.source_provider is null there). Used where "no real external source" must stay a
* distinct, falsy case, e.g. ModelActionsMenu's "Open in {Provider}" link, which a Thingport
* upload has nothing to point to. */
export function importProviderInfo(provider: string | null | undefined): ImportProviderInfo | null {
if (!provider) return null;
return IMPORT_PROVIDER_INFO[provider] ?? null;
}
/** Same lookup, but for contexts that want a badge for EVERY print, including ones with no
* external source -- ModelCard's thumbnail badge and the dashboard's Top Providers list both
* want "Thingport" rather than nothing when source_provider is null. */
export function printProviderInfo(provider: string | null | undefined): ImportProviderInfo {
return importProviderInfo(provider) ?? IMPORT_PROVIDER_INFO.thingport;
}
+5 -2
View File
@@ -63,7 +63,9 @@
"uploadFailed": "Failed to upload: {{files}}",
"multiplateImportFailedWithMessage": "Multi-plate import ({{message}})",
"multiplateImportFailed": "Multi-plate import",
"imported": "Imported \"{{name}}\""
"imported": "Imported \"{{name}}\"",
"uploaded": "Uploaded \"{{name}}\"",
"uploadedMultiple": "Uploaded {{count}} models"
},
"zipImport": {
"title": "Import zip",
@@ -1008,7 +1010,8 @@
"unknownAuthor": "Unknown",
"addedToFavorites": "Added \"{{name}}\" to Favourites",
"removedFromFavorites": "Removed \"{{name}}\" from Favourites",
"importedFrom": "Imported from {{provider}}"
"importedFrom": "Imported from {{provider}}",
"uploadedDirectly": "Uploaded directly to Thingport"
},
"detail": {
"preview3d": "3D Preview",
+5 -2
View File
@@ -63,7 +63,9 @@
"uploadFailed": "Nepavyko įkelti: {{files}}",
"multiplateImportFailedWithMessage": "Kelių plokščių importas ({{message}})",
"multiplateImportFailed": "Kelių plokščių importas",
"imported": "Importuota „{{name}}“"
"imported": "Importuota „{{name}}“",
"uploaded": "Įkelta „{{name}}“",
"uploadedMultiple": "Įkelta {{count}} modelių"
},
"zipImport": {
"title": "Importuoti zip",
@@ -1008,7 +1010,8 @@
"unknownAuthor": "Nežinomas",
"addedToFavorites": "\"{{name}}\" pridėtas prie Mėgstamiausių",
"removedFromFavorites": "\"{{name}}\" pašalintas iš Mėgstamiausių",
"importedFrom": "Importuota iš {{provider}}"
"importedFrom": "Importuota iš {{provider}}",
"uploadedDirectly": "Įkelta tiesiai į Thingport"
},
"detail": {
"preview3d": "3D peržiūra",
+12 -4
View File
@@ -31,8 +31,10 @@ type Props = {
* provider chip linking back to their profile on the site they were imported from, and their
* bio) beside a wide "Models" grid of every print by them this user has imported -- the models
* route's own grid pattern, reused as-is via ModelCard. The whole thing reads as a page of its
* own, sidebar-colored and border-free, sitting inside the app shell rather than blending into
* it. Author rows are shared across users (not scoped to this account -- see
* own, sidebar-colored, sitting inside the app shell rather than blending into it -- a light-theme
* divider on the profile column's right edge separates it from the models grid (matching the rest
* of the app's light-mode borders, dropped in dark mode where the two columns already contrast).
* Author rows are shared across users (not scoped to this account -- see
* authorService.ts's doc comment on the backend), so there's nothing here to edit. */
export default function AuthorPage({ theme, previewMode, onUnauthorized }: Props) {
const { authorId } = useParams<{ authorId: string }>();
@@ -48,7 +50,7 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized }: Props
const displayName = author?.name || author?.handle || t("models:card.unknownAuthor");
// Only overrides the title -- the route's own default onBack (see AppLayout's useRouteChrome,
// "/authors/" -> navigate(-1)) already does exactly "back button goes to the previous page".
// "/authors/" -> useSmartBack) already does exactly "back button goes to the previous page".
usePageHeader({
title: author ? t("models:author.pageTitleWithName", { name: displayName }) : undefined,
});
@@ -153,7 +155,13 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized }: Props
alignItems: "start",
}}
>
<Box>
<Box
sx={{
borderRight: { xs: "none", md: "1px solid" },
borderColor: (muiTheme) => (muiTheme.palette.mode === "dark" ? "transparent" : "divider"),
pr: { xs: 0, md: 4 },
}}
>
<Avatar src={author.avatar_url ?? undefined} sx={{ width: 112, height: 112, fontSize: 40 }}>
<PersonIcon fontSize="large" />
</Avatar>
@@ -9,6 +9,7 @@ import { UnauthorizedError } from "../../api/client";
import { type Collection, collectionsApi } from "../../api/collections";
import { type Print, type PrintSortMode, printsApi } from "../../api/prints";
import { type PreviewMode } from "../../api/settings";
import type { AuthUser } from "../../api/auth";
import { type ResolvedTheme } from "../../constants/settingsOptions";
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll";
@@ -23,9 +24,10 @@ type Props = {
theme: ResolvedTheme;
previewMode: PreviewMode;
onUnauthorized?: () => void;
viewer?: AuthUser | null;
};
export default function CollectionDetailPage({ theme, previewMode, onUnauthorized }: Props) {
export default function CollectionDetailPage({ theme, previewMode, onUnauthorized, viewer }: Props) {
const { collectionId } = useParams<{ collectionId: string }>();
const navigate = useNavigate();
const { t } = useTranslation(["models", "common"]);
@@ -178,6 +180,7 @@ export default function CollectionDetailPage({ theme, previewMode, onUnauthorize
onRemovedFromCollection={removedId => setItems(prev => prev.filter(i => i.id !== removedId))}
onUpdated={updated => setItems(prev => prev.map(i => (i.id === updated.id ? updated : i)))}
onUnauthorized={onUnauthorized}
viewer={viewer}
/>
))}
</Box>
@@ -7,25 +7,13 @@ import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import Chip from "@mui/material/Chip";
import PublicIcon from "@mui/icons-material/Public";
import { IMPORT_PROVIDER_INFO } from "../../constants/importProviders";
import { printProviderInfo } from "../../constants/importProviders";
import type { DashboardProvider } from "../../api/dashboard";
type Props = {
providers: DashboardProvider[];
};
const OWN_UPLOAD_LABEL = "Thingport";
// Reuses the exact same brand colors as the provider badge shown on a model's card thumbnail
// (see constants/importProviders.ts) -- a direct upload/zip import has no import-source badge
// there (sourceProvider is null), so it falls back to the app's own primary color here instead
// of a fixed brand hex.
function chipSx(provider: string): { label: string; sx: object } {
const info = IMPORT_PROVIDER_INFO[provider];
if (info) return { label: info.label, sx: { bgcolor: info.color, color: "#fff", fontWeight: 600 } };
return { label: OWN_UPLOAD_LABEL, sx: { bgcolor: "primary.main", color: "primary.contrastText", fontWeight: 600 } };
}
/** Top Providers -- how many of this user's models came from each source (MakerWorld,
* Thingiverse, or a direct Thingport upload). A fixed, small set in practice, so unlike the
* other list cards this one has no "see more" dialog and its rows aren't clickable -- there's
@@ -48,11 +36,15 @@ export default function ProviderListCard({ providers }: Props) {
) : (
<List dense disablePadding>
{providers.map((p) => {
const { label, sx } = chipSx(p.provider);
const info = printProviderInfo(p.provider);
return (
<ListItem key={p.provider} sx={{ px: 1 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ width: "100%" }}>
<Chip label={label} size="small" sx={sx} />
<Chip
label={info.label}
size="small"
sx={{ bgcolor: info.color, color: info.textColor ?? "#fff", fontWeight: 600 }}
/>
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, pl: 1 }}>
{t("dashboard.topProviders.modelCount", { count: p.model_count })}
</Typography>
@@ -14,9 +14,11 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
import PrintIcon from "@mui/icons-material/Print";
import type { Print } from "../../api/prints";
import { printsApi } from "../../api/prints";
import type { AuthUser } from "../../api/auth";
import { slicerLaunchUrl } from "../../utils/slicerLaunch";
import { SLICER_OPTIONS } from "../../constants/settingsOptions";
import { useSlicerPreference } from "../../hooks/useSlicerPreference";
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
import { useDownloadPrint } from "./useDownloadPrint";
import DownloadPickerDialog from "./DownloadPickerDialog";
@@ -24,6 +26,10 @@ type Props = {
print: Print;
onSelectCategory: (id: string) => void;
onUnauthorized?: () => void;
/** Only used as a fallback when the print has neither an Author nor a plain `creator` string --
* a direct upload has no import-source author at all, so it shows the viewer's own identity
* instead of "Unknown", since every print here is the viewer's own. */
viewer?: AuthUser | null;
};
/** The model detail page's right-hand summary card, sticky so it stays in view while the
@@ -33,10 +39,11 @@ type Props = {
* "Download model files" (the same picker-or-direct-download flow as ModelActionsMenu's
* Download, via useDownloadPrint so the two can't drift), view/print counts, and -- only for an
* actually-imported print, per source_provider -- when it was imported. */
export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized }: Props) {
export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized, viewer }: Props) {
const { t } = useTranslation(["models", "common"]);
const navigate = useNavigate();
const slicerPreference = useSlicerPreference();
const viewerAvatarUrl = useGravatarUrl(viewer?.email, 56);
const { pickerOpen, setPickerOpen, downloading, handleDownload, downloadPlate, downloadAllZip, sortedPlates } =
useDownloadPrint(print, onUnauthorized);
@@ -61,14 +68,19 @@ export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized
? new Date(print.created_at).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })
: null;
const authorName = print.author?.name || print.author?.handle || print.creator || null;
const showViewerAsAuthor =
!print.author?.name && !print.author?.handle && !print.creator && !print.source_provider && Boolean(viewer);
const authorName =
print.author?.name || print.author?.handle || print.creator || (showViewerAsAuthor ? viewer!.display_name : null);
const authorAvatarUrl = print.author?.avatar_url || (showViewerAsAuthor ? viewerAvatarUrl : undefined);
return (
<Paper
elevation={0}
variant="outlined"
sx={{
p: 2.5,
borderRadius: "12px",
borderColor: (muiTheme) => (muiTheme.palette.mode === "dark" ? "transparent" : "divider"),
position: { xs: "static", md: "sticky" },
// TopBar is sticky too (see its own doc comment) and sits above this in stacking order --
// sticking at a fixed offset from the viewport top would land this panel right underneath
@@ -96,7 +108,7 @@ export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized
}}
onClick={() => print.author && navigate(`/authors/${print.author.id}`)}
>
<Avatar src={print.author?.avatar_url || undefined} sx={{ width: 28, height: 28, fontSize: 13, color: "inherit !important" }}>
<Avatar src={authorAvatarUrl || undefined} sx={{ width: 28, height: 28, fontSize: 13, color: "inherit !important" }}>
{(authorName || "?").slice(0, 1).toUpperCase()}
</Avatar>
<Typography variant="body2" sx={{ color: "inherit" }}>
+9 -4
View File
@@ -15,12 +15,14 @@ import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { UnauthorizedError } from "../../api/client";
import { type Print, printsApi } from "../../api/prints";
import type { AuthUser } from "../../api/auth";
import { type ResolvedTheme } from "../../constants/settingsOptions";
import { MODEL_EXTS } from "../../constants/fileTypes";
import { extOf } from "../../utils/fileExtensions";
import { renderPreviewContent } from "../../components/media/renderPreviewContent";
import { ModelSnapshot } from "../../components/media/ModelViewer/ModelSnapshot";
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
import { useSmartBack } from "../../components/Layout/NavigationHistoryContext";
import Model3DPreviewModal from "./Model3DPreviewModal";
import ModelActionsMenu from "./ModelActionsMenu";
import FavoriteButton from "./FavoriteButton";
@@ -30,9 +32,10 @@ type Props = {
theme: ResolvedTheme;
onSelectCategory: (id: string) => void;
onUnauthorized?: () => void;
viewer?: AuthUser | null;
};
export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorized }: Props) {
export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorized, viewer }: Props) {
const { printId } = useParams<{ printId: string }>();
const navigate = useNavigate();
const { t } = useTranslation(["models", "common", "library"]);
@@ -44,8 +47,10 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
// Back always returns wherever the user came from (the models grid, filtered to whichever
// category they'd selected, or an author page) -- browser history already carries that, so this
// is also what a post-delete redirect below reuses.
const goBack = () => navigate(-1);
// is also what a post-delete redirect below reuses. useSmartBack skips over any history entry
// that's the same route as this one (e.g. a sort-tab change re-pushing the same list page)
// instead of requiring an extra click to actually leave.
const goBack = useSmartBack();
usePageHeader({
title: print ? (print.title || print.name) : undefined,
@@ -320,7 +325,7 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
it room to travel as the page scrolls instead of being stuck the moment its own
(short) content ends. */}
<Box sx={{ alignSelf: "stretch" }}>
<ModelSidePanel print={print} onSelectCategory={onSelectCategory} onUnauthorized={onUnauthorized} />
<ModelSidePanel print={print} onSelectCategory={onSelectCategory} onUnauthorized={onUnauthorized} viewer={viewer} />
</Box>
</Box>
@@ -121,7 +121,7 @@ export default function CategoriesPanel({ categories, loading, selectedId, onSel
return (
<>
<Paper
elevation={0}
variant="outlined"
sx={{
width: 260,
flexShrink: 0,
@@ -129,6 +129,7 @@ export default function CategoriesPanel({ categories, loading, selectedId, onSel
p: 1.5,
alignSelf: "flex-start",
bgcolor: "background.paper",
borderColor: (muiTheme) => (muiTheme.palette.mode === "dark" ? "transparent" : "divider"),
}}
>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 0.5, pb: 1 }}>
+45 -25
View File
@@ -16,9 +16,11 @@ import { type ResolvedTheme } from "../../constants/settingsOptions";
import { UnauthorizedError } from "../../api/client";
import { useToast } from "../../components/ToastProvider";
import { renderPreviewContent } from "../../components/media/renderPreviewContent";
import { importProviderInfo } from "../../constants/importProviders";
import { printProviderInfo } from "../../constants/importProviders";
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
import StarToggle from "../../components/StarToggle";
import ModelActionsMenu from "../ModelDetailPage/ModelActionsMenu";
import type { AuthUser } from "../../api/auth";
type Props = {
item: Print;
@@ -32,6 +34,12 @@ type Props = {
* "Remove from collection" in the card's "..." menu. See ModelActionsMenu's doc comment. */
collectionId?: string;
onRemovedFromCollection?: (id: string) => void;
/** Only used as a fallback when the print has neither an Author nor a plain `creator` string --
* a direct upload has no import-source author at all, so it shows the viewer's own identity
* instead of "Unknown" (every print in this per-user app is, after all, the viewer's own).
* Not passed down from AuthorPage: a print reachable from there always has a real Author, so
* the fallback never applies there. */
viewer?: AuthUser | null;
};
// The star/"..." hover icons sit on a plain neutral circle -- readable over any thumbnail color
@@ -60,6 +68,7 @@ export default function ModelCard({
onUnauthorized,
collectionId,
onRemovedFromCollection,
viewer,
}: Props) {
const { t } = useTranslation(["models", "common"]);
const navigate = useNavigate();
@@ -76,8 +85,15 @@ export default function ModelCard({
const [isFavorite, setIsFavorite] = useState(item.is_favorite);
const favoritePendingRef = useRef(false);
const author = item.author;
const authorName = author?.name || author?.handle || item.creator || null;
const providerInfo = importProviderInfo(item.source_provider);
const viewerAvatarUrl = useGravatarUrl(viewer?.email, 40);
// Direct uploads have no Author row and usually no `creator` string either -- fall back to the
// viewer's own identity rather than "Unknown", since every print here is the viewer's own.
// Deliberately NOT clickable to an author page (see the click handler below, still keyed off
// the real `author` object): there's no Author id behind this fallback to navigate to.
const showViewerAsAuthor = !author?.name && !author?.handle && !item.creator && !item.source_provider && Boolean(viewer);
const authorName = author?.name || author?.handle || item.creator || (showViewerAsAuthor ? viewer!.display_name : null);
const authorAvatarUrl = author?.avatar_url || (showViewerAsAuthor ? viewerAvatarUrl : undefined);
const providerInfo = printProviderInfo(item.source_provider);
useEffect(() => { setIsFavorite(item.is_favorite); }, [item.is_favorite]);
@@ -134,27 +150,31 @@ export default function ModelCard({
{renderPreviewContent(item, "card", theme, t, previewMode)}
</Box>
{providerInfo && (
<Tooltip title={t("models:card.importedFrom", { provider: providerInfo.label })}>
<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>
</Tooltip>
)}
<Tooltip
title={
item.source_provider
? t("models:card.importedFrom", { provider: providerInfo.label })
: t("models:card.uploadedDirectly")
}
>
<Box
sx={{
position: "absolute",
top: 8,
left: 8,
px: 1,
py: 0.375,
borderRadius: 1,
fontSize: 11,
fontWeight: 600,
lineHeight: 1.4,
color: providerInfo.textColor ?? "#fff",
bgcolor: providerInfo.color,
}}
>
{providerInfo.label}
</Box>
</Tooltip>
<Stack
className="model-card-actions"
@@ -218,7 +238,7 @@ export default function ModelCard({
navigate(`/authors/${author.id}`);
}}
>
<Avatar src={author?.avatar_url || undefined} sx={{ width: 20, height: 20, fontSize: 11, color: "inherit !important" }}>
<Avatar src={authorAvatarUrl || undefined} sx={{ width: 20, height: 20, fontSize: 11, color: "inherit !important" }}>
{(authorName || "?").slice(0, 1).toUpperCase()}
</Avatar>
<Typography variant="caption" noWrap sx={{ color: "inherit" }}>
+4 -1
View File
@@ -9,6 +9,7 @@ import { UnauthorizedError } from "../../api/client";
import { type Print, type PrintSortMode, printsApi } from "../../api/prints";
import { type Category, type CategoryMetaInput, categoriesApi } from "../../api/categories";
import { type PreviewMode } from "../../api/settings";
import type { AuthUser } from "../../api/auth";
import { type ResolvedTheme } from "../../constants/settingsOptions";
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll";
@@ -31,9 +32,10 @@ type Props = {
onUnauthorized?: () => void;
theme: ResolvedTheme;
previewMode: PreviewMode;
viewer?: AuthUser | null;
};
export default function ModelsPage({ categoryId, onSelectCategory, categoriesVersion, onCategoriesChanged, printsVersion, onUnauthorized, theme, previewMode }: Props) {
export default function ModelsPage({ categoryId, onSelectCategory, categoriesVersion, onCategoriesChanged, printsVersion, onUnauthorized, theme, previewMode, viewer }: Props) {
const { t } = useTranslation(["models", "common"]);
const [categories, setCategories] = useState<Category[]>([]);
const [categoriesLoading, setCategoriesLoading] = useState(false);
@@ -281,6 +283,7 @@ export default function ModelsPage({ categoryId, onSelectCategory, categoriesVer
onFavoriteChange={updated => setItems(prev => prev.map(i => (i.id === updated.id ? updated : i)))}
onUpdated={updated => setItems(prev => prev.map(i => (i.id === updated.id ? updated : i)))}
onUnauthorized={onUnauthorized}
viewer={viewer}
/>
))}
</Box>
+4 -1
View File
@@ -9,6 +9,7 @@ import { UnauthorizedError } from "../../api/client";
import { type Print, type PrintSortMode, printsApi } from "../../api/prints";
import { type Collection, collectionsApi } from "../../api/collections";
import { type PreviewMode } from "../../api/settings";
import type { AuthUser } from "../../api/auth";
import { type ResolvedTheme } from "../../constants/settingsOptions";
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll";
@@ -22,6 +23,7 @@ type Props = {
theme: ResolvedTheme;
previewMode: PreviewMode;
onUnauthorized?: () => void;
viewer?: AuthUser | null;
};
/** Same list/grid/sort/infinite-scroll shape as CollectionDetailPage, but for a tag: tags aren't
@@ -29,7 +31,7 @@ type Props = {
* fetch-by-id step (or "not found" state) -- the page header title comes directly from the
* route param and the list is prints filtered by that tag, plus (since collections can carry
* tags too) any collections carrying it, shown first in the same grid as CollectionCards. */
export default function TagDetailPage({ theme, previewMode, onUnauthorized }: Props) {
export default function TagDetailPage({ theme, previewMode, onUnauthorized, viewer }: Props) {
const { tagName } = useParams<{ tagName: string }>();
const tag = tagName ? decodeURIComponent(tagName) : "";
const { t } = useTranslation(["models", "common"]);
@@ -173,6 +175,7 @@ export default function TagDetailPage({ theme, previewMode, onUnauthorized }: Pr
onFavoriteChange={updated => setItems(prev => prev.map(i => (i.id === updated.id ? updated : i)))}
onUpdated={updated => setItems(prev => prev.map(i => (i.id === updated.id ? updated : i)))}
onUnauthorized={onUnauthorized}
viewer={viewer}
/>
))}
</Box>
+19 -2
View File
@@ -7,6 +7,10 @@ import * as THREE from "three";
import type { ResolvedTheme } from "../constants/settingsOptions";
import { applyThemeToObject, disposeObject3D, loadObjectFromAsset, paletteForTheme } from "./modelLoaders";
// Same "three-quarter" viewing angle ModelViewer's fitCameraToBox uses for non-Bambu formats, so
// the static thumbnail matches the angle a user sees first when they open the live viewer.
const SNAPSHOT_VIEW_DIRECTION = new THREE.Vector3(0.9, 0.7, 2.1).normalize();
export const snapshotCache = new Map<string, string>();
let snapshotRenderer: THREE.WebGLRenderer | null = null;
@@ -64,6 +68,9 @@ export async function generateModelSnapshot(
const scene = new THREE.Scene();
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.2));
scene.add(new THREE.AmbientLight(0xffffff, 0.8));
// near/far start as placeholders -- both get set below from the model's actual bounding radius,
// since a fixed 0.1/1000 clips the geometry entirely for models far outside that range (this was
// the root cause of blank/background-only thumbnails: the renderer had nothing in view).
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
const { renderer, release } = await acquireSnapshotRenderer();
renderer.setSize(width, height, false);
@@ -71,10 +78,20 @@ export async function generateModelSnapshot(
const box = new THREE.Box3().setFromObject(object);
if (!box.isEmpty()) {
// Same distance-solving as ModelViewer's fitCameraToBox: fit to the box's circumscribed
// sphere against both vertical and (aspect-derived) horizontal FOV, so the model is framed
// fully regardless of its real-world scale or the thumbnail's aspect ratio.
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const radius = Math.max(size.x, size.y, size.z) || 1;
camera.position.copy(center).add(new THREE.Vector3(radius * 1.8, radius * 1.3, radius * 1.6));
const radius = Math.max(size.length() / 2, 0.001);
const padding = 1.15;
const vFov = THREE.MathUtils.degToRad(camera.fov);
const hFov = 2 * Math.atan(Math.tan(vFov / 2) * camera.aspect);
const distance = padding * Math.max(radius / Math.sin(vFov / 2), radius / Math.sin(hFov / 2));
camera.position.copy(center).addScaledVector(SNAPSHOT_VIEW_DIRECTION, distance);
camera.near = Math.max(distance / 1000, 0.01);
camera.far = distance + radius * 4;
camera.updateProjectionMatrix();
camera.lookAt(center);
} else {
camera.position.set(1, 1, 3);
+5 -3
View File
@@ -1,6 +1,6 @@
import { UnauthorizedError } from "../api/client";
import { categoriesApi } from "../api/categories";
import { printsApi } from "../api/prints";
import { printsApi, type Print } from "../api/prints";
export type UploadEntry = {
file: File;
@@ -94,6 +94,7 @@ export async function uploadEntriesToCategory(
let uploaded = 0;
let aborted = false;
const uploadedEntries: UploadEntry[] = [];
const prints: Print[] = [];
const categoryCache = new Map<string, string>();
const baseKey = parentCategoryId || "root";
@@ -136,9 +137,10 @@ export async function uploadEntriesToCategory(
// Every leaf of a folder tree (dropped folder or webkitdirectory picker)
// is always its own single-plate print, so this is always a one-file
// upload -- the separate/multiplate mode never applies here.
await printsApi.upload([entry.file], { category_id: targetCategoryId || undefined });
const result = await printsApi.upload([entry.file], { category_id: targetCategoryId || undefined });
uploaded += 1;
uploadedEntries.push(entry);
prints.push(...result.prints);
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
@@ -150,5 +152,5 @@ export async function uploadEntriesToCategory(
failed.push(message && message !== "Upload failed" ? `${entry.file.name} (${message})` : entry.file.name);
}
}
return { uploaded, failed, uploadedEntries };
return { uploaded, failed, uploadedEntries, prints };
}