fix: minor adjustments in regards of import redirects and authors

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-12 17:27:32 +02:00
1 parent 589a5f464a
commit f187b9f25e
9 files changed
+86 -15

No files matched your search

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ImportJob" ADD COLUMN "resultPrintId" TEXT;
+7
View File
@@ -210,6 +210,13 @@ model ImportJob {
failedCount Int @default(0)
errorMessage String?
resultCollectionId String?
// Set whenever this job finished having created exactly one Print, whatever its type -- a ZIP
// job (a single link that needed the zip-entry picker), or a COLLECTION job (MakerWorld/
// Thingiverse/Printables) that happened to succeed on just one item despite being a batch import.
// See importJobRunner.ts's various runners. ImportJobContext uses it to redirect straight into
// that one print's details page; a job with more than one result instead sends the viewer to the
// models grid, and a COLLECTION job's own resultCollectionId grouping still applies either way.
resultPrintId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+5
View File
@@ -382,6 +382,10 @@ export type ImportJobOut = {
failed_count: number;
error_message: string | null;
result_collection_id: string | null;
// Set whenever this job finished having created exactly one Print, whatever its type -- see
// importJobRunner.ts. Lets the frontend redirect straight into that print's details page instead
// of the models grid.
result_print_id: string | null;
};
export function toImportJobOut(job: ImportJob): ImportJobOut {
@@ -399,6 +403,7 @@ export function toImportJobOut(job: ImportJob): ImportJobOut {
failed_count: job.failedCount,
error_message: job.errorMessage,
result_collection_id: job.resultCollectionId,
result_print_id: job.resultPrintId,
};
}
+8
View File
@@ -128,6 +128,10 @@ export async function runCollectionImportJob(jobId: string, userId: string, body
status: "DONE",
sourceLabel: collectionTitle,
resultCollectionId,
// Only when this is unambiguous -- see resultPrintId's doc comment on the schema. A batch
// job that happens to succeed on just one item still deserves "open that one model" over
// "open the (now single-item) collection it also landed in".
resultPrintId: successPrintIds.length === 1 ? successPrintIds[0] : null,
processed,
imported,
alreadyInLibrary,
@@ -239,6 +243,7 @@ async function runThingiverseThingsImportJob(
status: "DONE",
sourceLabel: collectionTitle,
resultCollectionId,
resultPrintId: successPrintIds.length === 1 ? successPrintIds[0] : null,
processed,
imported,
alreadyInLibrary,
@@ -365,6 +370,7 @@ export async function runPrintablesCollectionImportJob(jobId: string, userId: st
status: "DONE",
sourceLabel: collectionTitle,
resultCollectionId,
resultPrintId: successPrintIds.length === 1 ? successPrintIds[0] : null,
processed,
imported,
alreadyInLibrary,
@@ -431,6 +437,8 @@ export async function runZipImportJob(jobId: string, userId: string, body: ZipIm
processed: body.entries.length,
imported: prints.length,
failedCount: failed.length,
// Only when this is unambiguous -- see resultPrintId's doc comment on the schema.
resultPrintId: prints.length === 1 ? prints[0].id : null,
});
void createLog({
userId,
+5
View File
@@ -47,6 +47,11 @@ export type ImportJob = {
failed_count: number;
error_message: string | null;
result_collection_id: string | null;
// Set only for a "ZIP" job (a single link that turned out to need the zip-entry picker) that
// finished having created exactly one Print -- see the backend's runZipImportJob. Used to
// redirect into that print's edit mode, mirroring what a synchronous single-link import
// already does (see useUploadImport's openForEditing).
result_print_id: string | null;
};
type ImportLinkPayload = {
@@ -1,5 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import type React from "react";
import { useNavigate } from "react-router-dom";
import { importsApi, type ImportJob } from "../../api/imports";
import { UnauthorizedError } from "../../api/client";
@@ -37,6 +38,7 @@ export function ImportJobProvider({
onJobCompleted?: () => void;
children: React.ReactNode;
}) {
const navigate = useNavigate();
const [activeJob, setActiveJob] = useState<ImportJob | null>(null);
const pollRef = useRef<number | null>(null);
const onJobCompletedRef = useRef(onJobCompleted);
@@ -59,12 +61,29 @@ export function ImportJobProvider({
stopPolling();
setActiveJob(null);
onJobCompletedRef.current?.();
// Any finished import lands the viewer somewhere useful: exactly one resulting print (see
// the backend's resultPrintId -- set for a "ZIP" job, or a "COLLECTION" batch job that
// happened to succeed on just one item) opens straight to its details page, matching
// useUploadImport's openForViewing for a synchronous single-link import. Not edit mode: an
// import arrives with real metadata already, unlike a plain upload. More than one result
// has no single "it" to open, so it goes to the models grid instead -- and nothing goes
// anywhere for a job that imported nothing at all (every item failed).
if (job.status === "DONE") {
if (job.result_print_id) {
navigate(`/models/${job.result_print_id}`);
} else if (job.imported + job.already_in_library > 1) {
// job.imported alone undercounts a batch where some items were dedup hits (already in
// the library) rather than newly imported -- those still landed a real print each, and
// still count toward "more than one result" the same as a fresh import would.
navigate("/models");
}
}
} catch (err) {
stopPolling();
setActiveJob(null);
if (err instanceof UnauthorizedError) onUnauthorized?.();
}
}, [onUnauthorized, stopPolling]);
}, [onUnauthorized, stopPolling, navigate]);
const startPolling = useCallback((jobId: string) => {
stopPolling();
@@ -136,13 +136,19 @@ 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.
// Sends the user straight into editing a just-uploaded model (per spec: a plain upload has no
// title/notes/tags/category yet, so drop into edit mode immediately to fill them 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}`);
};
// A provider import, by contrast, arrives with real metadata already (title, creator, preview
// images, ...) -- just open its details page, not the edit form.
const openForViewing = (print: Print) => {
navigate(`/models/${print.id}`);
};
const uploadFlatAsMultiplate = async (files: File[]) => {
try {
const result = await printsApi.upload(files, { category_id: categoryId || undefined, mode: "multiplate" });
@@ -386,7 +392,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);
openForViewing(imported);
return;
}
@@ -395,7 +401,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);
openForViewing(imported);
return;
}
setImporting(false);
@@ -406,7 +412,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);
openForViewing(imported);
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
+13 -3
View File
@@ -1,3 +1,5 @@
import type { Theme } from "@mui/material/styles";
// Display info for a Print's import source (Print.source_provider from the backend -- see
// importService.ts's identifySourceModel). Same brand colors UserMenu already uses for its
// MakerWorld connection chip, kept here so the model card badge and "Open in {Provider}" menu
@@ -6,8 +8,9 @@
// `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 };
// care which kind a given provider uses. `textColor` may also be an sx-style theme callback,
// for "thingport"'s dark-mode override below.
export type ImportProviderInfo = { label: string; color: string; textColor?: string | ((theme: Theme) => string) };
export const IMPORT_PROVIDER_INFO: Record<string, ImportProviderInfo> = {
makerworld: { label: "MakerWorld", color: "#00B800" },
@@ -17,7 +20,14 @@ export const IMPORT_PROVIDER_INFO: Record<string, ImportProviderInfo> = {
// 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" },
// Dark mode overrides to a flat white instead of following background.paper (a dark navy
// there) -- text.primary's dark-mode chip background is a muted grey, not the near-black light
// mode gets, so paper's own dark tone reads as too low-contrast against it.
thingport: {
label: "Thingport",
color: "text.primary",
textColor: (theme) => (theme.palette.mode === "dark" ? "#FFFFFF" : theme.palette.background.paper),
},
};
/** Looks up a KNOWN external provider only -- null for anything else, including a plain upload
+14 -5
View File
@@ -24,7 +24,6 @@ import { useToast } from "../../components/ToastProvider";
import { importProviderInfo, printProviderInfo } from "../../constants/importProviders";
import { authorProfileUrl } from "../../utils/authorProfileUrl";
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
import { dividerBorderColor } from "../../theme";
import ModelCard from "../ModelsPage/ModelCard";
const PAGE_SIZE = 24;
@@ -231,19 +230,29 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized, viewer,
}}
/>
<Box sx={{ p: 3 }}>
<Box sx={{ pl: 3, pr: 3 }}>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "320px 1fr" },
gap: "32px",
alignItems: "start",
// "stretch" (the grid default, but spelled out since the divider below depends on it)
// -- so the shorter profile column's box is exactly as tall as the models grid next to
// it, and its border-right runs the full height instead of stopping wherever the
// profile column's own (usually much shorter) content ends.
alignItems: "stretch",
pt: 3,
pb: 3
}}
>
<Box
sx={{
borderRight: { xs: "none", md: "1px solid" },
borderColor: dividerBorderColor,
// Unlike every other light-mode-only border in the app (see dividerBorderColor),
// this one stays visible in dark mode too -- the same plain `divider` token the
// sidebar's own Download/Administration separator renders unconditionally, rather
// than the sidebar's own outer edge (which dividerBorderColor hides in dark mode).
borderColor: "divider",
pr: { xs: 0, md: 4 },
}}
>
@@ -321,7 +330,7 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized, viewer,
</Typography>
</Box>
<Box sx={{ minWidth: 0 }}>
<Box sx={{ minWidth: 0, pt: 3, pb: 3 }}>
<Typography variant="h6" fontWeight={700} sx={{ mb: 2 }}>{t("models:pageTitle")}</Typography>
{items.length ? (
<Stack spacing={2}>