mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat(tags): add pagination, thumbnails, and tag deletion
This commit is contained in:
1 parent
8ef8cd4478
commit
cdf0a53251
18 files changed
+846
-93
No files matched your search
@@ -209,6 +209,11 @@ export function toPlateOut(printId: string, plate: Plate): PlateOut {
|
||||
};
|
||||
}
|
||||
|
||||
/** A model's picture for a mosaic: its first image, else its first plate's render; null with neither on disk. */
|
||||
export function printCoverUrl(previewImageId: string | null, plateId: string | null): string | null {
|
||||
return (previewImageId && previewImageUrl(previewImageId)) || (plateId && plateThumbUrl(plateId)) || null;
|
||||
}
|
||||
|
||||
function previewImageUrl(id: string): string | null {
|
||||
if (!previewImageExists(id)) return null;
|
||||
const mtime = fs.statSync(previewImagePath(id)).mtimeMs;
|
||||
|
||||
+109
-13
@@ -4,7 +4,11 @@ import { prisma } from "../db";
|
||||
import { requireAuth } from "../auth";
|
||||
import { asyncHandler } from "../utils/asyncHandler";
|
||||
import { normalizeTag } from "../utils/tagNormalization";
|
||||
import { printCoverUrl } from "../dto";
|
||||
import { addTagBookmark, listBookmarkedTagSet, removeTagBookmark } from "../services/bookmarkService";
|
||||
import { createLog } from "../services/auditLog";
|
||||
import { relocatePrintsForToken } from "../services/printService";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
@@ -15,32 +19,97 @@ function sortedBookmarks(tags: Iterable<string>): string[] {
|
||||
|
||||
// Every tag in the library (unlike GET /tags, which follows the grid filter).
|
||||
|
||||
const sortSchema = z.enum(["popular", "name"]).catch("popular");
|
||||
/** Pictures behind each tag's tile, from its newest models. */
|
||||
const TAG_THUMBNAILS = 10;
|
||||
/** Models looked at per tag for those pictures; more than ten, as some models have none yet. */
|
||||
const TAG_THUMBNAIL_CANDIDATES = 30;
|
||||
|
||||
const summaryQuery = z.object({
|
||||
sort: z.enum(["popular", "name"]).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.
|
||||
min_count: z.coerce.number().int().min(1).catch(1),
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/tags/summary",
|
||||
asyncHandler(async (req, res) => {
|
||||
const sort = sortSchema.parse(req.query.sort);
|
||||
const { sort, limit, offset, min_count: minCount } = summaryQuery.parse(req.query);
|
||||
const [prints, bookmarked] = await Promise.all([
|
||||
prisma.print.findMany({ where: { userId: req.userId }, select: { tags: true } }),
|
||||
// Counting needs only the tags; pictures are joined below for this page's tags alone.
|
||||
prisma.print.findMany({
|
||||
where: { userId: req.userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, tags: true },
|
||||
}),
|
||||
listBookmarkedTagSet(req.userId!),
|
||||
]);
|
||||
const counts = new Map<string, number>();
|
||||
for (const print of prints) {
|
||||
for (const tag of print.tags) {
|
||||
const cleaned = normalizeTag(tag);
|
||||
if (!cleaned) continue;
|
||||
counts.set(cleaned, (counts.get(cleaned) ?? 0) + 1);
|
||||
for (const tag of new Set(print.tags.map(normalizeTag))) {
|
||||
if (tag) counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const tags = [...counts.entries()]
|
||||
.map(([name, count]) => ({ name, count, bookmarked: bookmarked.has(name) }))
|
||||
.toSorted((a, b) =>
|
||||
const matching = [...counts.entries()]
|
||||
.filter(([, count]) => count >= minCount)
|
||||
.toSorted(([a, countA], [b, countB]) =>
|
||||
sort === "name"
|
||||
? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
||||
: b.count - a.count || a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
|
||||
? a.toLowerCase().localeCompare(b.toLowerCase())
|
||||
: countB - countA || a.toLowerCase().localeCompare(b.toLowerCase()),
|
||||
);
|
||||
res.json(tags);
|
||||
const page = matching.slice(offset, offset + limit);
|
||||
|
||||
// Each of this page's tags' newest models, then their first image and plate in one query.
|
||||
const candidates = new Map(page.map(([name]) => [name, [] as string[]]));
|
||||
for (const print of prints) {
|
||||
for (const tag of new Set(print.tags.map(normalizeTag))) {
|
||||
const ids = candidates.get(tag);
|
||||
if (ids && ids.length < TAG_THUMBNAIL_CANDIDATES) ids.push(print.id);
|
||||
}
|
||||
}
|
||||
const pictureRows = await prisma.print.findMany({
|
||||
where: { id: { in: [...new Set([...candidates.values()].flat())] } },
|
||||
select: {
|
||||
id: true,
|
||||
previewImages: { orderBy: { position: "asc" }, take: 1, select: { id: true } },
|
||||
plates: { orderBy: { position: "asc" }, take: 1, select: { id: true } },
|
||||
},
|
||||
});
|
||||
const pictureIds = new Map(pictureRows.map((row) => [row.id, row]));
|
||||
// A model under several of the page's tags is looked up on disk once.
|
||||
const covers = new Map<string, string | null>();
|
||||
const coverOf = (id: string) => {
|
||||
if (!covers.has(id)) {
|
||||
const row = pictureIds.get(id);
|
||||
covers.set(id, row ? printCoverUrl(row.previewImages[0]?.id ?? null, row.plates[0]?.id ?? null) : null);
|
||||
}
|
||||
return covers.get(id);
|
||||
};
|
||||
const thumbnails = new Map<string, string[]>();
|
||||
for (const [tag, ids] of candidates) {
|
||||
const pictures: string[] = [];
|
||||
// Stops at ten, so the rest of the candidates aren't looked up on disk.
|
||||
for (const id of ids) {
|
||||
if (pictures.length >= TAG_THUMBNAILS) break;
|
||||
const cover = coverOf(id);
|
||||
if (cover) pictures.push(cover);
|
||||
}
|
||||
thumbnails.set(tag, pictures);
|
||||
}
|
||||
|
||||
res.json({
|
||||
items: page.map(([name, count]) => ({
|
||||
name,
|
||||
count,
|
||||
bookmarked: bookmarked.has(name),
|
||||
thumbnails: thumbnails.get(name) ?? [],
|
||||
})),
|
||||
total: matching.length,
|
||||
// Every tag, whatever its count: tells "none match the filter" from "no tags at all".
|
||||
total_all: counts.size,
|
||||
has_more: offset + page.length < matching.length,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -69,4 +138,31 @@ router.delete(
|
||||
}),
|
||||
);
|
||||
|
||||
// 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);
|
||||
// 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 });
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -25,7 +25,8 @@ export type LogAction =
|
||||
| "collection_sync_enabled"
|
||||
| "collection_sync_disabled"
|
||||
| "ai_categorization_run"
|
||||
| "ai_description_run";
|
||||
| "ai_description_run"
|
||||
| "tag_deleted";
|
||||
|
||||
/** Fire-and-forget: a logging failure must never break the action it records. */
|
||||
export async function createLog(params: {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import request from "supertest";
|
||||
import sharp from "sharp";
|
||||
import { createApp } from "../src/app";
|
||||
@@ -109,3 +111,101 @@ describe("a 3D render made while an import runs", () => {
|
||||
expect(await redOf(plateThumbPath(plate.id))).toBeGreaterThan(180);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tag tiles", () => {
|
||||
it("give each tag up to ten pictures, from its newest models that have one", async () => {
|
||||
const tag = `Mosaic-${Date.now()}`;
|
||||
const prints: { id: string; plateId: string }[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const res = await request(app).post("/api/upload").set(auth()).attach("files", Buffer.from(STL), `m${i}.stl`);
|
||||
const print = res.body.prints[0] as { id: string; plates: { id: string }[] };
|
||||
await request(app)
|
||||
.post(`/api/print/${print.id}/tags`)
|
||||
.set(auth())
|
||||
.send({ tags: [tag] });
|
||||
prints.push({ id: print.id, plateId: print.plates[0].id });
|
||||
}
|
||||
// The newest has no picture at all; the other eleven have a rendered plate.
|
||||
for (const { plateId } of prints.slice(0, 11)) {
|
||||
await fs.mkdir(path.dirname(plateThumbPath(plateId)), { recursive: true });
|
||||
await sharp(await png(200))
|
||||
.jpeg()
|
||||
.toFile(plateThumbPath(plateId));
|
||||
}
|
||||
|
||||
const res = await request(app).get("/api/tags/summary").set(auth());
|
||||
const tile = (res.body.items as { name: string; count: number; thumbnails: string[] }[]).find(
|
||||
(t) => t.name === tag,
|
||||
)!;
|
||||
expect(tile.count).toBe(12);
|
||||
expect(tile.thumbnails).toHaveLength(10);
|
||||
expect(tile.thumbnails[0]).toContain(`/plate/${prints[10].plateId}/thumb.jpg`);
|
||||
expect(tile.thumbnails.some((url) => url.includes(prints[11].plateId))).toBe(false);
|
||||
});
|
||||
|
||||
it("come a page at a time, leaving out tags under the minimum count", async () => {
|
||||
const own = (
|
||||
await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: "Pages", email: `tag-pages-${Date.now()}@example.com`, password: "password123" })
|
||||
).body.token as string;
|
||||
const tagged: string[][] = [
|
||||
["Alpha", "Beta"],
|
||||
["Alpha", "Beta"],
|
||||
["Alpha", "Gamma"],
|
||||
];
|
||||
for (const tags of tagged) {
|
||||
const res = await request(app)
|
||||
.post("/api/upload")
|
||||
.set({ Authorization: `Bearer ${own}` })
|
||||
.attach("files", Buffer.from(STL), "p.stl");
|
||||
await request(app)
|
||||
.post(`/api/print/${res.body.prints[0].id}/tags`)
|
||||
.set({ Authorization: `Bearer ${own}` })
|
||||
.send({ tags });
|
||||
}
|
||||
const page = (query: Record<string, string | number>) =>
|
||||
request(app)
|
||||
.get("/api/tags/summary")
|
||||
.set({ Authorization: `Bearer ${own}` })
|
||||
.query(query);
|
||||
|
||||
const first = await page({ limit: 1, offset: 0, min_count: 2 });
|
||||
expect(first.body.items.map((t: { name: string }) => t.name)).toEqual(["Alpha"]);
|
||||
expect(first.body).toMatchObject({ total: 2, total_all: 3, has_more: true });
|
||||
|
||||
const second = await page({ limit: 1, offset: 1, min_count: 2 });
|
||||
expect(second.body.items.map((t: { name: string; count: number }) => [t.name, t.count])).toEqual([["Beta", 2]]);
|
||||
expect(second.body.has_more).toBe(false);
|
||||
|
||||
const byName = await page({ sort: "name" });
|
||||
expect(byName.body.items.map((t: { name: string }) => t.name)).toEqual(["Alpha", "Beta", "Gamma"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleting a tag", () => {
|
||||
it("takes it off every model that has it, and drops its bookmark", async () => {
|
||||
const own = (
|
||||
await request(app)
|
||||
.post("/api/register")
|
||||
.send({ displayName: "Untag", email: `tag-delete-${Date.now()}@example.com`, password: "password123" })
|
||||
).body.token as string;
|
||||
const as = { Authorization: `Bearer ${own}` };
|
||||
const ids: string[] = [];
|
||||
for (const tags of [["Doomed", "Kept"], ["Doomed"], ["Kept"]]) {
|
||||
const res = await request(app).post("/api/upload").set(as).attach("files", Buffer.from(STL), "p.stl");
|
||||
ids.push(res.body.prints[0].id);
|
||||
await request(app).post(`/api/print/${res.body.prints[0].id}/tags`).set(as).send({ tags });
|
||||
}
|
||||
await request(app).post("/api/tags/Doomed/bookmark").set(as);
|
||||
|
||||
const res = await request(app).delete("/api/tags/doomed").set(as);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.models).toBe(2);
|
||||
const tagsOf = await Promise.all(
|
||||
ids.map(async (id) => (await request(app).get(`/api/print/${id}`).set(as)).body.tags),
|
||||
);
|
||||
expect(tagsOf).toEqual([["Kept"], [], ["Kept"]]);
|
||||
expect((await request(app).get("/api/tags/bookmarked").set(as)).body).toEqual([]);
|
||||
});
|
||||
});
|
||||
Generated
+28
@@ -16,6 +16,7 @@
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@mui/icons-material": "^6.1.0",
|
||||
"@mui/material": "^6.1.0",
|
||||
"@tanstack/react-virtual": "3.14.13",
|
||||
"fflate": "0.8.3",
|
||||
"i18next": "^23.15.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
@@ -1793,6 +1794,33 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.14.13",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.13.tgz",
|
||||
"integrity": "sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.17.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.17.11",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.11.tgz",
|
||||
"integrity": "sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@mui/icons-material": "^6.1.0",
|
||||
"@mui/material": "^6.1.0",
|
||||
"@tanstack/react-virtual": "3.14.13",
|
||||
"fflate": "0.8.3",
|
||||
"i18next": "^23.15.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
|
||||
@@ -37,7 +37,8 @@ export type LogAction =
|
||||
| "collection_sync_enabled"
|
||||
| "collection_sync_disabled"
|
||||
| "ai_categorization_run"
|
||||
| "ai_description_run";
|
||||
| "ai_description_run"
|
||||
| "tag_deleted";
|
||||
|
||||
export type LogEntry = {
|
||||
id: string;
|
||||
|
||||
@@ -7,15 +7,47 @@ export type TagSummary = {
|
||||
name: string;
|
||||
count: number;
|
||||
bookmarked: boolean;
|
||||
/** Up to 10 pictures of its newest models, for its tile. */
|
||||
thumbnails: string[];
|
||||
};
|
||||
|
||||
export type TagSummaryPage = {
|
||||
items: TagSummary[];
|
||||
/** Tags matching `min_count`. */
|
||||
total: number;
|
||||
/** Every tag, whatever its count. */
|
||||
total_all: number;
|
||||
has_more: boolean;
|
||||
};
|
||||
|
||||
export const tagsApi = {
|
||||
listSummary: async (sort: TagSortMode = "popular"): Promise<TagSummary[]> => {
|
||||
const res = await fetch(`${apiBase()}/tags/summary?sort=${sort}`, { headers: authHeaders() });
|
||||
listSummary: async (params: {
|
||||
sort: TagSortMode;
|
||||
limit: number;
|
||||
offset: number;
|
||||
minCount: number;
|
||||
}): Promise<TagSummaryPage> => {
|
||||
const query = new URLSearchParams({
|
||||
sort: params.sort,
|
||||
limit: String(params.limit),
|
||||
offset: String(params.offset),
|
||||
min_count: String(params.minCount),
|
||||
});
|
||||
const res = await fetch(`${apiBase()}/tags/summary?${query}`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to list tags");
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/** Takes the tag off every model that has it. */
|
||||
remove: async (tag: string): Promise<{ models: number }> => {
|
||||
const res = await fetch(`${apiBase()}/tags/${encodeURIComponent(tag)}`, {
|
||||
method: "DELETE",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
assertOk(res, "Failed to delete tag");
|
||||
return res.json();
|
||||
},
|
||||
|
||||
listBookmarked: async (): Promise<string[]> => {
|
||||
const res = await fetch(`${apiBase()}/tags/bookmarked`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to list bookmarked tags");
|
||||
|
||||
@@ -55,14 +55,3 @@ export function TableRowSkeletons({ rows, columns }: { rows: number; columns: nu
|
||||
</TableRow>
|
||||
));
|
||||
}
|
||||
|
||||
/** Pill-shaped placeholders for a wrap of chips, such as the tag list. */
|
||||
export function ChipSkeletons({ count }: { count: number }) {
|
||||
return (
|
||||
<Stack direction="row" flexWrap="wrap" useFlexGap spacing={1} aria-hidden>
|
||||
{Array.from({ length: count }, (_, index) => (
|
||||
<Skeleton key={index} variant="rounded" height={24} width={56 + ((index * 37) % 70)} sx={{ borderRadius: 4 }} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -932,11 +932,14 @@
|
||||
"collection_sync_disabled": "Collection sync turned off",
|
||||
"authors_linked": "Linked missing authors",
|
||||
"descriptions_refetched": "Refetched model descriptions",
|
||||
"tag_deleted": "Tag deleted",
|
||||
"ai_categorization_run": "AI categorization run",
|
||||
"ai_description_run": "AI description improvement run"
|
||||
},
|
||||
"authorsLinked": "{{count}} model(s) linked",
|
||||
"descriptionsRefetched": "{{email}} — {{updated}} of {{total}} description(s) changed",
|
||||
"tagDeleted_one": "{{name}} — removed from {{count}} model",
|
||||
"tagDeleted_other": "{{name}} — removed from {{count}} models",
|
||||
"aiRunSummary": "{{done}} of {{total}} checked — {{suggested}} suggested, {{applied}} applied, {{failed}} failed",
|
||||
"aiRunStopped": "(stopped)"
|
||||
},
|
||||
@@ -2138,6 +2141,13 @@
|
||||
"allHidden": "No tags with 2+ models. Turn off the filter above to see all tags.",
|
||||
"bookmarkTag": "Bookmark tag",
|
||||
"unbookmarkTag": "Remove bookmark",
|
||||
"deleteTag": "Delete tag",
|
||||
"deleteTitle": "Delete tag \"{{name}}\"?",
|
||||
"deleteMessage": "The tag is removed from every model that has it. The models themselves are kept.",
|
||||
"deleteConfirm": "Delete tag",
|
||||
"deleted_one": "Tag \"{{name}}\" removed from {{count}} model",
|
||||
"deleted_other": "Tag \"{{name}}\" removed from {{count}} models",
|
||||
"deleteFailed": "Failed to delete the tag. Please try again.",
|
||||
"bookmarkFailed": "Failed to update bookmark. Please try again.",
|
||||
"downloadAllZip": "Download all Tag models as zip",
|
||||
"downloadZipTitle": "Download tag \"{{name}}\" as zip",
|
||||
|
||||
@@ -940,11 +940,16 @@
|
||||
"collection_sync_disabled": "Išjungtas kolekcijos sinchronizavimas",
|
||||
"authors_linked": "Susieti trūkstami autoriai",
|
||||
"descriptions_refetched": "Iš naujo gauti modelių aprašymai",
|
||||
"tag_deleted": "Žyma ištrinta",
|
||||
"ai_categorization_run": "AI kategorizavimo paleidimas",
|
||||
"ai_description_run": "AI aprašymų tobulinimo paleidimas"
|
||||
},
|
||||
"authorsLinked": "Susieta modelių: {{count}}",
|
||||
"descriptionsRefetched": "{{email}} — pakeista aprašymų: {{updated}} iš {{total}}",
|
||||
"tagDeleted_one": "{{name}} — pašalinta iš {{count}} modelio",
|
||||
"tagDeleted_few": "{{name}} — pašalinta iš {{count}} modelių",
|
||||
"tagDeleted_many": "{{name}} — pašalinta iš {{count}} modelio",
|
||||
"tagDeleted_other": "{{name}} — pašalinta iš {{count}} modelių",
|
||||
"aiRunSummary": "Patikrinta {{done}} iš {{total}} — pasiūlyta {{suggested}}, pritaikyta {{applied}}, nepavyko {{failed}}",
|
||||
"aiRunStopped": "(sustabdyta)"
|
||||
},
|
||||
@@ -2186,6 +2191,15 @@
|
||||
"allHidden": "Nėra žymų su 2+ modeliais. Išjunkite filtrą aukščiau, kad matytumėte visas žymas.",
|
||||
"bookmarkTag": "Įsiminti žymą",
|
||||
"unbookmarkTag": "Pašalinti iš įsimintų",
|
||||
"deleteTag": "Ištrinti žymą",
|
||||
"deleteTitle": "Ištrinti žymą „{{name}}“?",
|
||||
"deleteMessage": "Žyma bus pašalinta iš visų ja pažymėtų modelių. Patys modeliai liks.",
|
||||
"deleteConfirm": "Ištrinti žymą",
|
||||
"deleted_one": "Žyma „{{name}}“ pašalinta iš {{count}} modelio",
|
||||
"deleted_few": "Žyma „{{name}}“ pašalinta iš {{count}} modelių",
|
||||
"deleted_many": "Žyma „{{name}}“ pašalinta iš {{count}} modelio",
|
||||
"deleted_other": "Žyma „{{name}}“ pašalinta iš {{count}} modelių",
|
||||
"deleteFailed": "Nepavyko ištrinti žymos. Bandykite dar kartą.",
|
||||
"bookmarkFailed": "Nepavyko atnaujinti įsimintos žymos. Bandykite dar kartą.",
|
||||
"downloadAllZip": "Atsisiųsti visus žymos modelius kaip zip",
|
||||
"downloadZipTitle": "Atsisiųsti žymą „{{name}}“ kaip zip",
|
||||
|
||||
@@ -52,6 +52,7 @@ const ACTION_COLORS: Record<LogAction, ActionColor> = {
|
||||
collection_sync_disabled: "default",
|
||||
ai_categorization_run: "info",
|
||||
ai_description_run: "info",
|
||||
tag_deleted: "error",
|
||||
};
|
||||
|
||||
function isoDateOnly(d: Date): string {
|
||||
@@ -139,6 +140,11 @@ export default function LogsPage({ onUnauthorized }: Props) {
|
||||
const failed = typeof d.failed === "number" ? d.failed : 0;
|
||||
return t("adminSettings.logs.importSummary", { label, imported, failed });
|
||||
}
|
||||
case "tag_deleted":
|
||||
return t("adminSettings.logs.tagDeleted", {
|
||||
name: typeof d.name === "string" ? d.name : "",
|
||||
count: typeof d.models === "number" ? d.models : 0,
|
||||
});
|
||||
case "ai_categorization_run":
|
||||
case "ai_description_run": {
|
||||
const count = (key: string) => (typeof d[key] === "number" ? d[key] : 0);
|
||||
|
||||
@@ -1,31 +1,82 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { tagsApi } from "../../api/tags";
|
||||
import { useConfirm } from "../../components/ConfirmProvider";
|
||||
import DownloadZipConfirmDialog from "../../components/DownloadZipConfirmDialog";
|
||||
import { useToast } from "../../components/ToastProvider";
|
||||
|
||||
type Props = {
|
||||
tag: string;
|
||||
onUnauthorized?: () => void;
|
||||
/** After the tag is taken off its models. */
|
||||
onDeleted?: () => void;
|
||||
/** For a trigger drawn over a picture, as on a tag's tile. */
|
||||
triggerSx?: SxProps<Theme>;
|
||||
iconFontSize?: number;
|
||||
};
|
||||
|
||||
export default function TagActionsMenu({ tag, onUnauthorized }: Props) {
|
||||
export default function TagActionsMenu({ tag, onUnauthorized, onDeleted, triggerSx, iconFontSize }: Props) {
|
||||
const { t } = useTranslation(["models", "common"]);
|
||||
const confirm = useConfirm();
|
||||
const showToast = useToast();
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [downloadOpen, setDownloadOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const closeMenu = () => setAnchorEl(null);
|
||||
|
||||
const deleteTag = async () => {
|
||||
const ok = await confirm({
|
||||
title: t("models:tags.deleteTitle", { name: tag }),
|
||||
message: t("models:tags.deleteMessage"),
|
||||
confirmLabel: t("models:tags.deleteConfirm"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const { models } = await tagsApi.remove(tag);
|
||||
showToast({ message: t("models:tags.deleted", { name: tag, count: models }) });
|
||||
onDeleted?.();
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else showToast({ message: t("models:tags.deleteFailed"), severity: "error" });
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton size="small" onClick={(e) => setAnchorEl(e.currentTarget)} aria-label={t("common:more") ?? undefined}>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Tooltip title={t("common:moreActions")}>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||
aria-label={t("common:moreActions") ?? undefined}
|
||||
disabled={deleting}
|
||||
sx={triggerSx}
|
||||
>
|
||||
{deleting ? (
|
||||
<CircularProgress size={iconFontSize ?? 18} />
|
||||
) : (
|
||||
<MoreVertIcon sx={iconFontSize ? { fontSize: iconFontSize } : undefined} fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={closeMenu}>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
@@ -38,6 +89,18 @@ export default function TagActionsMenu({ tag, onUnauthorized }: Props) {
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("models:tags.downloadAllZip")}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
closeMenu();
|
||||
void deleteTag();
|
||||
}}
|
||||
sx={{ color: "error.main" }}
|
||||
>
|
||||
<ListItemIcon sx={{ color: "inherit" }}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("models:tags.deleteTag")}</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<DownloadZipConfirmDialog
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
@@ -57,6 +57,7 @@ export default function TagDetailPage({ theme, previewMode, onUnauthorized, onBo
|
||||
});
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
usePageHeader({
|
||||
title: tag ? t("models:tags.detail.title", { name: tag }) : undefined,
|
||||
subtitle: tag ? t("models:tags.detail.subtitle") : undefined,
|
||||
@@ -68,7 +69,14 @@ export default function TagDetailPage({ theme, previewMode, onUnauthorized, onBo
|
||||
onUnauthorized={onUnauthorized}
|
||||
onBookmarksChanged={onBookmarksChanged}
|
||||
/>
|
||||
<TagActionsMenu tag={tag} onUnauthorized={onUnauthorized} />
|
||||
<TagActionsMenu
|
||||
tag={tag}
|
||||
onUnauthorized={onUnauthorized}
|
||||
onDeleted={() => {
|
||||
onBookmarksChanged?.();
|
||||
navigate("/models/tags");
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
) : undefined,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MOSAICS } from "./TagTile";
|
||||
|
||||
describe("tag tile mosaics", () => {
|
||||
it("split the 4 x 4 grid into exactly n rectangles for n pictures", () => {
|
||||
MOSAICS.forEach((rows, index) => {
|
||||
const pictures = index + 1;
|
||||
expect(rows).toHaveLength(4);
|
||||
const letters = [...new Set(rows.join(""))].toSorted();
|
||||
// a, b, c... one letter per picture, in order.
|
||||
expect(letters).toEqual(Array.from({ length: pictures }, (_, i) => String.fromCharCode(97 + i)));
|
||||
for (const letter of letters) {
|
||||
const cells = rows.flatMap((row, y) => [...row].flatMap((cell, x) => (cell === letter ? [[x, y]] : [])));
|
||||
const xs = cells.map(([x]) => x);
|
||||
const ys = cells.map(([, y]) => y);
|
||||
const width = Math.max(...xs) - Math.min(...xs) + 1;
|
||||
const height = Math.max(...ys) - Math.min(...ys) + 1;
|
||||
// A CSS grid area must be one rectangle: every cell of its bounding box is that letter.
|
||||
expect(cells).toHaveLength(width * height);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import BookmarkIcon from "@mui/icons-material/Bookmark";
|
||||
import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder";
|
||||
import LocalOfferOutlinedIcon from "@mui/icons-material/LocalOfferOutlined";
|
||||
import Box from "@mui/material/Box";
|
||||
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 { printsApi } from "../../api/prints";
|
||||
import type { TagSummary } from "../../api/tags";
|
||||
import { colorForTag } from "../../utils/tagColors";
|
||||
import TagActionsMenu from "../TagDetailPage/TagActionsMenu";
|
||||
|
||||
// A 4 x 4 grid split into as many pictures as the tag has, big and small together. Each letter is one picture.
|
||||
export const MOSAICS: string[][] = [
|
||||
["aaaa", "aaaa", "aaaa", "aaaa"],
|
||||
["aabb", "aabb", "aabb", "aabb"],
|
||||
["aabb", "aabb", "aacc", "aacc"],
|
||||
["aabc", "aabc", "aadd", "aadd"],
|
||||
["aabb", "aabb", "cdee", "cdee"],
|
||||
["aabc", "aabc", "ddee", "ffee"],
|
||||
["aabc", "aabd", "eeff", "eegg"],
|
||||
["abcc", "dbcc", "eeff", "ghff"],
|
||||
["aabc", "aade", "fgii", "hgii"],
|
||||
["aabc", "aade", "fghh", "fijj"],
|
||||
];
|
||||
|
||||
/** Grid areas for a mosaic's pictures, from its rows of letters. */
|
||||
const areas = (rows: string[]) => rows.map((row) => `"${row.split("").join(" ")}"`).join(" ");
|
||||
|
||||
type Props = {
|
||||
tag: TagSummary;
|
||||
onToggleBookmark: (tag: TagSummary) => void;
|
||||
onDeleted: (tag: TagSummary) => void;
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
const overlayButtonSx = {
|
||||
bgcolor: "rgba(255, 255, 255, 0.92)",
|
||||
color: "#5f6368",
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.2)",
|
||||
"&:hover, &.Mui-focusVisible": { bgcolor: "#ffffff", color: "primary.main" },
|
||||
} as const;
|
||||
|
||||
/** A square for a tag: a mosaic of its models, its name, count and bookmark along the bottom, and its menu on hover. */
|
||||
function TagTile({ tag, onToggleBookmark, onDeleted, onUnauthorized }: Props) {
|
||||
const { t } = useTranslation(["models", "common"]);
|
||||
const pictures = tag.thumbnails.slice(0, MOSAICS.length);
|
||||
const layout = pictures.length ? MOSAICS[pictures.length - 1] : null;
|
||||
const fallback = colorForTag(tag.name);
|
||||
const bookmarkLabel = tag.bookmarked ? t("models:tags.unbookmarkTag") : t("models:tags.bookmarkTag");
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
aspectRatio: "1 / 1",
|
||||
borderRadius: "14px",
|
||||
overflow: "hidden",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
bgcolor: layout ? "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 img": { transform: "scale(1.04)" },
|
||||
"&:hover .tag-tile-menu, &:focus-within .tag-tile-menu": { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
{/* 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)}`}
|
||||
aria-label={tag.name}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
borderRadius: "inherit",
|
||||
"&:focus-visible": { outline: "2px solid", outlineColor: "primary.main", outlineOffset: -3 },
|
||||
}}
|
||||
>
|
||||
{layout ? (
|
||||
<Box
|
||||
className="tag-mosaic"
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gridTemplateRows: "repeat(4, 1fr)",
|
||||
gridTemplateAreas: areas(layout),
|
||||
gap: "2px",
|
||||
}}
|
||||
>
|
||||
{pictures.map((url, index) => (
|
||||
<Box key={url} sx={{ gridArea: String.fromCharCode(97 + index), overflow: "hidden", minHeight: 0 }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={printsApi.fileUrl(url)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
sx={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
transition: "transform 300ms",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<LocalOfferOutlinedIcon
|
||||
aria-hidden
|
||||
sx={{ position: "absolute", inset: 0, m: "auto", fontSize: 56, color: fallback.text, opacity: 0.35 }}
|
||||
/>
|
||||
)}
|
||||
{/* Keeps the chip and the bookmark readable over any picture. */}
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "linear-gradient(to top, rgba(0, 0, 0, 0.4), transparent 40%)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ position: "absolute", bottom: 8, left: 8, right: 48, display: "flex" }}>
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 0.75,
|
||||
maxWidth: "100%",
|
||||
pl: 1.25,
|
||||
pr: 0.5,
|
||||
py: 0.5,
|
||||
borderRadius: "999px",
|
||||
bgcolor: "rgba(255, 255, 255, 0.92)",
|
||||
backdropFilter: "blur(6px)",
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.2)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" fontWeight={600} noWrap sx={{ color: "#1f1f1f", minWidth: 0 }}>
|
||||
{tag.name}
|
||||
</Typography>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
px: 0.75,
|
||||
borderRadius: "999px",
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 700,
|
||||
lineHeight: "18px",
|
||||
color: "#ffffff",
|
||||
bgcolor: "primary.main",
|
||||
}}
|
||||
>
|
||||
{tag.count}
|
||||
</Box>
|
||||
</Box>
|
||||
</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}
|
||||
triggerSx={{ width: 30, height: 30, ...overlayButtonSx }}
|
||||
/>
|
||||
</Box>
|
||||
<Tooltip title={bookmarkLabel}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={bookmarkLabel}
|
||||
aria-pressed={tag.bookmarked}
|
||||
onClick={() => onToggleBookmark(tag)}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 6,
|
||||
right: 6,
|
||||
...overlayButtonSx,
|
||||
color: tag.bookmarked ? "primary.main" : overlayButtonSx.color,
|
||||
}}
|
||||
>
|
||||
{tag.bookmarked ? <BookmarkIcon fontSize="small" /> : <BookmarkBorderIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// The page passes the same callbacks every time, so only a tile whose tag changed draws again.
|
||||
export default memo(TagTile);
|
||||
|
||||
// A few different mosaics, so a loading grid doesn't look stamped out.
|
||||
const SKELETON_MOSAICS = [4, 6, 2, 9, 5, 7].map((count) => MOSAICS[count]);
|
||||
|
||||
/** Stand-ins shaped like tiles: a mosaic of blocks, a pill for the name, a circle for the bookmark. */
|
||||
export function TagTileSkeletons({ count }: { count: number }) {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const layout = SKELETON_MOSAICS[index % SKELETON_MOSAICS.length];
|
||||
const blocks = new Set(layout.join("")).size;
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: "relative",
|
||||
aspectRatio: "1 / 1",
|
||||
borderRadius: "14px",
|
||||
overflow: "hidden",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gridTemplateRows: "repeat(4, 1fr)",
|
||||
gridTemplateAreas: areas(layout),
|
||||
gap: "2px",
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: blocks }, (_block, block) => (
|
||||
<Skeleton
|
||||
key={block}
|
||||
variant="rectangular"
|
||||
animation="wave"
|
||||
sx={{ gridArea: String.fromCharCode(97 + block), width: "100%", height: "100%" }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Skeleton
|
||||
variant="rounded"
|
||||
sx={{ position: "absolute", bottom: 10, left: 10, width: "48%", height: 26, borderRadius: "999px" }}
|
||||
/>
|
||||
<Skeleton variant="circular" sx={{ position: "absolute", bottom: 8, right: 8, width: 30, height: 30 }} />
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useWindowVirtualizer } from "@tanstack/react-virtual";
|
||||
import Box from "@mui/material/Box";
|
||||
import type { TagSummary } from "../../api/tags";
|
||||
import { TagTileSkeletons } from "./TagTile";
|
||||
|
||||
const MIN_TILE = 160;
|
||||
const GAP = 12;
|
||||
|
||||
type Props = {
|
||||
tags: TagSummary[];
|
||||
/** Skeleton tiles after the last tag, while the next page loads. */
|
||||
skeletons: number;
|
||||
renderTile: (tag: TagSummary) => ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* The tag tiles, only the rows near the screen in the page: hundreds of tags would otherwise keep thousands of
|
||||
* pictures in the page. Tiles are square, so each row's height follows from the width, without measuring.
|
||||
*/
|
||||
export default function VirtualTagGrid({ tags, skeletons, renderTile }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [top, setTop] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const measure = () => {
|
||||
setWidth(node.clientWidth);
|
||||
setTop(node.getBoundingClientRect().top + window.scrollY);
|
||||
};
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// As many 160px columns as fit, at least two, like the plain grid's auto-fill.
|
||||
const columns = width ? Math.max(2, Math.floor((width + GAP) / (MIN_TILE + GAP))) : 2;
|
||||
const tile = width ? (width - GAP * (columns - 1)) / columns : MIN_TILE;
|
||||
const cells = tags.length + skeletons;
|
||||
const rows = Math.ceil(cells / columns);
|
||||
|
||||
const virtualizer = useWindowVirtualizer({
|
||||
count: rows,
|
||||
estimateSize: () => tile + GAP,
|
||||
overscan: 3,
|
||||
scrollMargin: top,
|
||||
});
|
||||
// A new width changes every row's height.
|
||||
useEffect(() => {
|
||||
virtualizer.measure();
|
||||
// oxlint-disable-next-line react/exhaustive-effect-dependencies
|
||||
}, [virtualizer, tile]);
|
||||
|
||||
return (
|
||||
<Box ref={ref} sx={{ position: "relative", width: "100%", height: Math.max(0, virtualizer.getTotalSize() - GAP) }}>
|
||||
{virtualizer.getVirtualItems().map((row) => {
|
||||
const start = row.index * columns;
|
||||
const end = Math.min(start + columns, cells);
|
||||
const rowTags = tags.slice(start, Math.min(end, tags.length));
|
||||
return (
|
||||
<Box
|
||||
key={row.key}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${row.start - virtualizer.options.scrollMargin}px)`,
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
gap: `${GAP}px`,
|
||||
}}
|
||||
>
|
||||
{rowTags.map(renderTile)}
|
||||
{end > tags.length && <TagTileSkeletons count={end - Math.max(start, tags.length)} />}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,44 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Chip from "@mui/material/Chip";
|
||||
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 { UnauthorizedError } from "../../api/client";
|
||||
import { type TagSortMode, type TagSummary, tagsApi } from "../../api/tags";
|
||||
import { dividerBorderColor } from "../../theme";
|
||||
import TagSortTabs from "./TagSortTabs";
|
||||
import { ChipSkeletons } from "../../components/ListSkeleton";
|
||||
import TagTile, { TagTileSkeletons } from "./TagTile";
|
||||
import VirtualTagGrid from "./VirtualTagGrid";
|
||||
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll";
|
||||
|
||||
type Props = {
|
||||
onUnauthorized?: () => void;
|
||||
onBookmarksChanged?: () => void;
|
||||
};
|
||||
|
||||
/** Every tag as a chip with a bookmark toggle (the chip's deleteIcon slot). Rarely-used tags
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
const TILE_GRID = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(min(160px, calc(50% - 6px)), 1fr))",
|
||||
gap: 1.5,
|
||||
} as const;
|
||||
|
||||
/** Every tag as a square of its models' pictures, with its count and a bookmark toggle. Rarely-used tags
|
||||
* (under 2 models) are hidden by default. */
|
||||
export default function TagsPage({ onUnauthorized, onBookmarksChanged }: Props) {
|
||||
const { t } = useTranslation(["models", "common"]);
|
||||
const navigate = useNavigate();
|
||||
const [tags, setTags] = useState<TagSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [anyTags, setAnyTags] = useState(false);
|
||||
const [sortMode, setSortMode] = useState<TagSortMode>("popular");
|
||||
const [pendingTag, setPendingTag] = useState<string | null>(null);
|
||||
const [hideRarelyUsed, setHideRarelyUsed] = useState(true);
|
||||
const visibleTags = hideRarelyUsed ? tags.filter((tag) => tag.count >= 2) : tags;
|
||||
const minCount = hideRarelyUsed ? 2 : 1;
|
||||
// Bumped whenever the sort or filter changes, so a page still on its way for the old one is dropped.
|
||||
const generation = useRef(0);
|
||||
|
||||
const handleError = (err: unknown, message?: string) => {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
@@ -42,27 +51,49 @@ export default function TagsPage({ onUnauthorized, onBookmarksChanged }: Props)
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const current = ++generation.current;
|
||||
setLoading(true);
|
||||
setTags([]);
|
||||
(async () => {
|
||||
try {
|
||||
const result = await tagsApi.listSummary(sortMode);
|
||||
if (!cancelled) setTags(result);
|
||||
const page = await tagsApi.listSummary({ sort: sortMode, limit: PAGE_SIZE, offset: 0, minCount });
|
||||
if (current !== generation.current) return;
|
||||
setTags(page.items);
|
||||
setHasMore(page.has_more);
|
||||
setAnyTags(page.total_all > 0);
|
||||
} catch (err) {
|
||||
if (!cancelled) handleError(err, t("models:errors.loadFailed"));
|
||||
if (current === generation.current) handleError(err, t("models:errors.loadFailed"));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
if (current === generation.current) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sortMode]);
|
||||
}, [sortMode, minCount]);
|
||||
|
||||
const toggleBookmark = async (tag: TagSummary) => {
|
||||
if (pendingTag) return;
|
||||
setPendingTag(tag.name);
|
||||
const loadMore = useCallback(async () => {
|
||||
const current = generation.current;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await tagsApi.listSummary({ sort: sortMode, limit: PAGE_SIZE, offset: tags.length, minCount });
|
||||
if (current !== generation.current) return;
|
||||
setTags((prev) => [...prev, ...page.items]);
|
||||
setHasMore(page.has_more);
|
||||
} catch (err) {
|
||||
if (current === generation.current) handleError(err, t("models:errors.loadFailed"));
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sortMode, minCount, tags.length]);
|
||||
|
||||
const sentinelRef = useInfiniteScroll(() => void loadMore(), hasMore, loading || loadingMore);
|
||||
|
||||
// A ref, not state: the callbacks below stay the same between renders, so the memoized tiles don't redraw.
|
||||
const pendingTag = useRef<string | null>(null);
|
||||
const toggleBookmark = useCallback(async (tag: TagSummary) => {
|
||||
if (pendingTag.current) return;
|
||||
pendingTag.current = tag.name;
|
||||
const nextBookmarked = !tag.bookmarked;
|
||||
setTags((prev) => prev.map((t2) => (t2.name === tag.name ? { ...t2, bookmarked: nextBookmarked } : t2)));
|
||||
try {
|
||||
@@ -77,9 +108,19 @@ export default function TagsPage({ onUnauthorized, onBookmarksChanged }: Props)
|
||||
console.error(err);
|
||||
alert(t("models:tags.bookmarkFailed"));
|
||||
} finally {
|
||||
setPendingTag(null);
|
||||
pendingTag.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const removeTag = useCallback(
|
||||
(tag: TagSummary) => {
|
||||
setTags((prev) => prev.filter((other) => other.name !== tag.name));
|
||||
if (tag.bookmarked) onBookmarksChanged?.();
|
||||
},
|
||||
[onBookmarksChanged],
|
||||
);
|
||||
const onToggleBookmark = useCallback((tag: TagSummary) => void toggleBookmark(tag), [toggleBookmark]);
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
@@ -98,42 +139,29 @@ export default function TagsPage({ onUnauthorized, onBookmarksChanged }: Props)
|
||||
</Stack>
|
||||
|
||||
{loading ? (
|
||||
<ChipSkeletons count={28} />
|
||||
) : visibleTags.length ? (
|
||||
<Stack direction="row" flexWrap="wrap" useFlexGap spacing={1}>
|
||||
{visibleTags.map((tag) => (
|
||||
<Chip
|
||||
key={tag.name}
|
||||
size="small"
|
||||
clickable
|
||||
variant="outlined"
|
||||
onClick={() => navigate(`/models/tags/${encodeURIComponent(tag.name)}`)}
|
||||
label={`${tag.name} (${tag.count})`}
|
||||
deleteIcon={
|
||||
tag.bookmarked ? <BookmarkIcon fontSize="inherit" /> : <BookmarkBorderIcon fontSize="inherit" />
|
||||
}
|
||||
onDelete={() => void toggleBookmark(tag)}
|
||||
aria-label={
|
||||
tag.bookmarked
|
||||
? (t("models:tags.unbookmarkTag") ?? undefined)
|
||||
: (t("models:tags.bookmarkTag") ?? undefined)
|
||||
}
|
||||
sx={{
|
||||
p: 1,
|
||||
color: (theme) => theme.thingport.navInactiveText,
|
||||
bgcolor: "background.paper",
|
||||
borderColor: dividerBorderColor,
|
||||
"& .MuiChip-deleteIcon": {
|
||||
color: tag.bookmarked ? "primary.main" : "inherit",
|
||||
"&:hover": { color: tag.bookmarked ? "primary.main" : "inherit" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Box sx={TILE_GRID}>
|
||||
<TagTileSkeletons count={12} />
|
||||
</Box>
|
||||
) : tags.length ? (
|
||||
<>
|
||||
<VirtualTagGrid
|
||||
tags={tags}
|
||||
skeletons={loadingMore ? 6 : 0}
|
||||
renderTile={(tag) => (
|
||||
<TagTile
|
||||
key={tag.name}
|
||||
tag={tag}
|
||||
onToggleBookmark={onToggleBookmark}
|
||||
onUnauthorized={onUnauthorized}
|
||||
onDeleted={removeTag}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{hasMore && <Box ref={sentinelRef} sx={{ height: 1 }} />}
|
||||
</>
|
||||
) : (
|
||||
<Stack alignItems="center" spacing={1} sx={{ py: 8, color: "text.secondary" }}>
|
||||
<Typography variant="body2">{tags.length ? t("models:tags.allHidden") : t("models:tags.empty")}</Typography>
|
||||
<Typography variant="body2">{anyTags ? t("models:tags.allHidden") : t("models:tags.empty")}</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
Reference in new issue
Block a user