From 88aa769fe6e4ff8ce677a1255c54be10a5b52671 Mon Sep 17 00:00:00 2001 From: CoffeeKnyte <67730400+CoffeeKnyte@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:51:12 +0800 Subject: [PATCH] feat(collections): surface server collections on the user Collections tab (#156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(collections): surface server collections on the user Collections tab The user-facing Collections tab only showed personal collections, which are usually empty — leaving most users with a confusingly blank page. Server (admin-curated) collections were reachable only inside each individual library's tab. Add a new GET /collections/server endpoint that aggregates visible library collections across every accessible library (honoring access scope, capped per library with a total_count for a See all link), and restructure Collections.tsx into two titled sections: Your collections (personal) and Server collections (horizontal teaser rows per library, linking into each library's Collections tab). Extract the shared CollectionPosterCard so the per-library grid and the new rows share one implementation. * fix(collections): match server-collections loading skeleton to row layout The Server collections section renders as one horizontal teaser row per library, but the loading skeleton showed a poster grid — so data arriving visibly reflowed the page from a grid into rows. Mirror the final layout (section header + per-library rows of poster cards) in the skeleton, and drop the now-unused COLLECTION_POSTER_GRID_CLASSES import. Addresses CodeRabbit review comment on PR #156. Co-Authored-By: Claude Opus 4.8 * Align server collections with shared carousel behavior - Add opt-out edge padding to reusable media carousels - Render server collection rows with shared carousel controls and spacing --------- Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- ...26-06-14-collections-tab-server-section.md | 276 ++++++++++++++++++ internal/api/handlers/server_collections.go | 111 +++++++ .../api/handlers/server_collections_test.go | 47 +++ internal/api/router.go | 6 + web/src/api/types.ts | 14 + web/src/components/MediaCarousel.tsx | 20 +- .../collections/CollectionPosterCard.tsx | 89 ++++++ web/src/hooks/queries/collections.ts | 13 + web/src/hooks/queries/keys.ts | 1 + web/src/pages/Collections.tsx | 204 +++++++++---- web/src/pages/LibraryCollections.tsx | 89 +----- 11 files changed, 727 insertions(+), 143 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-14-collections-tab-server-section.md create mode 100644 internal/api/handlers/server_collections.go create mode 100644 internal/api/handlers/server_collections_test.go create mode 100644 web/src/components/collections/CollectionPosterCard.tsx diff --git a/docs/superpowers/plans/2026-06-14-collections-tab-server-section.md b/docs/superpowers/plans/2026-06-14-collections-tab-server-section.md new file mode 100644 index 00000000..69cea1bc --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-collections-tab-server-section.md @@ -0,0 +1,276 @@ +# Plan: Personal + Server sections on the user Collections tab + +Status: Draft +Branch: `feat/collections-server-section` +Owner: TBD +Commands assume the repository root is the cwd. + +## Problem + +The user-facing **Collections** tab on the home screen (`web/src/pages/Collections.tsx`) +only shows the signed-in profile's *personal* collections. For most users this list is +empty or nearly empty, so the tab looks broken or pointless. + +Meanwhile, server-curated collections (admin-created "library collections") only appear +*inside each individual library's* Collections tab (`web/src/pages/LibraryCollections.tsx`, +served by `GET /library/{id}/collections`). There is no place in the app where a user can +see the collections curated across the whole server — they are effectively hidden behind +per-library navigation. + +We want the top-level Collections tab to become the single home for collections: + +1. **Your collections** — the user's personal/shared collections, with a large section + title, shown at the top (current behavior, kept intact: grouping, drag-reorder, create, + edit, sync, delete). +2. **Server collections** — below that, a clearly titled section aggregating the visible + server (library) collections across every library the user can access, so they are no + longer buried per-library. + +## Current state (what exists today) + +### Frontend +- `web/src/pages/Collections.tsx` — the top-level tab. Renders the page header + a + `GroupedCollectionsBoard` of personal collections. Data via `useCollections()` / + `useCollectionGroups()` (`web/src/hooks/queries/collections.ts`), both backed by + `GET /collections` → `CollectionsListResponse { collections, groups }`. +- `web/src/pages/LibraryCollections.tsx` — per-library tab. Renders a poster grid + (`CollectionPosterCard`) grouped by admin groups + ungrouped. Data via + `useLibraryCollections(libraryId)` → `GET /library/{id}/collections` → + `LibraryTabResponse { groups, ungrouped }`. **This is the visual we want to reuse for the + server section** (poster cards, item-count badge, navigate to catalog href). +- `web/src/api/types.ts` — `Collection`, `CollectionGroup`, `CollectionsListResponse`, + `LibraryTabResponse`, `LibraryTabGroup`, `LibraryTabCollection`, `LibraryTabUngrouped`. +- Catalog hrefs: `buildLibraryCollectionCatalogHref(id, title)` and + `buildUserCollectionCatalogHref(id, title)` in `web/src/pages/catalogSearchParams.ts`. + +### Backend +- `internal/api/handlers/collections.go` — personal collection handlers under `/collections` + (registered in `internal/api/router.go` ~lines 1700-1726, behind `RequireProfile`). +- `internal/api/handlers/library_collections.go`: + - `HandleListLibraryCollections` (line 813) — builds the per-library `LibraryTabResponse`. + - `requestCanAccessLibrary` (line 3311) — checks `access.GetScope(ctx).AllowedLibraryIDs` + (nil ⇒ access to all libraries). + - `LibraryCollectionRepository.ListAll(ctx, libraryID *int, opts)` + (`internal/catalog/library_collection_repo.go:368`) — **with `libraryID == nil` it + already returns server collections across all libraries** (uses + `libraryCollectionScopeFallbackJoin`), filtering `visibility = 'visible'` unless + `IncludeHidden`. This is the aggregation primitive we need. + - `presignGPURL` / `toLibraryCollectionResponses` — turn stored poster paths into signed + URLs for the response. + +## Design decision: aggregate on the server, new endpoint + +We add a new **user-facing aggregate endpoint** rather than fanning out N per-library calls +from the client (avoids N round-trips, keeps access/visibility filtering server-side, and +matches the "Performance first / Reliability first" priorities in `CLAUDE.md`). + +### New endpoint + +`GET /collections/server` (registered next to the other `/collections` routes in +`internal/api/router.go`, behind `RequireProfile`). + +Response shape (new types, mirrors the poster-card needs without per-library group noise): + +```jsonc +{ + "libraries": [ + { + "library_id": 3, + "library_name": "Movies", + "total_count": 80, // total visible server collections in this library + "collections": [ // CAPPED teaser slice (see "Scale & pagination") + { + "id": "...", + "title": "...", + "poster_url": "https://signed...", + "poster_thumbhash": "...", + "item_count": 42, + "featured": false + } + ] + } + ] +} +``` + +Grouping by library (with `library_name`) is the recommended default: it lets the frontend +render "Server collections" as one **horizontal scrollable row per library**, which keeps a +large catalog legible. (Alternative: a single flat list — simpler UI, but loses provenance +and the per-library "See all" affordance. Recommend per-library grouping.) + +### Scale & pagination (many collections per library) + +The server section is a **discovery** surface aggregating across *all* accessible libraries +at once, so a full wrapping grid per library does not scale (e.g. 5 libraries × 80 +collections = 400 stacked tiles). Decisions: + +- **Render each library as a horizontal carousel row**, not a wrapping grid. Heading = the + library name + a **"See all →"** link. +- **Cap the response** at ~20 collections per library and include `total_count`. Ordering of + the capped slice follows the repo's existing `featured DESC, sort_order ASC, title ASC` + ("best first"), so the teaser row surfaces featured/curated collections. +- **"See all" links to the existing per-library Collections tab** + (`/library/{id}` Collections tab, served by the unchanged + `GET /library/{id}/collections`), which already renders the full wrapping grid with admin + grouping. We do **not** build a new full-aggregate view — reuse the canonical + "show everything for this library" surface. +- This caps payload size and the per-library query cost, and keeps the home tab fast + ("Performance first"). + +### API contract impact + +- `GET /collections` (personal) — **unchanged**. +- `GET /library/{id}/collections` (per-library tab, "See all" target) — **unchanged**; + reused as-is. +- `GET /collections/server` — **new, additive**. No existing response changes; no + Android/Apple contract break. + +### Why a separate endpoint, not extending `GET /collections` + +It is tempting to make `GET /collections` return both personal and server collections in one +call. We deliberately do **not**, for three reasons: + +- **Client safety / semantics.** `GET /collections`'s `collections[]` array is the user's + *editable, personal* shelves — web and the Android/Apple clients render reorder/edit/ + delete/sync affordances on it. Mixing read-only server collections into that same array is + a *silent semantic break*: the JSON still parses, so nothing errors, but clients would + offer "delete"/"reorder" on collections the user can't mutate (→ 403/404). A new sibling + field (`{ collections, groups, server }`) would be additive and parse-safe, but still + couples discovery into the personal-management contract. +- **Cache / refetch lifecycle.** `GET /collections` is the heavily-mutated query — + invalidated on every create, edit, reorder, group change, and sync (via + `invalidateUserCollectionQueries`). Server collections don't change on a personal reorder, + so folding them in would refetch the whole server-wide catalog on every personal mutation + ("Performance first" violation). Separate endpoints ⇒ independent cache keys + lifecycles. +- **Zero downstream risk.** A brand-new `/collections/server` endpoint cannot break existing + clients, because nothing calls it yet. Consolidating into one call later is a *deliberate + follow-up requiring coordinated Android/Apple work* — and must use a distinct field, never + overload `collections[]`. + +### Handler logic (`HandleListServerCollections`) + +1. Resolve accessible library IDs: read `access.GetScope(ctx)`. If `AllowedLibraryIDs == + nil`, the user can see all libraries → list all of them; else restrict to that set. +2. List visible server collections: + - Simplest correct approach: call `repo.ListAll(ctx, nil, ListLibraryCollectionsOptions{})` + once (server-wide, visible-only), then **filter each collection to libraries the user + can access** and bucket by library. This requires knowing each collection's + library membership (`library_collection_libraries`). If `ListAll` does not already + return membership, either (a) add a per-library loop calling + `repo.ListByLibrary(ctx, libID, opts)` for each accessible library (bounded by library + count, each query already does the grouping/sort), or (b) extend the repo to return + library membership. **Recommended: per-accessible-library loop** — reuses the existing, + tested `ListByLibrary` path and naturally yields the per-library buckets and respects + scope. Deduplicate collections that span multiple libraries if we choose flat output; + for per-library output, a collection legitimately appears under each of its libraries. +3. For each library, set `total_count` to the full visible count, then **cap + `collections` to ~20** (keep the repo's `featured DESC, sort_order ASC, title ASC` + ordering so featured/curated collections lead). Define the cap as a single named + constant. +4. Resolve library display names (catalog/library store lookup) for `library_name`. +5. Presign poster URLs via the existing `presignGPURL` helper — only for the capped slice, + so we never presign 100 posters we won't send. +6. Skip empty libraries. Return `200` with `{ libraries: [] }` when nothing is visible. + +Reuse existing helpers (`presignGPURL`, sort helpers like `applyCollectionSort`) rather than +duplicating logic. Add the new response structs next to the existing +`libraryTabResponse` types in `library_collections.go`, or in `collections.go` if cleaner — +keep them in the package that owns the behavior. + +## Frontend changes + +### Types (`web/src/api/types.ts`) +Add: +```ts +export interface ServerCollectionsLibrary { + library_id: number; + library_name: string; + total_count: number; // total visible; collections[] is a capped teaser slice + collections: LibraryTabCollection[]; // reuse existing shape +} +export interface ServerCollectionsResponse { + libraries: ServerCollectionsLibrary[]; +} +``` + +### Query hook (`web/src/hooks/queries/collections.ts` + `keys.ts`) +- Add `collectionKeys.server()` to `web/src/hooks/queries/keys.ts`. +- Add `useServerCollections()` → `GET /collections/server` → + `ServerCollectionsResponse`. Independent query key from `/collections`, so the personal + section keeps its single round-trip and the server section loads in parallel. + +### Page (`web/src/pages/Collections.tsx`) +Restructure into two titled sections inside the existing `page-shell`: + +1. **"Your collections"** section + - Add a large section title (e.g. `

Your collections

`) + above the existing `GroupedCollectionsBoard` / empty-state block. Keep all current + personal-collection behavior unchanged (create, group, reorder, sync, delete). + - The page-level `

Collections

` header + subtitle + action buttons stay at the + very top. + +2. **"Server collections"** section (new) + - Title `

Server collections

` with a short subtitle ("Curated across every + library on this server"). + - Use `useServerCollections()`. Render **one horizontal scrollable row per library** + (carousel), not a wrapping grid. Each poster card reuses the **same visual as + `LibraryCollections.tsx`** — extract `CollectionPosterCard` into a shared component so + both pages use it (avoids duplicate logic per `CLAUDE.md` maintainability rule). + Proposed location: `web/src/components/collections/CollectionPosterCard.tsx`. + - For each `library` bucket: a sub-heading row with `library_name` and a **"See all →"** + link routing to that library's existing Collections tab (the unchanged + `GET /library/{id}/collections` surface). Show "See all" only when + `total_count > collections.length`; optionally include the count, e.g. "See all (80)". + - Cards navigate via `buildLibraryCollectionCatalogHref(id, title)` and show the + item-count badge. These are admin/library collections (`kind="regular"`); the + sidebar-pin affordance can remain — pass `library.library_id` as `libraryId`. + - Use the project's existing horizontal-row/carousel pattern if one exists (check + `web/src/components` for a shelf/row component used on the home screen) rather than + hand-rolling overflow scrolling; reuse it for consistent snap/scroll behavior. + - Loading: skeleton row(s) (reuse the existing skeleton block pattern). + - Empty: if `libraries` is empty, render nothing (or a muted "No server collections yet" + line) — do not show a scary empty state, since the personal section above may have + content. + +### Refactor note +Extracting `CollectionPosterCard` is the only shared-logic extraction required. Keep the +extraction behavior-identical for `LibraryCollections.tsx` (props: `collection`, `kind`, +`libraryId`). The per-library tab keeps its wrapping grid; only the new server section uses +horizontal rows. + +## Out of scope / non-goals +- No changes to how admin/library collections are created or to per-library tabs. +- No reordering/editing of server collections from the user Collections tab (read-only). +- No new visibility model — we honor existing `visibility = 'visible'` + access scope. + +## Risks / follow-ups +- **Access scope correctness**: must not leak collections from libraries outside + `AllowedLibraryIDs`. Mitigate by driving the query from accessible library IDs, and add a + test for a restricted-scope user. +- **Collections spanning multiple libraries** appear once per library in per-library output; + confirm this is the desired UX (vs. dedup). Decide before implementation. +- **Performance**: per-accessible-library loop is N queries. Acceptable for typical library + counts; if N grows large, revisit with a single membership-aware query. Add an index check + on `library_collection_libraries(library_id)` (likely already present). +- **Client parity**: this is a new server endpoint and a web-only UI change. Android/Apple + clients have their own collections surfaces — file follow-up issues if they should mirror + the personal+server split. No API contract break (purely additive endpoint). +- **Empty-state UX** for users with no personal AND no server collections — ensure the page + still reads sensibly. + +## Verification +- Backend: `go build ./...`, `go vet ./...`, and a handler/unit test for + `HandleListServerCollections` covering (a) full access, (b) restricted scope, (c) empty. +- Frontend: `cd web && pnpm run lint && pnpm run format:check`; `make verify-local-paths`. +- Manual: log in as a normal profile with empty personal collections; confirm the + "Your collections" section shows the empty/create state and the "Server collections" + section lists library collections grouped by library with correct posters, counts, and + working navigation into each collection's catalog view. + +## Implementation order +1. Backend: response types + `HandleListServerCollections` + route registration. +2. Backend test for access-scope filtering. +3. Frontend: types + `useServerCollections` hook + `collectionKeys.server()`. +4. Frontend: extract shared `CollectionPosterCard`; refactor `LibraryCollections.tsx`. +5. Frontend: restructure `Collections.tsx` into the two titled sections. +6. Lint/format/build/manual verification. diff --git a/internal/api/handlers/server_collections.go b/internal/api/handlers/server_collections.go new file mode 100644 index 00000000..a361566a --- /dev/null +++ b/internal/api/handlers/server_collections.go @@ -0,0 +1,111 @@ +package handlers + +import ( + "net/http" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" +) + +// serverCollectionsPerLibraryCap bounds how many collections each library +// contributes to the aggregated server-collections response. The frontend +// renders each library as a horizontal teaser row with a "See all" link into +// the per-library Collections tab, so it never needs the full set up front. +// Capping keeps the payload and the per-library presign cost bounded. +const serverCollectionsPerLibraryCap = 20 + +// serverCollectionsLibrary is one library's bucket of visible server (admin) +// collections. Collections is a capped teaser slice; TotalCount is the full +// visible count, used to drive the "See all (N)" affordance. +type serverCollectionsLibrary struct { + LibraryID int `json:"library_id"` + LibraryName string `json:"library_name"` + TotalCount int `json:"total_count"` + Collections []libraryTabCollection `json:"collections"` +} + +type serverCollectionsResponse struct { + Libraries []serverCollectionsLibrary `json:"libraries"` +} + +// capServerCollections trims a library's collections to the per-library teaser +// cap and returns the capped slice alongside the original total count (used for +// the frontend's "See all (N)" affordance). +func capServerCollections(collections []*models.LibraryCollection) ([]*models.LibraryCollection, int) { + total := len(collections) + if total > serverCollectionsPerLibraryCap { + return collections[:serverCollectionsPerLibraryCap], total + } + return collections, total +} + +// HandleListServerCollections aggregates visible server (admin-curated library) +// collections across every library the requester can access, bucketed by +// library. It powers the "Server collections" section of the user-facing +// Collections tab, surfacing collections that otherwise live only inside each +// individual library's tab. +// +// This is intentionally a separate endpoint from GET /collections (which +// returns the user's editable personal collections): the two have different +// access semantics and cache/refetch lifecycles, and overloading the personal +// response with read-only server collections would be a client-facing trap. +func (h *LibraryCollectionHandler) HandleListServerCollections(w http.ResponseWriter, r *http.Request) { + resp := serverCollectionsResponse{Libraries: []serverCollectionsLibrary{}} + if h.FolderRepo == nil { + writeJSON(w, http.StatusOK, resp) + return + } + + folders, err := h.FolderRepo.List(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load libraries") + return + } + + // Folders are already ordered by sort_order; iterate in that order so the + // rendered rows match the user's library ordering. + for _, f := range folders { + if !f.Enabled { + continue + } + // Honor the requester's library access scope; never reveal collections + // from libraries outside AllowedLibraryIDs. + if !requestCanAccessLibrary(r, f.ID) { + continue + } + + // ListByLibrary returns only visible collections, ordered + // featured-first, so the capped slice surfaces the best ones. + collections, err := h.repo.ListByLibrary(r.Context(), f.ID, catalog.ListLibraryCollectionsOptions{}) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collections") + return + } + if len(collections) == 0 { + continue + } + + collections, total := capServerCollections(collections) + + colls := make([]libraryTabCollection, 0, len(collections)) + for _, c := range collections { + colls = append(colls, libraryTabCollection{ + ID: c.ID, + Title: c.Title, + PosterURL: h.presignGPURL(r, c.PosterURL), + PosterThumbhash: c.PosterThumbhash, + ItemCount: c.ItemCount, + Featured: c.Featured, + }) + } + + resp.Libraries = append(resp.Libraries, serverCollectionsLibrary{ + LibraryID: f.ID, + LibraryName: f.Name, + TotalCount: total, + Collections: colls, + }) + } + + writeJSON(w, http.StatusOK, resp) +} diff --git a/internal/api/handlers/server_collections_test.go b/internal/api/handlers/server_collections_test.go new file mode 100644 index 00000000..045a315f --- /dev/null +++ b/internal/api/handlers/server_collections_test.go @@ -0,0 +1,47 @@ +package handlers + +import ( + "testing" + + "github.com/Silo-Server/silo-server/internal/models" +) + +func TestCapServerCollections(t *testing.T) { + makeN := func(n int) []*models.LibraryCollection { + out := make([]*models.LibraryCollection, n) + for i := range out { + out[i] = &models.LibraryCollection{ID: string(rune('a' + i%26))} + } + return out + } + + t.Run("under cap returns all, total equals length", func(t *testing.T) { + in := makeN(serverCollectionsPerLibraryCap - 3) + got, total := capServerCollections(in) + if total != serverCollectionsPerLibraryCap-3 { + t.Fatalf("total = %d, want %d", total, serverCollectionsPerLibraryCap-3) + } + if len(got) != len(in) { + t.Fatalf("len(got) = %d, want %d", len(got), len(in)) + } + }) + + t.Run("at cap returns all", func(t *testing.T) { + in := makeN(serverCollectionsPerLibraryCap) + got, total := capServerCollections(in) + if total != serverCollectionsPerLibraryCap || len(got) != serverCollectionsPerLibraryCap { + t.Fatalf("got len=%d total=%d, want both %d", len(got), total, serverCollectionsPerLibraryCap) + } + }) + + t.Run("over cap trims slice but reports full total", func(t *testing.T) { + in := makeN(serverCollectionsPerLibraryCap + 17) + got, total := capServerCollections(in) + if len(got) != serverCollectionsPerLibraryCap { + t.Fatalf("len(got) = %d, want %d", len(got), serverCollectionsPerLibraryCap) + } + if total != serverCollectionsPerLibraryCap+17 { + t.Fatalf("total = %d, want %d", total, serverCollectionsPerLibraryCap+17) + } + }) +} diff --git a/internal/api/router.go b/internal/api/router.go index a04a3bf9..cd209bcd 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1805,6 +1805,12 @@ func NewRouter(deps Dependencies) chi.Router { r.Route("/collections", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Get("/", collectionHandler.HandleListCollections) + if libraryCollectionHandler != nil { + // Aggregated server (admin-curated) collections across + // every accessible library. Separate from "/" (personal, + // editable) by design — different access + cache lifecycle. + r.Get("/server", libraryCollectionHandler.HandleListServerCollections) + } r.Post("/", collectionHandler.HandleCreateCollection) r.Post("/preview", collectionHandler.HandlePreviewCollection) r.Put("/order", collectionHandler.HandleReorderCollections) diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 3550e11a..909792ed 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1479,6 +1479,20 @@ export interface LibraryTabResponse { ungrouped?: LibraryTabUngrouped; } +// One library's bucket of server (admin-curated) collections in the aggregated +// server-collections response. `collections` is a capped teaser slice; +// `total_count` is the full visible count, used for the "See all (N)" link. +export interface ServerCollectionsLibrary { + library_id: number; + library_name: string; + total_count: number; + collections: LibraryTabCollection[]; +} + +export interface ServerCollectionsResponse { + libraries: ServerCollectionsLibrary[]; +} + export interface ImportMDBListCollectionRequest { library_id?: number; library_ids?: number[]; diff --git a/web/src/components/MediaCarousel.tsx b/web/src/components/MediaCarousel.tsx index d5a2710b..415c6a95 100644 --- a/web/src/components/MediaCarousel.tsx +++ b/web/src/components/MediaCarousel.tsx @@ -15,6 +15,14 @@ interface MediaCarouselProps { onViewAll?: () => void; /** Optional actions rendered next to the title (e.g. pin button) */ headerActions?: ReactNode; + /** + * When true (default) the carousel applies its own page-edge horizontal + * padding, so it sits correctly on full-bleed pages (Home, Library). Set to + * false when embedding inside an already-padded container (e.g. a + * `page-shell` page) so the header and cards align to the parent's content + * box instead of picking up a second layer of padding. + */ + edgePadding?: boolean; } export default function MediaCarousel({ @@ -26,8 +34,14 @@ export default function MediaCarousel({ skeletonAspect = "aspect-[2/3]", onViewAll, headerActions, + edgePadding = true, }: MediaCarouselProps) { const { emblaRef, canScrollPrev, canScrollNext, scrollPrev, scrollNext } = useCarouselEmbla(); + // Page-edge padding is opt-out so the carousel can also be embedded in an + // already-padded container without double-padding the header and cards. + const headerPadX = edgePadding ? " px-4 sm:px-6 lg:px-10 xl:px-12" : ""; + const viewportPadX = edgePadding ? " pr-4 sm:pr-6 lg:pr-10 xl:pr-12" : ""; + const containerPadX = edgePadding ? " pl-4 sm:pl-6 lg:pl-10 xl:pl-12" : ""; const slideChildren = loading ? Array.from({ length: skeletonCount }).map((_, i) => (
@@ -40,7 +54,7 @@ export default function MediaCarousel({ return (
-
+
{titleHref ? ( @@ -86,7 +100,7 @@ export default function MediaCarousel({
{ @@ -99,7 +113,7 @@ export default function MediaCarousel({ >
    {slideChildren.map((child, index) => (
  • diff --git a/web/src/components/collections/CollectionPosterCard.tsx b/web/src/components/collections/CollectionPosterCard.tsx new file mode 100644 index 00000000..9340c120 --- /dev/null +++ b/web/src/components/collections/CollectionPosterCard.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { Pin, PinOff, User } from "lucide-react"; +import type { LibraryTabCollection } from "@/api/types"; +import { useToggleSidebarPin } from "@/hooks/queries/sidebarPins"; +import { useViewTransitionNavigate } from "@/hooks/useViewTransition"; +import { + buildLibraryCollectionCatalogHref, + buildUserCollectionCatalogHref, +} from "@/pages/catalogSearchParams"; + +// Shared poster card for both the per-library Collections tab and the +// aggregated "Server collections" section on the home Collections tab. Library +// (admin) collections get a sidebar-pin affordance; user collections do not. +export const COLLECTION_POSTER_GRID_CLASSES = + "grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-3"; + +export function CollectionPosterCard({ + collection, + kind, + libraryId, +}: { + collection: LibraryTabCollection; + kind: "regular" | "user_collections"; + libraryId: number; +}) { + const [loaded, setLoaded] = useState(false); + const navigate = useViewTransitionNavigate(); + const { togglePin, isPinned } = useToggleSidebarPin(); + const pinned = isPinned(libraryId, "collection", collection.id); + + const isUserCollection = kind === "user_collections"; + const href = isUserCollection + ? buildUserCollectionCatalogHref(collection.id, collection.title) + : buildLibraryCollectionCatalogHref(collection.id, collection.title); + + return ( +
    + + {/* Pin to sidebar button — only for library collections */} + {!isUserCollection && ( + + )} +
    + ); +} diff --git a/web/src/hooks/queries/collections.ts b/web/src/hooks/queries/collections.ts index 74143054..cd7c5a1c 100644 --- a/web/src/hooks/queries/collections.ts +++ b/web/src/hooks/queries/collections.ts @@ -6,6 +6,7 @@ import type { CollectionItem, CollectionsListResponse, CreateCollectionRequest, + ServerCollectionsResponse, UpdateCollectionRequest, } from "@/api/types"; import { collectionKeys } from "./keys"; @@ -50,6 +51,18 @@ export function useCollectionGroups() { }); } +// useServerCollections loads the admin-curated "server" collections aggregated +// across every library the viewer can access. Kept on a separate query key from +// useCollections() (personal, editable) so personal mutations don't refetch the +// server-wide catalog and the two sections load independently. +export function useServerCollections() { + return useQuery({ + queryKey: collectionKeys.server(), + queryFn: () => + api("/collections/server").then((d) => d.libraries ?? []), + }); +} + export function useCollectionItems(collectionId: string) { return useQuery({ queryKey: collectionKeys.items(collectionId), diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index 470cd15e..e2c54eaf 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -111,6 +111,7 @@ export const historyKeys = { export const collectionKeys = { all: ["collections"] as const, list: () => ["collections", "list"] as const, + server: () => ["collections", "server"] as const, items: (collectionId: string) => ["collections", "items", collectionId] as const, preview: (scope: "user" | "admin", fingerprint: string) => ["collections", "preview", scope, fingerprint] as const, diff --git a/web/src/pages/Collections.tsx b/web/src/pages/Collections.tsx index 9397feb5..d46e5441 100644 --- a/web/src/pages/Collections.tsx +++ b/web/src/pages/Collections.tsx @@ -16,7 +16,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { CSS } from "@dnd-kit/utilities"; -import type { Collection, UserCollectionType } from "@/api/types"; +import type { Collection, ServerCollectionsLibrary, UserCollectionType } from "@/api/types"; import { useCollectionGroups, useCollections, @@ -25,9 +25,12 @@ import { useDeleteCollectionGroup, useReorderCollectionGroups, useReorderCollections, + useServerCollections, useUpdateCollection, useUpdateCollectionGroup, } from "@/hooks/queries/collections"; +import { CollectionPosterCard } from "@/components/collections/CollectionPosterCard"; +import MediaCarousel from "@/components/MediaCarousel"; import { useSyncUserCollection } from "@/hooks/queries/userCollectionImports"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; import { ConfirmDialog } from "@/components/ConfirmDialog"; @@ -124,65 +127,156 @@ function CollectionList() {
- {collections.length === 0 ? ( -
- -
-

No collections yet

-

- Start from a curated TMDB, Trakt, or MDBList template — or build your own from - scratch. -

+
+

Your collections

+ {collections.length === 0 ? ( +
+ +
+

No collections yet

+

+ Start from a curated TMDB, Trakt, or MDBList template — or build your own from + scratch. +

+
+
+ + +
-
- - -
-
- ) : ( - { - const syncable = isImportedType(collection.collection_type); - const isSyncing = syncMutation.isPending && syncMutation.variables === collection.id; - return ( - syncMutation.mutate(collection.id)} - onEdit={() => navigate(buildUserCollectionEditorPath(collection.id))} - onDelete={() => setConfirmDeleteCollection(collection)} - /> - ); - }} - onReorderInGroup={(groupId, orderedIds) => - reorderMutation.mutate({ orderedIds, groupId }) - } - onMoveItemAcross={(itemId, toGroupId) => - updateMutation.mutate({ id: itemId, body: { group_id: toGroupId } }) - } - onReorderGroups={(orderedIds) => reorderGroupsMutation.mutate(orderedIds)} - onAddGroup={(title) => - createGroupMutation.mutate({ slug: slugifyGroupSlug(title), name: title }) - } - onRenameGroup={(id, title) => renameGroupMutation.mutate({ id, name: title })} - onDeleteGroup={(id) => deleteGroupMutation.mutate(id)} - /> - )} + ) : ( + { + const syncable = isImportedType(collection.collection_type); + const isSyncing = syncMutation.isPending && syncMutation.variables === collection.id; + return ( + syncMutation.mutate(collection.id)} + onEdit={() => navigate(buildUserCollectionEditorPath(collection.id))} + onDelete={() => setConfirmDeleteCollection(collection)} + /> + ); + }} + onReorderInGroup={(groupId, orderedIds) => + reorderMutation.mutate({ orderedIds, groupId }) + } + onMoveItemAcross={(itemId, toGroupId) => + updateMutation.mutate({ id: itemId, body: { group_id: toGroupId } }) + } + onReorderGroups={(orderedIds) => reorderGroupsMutation.mutate(orderedIds)} + onAddGroup={(title) => + createGroupMutation.mutate({ slug: slugifyGroupSlug(title), name: title }) + } + onRenameGroup={(id, title) => renameGroupMutation.mutate({ id, name: title })} + onDeleteGroup={(id) => deleteGroupMutation.mutate(id)} + /> + )} +
+ +
); } +// ServerCollectionsSection renders admin-curated collections aggregated across +// every accessible library, one horizontal teaser row per library. Each row's +// title (and the "Explore all" action when the library has more) links into +// that library's full Collections tab. +function ServerCollectionsSection() { + const { data, isLoading } = useServerCollections(); + const libraries = data ?? []; + + if (isLoading) { + // Mirror the loaded layout (per-library horizontal rows) so data arriving + // doesn't shift the page from a grid into rows. + return ( +
+
+

Server collections

+

+ Curated shelves from across every library on this server. +

+
+
+ {Array.from({ length: 2 }).map((_, row) => ( +
+ +
+ {Array.from({ length: 7 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ ))} +
+
+ ); + } + + if (libraries.length === 0) return null; + + return ( +
+
+

Server collections

+

+ Curated shelves from across every library on this server. +

+
+
+ {libraries.map((library) => ( + + ))} +
+
+ ); +} + +// One library's teaser row of server collections, rendered with the shared +// MediaCarousel so it matches every other content row in the app (hover scroll +// arrows, edge fades, drag/snap, keyboard nav). edgePadding is off because this +// section sits inside the Collections page's `page-shell`, which already +// supplies horizontal padding — the row title and cards align to that column. +function ServerLibraryRow({ library }: { library: ServerCollectionsLibrary }) { + const navigate = useNavigate(); + const collectionsHref = `/library/${library.library_id}?tab=collections`; + const hasMore = library.total_count > library.collections.length; + return ( + navigate(collectionsHref) : undefined} + edgePadding={false} + > + {library.collections.map((collection) => ( +
+ +
+ ))} +
+ ); +} + function SortableCollectionCard({ collection, syncable, diff --git a/web/src/pages/LibraryCollections.tsx b/web/src/pages/LibraryCollections.tsx index dded0c3b..a843a5f1 100644 --- a/web/src/pages/LibraryCollections.tsx +++ b/web/src/pages/LibraryCollections.tsx @@ -1,23 +1,16 @@ -import { useState } from "react"; import type { LibraryTabCollection, LibraryTabGroup, LibraryTabUngrouped } from "@/api/types"; import { useLibraryCollections } from "@/hooks/queries/libraryCollections"; -import { useToggleSidebarPin } from "@/hooks/queries/sidebarPins"; -import { useViewTransitionNavigate } from "@/hooks/useViewTransition"; -import { - buildLibraryCollectionCatalogHref, - buildUserCollectionCatalogHref, -} from "@/pages/catalogSearchParams"; import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; -import { Pin, PinOff, User } from "lucide-react"; +import { + COLLECTION_POSTER_GRID_CLASSES as GRID_CLASSES, + CollectionPosterCard, +} from "@/components/collections/CollectionPosterCard"; interface LibraryCollectionsProps { libraryId: number; } -const GRID_CLASSES = - "grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-3"; - export default function LibraryCollections({ libraryId }: LibraryCollectionsProps) { const { data, isLoading } = useLibraryCollections(libraryId); @@ -137,77 +130,3 @@ function GroupSection({ group, libraryId }: { group: LibraryTabGroup; libraryId: ); } - -function CollectionPosterCard({ - collection, - kind, - libraryId, -}: { - collection: LibraryTabCollection; - kind: "regular" | "user_collections"; - libraryId: number; -}) { - const [loaded, setLoaded] = useState(false); - const navigate = useViewTransitionNavigate(); - const { togglePin, isPinned } = useToggleSidebarPin(); - const pinned = isPinned(libraryId, "collection", collection.id); - - const isUserCollection = kind === "user_collections"; - const href = isUserCollection - ? buildUserCollectionCatalogHref(collection.id, collection.title) - : buildLibraryCollectionCatalogHref(collection.id, collection.title); - - return ( -
- - {/* Pin to sidebar button — only for library collections */} - {!isUserCollection && ( - - )} -
- ); -}