feat: allow bookmarking collections

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-19 15:41:38 +02:00
1 parent 523916d2b1
commit 4f408031b4
22 files changed
+601 -91

No files matched your search

@@ -0,0 +1,51 @@
-- CreateEnum
CREATE TYPE "BookmarkType" AS ENUM ('TAG', 'COLLECTION');
-- CreateTable
CREATE TABLE "Bookmark" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "BookmarkType" NOT NULL,
"tag" TEXT,
"collectionId" TEXT,
"order" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Bookmark_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Bookmark_userId_order_idx" ON "Bookmark"("userId", "order");
-- CreateIndex
CREATE UNIQUE INDEX "Bookmark_userId_type_tag_key" ON "Bookmark"("userId", "type", "tag");
-- CreateIndex
CREATE UNIQUE INDEX "Bookmark_userId_collectionId_key" ON "Bookmark"("userId", "collectionId");
-- AddForeignKey
ALTER TABLE "Bookmark" ADD CONSTRAINT "Bookmark_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Bookmark" ADD CONSTRAINT "Bookmark_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- DataMigration: carry every existing User.bookmarkedTags entry over into a Bookmark row (type
-- TAG) before that column is dropped below, one Bookmark per (user, tag) pair. `order` is seeded
-- alphabetically per user (row_number over the tag, 0-based) since that's exactly how the old
-- bookmarkedTags-backed sidebar/list already sorted them (see routes/tags.ts's old
-- sortedBookmarks) -- so this backfill is a no-op for anyone who hasn't dragged anything yet. The
-- id is just a random-enough unique string (this table has no other rows yet, so collisions are
-- effectively impossible); it doesn't need to be a real cuid.
INSERT INTO "Bookmark" ("id", "userId", "type", "tag", "order", "createdAt")
SELECT
substr(md5(random()::text || clock_timestamp()::text || u."id" || t.tag), 1, 24),
u."id",
'TAG',
t.tag,
(row_number() OVER (PARTITION BY u."id" ORDER BY t.tag) - 1)::int,
now()
FROM "User" u
CROSS JOIN LATERAL unnest(u."bookmarkedTags") AS t(tag);
-- AlterTable
ALTER TABLE "User" DROP COLUMN "bookmarkedTags";
+37 -5
View File
@@ -40,11 +40,6 @@ model User {
// default rather than to a stored value. Server-persisted (not just localStorage) so it follows
// the account across devices/browsers, same reasoning as slicer/makerworldCookie above.
themePreference 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
@@ -59,6 +54,7 @@ model User {
notifications Notification[]
logs Log[]
authorLinks AuthorLink[]
bookmarks Bookmark[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -164,6 +160,7 @@ model Collection {
description String?
tags String[] @default([])
items CollectionItem[]
bookmarks Bookmark[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -171,6 +168,41 @@ model Collection {
@@index([tags], type: Gin)
}
enum BookmarkType {
TAG
COLLECTION
}
/// A tag or collection a user has pinned to the sidebar's quick-access "Bookmarks" section (see
/// Sidebar/index.tsx and services/bookmarkService.ts). Tags and collections share one row shape
/// and one `order` sequence so the sidebar can show both kinds interleaved, sorted by that single
/// field: a new bookmark gets the current max `order` + 1 (so it lands at the bottom), and
/// dragging a row in the sidebar renumbers every one of this user's bookmarks 0..n-1 to match the
/// dropped order (POST /bookmarks/reorder) -- there's no separate "added at" fallback because
/// `order` is always fully populated.
///
/// Exactly one of `tag` (TAG rows) / `collectionId` (COLLECTION rows) is set, matching `type` --
/// a tag is just a string (no Tag row anywhere else to reference, same as User.bookmarkedTags
/// used to be), while a collection is a real row this cascades from. The two @@unique
/// constraints below only bind the non-null side for each type: Postgres never treats two NULLs
/// as equal, so e.g. every COLLECTION row's null `tag` is invisible to the (userId, type, tag)
/// index, and only TAG rows' actual tag values are deduped by it (and vice versa).
model Bookmark {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type BookmarkType
tag String?
collectionId String?
collection Collection? @relation(fields: [collectionId], references: [id], onDelete: Cascade)
order Int
createdAt DateTime @default(now())
@@unique([userId, type, tag])
@@unique([userId, collectionId])
@@index([userId, order])
}
/// Join row between Collection and Print (many-to-many), with a position for cover/display
/// ordering.
model CollectionItem {
+2
View File
@@ -15,6 +15,7 @@ import tagsRoutes from "./routes/tags";
import settingsRoutes from "./routes/settings";
import importsRoutes from "./routes/imports";
import collectionsRoutes from "./routes/collections";
import bookmarksRoutes from "./routes/bookmarks";
import notificationsRoutes from "./routes/notifications";
import adminRoutes from "./routes/admin";
import dashboardRoutes from "./routes/dashboard";
@@ -47,6 +48,7 @@ export function createApp(): Express {
app.use("/api", tagsRoutes);
app.use("/api", importsRoutes);
app.use("/api", collectionsRoutes);
app.use("/api", bookmarksRoutes);
app.use("/api", notificationsRoutes);
app.use("/api", adminRoutes);
app.use("/api", dashboardRoutes);
+13 -1
View File
@@ -309,11 +309,21 @@ export type CollectionOut = {
* collectionService.ts's SYSTEM_COLLECTIONS) -- the frontend uses this to pick a translated
* display name instead of `name`, and to hide the edit/delete actions those can't support. */
system_key: SystemCollectionKey | null;
/** Whether this user has bookmarked this collection (see services/bookmarkService.ts) -- backs
* the Collections grid card's "..." menu and the collection detail page's title-row toggle.
* Always false for a system pseudo-collection: there's no real Collection row for a Bookmark to
* point at, so those can't be bookmarked (the frontend hides the toggle for them entirely). */
bookmarked: boolean;
};
/** `coverPrints` should already be the up-to-4 cover PrintOuts (see collections.ts), ordered by
* the collection's item position ascending. */
export function toCollectionOut(collection: Collection, itemCount: number, coverPrints: PrintOut[]): CollectionOut {
export function toCollectionOut(
collection: Collection,
itemCount: number,
coverPrints: PrintOut[],
bookmarked: boolean,
): CollectionOut {
return {
id: collection.id,
name: collection.name,
@@ -323,6 +333,7 @@ export function toCollectionOut(collection: Collection, itemCount: number, cover
cover_items: coverPrints,
created_at: collection.createdAt.toISOString(),
system_key: null,
bookmarked,
};
}
@@ -346,6 +357,7 @@ export function toSystemCollectionOut(
cover_items: coverPrints,
created_at: new Date(0).toISOString(),
system_key: key,
bookmarked: false,
};
}
+35
View File
@@ -0,0 +1,35 @@
import { Router } from "express";
import { z } from "zod";
import { requireAuth } from "../auth";
import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import { listBookmarks, reorderBookmarks } from "../services/bookmarkService";
const router = Router();
router.use(requireAuth);
// ---- GET /bookmarks -------------------------------------------------------------------------
// Every bookmarked tag and collection for this user, tags and collections interleaved in one
// list ordered by Bookmark.order -- backs the sidebar's quick-access "Bookmarks" section (see
// Sidebar/index.tsx). A new bookmark (POST /tags/:tag/bookmark, POST /collection/:id/bookmark)
// always lands at the bottom; dragging a row in the sidebar calls POST /bookmarks/reorder below
// to persist the dropped order.
router.get(
"/bookmarks",
asyncHandler(async (req, res) => {
res.json(await listBookmarks(req.userId!));
}),
);
const reorderSchema = z.object({ bookmark_ids: z.array(z.string()).min(1) });
router.post(
"/bookmarks/reorder",
asyncHandler(async (req, res) => {
const body = parseBody(reorderSchema, req.body);
await reorderBookmarks(req.userId!, body.bookmark_ids);
res.json(await listBookmarks(req.userId!));
}),
);
export default router;
+46 -5
View File
@@ -15,6 +15,7 @@ import {
normalizeCollectionName,
systemCollectionKeyForId,
} from "../services/collectionService";
import { addCollectionBookmark, listBookmarkedCollectionIdSet, removeCollectionBookmark } from "../services/bookmarkService";
import { printOutsByIds } from "../services/printLoader";
import { createLog } from "../services/auditLog";
import { toCollectionOut, type PrintOut } from "../dto";
@@ -33,7 +34,7 @@ const COVER_ITEM_LIMIT = 4;
router.get(
"/collections",
asyncHandler(async (req, res) => {
const [systemCollections, collections] = await Promise.all([
const [systemCollections, collections, bookmarkedIds] = await Promise.all([
listSystemCollectionOuts(req.userId!),
prisma.collection.findMany({
where: { userId: req.userId },
@@ -43,6 +44,7 @@ router.get(
items: { orderBy: { position: "asc" }, take: COVER_ITEM_LIMIT },
},
}),
listBookmarkedCollectionIdSet(req.userId!),
]);
const coverPrintIds = collections.flatMap((c) => c.items.map((i) => i.printId));
const printOuts = await printOutsByIds(req.userId!, coverPrintIds);
@@ -53,6 +55,7 @@ router.get(
c,
c._count.items,
c.items.map((i) => printOuts.get(i.printId)).filter((p): p is PrintOut => Boolean(p)),
bookmarkedIds.has(c.id),
),
),
]);
@@ -73,7 +76,7 @@ router.post(
tags: normalizeTags(body.tags),
},
});
res.json(toCollectionOut(collection, 0, []));
res.json(toCollectionOut(collection, 0, [], false));
void createLog({
userId: req.userId!,
action: "collection_created",
@@ -96,7 +99,10 @@ router.get(
include: { _count: { select: { items: true } } },
});
if (!collection) throw new HttpError(404, "Collection not found");
res.json(toCollectionOut(collection, collection._count.items, []));
const bookmarked = Boolean(
await prisma.bookmark.findFirst({ where: { userId: req.userId, type: "COLLECTION", collectionId: collection.id } }),
);
res.json(toCollectionOut(collection, collection._count.items, [], bookmarked));
}),
);
@@ -119,8 +125,11 @@ router.patch(
tags: normalizeTags(body.tags),
},
});
const itemCount = await prisma.collectionItem.count({ where: { collectionId: updated.id } });
res.json(toCollectionOut(updated, itemCount, []));
const [itemCount, bookmarked] = await Promise.all([
prisma.collectionItem.count({ where: { collectionId: updated.id } }),
prisma.bookmark.findFirst({ where: { userId: req.userId, type: "COLLECTION", collectionId: updated.id } }),
]);
res.json(toCollectionOut(updated, itemCount, [], Boolean(bookmarked)));
void createLog({
userId: req.userId!,
action: "collection_edited",
@@ -198,6 +207,38 @@ router.post(
}),
);
// ---- POST/DELETE /collection/:id/bookmark ----------------------------------------------------
// Adds/removes this collection from the sidebar's quick-access "Bookmarks" section -- the
// Collections grid card's "..." menu and the collection detail page's title-row toggle. Same
// system-collection restriction as the item add/remove routes above: Favourites/Browsing History
// have no real Collection row for a Bookmark to reference.
router.post(
"/collection/:id/bookmark",
asyncHandler(async (req, res) => {
if (isSystemCollectionId(req.params.id)) {
throw new HttpError(400, "This collection can't be bookmarked");
}
const collection = await prisma.collection.findFirst({ where: { id: req.params.id, userId: req.userId } });
if (!collection) throw new HttpError(404, "Collection not found");
await addCollectionBookmark(req.userId!, collection.id);
res.json({ ok: true });
}),
);
router.delete(
"/collection/:id/bookmark",
asyncHandler(async (req, res) => {
if (isSystemCollectionId(req.params.id)) {
throw new HttpError(400, "This collection can't be bookmarked");
}
const collection = await prisma.collection.findFirst({ where: { id: req.params.id, userId: req.userId } });
if (!collection) throw new HttpError(404, "Collection not found");
await removeCollectionBookmark(req.userId!, collection.id);
res.json({ ok: true });
}),
);
// Every real (non-system) collection this user owns, flagged with whether `printId` is currently
// a member -- backs the "Add to collection" picker opened from ModelActionsMenu. System
// pseudo-collections are omitted since they can't be toggled this way (see the POST/DELETE above).
+18 -21
View File
@@ -2,15 +2,15 @@ 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";
import { normalizeTag } from "../utils/tagNormalization";
import { addTagBookmark, listBookmarkedTagSet, removeTagBookmark } from "../services/bookmarkService";
const router = Router();
router.use(requireAuth);
function sortedBookmarks(tags: string[]): string[] {
return tags.toSorted((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
function sortedBookmarks(tags: Iterable<string>): string[] {
return [...tags].toSorted((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
}
// ---- GET /tags/summary?sort=popular|name -------------------------------------------------------
@@ -24,11 +24,10 @@ router.get(
"/tags/summary",
asyncHandler(async (req, res) => {
const sort = sortSchema.parse(req.query.sort);
const [prints, user] = await Promise.all([
const [prints, bookmarked] = await Promise.all([
prisma.print.findMany({ where: { userId: req.userId }, select: { tags: true } }),
prisma.user.findUnique({ where: { id: req.userId }, select: { bookmarkedTags: true } }),
listBookmarkedTagSet(req.userId!),
]);
const bookmarked = new Set(user?.bookmarkedTags ?? []);
const counts = new Map<string, number>();
for (const print of prints) {
for (const tag of print.tags) {
@@ -49,39 +48,37 @@ router.get(
);
// ---- 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.
// Just the bookmarked tag names -- for TagsPage/TagDetailPage's own bookmark toggles, so they can
// tell whether the tag they're showing is already bookmarked. Deliberately not /tags/summary,
// which scans every print. The sidebar's quick-access list itself reads GET /bookmarks instead
// (routes/bookmarks.ts), which also carries the manual sort order and the bookmarked collections.
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 ?? []));
res.json(sortedBookmarks(await listBookmarkedTagSet(req.userId!)));
}),
);
// ---- POST/DELETE /tags/:tag/bookmark --------------------------------------------------------------
// Adds/removes this tag from the sidebar's quick-access "Bookmarks" section (see
// services/bookmarkService.ts and GET /bookmarks in routes/bookmarks.ts, which is what the
// sidebar itself actually reads -- these two just flip membership from the Tags list/detail
// pages' own toggle).
router.post(
"/tags/:tag/bookmark",
asyncHandler(async (req, res) => {
const tag = normalizeTag(req.params.tag);
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));
await addTagBookmark(req.userId!, req.params.tag);
res.json({ ok: true });
}),
);
router.delete(
"/tags/:tag/bookmark",
asyncHandler(async (req, res) => {
const tag = normalizeTag(req.params.tag);
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));
await removeTagBookmark(req.userId!, req.params.tag);
res.json({ ok: true });
}),
);
+98
View File
@@ -0,0 +1,98 @@
import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import { normalizeTag } from "../utils/tagNormalization";
// ---- Shared "Bookmarks" quick-access list ------------------------------------------------------
//
// A Bookmark row is either a TAG (a plain string -- see normalizeTag) or a COLLECTION (a real,
// non-system Collection row). Both share one `order` sequence per user so the sidebar can show
// them interleaved, sorted by that single field -- see the Bookmark model's own doc comment in
// schema.prisma for the full reasoning. This module is the only place that touches the Bookmark
// table; routes/tags.ts, routes/collections.ts and routes/bookmarks.ts all go through it.
/** The next `order` value for a new bookmark of this user's -- current max + 1 (or 0 if they have
* none yet), so a fresh bookmark always lands at the bottom of the list. */
async function nextBookmarkOrder(userId: string): Promise<number> {
const result = await prisma.bookmark.aggregate({ where: { userId }, _max: { order: true } });
return (result._max.order ?? -1) + 1;
}
/** Every tag this user has bookmarked, for TagsPage/summary and TagDetailPage's title toggle. */
export async function listBookmarkedTagSet(userId: string): Promise<Set<string>> {
const rows = await prisma.bookmark.findMany({ where: { userId, type: "TAG" }, select: { tag: true } });
return new Set(rows.map((r) => r.tag).filter((tag): tag is string => Boolean(tag)));
}
/** Every collection id this user has bookmarked, for the Collections grid/detail page's "..."
* menu and title toggle. */
export async function listBookmarkedCollectionIdSet(userId: string): Promise<Set<string>> {
const rows = await prisma.bookmark.findMany({ where: { userId, type: "COLLECTION" }, select: { collectionId: true } });
return new Set(rows.map((r) => r.collectionId).filter((id): id is string => Boolean(id)));
}
/** Idempotent -- bookmarking an already-bookmarked tag is a no-op rather than an error, so a
* double-click (or a stale optimistic UI) never surfaces a failure. */
export async function addTagBookmark(userId: string, rawTag: string): Promise<void> {
const tag = normalizeTag(rawTag);
if (!tag) throw new HttpError(400, "Tag is required");
const existing = await prisma.bookmark.findFirst({ where: { userId, type: "TAG", tag } });
if (existing) return;
const order = await nextBookmarkOrder(userId);
await prisma.bookmark.create({ data: { userId, type: "TAG", tag, order } });
}
export async function removeTagBookmark(userId: string, rawTag: string): Promise<void> {
const tag = normalizeTag(rawTag);
await prisma.bookmark.deleteMany({ where: { userId, type: "TAG", tag } });
}
/** Same idempotent shape as addTagBookmark. Callers are responsible for checking the collection
* exists, belongs to this user, and isn't one of the built-in system pseudo-collections (which
* have no real Collection row for the FK to point at) -- see routes/collections.ts. */
export async function addCollectionBookmark(userId: string, collectionId: string): Promise<void> {
const existing = await prisma.bookmark.findFirst({ where: { userId, type: "COLLECTION", collectionId } });
if (existing) return;
const order = await nextBookmarkOrder(userId);
await prisma.bookmark.create({ data: { userId, type: "COLLECTION", collectionId, order } });
}
export async function removeCollectionBookmark(userId: string, collectionId: string): Promise<void> {
await prisma.bookmark.deleteMany({ where: { userId, type: "COLLECTION", collectionId } });
}
export type BookmarkEntryOut =
| { id: string; type: "tag"; tag: string }
| { id: string; type: "collection"; collection_id: string; name: string };
/** The full merged, ordered bookmark list -- backs GET /bookmarks (the sidebar's quick-access
* section) and the response of POST /bookmarks/reorder. A COLLECTION row's `collection` is never
* null here: the FK cascades (see schema.prisma), so a bookmark for a deleted collection is
* deleted right along with it, unlike a TAG bookmark, which can quietly outlive every print that
* ever carried that tag. */
export async function listBookmarks(userId: string): Promise<BookmarkEntryOut[]> {
const rows = await prisma.bookmark.findMany({
where: { userId },
orderBy: { order: "asc" },
include: { collection: { select: { id: true, name: true } } },
});
return rows.map((row) =>
row.type === "TAG"
? { id: row.id, type: "tag" as const, tag: row.tag! }
: { id: row.id, type: "collection" as const, collection_id: row.collection!.id, name: row.collection!.name },
);
}
/** Persists a full drag-and-drop reorder of the sidebar's bookmark list: `orderedIds` must name
* exactly this user's current bookmarks (tags and collections together), in their new order --
* same "must be exactly the current sibling set" validation as POST /categories/reorder. Every
* bookmark is renumbered 0..n-1 in one transaction. */
export async function reorderBookmarks(userId: string, orderedIds: string[]): Promise<void> {
const bookmarks = await prisma.bookmark.findMany({ where: { userId }, select: { id: true } });
const currentIds = new Set(bookmarks.map((b) => b.id));
if (orderedIds.length !== bookmarks.length || !orderedIds.every((id) => currentIds.has(id))) {
throw new HttpError(400, "bookmark_ids must contain exactly this user's current bookmarks");
}
await prisma.$transaction(
orderedIds.map((id, idx) => prisma.bookmark.update({ where: { id }, data: { order: idx } })),
);
}
+26 -8
View File
@@ -85,14 +85,17 @@ function AppShell({
});
const [nonce, setNonce] = React.useState(0);
const [categoryVersion, setCategoryVersion] = React.useState(0);
const [tagBookmarksVersion, setTagBookmarksVersion] = React.useState(0);
// Bumped whenever a tag or collection is bookmarked/unbookmarked anywhere (Tags list/detail,
// Collections grid/detail) so the sidebar's quick-access list refetches -- see Sidebar's own
// bookmarksVersion prop.
const [bookmarksVersion, setBookmarksVersion] = React.useState(0);
const handleCategoriesChanged = React.useCallback(() => {
setCategoryVersion(v => v + 1);
}, []);
const handleTagBookmarksChanged = React.useCallback(() => {
setTagBookmarksVersion(v => v + 1);
const handleBookmarksChanged = React.useCallback(() => {
setBookmarksVersion(v => v + 1);
}, []);
const handlePrintsChanged = React.useCallback(() => {
@@ -107,7 +110,7 @@ function AppShell({
categoryId={categoryId}
onSelectCategory={setCategoryId}
onPrintsChanged={handlePrintsChanged}
tagBookmarksVersion={tagBookmarksVersion}
bookmarksVersion={bookmarksVersion}
onUnauthorized={onUnauthorized}
isAdmin={isAdmin}
onOpenProfile={() => navigate("/profile")}
@@ -136,15 +139,30 @@ function AppShell({
/>
<Route
path="/models/collections"
element={<CollectionsPage theme={resolvedTheme} previewMode={previewMode} onUnauthorized={onUnauthorized} />}
element={
<CollectionsPage
theme={resolvedTheme}
previewMode={previewMode}
onUnauthorized={onUnauthorized}
onBookmarksChanged={handleBookmarksChanged}
/>
}
/>
<Route
path="/models/collections/:collectionId"
element={<CollectionDetailPage theme={resolvedTheme} previewMode={previewMode} onUnauthorized={onUnauthorized} viewer={user} />}
element={
<CollectionDetailPage
theme={resolvedTheme}
previewMode={previewMode}
onUnauthorized={onUnauthorized}
onBookmarksChanged={handleBookmarksChanged}
viewer={user}
/>
}
/>
<Route
path="/models/tags"
element={<TagsPage onUnauthorized={onUnauthorized} onBookmarksChanged={handleTagBookmarksChanged} />}
element={<TagsPage onUnauthorized={onUnauthorized} onBookmarksChanged={handleBookmarksChanged} />}
/>
<Route
path="/models/tags/:tagName"
@@ -153,7 +171,7 @@ function AppShell({
theme={resolvedTheme}
previewMode={previewMode}
onUnauthorized={onUnauthorized}
onBookmarksChanged={handleTagBookmarksChanged}
onBookmarksChanged={handleBookmarksChanged}
viewer={user}
/>
}
+32
View File
@@ -0,0 +1,32 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk } from "./client";
/** One row of the sidebar's quick-access "Bookmarks" section -- a bookmarked tag (just its name,
* same as tagsApi) or a bookmarked collection (id + name, resolved server-side). Both share one
* ordered list (see Sidebar/index.tsx), sorted by the manual drag order the backend keeps in
* Bookmark.order. */
export type BookmarkEntry =
| { id: string; type: "tag"; tag: string }
| { id: string; type: "collection"; collection_id: string; name: string };
export const bookmarksApi = {
/** Every bookmarked tag and collection, tags and collections interleaved in one list already
* sorted by the user's manual order -- backs the sidebar's quick-access list directly. */
list: async (): Promise<BookmarkEntry[]> => {
const res = await fetch(`${apiBase()}/bookmarks`, { headers: authHeaders() });
assertOk(res, "Failed to list bookmarks");
return res.json();
},
/** Persists a full drag-and-drop reorder of the sidebar's bookmark list -- `ids` must be
* exactly this user's current bookmark ids (from `list()`), in their new order. */
reorder: async (ids: string[]): Promise<BookmarkEntry[]> => {
const res = await fetch(`${apiBase()}/bookmarks/reorder`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ bookmark_ids: ids }),
});
assertOk(res, "Failed to reorder bookmarks");
return res.json();
},
};
+16
View File
@@ -16,6 +16,9 @@ export type Collection = {
* be edited or deleted, and their card/detail title should come from a translated label keyed
* off this instead of `name`. */
system_key: SystemCollectionKey | null;
/** Whether this user has bookmarked this collection into the sidebar's quick-access list --
* always false for a system pseudo-collection, which can't be bookmarked. */
bookmarked: boolean;
};
export type CollectionInput = {
@@ -98,4 +101,17 @@ export const collectionsApi = {
assertOk(res, "Failed to list collections");
return res.json();
},
/** Adds/removes a (real, non-system) collection from the sidebar's quick-access "Bookmarks"
* list -- same shape as tagsApi.bookmark/unbookmark. Used by the Collections grid card's "..."
* menu and the collection detail page's title-row toggle. */
bookmark: async (id: string): Promise<void> => {
const res = await fetch(`${apiBase()}/collection/${id}/bookmark`, { method: "POST", headers: authHeaders() });
assertOk(res, "Failed to bookmark collection");
},
unbookmark: async (id: string): Promise<void> => {
const res = await fetch(`${apiBase()}/collection/${id}/bookmark`, { method: "DELETE", headers: authHeaders() });
assertOk(res, "Failed to remove bookmark");
},
};
+2 -4
View File
@@ -25,21 +25,19 @@ export const tagsApi = {
return res.json();
},
bookmark: async (tag: string): Promise<string[]> => {
bookmark: async (tag: string): Promise<void> => {
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[]> => {
unbookmark: async (tag: string): Promise<void> => {
const res = await fetch(`${apiBase()}/tags/${encodeURIComponent(tag)}/bookmark`, {
method: "DELETE",
headers: authHeaders(),
});
assertOk(res, "Failed to remove bookmark");
return res.json();
},
};
+3 -3
View File
@@ -28,7 +28,7 @@ type AppLayoutProps = {
categoryId: string | null;
onSelectCategory: (id: string | null) => void;
onPrintsChanged: () => void;
tagBookmarksVersion: number;
bookmarksVersion: number;
onUnauthorized: () => void;
isAdmin: boolean;
onOpenProfile: () => void;
@@ -133,7 +133,7 @@ function AppLayoutShell({
categoryId,
onSelectCategory,
onPrintsChanged,
tagBookmarksVersion,
bookmarksVersion,
onUnauthorized,
isAdmin,
onOpenProfile,
@@ -162,7 +162,7 @@ function AppLayoutShell({
display: "flex",
}}
>
<Sidebar isAdmin={isAdmin} onSelectCategory={onSelectCategory} tagBookmarksVersion={tagBookmarksVersion} />
<Sidebar isAdmin={isAdmin} onSelectCategory={onSelectCategory} bookmarksVersion={bookmarksVersion} />
<Box component="main" sx={{ flex: 1, p: 2, pt: 0 }}>
{apiUp === false && (
<Alert severity="error" sx={{ mb: 1.5 }}>
@@ -24,7 +24,7 @@ import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import Wordmark from "../../Wordmark";
import { tagsApi } from "../../../api/tags";
import { type BookmarkEntry, bookmarksApi } from "../../../api/bookmarks";
const SIDEBAR_WIDTH = 240;
const SIDEBAR_COLLAPSED_WIDTH = 72;
@@ -84,18 +84,28 @@ 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;
/** Bumped whenever a tag or collection is bookmarked/unbookmarked elsewhere (the Tags/
* Collections list pages, or a tag/collection 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. */
bookmarksVersion?: number;
};
/** Where a bookmark entry navigates to, and its display label -- tags and collections share one
* ordered list (see BookmarkEntry) but differ in both. */
function bookmarkTarget(entry: BookmarkEntry): { href: string; label: string } {
return entry.type === "tag"
? { href: `/models/tags/${encodeURIComponent(entry.tag)}`, label: entry.tag }
: { href: `/models/collections/${entry.collection_id}`, label: entry.name };
}
/** 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) a single "Administration" row -- it's just a link to the /admin hub
* page now, not an expandable list of every admin sub-page (see AdminPage). Category browsing
* lives inside the Models page itself, not here. */
export default function Sidebar({ isAdmin, onSelectCategory, tagBookmarksVersion }: Props) {
* (once any tag or collection is bookmarked) a divider, a "Bookmarks" heading, and one row per
* bookmark -- tags and collections interleaved, in the user's own manual order (drag a row up or
* down to reorder; see handleDrop) -- and finally (for admins) a single "Administration" row --
* it's just a link to the /admin hub page now, not an expandable list of every admin sub-page
* (see AdminPage). Category browsing lives inside the Models page itself, not here. */
export default function Sidebar({ isAdmin, onSelectCategory, bookmarksVersion }: Props) {
const { t } = useTranslation(["app", "common"]);
const location = useLocation();
const navigate = useNavigate();
@@ -103,17 +113,40 @@ export default function Sidebar({ isAdmin, onSelectCategory, tagBookmarksVersion
if (typeof window === "undefined") return false;
return window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === "true";
});
const [bookmarkedTags, setBookmarkedTags] = useState<string[]>([]);
const [bookmarks, setBookmarks] = useState<BookmarkEntry[]>([]);
// The bookmark id currently being dragged, if any -- set on that row's dragstart, read by every
// other row's drop handler, cleared once the gesture ends (drop, or a drag that's cancelled).
const [draggingId, setDraggingId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
tagsApi.listBookmarked()
.then(tags => { if (!cancelled) setBookmarkedTags(tags); })
bookmarksApi.list()
.then(entries => { if (!cancelled) setBookmarks(entries); })
.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.
// bookmarksVersion is a deliberate refetch trigger, not read inside the effect itself.
// oxlint-disable-next-line react/exhaustive-effect-dependencies
}, [tagBookmarksVersion]);
}, [bookmarksVersion]);
// Drops `draggedId` immediately before/after `targetId` (wherever it lands in the array once
// moved next to it) -- applied optimistically so the row jumps right away, then persisted via
// POST /bookmarks/reorder; a failed save just refetches the server's own order rather than
// trying to roll back the local splice by hand.
const handleDrop = (draggedId: string, targetId: string) => {
if (draggedId === targetId) return;
setBookmarks(prev => {
const from = prev.findIndex(b => b.id === draggedId);
const to = prev.findIndex(b => b.id === targetId);
if (from === -1 || to === -1) return prev;
const next = prev.slice();
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
bookmarksApi.reorder(next.map(b => b.id)).catch(() => {
bookmarksApi.list().then(setBookmarks).catch(() => { /* leave the optimistic order as-is */ });
});
return next;
});
};
const onDashboard = location.pathname === "/";
const onCollections = location.pathname.startsWith("/models/collections");
@@ -285,11 +318,14 @@ 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 && (
{/* Bookmarked tags + collections, interleaved in the user's own manual order -- 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. Dragging is only wired
up while expanded too: the collapsed rail is icon-only, with no room for a
meaningful drag target. */}
{bookmarks.length > 0 && (
<>
<Divider sx={{ my: 1 }} />
{!collapsed && (
@@ -301,28 +337,37 @@ export default function Sidebar({ isAdmin, onSelectCategory, tagBookmarksVersion
{t("sidebar.bookmarks")}
</Typography>
)}
{bookmarkedTags.map(tag => {
const target = `/models/tags/${encodeURIComponent(tag)}`;
const selected = location.pathname === target;
{bookmarks.map(entry => {
const { href, label } = bookmarkTarget(entry);
const selected = location.pathname === href;
return collapsed ? (
<CollapsedNavIcon
key={tag}
key={entry.id}
icon={<BookmarkIcon fontSize="small" />}
label={tag}
label={label}
selected={selected}
onClick={() => navigate(target)}
onClick={() => navigate(href)}
/>
) : (
<ListItemButton
key={tag}
key={entry.id}
draggable
onDragStart={() => setDraggingId(entry.id)}
onDragOver={e => e.preventDefault()}
onDrop={e => {
e.preventDefault();
if (draggingId) handleDrop(draggingId, entry.id);
setDraggingId(null);
}}
onDragEnd={() => setDraggingId(null)}
selected={selected}
onClick={() => navigate(target)}
sx={{ borderRadius: 1, mb: 0.5, ...navRowSx(selected) }}
onClick={() => navigate(href)}
sx={{ borderRadius: 1, mb: 0.5, cursor: "grab", ...navRowSx(selected) }}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<BookmarkIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={tag} primaryTypographyProps={{ variant: "body2", noWrap: true }} />
<ListItemText primary={label} primaryTypographyProps={{ variant: "body2", noWrap: true }} />
</ListItemButton>
);
})}
+3
View File
@@ -1133,6 +1133,9 @@
"pageTitle": "Collections",
"newCollection": "New Collection",
"empty": "No collections yet.",
"bookmarkCollection": "Bookmark collection",
"unbookmarkCollection": "Remove bookmark",
"bookmarkFailed": "Failed to update bookmark. Please try again.",
"system": {
"favorites": "Favourites",
"history": "Browsing History"
+3
View File
@@ -1134,6 +1134,9 @@
"pageTitle": "Kolekcijos",
"newCollection": "Nauja kolekcija",
"empty": "Kol kas nėra kolekcijų.",
"bookmarkCollection": "Įsiminti kolekciją",
"unbookmarkCollection": "Pašalinti iš įsimintų",
"bookmarkFailed": "Nepavyko atnaujinti įsimintos kolekcijos. Bandykite dar kartą.",
"system": {
"favorites": "Mėgstami",
"history": "Naršymo istorija"
@@ -9,6 +9,8 @@ import CircularProgress from "@mui/material/CircularProgress";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import BookmarkIcon from "@mui/icons-material/Bookmark";
import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder";
import type { SxProps, Theme } from "@mui/material/styles";
import { UnauthorizedError } from "../../api/client";
import { type Collection, type CollectionInput, collectionsApi } from "../../api/collections";
@@ -20,23 +22,56 @@ type Props = {
onUpdated: (collection: Collection) => void;
onUnauthorized?: () => void;
onDeleted: () => void;
/** Called after a successful bookmark/unbookmark so the sidebar's own bookmarked list can
* refetch -- same callback TagBookmarkButton/TagsPage already use for tags. */
onBookmarksChanged?: () => void;
/** Lets callers restyle the trigger button -- e.g. the Collections grid's hover overlay,
* which needs to read over an arbitrary cover photo instead of the header's plain icon. */
triggerSx?: SxProps<Theme>;
};
/** The "..." menu for a collection: Edit (opens the same create/edit modal used from the
* Collections grid, prefilled) and Delete (confirm, then delete). Shared by the collection
* detail page's header and the Collections grid's per-card hover overlay. */
export default function CollectionActionsMenu({ collection, onUpdated, onUnauthorized, onDeleted, triggerSx }: Props) {
/** The "..." menu for a collection: Bookmark/Remove from bookmarks, Edit (opens the same
* create/edit modal used from the Collections grid, prefilled) and Delete (confirm, then
* delete). Shared by the collection detail page's header and the Collections grid's per-card
* hover overlay. */
export default function CollectionActionsMenu({
collection,
onUpdated,
onUnauthorized,
onDeleted,
onBookmarksChanged,
triggerSx,
}: Props) {
const { t } = useTranslation(["models", "common"]);
const confirmDialog = useConfirm();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [bookmarking, setBookmarking] = useState(false);
const closeMenu = () => setAnchorEl(null);
const handleToggleBookmark = async () => {
closeMenu();
if (bookmarking) return;
setBookmarking(true);
const next = !collection.bookmarked;
try {
await (next ? collectionsApi.bookmark(collection.id) : collectionsApi.unbookmark(collection.id));
onUpdated({ ...collection, bookmarked: next });
onBookmarksChanged?.();
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
console.error(err);
alert(t("models:collections.bookmarkFailed"));
} finally {
setBookmarking(false);
}
};
const handleEdit = async (input: CollectionInput) => {
try {
onUpdated(await collectionsApi.update(collection.id, input));
@@ -78,12 +113,20 @@ export default function CollectionActionsMenu({ collection, onUpdated, onUnautho
size="small"
onClick={e => setAnchorEl(e.currentTarget)}
aria-label={t("common:more") ?? undefined}
disabled={deleting}
disabled={deleting || bookmarking}
sx={triggerSx}
>
{deleting ? <CircularProgress size={18} /> : <MoreVertIcon fontSize="small" />}
</IconButton>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={closeMenu}>
<MenuItem onClick={handleToggleBookmark}>
<ListItemIcon>
{collection.bookmarked ? <BookmarkIcon fontSize="small" color="primary" /> : <BookmarkBorderIcon fontSize="small" />}
</ListItemIcon>
<ListItemText>
{collection.bookmarked ? t("models:collections.unbookmarkCollection") : t("models:collections.bookmarkCollection")}
</ListItemText>
</MenuItem>
<MenuItem onClick={() => { closeMenu(); setEditOpen(true); }}>
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t("common:edit")}</ListItemText>
@@ -0,0 +1,67 @@
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 { collectionsApi } from "../../api/collections";
type Props = {
collectionId: string;
bookmarked: boolean;
onUnauthorized?: () => void;
/** Called after a successful toggle so the sidebar's own bookmarked list can refetch. */
onBookmarksChanged?: () => void;
/** Called after a successful toggle with the new state, so a caller that also shows this
* collection's bookmark status elsewhere on the same page (e.g. the "..." menu) can stay in
* sync instead of reading a now-stale `bookmarked` prop. */
onToggled?: (bookmarked: boolean) => void;
};
/** The collection detail page's title-row bookmark toggle -- adds/removes this collection from
* the sidebar's quick-access list. Identical optimistic-flip shape to TagBookmarkButton. */
export default function CollectionBookmarkButton({
collectionId,
bookmarked,
onUnauthorized,
onBookmarksChanged,
onToggled,
}: 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 ? collectionsApi.bookmark(collectionId) : collectionsApi.unbookmark(collectionId));
onToggled?.(next);
onBookmarksChanged?.();
} catch (err) {
setOverride(bookmarked);
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
console.error(err);
alert(t("models:collections.bookmarkFailed"));
} finally {
pendingRef.current = false;
}
};
const label = isBookmarked ? t("models:collections.unbookmarkCollection") : t("models:collections.bookmarkCollection");
return (
<Tooltip title={label}>
<IconButton size="small" onClick={toggle} aria-label={label}>
{isBookmarked ? <BookmarkIcon fontSize="small" color="primary" /> : <BookmarkBorderIcon fontSize="small" />}
</IconButton>
</Tooltip>
);
}
@@ -17,6 +17,7 @@ import { collectionDisplayName } from "../../utils/collectionDisplay";
import ModelCard from "../ModelsPage/ModelCard";
import SortTabs from "../ModelsPage/SortTabs";
import CollectionActionsMenu from "./CollectionActionsMenu";
import CollectionBookmarkButton from "./CollectionBookmarkButton";
const PAGE_SIZE = 24;
@@ -24,10 +25,11 @@ type Props = {
theme: ResolvedTheme;
previewMode: PreviewMode;
onUnauthorized?: () => void;
onBookmarksChanged?: () => void;
viewer?: AuthUser | null;
};
export default function CollectionDetailPage({ theme, previewMode, onUnauthorized, viewer }: Props) {
export default function CollectionDetailPage({ theme, previewMode, onUnauthorized, onBookmarksChanged, viewer }: Props) {
const { collectionId } = useParams<{ collectionId: string }>();
const navigate = useNavigate();
const { t } = useTranslation(["models", "common"]);
@@ -56,12 +58,22 @@ export default function CollectionDetailPage({ theme, previewMode, onUnauthorize
usePageHeader({
title: collection ? t("models:collections.detail.title", { name: collectionDisplayName(collection, t) }) : undefined,
actions: collection && !collection.system_key ? (
<CollectionActionsMenu
collection={collection}
onUpdated={setCollection}
onUnauthorized={onUnauthorized}
onDeleted={goBack}
/>
<Stack direction="row" alignItems="center" spacing={0.5}>
<CollectionBookmarkButton
collectionId={collection.id}
bookmarked={collection.bookmarked}
onUnauthorized={onUnauthorized}
onBookmarksChanged={onBookmarksChanged}
onToggled={bookmarked => setCollection(prev => (prev ? { ...prev, bookmarked } : prev))}
/>
<CollectionActionsMenu
collection={collection}
onUpdated={setCollection}
onUnauthorized={onUnauthorized}
onDeleted={goBack}
onBookmarksChanged={onBookmarksChanged}
/>
</Stack>
) : undefined,
});
@@ -20,6 +20,7 @@ type Props = {
onUpdated: (collection: Collection) => void;
onDeleted: (id: string) => void;
onUnauthorized?: () => void;
onBookmarksChanged?: () => void;
};
const COVER_TILE_LIMIT = 4;
@@ -69,7 +70,7 @@ function CoverTile({
);
}
export default function CollectionCard({ collection, theme, previewMode, onUpdated, onDeleted, onUnauthorized }: Props) {
export default function CollectionCard({ collection, theme, previewMode, onUpdated, onDeleted, onUnauthorized, onBookmarksChanged }: Props) {
const { t } = useTranslation(["models", "common"]);
const navigate = useNavigate();
const coverItems = collection.cover_items.slice(0, COVER_TILE_LIMIT);
@@ -150,6 +151,7 @@ export default function CollectionCard({ collection, theme, previewMode, onUpdat
onUpdated={onUpdated}
onDeleted={() => onDeleted(collection.id)}
onUnauthorized={onUnauthorized}
onBookmarksChanged={onBookmarksChanged}
triggerSx={{ color: "#fff" }}
/>
</Box>
+3 -1
View File
@@ -15,11 +15,12 @@ import CollectionsActionsMenu from "./CollectionsActionsMenu";
type Props = {
onUnauthorized?: () => void;
onBookmarksChanged?: () => void;
theme: ResolvedTheme;
previewMode: PreviewMode;
};
export default function CollectionsPage({ onUnauthorized, theme, previewMode }: Props) {
export default function CollectionsPage({ onUnauthorized, onBookmarksChanged, theme, previewMode }: Props) {
const { t } = useTranslation(["models", "common"]);
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(false);
@@ -97,6 +98,7 @@ export default function CollectionsPage({ onUnauthorized, theme, previewMode }:
onUpdated={handleCollectionUpdated}
onDeleted={handleCollectionDeleted}
onUnauthorized={onUnauthorized}
onBookmarksChanged={onBookmarksChanged}
/>
))}
</Box>
@@ -194,6 +194,7 @@ export default function TagDetailPage({ theme, previewMode, onUnauthorized, onBo
onUpdated={updated => setCollections(prev => prev.map(c => (c.id === updated.id ? updated : c)))}
onDeleted={deletedId => setCollections(prev => prev.filter(c => c.id !== deletedId))}
onUnauthorized={onUnauthorized}
onBookmarksChanged={onBookmarksChanged}
/>
))}
{items.map(item => (