From f187b9f25ec2cadf12ca03430c601ec0077b27bc Mon Sep 17 00:00:00 2001 From: Tautvydas Derzinskas Date: Sat, 12 Sep 2026 17:27:32 +0200 Subject: [PATCH] fix: minor adjustments in regards of import redirects and authors --- .../migration.sql | 2 ++ backend/prisma/schema.prisma | 7 +++++++ backend/src/dto.ts | 5 +++++ backend/src/services/importJobRunner.ts | 8 +++++++ frontend/src/api/imports.ts | 5 +++++ .../components/Layout/ImportJobContext.tsx | 21 ++++++++++++++++++- .../components/uploads/useUploadImport.tsx | 18 ++++++++++------ frontend/src/constants/importProviders.ts | 16 +++++++++++--- frontend/src/pages/AuthorPage/index.tsx | 19 ++++++++++++----- 9 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 backend/prisma/migrations/20260912010000_add_import_job_result_print/migration.sql diff --git a/backend/prisma/migrations/20260912010000_add_import_job_result_print/migration.sql b/backend/prisma/migrations/20260912010000_add_import_job_result_print/migration.sql new file mode 100644 index 0000000..d3218e8 --- /dev/null +++ b/backend/prisma/migrations/20260912010000_add_import_job_result_print/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ImportJob" ADD COLUMN "resultPrintId" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 6f70003..0b12df7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/dto.ts b/backend/src/dto.ts index 012a5b8..ffe5406 100644 --- a/backend/src/dto.ts +++ b/backend/src/dto.ts @@ -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, }; } diff --git a/backend/src/services/importJobRunner.ts b/backend/src/services/importJobRunner.ts index 265ce0f..3d9fd80 100644 --- a/backend/src/services/importJobRunner.ts +++ b/backend/src/services/importJobRunner.ts @@ -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, diff --git a/frontend/src/api/imports.ts b/frontend/src/api/imports.ts index 7cda793..42c9911 100644 --- a/frontend/src/api/imports.ts +++ b/frontend/src/api/imports.ts @@ -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 = { diff --git a/frontend/src/components/Layout/ImportJobContext.tsx b/frontend/src/components/Layout/ImportJobContext.tsx index e278d9c..fe10726 100644 --- a/frontend/src/components/Layout/ImportJobContext.tsx +++ b/frontend/src/components/Layout/ImportJobContext.tsx @@ -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(null); const pollRef = useRef(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(); diff --git a/frontend/src/components/uploads/useUploadImport.tsx b/frontend/src/components/uploads/useUploadImport.tsx index 7a915bb..294aa51 100644 --- a/frontend/src/components/uploads/useUploadImport.tsx +++ b/frontend/src/components/uploads/useUploadImport.tsx @@ -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=` 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=` 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?.(); diff --git a/frontend/src/constants/importProviders.ts b/frontend/src/constants/importProviders.ts index 3494651..d67b03e 100644 --- a/frontend/src/constants/importProviders.ts +++ b/frontend/src/constants/importProviders.ts @@ -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 = { makerworld: { label: "MakerWorld", color: "#00B800" }, @@ -17,7 +20,14 @@ export const IMPORT_PROVIDER_INFO: Record = { // 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 diff --git a/frontend/src/pages/AuthorPage/index.tsx b/frontend/src/pages/AuthorPage/index.tsx index 3427cb8..357ecdc 100644 --- a/frontend/src/pages/AuthorPage/index.tsx +++ b/frontend/src/pages/AuthorPage/index.tsx @@ -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, }} /> - + @@ -321,7 +330,7 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized, viewer, - + {t("models:pageTitle")} {items.length ? (