fix: improve bookmarks ui

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-13 10:47:19 +02:00
1 parent daac2271d8
commit 01f8812cef
8 files changed
+80 -161

No files matched your search

-1
View File
@@ -13,7 +13,6 @@
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"tags:normalize-casing": "tsx src/scripts/normalizeTagCasing.ts",
"test": "vitest run",
"lint": "oxlint ."
},
-18
View File
@@ -1,18 +0,0 @@
/** Manual/on-demand entry point for the tag-casing backfill (see services/tagCasingBackfill.ts).
* You shouldn't normally need to run this yourself -- server.ts already runs it automatically
* once, the first time the app boots after this feature shipped, tracked via a Setting row so
* later restarts skip it. This script is here for local dev (before that first boot) or to force
* a fresh pass on demand; it always runs, ignoring that tracking.
*
* Run via `npx tsx src/scripts/normalizeTagCasing.ts` (or `npm run tags:normalize-casing`) from
* backend/, against whatever DATABASE_URL the environment currently points at.
*/
import { prisma } from "../db";
import { normalizeAllTagCasing } from "../services/tagCasingBackfill";
normalizeAllTagCasing()
.then(() => prisma.$disconnect())
.catch((err) => {
console.error(err);
process.exit(1);
});
+3 -13
View File
@@ -1,7 +1,6 @@
import { createApp } from "./app";
import { API_PORT } from "./config";
import { prisma } from "./db"; // also ensures storage directories exist before we start serving
import { normalizeAllTagCasing } from "./services/tagCasingBackfill";
const app = createApp();
@@ -15,15 +14,6 @@ prisma.importJob
})
.catch((err) => console.error("Failed to recover stale import jobs on startup:", err));
// TEMPORARY: runs on every boot, no "already done" guard -- remove this call (and the now-unused
// import above) once this has run against prod after the next deploy. Awaited (unlike the
// stale-import-job cleanup above) so the app doesn't start serving requests against inconsistent
// tag casing, but a failure here still shouldn't block the app from starting at all, so it's
// caught and logged rather than left to crash the process.
normalizeAllTagCasing()
.catch((err) => console.error("Tag casing backfill failed to run:", err))
.finally(() => {
app.listen(API_PORT, () => {
console.log(`Thingport API listening on port ${API_PORT}`);
});
});
app.listen(API_PORT, () => {
console.log(`Thingport API listening on port ${API_PORT}`);
});
-70
View File
@@ -1,70 +0,0 @@
/** Backfill: rewrites every existing Print/Category/Collection tags array (and every User's
* bookmarked tags) to the canonical casing (normalizeTags) that every write path now enforces
* going forward -- so tags that only ever differed by casing ("skadis" / "SKADIS") collapse into
* one ("Skadis"). Safe to run more than once: normalizing already-normalized tags is a no-op per
* row, so a row is only touched (and only counted as "updated") when something actually changes.
*/
import { prisma } from "../db";
import { normalizeTags } from "../utils/tagNormalization";
function sameTags(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((tag, i) => tag === b[i]);
}
async function normalizePrints(): Promise<void> {
const rows = await prisma.print.findMany({ select: { id: true, tags: true } });
let updated = 0;
for (const row of rows) {
const next = normalizeTags(row.tags);
if (sameTags(row.tags, next)) continue;
await prisma.print.update({ where: { id: row.id }, data: { tags: next } });
updated++;
}
console.log(`Print: ${updated} of ${rows.length} row(s) updated`);
}
async function normalizeCategories(): Promise<void> {
const rows = await prisma.category.findMany({ select: { id: true, tags: true } });
let updated = 0;
for (const row of rows) {
const next = normalizeTags(row.tags);
if (sameTags(row.tags, next)) continue;
await prisma.category.update({ where: { id: row.id }, data: { tags: next } });
updated++;
}
console.log(`Category: ${updated} of ${rows.length} row(s) updated`);
}
async function normalizeCollections(): Promise<void> {
const rows = await prisma.collection.findMany({ select: { id: true, tags: true } });
let updated = 0;
for (const row of rows) {
const next = normalizeTags(row.tags);
if (sameTags(row.tags, next)) continue;
await prisma.collection.update({ where: { id: row.id }, data: { tags: next } });
updated++;
}
console.log(`Collection: ${updated} of ${rows.length} row(s) updated`);
}
async function normalizeBookmarkedTags(): Promise<void> {
const rows = await prisma.user.findMany({ select: { id: true, bookmarkedTags: true } });
let updated = 0;
for (const row of rows) {
const next = normalizeTags(row.bookmarkedTags);
if (sameTags(row.bookmarkedTags, next)) continue;
await prisma.user.update({ where: { id: row.id }, data: { bookmarkedTags: next } });
updated++;
}
console.log(`User.bookmarkedTags: ${updated} of ${rows.length} row(s) updated`);
}
/** Runs the full backfill unconditionally. Used by the standalone `tags:normalize-casing` script
* and by server.ts's startup call (see the TEMPORARY note there -- there's no "only once" guard
* here, so remove that call once this has run against prod after the next deploy). */
export async function normalizeAllTagCasing(): Promise<void> {
await normalizePrints();
await normalizeCategories();
await normalizeCollections();
await normalizeBookmarkedTags();
}
@@ -6,6 +6,7 @@ import { dividerBorderColor } from "../../../theme";
import Box from "@mui/material/Box";
import Link from "@mui/material/Link";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
@@ -97,10 +98,10 @@ type Props = {
tagBookmarksVersion?: number;
};
/** The persistent app-wide navigation rail: Dashboard, Models, Collections, Tags, Downloads, and
* (for admins) Administration. Category browsing lives inside the Models page itself, not here.
* Tags additionally grows a quick-access sub-list of bookmarked tags underneath it, once any
* exist and the sidebar isn't collapsed to its icon-only rail (no room for tag names there). */
/** The persistent app-wide navigation rail: Dashboard, Models, Collections, Tags, Downloads, then
* (once any tag is bookmarked) a divider, a "Bookmarks" heading, and one row per bookmarked tag,
* and finally (for admins) Administration. Category browsing lives inside the Models page
* itself, not here. */
export default function Sidebar({ isAdmin, onSelectCategory, tagBookmarksVersion }: Props) {
const { t } = useTranslation(["app", "common"]);
const location = useLocation();
@@ -256,56 +257,23 @@ export default function Sidebar({ isAdmin, onSelectCategory, tagBookmarksVersion
)}
{collapsed ? (
<>
<CollapsedNavIcon
icon={<LocalOfferIcon fontSize="small" />}
label={t("sidebar.tags")}
selected={onTags && location.pathname === "/models/tags"}
onClick={() => navigate("/models/tags")}
/>
{bookmarkedTags.map(tag => {
const target = `/models/tags/${encodeURIComponent(tag)}`;
return (
<CollapsedNavIcon
key={tag}
icon={<BookmarkIcon fontSize="small" />}
label={tag}
selected={location.pathname === target}
onClick={() => navigate(target)}
/>
);
})}
</>
<CollapsedNavIcon
icon={<LocalOfferIcon fontSize="small" />}
label={t("sidebar.tags")}
selected={onTags && location.pathname === "/models/tags"}
onClick={() => navigate("/models/tags")}
/>
) : (
<>
<ListItemButton
selected={onTags && location.pathname === "/models/tags"}
onClick={() => navigate("/models/tags")}
sx={{ borderRadius: 1, mb: 0.5, ...navRowSx(onTags && location.pathname === "/models/tags") }}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<LocalOfferIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t("sidebar.tags")} primaryTypographyProps={{ variant: "body2" }} />
</ListItemButton>
{bookmarkedTags.map(tag => {
const target = `/models/tags/${encodeURIComponent(tag)}`;
const selected = location.pathname === target;
return (
<ListItemButton
key={tag}
selected={selected}
onClick={() => navigate(target)}
sx={{ borderRadius: 1, mb: 0.5, pl: 4, ...navRowSx(selected) }}
>
<ListItemText
primary={tag}
primaryTypographyProps={{ variant: "body2", noWrap: true }}
/>
</ListItemButton>
);
})}
</>
<ListItemButton
selected={onTags && location.pathname === "/models/tags"}
onClick={() => navigate("/models/tags")}
sx={{ borderRadius: 1, mb: 0.5, ...navRowSx(onTags && location.pathname === "/models/tags") }}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<LocalOfferIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t("sidebar.tags")} primaryTypographyProps={{ variant: "body2" }} />
</ListItemButton>
)}
{collapsed ? (
@@ -328,6 +296,50 @@ export default function Sidebar({ isAdmin, onSelectCategory, tagBookmarksVersion
</ListItemButton>
)}
{/* Bookmarked tags -- only once any exist, so an empty section never shows just a bare
divider with nothing under it. The "Bookmarks" label is a plain heading (nothing to
click), so it's skipped entirely while collapsed rather than rendered as dead space
-- unlike every row above, the collapsed rail has no way to show it at all. */}
{bookmarkedTags.length > 0 && (
<>
<Divider sx={{ my: 1 }} />
{!collapsed && (
<Typography
variant="caption"
fontWeight={700}
sx={{ display: "block", px: 1.5, mb: 0.5, color: (theme) => theme.thingport.navInactiveText }}
>
{t("sidebar.bookmarks")}
</Typography>
)}
{bookmarkedTags.map(tag => {
const target = `/models/tags/${encodeURIComponent(tag)}`;
const selected = location.pathname === target;
return collapsed ? (
<CollapsedNavIcon
key={tag}
icon={<BookmarkIcon fontSize="small" />}
label={tag}
selected={selected}
onClick={() => navigate(target)}
/>
) : (
<ListItemButton
key={tag}
selected={selected}
onClick={() => navigate(target)}
sx={{ borderRadius: 1, mb: 0.5, ...navRowSx(selected) }}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<BookmarkIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={tag} primaryTypographyProps={{ variant: "body2", noWrap: true }} />
</ListItemButton>
);
})}
</>
)}
{isAdmin && (
<>
<Divider sx={{ my: 1 }} />
+2 -1
View File
@@ -54,7 +54,8 @@
"models": "Models",
"collections": "Collections",
"tags": "Tags",
"downloads": "Downloads"
"downloads": "Downloads",
"bookmarks": "Bookmarks"
},
"uploadBar": {
"uploading": "Uploading…",
+2 -1
View File
@@ -54,7 +54,8 @@
"models": "Modeliai",
"collections": "Kolekcijos",
"tags": "Žymos",
"downloads": "Atsisiuntimai"
"downloads": "Atsisiuntimai",
"bookmarks": "Įsimintos"
},
"uploadBar": {
"uploading": "Įkeliama…",
+8 -4
View File
@@ -23,10 +23,12 @@ type Props = {
* "<name> (<count>)" plus a bookmark toggle (Chip's own deleteIcon slot, repurposed -- its click
* target is already separate from the chip's own onClick, exactly the two independent actions
* this needs) -- wrapping left-to-right instead of one per row, so far more fit on screen at
* once. Colored like the sidebar's own inactive nav rows (thingport.navInactiveText) with the
* same divider-in-light/invisible-in-dark border used by Sidebar/CategoriesPanel/ModelSidePanel
* (dividerBorderColor), so this reads as an extension of that same nav chrome rather than a
* one-off style. Sorted Popular (most models, default) or Name. Bookmarking surfaces the tag in
* once. Colored and backgrounded like the sidebar itself (thingport.navInactiveText text,
* background.paper fill -- the same values Sidebar's own <aside> uses, not the page's own
* background behind it) with the same divider-in-light/invisible-in-dark border used by
* Sidebar/CategoriesPanel/ModelSidePanel (dividerBorderColor), so this reads as an extension of
* that same nav chrome rather than a one-off style. Sorted Popular (most models, default) or
* Name. Bookmarking surfaces the tag in
* the sidebar's own quick-access list (see Sidebar). A "hide rarely-used tags" switch (on by
* default) filters out anything used by fewer than 2 models -- purely a client-side filter over
* the same already-fetched list, not a separate API call. */
@@ -126,7 +128,9 @@ export default function TagsPage({ onUnauthorized, onBookmarksChanged }: Props)
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",