Vellum/internal/media/home.go
mithral dc95a9f78a Redesign the user-facing experience: home, browse, item pages
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.
2026-10-09 21:38:11 -04:00

174 lines
4.6 KiB
Go

package media
import (
"database/sql"
"net/http"
"strings"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/httpx"
)
// shelfItem is one card on the home screen.
type shelfItem struct {
ID int64 `json:"id"`
LibraryID int64 `json:"library_id"`
Kind string `json:"kind"`
Title string `json:"title"`
Percent float64 `json:"percent"`
FileID int64 `json:"file_id"`
UpdatedAt string `json:"updated_at"`
}
// homeResponse is everything the landing screen needs in one request.
type homeResponse struct {
Continue []shelfItem `json:"continue"`
Recent []shelfItem `json:"recent"`
CountsByKind map[string]int `json:"counts_by_kind"`
LibraryCount int `json:"library_count"`
TotalItems int `json:"total_items"`
}
// handleHome powers the landing screen: what to continue, what is new, and how
// much is in each section.
//
// One request rather than three round trips, because the landing screen is the
// first thing a user sees and should not pop in piece by piece.
func (h *Handlers) handleHome(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, homeResponse{
Continue: []shelfItem{},
Recent: []shelfItem{},
CountsByKind: map[string]int{},
})
return
}
in, args := inClause(libs)
resp := homeResponse{CountsByKind: map[string]int{}, LibraryCount: len(libs)}
// Continue: started but unfinished, most recent first.
continueQ := `
SELECT i.id, i.library_id, i.kind, i.title, p.percent, p.file_id, p.updated_at
FROM progress p
JOIN media_items i ON i.id = p.item_id
WHERE p.user_id = ? AND p.finished = 0 AND i.missing = 0
AND i.library_id IN (` + in + `)
ORDER BY p.updated_at DESC
LIMIT 20`
rows, err := h.db.QueryContext(ctx, continueQ, append([]any{u.ID}, args...)...)
if err != nil {
h.fail(w, "home: continue", err)
return
}
resp.Continue, err = scanShelf(rows)
if err != nil {
h.fail(w, "home: scan continue", err)
return
}
// Recent: newest additions across every visible library.
recentQ := `
SELECT i.id, i.library_id, i.kind, i.title, 0, 0, i.added_at
FROM media_items i
WHERE i.missing = 0 AND i.library_id IN (` + in + `)
ORDER BY i.added_at DESC, i.id DESC
LIMIT 40`
rows, err = h.db.QueryContext(ctx, recentQ, args...)
if err != nil {
h.fail(w, "home: recent", err)
return
}
resp.Recent, err = scanShelf(rows)
if err != nil {
h.fail(w, "home: scan recent", err)
return
}
// Counts per kind, for the sidebar and section headers.
countQ := `
SELECT i.kind, count(*) FROM media_items i
WHERE i.missing = 0 AND i.library_id IN (` + in + `)
GROUP BY i.kind`
rows, err = h.db.QueryContext(ctx, countQ, args...)
if err != nil {
h.fail(w, "home: counts", err)
return
}
defer rows.Close()
for rows.Next() {
var kind string
var n int
if err := rows.Scan(&kind, &n); err != nil {
h.fail(w, "home: scan counts", err)
return
}
resp.CountsByKind[kind] = n
resp.TotalItems += n
}
if err := rows.Err(); err != nil {
h.fail(w, "home: iterate counts", err)
return
}
httpx.WriteJSON(w, http.StatusOK, resp)
}
// handleAuthors returns the people credited on an item.
func (h *Handlers) handleAuthors(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT p.name FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.item_id = ? ORDER BY ip.ord`, it.ID)
if err != nil {
h.fail(w, "authors", err)
return
}
defer rows.Close()
authors := []string{}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
h.fail(w, "scan author", err)
return
}
authors = append(authors, name)
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"authors": authors})
}
func scanShelf(rows *sql.Rows) ([]shelfItem, error) {
defer rows.Close()
out := []shelfItem{}
for rows.Next() {
var s shelfItem
if err := rows.Scan(&s.ID, &s.LibraryID, &s.Kind, &s.Title, &s.Percent, &s.FileID, &s.UpdatedAt); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// inClause builds "?,?,?" plus the matching args for an IN list.
func inClause(ids []int64) (string, []any) {
placeholders := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
return strings.Join(placeholders, ","), args
}