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.
This commit is contained in:
parent
cebe1977e4
commit
dc95a9f78a
17 changed files with 1298 additions and 307 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -15,3 +15,6 @@
|
||||||
*.swp
|
*.swp
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# local preview instance
|
||||||
|
demo/
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,10 @@ func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator, cacheDir s
|
||||||
|
|
||||||
// Register mounts the routes.
|
// Register mounts the routes.
|
||||||
func (h *Handlers) Register(mux *http.ServeMux) {
|
func (h *Handlers) Register(mux *http.ServeMux) {
|
||||||
|
mux.Handle("GET /api/v1/home", h.auth.Require(h.handleHome))
|
||||||
|
mux.Handle("GET /api/v1/items", h.auth.Require(h.handleListItems))
|
||||||
mux.Handle("GET /api/v1/items/{id}", h.auth.Require(h.handleItem))
|
mux.Handle("GET /api/v1/items/{id}", h.auth.Require(h.handleItem))
|
||||||
|
mux.Handle("GET /api/v1/items/{id}/authors", h.auth.Require(h.handleAuthors))
|
||||||
mux.Handle("GET /api/v1/items/{id}/file", h.auth.Require(h.handleFile))
|
mux.Handle("GET /api/v1/items/{id}/file", h.auth.Require(h.handleFile))
|
||||||
mux.Handle("GET /api/v1/items/{id}/files/{fileID}", h.auth.Require(h.handleFileByID))
|
mux.Handle("GET /api/v1/items/{id}/files/{fileID}", h.auth.Require(h.handleFileByID))
|
||||||
mux.Handle("GET /api/v1/items/{id}/cover", h.auth.Require(h.handleCover))
|
mux.Handle("GET /api/v1/items/{id}/cover", h.auth.Require(h.handleCover))
|
||||||
|
|
@ -58,6 +61,12 @@ func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.Use
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authors, err := h.authors(r, it.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, "authors", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
out := map[string]any{
|
out := map[string]any{
|
||||||
"item": map[string]any{
|
"item": map[string]any{
|
||||||
"id": it.ID,
|
"id": it.ID,
|
||||||
|
|
@ -70,6 +79,7 @@ func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.Use
|
||||||
},
|
},
|
||||||
"files": files,
|
"files": files,
|
||||||
"has_cover": h.hasCover(r, it),
|
"has_cover": h.hasCover(r, it),
|
||||||
|
"authors": authors,
|
||||||
}
|
}
|
||||||
if has {
|
if has {
|
||||||
out["progress"] = prog
|
out["progress"] = prog
|
||||||
|
|
@ -77,6 +87,28 @@ func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.Use
|
||||||
httpx.WriteJSON(w, http.StatusOK, out)
|
httpx.WriteJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authors returns the credited people for an item.
|
||||||
|
func (h *Handlers) authors(r *http.Request, itemID int64) ([]string, error) {
|
||||||
|
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`, itemID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handlers) handleFile(w http.ResponseWriter, r *http.Request, u auth.User) {
|
func (h *Handlers) handleFile(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||||
it, ok := h.authorized(w, r, u)
|
it, ok := h.authorized(w, r, u)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
|
||||||
174
internal/media/home.go
Normal file
174
internal/media/home.go
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
89
internal/media/items.go
Normal file
89
internal/media/items.go
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
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)})
|
||||||
|
}
|
||||||
211
web/src/app.css
211
web/src/app.css
|
|
@ -1,27 +1,30 @@
|
||||||
/* Vellum design tokens.
|
/* Vellum — user-facing design tokens.
|
||||||
Restrained by intent: one accent, a tight spacing scale, hairline borders,
|
Media-first: dark by default, covers are the primary content, chrome recedes.
|
||||||
and no decorative gradient or shadow noise. Dark is the default surface. */
|
One accent, a tight type scale, and generous spacing so artwork breathes. */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #0b0d10;
|
--bg: #0a0a0c;
|
||||||
--bg-raised: #131619;
|
--surface: #131316;
|
||||||
--bg-hover: #191d21;
|
--surface-2: #1b1b20;
|
||||||
--bg-active: #1f2429;
|
--surface-3: #24242b;
|
||||||
--border: #23282e;
|
--border: #26262e;
|
||||||
--border-strong: #333a42;
|
--border-strong: #34343e;
|
||||||
|
|
||||||
--text: #e8eaed;
|
--text: #f3f3f5;
|
||||||
--text-muted: #9aa3ad;
|
--muted: #a2a2ae;
|
||||||
--text-faint: #6b747e;
|
--faint: #6c6c78;
|
||||||
|
|
||||||
--accent: #6d7cff;
|
--accent: #6d7cff;
|
||||||
--accent-hover: #8290ff;
|
--accent-hover: #808dff;
|
||||||
|
--accent-soft: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
--accent-text: #ffffff;
|
--accent-text: #ffffff;
|
||||||
--danger: #ff6b6b;
|
--danger: #ff6b6b;
|
||||||
--success: #4ade80;
|
--success: #46d17f;
|
||||||
|
|
||||||
--radius: 6px;
|
--radius-sm: 6px;
|
||||||
--radius-lg: 10px;
|
--radius: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--radius-cover: 8px;
|
||||||
|
|
||||||
--space-1: 4px;
|
--space-1: 4px;
|
||||||
--space-2: 8px;
|
--space-2: 8px;
|
||||||
|
|
@ -29,27 +32,33 @@
|
||||||
--space-4: 16px;
|
--space-4: 16px;
|
||||||
--space-5: 24px;
|
--space-5: 24px;
|
||||||
--space-6: 32px;
|
--space-6: 32px;
|
||||||
|
--space-7: 48px;
|
||||||
|
|
||||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
|
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
|
||||||
'Noto Sans', sans-serif;
|
'Noto Sans', sans-serif;
|
||||||
--mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
--mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
|
||||||
--nav-width: 220px;
|
--nav-width: 232px;
|
||||||
|
--shadow-cover: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
--shadow-lift: 0 12px 32px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: light) {
|
||||||
:root {
|
:root {
|
||||||
--bg: #ffffff;
|
--bg: #f7f7f8;
|
||||||
--bg-raised: #fafafa;
|
--surface: #ffffff;
|
||||||
--bg-hover: #f2f3f5;
|
--surface-2: #f1f1f3;
|
||||||
--bg-active: #e9ebee;
|
--surface-3: #e7e7ea;
|
||||||
--border: #e4e6ea;
|
--border: #e6e6ea;
|
||||||
--border-strong: #cfd3d9;
|
--border-strong: #d2d2d9;
|
||||||
--text: #14171a;
|
--text: #16161a;
|
||||||
--text-muted: #5c6672;
|
--muted: #5d5d68;
|
||||||
--text-faint: #8b949e;
|
--faint: #8b8b96;
|
||||||
--accent: #4f5bd5;
|
--accent: #5563e8;
|
||||||
--accent-hover: #3f4bc4;
|
--accent-hover: #4553d8;
|
||||||
|
--accent-text: #ffffff;
|
||||||
|
--shadow-cover: 0 1px 2px rgba(20, 20, 30, 0.08), 0 6px 18px rgba(20, 20, 30, 0.1);
|
||||||
|
--shadow-lift: 0 10px 28px rgba(20, 20, 30, 0.16);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,17 +95,16 @@ h2,
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.015em;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 20px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 16px;
|
font-size: 17px;
|
||||||
}
|
}
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
|
|
@ -111,21 +119,25 @@ select {
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
padding: 7px 12px;
|
padding: 8px 14px;
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--bg-raised);
|
background: var(--surface-2);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.12s ease, border-color 0.12s ease;
|
transition: background 0.12s ease, border-color 0.12s ease, transform 0.08s ease;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.btn:hover:not(:disabled) {
|
.btn:hover:not(:disabled) {
|
||||||
background: var(--bg-hover);
|
background: var(--surface-3);
|
||||||
|
}
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
|
|
@ -142,12 +154,17 @@ select {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.btn-ghost:hover:not(:disabled) {
|
.btn-ghost:hover:not(:disabled) {
|
||||||
background: var(--bg-hover);
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.btn-lg {
|
||||||
|
padding: 11px 22px;
|
||||||
|
font-size: 15px;
|
||||||
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 10px;
|
padding: 9px 12px;
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
|
@ -157,7 +174,7 @@ select {
|
||||||
.input:focus {
|
.input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
|
|
@ -168,7 +185,7 @@ select {
|
||||||
.field label {
|
.field label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Surfaces ───────────────────────────────────────────────────────── */
|
/* ── Surfaces ───────────────────────────────────────────────────────── */
|
||||||
|
|
@ -176,40 +193,99 @@ select {
|
||||||
.card {
|
.card {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: var(--bg-raised);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 1px 7px;
|
padding: 2px 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
padding: 5px 12px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.chip:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--faint);
|
||||||
|
}
|
||||||
|
.chip.active {
|
||||||
|
background: var(--text);
|
||||||
|
border-color: var(--text);
|
||||||
|
color: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert {
|
.alert {
|
||||||
padding: 10px 12px;
|
padding: 10px 14px;
|
||||||
border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--border));
|
border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--border));
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
||||||
color: var(--text);
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.ok {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--success) 40%, var(--border));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: color-mix(in srgb, var(--success) 9%, transparent);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--text-muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.faint {
|
.faint {
|
||||||
color: var(--text-faint);
|
color: var(--faint);
|
||||||
}
|
}
|
||||||
.mono {
|
.mono {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Cover card ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
border-radius: var(--radius-cover);
|
||||||
|
background: var(--surface-2);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-cover);
|
||||||
|
transition: transform 0.16s ease, box-shadow 0.16s ease;
|
||||||
|
}
|
||||||
|
.cover.wide {
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
.cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.cover .placeholder {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--faint);
|
||||||
|
background: linear-gradient(150deg, var(--surface-2), var(--surface-3));
|
||||||
|
}
|
||||||
|
.cover img + .placeholder {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Empty state ────────────────────────────────────────────────────── */
|
/* ── Empty state ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
|
|
@ -217,20 +293,20 @@ select {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-2);
|
||||||
padding: var(--space-6) var(--space-4);
|
padding: var(--space-7) var(--space-4);
|
||||||
border: 1px dashed var(--border-strong);
|
border: 1px dashed var(--border-strong);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.empty h3 {
|
.empty h3 {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 14px;
|
width: 16px;
|
||||||
height: 14px;
|
height: 16px;
|
||||||
border: 2px solid var(--border-strong);
|
border: 2px solid var(--border-strong);
|
||||||
border-top-color: var(--accent);
|
border-top-color: var(--accent);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|
@ -241,3 +317,28 @@ select {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, var(--surface-2) 25%, var(--surface-3) 37%, var(--surface-2) 63%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
animation: shimmer 1.4s ease infinite;
|
||||||
|
border-radius: var(--radius-cover);
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 100% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -100% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 3px solid var(--bg);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,11 +133,17 @@ export const api = {
|
||||||
items: (libraryId: number, limit = 200) =>
|
items: (libraryId: number, limit = 200) =>
|
||||||
request<{ items: Item[]; limit: number }>('GET', `/api/v1/libraries/${libraryId}/items?limit=${limit}`),
|
request<{ items: Item[]; limit: number }>('GET', `/api/v1/libraries/${libraryId}/items?limit=${limit}`),
|
||||||
|
|
||||||
item: (id: number) =>
|
item: (id: number) => request<ItemDetail>('GET', `/api/v1/items/${id}`),
|
||||||
request<{ item: Item; files: { id: number; path: string; size: number; container: string }[]; has_cover: boolean; progress?: Progress }>(
|
|
||||||
'GET',
|
home: () => request<Home>('GET', '/api/v1/home'),
|
||||||
`/api/v1/items/${id}`,
|
|
||||||
),
|
listItems: (opts: { kind?: string; libraryId?: number; limit?: number } = {}) => {
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
if (opts.kind) p.set('kind', opts.kind)
|
||||||
|
if (opts.libraryId) p.set('library_id', String(opts.libraryId))
|
||||||
|
if (opts.limit) p.set('limit', String(opts.limit))
|
||||||
|
return request<{ items: Item[]; total: number }>('GET', `/api/v1/items?${p.toString()}`)
|
||||||
|
},
|
||||||
|
|
||||||
ebook: (id: number) => request<{ metadata: EbookMetadata }>('GET', `/api/v1/items/${id}/ebook`),
|
ebook: (id: number) => request<{ metadata: EbookMetadata }>('GET', `/api/v1/items/${id}/ebook`),
|
||||||
|
|
||||||
|
|
@ -194,6 +200,32 @@ export interface SearchResult {
|
||||||
title: string
|
title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShelfItem {
|
||||||
|
id: number
|
||||||
|
library_id: number
|
||||||
|
kind: string
|
||||||
|
title: string
|
||||||
|
percent: number
|
||||||
|
file_id: number
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Home {
|
||||||
|
continue: ShelfItem[] | null
|
||||||
|
recent: ShelfItem[] | null
|
||||||
|
counts_by_kind: Record<string, number>
|
||||||
|
library_count: number
|
||||||
|
total_items: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemDetail {
|
||||||
|
item: Item
|
||||||
|
files: { id: number; path: string; size: number; container: string; missing: boolean }[]
|
||||||
|
has_cover: boolean
|
||||||
|
authors: string[] | null
|
||||||
|
progress?: Progress
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScanResult {
|
export interface ScanResult {
|
||||||
library_id: number
|
library_id: number
|
||||||
scanned: number
|
scanned: number
|
||||||
|
|
|
||||||
87
web/src/lib/components/Browse.svelte
Normal file
87
web/src/lib/components/Browse.svelte
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { api, ApiError, type Item } from '../api'
|
||||||
|
import { mediaTypes } from '../session'
|
||||||
|
import { go } from '../nav'
|
||||||
|
import Card from './Card.svelte'
|
||||||
|
|
||||||
|
let {
|
||||||
|
kind = undefined,
|
||||||
|
libraryId = undefined,
|
||||||
|
}: { kind?: string; libraryId?: number } = $props()
|
||||||
|
|
||||||
|
let items = $state<Item[]>([])
|
||||||
|
let loading = $state(true)
|
||||||
|
let error = $state<string | null>(null)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void load(kind, libraryId)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load(k: string | undefined, lib: number | undefined) {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
try {
|
||||||
|
const res = await api.listItems({ kind: k, libraryId: lib })
|
||||||
|
items = res.items
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof ApiError ? err.message : 'could not load items'
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = $derived(($mediaTypes as { Kind: string; Name: string }[]))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !kind && !libraryId}
|
||||||
|
<div class="chips">
|
||||||
|
<button class="chip" class:active={!kind} onclick={() => go({ name: 'browse' })}>All</button>
|
||||||
|
{#each types as t (t.Kind)}
|
||||||
|
<button class="chip" class:active={kind === t.Kind} onclick={() => go({ name: 'browse', kind: t.Kind })}>
|
||||||
|
{t.Name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="grid">
|
||||||
|
{#each Array(12) as _}<div class="skeleton tile"></div>{/each}
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="alert">{error}</div>
|
||||||
|
{:else if items.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<h3>Nothing here</h3>
|
||||||
|
<p>No items match this section yet.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid">
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<Card {item} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
||||||
|
gap: var(--space-5) var(--space-4);
|
||||||
|
}
|
||||||
|
.skeleton.tile {
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||||
|
gap: var(--space-4) var(--space-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
107
web/src/lib/components/Card.svelte
Normal file
107
web/src/lib/components/Card.svelte
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { api } from '../api'
|
||||||
|
import { go } from '../nav'
|
||||||
|
|
||||||
|
let {
|
||||||
|
item,
|
||||||
|
percent = 0,
|
||||||
|
wide = false,
|
||||||
|
size = 'md',
|
||||||
|
showKind = false,
|
||||||
|
}: {
|
||||||
|
item: { id: number; title: string; kind?: string }
|
||||||
|
percent?: number
|
||||||
|
wide?: boolean
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
showKind?: boolean
|
||||||
|
} = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button class="tile {size}" onclick={() => go({ name: 'item', id: item.id })}>
|
||||||
|
<div class="cover" class:wide>
|
||||||
|
<img
|
||||||
|
src={api.coverUrl(item.id)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
onerror={(e) => (e.currentTarget as HTMLImageElement).remove()}
|
||||||
|
/>
|
||||||
|
<span class="placeholder">{(item.title || '?').slice(0, 1).toUpperCase()}</span>
|
||||||
|
{#if percent > 0}
|
||||||
|
<span class="progress"><i style="width:{Math.min(Math.max(percent, 0), 1) * 100}%"></i></span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="meta">
|
||||||
|
<span class="title">{item.title}</span>
|
||||||
|
{#if showKind && item.kind}<span class="kind">{item.kind}</span>{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.tile:hover .cover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: var(--shadow-lift);
|
||||||
|
}
|
||||||
|
.tile:hover .title {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.cover {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.kind {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.progress {
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
right: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
/* Sizes only affect the type, not the cover: covers always fill their slot. */
|
||||||
|
.sm .title {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.lg .title {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
120
web/src/lib/components/Home.svelte
Normal file
120
web/src/lib/components/Home.svelte
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { api, ApiError, type Home, type MediaType } from '../api'
|
||||||
|
import { mediaTypes, user, counts } from '../session'
|
||||||
|
import { go } from '../nav'
|
||||||
|
import Shelf from './Shelf.svelte'
|
||||||
|
|
||||||
|
let home = $state<Home | null>(null)
|
||||||
|
let loading = $state(true)
|
||||||
|
let error = $state<string | null>(null)
|
||||||
|
|
||||||
|
onMount(load)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
try {
|
||||||
|
home = await api.home()
|
||||||
|
counts.set(home.counts_by_kind ?? {})
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof ApiError ? err.message : 'could not load your library'
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const continueItems = $derived(home?.continue ?? [])
|
||||||
|
const recent = $derived(home?.recent ?? [])
|
||||||
|
|
||||||
|
function percentFor(id: number): number {
|
||||||
|
return continueItems.find((c) => c.id === id)?.percent ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group recent additions by media type so the home screen reads as a set of
|
||||||
|
// shelves rather than one undifferentiated wall.
|
||||||
|
function recentOfKind(kind: string) {
|
||||||
|
return recent.filter((r) => r.kind === kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFor(kind: string): string {
|
||||||
|
return $mediaTypes.find((t: MediaType) => t.Kind === kind)?.Name ?? kind
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindsWithContent = $derived(
|
||||||
|
($mediaTypes as MediaType[])
|
||||||
|
.map((t) => t.Kind)
|
||||||
|
.filter((k) => (home?.counts_by_kind[k] ?? 0) > 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
const greeting = $derived($user?.display_name || $user?.username || '')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">
|
||||||
|
<div class="skeleton big"></div>
|
||||||
|
<div class="skeleton-row">
|
||||||
|
{#each Array(6) as _}<div class="skeleton tile"></div>{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="alert">{error}</div>
|
||||||
|
{:else if (home?.total_items ?? 0) === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<h3>Nothing in your library yet</h3>
|
||||||
|
<p>
|
||||||
|
{#if $user?.is_admin}
|
||||||
|
Add a library and run a scan to see your media here.
|
||||||
|
{:else}
|
||||||
|
You do not have access to any media yet. Ask an administrator to grant a library.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{#if $user?.is_admin}
|
||||||
|
<button class="btn btn-primary" onclick={() => go({ name: 'libraries' })}>Add a library</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="hello muted">Welcome back, {greeting}.</p>
|
||||||
|
|
||||||
|
{#if continueItems.length > 0}
|
||||||
|
<Shelf title="Continue" items={continueItems} {percentFor} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each kindsWithContent as kind (kind)}
|
||||||
|
{@const items = recentOfKind(kind)}
|
||||||
|
{#if items.length > 0}
|
||||||
|
<Shelf
|
||||||
|
title={labelFor(kind)}
|
||||||
|
{items}
|
||||||
|
onSeeAll={() => go({ name: 'browse', kind })}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hello {
|
||||||
|
margin: 0 0 var(--space-5);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
.skeleton.big {
|
||||||
|
height: 22px;
|
||||||
|
width: 220px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.skeleton-row {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: 150px;
|
||||||
|
gap: var(--space-4);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.skeleton.tile {
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
236
web/src/lib/components/ItemDetail.svelte
Normal file
236
web/src/lib/components/ItemDetail.svelte
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { api, ApiError, type ItemDetail, type EbookMetadata } from '../api'
|
||||||
|
import { mediaTypes } from '../session'
|
||||||
|
import { go } from '../nav'
|
||||||
|
|
||||||
|
let { id }: { id: number } = $props()
|
||||||
|
|
||||||
|
let detail = $state<ItemDetail | null>(null)
|
||||||
|
let description = $state('')
|
||||||
|
let loading = $state(true)
|
||||||
|
let error = $state<string | null>(null)
|
||||||
|
|
||||||
|
onMount(load)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
try {
|
||||||
|
detail = await api.item(id)
|
||||||
|
if (detail.item.kind === 'ebooks') {
|
||||||
|
try {
|
||||||
|
const md: EbookMetadata = (await api.ebook(id)).metadata
|
||||||
|
description = md.description || ''
|
||||||
|
} catch {
|
||||||
|
// A parse failure must not break the page.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof ApiError ? err.message : 'could not load this item'
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindLabel = $derived(
|
||||||
|
($mediaTypes as { Kind: string; Name: string }[]).find((t) => t.Kind === detail?.item.kind)?.Name ??
|
||||||
|
detail?.item.kind ??
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
|
||||||
|
const actionLabel = $derived.by(() => {
|
||||||
|
switch (detail?.item.kind) {
|
||||||
|
case 'audiobooks':
|
||||||
|
case 'music':
|
||||||
|
return 'Play'
|
||||||
|
case 'photos':
|
||||||
|
return 'View'
|
||||||
|
default:
|
||||||
|
return 'Read'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const percent = $derived(detail?.progress ? Math.round(detail.progress.percent * 100) : 0)
|
||||||
|
const started = $derived(percent > 0 && percent < 100)
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (!detail) return
|
||||||
|
go({ name: 'reader', itemId: detail.item.id, title: detail.item.title })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="detail">
|
||||||
|
<div class="skeleton poster"></div>
|
||||||
|
<div class="skeleton lines"></div>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="alert">{error}</div>
|
||||||
|
{:else if detail}
|
||||||
|
<div class="detail">
|
||||||
|
<div class="poster">
|
||||||
|
<div class="cover">
|
||||||
|
<img
|
||||||
|
src={api.coverUrl(detail.item.id)}
|
||||||
|
alt=""
|
||||||
|
onerror={(e) => (e.currentTarget as HTMLImageElement).remove()}
|
||||||
|
/>
|
||||||
|
<span class="placeholder">{(detail.item.title || '?').slice(0, 1).toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<span class="kind">{kindLabel}{#if detail.item.year} · {detail.item.year}{/if}</span>
|
||||||
|
<h1>{detail.item.title}</h1>
|
||||||
|
{#if detail.authors && detail.authors.length > 0}
|
||||||
|
<p class="byline">{detail.authors.join(', ')}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if started}
|
||||||
|
<div class="resume">
|
||||||
|
<div class="track"><i style="width:{percent}%"></i></div>
|
||||||
|
<span class="muted">{percent}% complete</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary btn-lg" onclick={open}>
|
||||||
|
{started ? 'Continue' : actionLabel}
|
||||||
|
</button>
|
||||||
|
<a class="btn btn-lg" href={api.fileUrl(detail.item.id)} download>Download</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if description}
|
||||||
|
<p class="description">{description}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if detail.files.length > 1}
|
||||||
|
<div class="tracks">
|
||||||
|
<h3>{detail.files.length} files</h3>
|
||||||
|
<ol>
|
||||||
|
{#each detail.files as f (f.id)}
|
||||||
|
<li>
|
||||||
|
<span class="name">{f.path.split('/').pop()}</span>
|
||||||
|
<span class="faint mono">{Math.round(f.size / 1024 / 1024)} MB</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.detail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px 1fr;
|
||||||
|
gap: var(--space-6);
|
||||||
|
align-items: start;
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
.poster .cover {
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
}
|
||||||
|
.kind {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
.byline {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.resume {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
.track {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.track i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.resume .muted {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
margin: var(--space-3) 0 0;
|
||||||
|
max-width: 62ch;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.tracks {
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
.tracks h3 {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.tracks ol {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.tracks li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.tracks li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.skeleton.poster {
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
border-radius: var(--radius-cover);
|
||||||
|
}
|
||||||
|
.skeleton.lines {
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.detail {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.poster {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -127,7 +127,7 @@
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each shown as lib (lib.id)}
|
{#each shown as lib (lib.id)}
|
||||||
<div class="card lib">
|
<div class="card lib">
|
||||||
<button class="open" onclick={() => go({ name: 'library', id: lib.id })}>
|
<button class="open" onclick={() => go({ name: 'browse', libraryId: lib.id })}>
|
||||||
<span class="kind">{lib.kind}</span>
|
<span class="kind">{lib.kind}</span>
|
||||||
<strong>{lib.name}</strong>
|
<strong>{lib.name}</strong>
|
||||||
<span class="path mono">{lib.root_path}</span>
|
<span class="path mono">{lib.root_path}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { api, ApiError, type Item } from '../api'
|
|
||||||
import { go } from '../nav'
|
|
||||||
|
|
||||||
let { libraryId }: { libraryId: number } = $props()
|
|
||||||
|
|
||||||
let items = $state<Item[]>([])
|
|
||||||
let loading = $state(true)
|
|
||||||
let error = $state<string | null>(null)
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void load(libraryId)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function load(id: number) {
|
|
||||||
loading = true
|
|
||||||
error = null
|
|
||||||
try {
|
|
||||||
const res = await api.items(id)
|
|
||||||
items = res.items
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof ApiError ? err.message : 'could not load items'
|
|
||||||
} finally {
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="center"><div class="spinner"></div></div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="alert">{error}</div>
|
|
||||||
{:else if items.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<h3>Nothing here yet</h3>
|
|
||||||
<p>Run a scan on this library to index the files on disk.</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="grid">
|
|
||||||
{#each items as item (item.id)}
|
|
||||||
<button class="tile" onclick={() => go({ name: 'reader', itemId: item.id, title: item.title })}>
|
|
||||||
<div class="cover">
|
|
||||||
<img src={api.coverUrl(item.id)} alt="" loading="lazy" onerror={(e) => (e.currentTarget as HTMLImageElement).remove()} />
|
|
||||||
<span class="fallback">{item.title.slice(0, 1).toUpperCase()}</span>
|
|
||||||
</div>
|
|
||||||
<span class="title" title={item.title}>{item.title}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.center {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: var(--space-6);
|
|
||||||
}
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
.tile {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.cover {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
overflow: hidden;
|
|
||||||
transition: border-color 0.12s ease, transform 0.12s ease;
|
|
||||||
}
|
|
||||||
.tile:hover .cover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
.cover img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.fallback {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-size: 40px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
.cover img + .fallback {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.3;
|
|
||||||
overflow: hidden;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api, ApiError, type SearchResult } from '../api'
|
import { api, ApiError, type SearchResult } from '../api'
|
||||||
import { go } from '../nav'
|
import { mediaTypes } from '../session'
|
||||||
|
import Card from './Card.svelte'
|
||||||
|
|
||||||
let { query }: { query: string } = $props()
|
let { query }: { query: string } = $props()
|
||||||
|
|
||||||
|
|
@ -23,87 +24,64 @@
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function labelFor(kind: string): string {
|
||||||
|
return ($mediaTypes as { Kind: string; Name: string }[]).find((t) => t.Kind === kind)?.Name ?? kind
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = $derived.by(() => {
|
||||||
|
const map = new Map<string, SearchResult[]>()
|
||||||
|
for (const r of results) {
|
||||||
|
const list = map.get(r.kind) ?? []
|
||||||
|
list.push(r)
|
||||||
|
map.set(r.kind, list)
|
||||||
|
}
|
||||||
|
return [...map.entries()]
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="center"><div class="spinner"></div></div>
|
<div class="grid">
|
||||||
|
{#each Array(8) as _}<div class="skeleton tile"></div>{/each}
|
||||||
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="alert">{error}</div>
|
<div class="alert">{error}</div>
|
||||||
{:else if results.length === 0}
|
{:else if results.length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<h3>No matches</h3>
|
<h3>No matches for “{query}”</h3>
|
||||||
<p>Nothing in your libraries matches “{query}”.</p>
|
<p>Try a shorter word, or part of a title.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="count muted">{results.length} result{results.length === 1 ? '' : 's'}</p>
|
<p class="count muted">{results.length} result{results.length === 1 ? '' : 's'}</p>
|
||||||
<div class="card list">
|
{#each groups as [kind, items] (kind)}
|
||||||
{#each results as r (r.id)}
|
<section>
|
||||||
<button class="row" onclick={() => go({ name: 'reader', itemId: r.id, title: r.title })}>
|
<h2>{labelFor(kind)}</h2>
|
||||||
<img class="thumb" src={api.coverUrl(r.id)} alt="" loading="lazy" onerror={(e) => (e.currentTarget as HTMLImageElement).remove()} />
|
<div class="grid">
|
||||||
<span class="meta">
|
{#each items as item (item.id)}
|
||||||
<strong>{r.title}</strong>
|
<Card {item} />
|
||||||
<span class="muted mono">{r.path}</span>
|
|
||||||
</span>
|
|
||||||
<span class="badge">{r.kind}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.center {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: var(--space-6);
|
|
||||||
}
|
|
||||||
.count {
|
.count {
|
||||||
margin: 0 0 var(--space-3);
|
margin: 0 0 var(--space-5);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.list {
|
section {
|
||||||
overflow: hidden;
|
margin-bottom: var(--space-6);
|
||||||
}
|
}
|
||||||
.row {
|
h2 {
|
||||||
display: flex;
|
margin-bottom: var(--space-3);
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
.row:last-child {
|
.grid {
|
||||||
border-bottom: none;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
||||||
|
gap: var(--space-5) var(--space-4);
|
||||||
}
|
}
|
||||||
.row:hover {
|
.skeleton.tile {
|
||||||
background: var(--bg-hover);
|
aspect-ratio: 2 / 3;
|
||||||
}
|
|
||||||
.thumb {
|
|
||||||
width: 28px;
|
|
||||||
height: 42px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 3px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--bg);
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
.meta {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.meta strong {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.meta .muted {
|
|
||||||
font-size: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
76
web/src/lib/components/Shelf.svelte
Normal file
76
web/src/lib/components/Shelf.svelte
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Card from './Card.svelte'
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
percentFor = () => 0,
|
||||||
|
onSeeAll = null,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
items: { id: number; title: string; kind?: string }[]
|
||||||
|
percentFor?: (id: number) => number
|
||||||
|
onSeeAll?: (() => void) | null
|
||||||
|
} = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if items.length > 0}
|
||||||
|
<section class="shelf">
|
||||||
|
<header>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{#if onSeeAll}
|
||||||
|
<button class="see-all" onclick={onSeeAll}>See all →</button>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
<div class="row">
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<div class="slot">
|
||||||
|
<Card {item} percent={percentFor(item.id)} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.shelf {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.see-all {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.see-all:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: 150px;
|
||||||
|
gap: var(--space-4);
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: var(--space-2);
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
|
}
|
||||||
|
.slot {
|
||||||
|
scroll-snap-align: start;
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.row {
|
||||||
|
grid-auto-columns: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,76 +1,79 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { user, libraries, mediaTypes, logout } from '../session'
|
import { user, logout, mediaTypes, counts } from '../session'
|
||||||
import { view, go, type View } from '../nav'
|
import { view, go, type View } from '../nav'
|
||||||
import Libraries from './Libraries.svelte'
|
import Home from './Home.svelte'
|
||||||
import LibraryView from './LibraryView.svelte'
|
import Browse from './Browse.svelte'
|
||||||
|
import ItemDetail from './ItemDetail.svelte'
|
||||||
import Reader from './Reader.svelte'
|
import Reader from './Reader.svelte'
|
||||||
import Users from './Users.svelte'
|
|
||||||
import SearchResults from './SearchResults.svelte'
|
import SearchResults from './SearchResults.svelte'
|
||||||
|
import Libraries from './Libraries.svelte'
|
||||||
|
import Users from './Users.svelte'
|
||||||
|
|
||||||
let kindFilter = $state<string | null>(null)
|
|
||||||
let searchBox = $state('')
|
let searchBox = $state('')
|
||||||
|
|
||||||
|
const types = $derived(
|
||||||
|
($mediaTypes as { Kind: string; Name: string }[]).filter((t) => ($counts[t.Kind] ?? 0) > 0),
|
||||||
|
)
|
||||||
|
|
||||||
function submitSearch(event: SubmitEvent) {
|
function submitSearch(event: SubmitEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const q = searchBox.trim()
|
const q = searchBox.trim()
|
||||||
if (q) go({ name: 'search', query: q })
|
if (q) go({ name: 'search', query: q })
|
||||||
}
|
}
|
||||||
|
|
||||||
const kinds = $derived(
|
|
||||||
$mediaTypes.filter((t) => $libraries.some((l) => l.kind === t.Kind)),
|
|
||||||
)
|
|
||||||
|
|
||||||
function countsFor(kind: string) {
|
|
||||||
return $libraries.filter((l) => l.kind === kind).length
|
|
||||||
}
|
|
||||||
|
|
||||||
function title(v: View): string {
|
function title(v: View): string {
|
||||||
switch (v.name) {
|
switch (v.name) {
|
||||||
case 'libraries':
|
case 'home':
|
||||||
return kindFilter ? ($mediaTypes.find((t) => t.Kind === kindFilter)?.Name ?? 'Library') : 'All libraries'
|
return 'Home'
|
||||||
case 'library':
|
case 'browse':
|
||||||
return $libraries.find((l) => l.id === v.id)?.name ?? 'Library'
|
if (v.kind) return types.find((t) => t.Kind === v.kind)?.Name ?? 'Browse'
|
||||||
|
return 'Browse'
|
||||||
|
case 'item':
|
||||||
|
return ''
|
||||||
case 'reader':
|
case 'reader':
|
||||||
return v.title
|
return v.title
|
||||||
case 'search':
|
case 'search':
|
||||||
return 'Search'
|
return 'Search'
|
||||||
|
case 'libraries':
|
||||||
|
return 'Libraries'
|
||||||
case 'users':
|
case 'users':
|
||||||
return 'Users'
|
return 'Users'
|
||||||
case 'settings':
|
|
||||||
return 'Settings'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectKind(kind: string | null) {
|
|
||||||
kindFilter = kind
|
|
||||||
go({ name: 'libraries' })
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<aside class="nav">
|
<aside class="nav">
|
||||||
<div class="brand">
|
<button class="brand" onclick={() => go({ name: 'home' })}>
|
||||||
<span class="mark">V</span>
|
<span class="mark">V</span>
|
||||||
<strong>Vellum</strong>
|
<strong>Vellum</strong>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<nav class="links">
|
<nav class="links">
|
||||||
<button class="navlink" class:active={$view.name === 'libraries' && kindFilter === null} onclick={() => selectKind(null)}>
|
<button class="navlink" class:active={$view.name === 'home'} onclick={() => go({ name: 'home' })}>
|
||||||
<span>All libraries</span>
|
<span>Home</span>
|
||||||
<span class="count">{$libraries.length}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
{#each types as t (t.Kind)}
|
||||||
{#each kinds as kind (kind.Kind)}
|
<button
|
||||||
<button class="navlink" class:active={kindFilter === kind.Kind} onclick={() => selectKind(kind.Kind)}>
|
class="navlink"
|
||||||
<span>{kind.Name}</span>
|
class:active={$view.name === 'browse' && $view.kind === t.Kind}
|
||||||
<span class="count">{countsFor(kind.Kind)}</span>
|
onclick={() => go({ name: 'browse', kind: t.Kind })}
|
||||||
|
>
|
||||||
|
<span>{t.Name}</span>
|
||||||
|
<span class="count">{$counts[t.Kind]}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
<button class="navlink" class:active={$view.name === 'browse' && !$view.kind} onclick={() => go({ name: 'browse' })}>
|
||||||
|
<span>Browse all</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{#if $user?.is_admin}
|
{#if $user?.is_admin}
|
||||||
<div class="section">Admin</div>
|
<div class="section">Manage</div>
|
||||||
<nav class="links">
|
<nav class="links">
|
||||||
|
<button class="navlink" class:active={$view.name === 'libraries'} onclick={() => go({ name: 'libraries' })}>
|
||||||
|
<span>Libraries</span>
|
||||||
|
</button>
|
||||||
<button class="navlink" class:active={$view.name === 'users'} onclick={() => go({ name: 'users' })}>
|
<button class="navlink" class:active={$view.name === 'users'} onclick={() => go({ name: 'users' })}>
|
||||||
<span>Users</span>
|
<span>Users</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -79,8 +82,11 @@
|
||||||
|
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
<div class="who">
|
<div class="who">
|
||||||
<strong>{$user?.display_name}</strong>
|
<span class="avatar">{($user?.display_name || $user?.username || '?').slice(0, 1).toUpperCase()}</span>
|
||||||
{#if $user?.is_admin}<span class="badge">admin</span>{/if}
|
<span class="name">
|
||||||
|
<strong>{$user?.display_name || $user?.username}</strong>
|
||||||
|
<span class="faint">@{$user?.username}{#if $user?.is_admin} · admin{/if}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-ghost" onclick={logout}>Sign out</button>
|
<button class="btn btn-ghost" onclick={logout}>Sign out</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -88,12 +94,16 @@
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
{#if title($view)}
|
||||||
<h1>{title($view)}</h1>
|
<h1>{title($view)}</h1>
|
||||||
|
{:else}
|
||||||
|
<button class="back" onclick={() => history.back()} aria-label="Back">←</button>
|
||||||
|
{/if}
|
||||||
<form class="search" onsubmit={submitSearch}>
|
<form class="search" onsubmit={submitSearch}>
|
||||||
<input
|
<input
|
||||||
class="input"
|
class="input"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search titles and paths"
|
placeholder="Search your library"
|
||||||
bind:value={searchBox}
|
bind:value={searchBox}
|
||||||
aria-label="Search"
|
aria-label="Search"
|
||||||
/>
|
/>
|
||||||
|
|
@ -101,14 +111,18 @@
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{#if $view.name === 'libraries'}
|
{#if $view.name === 'home'}
|
||||||
<Libraries {kindFilter} />
|
<Home />
|
||||||
{:else if $view.name === 'library'}
|
{:else if $view.name === 'browse'}
|
||||||
<LibraryView libraryId={$view.id} />
|
<Browse kind={$view.kind} libraryId={$view.libraryId} />
|
||||||
|
{:else if $view.name === 'item'}
|
||||||
|
<ItemDetail id={$view.id} />
|
||||||
{:else if $view.name === 'reader'}
|
{:else if $view.name === 'reader'}
|
||||||
<Reader itemId={$view.itemId} />
|
<Reader itemId={$view.itemId} />
|
||||||
{:else if $view.name === 'search'}
|
{:else if $view.name === 'search'}
|
||||||
<SearchResults query={$view.query} />
|
<SearchResults query={$view.query} />
|
||||||
|
{:else if $view.name === 'libraries'}
|
||||||
|
<Libraries />
|
||||||
{:else if $view.name === 'users'}
|
{:else if $view.name === 'users'}
|
||||||
<Users />
|
<Users />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -126,66 +140,72 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4) var(--space-3);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
background: var(--bg-raised);
|
background: var(--surface);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.brand {
|
.brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
padding: 0 var(--space-1);
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
.mark {
|
.mark {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 24px;
|
width: 26px;
|
||||||
height: 24px;
|
height: 26px;
|
||||||
border-radius: 6px;
|
border-radius: 7px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--accent-text);
|
color: var(--accent-text);
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.links {
|
.links {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 1px;
|
||||||
}
|
}
|
||||||
.navlink {
|
.navlink {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px 10px;
|
padding: 7px 10px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-muted);
|
color: var(--muted);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
font-size: 13.5px;
|
||||||
}
|
}
|
||||||
.navlink:hover {
|
.navlink:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--surface-2);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.navlink.active {
|
.navlink.active {
|
||||||
background: var(--bg-active);
|
background: var(--surface-3);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.count {
|
.count {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-faint);
|
color: var(--faint);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.section {
|
.section {
|
||||||
margin: 0 var(--space-1) -8px;
|
margin: 0 var(--space-2) -8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.07em;
|
||||||
color: var(--text-faint);
|
color: var(--faint);
|
||||||
}
|
}
|
||||||
.foot {
|
.foot {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
|
|
@ -199,7 +219,33 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
padding: 0 var(--space-1);
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface-3);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.who .name {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.25;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.who strong {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.who .faint {
|
||||||
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.main {
|
.main {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -212,17 +258,39 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
height: 52px;
|
height: 60px;
|
||||||
padding: 0 var(--space-5);
|
padding: 0 var(--space-6);
|
||||||
border-bottom: 1px solid var(--border);
|
flex: none;
|
||||||
}
|
}
|
||||||
.search {
|
.search {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 320px;
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
.back {
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.back:hover {
|
||||||
|
background: var(--surface-3);
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: var(--space-5);
|
padding: var(--space-4) var(--space-6) var(--space-7);
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,15 @@ import { writable } from 'svelte/store'
|
||||||
|
|
||||||
// The app is small enough that a typed discriminated union beats a router.
|
// The app is small enough that a typed discriminated union beats a router.
|
||||||
export type View =
|
export type View =
|
||||||
| { name: 'libraries' }
|
| { name: 'home' }
|
||||||
| { name: 'library'; id: number }
|
| { name: 'browse'; kind?: string; libraryId?: number }
|
||||||
|
| { name: 'item'; id: number }
|
||||||
| { name: 'reader'; itemId: number; title: string }
|
| { name: 'reader'; itemId: number; title: string }
|
||||||
| { name: 'search'; query: string }
|
| { name: 'search'; query: string }
|
||||||
|
| { name: 'libraries' }
|
||||||
| { name: 'users' }
|
| { name: 'users' }
|
||||||
| { name: 'settings' }
|
|
||||||
|
|
||||||
export const view = writable<View>({ name: 'libraries' })
|
export const view = writable<View>({ name: 'home' })
|
||||||
|
|
||||||
export function go(next: View) {
|
export function go(next: View) {
|
||||||
view.set(next)
|
view.set(next)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export const libraries = writable<Library[]>([])
|
||||||
export const mediaTypes = writable<MediaType[]>([])
|
export const mediaTypes = writable<MediaType[]>([])
|
||||||
export const loadingLibraries = writable(false)
|
export const loadingLibraries = writable(false)
|
||||||
export const bootError = writable<string | null>(null)
|
export const bootError = writable<string | null>(null)
|
||||||
|
export const counts = writable<Record<string, number>>({})
|
||||||
|
|
||||||
export async function bootstrap() {
|
export async function bootstrap() {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue