From dc95a9f78a632aebeef34a81db2e6bbbb088cd8f Mon Sep 17 00:00:00 2001 From: mithral Date: Fri, 9 Oct 2026 21:38:11 -0400 Subject: [PATCH] Redesign the user-facing experience: home, browse, item pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 3 + internal/media/handlers.go | 32 +++ internal/media/home.go | 174 +++++++++++++++ internal/media/items.go | 89 ++++++++ web/src/app.css | 211 ++++++++++++----- web/src/lib/api.ts | 42 +++- web/src/lib/components/Browse.svelte | 87 ++++++++ web/src/lib/components/Card.svelte | 107 +++++++++ web/src/lib/components/Home.svelte | 120 ++++++++++ web/src/lib/components/ItemDetail.svelte | 236 ++++++++++++++++++++ web/src/lib/components/Libraries.svelte | 2 +- web/src/lib/components/LibraryView.svelte | 114 ---------- web/src/lib/components/SearchResults.svelte | 106 ++++----- web/src/lib/components/Shelf.svelte | 76 +++++++ web/src/lib/components/Shell.svelte | 196 ++++++++++------ web/src/lib/nav.ts | 9 +- web/src/lib/session.ts | 1 + 17 files changed, 1298 insertions(+), 307 deletions(-) create mode 100644 internal/media/home.go create mode 100644 internal/media/items.go create mode 100644 web/src/lib/components/Browse.svelte create mode 100644 web/src/lib/components/Card.svelte create mode 100644 web/src/lib/components/Home.svelte create mode 100644 web/src/lib/components/ItemDetail.svelte delete mode 100644 web/src/lib/components/LibraryView.svelte create mode 100644 web/src/lib/components/Shelf.svelte diff --git a/.gitignore b/.gitignore index 99c66a0..4331fd3 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ *.swp .idea/ .vscode/ + +# local preview instance +demo/ diff --git a/internal/media/handlers.go b/internal/media/handlers.go index aec7290..905a7dd 100644 --- a/internal/media/handlers.go +++ b/internal/media/handlers.go @@ -31,7 +31,10 @@ func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator, cacheDir s // Register mounts the routes. 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}/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}/files/{fileID}", h.auth.Require(h.handleFileByID)) 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 } + authors, err := h.authors(r, it.ID) + if err != nil { + h.fail(w, "authors", err) + return + } + out := map[string]any{ "item": map[string]any{ "id": it.ID, @@ -70,6 +79,7 @@ func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.Use }, "files": files, "has_cover": h.hasCover(r, it), + "authors": authors, } if has { 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) } +// 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) { it, ok := h.authorized(w, r, u) if !ok { diff --git a/internal/media/home.go b/internal/media/home.go new file mode 100644 index 0000000..a2ee98a --- /dev/null +++ b/internal/media/home.go @@ -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 +} diff --git a/internal/media/items.go b/internal/media/items.go new file mode 100644 index 0000000..bb87495 --- /dev/null +++ b/internal/media/items.go @@ -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)}) +} diff --git a/web/src/app.css b/web/src/app.css index f1148b9..603ca35 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -1,27 +1,30 @@ -/* Vellum design tokens. - Restrained by intent: one accent, a tight spacing scale, hairline borders, - and no decorative gradient or shadow noise. Dark is the default surface. */ +/* Vellum — user-facing design tokens. + Media-first: dark by default, covers are the primary content, chrome recedes. + One accent, a tight type scale, and generous spacing so artwork breathes. */ :root { - --bg: #0b0d10; - --bg-raised: #131619; - --bg-hover: #191d21; - --bg-active: #1f2429; - --border: #23282e; - --border-strong: #333a42; + --bg: #0a0a0c; + --surface: #131316; + --surface-2: #1b1b20; + --surface-3: #24242b; + --border: #26262e; + --border-strong: #34343e; - --text: #e8eaed; - --text-muted: #9aa3ad; - --text-faint: #6b747e; + --text: #f3f3f5; + --muted: #a2a2ae; + --faint: #6c6c78; --accent: #6d7cff; - --accent-hover: #8290ff; + --accent-hover: #808dff; + --accent-soft: color-mix(in srgb, var(--accent) 16%, transparent); --accent-text: #ffffff; --danger: #ff6b6b; - --success: #4ade80; + --success: #46d17f; - --radius: 6px; - --radius-lg: 10px; + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; + --radius-cover: 8px; --space-1: 4px; --space-2: 8px; @@ -29,27 +32,33 @@ --space-4: 16px; --space-5: 24px; --space-6: 32px; + --space-7: 48px; --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, 'Noto Sans', sans-serif; --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) { :root { - --bg: #ffffff; - --bg-raised: #fafafa; - --bg-hover: #f2f3f5; - --bg-active: #e9ebee; - --border: #e4e6ea; - --border-strong: #cfd3d9; - --text: #14171a; - --text-muted: #5c6672; - --text-faint: #8b949e; - --accent: #4f5bd5; - --accent-hover: #3f4bc4; + --bg: #f7f7f8; + --surface: #ffffff; + --surface-2: #f1f1f3; + --surface-3: #e7e7ea; + --border: #e6e6ea; + --border-strong: #d2d2d9; + --text: #16161a; + --muted: #5d5d68; + --faint: #8b8b96; + --accent: #5563e8; + --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 { margin: 0; font-weight: 600; - letter-spacing: -0.01em; + letter-spacing: -0.015em; } - h1 { - font-size: 20px; + font-size: 22px; } h2 { - font-size: 16px; + font-size: 17px; } h3 { - font-size: 14px; + font-size: 15px; } button, @@ -111,21 +119,25 @@ select { .btn { display: inline-flex; align-items: center; + justify-content: center; gap: var(--space-2); - padding: 7px 12px; + padding: 8px 14px; border: 1px solid var(--border-strong); border-radius: var(--radius); - background: var(--bg-raised); + background: var(--surface-2); color: var(--text); 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; } .btn:hover:not(:disabled) { - background: var(--bg-hover); + background: var(--surface-3); +} +.btn:active:not(:disabled) { + transform: translateY(1px); } .btn:disabled { - opacity: 0.5; + opacity: 0.45; cursor: not-allowed; } .btn-primary { @@ -142,12 +154,17 @@ select { background: transparent; } .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 { width: 100%; - padding: 8px 10px; + padding: 9px 12px; border: 1px solid var(--border-strong); border-radius: var(--radius); background: var(--bg); @@ -157,7 +174,7 @@ select { .input:focus { outline: none; 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 { @@ -168,7 +185,7 @@ select { .field label { font-size: 12px; font-weight: 500; - color: var(--text-muted); + color: var(--muted); } /* ── Surfaces ───────────────────────────────────────────────────────── */ @@ -176,40 +193,99 @@ select { .card { border: 1px solid var(--border); border-radius: var(--radius-lg); - background: var(--bg-raised); + background: var(--surface); } .badge { display: inline-block; - padding: 1px 7px; + padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border-strong); font-size: 11px; - color: var(--text-muted); + color: var(--muted); 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 { - padding: 10px 12px; + padding: 10px 14px; border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--border)); border-radius: var(--radius); 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; } .muted { - color: var(--text-muted); + color: var(--muted); } .faint { - color: var(--text-faint); + color: var(--faint); } .mono { font-family: var(--mono); 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 { @@ -217,20 +293,20 @@ select { flex-direction: column; align-items: center; justify-content: center; - gap: var(--space-3); - padding: var(--space-6) var(--space-4); + gap: var(--space-2); + padding: var(--space-7) var(--space-4); border: 1px dashed var(--border-strong); border-radius: var(--radius-lg); text-align: center; - color: var(--text-muted); + color: var(--muted); } .empty h3 { color: var(--text); } .spinner { - width: 14px; - height: 14px; + width: 16px; + height: 16px; border: 2px solid var(--border-strong); border-top-color: var(--accent); border-radius: 50%; @@ -241,3 +317,28 @@ select { 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); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f118ef4..cf68dcb 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -133,11 +133,17 @@ export const api = { items: (libraryId: number, limit = 200) => request<{ items: Item[]; limit: number }>('GET', `/api/v1/libraries/${libraryId}/items?limit=${limit}`), - item: (id: number) => - request<{ item: Item; files: { id: number; path: string; size: number; container: string }[]; has_cover: boolean; progress?: Progress }>( - 'GET', - `/api/v1/items/${id}`, - ), + item: (id: number) => request('GET', `/api/v1/items/${id}`), + + home: () => request('GET', '/api/v1/home'), + + 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`), @@ -194,6 +200,32 @@ export interface SearchResult { 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 + 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 { library_id: number scanned: number diff --git a/web/src/lib/components/Browse.svelte b/web/src/lib/components/Browse.svelte new file mode 100644 index 0000000..4f58b0c --- /dev/null +++ b/web/src/lib/components/Browse.svelte @@ -0,0 +1,87 @@ + + +{#if !kind && !libraryId} +
+ + {#each types as t (t.Kind)} + + {/each} +
+{/if} + +{#if loading} +
+ {#each Array(12) as _}
{/each} +
+{:else if error} +
{error}
+{:else if items.length === 0} +
+

Nothing here

+

No items match this section yet.

+
+{:else} +
+ {#each items as item (item.id)} + + {/each} +
+{/if} + + diff --git a/web/src/lib/components/Card.svelte b/web/src/lib/components/Card.svelte new file mode 100644 index 0000000..7b8026d --- /dev/null +++ b/web/src/lib/components/Card.svelte @@ -0,0 +1,107 @@ + + + + + diff --git a/web/src/lib/components/Home.svelte b/web/src/lib/components/Home.svelte new file mode 100644 index 0000000..a921d1d --- /dev/null +++ b/web/src/lib/components/Home.svelte @@ -0,0 +1,120 @@ + + +{#if loading} +
+
+
+ {#each Array(6) as _}
{/each} +
+
+{:else if error} +
{error}
+{:else if (home?.total_items ?? 0) === 0} +
+

Nothing in your library yet

+

+ {#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} +

+ {#if $user?.is_admin} + + {/if} +
+{:else} +

Welcome back, {greeting}.

+ + {#if continueItems.length > 0} + + {/if} + + {#each kindsWithContent as kind (kind)} + {@const items = recentOfKind(kind)} + {#if items.length > 0} + go({ name: 'browse', kind })} + /> + {/if} + {/each} +{/if} + + diff --git a/web/src/lib/components/ItemDetail.svelte b/web/src/lib/components/ItemDetail.svelte new file mode 100644 index 0000000..38d6e28 --- /dev/null +++ b/web/src/lib/components/ItemDetail.svelte @@ -0,0 +1,236 @@ + + +{#if loading} +
+
+
+
+{:else if error} +
{error}
+{:else if detail} +
+
+
+ (e.currentTarget as HTMLImageElement).remove()} + /> + {(detail.item.title || '?').slice(0, 1).toUpperCase()} +
+
+ +
+ {kindLabel}{#if detail.item.year} · {detail.item.year}{/if} +

{detail.item.title}

+ {#if detail.authors && detail.authors.length > 0} + + {/if} + + {#if started} +
+
+ {percent}% complete +
+ {/if} + +
+ + Download +
+ + {#if description} +

{description}

+ {/if} + + {#if detail.files.length > 1} +
+

{detail.files.length} files

+
    + {#each detail.files as f (f.id)} +
  1. + {f.path.split('/').pop()} + {Math.round(f.size / 1024 / 1024)} MB +
  2. + {/each} +
+
+ {/if} +
+
+{/if} + + diff --git a/web/src/lib/components/Libraries.svelte b/web/src/lib/components/Libraries.svelte index ce232d5..7f8071f 100644 --- a/web/src/lib/components/Libraries.svelte +++ b/web/src/lib/components/Libraries.svelte @@ -127,7 +127,7 @@
{#each shown as lib (lib.id)}
- - {/each} -
-{/if} - - diff --git a/web/src/lib/components/SearchResults.svelte b/web/src/lib/components/SearchResults.svelte index a7b783a..77e5f2c 100644 --- a/web/src/lib/components/SearchResults.svelte +++ b/web/src/lib/components/SearchResults.svelte @@ -1,6 +1,7 @@ {#if loading} -
+
+ {#each Array(8) as _}
{/each} +
{:else if error}
{error}
{:else if results.length === 0}
-

No matches

-

Nothing in your libraries matches “{query}”.

+

No matches for “{query}”

+

Try a shorter word, or part of a title.

{:else}

{results.length} result{results.length === 1 ? '' : 's'}

-
- {#each results as r (r.id)} - - {/each} -
+ {#each groups as [kind, items] (kind)} +
+

{labelFor(kind)}

+
+ {#each items as item (item.id)} + + {/each} +
+
+ {/each} {/if} diff --git a/web/src/lib/components/Shelf.svelte b/web/src/lib/components/Shelf.svelte new file mode 100644 index 0000000..390a5c0 --- /dev/null +++ b/web/src/lib/components/Shelf.svelte @@ -0,0 +1,76 @@ + + +{#if items.length > 0} +
+
+

{title}

+ {#if onSeeAll} + + {/if} +
+
+ {#each items as item (item.id)} +
+ +
+ {/each} +
+
+{/if} + + diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index eb2a88b..a2fd6ca 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -1,76 +1,79 @@