mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 14:12:25 -05:00
feat: allow bookmarking collections
This commit is contained in:
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";
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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 });
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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,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");
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
Reference in new issue
Block a user