mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
fix: various small UI issues
This commit is contained in:
1 parent
45b6bac574
commit
8ba368e209
21 files changed
+320
-92
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user