diff --git a/backend/prisma/migrations/20260919133143_add_bookmarks/migration.sql b/backend/prisma/migrations/20260919133143_add_bookmarks/migration.sql new file mode 100644 index 0000000..f798e7c --- /dev/null +++ b/backend/prisma/migrations/20260919133143_add_bookmarks/migration.sql @@ -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"; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 48e8c64..5b05e68 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 { diff --git a/backend/src/app.ts b/backend/src/app.ts index fdd8103..6a670d4 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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); diff --git a/backend/src/dto.ts b/backend/src/dto.ts index ffe5406..c123f1f 100644 --- a/backend/src/dto.ts +++ b/backend/src/dto.ts @@ -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, }; } diff --git a/backend/src/routes/bookmarks.ts b/backend/src/routes/bookmarks.ts new file mode 100644 index 0000000..1a09c13 --- /dev/null +++ b/backend/src/routes/bookmarks.ts @@ -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; diff --git a/backend/src/routes/collections.ts b/backend/src/routes/collections.ts index 4a2562c..23cb927 100644 --- a/backend/src/routes/collections.ts +++ b/backend/src/routes/collections.ts @@ -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). diff --git a/backend/src/routes/tags.ts b/backend/src/routes/tags.ts index 39008db..6b563b9 100644 --- a/backend/src/routes/tags.ts +++ b/backend/src/routes/tags.ts @@ -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[] { + 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(); 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 }); }), ); diff --git a/backend/src/services/bookmarkService.ts b/backend/src/services/bookmarkService.ts new file mode 100644 index 0000000..8090753 --- /dev/null +++ b/backend/src/services/bookmarkService.ts @@ -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 { + 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> { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 } })), + ); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 11810af..0827570 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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({ /> } + element={ + + } /> } + element={ + + } /> } + element={} /> } diff --git a/frontend/src/api/bookmarks.ts b/frontend/src/api/bookmarks.ts new file mode 100644 index 0000000..5770c7e --- /dev/null +++ b/frontend/src/api/bookmarks.ts @@ -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 => { + 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 => { + 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(); + }, +}; diff --git a/frontend/src/api/collections.ts b/frontend/src/api/collections.ts index 72d6a39..a5a5018 100644 --- a/frontend/src/api/collections.ts +++ b/frontend/src/api/collections.ts @@ -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 => { + const res = await fetch(`${apiBase()}/collection/${id}/bookmark`, { method: "POST", headers: authHeaders() }); + assertOk(res, "Failed to bookmark collection"); + }, + + unbookmark: async (id: string): Promise => { + const res = await fetch(`${apiBase()}/collection/${id}/bookmark`, { method: "DELETE", headers: authHeaders() }); + assertOk(res, "Failed to remove bookmark"); + }, }; diff --git a/frontend/src/api/tags.ts b/frontend/src/api/tags.ts index 8b97ce7..253726c 100644 --- a/frontend/src/api/tags.ts +++ b/frontend/src/api/tags.ts @@ -25,21 +25,19 @@ export const tagsApi = { return res.json(); }, - bookmark: async (tag: string): Promise => { + bookmark: async (tag: string): Promise => { 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 => { + unbookmark: async (tag: string): Promise => { const res = await fetch(`${apiBase()}/tags/${encodeURIComponent(tag)}/bookmark`, { method: "DELETE", headers: authHeaders(), }); assertOk(res, "Failed to remove bookmark"); - return res.json(); }, }; diff --git a/frontend/src/components/Layout/AppLayout.tsx b/frontend/src/components/Layout/AppLayout.tsx index 170553f..27711f3 100644 --- a/frontend/src/components/Layout/AppLayout.tsx +++ b/frontend/src/components/Layout/AppLayout.tsx @@ -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", }} > - + {apiUp === false && ( diff --git a/frontend/src/components/Layout/Sidebar/index.tsx b/frontend/src/components/Layout/Sidebar/index.tsx index f65c094..7e46c69 100644 --- a/frontend/src/components/Layout/Sidebar/index.tsx +++ b/frontend/src/components/Layout/Sidebar/index.tsx @@ -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([]); + const [bookmarks, setBookmarks] = useState([]); + // 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(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 )} - {/* 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 && ( <> {!collapsed && ( @@ -301,28 +337,37 @@ export default function Sidebar({ isAdmin, onSelectCategory, tagBookmarksVersion {t("sidebar.bookmarks")} )} - {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 ? ( } - label={tag} + label={label} selected={selected} - onClick={() => navigate(target)} + onClick={() => navigate(href)} /> ) : ( 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) }} > - + ); })} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index ae728ca..e64fd29 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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" diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index b6ae52b..870bea9 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -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" diff --git a/frontend/src/pages/CollectionDetailPage/CollectionActionsMenu.tsx b/frontend/src/pages/CollectionDetailPage/CollectionActionsMenu.tsx index ff95cf1..6407da1 100644 --- a/frontend/src/pages/CollectionDetailPage/CollectionActionsMenu.tsx +++ b/frontend/src/pages/CollectionDetailPage/CollectionActionsMenu.tsx @@ -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; }; -/** 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(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 ? : } + + + {collection.bookmarked ? : } + + + {collection.bookmarked ? t("models:collections.unbookmarkCollection") : t("models:collections.bookmarkCollection")} + + { closeMenu(); setEditOpen(true); }}> {t("common:edit")} diff --git a/frontend/src/pages/CollectionDetailPage/CollectionBookmarkButton.tsx b/frontend/src/pages/CollectionDetailPage/CollectionBookmarkButton.tsx new file mode 100644 index 0000000..e717440 --- /dev/null +++ b/frontend/src/pages/CollectionDetailPage/CollectionBookmarkButton.tsx @@ -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(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 ( + + + {isBookmarked ? : } + + + ); +} diff --git a/frontend/src/pages/CollectionDetailPage/index.tsx b/frontend/src/pages/CollectionDetailPage/index.tsx index f89e765..493762f 100644 --- a/frontend/src/pages/CollectionDetailPage/index.tsx +++ b/frontend/src/pages/CollectionDetailPage/index.tsx @@ -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 ? ( - + + setCollection(prev => (prev ? { ...prev, bookmarked } : prev))} + /> + + ) : undefined, }); diff --git a/frontend/src/pages/CollectionsPage/CollectionCard.tsx b/frontend/src/pages/CollectionsPage/CollectionCard.tsx index 20c6911..a0b3598 100644 --- a/frontend/src/pages/CollectionsPage/CollectionCard.tsx +++ b/frontend/src/pages/CollectionsPage/CollectionCard.tsx @@ -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" }} /> diff --git a/frontend/src/pages/CollectionsPage/index.tsx b/frontend/src/pages/CollectionsPage/index.tsx index 74f4502..9de8394 100644 --- a/frontend/src/pages/CollectionsPage/index.tsx +++ b/frontend/src/pages/CollectionsPage/index.tsx @@ -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([]); const [loading, setLoading] = useState(false); @@ -97,6 +98,7 @@ export default function CollectionsPage({ onUnauthorized, theme, previewMode }: onUpdated={handleCollectionUpdated} onDeleted={handleCollectionDeleted} onUnauthorized={onUnauthorized} + onBookmarksChanged={onBookmarksChanged} /> ))} diff --git a/frontend/src/pages/TagDetailPage/index.tsx b/frontend/src/pages/TagDetailPage/index.tsx index 979c638..9dff3da 100644 --- a/frontend/src/pages/TagDetailPage/index.tsx +++ b/frontend/src/pages/TagDetailPage/index.tsx @@ -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 => (