mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
fix: improve bookmarks ui
This commit is contained in:
1 parent
daac2271d8
commit
01f8812cef
8 files changed
+80
-161
No files matched your search
@@ -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 ."
|
||||
},
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
});
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
"models": "Models",
|
||||
"collections": "Collections",
|
||||
"tags": "Tags",
|
||||
"downloads": "Downloads"
|
||||
"downloads": "Downloads",
|
||||
"bookmarks": "Bookmarks"
|
||||
},
|
||||
"uploadBar": {
|
||||
"uploading": "Uploading…",
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
"models": "Modeliai",
|
||||
"collections": "Kolekcijos",
|
||||
"tags": "Žymos",
|
||||
"downloads": "Atsisiuntimai"
|
||||
"downloads": "Atsisiuntimai",
|
||||
"bookmarks": "Įsimintos"
|
||||
},
|
||||
"uploadBar": {
|
||||
"uploading": "Įkeliama…",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user