feat: sort, bulk actions and merge for collections and tags

Collections sort by newest, name or most models; tags gain a newest sort. Both pages get select mode with merge, bookmark and delete. Merging asks which one to keep and previews the result: the kept one's new model count, what is deleted, a bookmark moving over and syncs that stop. Built-in collections sit select mode out.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-20 19:50:56 +02:00
1 parent 825e8395d8
commit a0dbba8058
23 files changed
+2038 -271

No files matched your search

+57
View File
@@ -9,10 +9,13 @@ import { asyncHandler } from "../utils/asyncHandler";
import {
addPrintsToCollection,
assertCollectionNameAvailable,
deleteCollections,
isSystemCollectionId,
listSystemCollectionOuts,
loadSystemCollectionOut,
mergeCollections,
normalizeCollectionName,
previewCollectionMerge,
systemCollectionKeyForId,
} from "../services/collectionService";
import {
@@ -113,6 +116,60 @@ router.post(
}),
);
const idsSchema = z.object({ ids: z.array(z.string()).min(1).max(1000) });
const mergeSchema = z.object({ primary_id: z.string(), ids: z.array(z.string()).min(1).max(1000) });
router.post(
"/collections/merge/preview",
asyncHandler(async (req, res) => {
const body = parseBody(idsSchema, req.body);
res.json(await previewCollectionMerge(req.userId!, body.ids));
}),
);
// `ids` are merged into `primary_id` and deleted; it may list `primary_id` too.
router.post(
"/collections/merge",
asyncHandler(async (req, res) => {
const body = parseBody(mergeSchema, req.body);
const { primary, merged, added } = await mergeCollections(req.userId!, body.primary_id, body.ids);
const [itemCount, covers, bookmarked, sync] = await Promise.all([
prisma.collectionItem.count({ where: { collectionId: primary.id } }),
coverPrints(req.userId!, primary.id),
prisma.bookmark.findFirst({ where: { userId: req.userId, type: "COLLECTION", collectionId: primary.id } }),
prisma.collectionSync.findUnique({ where: { collectionId: primary.id } }),
]);
res.json({
collection: toCollectionOut(primary, itemCount, covers, Boolean(bookmarked), sync),
merged_ids: merged.map((c) => c.id),
added,
});
void createLog({
userId: req.userId!,
action: "collections_merged",
targetId: primary.id,
details: { name: primary.name, merged: merged.map((c) => c.name), added },
});
}),
);
router.post(
"/collections/delete",
asyncHandler(async (req, res) => {
const body = parseBody(idsSchema, req.body);
const deleted = await deleteCollections(req.userId!, body.ids);
res.json({ deleted: deleted.map((c) => c.id) });
for (const collection of deleted) {
void createLog({
userId: req.userId!,
action: "collection_deleted",
targetId: collection.id,
details: { name: collection.name },
});
}
}),
);
router.get(
"/collection/:id",
asyncHandler(async (req, res) => {
+51 -32
View File
@@ -1,21 +1,20 @@
import { Router } from "express";
import { z } from "zod";
import { prisma } from "../db";
import { requireAuth } from "../auth";
import { asyncHandler } from "../utils/asyncHandler";
import { normalizeTag } from "../utils/tagNormalization";
import { parseBody } from "../utils/validate";
import { addTagBookmark, listBookmarkedTagSet, removeTagBookmark } from "../services/bookmarkService";
import { createLog } from "../services/auditLog";
import { relocatePrintsForToken } from "../services/printService";
import { HttpError } from "../utils/fileUtils";
import {
ensureTagMosaic,
listTaggedPrints,
removeTagMosaics,
tagMosaicUrl,
tagPictures,
type MosaicKind,
} from "../services/tagMosaicService";
import { deleteTags, mergeTags, previewTagMerge } from "../services/tagService";
const router = Router();
router.use(requireAuth);
@@ -27,7 +26,7 @@ function sortedBookmarks(tags: Iterable<string>): string[] {
// Every tag in the library (unlike GET /tags, which follows the grid filter).
const summaryQuery = z.object({
sort: z.enum(["popular", "name"]).catch("popular"),
sort: z.enum(["popular", "name", "newest"]).catch("popular"),
limit: z.coerce.number().int().min(1).max(100).catch(48),
offset: z.coerce.number().int().min(0).catch(0),
// Tags on fewer models are left out, so a page holds only what's shown.
@@ -45,13 +44,15 @@ router.get(
if (tag) counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
const matching = [...counts.entries()]
.filter(([, count]) => count >= minCount)
.toSorted(([a, countA], [b, countB]) =>
sort === "name"
? a.toLowerCase().localeCompare(b.toLowerCase())
: countB - countA || a.toLowerCase().localeCompare(b.toLowerCase()),
);
// Prints come newest first, so a map's insertion order is each tag's newest model; "newest" keeps it.
const byName = (a: string, b: string) => a.toLowerCase().localeCompare(b.toLowerCase());
const entries = [...counts.entries()].filter(([, count]) => count >= minCount);
const matching =
sort === "newest"
? entries
: entries.toSorted(([a, countA], [b, countB]) =>
sort === "name" ? byName(a, b) : countB - countA || byName(a, b),
);
const page = matching.slice(offset, offset + limit);
// Pictures for this page's tags alone, joined in one query; the browser picks which mosaic to show.
@@ -115,31 +116,49 @@ router.delete(
}),
);
const tagsSchema = z.object({ tags: z.array(z.string()).min(1).max(1000) });
const mergeSchema = z.object({ primary: z.string(), tags: z.array(z.string()).min(1).max(1000) });
router.post(
"/tags/merge/preview",
asyncHandler(async (req, res) => {
const body = parseBody(tagsSchema, req.body);
res.json(await previewTagMerge(req.userId!, body.tags));
}),
);
// `tags` are put under `primary` and removed; it may list `primary` too.
router.post(
"/tags/merge",
asyncHandler(async (req, res) => {
const body = parseBody(mergeSchema, req.body);
const { primary, merged, added } = await mergeTags(req.userId!, body.primary, body.tags);
res.json({ tag: primary, merged, added });
void createLog({ userId: req.userId!, action: "tags_merged", details: { name: primary, merged, added } });
}),
);
function logDeleted(userId: string, counts: Map<string, number>) {
for (const [name, models] of counts) void createLog({ userId, action: "tag_deleted", details: { name, models } });
}
router.post(
"/tags/delete",
asyncHandler(async (req, res) => {
const body = parseBody(tagsSchema, req.body);
const counts = await deleteTags(req.userId!, body.tags);
res.json({ deleted: [...counts.keys()] });
logDeleted(req.userId!, counts);
}),
);
// Takes the tag off every model of the user's that has it, in whatever case it was saved.
router.delete(
"/tags/:tag",
asyncHandler(async (req, res) => {
const tag = normalizeTag(req.params.tag);
if (!tag) throw new HttpError(400, "Tag is required");
const prints = await prisma.print.findMany({ where: { userId: req.userId }, select: { id: true, tags: true } });
const tagged = prints.filter((print) => print.tags.some((other) => normalizeTag(other) === tag));
await prisma.$transaction(
tagged.map((print) =>
prisma.print.update({
where: { id: print.id },
data: { tags: print.tags.filter((other) => normalizeTag(other) !== tag) },
}),
),
);
await removeTagBookmark(req.userId!, tag);
await removeTagMosaics(req.userId!, tag);
// Files only move when the storage template is laid out by tags.
await relocatePrintsForToken(
"tags",
tagged.map((print) => print.id),
);
void createLog({ userId: req.userId!, action: "tag_deleted", details: { name: tag, models: tagged.length } });
res.json({ models: tagged.length });
const counts = await deleteTags(req.userId!, [req.params.tag]);
res.json({ models: [...counts.values()][0] });
logDeleted(req.userId!, counts);
}),
);
+2
View File
@@ -20,6 +20,7 @@ export type LogAction =
| "collection_created"
| "collection_edited"
| "collection_deleted"
| "collections_merged"
| "collection_item_added"
| "collection_item_removed"
| "collection_sync_enabled"
@@ -27,6 +28,7 @@ export type LogAction =
| "ai_categorization_run"
| "ai_description_run"
| "tag_deleted"
| "tags_merged"
| "user_role_changed"
| "user_deleted"
| "provider_toggled"
+36
View File
@@ -1,3 +1,4 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { normalizeTag } from "../utils/tagNormalization";
@@ -51,6 +52,41 @@ export async function removeCollectionBookmark(userId: string, collectionId: str
await prisma.bookmark.deleteMany({ where: { userId, type: "COLLECTION", collectionId } });
}
/** For a merge: the first of `fromIds`' bookmarks becomes `toId`'s, keeping its place in the list, unless `toId`
* already has one. The rest go when their collections are deleted. */
export async function moveCollectionBookmark(
userId: string,
fromIds: string[],
toId: string,
db: Prisma.TransactionClient = prisma,
): Promise<void> {
if (await db.bookmark.findFirst({ where: { userId, type: "COLLECTION", collectionId: toId } })) return;
const first = await db.bookmark.findFirst({
where: { userId, type: "COLLECTION", collectionId: { in: fromIds } },
orderBy: { order: "asc" },
});
if (first) await db.bookmark.update({ where: { id: first.id }, data: { collectionId: toId } });
}
/** The same for tags, which outlive nothing to cascade from, so the other bookmarks are removed here. */
export async function moveTagBookmark(
userId: string,
fromTags: string[],
toTag: string,
db: Prisma.TransactionClient = prisma,
): Promise<void> {
const from = fromTags.map(normalizeTag);
const to = normalizeTag(toTag);
if (!(await db.bookmark.findFirst({ where: { userId, type: "TAG", tag: to } }))) {
const first = await db.bookmark.findFirst({
where: { userId, type: "TAG", tag: { in: from } },
orderBy: { order: "asc" },
});
if (first) await db.bookmark.update({ where: { id: first.id }, data: { tag: to } });
}
await db.bookmark.deleteMany({ where: { userId, type: "TAG", tag: { in: from } } });
}
export type BookmarkEntryOut =
{ id: string; type: "tag"; tag: string } | { id: string; type: "collection"; collection_id: string; name: string };
+79
View File
@@ -2,6 +2,7 @@ import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { printOutsByIds } from "./printLoader";
import { relocatePrintsForToken } from "./printService";
import { moveCollectionBookmark } from "./bookmarkService";
import { toSystemCollectionOut, type CollectionOut, type SystemCollectionKey } from "../dto";
import type { Collection, Prisma } from "@prisma/client";
@@ -116,3 +117,81 @@ export async function listSystemCollectionOuts(userId: string): Promise<Collecti
(Object.keys(SYSTEM_COLLECTIONS) as SystemCollectionKey[]).map((key) => loadSystemCollectionOut(userId, key)),
);
}
/** The user's own real collections with these ids, in the order given. A system or unknown id is an error, so a bulk
* action never quietly does less than asked. */
async function ownCollections(userId: string, ids: string[]): Promise<Collection[]> {
if (ids.some(isSystemCollectionId)) throw new HttpError(400, "Built-in collections can't be merged or deleted");
const unique = [...new Set(ids)];
const rows = await prisma.collection.findMany({ where: { id: { in: unique }, userId } });
if (rows.length !== unique.length) throw new HttpError(404, "Collection not found");
const byId = new Map(rows.map((row) => [row.id, row]));
return unique.map((id) => byId.get(id)!);
}
/** How many models the merged collection would hold: every model in any of them, once. The same whichever one is
* kept, so the dialog can switch it without asking again. */
export async function previewCollectionMerge(userId: string, ids: string[]): Promise<{ models: number }> {
const collections = await ownCollections(userId, ids);
const rows = await prisma.collectionItem.findMany({
where: { collectionId: { in: collections.map((c) => c.id) } },
distinct: ["printId"],
select: { printId: true },
});
return { models: rows.length };
}
/** Moves the others' models into `primaryId` after its own, in the others' order, then deletes the others with their
* syncs. The kept one keeps its name, description, tags and sync, and takes over a bookmark if it had none. */
export async function mergeCollections(
userId: string,
primaryId: string,
ids: string[],
): Promise<{ primary: Collection; merged: Collection[]; added: number }> {
const [primary, ...merged] = await ownCollections(userId, [primaryId, ...ids]);
if (!merged.length) throw new HttpError(400, "Pick at least one other collection to merge");
const mergedIds = merged.map((c) => c.id);
const order = new Map(mergedIds.map((id, index) => [id, index]));
const items = (
await prisma.collectionItem.findMany({
where: { collectionId: { in: mergedIds } },
orderBy: { position: "asc" },
select: { collectionId: true, printId: true },
})
).toSorted((a, b) => order.get(a.collectionId)! - order.get(b.collectionId)!);
const printIds = [...new Set(items.map((item) => item.printId))];
const added = await prisma.$transaction(async (tx) => {
const last = await tx.collectionItem.findFirst({
where: { collectionId: primary.id },
orderBy: { position: "desc" },
});
let position = (last?.position ?? -1) + 1;
const { count } = await tx.collectionItem.createMany({
data: printIds.map((printId) => ({ collectionId: primary.id, printId, position: position++ })),
skipDuplicates: true,
});
await moveCollectionBookmark(userId, mergedIds, primary.id, tx);
await tx.collection.deleteMany({ where: { id: { in: mergedIds } } });
return count;
});
await relocatePrintsForToken("collection", printIds);
return { primary, merged, added };
}
/** Deletes them together, so a model in several is moved on disk once. Their models stay. */
export async function deleteCollections(userId: string, ids: string[]): Promise<Collection[]> {
const collections = await ownCollections(userId, ids);
const collectionIds = collections.map((c) => c.id);
const items = await prisma.collectionItem.findMany({
where: { collectionId: { in: collectionIds } },
distinct: ["printId"],
select: { printId: true },
});
await prisma.collection.deleteMany({ where: { id: { in: collectionIds } } });
await relocatePrintsForToken(
"collection",
items.map((item) => item.printId),
);
return collections;
}
+101
View File
@@ -0,0 +1,101 @@
import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { normalizeTag } from "../utils/tagNormalization";
import { moveTagBookmark, removeTagBookmark } from "./bookmarkService";
import { relocatePrintsForToken } from "./printService";
import { listTaggedPrints, removeTagMosaics } from "./tagMosaicService";
function normalizedSet(tags: string[]): Set<string> {
const set = new Set(tags.map(normalizeTag));
set.delete("");
if (!set.size) throw new HttpError(400, "Tag is required");
return set;
}
/** `tags` with each of `from` swapped for `to`, or dropped without one, and no tag twice. Saved case is kept for the
* rest, as tags saved before normalization may vary. */
function rewrite(tags: string[], from: Set<string>, to: string | null): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const tag of tags) {
const next = from.has(normalizeTag(tag)) ? to : tag;
if (!next || seen.has(normalizeTag(next))) continue;
seen.add(normalizeTag(next));
result.push(next);
}
return result;
}
/** Swaps `from` for `to` (or removes them) on every model and collection of the user's, in one transaction so two
* rewrites can't each save a model's tags over the other's. Answers how many models had each of `from`, the models
* changed, and how many of those hadn't had `to`. */
async function rewriteTags(
userId: string,
from: Set<string>,
to: string | null,
): Promise<{ counts: Map<string, number>; printIds: string[]; gained: number }> {
const [prints, collections] = await Promise.all([
prisma.print.findMany({ where: { userId }, select: { id: true, tags: true } }),
prisma.collection.findMany({ where: { userId }, select: { id: true, tags: true } }),
]);
const counts = new Map([...from].map((tag) => [tag, 0]));
let gained = 0;
const tagged = prints.filter((print) => {
const own = new Set(print.tags.map(normalizeTag));
for (const tag of from) if (own.has(tag)) counts.set(tag, counts.get(tag)! + 1);
const hit = [...from].some((tag) => own.has(tag));
if (hit && to && !own.has(to)) gained++;
return hit;
});
const taggedCollections = collections.filter((c) => c.tags.some((tag) => from.has(normalizeTag(tag))));
await prisma.$transaction([
...tagged.map((print) =>
prisma.print.update({ where: { id: print.id }, data: { tags: rewrite(print.tags, from, to) } }),
),
...taggedCollections.map((c) =>
prisma.collection.update({ where: { id: c.id }, data: { tags: rewrite(c.tags, from, to) } }),
),
]);
return { counts, printIds: tagged.map((print) => print.id), gained };
}
/** Takes the tags off every model and collection that has them, with their bookmarks and tile pictures. Answers how
* many models had each. */
export async function deleteTags(userId: string, tags: string[]): Promise<Map<string, number>> {
const from = normalizedSet(tags);
const { counts, printIds } = await rewriteTags(userId, from, null);
for (const tag of from) {
await removeTagBookmark(userId, tag);
await removeTagMosaics(userId, tag);
}
// Files only move when the storage template is laid out by tags.
await relocatePrintsForToken("tags", printIds);
return counts;
}
/** How many models would carry the merged tag: every model with any of `tags`, once. The same whichever one is kept. */
export async function previewTagMerge(userId: string, tags: string[]): Promise<{ models: number }> {
const wanted = normalizedSet(tags);
const prints = await listTaggedPrints(userId);
return { models: prints.filter((print) => print.tags.some((tag) => wanted.has(normalizeTag(tag)))).length };
}
/** Puts `primary` in place of each of the others on every model and collection, then removes the others; `primary`
* takes over a bookmark if it had none. Answers the others, and how many models gained `primary`. */
export async function mergeTags(
userId: string,
primaryTag: string,
tags: string[],
): Promise<{ primary: string; merged: string[]; added: number }> {
const primary = normalizeTag(primaryTag);
if (!primary) throw new HttpError(400, "Tag is required");
const from = normalizedSet(tags);
from.delete(primary);
if (!from.size) throw new HttpError(400, "Pick at least one other tag to merge");
const { printIds, gained } = await rewriteTags(userId, from, primary);
await moveTagBookmark(userId, [...from], primary);
for (const tag of from) await removeTagMosaics(userId, tag);
await relocatePrintsForToken("tags", printIds);
return { primary, merged: [...from], added: gained };
}
+183
View File
@@ -0,0 +1,183 @@
import { beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
const app = createApp();
const stamp = Date.now();
let token: string;
let userId: string;
const auth = () => ({ Authorization: `Bearer ${token}` });
/** Unique to this run, so tags from earlier runs don't count. */
const tag = (name: string) => `${name}${stamp}`;
beforeAll(async () => {
const registered = (
await request(app)
.post("/api/register")
.send({ displayName: "Merger", email: `merge-${stamp}@example.com`, password: "password123" })
).body;
token = registered.token;
userId = registered.user.id;
});
let printCount = 0;
async function createPrint(tags: string[] = []) {
const name = `merge-print-${stamp}-${printCount++}`;
return (await prisma.print.create({ data: { userId, name, nameNormalized: name, tags } })).id;
}
async function createCollection(name: string, printIds: string[] = []): Promise<string> {
const res = await request(app)
.post("/api/collections")
.set(auth())
.send({ name: `${name} ${stamp}` });
expect(res.status).toBe(200);
if (printIds.length) {
await request(app).post(`/api/collection/${res.body.id}/items`).set(auth()).send({ print_ids: printIds });
}
return res.body.id;
}
const itemsOf = async (collectionId: string) =>
(await prisma.collectionItem.findMany({ where: { collectionId }, orderBy: { position: "asc" } })).map(
(item) => item.printId,
);
describe("merging collections", () => {
it("previews every model once, whichever is kept", async () => {
const [a, b, c] = [await createPrint(), await createPrint(), await createPrint()];
const first = await createCollection("Preview A", [a, b]);
const second = await createCollection("Preview B", [b, c]);
const res = await request(app)
.post("/api/collections/merge/preview")
.set(auth())
.send({ ids: [first, second] });
expect(res.body).toEqual({ models: 3 });
});
it("appends the others' models after the kept one's, deletes them, and moves their bookmark", async () => {
const [a, b, c, d] = [await createPrint(), await createPrint(), await createPrint(), await createPrint()];
const kept = await createCollection("Kept", [a, b]);
const other = await createCollection("Other", [b, c]);
const third = await createCollection("Third", [d]);
await request(app).post(`/api/collection/${other}/bookmark`).set(auth());
const res = await request(app)
.post("/api/collections/merge")
.set(auth())
.send({ primary_id: kept, ids: [kept, other, third] });
expect(res.status).toBe(200);
expect(res.body.collection.id).toBe(kept);
expect(res.body.collection.item_count).toBe(4);
expect(res.body.collection.bookmarked).toBe(true);
expect(res.body.added).toBe(2);
expect(res.body.merged_ids.toSorted()).toEqual([other, third].toSorted());
expect(await itemsOf(kept)).toEqual([a, b, c, d]);
expect(await prisma.collection.count({ where: { id: { in: [other, third] } } })).toBe(0);
// The models themselves stay.
expect(await prisma.print.count({ where: { id: { in: [a, b, c, d] } } })).toBe(4);
});
it("drops a merged collection's sync and keeps the kept one's name", async () => {
const kept = await createCollection("Unsynced");
const synced = await createCollection("Synced", [await createPrint()]);
await prisma.collectionSync.create({
data: { userId, collectionId: synced, provider: "printables", externalId: `x${stamp}`, sourceUrl: "https://x" },
});
const res = await request(app)
.post("/api/collections/merge")
.set(auth())
.send({ primary_id: kept, ids: [synced] });
expect(res.body.collection.name).toBe(`Unsynced ${stamp}`);
expect(res.body.collection.sync).toBeNull();
expect(await prisma.collectionSync.count({ where: { collectionId: synced } })).toBe(0);
});
it("refuses a lone collection, a built-in one, or someone else's", async () => {
const lone = await createCollection("Lone");
const merge = (body: object) => request(app).post("/api/collections/merge").set(auth()).send(body);
expect((await merge({ primary_id: lone, ids: [lone] })).status).toBe(400);
expect((await merge({ primary_id: lone, ids: ["favorites"] })).status).toBe(400);
expect((await merge({ primary_id: lone, ids: ["not-a-collection"] })).status).toBe(404);
expect(await prisma.collection.count({ where: { id: lone } })).toBe(1);
});
it("deletes several at once, keeping their models", async () => {
const print = await createPrint();
const ids = [await createCollection("Bulk 1", [print]), await createCollection("Bulk 2", [print])];
const res = await request(app).post("/api/collections/delete").set(auth()).send({ ids });
expect(res.body.deleted.toSorted()).toEqual(ids.toSorted());
expect(await prisma.collection.count({ where: { id: { in: ids } } })).toBe(0);
expect(await prisma.print.count({ where: { id: print } })).toBe(1);
});
});
describe("merging tags", () => {
const tagsOf = async (id: string) => (await prisma.print.findUniqueOrThrow({ where: { id } })).tags;
it("previews every model with any of them, once", async () => {
await createPrint([tag("Pa"), tag("Pb")]);
await createPrint([tag("Pb")]);
const res = await request(app)
.post("/api/tags/merge/preview")
.set(auth())
.send({ tags: [tag("Pa"), tag("Pb")] });
expect(res.body).toEqual({ models: 2 });
});
it("swaps the others for the kept one on models and collections, without doubling it", async () => {
const both = await createPrint([tag("Dragon"), "Other", tag("Dragons").toLowerCase()]);
const onlyOld = await createPrint([tag("Dragons")]);
const untouched = await createPrint(["Other"]);
const collection = await createCollection("Tagged");
await prisma.collection.update({ where: { id: collection }, data: { tags: [tag("Dragons")] } });
await request(app)
.post(`/api/tags/${tag("Dragons")}/bookmark`)
.set(auth());
const res = await request(app)
.post("/api/tags/merge")
.set(auth())
.send({ primary: tag("Dragon"), tags: [tag("Dragon"), tag("Dragons")] });
expect(res.status).toBe(200);
expect(res.body).toEqual({ tag: tag("Dragon"), merged: [tag("Dragons")], added: 1 });
expect(await tagsOf(both)).toEqual([tag("Dragon"), "Other"]);
expect(await tagsOf(onlyOld)).toEqual([tag("Dragon")]);
expect(await tagsOf(untouched)).toEqual(["Other"]);
expect((await prisma.collection.findUniqueOrThrow({ where: { id: collection } })).tags).toEqual([tag("Dragon")]);
const bookmarked = (await request(app).get("/api/tags/bookmarked").set(auth())).body as string[];
expect(bookmarked).toContain(tag("Dragon"));
expect(bookmarked).not.toContain(tag("Dragons"));
});
it("refuses to merge a tag into itself alone", async () => {
const res = await request(app)
.post("/api/tags/merge")
.set(auth())
.send({ primary: tag("Self"), tags: [tag("Self")] });
expect(res.status).toBe(400);
});
it("deletes several at once", async () => {
const print = await createPrint([tag("Gone1"), tag("Gone2"), "Kept"]);
const res = await request(app)
.post("/api/tags/delete")
.set(auth())
.send({ tags: [tag("Gone1"), tag("Gone2")] });
expect(res.body.deleted.toSorted()).toEqual([tag("Gone1"), tag("Gone2")].toSorted());
expect(await tagsOf(print)).toEqual(["Kept"]);
});
});
describe("sorting tags by newest", () => {
it("leads with the tag on the newest model", async () => {
await createPrint([tag("Older")]);
await new Promise((resolve) => setTimeout(resolve, 5));
await createPrint([tag("Newer")]);
const res = await request(app).get("/api/tags/summary?sort=newest&limit=100").set(auth());
const names = (res.body.items as { name: string }[]).map((item) => item.name);
expect(names.indexOf(tag("Newer"))).toBeLessThan(names.indexOf(tag("Older")));
});
});
+2
View File
@@ -71,6 +71,7 @@ export type LogAction =
| "collection_created"
| "collection_edited"
| "collection_deleted"
| "collections_merged"
| "collection_item_added"
| "collection_item_removed"
| "collection_sync_enabled"
@@ -78,6 +79,7 @@ export type LogAction =
| "ai_categorization_run"
| "ai_description_run"
| "tag_deleted"
| "tags_merged"
| "user_role_changed"
| "user_deleted"
| "provider_toggled"
+6
View File
@@ -56,3 +56,9 @@ export async function readErrorMessage(res: Response, fallback: string) {
}
return trimmed;
}
/** Like `assertOk`, but a failure carries the server's own message. */
export async function assertOkWithMessage(res: Response, fallback: string) {
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, fallback));
}
+37 -1
View File
@@ -1,5 +1,5 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk, readErrorMessage } from "./client";
import { apiBase, assertOk, assertOkWithMessage, readErrorMessage } from "./client";
import type { Print } from "./prints";
export type SystemCollectionKey = "favorites" | "history";
@@ -56,6 +56,21 @@ export type CollectionMembership = {
in_collection: boolean;
};
export type CollectionMergeResult = {
/** The kept collection, with every model now in it. */
collection: Collection;
merged_ids: string[];
/** Models that weren't in the kept collection before. */
added: number;
};
const postJson = (path: string, body: unknown) =>
fetch(`${apiBase()}${path}`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(body),
});
export const collectionsApi = {
list: async (): Promise<Collection[]> => {
const res = await fetch(`${apiBase()}/collections`, { headers: authHeaders() });
@@ -94,6 +109,27 @@ export const collectionsApi = {
assertOk(res, "Delete collection failed");
},
/** How many models a merge of these would hold: the same whichever is kept. */
previewMerge: async (ids: string[]): Promise<{ models: number }> => {
const res = await postJson("/collections/merge/preview", { ids });
await assertOkWithMessage(res, "Failed to preview the merge");
return res.json();
},
/** Moves the others' models into `primaryId` and deletes the others. */
merge: async (primaryId: string, ids: string[]): Promise<CollectionMergeResult> => {
const res = await postJson("/collections/merge", { primary_id: primaryId, ids });
await assertOkWithMessage(res, "Merge failed");
return res.json();
},
/** Their models are untouched. */
deleteMany: async (ids: string[]): Promise<{ deleted: string[] }> => {
const res = await postJson("/collections/delete", { ids });
await assertOkWithMessage(res, "Delete collections failed");
return res.json();
},
/** The print itself is untouched. */
removeItem: async (collectionId: string, printId: string): Promise<void> => {
const res = await fetch(`${apiBase()}/collection/${collectionId}/items/${printId}`, {
+30 -2
View File
@@ -1,7 +1,7 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk } from "./client";
import { apiBase, assertOk, assertOkWithMessage } from "./client";
export type TagSortMode = "popular" | "name";
export type TagSortMode = "popular" | "name" | "newest";
export type TagSummary = {
name: string;
@@ -22,6 +22,13 @@ export type TagSummaryPage = {
has_more: boolean;
};
const postJson = (path: string, body: unknown) =>
fetch(`${apiBase()}${path}`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(body),
});
export const tagsApi = {
listSummary: async (params: {
sort: TagSortMode;
@@ -50,6 +57,27 @@ export const tagsApi = {
return res.json();
},
/** How many models would carry the merged tag: the same whichever is kept. */
previewMerge: async (tags: string[]): Promise<{ models: number }> => {
const res = await postJson("/tags/merge/preview", { tags });
await assertOkWithMessage(res, "Failed to preview the merge");
return res.json();
},
/** Puts `primary` in place of the others on every model, and removes the others. `added`: models that gained it. */
merge: async (primary: string, tags: string[]): Promise<{ tag: string; merged: string[]; added: number }> => {
const res = await postJson("/tags/merge", { primary, tags });
await assertOkWithMessage(res, "Merge failed");
return res.json();
},
/** Takes each off every model that has it. */
removeMany: async (tags: string[]): Promise<{ deleted: string[] }> => {
const res = await postJson("/tags/delete", { tags });
await assertOkWithMessage(res, "Failed to delete tags");
return res.json();
},
listBookmarked: async (): Promise<string[]> => {
const res = await fetch(`${apiBase()}/tags/bookmarked`, { headers: authHeaders() });
assertOk(res, "Failed to list bookmarked tags");
+318
View File
@@ -0,0 +1,318 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import ButtonBase from "@mui/material/ButtonBase";
import CircularProgress from "@mui/material/CircularProgress";
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 Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { alpha, keyframes } from "@mui/material/styles";
import CallMergeIcon from "@mui/icons-material/CallMerge";
import CheckIcon from "@mui/icons-material/Check";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder";
import SyncDisabledIcon from "@mui/icons-material/SyncDisabled";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { UnauthorizedError } from "../api/client";
import HelpTip from "./HelpTip";
import RollingNumber from "./RollingNumber";
import { SectionLabel } from "./Layout/ConfigurationDialog/parts";
export type MergeItem = {
key: string;
name: string;
/** Models in it, or with it. */
count: number;
/** A small square picture of it. */
thumbnail: ReactNode;
bookmarked: boolean;
/** Collections only: the provider it syncs with, whose sync stops if it's merged away. */
syncProvider?: string | null;
};
type Props = {
open: boolean;
onClose: () => void;
kind: "collection" | "tag";
items: MergeItem[];
/** Every model in any of `items`, once: the merged one's count, whichever is kept. */
loadPreview: () => Promise<{ models: number }>;
/** Runs the merge; throws to keep the dialog open with the error. */
onMerge: (primary: MergeItem, others: MergeItem[]) => Promise<void>;
onUnauthorized?: () => void;
};
const fadeIn = keyframes`
from { opacity: 0; transform: translateY(3px); }
to { opacity: 1; transform: none; }
`;
const motion = { "@media (prefers-reduced-motion: reduce)": { animation: "none", transition: "none" } } as const;
/** The one with the most models: merging into it moves the fewest. */
const defaultPrimary = (items: MergeItem[]) =>
items.reduce((best, item) => (item.count > best.count ? item : best), items[0])?.key ?? "";
/** Merges several collections or tags into one the user picks, showing what each will become and what the merged one
* will hold before anything changes. */
export default function MergeDialog({ open, onClose, kind, items, loadPreview, onMerge, onUnauthorized }: Props) {
const { t, i18n } = useTranslation(["models", "common"]);
const [primaryKey, setPrimaryKey] = useState("");
const [union, setUnion] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [merging, setMerging] = useState(false);
// Fresh each open: the selection, and the models in it, may have changed since.
useEffect(() => {
if (!open) return;
let live = true;
setPrimaryKey(defaultPrimary(items));
setUnion(null);
setError(null);
loadPreview()
.then((preview) => live && setUnion(preview.models))
.catch((err: unknown) => {
if (!live) return;
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : t("models:merge.previewFailed"));
});
return () => {
live = false;
};
// Only on opening: `items` and `loadPreview` are rebuilt by every render of the page.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const primary = items.find((item) => item.key === primaryKey) ?? items[0];
const others = useMemo(() => items.filter((item) => item !== primary), [items, primary]);
if (!primary) return null;
const list = (names: string[]) => {
const quoted = names.map((name) => t("models:merge.quoted", { name }));
const shown =
quoted.length > 3 ? [...quoted.slice(0, 2), t("models:merge.andMore", { count: quoted.length - 2 })] : quoted;
return new Intl.ListFormat(i18n.language, { type: "conjunction" }).format(shown);
};
const otherNames = list(others.map((item) => item.name));
const added = union === null ? null : Math.max(0, union - primary.count);
const bookmarkMoves = !primary.bookmarked && others.some((item) => item.bookmarked);
const syncsLost = others.filter((item) => item.syncProvider);
const k = kind === "collection" ? "collections" : "tags";
const merge = async () => {
setMerging(true);
setError(null);
try {
await onMerge(primary, others);
onClose();
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : t("models:merge.failed"));
} finally {
setMerging(false);
}
};
return (
<Dialog open={open} onClose={() => !merging && onClose()} fullWidth maxWidth="sm">
<DialogTitle sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<CallMergeIcon color="primary" />
{t(`models:merge.${k}.title`, { count: items.length })}
</DialogTitle>
<DialogContent>
<Stack spacing={2.5}>
<Box>
<SectionLabel>
{t("models:merge.keep")}
<HelpTip text={t(`models:merge.${k}.keepHelp`)} />
</SectionLabel>
<Stack spacing={0.75} sx={{ maxHeight: 280, overflowY: "auto", p: "2px", m: "-2px" }}>
{items.map((item) => {
const kept = item === primary;
return (
<ButtonBase
key={item.key}
aria-pressed={kept}
disabled={merging}
onClick={() => setPrimaryKey(item.key)}
sx={(theme) => ({
display: "flex",
alignItems: "center",
gap: 1.25,
p: 0.75,
pr: 1,
borderRadius: "10px",
border: 1,
textAlign: "left",
borderColor: kept ? "primary.main" : "divider",
bgcolor: kept ? alpha(theme.palette.primary.main, 0.08) : "transparent",
boxShadow: kept ? `inset 0 0 0 1px ${theme.palette.primary.main}` : "none",
transition: "border-color 150ms, background-color 150ms, box-shadow 150ms",
"&:hover": { borderColor: kept ? "primary.main" : "text.disabled" },
"&.Mui-focusVisible": { outline: "2px solid", outlineColor: "primary.main", outlineOffset: 2 },
...motion,
})}
>
<Box
sx={{
width: 40,
height: 40,
flexShrink: 0,
borderRadius: "8px",
overflow: "hidden",
bgcolor: "action.hover",
// A card's "No preview" caption doesn't fit 40px; the empty square says as much.
"& .MuiTypography-root": { display: "none" },
// The ones going away fade back, so the kept one reads first.
opacity: kept ? 1 : 0.6,
filter: kept ? "none" : "grayscale(0.6)",
transition: "opacity 200ms, filter 200ms",
...motion,
}}
>
{item.thumbnail}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" fontWeight={kept ? 700 : 500} noWrap title={item.name}>
{item.name}
</Typography>
<Typography variant="caption" color="text.secondary" component="div" noWrap>
{t("models:collections.card.itemCount", { count: item.count })}
{item.syncProvider && ` · ${t("models:merge.syncsWith", { provider: item.syncProvider })}`}
</Typography>
</Box>
<StatusPill kept={kept} label={kept ? t("models:merge.kept") : t(`models:merge.${k}.goes`)} />
</ButtonBase>
);
})}
</Stack>
</Box>
<Box>
<SectionLabel>{t("models:merge.after")}</SectionLabel>
<Box sx={{ border: 1, borderColor: "divider", borderRadius: "10px", p: 1.5 }}>
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ flexWrap: "wrap", rowGap: 0.5 }}>
<Typography
key={primary.key}
variant="subtitle1"
fontWeight={700}
noWrap
sx={{ minWidth: 0, maxWidth: "100%", animation: `${fadeIn} 200ms ease-out`, ...motion }}
title={primary.name}
>
{primary.name}
</Typography>
{union === null && !error ? (
<Skeleton width={120} />
) : (
union !== null && (
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: "nowrap" }}>
<RollingNumber value={primary.count} /> →{" "}
<Box component="span" sx={{ color: "text.primary", fontWeight: 600 }}>
<RollingNumber value={union} />
</Box>{" "}
{t("models:merge.modelUnit", { count: union })}
{added !== null && added > 0 && (
<Box component="span" sx={{ color: "success.main", fontWeight: 600 }}>
{" · +"}
<RollingNumber value={added} /> {t("models:merge.new")}
</Box>
)}
</Typography>
)
)}
</Stack>
<Stack component="ul" spacing={0.75} sx={{ listStyle: "none", p: 0, m: 0, mt: 1.25 }}>
<Outcome icon={<DeleteOutlineIcon fontSize="small" color="error" />}>
{t(`models:merge.${k}.othersGo`, { names: otherNames, count: others.length, name: primary.name })}
</Outcome>
<Outcome icon={<LockOutlinedIcon fontSize="small" color="action" />}>
{t(`models:merge.${k}.keepsOwn`, { name: primary.name })}
</Outcome>
{bookmarkMoves && (
<Outcome icon={<BookmarkBorderIcon fontSize="small" color="primary" />}>
{t("models:merge.bookmarkMoves", { name: primary.name })}
</Outcome>
)}
</Stack>
</Box>
</Box>
{syncsLost.length > 0 && (
<Alert severity="warning" icon={<SyncDisabledIcon fontSize="inherit" />} sx={{ py: 0.25 }}>
{t("models:merge.collections.syncStops", {
names: list(syncsLost.map((item) => item.name)),
count: syncsLost.length,
})}
</Alert>
)}
{error && <Alert severity="error">{error}</Alert>}
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ color: "text.secondary" }}>
<InfoOutlinedIcon sx={{ fontSize: 16 }} />
<Typography variant="caption">{t("models:merge.cantUndo")}</Typography>
</Stack>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={merging}>
{t("common:cancel")}
</Button>
<Button
variant="contained"
onClick={() => void merge()}
disabled={merging || union === null}
startIcon={merging ? <CircularProgress size={14} color="inherit" /> : <CallMergeIcon fontSize="small" />}
sx={{ maxWidth: "70%" }}
>
<Box component="span" sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{t("models:merge.mergeInto", { name: primary.name })}
</Box>
</Button>
</DialogActions>
</Dialog>
);
}
function StatusPill({ kept, label }: { kept: boolean; label: string }) {
return (
<Box
key={String(kept)}
component="span"
sx={(theme) => ({
flexShrink: 0,
display: "inline-flex",
alignItems: "center",
gap: 0.5,
px: 1,
py: 0.25,
borderRadius: "999px",
fontSize: "0.72rem",
fontWeight: 600,
whiteSpace: "nowrap",
color: kept ? "primary.contrastText" : "error.main",
bgcolor: kept ? "primary.main" : alpha(theme.palette.error.main, 0.1),
animation: `${fadeIn} 180ms ease-out`,
...motion,
})}
>
{kept ? <CheckIcon sx={{ fontSize: 14 }} /> : <DeleteOutlineIcon sx={{ fontSize: 14 }} />}
{label}
</Box>
);
}
function Outcome({ icon, children }: { icon: ReactNode; children: ReactNode }) {
return (
<Stack component="li" direction="row" spacing={1} alignItems="flex-start">
<Box sx={{ display: "flex", mt: "1px" }}>{icon}</Box>
<Typography variant="body2">{children}</Typography>
</Stack>
);
}
@@ -0,0 +1,50 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
type Props<T extends string> = {
value: T;
options: { value: T; label: string }[];
onChange: (value: T) => void;
};
/** A row of sort choices as words, the chosen one bold in the accent colour, as the model grids have it. */
export default function SortTabs<T extends string>({ value, options, onChange }: Props<T>) {
return (
<Stack direction="row" spacing={3}>
{options.map((option) => {
const chosen = option.value === value;
return (
<Typography
key={option.value}
component="button"
type="button"
variant="body2"
aria-pressed={chosen}
onClick={() => onChange(option.value)}
sx={{
p: 0,
border: 0,
bgcolor: "transparent",
font: "inherit",
cursor: "pointer",
fontSize: 14,
fontWeight: chosen ? 700 : 500,
color: chosen ? "primary.main" : "text.secondary",
transition: "color 150ms",
"&:hover": { color: "primary.main" },
"&:focus-visible": {
outline: "2px solid",
outlineColor: "primary.main",
outlineOffset: 2,
borderRadius: 0.5,
},
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
}}
>
{option.label}
</Typography>
);
})}
</Stack>
);
}
+81 -2
View File
@@ -1251,7 +1251,9 @@
"sign_in_connected": "Sign-in provider connected",
"sign_in_disconnected": "Sign-in provider disconnected",
"grab_connected": "Thingport Grab connected",
"grab_disconnected": "Thingport Grab removed"
"grab_disconnected": "Thingport Grab removed",
"collections_merged": "Collections merged",
"tags_merged": "Tags merged"
},
"authorsLinked": "{{count}} model(s) linked",
"descriptionsRefetched": "{{email}} — {{updated}} of {{total}} description(s) changed",
@@ -1265,7 +1267,8 @@
"providerOff": "{{provider}} turned off",
"aiRunSummary": "{{done}} of {{total}} checked — {{suggested}} suggested, {{applied}} applied, {{failed}} failed",
"aiRunStopped": "(stopped)",
"viaProvider": "via {{provider}}"
"viaProvider": "via {{provider}}",
"merged": "{{merged}} → {{name}}"
},
"rendering": {
"heading": "Rendering",
@@ -2909,6 +2912,24 @@
"myminifactory": "Thingport stops listing your MyMiniFactory collections until you give your username again. Syncs already set up keep their models."
}
}
},
"sortByModels": "Most models",
"selection": {
"enter": "Select collections",
"toolbar": "Actions for the selected collections",
"builtIn": "Built-in collections can't be merged or deleted",
"bookmarked_one": "Bookmarked {{count}} collection",
"bookmarked_other": "Bookmarked {{count}} collections",
"unbookmarked_one": "Removed {{count}} collection from bookmarks",
"unbookmarked_other": "Removed {{count}} collections from bookmarks",
"deleteTitle_one": "Delete {{count}} collection?",
"deleteTitle_other": "Delete {{count}} collections?",
"deleteMessage_one": "The models in it are kept. This can't be undone.",
"deleteMessage_other": "The models in them are kept. This can't be undone.",
"deleted_one": "Deleted {{count}} collection",
"deleted_other": "Deleted {{count}} collections",
"merged_one": "Merged {{count}} collection into “{{name}}”",
"merged_other": "Merged {{count}} collections into “{{name}}”"
}
},
"tags": {
@@ -2933,6 +2954,25 @@
"title": "{{name}}",
"subtitle": "Tag",
"empty": "No models with this tag yet."
},
"selection": {
"enter": "Select tags",
"toolbar": "Actions for the selected tags",
"selectAllLoaded": "Select all loaded",
"bookmarked_one": "Bookmarked {{count}} tag",
"bookmarked_other": "Bookmarked {{count}} tags",
"unbookmarked_one": "Removed {{count}} tag from bookmarks",
"unbookmarked_other": "Removed {{count}} tags from bookmarks",
"deleteTitle_one": "Delete {{count}} tag?",
"deleteTitle_other": "Delete {{count}} tags?",
"deleteMessage_one": "It's removed from every model that has it. The models themselves are kept.",
"deleteMessage_other": "They're removed from every model that has them. The models themselves are kept.",
"deleteConfirm_one": "Delete tag",
"deleteConfirm_other": "Delete tags",
"deleted_one": "Deleted {{count}} tag",
"deleted_other": "Deleted {{count}} tags",
"merged_one": "Merged {{count}} tag into “{{name}}”",
"merged_other": "Merged {{count}} tags into “{{name}}”"
}
},
"downloadZip": {
@@ -3055,6 +3095,45 @@
"confirmRemoveFromCollection_other": "Remove {{count}} models from this collection? The models themselves are kept.",
"removedFromCollection_one": "Removed {{count}} model from the collection",
"removedFromCollection_other": "Removed {{count}} models from the collection"
},
"merge": {
"action": "Merge",
"needTwo": "Select at least 2 to merge",
"previewFailed": "Couldn't work out what the merge would do. Please try again.",
"failed": "Merge failed. Please try again.",
"keep": "Merge into",
"kept": "Kept",
"after": "After the merge",
"modelUnit_one": "model",
"modelUnit_other": "models",
"new": "new",
"quoted": "“{{name}}”",
"andMore_one": "{{count}} more",
"andMore_other": "{{count}} more",
"syncsWith": "syncs with {{provider}}",
"bookmarkMoves": "Its bookmark moves to “{{name}}”.",
"cantUndo": "A merge can't be undone.",
"mergeInto": "Merge into “{{name}}”",
"collections": {
"title_one": "Merge {{count}} collection",
"title_other": "Merge {{count}} collections",
"keepHelp": "Pick the collection to keep. The others' models are added to it, then the others are deleted.",
"goes": "Deleted",
"othersGo_one": "{{names}} is deleted. Its models aren't: they move into “{{name}}”.",
"othersGo_other": "{{names}} are deleted. Their models aren't: they move into “{{name}}”.",
"keepsOwn": "“{{name}}” keeps its name, description, tags and sync.",
"syncStops_one": "{{names}} syncs with a provider collection, and that sync stops. Keep it instead to carry on syncing.",
"syncStops_other": "{{names}} sync with provider collections, and those syncs stop. Keep one of them instead to carry on syncing it."
},
"tags": {
"title_one": "Merge {{count}} tag",
"title_other": "Merge {{count}} tags",
"keepHelp": "Pick the tag to keep. Every model with one of the others gets it instead, then the others are removed.",
"goes": "Removed",
"othersGo_one": "{{names}} is removed. Its models are tagged “{{name}}” instead.",
"othersGo_other": "{{names}} are removed. Their models are tagged “{{name}}” instead.",
"keepsOwn": "Models already tagged “{{name}}” stay as they are."
}
}
}
}
+81 -2
View File
@@ -1251,7 +1251,9 @@
"sign_in_connected": "Proveedor de inicio de sesión conectado",
"sign_in_disconnected": "Proveedor de inicio de sesión desconectado",
"grab_connected": "Thingport Grab conectado",
"grab_disconnected": "Thingport Grab eliminado"
"grab_disconnected": "Thingport Grab eliminado",
"collections_merged": "Colecciones fusionadas",
"tags_merged": "Etiquetas fusionadas"
},
"authorsLinked": "{{count}} modelo(s) vinculado(s)",
"descriptionsRefetched": "{{email}} — {{updated}} de {{total}} descripción(es) modificada(s)",
@@ -1265,7 +1267,8 @@
"providerOff": "{{provider}} desactivado",
"aiRunSummary": "{{done}} de {{total}} revisados — {{suggested}} sugeridos, {{applied}} aplicados, {{failed}} con error",
"aiRunStopped": "(detenida)",
"viaProvider": "mediante {{provider}}"
"viaProvider": "mediante {{provider}}",
"merged": "{{merged}} → {{name}}"
},
"rendering": {
"heading": "Renderizado",
@@ -2909,6 +2912,24 @@
"myminifactory": "Thingport deja de mostrar tus colecciones de MyMiniFactory hasta que vuelvas a indicar tu nombre de usuario. Las sincronizaciones ya configuradas conservan sus modelos."
}
}
},
"sortByModels": "Más modelos",
"selection": {
"enter": "Seleccionar colecciones",
"toolbar": "Acciones para las colecciones seleccionadas",
"builtIn": "Las colecciones integradas no se pueden fusionar ni eliminar",
"bookmarked_one": "{{count}} colección añadida a marcadores",
"bookmarked_other": "{{count}} colecciones añadidas a marcadores",
"unbookmarked_one": "{{count}} colección quitada de marcadores",
"unbookmarked_other": "{{count}} colecciones quitadas de marcadores",
"deleteTitle_one": "¿Eliminar {{count}} colección?",
"deleteTitle_other": "¿Eliminar {{count}} colecciones?",
"deleteMessage_one": "Sus modelos se conservan. No se puede deshacer.",
"deleteMessage_other": "Sus modelos se conservan. No se puede deshacer.",
"deleted_one": "{{count}} colección eliminada",
"deleted_other": "{{count}} colecciones eliminadas",
"merged_one": "{{count}} colección fusionada en «{{name}}»",
"merged_other": "{{count}} colecciones fusionadas en «{{name}}»"
}
},
"tags": {
@@ -2933,6 +2954,25 @@
"title": "{{name}}",
"subtitle": "Etiqueta",
"empty": "Aún no hay modelos con esta etiqueta."
},
"selection": {
"enter": "Seleccionar etiquetas",
"toolbar": "Acciones para las etiquetas seleccionadas",
"selectAllLoaded": "Seleccionar todas las cargadas",
"bookmarked_one": "{{count}} etiqueta añadida a marcadores",
"bookmarked_other": "{{count}} etiquetas añadidas a marcadores",
"unbookmarked_one": "{{count}} etiqueta quitada de marcadores",
"unbookmarked_other": "{{count}} etiquetas quitadas de marcadores",
"deleteTitle_one": "¿Eliminar {{count}} etiqueta?",
"deleteTitle_other": "¿Eliminar {{count}} etiquetas?",
"deleteMessage_one": "Se quita de todos los modelos que la tienen. Los modelos se conservan.",
"deleteMessage_other": "Se quitan de todos los modelos que las tienen. Los modelos se conservan.",
"deleteConfirm_one": "Eliminar etiqueta",
"deleteConfirm_other": "Eliminar etiquetas",
"deleted_one": "{{count}} etiqueta eliminada",
"deleted_other": "{{count}} etiquetas eliminadas",
"merged_one": "{{count}} etiqueta fusionada en «{{name}}»",
"merged_other": "{{count}} etiquetas fusionadas en «{{name}}»"
}
},
"downloadZip": {
@@ -3056,6 +3096,45 @@
"confirmRemoveFromCollection_other": "¿Quitar {{count}} modelos de esta colección? Los modelos en sí se conservan.",
"removedFromCollection_one": "Se quitó {{count}} modelo de la colección",
"removedFromCollection_other": "Se quitaron {{count}} modelos de la colección"
},
"merge": {
"action": "Fusionar",
"needTwo": "Selecciona al menos 2 para fusionar",
"previewFailed": "No se pudo calcular qué haría la fusión. Inténtalo de nuevo.",
"failed": "La fusión falló. Inténtalo de nuevo.",
"keep": "Fusionar en",
"kept": "Se conserva",
"after": "Tras la fusión",
"modelUnit_one": "modelo",
"modelUnit_other": "modelos",
"new": "nuevos",
"quoted": "«{{name}}»",
"andMore_one": "{{count}} más",
"andMore_other": "{{count}} más",
"syncsWith": "se sincroniza con {{provider}}",
"bookmarkMoves": "Su marcador pasa a «{{name}}».",
"cantUndo": "Una fusión no se puede deshacer.",
"mergeInto": "Fusionar en «{{name}}»",
"collections": {
"title_one": "Fusionar {{count}} colección",
"title_other": "Fusionar {{count}} colecciones",
"keepHelp": "Elige la colección que se conserva. Los modelos de las demás se añaden a ella y luego las demás se eliminan.",
"goes": "Se elimina",
"othersGo_one": "{{names}} se elimina. Sus modelos no: pasan a «{{name}}».",
"othersGo_other": "{{names}} se eliminan. Sus modelos no: pasan a «{{name}}».",
"keepsOwn": "«{{name}}» conserva su nombre, descripción, etiquetas y sincronización.",
"syncStops_one": "{{names}} se sincroniza con una colección de un proveedor, y esa sincronización se detiene. Consérvala a ella para seguir sincronizando.",
"syncStops_other": "{{names}} se sincronizan con colecciones de proveedores, y esas sincronizaciones se detienen. Conserva una de ellas para seguir sincronizándola."
},
"tags": {
"title_one": "Fusionar {{count}} etiqueta",
"title_other": "Fusionar {{count}} etiquetas",
"keepHelp": "Elige la etiqueta que se conserva. Cada modelo con alguna de las demás la recibe en su lugar y luego las demás se quitan.",
"goes": "Se quita",
"othersGo_one": "{{names}} se quita. Sus modelos pasan a tener «{{name}}».",
"othersGo_other": "{{names}} se quitan. Sus modelos pasan a tener «{{name}}».",
"keepsOwn": "Los modelos que ya tienen «{{name}}» no cambian."
}
}
}
}
+81 -2
View File
@@ -1251,7 +1251,9 @@
"sign_in_connected": "Fournisseur de connexion connecté",
"sign_in_disconnected": "Fournisseur de connexion déconnecté",
"grab_connected": "Thingport Grab connecté",
"grab_disconnected": "Thingport Grab retiré"
"grab_disconnected": "Thingport Grab retiré",
"collections_merged": "Collections fusionnées",
"tags_merged": "Étiquettes fusionnées"
},
"authorsLinked": "{{count}} modèle(s) relié(s)",
"descriptionsRefetched": "{{email}} — {{updated}} description(s) sur {{total}} modifiée(s)",
@@ -1265,7 +1267,8 @@
"providerOff": "{{provider}} désactivé",
"aiRunSummary": "{{done}} sur {{total}} vérifiés — {{suggested}} suggérés, {{applied}} appliqués, {{failed}} en échec",
"aiRunStopped": "(arrêtée)",
"viaProvider": "via {{provider}}"
"viaProvider": "via {{provider}}",
"merged": "{{merged}} → {{name}}"
},
"rendering": {
"heading": "Rendu",
@@ -2922,6 +2925,24 @@
"myminifactory": "Thingport n’affiche plus vos collections MyMiniFactory tant que vous n’avez pas à nouveau indiqué votre nom d’utilisateur. Les synchronisations déjà configurées conservent leurs modèles."
}
}
},
"sortByModels": "Plus de modèles",
"selection": {
"enter": "Sélectionner des collections",
"toolbar": "Actions pour les collections sélectionnées",
"builtIn": "Les collections intégrées ne peuvent être ni fusionnées ni supprimées",
"bookmarked_one": "{{count}} collection ajoutée aux favoris",
"bookmarked_other": "{{count}} collections ajoutées aux favoris",
"unbookmarked_one": "{{count}} collection retirée des favoris",
"unbookmarked_other": "{{count}} collections retirées des favoris",
"deleteTitle_one": "Supprimer {{count}} collection ?",
"deleteTitle_other": "Supprimer {{count}} collections ?",
"deleteMessage_one": "Ses modèles sont conservés. Action irréversible.",
"deleteMessage_other": "Leurs modèles sont conservés. Action irréversible.",
"deleted_one": "{{count}} collection supprimée",
"deleted_other": "{{count}} collections supprimées",
"merged_one": "{{count}} collection fusionnée dans « {{name}} »",
"merged_other": "{{count}} collections fusionnées dans « {{name}} »"
}
},
"tags": {
@@ -2946,6 +2967,25 @@
"title": "{{name}}",
"subtitle": "Étiquette",
"empty": "Aucun modèle avec cette étiquette pour le moment."
},
"selection": {
"enter": "Sélectionner des étiquettes",
"toolbar": "Actions pour les étiquettes sélectionnées",
"selectAllLoaded": "Tout sélectionner (chargées)",
"bookmarked_one": "{{count}} étiquette ajoutée aux favoris",
"bookmarked_other": "{{count}} étiquettes ajoutées aux favoris",
"unbookmarked_one": "{{count}} étiquette retirée des favoris",
"unbookmarked_other": "{{count}} étiquettes retirées des favoris",
"deleteTitle_one": "Supprimer {{count}} étiquette ?",
"deleteTitle_other": "Supprimer {{count}} étiquettes ?",
"deleteMessage_one": "Elle est retirée de tous les modèles qui l'ont. Les modèles sont conservés.",
"deleteMessage_other": "Elles sont retirées de tous les modèles qui les ont. Les modèles sont conservés.",
"deleteConfirm_one": "Supprimer l'étiquette",
"deleteConfirm_other": "Supprimer les étiquettes",
"deleted_one": "{{count}} étiquette supprimée",
"deleted_other": "{{count}} étiquettes supprimées",
"merged_one": "{{count}} étiquette fusionnée dans « {{name}} »",
"merged_other": "{{count}} étiquettes fusionnées dans « {{name}} »"
}
},
"downloadZip": {
@@ -3069,6 +3109,45 @@
"confirmRemoveFromCollection_other": "Retirer {{count}} modèles de cette collection ? Les modèles eux-mêmes sont conservés.",
"removedFromCollection_one": "{{count}} modèle retiré de la collection",
"removedFromCollection_other": "{{count}} modèles retirés de la collection"
},
"merge": {
"action": "Fusionner",
"needTwo": "Sélectionnez-en au moins 2 pour fusionner",
"previewFailed": "Impossible de calculer l'effet de la fusion. Réessayez.",
"failed": "La fusion a échoué. Réessayez.",
"keep": "Fusionner dans",
"kept": "Conservée",
"after": "Après la fusion",
"modelUnit_one": "modèle",
"modelUnit_other": "modèles",
"new": "nouveaux",
"quoted": "« {{name}} »",
"andMore_one": "{{count}} autre",
"andMore_other": "{{count}} autres",
"syncsWith": "synchronisée avec {{provider}}",
"bookmarkMoves": "Son favori passe à « {{name}} ».",
"cantUndo": "Une fusion est irréversible.",
"mergeInto": "Fusionner dans « {{name}} »",
"collections": {
"title_one": "Fusionner {{count}} collection",
"title_other": "Fusionner {{count}} collections",
"keepHelp": "Choisissez la collection à conserver. Les modèles des autres y sont ajoutés, puis les autres sont supprimées.",
"goes": "Supprimée",
"othersGo_one": "{{names}} est supprimée. Pas ses modèles : ils passent dans « {{name}} ».",
"othersGo_other": "{{names}} sont supprimées. Pas leurs modèles : ils passent dans « {{name}} ».",
"keepsOwn": "« {{name}} » garde son nom, sa description, ses étiquettes et sa synchronisation.",
"syncStops_one": "{{names}} est synchronisée avec une collection d'un fournisseur, et cette synchronisation s'arrête. Conservez-la plutôt pour continuer à synchroniser.",
"syncStops_other": "{{names}} sont synchronisées avec des collections de fournisseurs, et ces synchronisations s'arrêtent. Conservez-en une pour continuer à la synchroniser."
},
"tags": {
"title_one": "Fusionner {{count}} étiquette",
"title_other": "Fusionner {{count}} étiquettes",
"keepHelp": "Choisissez l'étiquette à conserver. Chaque modèle portant l'une des autres la reçoit à la place, puis les autres sont retirées.",
"goes": "Retirée",
"othersGo_one": "{{names}} est retirée. Ses modèles reçoivent « {{name}} » à la place.",
"othersGo_other": "{{names}} sont retirées. Leurs modèles reçoivent « {{name}} » à la place.",
"keepsOwn": "Les modèles qui ont déjà « {{name}} » ne changent pas."
}
}
}
}
+121 -2
View File
@@ -1280,7 +1280,9 @@
"sign_in_connected": "Prijungtas prisijungimo teikėjas",
"sign_in_disconnected": "Atjungtas prisijungimo teikėjas",
"grab_connected": "Prijungtas Thingport Grab",
"grab_disconnected": "Pašalintas Thingport Grab"
"grab_disconnected": "Pašalintas Thingport Grab",
"collections_merged": "Kolekcijos sujungtos",
"tags_merged": "Žymos sujungtos"
},
"authorsLinked": "Susieta modelių: {{count}}",
"descriptionsRefetched": "{{email}} — pakeista aprašymų: {{updated}} iš {{total}}",
@@ -1298,7 +1300,8 @@
"providerOff": "{{provider}} išjungtas",
"aiRunSummary": "Patikrinta {{done}} iš {{total}} — pasiūlyta {{suggested}}, pritaikyta {{applied}}, nepavyko {{failed}}",
"aiRunStopped": "(sustabdyta)",
"viaProvider": "per {{provider}}"
"viaProvider": "per {{provider}}",
"merged": "{{merged}} → {{name}}"
},
"rendering": {
"heading": "Atvaizdavimas",
@@ -2993,6 +2996,36 @@
"myminifactory": "Thingport neberodys jūsų MyMiniFactory kolekcijų, kol vėl nurodysite vartotojo vardą. Jau nustatyti sinchronizavimai išsaugos savo modelius."
}
}
},
"sortByModels": "Daugiausia modelių",
"selection": {
"enter": "Žymėti kolekcijas",
"toolbar": "Veiksmai su pažymėtomis kolekcijomis",
"builtIn": "Įtaisytųjų kolekcijų negalima sujungti ar ištrinti",
"bookmarked_one": "Įsiminta {{count}} kolekcija",
"bookmarked_few": "Įsimintos {{count}} kolekcijos",
"bookmarked_many": "Įsiminta {{count}} kolekcijos",
"bookmarked_other": "Įsiminta {{count}} kolekcijų",
"unbookmarked_one": "Iš įsimintų pašalinta {{count}} kolekcija",
"unbookmarked_few": "Iš įsimintų pašalintos {{count}} kolekcijos",
"unbookmarked_many": "Iš įsimintų pašalinta {{count}} kolekcijos",
"unbookmarked_other": "Iš įsimintų pašalinta {{count}} kolekcijų",
"deleteTitle_one": "Ištrinti {{count}} kolekciją?",
"deleteTitle_few": "Ištrinti {{count}} kolekcijas?",
"deleteTitle_many": "Ištrinti {{count}} kolekcijos?",
"deleteTitle_other": "Ištrinti {{count}} kolekcijų?",
"deleteMessage_one": "Joje esantys modeliai liks. To atšaukti negalima.",
"deleteMessage_few": "Jose esantys modeliai liks. To atšaukti negalima.",
"deleteMessage_many": "Jose esantys modeliai liks. To atšaukti negalima.",
"deleteMessage_other": "Jose esantys modeliai liks. To atšaukti negalima.",
"deleted_one": "Ištrinta {{count}} kolekcija",
"deleted_few": "Ištrintos {{count}} kolekcijos",
"deleted_many": "Ištrinta {{count}} kolekcijos",
"deleted_other": "Ištrinta {{count}} kolekcijų",
"merged_one": "{{count}} kolekcija sujungta į „{{name}}“",
"merged_few": "{{count}} kolekcijos sujungtos į „{{name}}“",
"merged_many": "{{count}} kolekcijos sujungta į „{{name}}“",
"merged_other": "{{count}} kolekcijų sujungta į „{{name}}“"
}
},
"tags": {
@@ -3019,6 +3052,39 @@
"title": "{{name}}",
"subtitle": "Žyma",
"empty": "Su šia žyma modelių kol kas nėra."
},
"selection": {
"enter": "Žymėti žymas",
"toolbar": "Veiksmai su pažymėtomis žymomis",
"selectAllLoaded": "Pažymėti visas įkeltas",
"bookmarked_one": "Įsiminta {{count}} žyma",
"bookmarked_few": "Įsimintos {{count}} žymos",
"bookmarked_many": "Įsiminta {{count}} žymos",
"bookmarked_other": "Įsiminta {{count}} žymų",
"unbookmarked_one": "Iš įsimintų pašalinta {{count}} žyma",
"unbookmarked_few": "Iš įsimintų pašalintos {{count}} žymos",
"unbookmarked_many": "Iš įsimintų pašalinta {{count}} žymos",
"unbookmarked_other": "Iš įsimintų pašalinta {{count}} žymų",
"deleteTitle_one": "Ištrinti {{count}} žymą?",
"deleteTitle_few": "Ištrinti {{count}} žymas?",
"deleteTitle_many": "Ištrinti {{count}} žymos?",
"deleteTitle_other": "Ištrinti {{count}} žymų?",
"deleteMessage_one": "Žyma bus pašalinta iš visų ja pažymėtų modelių. Patys modeliai liks.",
"deleteMessage_few": "Žymos bus pašalintos iš visų jomis pažymėtų modelių. Patys modeliai liks.",
"deleteMessage_many": "Žymos bus pašalintos iš visų jomis pažymėtų modelių. Patys modeliai liks.",
"deleteMessage_other": "Žymos bus pašalintos iš visų jomis pažymėtų modelių. Patys modeliai liks.",
"deleteConfirm_one": "Ištrinti žymą",
"deleteConfirm_few": "Ištrinti žymas",
"deleteConfirm_many": "Ištrinti žymas",
"deleteConfirm_other": "Ištrinti žymas",
"deleted_one": "Ištrinta {{count}} žyma",
"deleted_few": "Ištrintos {{count}} žymos",
"deleted_many": "Ištrinta {{count}} žymos",
"deleted_other": "Ištrinta {{count}} žymų",
"merged_one": "{{count}} žyma sujungta į „{{name}}“",
"merged_few": "{{count}} žymos sujungtos į „{{name}}“",
"merged_many": "{{count}} žymos sujungta į „{{name}}“",
"merged_other": "{{count}} žymų sujungta į „{{name}}“"
}
},
"downloadZip": {
@@ -3155,6 +3221,59 @@
"removedFromCollection_one": "{{count}} modelis pašalintas iš kolekcijos",
"removedFromCollection_few": "{{count}} modeliai pašalinti iš kolekcijos",
"removedFromCollection_other": "{{count}} modelių pašalinta iš kolekcijos"
},
"merge": {
"action": "Sujungti",
"needTwo": "Sujungimui pažymėkite bent 2",
"previewFailed": "Nepavyko apskaičiuoti, ką pakeis sujungimas. Bandykite dar kartą.",
"failed": "Nepavyko sujungti. Bandykite dar kartą.",
"keep": "Sujungti į",
"kept": "Lieka",
"after": "Po sujungimo",
"modelUnit_one": "modelis",
"modelUnit_few": "modeliai",
"modelUnit_many": "modelio",
"modelUnit_other": "modelių",
"new": "naujų",
"quoted": "„{{name}}“",
"andMore_one": "dar {{count}}",
"andMore_few": "dar {{count}}",
"andMore_many": "dar {{count}}",
"andMore_other": "dar {{count}}",
"syncsWith": "sinchronizuojama su {{provider}}",
"bookmarkMoves": "Įsiminimas pereina „{{name}}“.",
"cantUndo": "Sujungimo atšaukti negalima.",
"mergeInto": "Sujungti į „{{name}}“",
"collections": {
"title_one": "Sujungti {{count}} kolekciją",
"title_few": "Sujungti {{count}} kolekcijas",
"title_many": "Sujungti {{count}} kolekcijos",
"title_other": "Sujungti {{count}} kolekcijų",
"keepHelp": "Pasirinkite, kurią kolekciją palikti. Kitų kolekcijų modeliai bus pridėti prie jos, o kitos kolekcijos ištrintos.",
"goes": "Ištrinama",
"othersGo_one": "{{names}} bus ištrinta. Jos modeliai liks: jie pereis į „{{name}}“.",
"othersGo_few": "{{names}} bus ištrintos. Jų modeliai liks: jie pereis į „{{name}}“.",
"othersGo_many": "{{names}} bus ištrintos. Jų modeliai liks: jie pereis į „{{name}}“.",
"othersGo_other": "{{names}} bus ištrintos. Jų modeliai liks: jie pereis į „{{name}}“.",
"keepsOwn": "„{{name}}“ išlaiko savo pavadinimą, aprašymą, žymas ir sinchronizavimą.",
"syncStops_one": "{{names}} sinchronizuojama su tiekėjo kolekcija, ir šis sinchronizavimas sustos. Palikite ją, jei norite toliau sinchronizuoti.",
"syncStops_few": "{{names}} sinchronizuojamos su tiekėjų kolekcijomis, ir šie sinchronizavimai sustos. Palikite vieną iš jų, jei norite toliau ją sinchronizuoti.",
"syncStops_many": "{{names}} sinchronizuojamos su tiekėjų kolekcijomis, ir šie sinchronizavimai sustos. Palikite vieną iš jų, jei norite toliau ją sinchronizuoti.",
"syncStops_other": "{{names}} sinchronizuojamos su tiekėjų kolekcijomis, ir šie sinchronizavimai sustos. Palikite vieną iš jų, jei norite toliau ją sinchronizuoti."
},
"tags": {
"title_one": "Sujungti {{count}} žymą",
"title_few": "Sujungti {{count}} žymas",
"title_many": "Sujungti {{count}} žymos",
"title_other": "Sujungti {{count}} žymų",
"keepHelp": "Pasirinkite, kurią žymą palikti. Visi modeliai su kitomis žymomis gaus ją, o kitos žymos bus pašalintos.",
"goes": "Pašalinama",
"othersGo_one": "{{names}} bus pašalinta. Jos modeliai bus pažymėti „{{name}}“.",
"othersGo_few": "{{names}} bus pašalintos. Jų modeliai bus pažymėti „{{name}}“.",
"othersGo_many": "{{names}} bus pašalintos. Jų modeliai bus pažymėti „{{name}}“.",
"othersGo_other": "{{names}} bus pašalintos. Jų modeliai bus pažymėti „{{name}}“.",
"keepsOwn": "Modeliai, jau pažymėti „{{name}}“, nesikeičia."
}
}
}
}
@@ -1,9 +1,11 @@
import { useTranslation } from "react-i18next";
import { useState } from "react";
import type React from "react";
import { useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import type { Theme } from "@mui/material/styles";
import Inventory2OutlinedIcon from "@mui/icons-material/Inventory2Outlined";
@@ -17,6 +19,7 @@ import CollectionActionsMenu from "../CollectionDetailPage/CollectionActionsMenu
import CollectionSyncBadge from "../../components/CollectionSyncBadge";
import CollectionSyncDialog from "../CollectionDetailPage/CollectionSyncDialog";
import { useCardContextMenu } from "../../components/Layout/ConfigurationDialog/AppTiles";
import SelectionCheck from "../../components/selection/SelectionCheck";
type Props = {
collection: Collection;
@@ -26,6 +29,10 @@ type Props = {
onDeleted: (id: string) => void;
onUnauthorized?: () => void;
onBookmarksChanged?: () => void;
/** Select mode: a click selects the card instead of opening it. Built-in collections sit it out. */
selectMode?: boolean;
selected?: boolean;
onToggleSelect?: (range: boolean) => void;
};
const COVER_TILE_LIMIT = 4;
@@ -36,20 +43,27 @@ function CoverTile({
theme,
previewMode,
overlayCount,
interactive,
}: {
print: Collection["cover_items"][number];
theme: ResolvedTheme;
previewMode: PreviewMode;
overlayCount?: number;
/** Off in select mode, where the click goes to the card. */
interactive: boolean;
}) {
const { t } = useTranslation(["models", "common"]);
const navigate = useNavigate();
return (
<Box
onClick={(e) => {
e.stopPropagation();
navigate(`/models/${print.id}`);
}}
onClick={
interactive
? (e) => {
e.stopPropagation();
navigate(`/models/${print.id}`);
}
: undefined
}
sx={{ position: "relative", width: "100%", height: "100%", cursor: "pointer", overflow: "hidden" }}
>
{renderPreviewContent(print, "card", theme, t, previewMode)}
@@ -91,6 +105,9 @@ export default function CollectionCard({
onDeleted,
onUnauthorized,
onBookmarksChanged,
selectMode = false,
selected = false,
onToggleSelect,
}: Props) {
const { t } = useTranslation(["models", "common"]);
const navigate = useNavigate();
@@ -102,151 +119,203 @@ export default function CollectionCard({
const openTarget = soleModelId ? `/models/${soleModelId}` : `/models/collections/${collection.id}`;
const [syncOpen, setSyncOpen] = useState(false);
const contextMenu = useCardContextMenu();
const system = Boolean(collection.system_key);
// Built-in ones can't be merged or deleted, so in select mode they step back and do nothing.
const sittingOut = selectMode && system;
const toggleSelect = (event: React.MouseEvent | React.KeyboardEvent) => onToggleSelect?.(event.shiftKey);
return (
<Box sx={{ position: "relative", pb: `${STACK_DEPTH_PX}px` }}>
{STACK_LAYERS.map(({ insetPx, dropPx, opacity }) => (
<Box
key={dropPx}
aria-hidden
sx={{
position: "absolute",
top: 0,
left: insetPx,
right: insetPx,
bottom: STACK_DEPTH_PX - dropPx,
borderRadius: "12px",
border: "1px solid",
borderColor: "divider",
bgcolor: cardBackground,
opacity,
}}
/>
))}
<Paper
variant="outlined"
// System collections have no menu, so they keep the browser's.
onContextMenu={collection.system_key ? undefined : contextMenu.onContextMenu}
<Tooltip title={sittingOut ? t("models:collections.selection.builtIn") : ""} followCursor>
<Box
sx={{
position: "relative",
overflow: "hidden",
borderRadius: "12px",
borderColor: "divider",
bgcolor: cardBackground,
"&:hover .collection-card-actions": { opacity: 1 },
pb: `${STACK_DEPTH_PX}px`,
opacity: sittingOut ? 0.45 : 1,
filter: sittingOut ? "grayscale(1)" : "none",
transition: "opacity 200ms, filter 200ms",
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
}}
>
<Box
sx={{
width: "100%",
aspectRatio: "4 / 3",
bgcolor: (muiTheme) =>
muiTheme.palette.mode === "dark" ? muiTheme.thingport.pageBackground : muiTheme.palette.grey[200],
}}
>
{coverItems.length === 0 && (
<Stack
alignItems="center"
justifyContent="center"
sx={{ width: "100%", height: "100%", color: "text.disabled" }}
>
<Typography variant="caption">{t("models:collections.card.empty")}</Typography>
</Stack>
)}
{coverItems.length === 1 && <CoverTile print={coverItems[0]} theme={theme} previewMode={previewMode} />}
{coverItems.length > 1 && (
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gridTemplateRows: "repeat(2, 1fr)",
gap: "2px",
width: "100%",
height: "100%",
}}
>
{coverItems.map((item, idx) => (
<CoverTile
key={item.id}
print={item}
theme={theme}
previewMode={previewMode}
overlayCount={idx === coverItems.length - 1 ? extraCount : undefined}
/>
))}
</Box>
)}
</Box>
{!collection.system_key && (
{STACK_LAYERS.map(({ insetPx, dropPx, opacity }) => (
<Box
className="collection-card-actions"
onClick={(e) => e.stopPropagation()}
key={dropPx}
aria-hidden
sx={{
position: "absolute",
top: 8,
right: 8,
opacity: 0,
transition: "opacity .15s ease",
bgcolor: "rgba(0, 0, 0, 0.55)",
borderRadius: "50%",
top: 0,
left: insetPx,
right: insetPx,
bottom: STACK_DEPTH_PX - dropPx,
borderRadius: "12px",
border: "1px solid",
borderColor: "divider",
bgcolor: cardBackground,
opacity,
}}
>
<CollectionActionsMenu
collection={collection}
onUpdated={onUpdated}
onDeleted={() => onDeleted(collection.id)}
onUnauthorized={onUnauthorized}
onBookmarksChanged={onBookmarksChanged}
triggerSx={{ color: "#fff" }}
contextMenuAt={contextMenu.at}
onContextMenuClose={contextMenu.close}
/>
</Box>
)}
<Box
onClick={() => navigate(openTarget)}
/>
))}
<Paper
variant="outlined"
// System collections have no menu, so they keep the browser's.
onContextMenu={system || selectMode ? undefined : contextMenu.onContextMenu}
{...(selectMode &&
!system && {
onClick: toggleSelect,
role: "checkbox",
"aria-checked": selected,
"aria-label": displayName,
tabIndex: 0,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key !== " " && event.key !== "Enter") return;
event.preventDefault();
toggleSelect(event);
},
})}
sx={{
p: 1.5,
cursor: "pointer",
transition: "background-color .15s ease",
"&:hover": { bgcolor: "background.paper" },
position: "relative",
overflow: "hidden",
borderRadius: "12px",
borderColor: "divider",
bgcolor: cardBackground,
// An outline, so selecting doesn't shift the card's contents the way a thicker border would.
outline: "2px solid",
outlineColor: selected ? "primary.main" : "transparent",
outlineOffset: -2,
transition: "outline-color .15s ease",
...(selectMode && { cursor: "pointer", userSelect: "none" }),
"&:focus-visible": { outlineColor: "primary.main" },
...(!selectMode && { "&:hover .collection-card-actions": { opacity: 1 } }),
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
<Stack direction="row" alignItems="center" spacing={0.5} minWidth={0}>
{collection.system_key && <LockIcon sx={{ fontSize: 14, color: "text.disabled", flexShrink: 0 }} />}
<Typography
variant="body2"
fontWeight={600}
noWrap
title={displayName}
sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}
>
{displayName}
</Typography>
</Stack>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ mt: 0.5, color: "#858585" }}>
<Inventory2OutlinedIcon sx={{ fontSize: 14 }} />
<Typography variant="caption">
{t("models:collections.card.itemCount", { count: collection.item_count })}
</Typography>
<Box
sx={{
width: "100%",
aspectRatio: "4 / 3",
bgcolor: (muiTheme) =>
muiTheme.palette.mode === "dark" ? muiTheme.thingport.pageBackground : muiTheme.palette.grey[200],
// Selected, the cover shrinks inside the outline, as a selected model card's does.
transform: selected ? "scale(0.92)" : "none",
borderRadius: selected ? "8px" : 0,
overflow: "hidden",
transition: "transform .2s ease, border-radius .2s ease",
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
}}
>
{coverItems.length === 0 && (
<Stack
alignItems="center"
justifyContent="center"
sx={{ width: "100%", height: "100%", color: "text.disabled" }}
>
<Typography variant="caption">{t("models:collections.card.empty")}</Typography>
</Stack>
)}
{coverItems.length === 1 && (
<CoverTile print={coverItems[0]} theme={theme} previewMode={previewMode} interactive={!selectMode} />
)}
{coverItems.length > 1 && (
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gridTemplateRows: "repeat(2, 1fr)",
gap: "2px",
width: "100%",
height: "100%",
}}
>
{coverItems.map((item, idx) => (
<CoverTile
key={item.id}
print={item}
theme={theme}
previewMode={previewMode}
overlayCount={idx === coverItems.length - 1 ? extraCount : undefined}
interactive={!selectMode}
/>
))}
</Box>
)}
</Box>
{selectMode && !system && <SelectionCheck selected={selected} />}
{!system && !selectMode && (
<Box
className="collection-card-actions"
onClick={(e) => e.stopPropagation()}
sx={{
position: "absolute",
top: 8,
right: 8,
opacity: 0,
transition: "opacity .15s ease",
bgcolor: "rgba(0, 0, 0, 0.55)",
borderRadius: "50%",
}}
>
<CollectionActionsMenu
collection={collection}
onUpdated={onUpdated}
onDeleted={() => onDeleted(collection.id)}
onUnauthorized={onUnauthorized}
onBookmarksChanged={onBookmarksChanged}
triggerSx={{ color: "#fff" }}
contextMenuAt={contextMenu.at}
onContextMenuClose={contextMenu.close}
/>
</Box>
{collection.sync && <CollectionSyncBadge sync={collection.sync} onClick={() => setSyncOpen(true)} />}
</Stack>
</Box>
</Paper>
{syncOpen && collection.sync && (
<CollectionSyncDialog
collection={{ ...collection, sync: collection.sync }}
onClose={() => setSyncOpen(false)}
onUpdated={onUpdated}
onUnauthorized={onUnauthorized}
/>
)}
</Box>
)}
<Box
onClick={selectMode ? undefined : () => navigate(openTarget)}
sx={{
p: 1.5,
cursor: "pointer",
transition: "background-color .15s ease",
...(!selectMode && { "&:hover": { bgcolor: "background.paper" } }),
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
<Stack direction="row" alignItems="center" spacing={0.5} minWidth={0}>
{collection.system_key && <LockIcon sx={{ fontSize: 14, color: "text.disabled", flexShrink: 0 }} />}
<Typography
variant="body2"
fontWeight={600}
noWrap
title={displayName}
sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}
>
{displayName}
</Typography>
</Stack>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ mt: 0.5, color: "#858585" }}>
<Inventory2OutlinedIcon sx={{ fontSize: 14 }} />
<Typography variant="caption">
{t("models:collections.card.itemCount", { count: collection.item_count })}
</Typography>
</Stack>
</Box>
{collection.sync && (
<CollectionSyncBadge
sync={collection.sync}
onClick={selectMode ? undefined : () => setSyncOpen(true)}
/>
)}
</Stack>
</Box>
</Paper>
{syncOpen && collection.sync && (
<CollectionSyncDialog
collection={{ ...collection, sync: collection.sync }}
onClose={() => setSyncOpen(false)}
onUpdated={onUpdated}
onUnauthorized={onUnauthorized}
/>
)}
</Box>
</Tooltip>
);
}
+211 -7
View File
@@ -1,13 +1,30 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useEffect, useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import AddIcon from "@mui/icons-material/Add";
import BookmarkIcon from "@mui/icons-material/Bookmark";
import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder";
import CallMergeIcon from "@mui/icons-material/CallMerge";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import Inventory2OutlinedIcon from "@mui/icons-material/Inventory2Outlined";
import { UnauthorizedError } from "../../api/client";
import { type Collection, type CollectionInput, collectionsApi } from "../../api/collections";
import { type PreviewMode } from "../../api/settings";
import { type ResolvedTheme } from "../../constants/settingsOptions";
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
import { useConfirm } from "../../components/ConfirmProvider";
import { useToast } from "../../components/ToastProvider";
import SortTabs from "../../components/controls/SortTabs";
import MergeDialog, { type MergeItem } from "../../components/MergeDialog";
import SelectionToolbar, { SelectionAction } from "../../components/selection/SelectionToolbar";
import { renderPreviewContent } from "../../components/media/renderPreviewContent";
import { useSelection } from "../../hooks/useSelection";
import { collectionDisplayName } from "../../utils/collectionDisplay";
import { importProviderInfo } from "../../constants/importProviders";
import CollectionCard from "./CollectionCard";
import CollectionFormModal from "./CollectionFormModal";
import CollectionsActionsMenu from "./CollectionsActionsMenu";
@@ -21,24 +38,35 @@ type Props = {
previewMode: PreviewMode;
};
type SortMode = "newest" | "name" | "models";
type Busy = "bookmark" | "delete" | null;
const collectionKey = (collection: Collection) => collection.id;
// Favourites and History are built in: nothing to merge or delete.
const isOwn = (collection: Collection) => !collection.system_key;
export default function CollectionsPage({ onUnauthorized, onBookmarksChanged, theme, previewMode }: Props) {
const { t } = useTranslation(["models", "common"]);
const confirm = useConfirm();
const showToast = useToast();
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [sortMode, setSortMode] = useState<SortMode>("newest");
const [mergeOpen, setMergeOpen] = useState(false);
const [busy, setBusy] = useState<Busy>(null);
usePageHeader({
actions: <CollectionsActionsMenu onAddCollection={() => setFormOpen(true)} />,
});
const handleError = (err: unknown, message?: string) => {
const handleError = (err: unknown, message: string) => {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return true;
return;
}
console.error(err);
if (message) alert(message);
return false;
showToast({ message: err instanceof Error && err.message ? err.message : message, severity: "error" });
};
const load = async () => {
@@ -57,6 +85,21 @@ export default function CollectionsPage({ onUnauthorized, onBookmarksChanged, th
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// The built-in ones stay first; the server sends the rest newest first.
const sorted = useMemo(() => {
const system = collections.filter((c) => c.system_key);
const own = collections.filter(isOwn);
const byName = (a: Collection, b: Collection) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
if (sortMode === "name") own.sort(byName);
if (sortMode === "models") own.sort((a, b) => b.item_count - a.item_count || byName(a, b));
return [...system, ...own];
}, [collections, sortMode]);
const selection = useSelection(sorted, collectionKey, isOwn);
const { selected } = selection;
const count = selected.length;
const allBookmarked = count > 0 && selected.every((c) => c.bookmarked);
const createCollection = async (input: CollectionInput) => {
await collectionsApi.create(input);
await load();
@@ -70,15 +113,170 @@ export default function CollectionsPage({ onUnauthorized, onBookmarksChanged, th
setCollections((prev) => prev.filter((c) => c.id !== id));
};
// One at a time: each new bookmark takes the next place in the list.
const toggleBookmarks = async () => {
const bookmark = !allBookmarked;
setBusy("bookmark");
try {
for (const collection of selected) {
if (collection.bookmarked === bookmark) continue;
await (bookmark ? collectionsApi.bookmark(collection.id) : collectionsApi.unbookmark(collection.id));
handleCollectionUpdated({ ...collection, bookmarked: bookmark });
}
onBookmarksChanged?.();
showToast({
message: t(bookmark ? "models:collections.selection.bookmarked" : "models:collections.selection.unbookmarked", {
count,
}),
});
} catch (err) {
handleError(err, t("models:collections.bookmarkFailed"));
onBookmarksChanged?.();
} finally {
setBusy(null);
}
};
const removeSelected = async () => {
const confirmed = await confirm({
title: t("models:collections.selection.deleteTitle", { count }),
message: t("models:collections.selection.deleteMessage", { count }),
confirmLabel: t("common:delete"),
destructive: true,
});
if (!confirmed) return;
setBusy("delete");
try {
const { deleted } = await collectionsApi.deleteMany(selected.map((c) => c.id));
setCollections((prev) => prev.filter((c) => !deleted.includes(c.id)));
if (selected.some((c) => c.bookmarked)) onBookmarksChanged?.();
showToast({ message: t("models:collections.selection.deleted", { count: deleted.length }) });
} catch (err) {
handleError(err, t("models:collections.detail.deleteFailed"));
} finally {
setBusy(null);
}
};
const mergeItems: MergeItem[] = selected.map((collection) => ({
key: collection.id,
name: collectionDisplayName(collection, t),
count: collection.item_count,
bookmarked: collection.bookmarked,
syncProvider: collection.sync
? (importProviderInfo(collection.sync.provider)?.label ?? collection.sync.provider)
: null,
thumbnail: collection.cover_items[0] ? (
renderPreviewContent(collection.cover_items[0], "card", theme, t, previewMode)
) : (
<Stack alignItems="center" justifyContent="center" sx={{ height: "100%", color: "text.disabled" }}>
<Inventory2OutlinedIcon fontSize="small" />
</Stack>
),
}));
const merge = async (primary: MergeItem, others: MergeItem[]) => {
const result = await collectionsApi.merge(
primary.key,
others.map((item) => item.key),
);
setCollections((prev) =>
prev
.filter((c) => !result.merged_ids.includes(c.id))
.map((c) => (c.id === result.collection.id ? result.collection : c)),
);
if (others.some((item) => item.bookmarked)) onBookmarksChanged?.();
selection.setActive(false);
showToast({
message: t("models:collections.selection.merged", { count: others.length, name: primary.name }),
severity: "success",
});
};
const sortOptions: { value: SortMode; label: string }[] = [
{ value: "newest", label: t("models:sort.newest") },
{ value: "name", label: t("models:tags.sortByName") },
{ value: "models", label: t("models:collections.sortByModels") },
];
const ownCount = collections.filter(isOwn).length;
const disabled = count === 0 || busy !== null;
const bookmarkLabel = allBookmarked
? t("models:collections.unbookmarkCollection")
: t("models:collections.bookmarkCollection");
return (
<Stack spacing={2}>
<Box sx={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 2, rowGap: 1 }}>
{ownCount > 0 && (
<SelectionToolbar
selection={selection}
total={ownCount}
busy={busy !== null}
labels={{
enter: t("models:collections.selection.enter"),
exit: t("models:selection.exit"),
toolbar: t("models:collections.selection.toolbar"),
selectAllLoaded: t("common:selectAll"),
}}
dialogs={
<MergeDialog
open={mergeOpen}
onClose={() => setMergeOpen(false)}
kind="collection"
items={mergeItems}
loadPreview={() => collectionsApi.previewMerge(mergeItems.map((item) => item.key))}
onMerge={merge}
onUnauthorized={onUnauthorized}
/>
}
>
<SelectionAction
label={t("models:merge.action")}
hint={t("models:merge.needTwo")}
icon={<CallMergeIcon fontSize="small" />}
disabled={count < 2 || busy !== null}
onClick={() => setMergeOpen(true)}
/>
<SelectionAction
label={bookmarkLabel}
icon={
busy === "bookmark" ? (
<CircularProgress size={18} />
) : allBookmarked ? (
<BookmarkIcon fontSize="small" color="primary" />
) : (
<BookmarkBorderIcon fontSize="small" />
)
}
disabled={disabled}
onClick={() => void toggleBookmarks()}
/>
<SelectionAction
label={t("common:delete")}
icon={
busy === "delete" ? (
<CircularProgress size={18} />
) : (
<DeleteOutlineIcon fontSize="small" color={disabled ? undefined : "error"} />
)
}
disabled={disabled}
onClick={() => void removeSelected()}
/>
</SelectionToolbar>
)}
<Box sx={{ ml: "auto" }}>
<SortTabs value={sortMode} options={sortOptions} onChange={setSortMode} />
</Box>
</Box>
{loading ? (
<Box sx={cardGridSx(6)}>
<CardSkeletons count={12} variant="collection" />
</Box>
) : collections.length ? (
<Box sx={cardGridSx(6)}>
{collections.map((collection) => (
{sorted.map((collection) => (
<CollectionCard
key={collection.id}
collection={collection}
@@ -88,12 +286,18 @@ export default function CollectionsPage({ onUnauthorized, onBookmarksChanged, th
onDeleted={handleCollectionDeleted}
onUnauthorized={onUnauthorized}
onBookmarksChanged={onBookmarksChanged}
selectMode={selection.active}
selected={selection.isSelected(collection.id)}
onToggleSelect={(range) => selection.toggle(collection.id, range)}
/>
))}
</Box>
) : (
<Stack alignItems="center" spacing={1} sx={{ py: 8, color: "text.secondary" }}>
<Stack alignItems="center" spacing={1.5} sx={{ py: 8, color: "text.secondary" }}>
<Typography variant="body2">{t("models:collections.empty")}</Typography>
<Button size="small" variant="outlined" startIcon={<AddIcon />} onClick={() => setFormOpen(true)}>
{t("models:collections.newCollection")}
</Button>
</Stack>
)}
+8
View File
@@ -46,6 +46,7 @@ const ACTION_COLORS: Record<LogAction, ActionColor> = {
collection_created: "success",
collection_edited: "warning",
collection_deleted: "error",
collections_merged: "warning",
collection_item_added: "info",
collection_item_removed: "default",
collection_sync_enabled: "info",
@@ -53,6 +54,7 @@ const ACTION_COLORS: Record<LogAction, ActionColor> = {
ai_categorization_run: "info",
ai_description_run: "info",
tag_deleted: "error",
tags_merged: "warning",
user_role_changed: "warning",
user_deleted: "error",
provider_toggled: "warning",
@@ -177,6 +179,12 @@ export default function LogsPage({ onUnauthorized }: Props) {
email: typeof d.email === "string" ? d.email : "",
count: typeof d.models === "number" ? d.models : 0,
});
case "collections_merged":
case "tags_merged":
return t("adminSettings.logs.merged", {
name: typeof d.name === "string" ? d.name : "",
merged: Array.isArray(d.merged) ? d.merged.join(", ") : "",
});
case "tag_deleted":
return t("adminSettings.logs.tagDeleted", {
name: typeof d.name === "string" ? d.name : "",
@@ -1,35 +0,0 @@
import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { type TagSortMode } from "../../api/tags";
const SORT_MODES: TagSortMode[] = ["popular", "name"];
type Props = {
value: TagSortMode;
onChange: (mode: TagSortMode) => void;
};
export default function TagSortTabs({ value, onChange }: Props) {
const { t } = useTranslation("models");
return (
<Stack direction="row" spacing={3}>
{SORT_MODES.map((mode) => (
<Typography
key={mode}
variant="body2"
onClick={() => onChange(mode)}
sx={{
cursor: "pointer",
fontSize: 14,
fontWeight: value === mode ? 700 : 500,
color: value === mode ? "primary.main" : "text.secondary",
"&:hover": { color: "primary.main" },
}}
>
{mode === "popular" ? t("sort.popular") : t("tags.sortByName")}
</Typography>
))}
</Stack>
);
}
+101 -41
View File
@@ -1,4 +1,5 @@
import { memo } from "react";
import type React from "react";
import { useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom";
import BookmarkIcon from "@mui/icons-material/Bookmark";
@@ -9,6 +10,7 @@ import IconButton from "@mui/material/IconButton";
import Skeleton from "@mui/material/Skeleton";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import { alpha } from "@mui/material/styles";
import { printsApi } from "../../api/prints";
import TintedRender from "../../components/media/TintedRender";
import { useRenderColor } from "../../hooks/useAccentColor";
@@ -18,6 +20,7 @@ import type { TagSummary } from "../../api/tags";
import { colorForTag } from "../../utils/tagColors";
import TagActionsMenu from "../TagDetailPage/TagActionsMenu";
import { useCardContextMenu } from "../../components/Layout/ConfigurationDialog/AppTiles";
import SelectionCheck from "../../components/selection/SelectionCheck";
/** Grid areas for a mosaic's pictures, from its rows of letters. */
const areas = (rows: string[]) => rows.map((row) => `"${row.split("").join(" ")}"`).join(" ");
@@ -27,6 +30,11 @@ type Props = {
onToggleBookmark: (tag: TagSummary) => void;
onDeleted: (tag: TagSummary) => void;
onUnauthorized?: () => void;
/** Select mode: a click selects the tile instead of opening the tag. */
selectMode?: boolean;
selected?: boolean;
/** The same function for every tile, so selecting one doesn't redraw the rest. */
onToggleSelect?: (tag: TagSummary, range: boolean) => void;
};
const overlayButtonSx = {
@@ -38,7 +46,15 @@ const overlayButtonSx = {
/** A square for a tag: a mosaic of its models, its bookmark top left, its name and count along the bottom, and its menu
* on hover or right-click. */
function TagTile({ tag, onToggleBookmark, onDeleted, onUnauthorized }: Props) {
function TagTile({
tag,
onToggleBookmark,
onDeleted,
onUnauthorized,
selectMode = false,
selected = false,
onToggleSelect,
}: Props) {
const { t } = useTranslation(["models", "common"]);
const fallback = colorForTag(tag.name);
const contextMenu = useCardContextMenu();
@@ -47,10 +63,26 @@ function TagTile({ tag, onToggleBookmark, onDeleted, onUnauthorized }: Props) {
const tint = renderSwatch(useRenderColor()).color;
// Led as the model cards are: renders when chosen and some are made, the pictures otherwise.
const renderMosaic = preference === "renders" ? tag.render_mosaic_url : null;
const toggleSelect = (event: React.MouseEvent | React.KeyboardEvent) => onToggleSelect?.(tag, event.shiftKey);
// In select mode the tile is a checkbox rather than a link to the tag.
const target = selectMode
? {
component: "div" as const,
role: "checkbox",
"aria-checked": selected,
tabIndex: 0,
onClick: toggleSelect,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key !== " " && event.key !== "Enter") return;
event.preventDefault();
toggleSelect(event);
},
}
: { component: RouterLink, to: `/models/tags/${encodeURIComponent(tag.name)}` };
return (
<Box
onContextMenu={contextMenu.onContextMenu}
onContextMenu={selectMode ? undefined : contextMenu.onContextMenu}
sx={{
position: "relative",
aspectRatio: "1 / 1",
@@ -60,21 +92,26 @@ function TagTile({ tag, onToggleBookmark, onDeleted, onUnauthorized }: Props) {
borderColor: "divider",
bgcolor: tag.mosaic_url ? "divider" : fallback.bg,
transition: "transform 150ms, box-shadow 150ms",
"&:hover": { transform: "translateY(-2px)", boxShadow: "0 6px 16px rgba(0, 0, 0, 0.16)" },
"&:hover .tag-mosaic > *": { transform: "scale(1.04)" },
"&:hover .tag-tile-menu, &:focus-within .tag-tile-menu": { opacity: 1 },
...(selectMode
? { userSelect: "none" }
: {
"&:hover": { transform: "translateY(-2px)", boxShadow: "0 6px 16px rgba(0, 0, 0, 0.16)" },
"&:hover .tag-mosaic > *": { transform: "scale(1.04)" },
"&:hover .tag-tile-menu, &:focus-within .tag-tile-menu": { opacity: 1 },
}),
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
}}
>
{/* The menu and bookmark sit beside the link, not in it: a click in the menu's dialogs would otherwise open
the tag. */}
<Box
component={RouterLink}
to={`/models/tags/${encodeURIComponent(tag.name)}`}
{...target}
aria-label={tag.name}
sx={{
position: "absolute",
inset: 0,
borderRadius: "inherit",
cursor: "pointer",
"&:focus-visible": { outline: "2px solid", outlineColor: "primary.main", outlineOffset: -3 },
}}
>
@@ -162,45 +199,68 @@ function TagTile({ tag, onToggleBookmark, onDeleted, onUnauthorized }: Props) {
</Tooltip>
</Box>
</Box>
<Box
className="tag-tile-menu"
sx={{
position: "absolute",
top: 6,
right: 6,
opacity: 0,
transition: "opacity 150ms",
// No hover on a touch screen, so the menu stays in reach.
"@media (hover: none)": { opacity: 1 },
}}
>
<TagActionsMenu
tag={tag.name}
onUnauthorized={onUnauthorized}
onDeleted={() => onDeleted(tag)}
iconFontSize={18}
contextMenuAt={contextMenu.at}
onContextMenuClose={contextMenu.close}
triggerSx={{ width: 30, height: 30, ...overlayButtonSx }}
/>
</Box>
<Tooltip title={bookmarkLabel}>
<IconButton
size="small"
aria-label={bookmarkLabel}
aria-pressed={tag.bookmarked}
onClick={() => onToggleBookmark(tag)}
{selectMode && (
<>
{/* An inset ring and a wash of the accent: the mosaic stays readable under both. */}
<Box
aria-hidden
sx={(theme) => ({
position: "absolute",
inset: 0,
borderRadius: "inherit",
pointerEvents: "none",
boxShadow: selected ? `inset 0 0 0 3px ${theme.palette.primary.main}` : "none",
bgcolor: selected ? alpha(theme.palette.primary.main, 0.18) : "transparent",
transition: "background-color 150ms, box-shadow 150ms",
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
})}
/>
<SelectionCheck selected={selected} sx={{ pointerEvents: "none" }} />
</>
)}
{!selectMode && (
<Box
className="tag-tile-menu"
sx={{
position: "absolute",
top: 6,
left: 6,
...overlayButtonSx,
color: tag.bookmarked ? "primary.main" : overlayButtonSx.color,
right: 6,
opacity: 0,
transition: "opacity 150ms",
// No hover on a touch screen, so the menu stays in reach.
"@media (hover: none)": { opacity: 1 },
}}
>
{tag.bookmarked ? <BookmarkIcon fontSize="small" /> : <BookmarkBorderIcon fontSize="small" />}
</IconButton>
</Tooltip>
<TagActionsMenu
tag={tag.name}
onUnauthorized={onUnauthorized}
onDeleted={() => onDeleted(tag)}
iconFontSize={18}
contextMenuAt={contextMenu.at}
onContextMenuClose={contextMenu.close}
triggerSx={{ width: 30, height: 30, ...overlayButtonSx }}
/>
</Box>
)}
{!selectMode && (
<Tooltip title={bookmarkLabel}>
<IconButton
size="small"
aria-label={bookmarkLabel}
aria-pressed={tag.bookmarked}
onClick={() => onToggleBookmark(tag)}
sx={{
position: "absolute",
top: 6,
left: 6,
...overlayButtonSx,
color: tag.bookmarked ? "primary.main" : overlayButtonSx.color,
}}
>
{tag.bookmarked ? <BookmarkIcon fontSize="small" /> : <BookmarkBorderIcon fontSize="small" />}
</IconButton>
</Tooltip>
)}
</Box>
);
}
+198 -10
View File
@@ -1,13 +1,26 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Switch from "@mui/material/Switch";
import FormControlLabel from "@mui/material/FormControlLabel";
import BookmarkIcon from "@mui/icons-material/Bookmark";
import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder";
import CallMergeIcon from "@mui/icons-material/CallMerge";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import LocalOfferOutlinedIcon from "@mui/icons-material/LocalOfferOutlined";
import { UnauthorizedError } from "../../api/client";
import { printsApi } from "../../api/prints";
import { type TagSortMode, type TagSummary, tagsApi } from "../../api/tags";
import TagSortTabs from "./TagSortTabs";
import SortTabs from "../../components/controls/SortTabs";
import MergeDialog, { type MergeItem } from "../../components/MergeDialog";
import SelectionToolbar, { SelectionAction } from "../../components/selection/SelectionToolbar";
import { useConfirm } from "../../components/ConfirmProvider";
import { useToast } from "../../components/ToastProvider";
import { useSelection } from "../../hooks/useSelection";
import { colorForTag } from "../../utils/tagColors";
import TagTile, { TagTileSkeletons } from "./TagTile";
import VirtualTagGrid from "./VirtualTagGrid";
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll";
@@ -25,6 +38,31 @@ function pageSizeForScreen(): number {
return Math.min(100, Math.max(30, Math.ceil(columns * rows * 1.5)));
}
const tagKey = (tag: TagSummary) => tag.name;
type Busy = "bookmark" | "delete" | null;
/** The tag's mosaic, or its colour and icon while it has no pictures. */
function TagThumbnail({ tag }: { tag: TagSummary }) {
const fallback = colorForTag(tag.name);
return tag.mosaic_url ? (
<Box
component="img"
src={printsApi.fileUrl(tag.mosaic_url)}
alt=""
sx={{ display: "block", width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<Stack
alignItems="center"
justifyContent="center"
sx={{ height: "100%", bgcolor: fallback.bg, color: fallback.text }}
>
<LocalOfferOutlinedIcon fontSize="small" />
</Stack>
);
}
const TILE_GRID = {
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(min(160px, calc(50% - 6px)), 1fr))",
@@ -35,6 +73,8 @@ const TILE_GRID = {
* (under 2 models) are hidden by default. */
export default function TagsPage({ onUnauthorized, onBookmarksChanged }: Props) {
const { t } = useTranslation(["models", "common"]);
const confirm = useConfirm();
const showToast = useToast();
const [tags, setTags] = useState<TagSummary[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
@@ -46,15 +86,26 @@ export default function TagsPage({ onUnauthorized, onBookmarksChanged }: Props)
const [pageSize] = useState(pageSizeForScreen);
// Bumped whenever the sort or filter changes, so a page still on its way for the old one is dropped.
const generation = useRef(0);
// Bumped to load the list again from the top, e.g. after a merge changed the counts.
const [reloadKey, setReloadKey] = useState(0);
const [mergeOpen, setMergeOpen] = useState(false);
const [busy, setBusy] = useState<Busy>(null);
const selection = useSelection(tags, tagKey);
const { selected } = selection;
const count = selected.length;
const allBookmarked = count > 0 && selected.every((tag) => tag.bookmarked);
// A ref, so the callback the tiles get stays the same while the selection changes.
const toggleRef = useRef(selection.toggle);
toggleRef.current = selection.toggle;
const onToggleSelect = useCallback((tag: TagSummary, range: boolean) => toggleRef.current(tag.name, range), []);
const handleError = (err: unknown, message?: string) => {
const handleError = (err: unknown, message: string) => {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return true;
return;
}
console.error(err);
if (message) alert(message);
return false;
showToast({ message: err instanceof Error && err.message ? err.message : message, severity: "error" });
};
useEffect(() => {
@@ -75,7 +126,7 @@ export default function TagsPage({ onUnauthorized, onBookmarksChanged }: Props)
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortMode, minCount, pageSize]);
}, [sortMode, minCount, pageSize, reloadKey]);
const loadMore = useCallback(async () => {
const current = generation.current;
@@ -113,7 +164,7 @@ export default function TagsPage({ onUnauthorized, onBookmarksChanged }: Props)
return;
}
console.error(err);
alert(t("models:tags.bookmarkFailed"));
showToast({ message: t("models:tags.bookmarkFailed"), severity: "error" });
} finally {
pendingTag.current = null;
}
@@ -129,9 +180,82 @@ export default function TagsPage({ onUnauthorized, onBookmarksChanged }: Props)
);
const onToggleBookmark = useCallback((tag: TagSummary) => void toggleBookmark(tag), [toggleBookmark]);
// One at a time: each new bookmark takes the next place in the list.
const toggleBookmarks = async () => {
const bookmark = !allBookmarked;
setBusy("bookmark");
try {
for (const tag of selected) {
if (tag.bookmarked === bookmark) continue;
await (bookmark ? tagsApi.bookmark(tag.name) : tagsApi.unbookmark(tag.name));
setTags((prev) => prev.map((other) => (other.name === tag.name ? { ...other, bookmarked: bookmark } : other)));
}
showToast({
message: t(bookmark ? "models:tags.selection.bookmarked" : "models:tags.selection.unbookmarked", { count }),
});
} catch (err) {
handleError(err, t("models:tags.bookmarkFailed"));
} finally {
onBookmarksChanged?.();
setBusy(null);
}
};
const removeSelected = async () => {
const confirmed = await confirm({
title: t("models:tags.selection.deleteTitle", { count }),
message: t("models:tags.selection.deleteMessage", { count }),
confirmLabel: t("models:tags.selection.deleteConfirm", { count }),
destructive: true,
});
if (!confirmed) return;
setBusy("delete");
try {
const { deleted } = await tagsApi.removeMany(selected.map((tag) => tag.name));
setTags((prev) => prev.filter((tag) => !deleted.includes(tag.name)));
if (selected.some((tag) => tag.bookmarked)) onBookmarksChanged?.();
showToast({ message: t("models:tags.selection.deleted", { count: deleted.length }) });
} catch (err) {
handleError(err, t("models:tags.deleteFailed"));
} finally {
setBusy(null);
}
};
const mergeItems: MergeItem[] = selected.map((tag) => ({
key: tag.name,
name: tag.name,
count: tag.count,
bookmarked: tag.bookmarked,
thumbnail: <TagThumbnail tag={tag} />,
}));
const merge = async (primary: MergeItem, others: MergeItem[]) => {
await tagsApi.merge(
primary.key,
others.map((item) => item.key),
);
if (others.some((item) => item.bookmarked)) onBookmarksChanged?.();
selection.setActive(false);
// Counts and pictures changed on the server, so the list comes again rather than being patched here.
setReloadKey((key) => key + 1);
showToast({
message: t("models:tags.selection.merged", { count: others.length, name: primary.name }),
severity: "success",
});
};
const sortOptions: { value: TagSortMode; label: string }[] = [
{ value: "popular", label: t("models:sort.popular") },
{ value: "newest", label: t("models:sort.newest") },
{ value: "name", label: t("models:tags.sortByName") },
];
const disabled = count === 0 || busy !== null;
const bookmarkLabel = allBookmarked ? t("models:tags.unbookmarkTag") : t("models:tags.bookmarkTag");
return (
<Stack spacing={2}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Box sx={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 2, rowGap: 1 }}>
<FormControlLabel
control={
<Switch size="small" checked={hideRarelyUsed} onChange={(e) => setHideRarelyUsed(e.target.checked)} />
@@ -142,8 +266,69 @@ export default function TagsPage({ onUnauthorized, onBookmarksChanged }: Props)
</Typography>
}
/>
<TagSortTabs value={sortMode} onChange={setSortMode} />
</Stack>
{anyTags && (
<SelectionToolbar
selection={selection}
total={tags.length}
hasMore={hasMore}
busy={busy !== null}
labels={{
enter: t("models:tags.selection.enter"),
exit: t("models:selection.exit"),
toolbar: t("models:tags.selection.toolbar"),
selectAllLoaded: t("models:tags.selection.selectAllLoaded"),
}}
dialogs={
<MergeDialog
open={mergeOpen}
onClose={() => setMergeOpen(false)}
kind="tag"
items={mergeItems}
loadPreview={() => tagsApi.previewMerge(mergeItems.map((item) => item.key))}
onMerge={merge}
onUnauthorized={onUnauthorized}
/>
}
>
<SelectionAction
label={t("models:merge.action")}
hint={t("models:merge.needTwo")}
icon={<CallMergeIcon fontSize="small" />}
disabled={count < 2 || busy !== null}
onClick={() => setMergeOpen(true)}
/>
<SelectionAction
label={bookmarkLabel}
icon={
busy === "bookmark" ? (
<CircularProgress size={18} />
) : allBookmarked ? (
<BookmarkIcon fontSize="small" color="primary" />
) : (
<BookmarkBorderIcon fontSize="small" />
)
}
disabled={disabled}
onClick={() => void toggleBookmarks()}
/>
<SelectionAction
label={t("common:delete")}
icon={
busy === "delete" ? (
<CircularProgress size={18} />
) : (
<DeleteOutlineIcon fontSize="small" color={disabled ? undefined : "error"} />
)
}
disabled={disabled}
onClick={() => void removeSelected()}
/>
</SelectionToolbar>
)}
<Box sx={{ ml: "auto" }}>
<SortTabs value={sortMode} options={sortOptions} onChange={setSortMode} />
</Box>
</Box>
{loading ? (
<Box sx={TILE_GRID}>
@@ -161,6 +346,9 @@ export default function TagsPage({ onUnauthorized, onBookmarksChanged }: Props)
onToggleBookmark={onToggleBookmark}
onUnauthorized={onUnauthorized}
onDeleted={removeTag}
selectMode={selection.active}
selected={selection.isSelected(tag.name)}
onToggleSelect={onToggleSelect}
/>
)}
/>