fix: make storage template collection and creator tokens reflect real data

{collection} read a free-text Print.collection field nothing ever set, so it
always rendered "Uncollected". It now uses the model's real collections: the
collection's name, "Multiple collections" when it's in several, or
"Uncollected" when it's in none. Adding or removing a model, and renaming or
deleting a collection, moves the files when the template uses {collection}.

{creator} now resolves to the author the model page shows, falling back to
the uploader's display name for their own uploads, and an author reset moves
the files. {name} (always the same as {model}) is rewritten to {model}.

The unused Print.collection column is dropped, and the admin Storage
Structure page explains each token.
This commit is contained in:
Tautvydas Derzinskas committed 2026-09-27 20:54:06 +02:00
1 parent 9dd3e693e0
commit 32710f25e7
13 files changed
+267 -44

No files matched your search

@@ -0,0 +1,4 @@
-- Print.collection was a free-text field that nothing in the app ever set, left over from before
-- real collections (Collection/CollectionItem) existed. The storage template's {collection} token
-- read it, so that token always rendered "Uncollected"; it now reads real collection membership.
ALTER TABLE "Print" DROP COLUMN "collection";
-1
View File
@@ -126,7 +126,6 @@ model Print {
title String?
notes String?
creator String?
collection String?
tags String[] @default([])
viewCount Int @default(0)
printCount Int @default(0)
-2
View File
@@ -111,7 +111,6 @@ export type PrintOut = {
notes: string | null;
creator: string | null;
author: AuthorOut | null;
collection: string | null;
tags: string[];
category_id: string | null;
// Only populated by the single-print detail fetch (printOutById) -- see toPrintOut's `category`
@@ -285,7 +284,6 @@ export function toPrintOut(
notes: print.notes,
creator: print.creator,
author: author ? toAuthorOut(author) : null,
collection: print.collection,
tags: print.tags,
category_id: print.categoryId,
category_name: category?.name ?? null,
+13
View File
@@ -17,6 +17,7 @@ import {
} from "../services/collectionService";
import { addCollectionBookmark, listBookmarkedCollectionIdSet, removeCollectionBookmark } from "../services/bookmarkService";
import { printOutsByIds } from "../services/printLoader";
import { relocatePrintsForToken } from "../services/printService";
import { createLog } from "../services/auditLog";
import { toCollectionOut, type PrintOut } from "../dto";
@@ -31,6 +32,13 @@ const collectionSchema = z.object({
const COVER_ITEM_LIMIT = 4;
/** The collection's models, for moving their files once its name or its existence stops being
* what their {collection} folder was built from. */
async function collectionPrintIds(collectionId: string): Promise<string[]> {
const items = await prisma.collectionItem.findMany({ where: { collectionId }, select: { printId: true } });
return items.map((item) => item.printId);
}
router.get(
"/collections",
asyncHandler(async (req, res) => {
@@ -116,6 +124,7 @@ router.patch(
const collection = await prisma.collection.findFirst({ where: { id: req.params.id, userId: req.userId } });
if (!collection) throw new HttpError(404, "Collection not found");
await assertCollectionNameAvailable(req.userId!, body.name, collection.id);
const renamed = body.name !== collection.name;
const updated = await prisma.collection.update({
where: { id: collection.id },
data: {
@@ -125,6 +134,7 @@ router.patch(
tags: normalizeTags(body.tags),
},
});
if (renamed) await relocatePrintsForToken("collection", await collectionPrintIds(updated.id));
const [itemCount, bookmarked] = await Promise.all([
prisma.collectionItem.count({ where: { collectionId: updated.id } }),
prisma.bookmark.findFirst({ where: { userId: req.userId, type: "COLLECTION", collectionId: updated.id } }),
@@ -147,7 +157,9 @@ router.delete(
}
const collection = await prisma.collection.findFirst({ where: { id: req.params.id, userId: req.userId } });
if (!collection) throw new HttpError(404, "Collection not found");
const printIds = await collectionPrintIds(collection.id);
await prisma.collection.delete({ where: { id: collection.id } });
await relocatePrintsForToken("collection", printIds);
res.json({ ok: true });
void createLog({
userId: req.userId!,
@@ -173,6 +185,7 @@ router.delete(
const print = await prisma.print.findFirst({ where: { id: req.params.printId, userId: req.userId } });
if (!print) throw new HttpError(404, "Print not found");
await prisma.collectionItem.deleteMany({ where: { collectionId: collection.id, printId: print.id } });
await relocatePrintsForToken("collection", [print.id]);
res.json({ ok: true });
void createLog({
userId: req.userId!,
+2 -4
View File
@@ -10,7 +10,7 @@ import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import { modelUpload } from "../uploadMiddleware";
import { createPrint, deletePlateFiles, resolvePlateFilePath, type NewPlateInput } from "../services/printCreation";
import { plateThumbPath, relocatePrint, uniqueModelName } from "../services/printService";
import { plateThumbPath, relocatePrint, relocatePrintsForToken, uniqueModelName } from "../services/printService";
import { previewImagePath, deleteAllPreviewImages } from "../services/previewImageService";
import { deleteAuthorIfOrphaned, getLinkedAuthorIds } from "../services/authorService";
import { toPrintOut } from "../dto";
@@ -98,7 +98,6 @@ async function buildPrintWhere(req: Request): Promise<Prisma.PrintWhereInput> {
{ title: { contains: q, mode: "insensitive" } },
{ notes: { contains: q, mode: "insensitive" } },
{ creator: { contains: q, mode: "insensitive" } },
{ collection: { contains: q, mode: "insensitive" } },
],
});
}
@@ -482,7 +481,6 @@ const metaSchema = z.object({
title: z.string().optional(),
notes: z.string().optional(),
creator: z.string().optional(),
collection: z.string().optional(),
});
router.post(
"/print/:id/meta",
@@ -503,7 +501,6 @@ router.post(
}
if (body.notes !== undefined) data.notes = body.notes;
if (body.creator !== undefined) data.creator = body.creator.trim() || null;
if (body.collection !== undefined) data.collection = body.collection.trim() || null;
const updated = await prisma.print.update({ where: { id: print.id }, data });
const plates = await prisma.plate.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } });
@@ -554,6 +551,7 @@ router.post(
data: { authorId: null, creator: null, sourceProvider: null, sourceExternalId: null },
});
if (print.authorId) await deleteAuthorIfOrphaned(print.authorId);
await relocatePrintsForToken("creator", [print.id]);
res.json({ print: await printOutById(req.userId!, print.id) });
void createLog({ userId: req.userId!, action: "model_edited", targetId: print.id, details: { field: "author_reset", name: print.name } });
}),
+4 -1
View File
@@ -1,6 +1,7 @@
import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { printOutsByIds } from "./printLoader";
import { relocatePrintsForToken } from "./printService";
import { toSystemCollectionOut, type CollectionOut, type SystemCollectionKey } from "../dto";
import type { Collection, Prisma } from "@prisma/client";
@@ -49,7 +50,8 @@ export async function findOrCreateCollectionByName(userId: string, name: string)
}
/** Adds `printIds` to a collection, skipping any already present, continuing the display
* position from wherever the collection's items currently leave off. */
* position from wherever the collection's items currently leave off -- then moves their files if
* the storage template has a {collection} folder. */
export async function addPrintsToCollection(collectionId: string, printIds: string[]): Promise<void> {
if (!printIds.length) return;
const last = await prisma.collectionItem.findFirst({
@@ -61,6 +63,7 @@ export async function addPrintsToCollection(collectionId: string, printIds: stri
data: printIds.map((printId) => ({ collectionId, printId, position: nextPosition++ })),
skipDuplicates: true,
});
await relocatePrintsForToken("collection", printIds);
}
// ---- Built-in "Favourites" / "Browsing History" pseudo-collections ----------------------------
+1 -3
View File
@@ -41,7 +41,6 @@ export type PrintMetaInput = {
tags?: string[];
categoryId?: string | null;
creator?: string | null;
collection?: string | null;
/** Id of an already-upserted Author row (see authorService.ts), e.g. "makerworld:12345". */
authorId?: string | null;
/** Set when this print was resolved from a known provider's model URL (see
@@ -128,7 +127,7 @@ export async function refreshAutoPreparedMetadata(printId: string): Promise<void
type CreatedPlate = { record: Plate; effectivePath: string | null };
async function createPlateAtPosition(
print: Pick<Print, "id" | "name" | "creator" | "collection" | "tags" | "categoryId" | "userId">,
print: Pick<Print, "id" | "name" | "creator" | "tags" | "categoryId" | "userId" | "authorId" | "sourceProvider">,
input: NewPlateInput,
position: number,
): Promise<CreatedPlate> {
@@ -191,7 +190,6 @@ export async function createPrint(
title: meta.title ?? null,
notes: meta.notes ?? null,
creator: meta.creator?.trim() || null,
collection: meta.collection?.trim() || null,
tags: normalizeTags(meta.tags || []),
categoryId: meta.categoryId ?? null,
authorId: meta.authorId ?? null,
+116 -26
View File
@@ -14,12 +14,22 @@ export const STORAGE_TEMPLATE_TOKENS = [
"tags",
"creator",
"model",
"name",
"filename",
"id",
"plate",
] as const;
// Older names still accepted in a saved template, and rewritten to the current one whenever the
// template is validated -- so the admin page shows (and the next save stores) the new name.
// {name} was always the same value as {model}.
const LEGACY_TOKEN_ALIASES: Record<string, (typeof STORAGE_TEMPLATE_TOKENS)[number]> = {
name: "model",
};
/** The {collection} folder for a model in no collection, and for one in more than one. */
export const NO_COLLECTION_FOLDER = "Uncollected";
export const MULTIPLE_COLLECTIONS_FOLDER = "Multiple collections";
export const DEFAULT_STORAGE_TEMPLATE = "{category}/{model}/{filename}";
const TOKEN_RE = /\{([a-z_]+)\}/g;
@@ -34,7 +44,11 @@ export function sanitizePathSegment(value: string | null | undefined, fallback:
}
export function validateStorageTemplate(template: string | null | undefined): string {
const normalized = (template || "").trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
const normalized = (template || "")
.trim()
.replace(/\\/g, "/")
.replace(/^\/+|\/+$/g, "")
.replace(TOKEN_RE, (match, token: string) => (token in LEGACY_TOKEN_ALIASES ? `{${LEGACY_TOKEN_ALIASES[token]}}` : match));
if (!normalized) throw new HttpError(400, "Storage template cannot be empty");
const tokens = [...normalized.matchAll(TOKEN_RE)].map((m) => m[1]);
const unknown = tokens.filter((t) => !(STORAGE_TEMPLATE_TOKENS as readonly string[]).includes(t));
@@ -84,7 +98,75 @@ function assertWithinStorage(relative: string): string {
return relative;
}
type PrintLike = Pick<Print, "id" | "name" | "creator" | "collection" | "tags" | "categoryId" | "userId">;
type PrintLike = Pick<
Print,
"id" | "name" | "creator" | "tags" | "categoryId" | "userId" | "authorId" | "sourceProvider"
>;
function templateUses(template: string, token: (typeof STORAGE_TEMPLATE_TOKENS)[number]): boolean {
return template.includes(`{${token}}`);
}
/** {creator}: the author the model page shows (ModelSidePanel): the imported Author's name or handle, else
* the plain-text creator an import stored, else -- for the owner's own upload, which has no
* source -- the owner. */
async function creatorLabel(print: PrintLike): Promise<string | null> {
if (print.authorId) {
const author = await prisma.author.findUnique({ where: { id: print.authorId }, select: { name: true, handle: true } });
if (author?.name || author?.handle) return author.name || author.handle;
}
if (print.creator) return print.creator;
if (print.sourceProvider) return null;
const owner = await prisma.user.findUnique({ where: { id: print.userId }, select: { displayName: true } });
return owner?.displayName ?? null;
}
/** A model can be in any number of collections; a folder can only hold it once. */
async function collectionLabel(printId: string): Promise<string> {
const items = await prisma.collectionItem.findMany({
where: { printId },
select: { collection: { select: { name: true } } },
take: 2,
});
if (items.length === 0) return NO_COLLECTION_FOLDER;
if (items.length > 1) return MULTIPLE_COLLECTIONS_FOLDER;
return items[0].collection.name;
}
type PrintTemplateValues = Record<string, string>;
/** Every token's value for one print, other than the per-plate {filename}/{plate}. Tokens that
* need their own query are only looked up when the template uses them. */
async function printTemplateValues(print: PrintLike, template: string): Promise<PrintTemplateValues> {
const tagLabel = (print.tags || []).map((t) => t.trim()).filter(Boolean).join(" + ") || "Untagged";
return {
category: templateUses(template, "category") ? (await categorySegments(print.userId, print.categoryId)).join("/") : "",
collection: templateUses(template, "collection") ? sanitizePathSegment(await collectionLabel(print.id), NO_COLLECTION_FOLDER) : "",
tags: sanitizePathSegment(tagLabel, "Untagged"),
creator: templateUses(template, "creator") ? sanitizePathSegment(await creatorLabel(print), "Unknown creator") : "",
model: sanitizePathSegment(print.name, "Model"),
id: print.id,
};
}
function renderPlatePath(
print: PrintLike,
template: string,
printValues: PrintTemplateValues,
plateFilename: string,
platePosition: number,
): string {
const values = {
...printValues,
filename: sanitizePathSegment(plateFilename, "file"),
plate: String(platePosition + 1),
};
const rendered = renderTemplate(template, values).replace(/\\/g, "/");
const parts = rendered.split("/").filter(Boolean).map((part) => sanitizePathSegment(part, "item"));
if (!parts.length) throw new HttpError(400, "Storage template produced an empty path");
const userSegment = sanitizePathSegment(`u-${print.userId}`, "user");
return assertWithinStorage(path.join(userSegment, ...parts));
}
/** Renders the on-disk relative path for one plate of a print using the storage template.
* Every user's files live under their own u-<userId> segment beneath the (instance-wide)
@@ -96,24 +178,8 @@ export async function renderPlateStoragePath(
template?: string | null,
): Promise<string> {
const safeTemplate = validateStorageTemplate(template ?? (await getStorageTemplate()));
const tagLabel = (print.tags || []).map((t) => t.trim()).filter(Boolean).join(" + ") || "Untagged";
const modelLabel = sanitizePathSegment(print.name, "Model");
const values: Record<string, string> = {
category: (await categorySegments(print.userId, print.categoryId)).join("/"),
collection: sanitizePathSegment(print.collection || "Uncollected", "Uncollected"),
tags: sanitizePathSegment(tagLabel, "Untagged"),
creator: sanitizePathSegment(print.creator || "Unknown creator", "Unknown creator"),
model: modelLabel,
name: modelLabel,
filename: sanitizePathSegment(plateFilename, "file"),
id: print.id,
plate: String(platePosition + 1),
};
const rendered = renderTemplate(safeTemplate, values).replace(/\\/g, "/");
const parts = rendered.split("/").filter(Boolean).map((part) => sanitizePathSegment(part, "item"));
if (!parts.length) throw new HttpError(400, "Storage template produced an empty path");
const userSegment = sanitizePathSegment(`u-${print.userId}`, "user");
return assertWithinStorage(path.join(userSegment, ...parts));
const printValues = await printTemplateValues(print, safeTemplate);
return renderPlatePath(print, safeTemplate, printValues, plateFilename, platePosition);
}
export function samplePlateStoragePaths(template: string): [string, string] {
@@ -124,7 +190,6 @@ export function samplePlateStoragePaths(template: string): [string, string] {
tags: "Print in place + Useful",
creator: "Example creator",
model: "Multi-part gadget",
name: "Multi-part gadget",
id: "a1b2c3d4",
};
const first = renderTemplate(safeTemplate, { ...base, filename: "Base.3mf", plate: "1" });
@@ -224,14 +289,17 @@ export async function pruneEmptyStorageDirs(start: string): Promise<void> {
}
/**
* Re-renders and (if needed) moves every plate of a print after a name/category/tag/creator/
* collection change. Mirrors MakersVault's relocate_asset, generalized to N plates. Supporting
* and prepared PrintFiles are never touched here — they live at a fixed bundles/ path.
* Re-renders and (if needed) moves every plate of a print after a change to anything its path
* is built from: name, category, tags, creator, collections. Mirrors MakersVault's relocate_asset,
* generalized to N plates. Supporting and prepared PrintFiles are never touched here — they live
* at a fixed bundles/ path.
*/
export async function relocatePrint(print: PrintLike, plates: Plate[], template?: string | null): Promise<void> {
const safeTemplate = validateStorageTemplate(template ?? (await getStorageTemplate()));
const printValues = await printTemplateValues(print, safeTemplate);
for (const plate of plates) {
const oldPath = managedPlatePath(plate);
const newRelative = await renderPlateStoragePath(print, plate.filename, plate.position, template);
const newRelative = renderPlatePath(print, safeTemplate, printValues, plate.filename, plate.position);
const newPath = path.join(STORAGE, newRelative);
if (path.resolve(oldPath) === path.resolve(newPath)) {
if (plate.storagePath !== newRelative) {
@@ -251,6 +319,28 @@ export async function relocatePrint(print: PrintLike, plates: Plate[], template?
}
}
/** Moves the given prints' files after a change that only matters when the storage template
* uses `token` -- a collection membership change, say, moves nothing under the default
* {category}/{model}/{filename}. Best-effort per print, like reorganizeManagedPrints: the change
* itself has already been saved, and one print's file collision shouldn't undo it or block the
* others. */
export async function relocatePrintsForToken(
token: (typeof STORAGE_TEMPLATE_TOKENS)[number],
printIds: string[],
): Promise<void> {
if (!printIds.length) return;
const template = validateStorageTemplate(await getStorageTemplate());
if (!templateUses(template, token)) return;
const prints = await prisma.print.findMany({ where: { id: { in: printIds } }, include: { plates: true } });
for (const print of prints) {
try {
await relocatePrint(print, print.plates, template);
} catch (err) {
console.error(`Couldn't move the files of print ${print.id} after a {${token}} change`, err);
}
}
}
/** Re-lays-out managed print storage. Pass `userId` for a category rename/delete affecting only
* that user's own prints; omit it for an instance-wide storage-template change (admin-only --
* see requireAdmin on POST /settings/storage), which needs to touch every user's files. */
+102
View File
@@ -0,0 +1,102 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { STORAGE } from "../src/config";
import { prisma } from "../src/db";
import { getStorageTemplate, setStorageTemplate, validateStorageTemplate } from "../src/services/printService";
const app = createApp();
const stamp = Date.now();
let token: string;
// The template is instance-wide and every test file shares one database (see vitest.config.ts),
// so it's put back afterwards.
let previousTemplate: string;
const auth = () => ({ Authorization: `Bearer ${token}` });
function tmpFile(name: string, contents: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "thingport-test-"));
const p = path.join(dir, name);
fs.writeFileSync(p, contents);
return p;
}
async function upload(name: string): Promise<string> {
const res = await request(app).post("/api/upload").set(auth()).attach("files", tmpFile(`${name}.stl`, `solid ${name} endsolid`));
expect(res.status).toBe(200);
return res.body.prints[0].id;
}
/** The plate's path below the user's own u-<id> folder, after checking the file really is there. */
async function platePath(printId: string): Promise<string> {
const plate = await prisma.plate.findFirstOrThrow({ where: { printId } });
expect(fs.existsSync(path.join(STORAGE, plate.storagePath))).toBe(true);
return plate.storagePath.split(path.sep).slice(1).join("/");
}
async function createCollection(name: string): Promise<string> {
const res = await request(app).post("/api/collections").set(auth()).send({ name });
expect(res.status).toBe(200);
return res.body.id;
}
beforeAll(async () => {
previousTemplate = await getStorageTemplate();
await setStorageTemplate("{collection}/{creator}/{model}/{filename}");
const res = await request(app)
.post("/api/register")
.send({ displayName: `Storage Tester ${stamp}`, email: `storage-template-${stamp}@example.com`, password: "password123" });
token = res.body.token;
});
afterAll(async () => {
await setStorageTemplate(previousTemplate);
});
describe("legacy tokens", () => {
it("rewrites {name} to {model}", () => {
expect(validateStorageTemplate("{creator}/{name}/{filename}")).toBe("{creator}/{model}/{filename}");
});
});
describe("{collection} and {creator} folders", () => {
it("follow a model's real collections as it's added, removed, and its collection renamed or deleted", async () => {
const printId = await upload(`collection-moves-${stamp}`);
const model = `collection-moves-${stamp}`;
const author = `Storage Tester ${stamp}`;
expect(await platePath(printId)).toBe(`Uncollected/${author}/${model}/${model}.stl`);
const first = await createCollection(`First ${stamp}`);
expect((await request(app).post(`/api/collection/${first}/items/${printId}`).set(auth())).status).toBe(200);
expect(await platePath(printId)).toBe(`First ${stamp}/${author}/${model}/${model}.stl`);
const second = await createCollection(`Second ${stamp}`);
await request(app).post(`/api/collection/${second}/items/${printId}`).set(auth());
expect(await platePath(printId)).toBe(`Multiple collections/${author}/${model}/${model}.stl`);
await request(app).delete(`/api/collection/${second}/items/${printId}`).set(auth());
expect(await platePath(printId)).toBe(`First ${stamp}/${author}/${model}/${model}.stl`);
await request(app).patch(`/api/collection/${first}`).set(auth()).send({ name: `Renamed ${stamp}` });
expect(await platePath(printId)).toBe(`Renamed ${stamp}/${author}/${model}/${model}.stl`);
await request(app).delete(`/api/collection/${first}`).set(auth());
expect(await platePath(printId)).toBe(`Uncollected/${author}/${model}/${model}.stl`);
});
it("uses an imported model's author, and the uploader's name once the author is reset", async () => {
const printId = await upload(`author-reset-${stamp}`);
const model = `author-reset-${stamp}`;
const authorId = `makerworld:storage-${stamp}`;
await prisma.author.create({ data: { id: authorId, provider: "makerworld", externalId: `storage-${stamp}`, name: "Imported Maker" } });
await prisma.print.update({ where: { id: printId }, data: { authorId, creator: "Imported Maker", sourceProvider: "makerworld" } });
await request(app).post(`/api/print/${printId}/tags`).set(auth()).send({ tags: ["x"] }); // any edit re-renders the path
expect(await platePath(printId)).toBe(`Uncollected/Imported Maker/${model}/${model}.stl`);
expect((await request(app).post(`/api/print/${printId}/author-reset`).set(auth())).status).toBe(200);
expect(await platePath(printId)).toBe(`Uncollected/Storage Tester ${stamp}/${model}/${model}.stl`);
});
});
-2
View File
@@ -61,7 +61,6 @@ export type Print = {
notes?: string | null;
creator?: string | null;
author?: Author | null;
collection?: string | null;
tags: string[];
category_id?: string | null;
// Only populated by GET /print/:id (the detail page) -- list endpoints don't join it.
@@ -346,7 +345,6 @@ export const printsApi = {
title?: string | null;
notes?: string | null;
creator?: string | null;
collection?: string | null;
}) => {
const res = await fetch(`${apiBase()}/print/${id}/meta`, {
method: "POST",
+11 -1
View File
@@ -297,7 +297,17 @@
"savedApplied": "Saved. Moved {{moved}} file(s){{skippedSuffix}}.",
"skippedSuffix": "; skipped {{skipped}}",
"savedNoApply": "Storage structure saved for new and updated models.",
"failed": "Failed to save storage settings."
"failed": "Failed to save storage settings.",
"tokens": {
"category": "The model's category, with parent categories as parent folders",
"collection": "The collection the model is in. Models in no collection go to \"Uncollected\", models in several go to \"Multiple collections\"",
"tags": "The model's tags, joined with \" + \"",
"creator": "The author shown on the model page; a user's own uploads use their display name",
"model": "The model's name",
"filename": "The file's name (required, in the last folder)",
"id": "The model's permanent ID",
"plate": "The file's position in the model, starting at 1"
}
},
"registrations": {
"allowLabel": "Allow new registrations",
+11 -1
View File
@@ -298,7 +298,17 @@
"savedApplied": "Išsaugota. Perkelta {{moved}} failų{{skippedSuffix}}.",
"skippedSuffix": "; praleista {{skipped}}",
"savedNoApply": "Saugojimo struktūra išsaugota naujiems ir atnaujintiems modeliams.",
"failed": "Nepavyko išsaugoti saugojimo nustatymų."
"failed": "Nepavyko išsaugoti saugojimo nustatymų.",
"tokens": {
"category": "Modelio kategorija; tėvinės kategorijos tampa tėviniais aplankais",
"collection": "Kolekcija, kuriai priklauso modelis. Modeliai be kolekcijos patenka į „Uncollected“, o esantys keliose – į „Multiple collections“",
"tags": "Modelio žymos, sujungtos „ + “",
"creator": "Modelio puslapyje rodomas autorius; naudotojo įkeltiems modeliams naudojamas jo rodomas vardas",
"model": "Modelio pavadinimas",
"filename": "Failo pavadinimas (privalomas, paskutiniame aplanke)",
"id": "Nekintantis modelio ID",
"plate": "Failo eilės numeris modelyje, pradedant nuo 1"
}
},
"registrations": {
"allowLabel": "Leisti naujas registracijas",
@@ -8,6 +8,7 @@ import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Checkbox from "@mui/material/Checkbox";
import FormControlLabel from "@mui/material/FormControlLabel";
import Tooltip from "@mui/material/Tooltip";
import { UnauthorizedError } from "../../api/client";
import { settingsApi } from "../../api/settings";
import SectionHeader from "../../components/SectionHeader";
@@ -25,7 +26,6 @@ const PLATE_PREVIEW_VALUES: Record<string, string>[] = [
tags: "Print in place + Useful",
creator: "Example creator",
model: "Cable clip",
name: "Cable clip",
filename: "Cable clip.3mf",
id: "a1b2c3d4",
plate: "1",
@@ -36,7 +36,6 @@ const PLATE_PREVIEW_VALUES: Record<string, string>[] = [
tags: "Print in place + Useful",
creator: "Example creator",
model: "Cable clip",
name: "Cable clip",
filename: "Cable clip-2.3mf",
id: "a1b2c3d4",
plate: "2",
@@ -148,8 +147,8 @@ export default function StorageSection({ onUnauthorized }: Props) {
/>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{storageTokens.map(token => (
<Tooltip key={token} title={t(`adminSettings.storage.tokens.${token}`, { defaultValue: "" })}>
<Button
key={token}
size="small"
variant="outlined"
sx={{ fontFamily: "monospace", textTransform: "none" }}
@@ -165,6 +164,7 @@ export default function StorageSection({ onUnauthorized }: Props) {
>
{`{${token}}`}
</Button>
</Tooltip>
))}
</Stack>
<Paper variant="outlined" sx={{ p: 1.5, borderStyle: "dashed" }}>