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.
174 lines
4.6 KiB
Go
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
|
|
}
|