The app looked like an admin panel. This is the product people actually see when they open their library. - Home: a landing screen with a Continue shelf (progress bars, straight from the progress table) and per-type shelves of recent additions. One request feeds it, so it does not pop in piece by piece. - Browse: cover grid per media type with type chips, plus browse-all. Powered by a new GET /items that lists across every visible library, filtered by kind, through the same access chokepoint. - Item page: cover, kind, title, authors, progress, a primary action that says Continue when you have started, description, and the file list for multi-file items. Cards now open the item page rather than jumping straight into a reader. - Search: results grouped by media type, cover-forward. - Navigation moved from "libraries" to "media types" — you browse Ebooks, not "the Ebooks folder". Libraries became an admin concern. - Design system: dark by default, cover-forward, one accent, tighter type scale, horizontal shelves, skeleton loaders. - media: GET /home, GET /items, and authors on item detail. Verified by screenshot in a live instance: home shelves, browse grid, and item page all render correctly.
89 lines
2.3 KiB
Go
89 lines
2.3 KiB
Go
package media
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/mithral/vellum/internal/auth"
|
|
"github.com/mithral/vellum/internal/httpx"
|
|
)
|
|
|
|
// itemRow is the browse-list shape.
|
|
type itemRow struct {
|
|
ID int64 `json:"id"`
|
|
LibraryID int64 `json:"library_id"`
|
|
Kind string `json:"kind"`
|
|
Path string `json:"path"`
|
|
Title string `json:"title"`
|
|
Year int `json:"year"`
|
|
}
|
|
|
|
// handleListItems lists items across every library the caller may read,
|
|
// optionally filtered by media kind or a single library.
|
|
//
|
|
// This is what powers "Browse > Ebooks" — one request, scoped by the same
|
|
// access-control chokepoint as everything else.
|
|
func (h *Handlers) handleListItems(w http.ResponseWriter, r *http.Request, u auth.User) {
|
|
ctx := r.Context()
|
|
|
|
libs, err := auth.VisibleLibraryIDs(ctx, h.db, u)
|
|
if err != nil {
|
|
h.fail(w, "visible libraries", err)
|
|
return
|
|
}
|
|
if len(libs) == 0 {
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": []itemRow{}, "total": 0})
|
|
return
|
|
}
|
|
|
|
in, args := inClause(libs)
|
|
query := `SELECT id, library_id, kind, path, title, COALESCE(year, 0)
|
|
FROM media_items
|
|
WHERE missing = 0 AND library_id IN (` + in + `)`
|
|
|
|
if kind := r.URL.Query().Get("kind"); kind != "" {
|
|
query += ` AND kind = ?`
|
|
args = append(args, kind)
|
|
}
|
|
if libStr := r.URL.Query().Get("library_id"); libStr != "" {
|
|
libID, err := strconv.ParseInt(libStr, 10, 64)
|
|
if err != nil || libID <= 0 {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid library_id")
|
|
return
|
|
}
|
|
query += ` AND library_id = ?`
|
|
args = append(args, libID)
|
|
}
|
|
|
|
limit := 300
|
|
if v := r.URL.Query().Get("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 {
|
|
limit = n
|
|
}
|
|
}
|
|
query += ` ORDER BY sort_title LIMIT ?`
|
|
args = append(args, limit)
|
|
|
|
rows, err := h.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
h.fail(w, "list items", err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []itemRow{}
|
|
for rows.Next() {
|
|
var it itemRow
|
|
if err := rows.Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, &it.Year); err != nil {
|
|
h.fail(w, "scan item", err)
|
|
return
|
|
}
|
|
items = append(items, it)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
h.fail(w, "iterate items", err)
|
|
return
|
|
}
|
|
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
|
|
}
|