feat(collections): surface server collections on the user Collections tab (#156)

* 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>
This commit is contained in:
CoffeeKnyte
2026-06-15 10:51:12 -04:00
committed by GitHub
co-authored by Quick Claude Opus 4.8
parent 5afe56cfc0
commit 88aa769fe6
11 changed files with 727 additions and 143 deletions
@@ -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. `<h2 class="page-title ...">Your collections</h2>`)
above the existing `GroupedCollectionsBoard` / 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.
2. **"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 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.
+111
View File
@@ -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)
}
@@ -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)
}
})
}
+6
View File
@@ -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)
+14
View File
@@ -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[];
+17 -3
View File
@@ -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) => (
<div key={i} className="w-[130px] sm:w-[150px] lg:w-[178px]">
@@ -40,7 +54,7 @@ export default function MediaCarousel({
return (
<section className="section-row group/carousel relative isolate">
<div className="mb-5 flex items-end justify-between gap-4 px-4 sm:px-6 lg:px-10 xl:px-12">
<div className={`mb-5 flex items-end justify-between gap-4${headerPadX}`}>
<div className="flex items-center gap-2">
{titleHref ? (
<Link to={titleHref} className="group/title hover:text-primary transition-colors">
@@ -86,7 +100,7 @@ export default function MediaCarousel({
<div
ref={emblaRef}
className="embla__viewport overflow-hidden pr-4 sm:pr-6 lg:pr-10 xl:pr-12"
className={`embla__viewport overflow-hidden${viewportPadX}`}
tabIndex={0}
aria-label="Media carousel"
onKeyDown={(e) => {
@@ -99,7 +113,7 @@ export default function MediaCarousel({
>
<ul
role="list"
className="embla__container flex cursor-grab list-none gap-4 pl-4 sm:pl-6 lg:gap-5 lg:pl-10 xl:pl-12"
className={`embla__container flex cursor-grab list-none gap-4 lg:gap-5${containerPadX}`}
>
{slideChildren.map((child, index) => (
<li key={index} className="embla__slide shrink-0">
@@ -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 (
<div className="group/card relative w-full text-left">
<button type="button" onClick={() => navigate(href)} className="block w-full text-left">
<div className="media-card-image relative aspect-[2/3] overflow-hidden rounded-xl">
{collection.poster_url ? (
<img
src={collection.poster_url}
alt={collection.title}
className={`h-full w-full object-cover transition duration-300 group-hover/card:scale-[1.05] ${
loaded ? "opacity-100" : "opacity-0"
}`}
loading="lazy"
onLoad={() => setLoaded(true)}
/>
) : (
<div className="text-muted-foreground flex h-full w-full flex-col items-center justify-center gap-1 p-3 text-center text-sm">
{isUserCollection && <User className="h-5 w-5 opacity-60" />}
<span className="line-clamp-3 font-medium">{collection.title}</span>
</div>
)}
<span className="bg-background/60 text-foreground absolute right-2 bottom-2 rounded-md px-2 py-0.5 text-[11px] font-bold backdrop-blur-sm">
{collection.item_count}
</span>
</div>
<div className="px-0.5 pt-2.5">
<div className="truncate text-[13px] font-semibold">{collection.title}</div>
{isUserCollection && <div className="text-muted-foreground text-xs">User collection</div>}
</div>
</button>
{/* Pin to sidebar button — only for library collections */}
{!isUserCollection && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
togglePin(libraryId, {
type: "collection",
id: collection.id,
label: collection.title,
});
}}
className={`absolute top-2 left-2 rounded-lg p-1.5 backdrop-blur-sm transition-all ${
pinned
? "bg-primary/90 text-primary-foreground"
: "bg-background/50 text-foreground opacity-0 group-hover/card:opacity-100"
}`}
title={pinned ? "Unpin from sidebar" : "Pin to sidebar"}
>
{pinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
</button>
)}
</div>
);
}
+13
View File
@@ -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<ServerCollectionsResponse>("/collections/server").then((d) => d.libraries ?? []),
});
}
export function useCollectionItems(collectionId: string) {
return useQuery({
queryKey: collectionKeys.items(collectionId),
+1
View File
@@ -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,
+149 -55
View File
@@ -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() {
</div>
</div>
{collections.length === 0 ? (
<div className="surface-panel flex flex-col items-center justify-center gap-3 rounded-[2rem] py-16 text-center">
<Library className="text-muted-foreground/50 h-10 w-10" />
<div className="space-y-1">
<p className="text-sm font-medium">No collections yet</p>
<p className="text-muted-foreground max-w-sm text-xs">
Start from a curated TMDB, Trakt, or MDBList template — or build your own from
scratch.
</p>
<section className="space-y-4">
<h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">Your collections</h2>
{collections.length === 0 ? (
<div className="surface-panel flex flex-col items-center justify-center gap-3 rounded-[2rem] py-16 text-center">
<Library className="text-muted-foreground/50 h-10 w-10" />
<div className="space-y-1">
<p className="text-sm font-medium">No collections yet</p>
<p className="text-muted-foreground max-w-sm text-xs">
Start from a curated TMDB, Trakt, or MDBList template — or build your own from
scratch.
</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button variant="outline" size="sm" onClick={() => setGalleryOpen(true)}>
<Sparkles className="mr-1 h-4 w-4" /> Start from a template
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => navigate(buildUserCollectionEditorPath("new"))}
>
<Plus className="mr-1 h-4 w-4" /> Create from scratch
</Button>
</div>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button variant="outline" size="sm" onClick={() => setGalleryOpen(true)}>
<Sparkles className="mr-1 h-4 w-4" /> Start from a template
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => navigate(buildUserCollectionEditorPath("new"))}
>
<Plus className="mr-1 h-4 w-4" /> Create from scratch
</Button>
</div>
</div>
) : (
<GroupedCollectionsBoard
items={collections}
groups={groups}
renderItem={(collection) => {
const syncable = isImportedType(collection.collection_type);
const isSyncing = syncMutation.isPending && syncMutation.variables === collection.id;
return (
<SortableCollectionCard
collection={collection}
syncable={syncable}
isSyncing={isSyncing}
onSync={() => 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)}
/>
)}
) : (
<GroupedCollectionsBoard
items={collections}
groups={groups}
renderItem={(collection) => {
const syncable = isImportedType(collection.collection_type);
const isSyncing = syncMutation.isPending && syncMutation.variables === collection.id;
return (
<SortableCollectionCard
collection={collection}
syncable={syncable}
isSyncing={isSyncing}
onSync={() => 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)}
/>
)}
</section>
<ServerCollectionsSection />
</div>
);
}
// 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 (
<section className="space-y-6">
<div className="space-y-1">
<h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">Server collections</h2>
<p className="text-muted-foreground text-sm">
Curated shelves from across every library on this server.
</p>
</div>
<div className="space-y-8">
{Array.from({ length: 2 }).map((_, row) => (
<div key={row} className="space-y-5">
<Skeleton className="h-7 w-40" />
<div className="flex gap-4 overflow-hidden lg:gap-5">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="w-[130px] shrink-0 sm:w-[150px] lg:w-[178px]">
<Skeleton className="aspect-[2/3] rounded-xl" />
<Skeleton className="mt-2.5 h-4 w-3/4" />
</div>
))}
</div>
</div>
))}
</div>
</section>
);
}
if (libraries.length === 0) return null;
return (
<section className="space-y-6">
<div className="space-y-1">
<h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">Server collections</h2>
<p className="text-muted-foreground text-sm">
Curated shelves from across every library on this server.
</p>
</div>
<div className="space-y-8">
{libraries.map((library) => (
<ServerLibraryRow key={library.library_id} library={library} />
))}
</div>
</section>
);
}
// 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 (
<MediaCarousel
title={library.library_name}
titleHref={collectionsHref}
onViewAll={hasMore ? () => navigate(collectionsHref) : undefined}
edgePadding={false}
>
{library.collections.map((collection) => (
<div key={collection.id} className="w-[130px] sm:w-[150px] lg:w-[178px]">
<CollectionPosterCard
collection={collection}
kind="regular"
libraryId={library.library_id}
/>
</div>
))}
</MediaCarousel>
);
}
function SortableCollectionCard({
collection,
syncable,
+4 -85
View File
@@ -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:
</section>
);
}
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 (
<div className="group/card relative w-full text-left">
<button type="button" onClick={() => navigate(href)} className="block w-full text-left">
<div className="media-card-image relative aspect-[2/3] overflow-hidden rounded-xl">
{collection.poster_url ? (
<img
src={collection.poster_url}
alt={collection.title}
className={`h-full w-full object-cover transition-opacity duration-300 ${
loaded ? "opacity-100" : "opacity-0"
}`}
loading="lazy"
onLoad={() => setLoaded(true)}
/>
) : (
<div className="text-muted-foreground flex h-full w-full flex-col items-center justify-center gap-1 p-3 text-center text-sm">
{isUserCollection && <User className="h-5 w-5 opacity-60" />}
<span className="line-clamp-3 font-medium">{collection.title}</span>
</div>
)}
<span className="bg-background/60 text-foreground absolute right-2 bottom-2 rounded-md px-2 py-0.5 text-[11px] font-bold backdrop-blur-sm">
{collection.item_count}
</span>
</div>
<div className="px-0.5 pt-2.5">
<div className="truncate text-[13px] font-semibold">{collection.title}</div>
{isUserCollection && <div className="text-muted-foreground text-xs">User collection</div>}
</div>
</button>
{/* Pin to sidebar button — only for library collections */}
{!isUserCollection && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
togglePin(libraryId, {
type: "collection",
id: collection.id,
label: collection.title,
});
}}
className={`absolute top-2 left-2 rounded-lg p-1.5 backdrop-blur-sm transition-all ${
pinned
? "bg-primary/90 text-primary-foreground"
: "bg-background/50 text-foreground opacity-0 group-hover/card:opacity-100"
}`}
title={pinned ? "Unpin from sidebar" : "Pin to sidebar"}
>
{pinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
</button>
)}
</div>
);
}