mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat: add tags route
This commit is contained in:
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[];
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user