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:
committed by
GitHub
co-authored by
Quick
Claude Opus 4.8
parent
5afe56cfc0
commit
88aa769fe6
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user