feat: add tags route

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-13 09:48:02 +02:00
1 parent 150d6d75ed
commit 6990c2ff9f
14 files changed
+524 -11

No files matched your search

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "bookmarkedTags" TEXT[] DEFAULT ARRAY[]::TEXT[];
+5
View File
@@ -35,6 +35,11 @@ model User {
// slicer's own registered URL protocol (e.g. bambustudio://) -- see
// services/slicerPreferenceService.ts. Not yet acted on anywhere; just stored for now.
slicer String?
// Tags saved from the Tags list/detail pages' bookmark toggle, for quick access from the
// sidebar -- just tag strings (there's no Tag row anywhere else to reference; a tag is only
// ever a value inside Print.tags), so a bookmark that no longer matches any print just quietly
// shows a zero count rather than needing cleanup.
bookmarkedTags String[] @default([])
// Populated by linking an imported Author to this account (the Author page's "It's me!"
// button -- see AuthorLink below) rather than edited directly: the first link with bio/cover
// data fills these in if they're still empty, later links never overwrite them. Shown on this
+2
View File
@@ -11,6 +11,7 @@ import platesRoutes from "./routes/plates";
import previewImagesRoutes from "./routes/previewImages";
import categoriesRoutes from "./routes/categories";
import printFilesRoutes from "./routes/printFiles";
import tagsRoutes from "./routes/tags";
import settingsRoutes from "./routes/settings";
import importsRoutes from "./routes/imports";
import collectionsRoutes from "./routes/collections";
@@ -43,6 +44,7 @@ export function createApp(): Express {
app.use("/api", previewImagesRoutes);
app.use("/api", categoriesRoutes);
app.use("/api", printFilesRoutes);
app.use("/api", tagsRoutes);
app.use("/api", importsRoutes);
app.use("/api", collectionsRoutes);
app.use("/api", notificationsRoutes);
+87
View File
@@ -0,0 +1,87 @@
import { Router } from "express";
import { z } from "zod";
import { prisma } from "../db";
import { requireAuth } from "../auth";
import { HttpError } from "../utils/fileUtils";
import { asyncHandler } from "../utils/asyncHandler";
const router = Router();
router.use(requireAuth);
function sortedBookmarks(tags: string[]): string[] {
return tags.toSorted((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
}
// ---- GET /tags/summary?sort=popular|name -------------------------------------------------------
// Every tag across this user's whole library (unfiltered -- unlike GET /tags in prints.ts, which
// scopes to whatever grid filter is active), with its model count and whether it's bookmarked.
// Backs the standalone Tags list page.
const sortSchema = z.enum(["popular", "name"]).catch("popular");
router.get(
"/tags/summary",
asyncHandler(async (req, res) => {
const sort = sortSchema.parse(req.query.sort);
const [prints, user] = await Promise.all([
prisma.print.findMany({ where: { userId: req.userId }, select: { tags: true } }),
prisma.user.findUnique({ where: { id: req.userId }, select: { bookmarkedTags: true } }),
]);
const bookmarked = new Set(user?.bookmarkedTags ?? []);
const counts = new Map<string, number>();
for (const print of prints) {
for (const tag of print.tags) {
const cleaned = tag.trim();
if (!cleaned) continue;
counts.set(cleaned, (counts.get(cleaned) ?? 0) + 1);
}
}
const tags = [...counts.entries()]
.map(([name, count]) => ({ name, count, bookmarked: bookmarked.has(name) }))
.toSorted((a, b) =>
sort === "name"
? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
: b.count - a.count || a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
);
res.json(tags);
}),
);
// ---- GET /tags/bookmarked ------------------------------------------------------------------------
// Just the bookmarked tag names, for the sidebar's quick-access list -- deliberately not
// /tags/summary, which scans every print; this is a single-row lookup.
router.get(
"/tags/bookmarked",
asyncHandler(async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: req.userId }, select: { bookmarkedTags: true } });
res.json(sortedBookmarks(user?.bookmarkedTags ?? []));
}),
);
// ---- POST/DELETE /tags/:tag/bookmark --------------------------------------------------------------
router.post(
"/tags/:tag/bookmark",
asyncHandler(async (req, res) => {
const tag = req.params.tag.trim();
if (!tag) throw new HttpError(400, "Tag is required");
const user = await prisma.user.findUnique({ where: { id: req.userId! }, select: { bookmarkedTags: true } });
const next = user?.bookmarkedTags.includes(tag) ? user.bookmarkedTags : [...(user?.bookmarkedTags ?? []), tag];
await prisma.user.update({ where: { id: req.userId! }, data: { bookmarkedTags: next } });
res.json(sortedBookmarks(next));
}),
);
router.delete(
"/tags/:tag/bookmark",
asyncHandler(async (req, res) => {
const tag = req.params.tag.trim();
const user = await prisma.user.findUnique({ where: { id: req.userId! }, select: { bookmarkedTags: true } });
const next = (user?.bookmarkedTags ?? []).filter((t) => t !== tag);
await prisma.user.update({ where: { id: req.userId! }, data: { bookmarkedTags: next } });
res.json(sortedBookmarks(next));
}),
);
export default router;
+20 -1
View File
@@ -12,6 +12,7 @@ import ModelsPage from "./pages/ModelsPage";
import ModelDetailPage from "./pages/ModelDetailPage";
import CollectionsPage from "./pages/CollectionsPage";
import CollectionDetailPage from "./pages/CollectionDetailPage";
import TagsPage from "./pages/TagsPage";
import TagDetailPage from "./pages/TagDetailPage";
import AuthorPage from "./pages/AuthorPage";
import AuthPage from "./pages/AuthPage";
@@ -79,11 +80,16 @@ function AppShell({
});
const [nonce, setNonce] = React.useState(0);
const [categoryVersion, setCategoryVersion] = React.useState(0);
const [tagBookmarksVersion, setTagBookmarksVersion] = React.useState(0);
const handleCategoriesChanged = React.useCallback(() => {
setCategoryVersion(v => v + 1);
}, []);
const handleTagBookmarksChanged = React.useCallback(() => {
setTagBookmarksVersion(v => v + 1);
}, []);
const handlePrintsChanged = React.useCallback(() => {
setNonce(n => n + 1);
}, []);
@@ -96,6 +102,7 @@ function AppShell({
categoryId={categoryId}
onSelectCategory={setCategoryId}
onPrintsChanged={handlePrintsChanged}
tagBookmarksVersion={tagBookmarksVersion}
onUnauthorized={onUnauthorized}
isAdmin={isAdmin}
onOpenProfile={() => navigate("/profile")}
@@ -130,9 +137,21 @@ function AppShell({
path="/models/collections/:collectionId"
element={<CollectionDetailPage theme={resolvedTheme} previewMode={previewMode} onUnauthorized={onUnauthorized} viewer={user} />}
/>
<Route
path="/models/tags"
element={<TagsPage onUnauthorized={onUnauthorized} onBookmarksChanged={handleTagBookmarksChanged} />}
/>
<Route
path="/models/tags/:tagName"
element={<TagDetailPage theme={resolvedTheme} previewMode={previewMode} onUnauthorized={onUnauthorized} viewer={user} />}
element={
<TagDetailPage
theme={resolvedTheme}
previewMode={previewMode}
onUnauthorized={onUnauthorized}
onBookmarksChanged={handleTagBookmarksChanged}
viewer={user}
/>
}
/>
<Route
path="/models/:printId"
+45
View File
@@ -0,0 +1,45 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk } from "./client";
export type TagSortMode = "popular" | "name";
export type TagSummary = {
name: string;
count: number;
bookmarked: boolean;
};
export const tagsApi = {
/** Every tag across this user's whole library (unfiltered), with its model count and whether
* it's bookmarked -- backs the standalone Tags list page. */
listSummary: async (sort: TagSortMode = "popular"): Promise<TagSummary[]> => {
const res = await fetch(`${apiBase()}/tags/summary?sort=${sort}`, { headers: authHeaders() });
assertOk(res, "Failed to list tags");
return res.json();
},
/** Just the bookmarked tag names, alphabetical -- for the sidebar's quick-access list. */
listBookmarked: async (): Promise<string[]> => {
const res = await fetch(`${apiBase()}/tags/bookmarked`, { headers: authHeaders() });
assertOk(res, "Failed to list bookmarked tags");
return res.json();
},
bookmark: async (tag: string): Promise<string[]> => {
const res = await fetch(`${apiBase()}/tags/${encodeURIComponent(tag)}/bookmark`, {
method: "POST",
headers: authHeaders(),
});
assertOk(res, "Failed to bookmark tag");
return res.json();
},
unbookmark: async (tag: string): Promise<string[]> => {
const res = await fetch(`${apiBase()}/tags/${encodeURIComponent(tag)}/bookmark`, {
method: "DELETE",
headers: authHeaders(),
});
assertOk(res, "Failed to remove bookmark");
return res.json();
},
};
+6 -1
View File
@@ -28,6 +28,7 @@ type AppLayoutProps = {
categoryId: string | null;
onSelectCategory: (id: string | null) => void;
onPrintsChanged: () => void;
tagBookmarksVersion: number;
onUnauthorized: () => void;
isAdmin: boolean;
onOpenProfile: () => void;
@@ -63,6 +64,9 @@ function useRouteChrome() {
// Overridden by CollectionDetailPage's usePageHeader once the collection loads.
title = t("models:collections.pageTitle");
onBack = goBack;
} else if (path === "/models/tags") {
title = t("models:tags.pageTitle");
onBack = () => navigate("/");
} else if (path.startsWith("/models/tags/")) {
// The tag name is already known from the URL (unlike a collection, a tag isn't a fetched
// entity), so this is set directly rather than waiting on TagDetailPage's usePageHeader --
@@ -124,6 +128,7 @@ function AppLayoutShell({
categoryId,
onSelectCategory,
onPrintsChanged,
tagBookmarksVersion,
onUnauthorized,
isAdmin,
onOpenProfile,
@@ -152,7 +157,7 @@ function AppLayoutShell({
display: "flex",
}}
>
<Sidebar isAdmin={isAdmin} onSelectCategory={onSelectCategory} />
<Sidebar isAdmin={isAdmin} onSelectCategory={onSelectCategory} tagBookmarksVersion={tagBookmarksVersion} />
<Box component="main" sx={{ flex: 1, p: 2, pt: 0 }}>
{apiUp === false && (
<Alert severity="error" sx={{ mb: 1.5 }}>
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link as RouterLink, useLocation, useNavigate } from "react-router-dom";
import type { Theme } from "@mui/material/styles";
@@ -17,6 +17,8 @@ import Tooltip from "@mui/material/Tooltip";
import SpaceDashboardIcon from "@mui/icons-material/SpaceDashboard";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import CollectionsIcon from "@mui/icons-material/Collections";
import LocalOfferIcon from "@mui/icons-material/LocalOffer";
import BookmarkIcon from "@mui/icons-material/Bookmark";
import DownloadIcon from "@mui/icons-material/Download";
import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
import SettingsIcon from "@mui/icons-material/Settings";
@@ -29,6 +31,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import Wordmark from "../../Wordmark";
import { tagsApi } from "../../../api/tags";
const SIDEBAR_WIDTH = 240;
const SIDEBAR_COLLAPSED_WIDTH = 72;
@@ -88,11 +91,17 @@ function CollapsedNavIcon({ icon, label, selected, onClick }: {
type Props = {
isAdmin: boolean;
onSelectCategory: (id: string | null) => void;
/** Bumped whenever a tag is bookmarked/unbookmarked elsewhere (the Tags list page, or a tag
* detail page's title-row toggle) so the quick-access list below refetches without needing a
* full remount -- same shape as ModelsPage's categoriesVersion/onCategoriesChanged. */
tagBookmarksVersion?: number;
};
/** The persistent app-wide navigation rail: Dashboard, Models, Collections, Downloads, and
* (for admins) Administration. Category browsing lives inside the Models page itself, not here. */
export default function Sidebar({ isAdmin, onSelectCategory }: Props) {
/** 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). */
export default function Sidebar({ isAdmin, onSelectCategory, tagBookmarksVersion }: Props) {
const { t } = useTranslation(["app", "common"]);
const location = useLocation();
const navigate = useNavigate();
@@ -101,10 +110,22 @@ export default function Sidebar({ isAdmin, onSelectCategory }: Props) {
return window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === "true";
});
const [adminExpanded, setAdminExpanded] = useState(false);
const [bookmarkedTags, setBookmarkedTags] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
tagsApi.listBookmarked()
.then(tags => { if (!cancelled) setBookmarkedTags(tags); })
.catch(() => { /* non-critical nav aid -- swallow and leave the list as-is */ });
return () => { cancelled = true; };
// tagBookmarksVersion is a deliberate refetch trigger, not read inside the effect itself.
// oxlint-disable-next-line react/exhaustive-effect-dependencies
}, [tagBookmarksVersion]);
const onDashboard = location.pathname === "/";
const onCollections = location.pathname.startsWith("/models/collections");
const onModels = (location.pathname.startsWith("/models") && !onCollections) || location.pathname.startsWith("/authors");
const onTags = location.pathname.startsWith("/models/tags");
const onModels = (location.pathname.startsWith("/models") && !onCollections && !onTags) || location.pathname.startsWith("/authors");
const onDownload = location.pathname.startsWith("/downloads");
const onAdminSettings = location.pathname.startsWith("/admin-settings");
const onAdminUsers = location.pathname.startsWith("/admin-users");
@@ -234,6 +255,59 @@ export default function Sidebar({ isAdmin, onSelectCategory }: Props) {
</ListItemButton>
)}
{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)}
/>
);
})}
</>
) : (
<>
<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>
);
})}
</>
)}
{collapsed ? (
<CollapsedNavIcon
icon={<DownloadIcon fontSize="small" />}
+7
View File
@@ -53,6 +53,7 @@
"collapseSidebar": "Collapse sidebar",
"models": "Models",
"collections": "Collections",
"tags": "Tags",
"downloads": "Downloads"
},
"uploadBar": {
@@ -1137,6 +1138,12 @@
}
},
"tags": {
"pageTitle": "Tags",
"sortByName": "Name",
"empty": "No tags yet.",
"bookmarkTag": "Bookmark tag",
"unbookmarkTag": "Remove bookmark",
"bookmarkFailed": "Failed to update bookmark. Please try again.",
"detail": {
"title": "Tag: {{name}}",
"empty": "No models with this tag yet."
+7
View File
@@ -53,6 +53,7 @@
"collapseSidebar": "Suskleisti šoninę juostą",
"models": "Modeliai",
"collections": "Kolekcijos",
"tags": "Žymos",
"downloads": "Atsisiuntimai"
},
"uploadBar": {
@@ -1138,6 +1139,12 @@
}
},
"tags": {
"pageTitle": "Žymos",
"sortByName": "Pavadinimas",
"empty": "Kol kas nėra žymų.",
"bookmarkTag": "Įsiminti žymą",
"unbookmarkTag": "Pašalinti iš įsimintų",
"bookmarkFailed": "Nepavyko atnaujinti įsimintos žymos. Bandykite dar kartą.",
"detail": {
"title": "Žyma: {{name}}",
"empty": "Su šia žyma modelių kol kas nėra."
@@ -0,0 +1,58 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Tooltip from "@mui/material/Tooltip";
import IconButton from "@mui/material/IconButton";
import BookmarkIcon from "@mui/icons-material/Bookmark";
import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder";
import { UnauthorizedError } from "../../api/client";
import { tagsApi } from "../../api/tags";
type Props = {
tag: string;
bookmarked: boolean;
onUnauthorized?: () => void;
/** Called after a successful toggle so the sidebar's own bookmarked-tags list can refetch. */
onBookmarksChanged?: () => void;
};
/** The tag detail page's title-row bookmark toggle -- adds/removes this tag from the sidebar's
* quick-access list. Same optimistic-flip shape as FavoriteButton, but derives its displayed
* state from `bookmarked` during render (an `overrideRef` from the last optimistic flip/rollback
* wins when set) instead of mirroring the prop into local state via an effect. */
export default function TagBookmarkButton({ tag, bookmarked, onUnauthorized, onBookmarksChanged }: Props) {
const { t } = useTranslation(["models", "common"]);
const [override, setOverride] = useState<boolean | null>(null);
const isBookmarked = override ?? bookmarked;
const pendingRef = useRef(false);
const toggle = async () => {
if (pendingRef.current) return;
pendingRef.current = true;
const next = !isBookmarked;
setOverride(next);
try {
await (next ? tagsApi.bookmark(tag) : tagsApi.unbookmark(tag));
onBookmarksChanged?.();
} catch (err) {
setOverride(bookmarked);
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
console.error(err);
alert(t("models:tags.bookmarkFailed"));
} finally {
pendingRef.current = false;
}
};
const label = isBookmarked ? t("models:tags.unbookmarkTag") : t("models:tags.bookmarkTag");
return (
<Tooltip title={label}>
<IconButton size="small" onClick={toggle} aria-label={label}>
{isBookmarked ? <BookmarkIcon fontSize="small" color="primary" /> : <BookmarkBorderIcon fontSize="small" />}
</IconButton>
</Tooltip>
);
}
+35 -4
View File
@@ -9,6 +9,7 @@ import { UnauthorizedError } from "../../api/client";
import { type Print, type PrintSortMode, printsApi } from "../../api/prints";
import { type Collection, collectionsApi } from "../../api/collections";
import { type PreviewMode } from "../../api/settings";
import { tagsApi } from "../../api/tags";
import type { AuthUser } from "../../api/auth";
import { type ResolvedTheme } from "../../constants/settingsOptions";
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
@@ -16,6 +17,7 @@ import { useInfiniteScroll } from "../../hooks/useInfiniteScroll";
import ModelCard from "../ModelsPage/ModelCard";
import SortTabs from "../ModelsPage/SortTabs";
import CollectionCard from "../CollectionsPage/CollectionCard";
import TagBookmarkButton from "./TagBookmarkButton";
const PAGE_SIZE = 24;
@@ -23,21 +25,25 @@ type Props = {
theme: ResolvedTheme;
previewMode: PreviewMode;
onUnauthorized?: () => void;
onBookmarksChanged?: () => void;
viewer?: AuthUser | null;
};
/** Same list/grid/sort/infinite-scroll shape as CollectionDetailPage, but for a tag: tags aren't
* entities with their own id/description/menu, just a string carried in the URL, so there's no
* entities with their own id/description, just a string carried in the URL, so there's no
* fetch-by-id step (or "not found" state) -- the page header title comes directly from the
* route param and the list is prints filtered by that tag, plus (since collections can carry
* tags too) any collections carrying it, shown first in the same grid as CollectionCards. */
export default function TagDetailPage({ theme, previewMode, onUnauthorized, viewer }: Props) {
* tags too) any collections carrying it, shown first in the same grid as CollectionCards. The
* title row's only action is a bookmark toggle (TagBookmarkButton) -- no dropdown menu, since
* there's nothing else to put in one. */
export default function TagDetailPage({ theme, previewMode, onUnauthorized, onBookmarksChanged, viewer }: Props) {
const { tagName } = useParams<{ tagName: string }>();
const tag = tagName ? decodeURIComponent(tagName) : "";
const { t } = useTranslation(["models", "common"]);
const [items, setItems] = useState<Print[]>([]);
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(true);
const [bookmarked, setBookmarked] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
@@ -54,7 +60,17 @@ export default function TagDetailPage({ theme, previewMode, onUnauthorized, view
});
};
usePageHeader({ title: tag ? t("models:tags.detail.title", { name: tag }) : undefined });
usePageHeader({
title: tag ? t("models:tags.detail.title", { name: tag }) : undefined,
actions: tag ? (
<TagBookmarkButton
tag={tag}
bookmarked={bookmarked}
onUnauthorized={onUnauthorized}
onBookmarksChanged={onBookmarksChanged}
/>
) : undefined,
});
const handleError = (err: unknown, message?: string) => {
if (err instanceof UnauthorizedError) {
@@ -88,6 +104,21 @@ export default function TagDetailPage({ theme, previewMode, onUnauthorized, view
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tag, sortMode]);
useEffect(() => {
if (!tag) return;
let cancelled = false;
(async () => {
try {
const bookmarks = await tagsApi.listBookmarked();
if (!cancelled) setBookmarked(bookmarks.includes(tag));
} catch (err) {
if (!cancelled) handleError(err);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tag]);
// Collections list has no server-side tag filter (and isn't paginated -- CollectionsPage
// fetches all of them too), so this filters client-side and refetches only on tag change, not
// sortMode -- collections here aren't sorted, just shown ahead of the prints grid.
@@ -0,0 +1,37 @@
import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { type TagSortMode } from "../../api/tags";
const SORT_MODES: TagSortMode[] = ["popular", "name"];
type Props = {
value: TagSortMode;
onChange: (mode: TagSortMode) => void;
};
/** "Popular / Name" sort row for the Tags list page -- same look and alignment as
* ModelsPage/SortTabs, just a different (tag-specific) pair of modes. */
export default function TagSortTabs({ value, onChange }: Props) {
const { t } = useTranslation("models");
return (
<Stack direction="row" spacing={3}>
{SORT_MODES.map(mode => (
<Typography
key={mode}
variant="body2"
onClick={() => onChange(mode)}
sx={{
cursor: "pointer",
fontSize: 14,
fontWeight: value === mode ? 700 : 500,
color: value === mode ? "primary.main" : "text.secondary",
"&:hover": { color: "primary.main" },
}}
>
{mode === "popular" ? t("sort.popular") : t("tags.sortByName")}
</Typography>
))}
</Stack>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, 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 CircularProgress from "@mui/material/CircularProgress";
import IconButton from "@mui/material/IconButton";
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 TagSortTabs from "./TagSortTabs";
type Props = {
onUnauthorized?: () => void;
onBookmarksChanged?: () => void;
};
/** The standalone Tags list: every tag across the user's library as a small row --
* "<name> (<count>)" plus a bookmark toggle -- sorted Popular (most models, default) or Name.
* Bookmarking surfaces the tag in the sidebar's own quick-access list (see Sidebar). */
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 [sortMode, setSortMode] = useState<TagSortMode>("popular");
const [pendingTag, setPendingTag] = useState<string | null>(null);
const handleError = (err: unknown, message?: string) => {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return true;
}
console.error(err);
if (message) alert(message);
return false;
};
useEffect(() => {
let cancelled = false;
setLoading(true);
(async () => {
try {
const result = await tagsApi.listSummary(sortMode);
if (!cancelled) setTags(result);
} catch (err) {
if (!cancelled) handleError(err, t("models:errors.loadFailed"));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortMode]);
const toggleBookmark = async (tag: TagSummary) => {
if (pendingTag) return;
setPendingTag(tag.name);
const nextBookmarked = !tag.bookmarked;
setTags(prev => prev.map(t2 => (t2.name === tag.name ? { ...t2, bookmarked: nextBookmarked } : t2)));
try {
await (nextBookmarked ? tagsApi.bookmark(tag.name) : tagsApi.unbookmark(tag.name));
onBookmarksChanged?.();
} catch (err) {
setTags(prev => prev.map(t2 => (t2.name === tag.name ? { ...t2, bookmarked: tag.bookmarked } : t2)));
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
console.error(err);
alert(t("models:tags.bookmarkFailed"));
} finally {
setPendingTag(null);
}
};
if (loading) {
return (
<Stack alignItems="center" sx={{ py: 8 }}>
<CircularProgress size={22} />
</Stack>
);
}
return (
<Stack spacing={2} sx={{ maxWidth: 640 }}>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<TagSortTabs value={sortMode} onChange={setSortMode} />
</Box>
{tags.length ? (
<Stack spacing={0.5}>
{tags.map(tag => (
<Stack
key={tag.name}
direction="row"
alignItems="center"
justifyContent="space-between"
onClick={() => navigate(`/models/tags/${encodeURIComponent(tag.name)}`)}
sx={{
px: 1.5,
py: 0.75,
borderRadius: 1.5,
cursor: "pointer",
"&:hover": { bgcolor: "action.hover" },
}}
>
<Typography variant="body2" noWrap sx={{ minWidth: 0 }}>
{tag.name}
<Typography component="span" variant="body2" color="text.secondary" sx={{ ml: 0.75 }}>
({tag.count})
</Typography>
</Typography>
<IconButton
size="small"
disabled={pendingTag === tag.name}
onClick={(e) => { e.stopPropagation(); void toggleBookmark(tag); }}
aria-label={tag.bookmarked ? t("models:tags.unbookmarkTag") : t("models:tags.bookmarkTag")}
>
{tag.bookmarked ? <BookmarkIcon fontSize="small" color="primary" /> : <BookmarkBorderIcon fontSize="small" />}
</IconButton>
</Stack>
))}
</Stack>
) : (
<Stack alignItems="center" spacing={1} sx={{ py: 8, color: "text.secondary" }}>
<Typography variant="body2">{t("models:tags.empty")}</Typography>
</Stack>
)}
</Stack>
);
}