mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
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:
1 parent
825e8395d8
commit
a0dbba8058
23 files changed
+2038
-271
No files matched your search
@@ -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
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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")));
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user