* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com>
15 KiB
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:
- 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).
- 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 + aGroupedCollectionsBoardof personal collections. Data viauseCollections()/useCollectionGroups()(web/src/hooks/queries/collections.ts), both backed byGET /collections→CollectionsListResponse { collections, groups }.web/src/pages/LibraryCollections.tsx— per-library tab. Renders a poster grid (CollectionPosterCard) grouped by admin groups + ungrouped. Data viauseLibraryCollections(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)andbuildUserCollectionCatalogHref(id, title)inweb/src/pages/catalogSearchParams.ts.
Backend
internal/api/handlers/collections.go— personal collection handlers under/collections(registered ininternal/api/router.go~lines 1700-1726, behindRequireProfile).internal/api/handlers/library_collections.go:HandleListLibraryCollections(line 813) — builds the per-libraryLibraryTabResponse.requestCanAccessLibrary(line 3311) — checksaccess.GetScope(ctx).AllowedLibraryIDs(nil ⇒ access to all libraries).LibraryCollectionRepository.ListAll(ctx, libraryID *int, opts)(internal/catalog/library_collection_repo.go:368) — withlibraryID == nilit already returns server collections across all libraries (useslibraryCollectionScopeFallbackJoin), filteringvisibility = 'visible'unlessIncludeHidden. 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):
{
"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 existingfeatured 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 unchangedGET /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'scollections[]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 /collectionsis the heavily-mutated query — invalidated on every create, edit, reorder, group change, and sync (viainvalidateUserCollectionQueries). 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/serverendpoint 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 overloadcollections[].
Handler logic (HandleListServerCollections)
- Resolve accessible library IDs: read
access.GetScope(ctx). IfAllowedLibraryIDs == nil, the user can see all libraries → list all of them; else restrict to that set. - 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). IfListAlldoes not already return membership, either (a) add a per-library loop callingrepo.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, testedListByLibrarypath 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.
- Simplest correct approach: call
- For each library, set
total_countto the full visible count, then capcollectionsto ~20 (keep the repo'sfeatured DESC, sort_order ASC, title ASCordering so featured/curated collections lead). Define the cap as a single named constant. - Resolve library display names (catalog/library store lookup) for
library_name. - Presign poster URLs via the existing
presignGPURLhelper — only for the capped slice, so we never presign 100 posters we won't send. - Skip empty libraries. Return
200with{ 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:
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()toweb/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:
-
"Your collections" section
- Add a large section title (e.g.
<h2 class="page-title ...">Your collections</h2>) above the existingGroupedCollectionsBoard/ empty-state block. Keep all current personal-collection behavior unchanged (create, group, reorder, sync, delete). - The page-level
<h1>Collections</h1>header + subtitle + action buttons stay at the very top.
- Add a large section title (e.g.
-
"Server collections" section (new)
- Title
<h2>Server collections</h2>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 asLibraryCollections.tsx— extractCollectionPosterCardinto a shared component so both pages use it (avoids duplicate logic perCLAUDE.mdmaintainability rule). Proposed location:web/src/components/collections/CollectionPosterCard.tsx. - For each
librarybucket: a sub-heading row withlibrary_nameand a "See all →" link routing to that library's existing Collections tab (the unchangedGET /library/{id}/collectionssurface). Show "See all" only whentotal_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 — passlibrary.library_idaslibraryId. - Use the project's existing horizontal-row/carousel pattern if one exists (check
web/src/componentsfor 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
librariesis 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.
- Title
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 forHandleListServerCollectionscovering (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
- Backend: response types +
HandleListServerCollections+ route registration. - Backend test for access-scope filtering.
- Frontend: types +
useServerCollectionshook +collectionKeys.server(). - Frontend: extract shared
CollectionPosterCard; refactorLibraryCollections.tsx. - Frontend: restructure
Collections.tsxinto the two titled sections. - Lint/format/build/manual verification.