feat: pick mapped provider categories by name

Each provider gets a Manage button that opens its categories as chips, by name, instead of a field of ids. The names come from a catalog checked in with the app, refreshed by backend/scripts/syncProviderCategories.ts. Stored values keep their shape.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-20 00:01:04 +02:00
1 parent 0aff9e9a9e
commit 87db3f0ff0
9 files changed
+8687 -131

No files matched your search

+195
View File
@@ -0,0 +1,195 @@
// Refreshes frontend/src/components/categoryMapping/providerCategories.json: every provider's own category tree, as id, name and parent, so
// the category mapping dialog can show names instead of ids. Run from backend/ when a site changes its categories:
//
// npx tsx scripts/syncProviderCategories.ts
//
// Thingiverse and MyMiniFactory list their categories only with an API key, so it reads this instance's (from the
// database in backend/.env, as the app does). The rest are public.
import { writeFileSync } from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { browserFetch } from "../src/utils/browserFetch";
import { getMyminifactoryApiKey, getThingiverseAccessToken } from "../src/services/settingsService";
import { prisma } from "../src/db";
type Entry = { id: string; name: string; parent: string | null };
type Node = Record<string, unknown>;
const OUT = path.resolve(__dirname, "../../frontend/src/components/categoryMapping/providerCategories.json");
const isNode = (value: unknown): value is Node => !!value && typeof value === "object" && !Array.isArray(value);
const nodes = (value: unknown): Node[] => (Array.isArray(value) ? value.filter(isNode) : []);
const text = (value: unknown) => (typeof value === "string" ? value.trim() : "");
/** Walks a nested tree, depth first, in the site's own order; ids seen before are skipped. */
function flatten(
roots: Node[],
read: (node: Node) => { id: unknown; name: unknown; children: unknown },
parent: string | null = null,
out: Entry[] = [],
seen = new Set<string>(),
): Entry[] {
for (const node of roots) {
const { id, name, children } = read(node);
const key = id == null ? "" : String(id);
const label = text(name);
if (!key || !label || seen.has(key)) continue;
seen.add(key);
out.push({ id: key, name: label, parent });
flatten(nodes(children), read, key, out, seen);
}
return out;
}
async function json(
url: string,
init: { method?: "GET" | "POST"; body?: string; headers?: Record<string, string> } = {},
) {
const res = await browserFetch(url, { ...init, headers: { Accept: "application/json", ...init.headers } });
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
return res.json() as Promise<unknown>;
}
async function nextData(url: string): Promise<Node> {
const res = await browserFetch(url, { headers: { Accept: "text/html" } });
const html = await res.text();
const m = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
if (!m) throw new Error(`${url}: no page data (HTTP ${res.status})`);
return JSON.parse(m[1]) as Node;
}
const FETCHERS: Record<string, () => Promise<Entry[]>> = {
async makerworld() {
const data = await nextData("https://makerworld.com/en/3d-models");
const tree = (data.props as Node)?.pageProps as Node;
return flatten(nodes((tree.categoryTree as Node)?.children), (n) => ({
id: n.id,
name: n.name,
children: n.children,
}));
},
async thingiverse() {
const token = await getThingiverseAccessToken();
if (!token) throw new Error("no Thingiverse App Token on this instance");
const headers = { Authorization: `Bearer ${token}` };
const top = nodes(await json("https://api.thingiverse.com/categories", { headers }));
const out: Entry[] = [];
for (const category of top) {
const id = String(category.id);
out.push({ id, name: text(category.name), parent: null });
const detail = await json(`https://api.thingiverse.com/categories/${text(category.slug)}`, { headers });
for (const child of nodes(isNode(detail) ? detail.children : null)) {
out.push({ id: String(child.id), name: text(child.name), parent: id });
}
}
return out;
},
async printables() {
const data = await json("https://api.printables.com/graphql/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: "{ categories { id name path { id } } }" }),
});
const categories = nodes(((data as Node).data as Node)?.categories);
return categories.map((c) => {
const trail = nodes(c.path);
return {
id: String(c.id),
name: text(c.name),
parent: trail.length > 1 ? String(trail[trail.length - 2].id) : null,
};
});
},
async myminifactory() {
const key = await getMyminifactoryApiKey();
if (!key) throw new Error("no MyMiniFactory API key on this instance");
// Pages its top-level categories, 30 at a time unless asked for more.
const data = await json(
`https://www.myminifactory.com/api/v2/categories?key=${encodeURIComponent(key)}&per_page=1000`,
);
return flatten(nodes((data as Node).items), (n) => ({
id: n.id,
name: n.name,
children: isNode(n.children) ? n.children.items : n.children,
}));
},
async snapmaker() {
const data = await json("https://api-models.snapmaker.com/api/model/category/tree");
// Its one root, "Categories", isn't a category of its own.
const root = nodes((data as Node).data)[0];
return flatten(nodes(root?.children), (n) => ({ id: n.id, name: n.nameKey, children: n.children }));
},
async makeronline() {
const data = await json("https://www.makeronline.com/api/category/options");
return flatten(nodes((data as Node).data), (n) => ({ id: n.id, name: n.name, children: n.children }));
},
async crealitycloud() {
const data = await json("https://www.crealitycloud.com/api/cxy/v2/common/categoryList", {
method: "POST",
headers: {
"Content-Type": "application/json",
Origin: "https://www.crealitycloud.com",
__cxy_app_id_: "cxy-gen2",
__cxy_app_ver_: "7.3.28",
__cxy_app_ch_: "Chrome 152.0.0.0",
__cxy_brand_: "creality",
__cxy_platform_: "2",
__cxy_os_lang_: "0",
__cxy_os_ver_: "macOS 10.15.7",
__cxy_timezone_: "0",
__cxy_duid_: randomUUID(),
__cxy_requestid_: randomUUID(),
},
body: JSON.stringify({ type: 7 }),
});
const list = ((data as Node).result as Node)?.list;
return flatten(nodes(list), (n) => ({ id: n.id, name: n.en_name || n.name, children: n.children }));
},
async nexprint() {
const data = await json("https://www.nexprint.com/gateway/api/v1/model-library-server/model-classification/tree", {
headers: { "client-id": "Nexprint", "user-lang": "en" },
});
return flatten(nodes((data as Node).data), (n) => ({
id: n.id,
name: n.classificationNameEn || n.name,
children: n.children,
}));
},
async thangs() {
// Every page carries the tree; a missing one is the lightest.
const data = await nextData("https://thangs.com/3d-models");
const appData = (data.props as Node)?.appData as Node;
const root = (appData?.["categories/root"] as Node)?.categories;
const out: Entry[] = [];
for (const category of nodes(root)) {
const name = text(category.name);
out.push({ id: name, name, parent: null });
for (const sub of Array.isArray(category.subcategories) ? category.subcategories : []) {
if (typeof sub === "string" && sub !== "All") out.push({ id: `${name}/${sub}`, name: sub, parent: name });
}
}
return out;
},
};
(async () => {
const catalog: Record<string, Entry[]> = {};
let failed = false;
for (const [provider, fetch] of Object.entries(FETCHERS)) {
try {
catalog[provider] = await fetch();
console.log(`${provider}: ${catalog[provider].length} categories`);
} catch (err) {
failed = true;
console.error(`${provider}: failed --`, err instanceof Error ? err.message : err);
}
}
await prisma.$disconnect();
if (failed) {
console.error("Not written: every provider has to come through, or its names would be lost.");
process.exit(1);
}
writeFileSync(OUT, `${JSON.stringify(catalog, null, 2)}\n`);
console.log(`Wrote ${path.relative(process.cwd(), OUT)}`);
})();
+3 -2
View File
@@ -8,8 +8,9 @@ Each model can sit in one **category** or one **folder**, both nested as deep as
top of the Models page.
- **Categories** sort models by what they are, such as Tools > Measuring or Toys & Games > Puzzles. Imported models are
placed by the source site's own category, through the provider category IDs each of your categories matches. The
optional [AI categorization](../guides/ai.md) can sort the rest.
placed by the source site's own category: each of your categories lists, per site, which of that site's categories
land in it. Change them from a category's details, with **Manage** next to each site, by picking the site's
categories by name. The optional [AI categorization](../guides/ai.md) can sort the rest.
- **Folders** are your own tree, the way you'd lay it out on disk. [Uploading a folder](../guides/upload-folders.md)
keeps its structure as folders.
@@ -0,0 +1,125 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import TuneIcon from "@mui/icons-material/Tune";
import { IMPORT_PROVIDER_INFO } from "../../constants/importProviders";
import { useDisabledProviders } from "../../hooks/useDisabledProviders";
import type { ImportProvider } from "../../api/settings";
import { ProviderLogo } from "../Layout/ConfigurationDialog/providerLogos";
import ProviderCategoryPicker from "./ProviderCategoryPicker";
import { indexCatalog, joinCatIds, loadProviderCatalog, splitCatIds, type CatalogCategory } from "./providerCatalog";
/** The providers a category can take imports from, in the order the rows show them. */
export const MAPPED_PROVIDERS = [
"makerworld",
"printables",
"thingiverse",
"myminifactory",
"snapmaker",
"makeronline",
"crealitycloud",
"nexprint",
"thangs",
] as const satisfies readonly ImportProvider[];
export type MappedProvider = (typeof MAPPED_PROVIDERS)[number];
type Props = {
/** Each provider's stored ids, ";"-separated. */
value: Record<MappedProvider, string>;
onChange: (provider: MappedProvider, value: string) => void;
/** The Thingport category being mapped. */
categoryName: string;
disabled?: boolean;
};
/** One row per provider: what its imports bring into this category, by name, and a button to change it. Providers an
* admin turned off are hidden; their mapping is kept. */
export default function ProviderCategoryMappings({ value, onChange, categoryName, disabled }: Props) {
const { t } = useTranslation("models");
const disabledProviders = useDisabledProviders();
const [indexes, setIndexes] = useState<Record<string, Map<string, CatalogCategory>> | null>(null);
const [editing, setEditing] = useState<MappedProvider | null>(null);
useEffect(() => {
let active = true;
loadProviderCatalog()
.then((catalog) => {
if (!active) return;
setIndexes(Object.fromEntries(Object.entries(catalog).map(([id, entries]) => [id, indexCatalog(entries)])));
})
// The names are a nicety here: without them the rows still count, and the picker says what went wrong.
.catch(() => undefined);
return () => {
active = false;
};
}, []);
const shown = MAPPED_PROVIDERS.filter((provider) => !disabledProviders.has(provider));
return (
<>
<Box sx={{ border: 1, borderColor: "divider", borderRadius: "12px", overflow: "hidden" }}>
{shown.map((provider, i) => {
const ids = splitCatIds(value[provider]);
const names = ids.map(
(id) => indexes?.[provider]?.get(id)?.name ?? t("categories.manager.mapping.unknown", { id }),
);
const name = IMPORT_PROVIDER_INFO[provider].label;
return (
<Box key={provider}>
{i > 0 && <Divider />}
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ px: 1.5, py: 1 }}>
<Box sx={{ color: "text.primary", flexShrink: 0 }}>
<ProviderLogo id={provider} size={22} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="body2" fontWeight={600} noWrap>
{name}
</Typography>
<Typography
variant="caption"
color={ids.length ? "text.secondary" : "text.disabled"}
noWrap
component="p"
title={names.join(", ")}
>
{ids.length ? names.join(", ") : t("categories.manager.mapping.none")}
</Typography>
</Box>
<Button
size="small"
variant={ids.length ? "contained" : "outlined"}
disableElevation
startIcon={<TuneIcon fontSize="small" />}
onClick={() => setEditing(provider)}
disabled={disabled}
aria-label={t("categories.manager.mapping.manageLabel", { provider: name, count: ids.length })}
sx={{ flexShrink: 0, whiteSpace: "nowrap" }}
>
{t("categories.manager.mapping.manage", { count: ids.length })}
</Button>
</Stack>
</Box>
);
})}
</Box>
{editing && (
<ProviderCategoryPicker
provider={editing}
providerName={IMPORT_PROVIDER_INFO[editing].label}
categoryName={categoryName}
selected={splitCatIds(value[editing])}
onClose={() => setEditing(null)}
onDone={(ids) => {
onChange(editing, joinCatIds(ids));
setEditing(null);
}}
/>
)}
</>
);
}
@@ -0,0 +1,240 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Chip from "@mui/material/Chip";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import InputAdornment from "@mui/material/InputAdornment";
import Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import { keyframes } from "@mui/material/styles";
import CheckIcon from "@mui/icons-material/Check";
import SearchIcon from "@mui/icons-material/Search";
import { ProviderLogo, type ProviderId } from "../Layout/ConfigurationDialog/providerLogos";
import { SectionLabel } from "../Layout/ConfigurationDialog/parts";
import { indexCatalog, loadProviderCatalog, type CatalogCategory } from "./providerCatalog";
type Props = {
provider: ProviderId;
providerName: string;
/** The Thingport category being mapped, for the title. */
categoryName: string;
selected: string[];
onClose: () => void;
onDone: (ids: string[]) => void;
};
const appear = keyframes`
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: none; }
`;
const chipMotion = {
transition: "background-color 150ms ease, color 150ms ease, border-color 150ms ease",
"@media (prefers-reduced-motion: reduce)": { transition: "none", animation: "none" },
} as const;
/** A provider's categories as chips to pick, grouped under their top-level ones, with search. Picks are kept here
* until Done; ids the catalog doesn't know (a category the site has since dropped) stay picked, shown by id. */
export default function ProviderCategoryPicker({
provider,
providerName,
categoryName,
selected: initial,
onClose,
onDone,
}: Props) {
const { t } = useTranslation(["models", "common"]);
const [index, setIndex] = useState<Map<string, CatalogCategory> | null>(null);
const [failed, setFailed] = useState(false);
const [selected, setSelected] = useState(initial);
const [query, setQuery] = useState("");
useEffect(() => {
let active = true;
loadProviderCatalog()
.then((catalog) => active && setIndex(indexCatalog(catalog[provider] ?? [])))
.catch(() => active && setFailed(true));
return () => {
active = false;
};
}, [provider]);
const picked = new Set(selected);
const toggle = (id: string) =>
setSelected((current) => (current.includes(id) ? current.filter((x) => x !== id) : [...current, id]));
// Top-level categories in the site's order, each with everything under it that matches the search. A top-level
// one that matches brings all of its own.
const groups = useMemo(() => {
if (!index) return [];
const needle = query.trim().toLowerCase();
const matches = (category: CatalogCategory) => category.path.join(" ").toLowerCase().includes(needle);
const all = [...index.values()];
return all
.filter((category) => category.id === category.root)
.flatMap((root) => {
const children = all.filter((category) => category.root === root.id && category.id !== root.id);
const shown = !needle || matches(root) ? children : children.filter(matches);
return !needle || matches(root) || shown.length ? [{ root, children: shown }] : [];
});
}, [index, query]);
const labelOf = (id: string) => {
const category = index?.get(id);
return category ? category.name : t("categories.manager.mapping.unknown", { id });
};
return (
<Dialog open onClose={onClose} fullWidth maxWidth="sm" scroll="paper">
<DialogTitle sx={{ pb: 1 }}>
<Stack direction="row" alignItems="center" spacing={1.25}>
<ProviderLogo id={provider} size={22} />
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" fontWeight={600} noWrap>
{t("categories.manager.mapping.pickerTitle", { provider: providerName })}
</Typography>
<Typography variant="caption" color="text.secondary" component="p" noWrap>
{t("categories.manager.mapping.pickerSubtitle", { provider: providerName, category: categoryName })}
</Typography>
</Box>
</Stack>
<TextField
size="small"
fullWidth
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("categories.manager.mapping.search", { provider: providerName }) ?? undefined}
// oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus
sx={{ mt: 1.5 }}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
},
htmlInput: { "aria-label": t("categories.manager.mapping.search", { provider: providerName }) },
}}
/>
</DialogTitle>
<DialogContent dividers>
{selected.length > 0 && (
<Box sx={{ mb: 2 }}>
<SectionLabel>{t("categories.manager.mapping.selected", { count: selected.length })}</SectionLabel>
<Stack direction="row" flexWrap="wrap" gap={0.75}>
{selected.map((id) => (
<Chip
key={id}
size="small"
color="primary"
label={labelOf(id)}
title={index?.get(id)?.path.join(" › ")}
onDelete={() => toggle(id)}
sx={{ ...chipMotion, animation: `${appear} 150ms ease` }}
/>
))}
</Stack>
</Box>
)}
{failed ? (
<Typography variant="body2" color="error">
{t("categories.manager.mapping.loadFailed")}
</Typography>
) : !index ? (
<Stack spacing={2} aria-busy="true">
{[0, 1, 2].map((row) => (
<Box key={row}>
<Skeleton variant="text" width="30%" />
<Stack direction="row" gap={0.75} flexWrap="wrap">
{[64, 88, 72, 96, 56].map((width) => (
<Skeleton key={width} variant="rounded" width={width} height={24} sx={{ borderRadius: 4 }} />
))}
</Stack>
</Box>
))}
</Stack>
) : groups.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 3, textAlign: "center" }}>
{query.trim()
? t("categories.manager.mapping.noMatches", { query: query.trim() })
: t("categories.manager.mapping.empty", { provider: providerName })}
</Typography>
) : (
<Stack spacing={2}>
{groups.map(({ root, children }) => (
<Box key={root.id}>
<CategoryChip label={root.name} selected={picked.has(root.id)} onClick={() => toggle(root.id)} strong />
{children.length > 0 && (
<Stack
direction="row"
flexWrap="wrap"
gap={0.75}
sx={{ mt: 0.75, pl: 1.5, borderLeft: 2, borderColor: "divider" }}
>
{children.map((category) => (
<CategoryChip
key={category.id}
label={category.name}
// Deeper ones say what they sit under.
title={category.path.length > 2 ? category.path.slice(1).join(" › ") : undefined}
selected={picked.has(category.id)}
onClick={() => toggle(category.id)}
/>
))}
</Stack>
)}
</Box>
))}
</Stack>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setSelected([])} disabled={!selected.length} sx={{ mr: "auto" }}>
{t("categories.manager.mapping.clearAll")}
</Button>
<Button onClick={onClose}>{t("common:cancel")}</Button>
<Button variant="contained" onClick={() => onDone(selected)}>
{t("categories.manager.mapping.done")}
</Button>
</DialogActions>
</Dialog>
);
}
function CategoryChip({
label,
title,
selected,
onClick,
strong = false,
}: {
label: string;
title?: string;
selected: boolean;
onClick: () => void;
strong?: boolean;
}) {
return (
<Chip
size="small"
label={label}
title={title}
clickable
onClick={onClick}
aria-pressed={selected}
color={selected ? "primary" : "default"}
variant={selected ? "filled" : "outlined"}
icon={selected ? <CheckIcon /> : undefined}
sx={{ ...chipMotion, ...(strong && { fontWeight: 700 }) }}
/>
);
}
@@ -0,0 +1,50 @@
// Every provider's own category tree, generated by backend/scripts/syncProviderCategories.ts. Loaded only when the
// mapping is edited: it's large, MyMiniFactory's alone near a thousand.
export type CatalogEntry = { id: string; name: string; parent: string | null };
export type Catalog = Record<string, CatalogEntry[]>;
/** One of a provider's categories, with where it sits: its top-level category, and its names from the top. */
export type CatalogCategory = CatalogEntry & { root: string; path: string[] };
let loading: Promise<Catalog> | null = null;
export function loadProviderCatalog(): Promise<Catalog> {
loading ??= import("./providerCategories.json").then((module) => module.default as Catalog);
return loading;
}
/** A provider's categories by id, each knowing its top-level category and path. */
export function indexCatalog(entries: CatalogEntry[]): Map<string, CatalogCategory> {
const byId = new Map(entries.map((entry) => [entry.id, entry]));
const index = new Map<string, CatalogCategory>();
for (const entry of entries) {
const path: string[] = [];
let current: CatalogEntry | undefined = entry;
let root = entry.id;
// Guards against a loop in the data, which would never end.
for (let depth = 0; current && depth < 10; depth++) {
path.unshift(current.name);
root = current.id;
current = current.parent ? byId.get(current.parent) : undefined;
}
index.set(entry.id, { ...entry, root, path });
}
return index;
}
/** The stored form, "800;71;1001" (or Thangs' category paths), as ids. */
export function splitCatIds(value: string): string[] {
return [
...new Set(
value
.split(";")
.map((id) => id.trim())
.filter(Boolean),
),
];
}
export function joinCatIds(ids: string[]): string {
return ids.join(";");
}
File diff suppressed because it is too large. Load diff
+18 -14
View File
@@ -2768,20 +2768,24 @@
"metaDialogTitle": "Details — {{name}}",
"metaTitleLabel": "Meta title",
"metaDescriptionLabel": "Meta description",
"makerworldCatIdLabel": "MakerWorld category IDs",
"thingiverseCatIdLabel": "Thingiverse category IDs",
"printablesCatIdLabel": "Printables category IDs",
"myminifactoryCatIdLabel": "MyMiniFactory category IDs",
"snapmakerCatIdLabel": "Snapmaker Space category IDs",
"makeronlineCatIdLabel": "MakerOnline category IDs",
"crealitycloudCatIdLabel": "Creality Cloud category IDs",
"nexprintCatIdLabel": "NexPrint category IDs",
"thangsCatIdLabel": "Thangs category paths",
"thangsCatPathsPlaceholder": "Toys & Games/Puzzles;Toys & Games",
"thangsCatPathsHelp": "Thangs names categories rather than numbering them: separate paths with ;",
"catIdsPlaceholder": "e.g. 800;71;1001",
"catIdsHelp": "Separate multiple IDs with \";\" -- matches if an import's category is any of these",
"dragToMove": "Drag to move"
"dragToMove": "Drag to move",
"mapping": {
"heading": "Source categories",
"help": "Models imported from these categories on each site land in \"{{category}}\". Pick them with Manage.",
"none": "None",
"manage": "Manage ({{count}})",
"manageLabel": "Manage {{provider}} categories, {{count}} picked",
"pickerTitle": "{{provider}} categories",
"pickerSubtitle": "Imports from the ones you pick land in \"{{category}}\"",
"search": "Search {{provider}} categories",
"selected": "Picked ({{count}})",
"clearAll": "Clear all",
"done": "Done",
"noMatches": "No categories match \"{{query}}\".",
"empty": "No categories known for {{provider}}.",
"loadFailed": "Couldn't load the category list. Close this and try again.",
"unknown": "Unknown #{{id}}"
}
}
},
"view": {
+18 -14
View File
@@ -2843,20 +2843,24 @@
"metaDialogTitle": "Informacija — {{name}}",
"metaTitleLabel": "Meta pavadinimas",
"metaDescriptionLabel": "Meta aprašymas",
"makerworldCatIdLabel": "MakerWorld kategorijų ID",
"thingiverseCatIdLabel": "Thingiverse kategorijų ID",
"printablesCatIdLabel": "Printables kategorijų ID",
"myminifactoryCatIdLabel": "MyMiniFactory kategorijų ID",
"snapmakerCatIdLabel": "Snapmaker Space kategorijų ID",
"makeronlineCatIdLabel": "MakerOnline kategorijų ID",
"crealitycloudCatIdLabel": "Creality Cloud kategorijų ID",
"nexprintCatIdLabel": "NexPrint kategorijų ID",
"thangsCatIdLabel": "Thangs kategorijų keliai",
"thangsCatPathsPlaceholder": "Toys & Games/Puzzles;Toys & Games",
"thangsCatPathsHelp": "Thangs kategorijas įvardija, o ne numeruoja: kelius atskirkite ;",
"catIdsPlaceholder": "pvz. 800;71;1001",
"catIdsHelp": "Kelis ID atskirkite „;“ -- atitiks, jei importo kategorija sutampa su bet kuriuo iš jų",
"dragToMove": "Vilkite, kad perkeltumėte"
"dragToMove": "Vilkite, kad perkeltumėte",
"mapping": {
"heading": "Šaltinių kategorijos",
"help": "Iš šių kiekvienos svetainės kategorijų importuoti modeliai pateks į „{{category}}“. Pasirinkite jas mygtuku Tvarkyti.",
"none": "Nėra",
"manage": "Tvarkyti ({{count}})",
"manageLabel": "Tvarkyti {{provider}} kategorijas, pasirinkta {{count}}",
"pickerTitle": "{{provider}} kategorijos",
"pickerSubtitle": "Importai iš pasirinktų pateks į „{{category}}“",
"search": "Ieškoti {{provider}} kategorijų",
"selected": "Pasirinkta ({{count}})",
"clearAll": "Išvalyti viską",
"done": "Atlikta",
"noMatches": "Jokia kategorija neatitinka „{{query}}“.",
"empty": "{{provider}} kategorijų sąrašas nežinomas.",
"loadFailed": "Nepavyko įkelti kategorijų sąrašo. Uždarykite ir bandykite dar kartą.",
"unknown": "Nežinoma #{{id}}"
}
}
},
"view": {
@@ -11,6 +11,11 @@ import Button from "@mui/material/Button";
import Alert from "@mui/material/Alert";
import CircularProgress from "@mui/material/CircularProgress";
import type { Category, CategoryMetaInput } from "../../api/categories";
import HelpTip from "../../components/HelpTip";
import { SectionLabel } from "../../components/Layout/ConfigurationDialog/parts";
import ProviderCategoryMappings, {
type MappedProvider,
} from "../../components/categoryMapping/ProviderCategoryMappings";
type Props = {
category: Category;
@@ -20,22 +25,24 @@ type Props = {
onSave: (meta: CategoryMetaInput) => Promise<void>;
};
/** Blank fields clear the metadata. Imports whose site category id matches any listed id
* (";"-separated) land in this category. Validation errors show inline and keep the edits. */
/** Blank fields clear the metadata. Imports whose site category matches one picked for its provider land in this
* category. Validation errors show inline and keep the edits. */
export default function CategoryMetaDialog({ category, showSiteCatIds, onClose, onSave }: Props) {
const { t } = useTranslation(["models", "common"]);
const untitledLabel = t("models:categories.untitled");
const [title, setTitle] = useState(category.meta_title ?? "");
const [description, setDescription] = useState(category.meta_description ?? "");
const [makerworldCatIds, setMakerworldCatIds] = useState(category.makerworld_cat_ids);
const [thingiverseCatIds, setThingiverseCatIds] = useState(category.thingiverse_cat_ids);
const [printablesCatIds, setPrintablesCatIds] = useState(category.printables_cat_ids);
const [myminifactoryCatIds, setMyminifactoryCatIds] = useState(category.myminifactory_cat_ids);
const [snapmakerCatIds, setSnapmakerCatIds] = useState(category.snapmaker_cat_ids);
const [makeronlineCatIds, setMakeronlineCatIds] = useState(category.makeronline_cat_ids);
const [crealitycloudCatIds, setCrealitycloudCatIds] = useState(category.crealitycloud_cat_ids);
const [nexprintCatIds, setNexprintCatIds] = useState(category.nexprint_cat_ids);
const [thangsCatIds, setThangsCatIds] = useState(category.thangs_cat_ids);
const [catIds, setCatIds] = useState<Record<MappedProvider, string>>({
makerworld: category.makerworld_cat_ids,
thingiverse: category.thingiverse_cat_ids,
printables: category.printables_cat_ids,
myminifactory: category.myminifactory_cat_ids,
snapmaker: category.snapmaker_cat_ids,
makeronline: category.makeronline_cat_ids,
crealitycloud: category.crealitycloud_cat_ids,
nexprint: category.nexprint_cat_ids,
thangs: category.thangs_cat_ids,
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -46,15 +53,15 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
await onSave({
metaTitle: title.trim() || null,
metaDescription: description.trim() || null,
makerworldCatIds,
thingiverseCatIds,
printablesCatIds,
myminifactoryCatIds,
snapmakerCatIds,
makeronlineCatIds,
crealitycloudCatIds,
nexprintCatIds,
thangsCatIds,
makerworldCatIds: catIds.makerworld,
thingiverseCatIds: catIds.thingiverse,
printablesCatIds: catIds.printables,
myminifactoryCatIds: catIds.myminifactory,
snapmakerCatIds: catIds.snapmaker,
makeronlineCatIds: catIds.makeronline,
crealitycloudCatIds: catIds.crealitycloud,
nexprintCatIds: catIds.nexprint,
thangsCatIds: catIds.thangs,
});
onClose();
} catch (err) {
@@ -90,87 +97,17 @@ export default function CategoryMetaDialog({ category, showSiteCatIds, onClose,
disabled={saving}
/>
{showSiteCatIds && (
// Two to a row, one column on a phone.
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" }, gap: 2 }}>
<TextField
label={t("models:categories.manager.makerworldCatIdLabel")}
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
helperText={t("models:categories.manager.catIdsHelp")}
fullWidth
value={makerworldCatIds}
onChange={(e) => setMakerworldCatIds(e.target.value)}
disabled={saving}
/>
<TextField
label={t("models:categories.manager.thingiverseCatIdLabel")}
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
helperText={t("models:categories.manager.catIdsHelp")}
fullWidth
value={thingiverseCatIds}
onChange={(e) => setThingiverseCatIds(e.target.value)}
disabled={saving}
/>
<TextField
label={t("models:categories.manager.printablesCatIdLabel")}
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
helperText={t("models:categories.manager.catIdsHelp")}
fullWidth
value={printablesCatIds}
onChange={(e) => setPrintablesCatIds(e.target.value)}
disabled={saving}
/>
<TextField
label={t("models:categories.manager.myminifactoryCatIdLabel")}
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
helperText={t("models:categories.manager.catIdsHelp")}
fullWidth
value={myminifactoryCatIds}
onChange={(e) => setMyminifactoryCatIds(e.target.value)}
disabled={saving}
/>
<TextField
label={t("models:categories.manager.snapmakerCatIdLabel")}
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
helperText={t("models:categories.manager.catIdsHelp")}
fullWidth
value={snapmakerCatIds}
onChange={(e) => setSnapmakerCatIds(e.target.value)}
disabled={saving}
/>
<TextField
label={t("models:categories.manager.makeronlineCatIdLabel")}
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
helperText={t("models:categories.manager.catIdsHelp")}
fullWidth
value={makeronlineCatIds}
onChange={(e) => setMakeronlineCatIds(e.target.value)}
disabled={saving}
/>
<TextField
label={t("models:categories.manager.crealitycloudCatIdLabel")}
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
helperText={t("models:categories.manager.catIdsHelp")}
fullWidth
value={crealitycloudCatIds}
onChange={(e) => setCrealitycloudCatIds(e.target.value)}
disabled={saving}
/>
<TextField
label={t("models:categories.manager.nexprintCatIdLabel")}
placeholder={t("models:categories.manager.catIdsPlaceholder") ?? undefined}
helperText={t("models:categories.manager.catIdsHelp")}
fullWidth
value={nexprintCatIds}
onChange={(e) => setNexprintCatIds(e.target.value)}
disabled={saving}
/>
<TextField
label={t("models:categories.manager.thangsCatIdLabel")}
placeholder={t("models:categories.manager.thangsCatPathsPlaceholder") ?? undefined}
helperText={t("models:categories.manager.thangsCatPathsHelp")}
fullWidth
value={thangsCatIds}
onChange={(e) => setThangsCatIds(e.target.value)}
<Box>
<SectionLabel>
{t("models:categories.manager.mapping.heading")}
<HelpTip
text={t("models:categories.manager.mapping.help", { category: category.name || untitledLabel })}
/>
</SectionLabel>
<ProviderCategoryMappings
value={catIds}
onChange={(provider, value) => setCatIds((current) => ({ ...current, [provider]: value }))}
categoryName={category.name || untitledLabel}
disabled={saving}
/>
</Box>