Files
silo-server/internal/api/handlers/server_collections.go
88aa769fe6 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>
2026-06-15 10:51:12 -04:00

112 lines
4.0 KiB
Go

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)
}