feat(tags): add pagination, thumbnails, and tag deletion

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-15 10:10:43 +02:00
1 parent 8ef8cd4478
commit cdf0a53251
18 files changed
+846 -93

No files matched your search

+5
View File
@@ -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
View File
@@ -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;
+2 -1
View File
@@ -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: {
+100
View File
@@ -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([]);
});
});
+28
View File
@@ -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",
+1
View File
@@ -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",
+2 -1
View File
@@ -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;
+34 -2
View File
@@ -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");
-11
View File
@@ -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>
);
}
+10
View File
@@ -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",
+14
View File
@@ -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",
+6
View File
@@ -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
+10 -2
View File
@@ -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);
}
});
});
});
+264
View File
@@ -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>
);
}
+87 -59
View File
@@ -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>