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:
mithral 2026-10-09 21:38:11 -04:00
parent cebe1977e4
commit dc95a9f78a
17 changed files with 1298 additions and 307 deletions

3
.gitignore vendored
View file

@ -15,3 +15,6 @@
*.swp
.idea/
.vscode/
# local preview instance
demo/

View file

@ -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 {

174
internal/media/home.go Normal file
View 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
View 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)})
}

View file

@ -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);
}

View file

@ -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<ItemDetail>('GET', `/api/v1/items/${id}`),
home: () => request<Home>('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<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 {
library_id: number
scanned: number

View 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>

View 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>

View 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>

View 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>

View file

@ -127,7 +127,7 @@
<div class="grid">
{#each shown as lib (lib.id)}
<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>
<strong>{lib.name}</strong>
<span class="path mono">{lib.root_path}</span>

View file

@ -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>

View file

@ -1,6 +1,7 @@
<script lang="ts">
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()
@ -23,87 +24,64 @@
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>
{#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}
<div class="alert">{error}</div>
{:else if results.length === 0}
<div class="empty">
<h3>No matches</h3>
<p>Nothing in your libraries matches “{query}”.</p>
<h3>No matches for “{query}”</h3>
<p>Try a shorter word, or part of a title.</p>
</div>
{:else}
<p class="count muted">{results.length} result{results.length === 1 ? '' : 's'}</p>
<div class="card list">
{#each results as r (r.id)}
<button class="row" onclick={() => go({ name: 'reader', itemId: r.id, title: r.title })}>
<img class="thumb" src={api.coverUrl(r.id)} alt="" loading="lazy" onerror={(e) => (e.currentTarget as HTMLImageElement).remove()} />
<span class="meta">
<strong>{r.title}</strong>
<span class="muted mono">{r.path}</span>
</span>
<span class="badge">{r.kind}</span>
</button>
{/each}
</div>
{#each groups as [kind, items] (kind)}
<section>
<h2>{labelFor(kind)}</h2>
<div class="grid">
{#each items as item (item.id)}
<Card {item} />
{/each}
</div>
</section>
{/each}
{/if}
<style>
.center {
display: grid;
place-items: center;
padding: var(--space-6);
}
.count {
margin: 0 0 var(--space-3);
margin: 0 0 var(--space-5);
font-size: 13px;
}
.list {
overflow: hidden;
section {
margin-bottom: var(--space-6);
}
.row {
display: flex;
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;
h2 {
margin-bottom: var(--space-3);
}
.row:last-child {
border-bottom: none;
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: var(--space-5) var(--space-4);
}
.row:hover {
background: var(--bg-hover);
}
.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;
.skeleton.tile {
aspect-ratio: 2 / 3;
}
</style>

View 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>

View file

@ -1,76 +1,79 @@
<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 Libraries from './Libraries.svelte'
import LibraryView from './LibraryView.svelte'
import Home from './Home.svelte'
import Browse from './Browse.svelte'
import ItemDetail from './ItemDetail.svelte'
import Reader from './Reader.svelte'
import Users from './Users.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('')
const types = $derived(
($mediaTypes as { Kind: string; Name: string }[]).filter((t) => ($counts[t.Kind] ?? 0) > 0),
)
function submitSearch(event: SubmitEvent) {
event.preventDefault()
const q = searchBox.trim()
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 {
switch (v.name) {
case 'libraries':
return kindFilter ? ($mediaTypes.find((t) => t.Kind === kindFilter)?.Name ?? 'Library') : 'All libraries'
case 'library':
return $libraries.find((l) => l.id === v.id)?.name ?? 'Library'
case 'home':
return 'Home'
case 'browse':
if (v.kind) return types.find((t) => t.Kind === v.kind)?.Name ?? 'Browse'
return 'Browse'
case 'item':
return ''
case 'reader':
return v.title
case 'search':
return 'Search'
case 'libraries':
return 'Libraries'
case 'users':
return 'Users'
case 'settings':
return 'Settings'
}
}
function selectKind(kind: string | null) {
kindFilter = kind
go({ name: 'libraries' })
}
</script>
<div class="shell">
<aside class="nav">
<div class="brand">
<button class="brand" onclick={() => go({ name: 'home' })}>
<span class="mark">V</span>
<strong>Vellum</strong>
</div>
</button>
<nav class="links">
<button class="navlink" class:active={$view.name === 'libraries' && kindFilter === null} onclick={() => selectKind(null)}>
<span>All libraries</span>
<span class="count">{$libraries.length}</span>
<button class="navlink" class:active={$view.name === 'home'} onclick={() => go({ name: 'home' })}>
<span>Home</span>
</button>
{#each kinds as kind (kind.Kind)}
<button class="navlink" class:active={kindFilter === kind.Kind} onclick={() => selectKind(kind.Kind)}>
<span>{kind.Name}</span>
<span class="count">{countsFor(kind.Kind)}</span>
{#each types as t (t.Kind)}
<button
class="navlink"
class:active={$view.name === 'browse' && $view.kind === t.Kind}
onclick={() => go({ name: 'browse', kind: t.Kind })}
>
<span>{t.Name}</span>
<span class="count">{$counts[t.Kind]}</span>
</button>
{/each}
<button class="navlink" class:active={$view.name === 'browse' && !$view.kind} onclick={() => go({ name: 'browse' })}>
<span>Browse all</span>
</button>
</nav>
{#if $user?.is_admin}
<div class="section">Admin</div>
<div class="section">Manage</div>
<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' })}>
<span>Users</span>
</button>
@ -79,8 +82,11 @@
<div class="foot">
<div class="who">
<strong>{$user?.display_name}</strong>
{#if $user?.is_admin}<span class="badge">admin</span>{/if}
<span class="avatar">{($user?.display_name || $user?.username || '?').slice(0, 1).toUpperCase()}</span>
<span class="name">
<strong>{$user?.display_name || $user?.username}</strong>
<span class="faint">@{$user?.username}{#if $user?.is_admin} · admin{/if}</span>
</span>
</div>
<button class="btn btn-ghost" onclick={logout}>Sign out</button>
</div>
@ -88,12 +94,16 @@
<main class="main">
<header class="topbar">
<h1>{title($view)}</h1>
{#if title($view)}
<h1>{title($view)}</h1>
{:else}
<button class="back" onclick={() => history.back()} aria-label="Back">←</button>
{/if}
<form class="search" onsubmit={submitSearch}>
<input
class="input"
type="search"
placeholder="Search titles and paths"
placeholder="Search your library"
bind:value={searchBox}
aria-label="Search"
/>
@ -101,14 +111,18 @@
</header>
<div class="content">
{#if $view.name === 'libraries'}
<Libraries {kindFilter} />
{:else if $view.name === 'library'}
<LibraryView libraryId={$view.id} />
{#if $view.name === 'home'}
<Home />
{:else if $view.name === 'browse'}
<Browse kind={$view.kind} libraryId={$view.libraryId} />
{:else if $view.name === 'item'}
<ItemDetail id={$view.id} />
{:else if $view.name === 'reader'}
<Reader itemId={$view.itemId} />
{:else if $view.name === 'search'}
<SearchResults query={$view.query} />
{:else if $view.name === 'libraries'}
<Libraries />
{:else if $view.name === 'users'}
<Users />
{/if}
@ -126,66 +140,72 @@
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-4);
padding: var(--space-4) var(--space-3);
border-right: 1px solid var(--border);
background: var(--bg-raised);
background: var(--surface);
overflow-y: auto;
}
.brand {
display: flex;
align-items: center;
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 {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 6px;
width: 26px;
height: 26px;
border-radius: 7px;
background: var(--accent);
color: var(--accent-text);
font-size: 12px;
font-size: 13px;
font-weight: 700;
}
.links {
display: flex;
flex-direction: column;
gap: 2px;
gap: 1px;
}
.navlink {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 6px 10px;
padding: 7px 10px;
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--text-muted);
color: var(--muted);
text-align: left;
cursor: pointer;
font-size: 13.5px;
}
.navlink:hover {
background: var(--bg-hover);
background: var(--surface-2);
color: var(--text);
}
.navlink.active {
background: var(--bg-active);
background: var(--surface-3);
color: var(--text);
font-weight: 500;
}
.count {
font-size: 12px;
color: var(--text-faint);
color: var(--faint);
font-variant-numeric: tabular-nums;
}
.section {
margin: 0 var(--space-1) -8px;
margin: 0 var(--space-2) -8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-faint);
letter-spacing: 0.07em;
color: var(--faint);
}
.foot {
margin-top: auto;
@ -199,7 +219,33 @@
display: flex;
align-items: center;
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;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.who .faint {
font-size: 11px;
}
.main {
display: flex;
@ -212,17 +258,39 @@
align-items: center;
justify-content: space-between;
gap: var(--space-4);
height: 52px;
padding: 0 var(--space-5);
border-bottom: 1px solid var(--border);
height: 60px;
padding: 0 var(--space-6);
flex: none;
}
.search {
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 {
flex: 1;
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>

View file

@ -2,14 +2,15 @@ import { writable } from 'svelte/store'
// The app is small enough that a typed discriminated union beats a router.
export type View =
| { name: 'libraries' }
| { name: 'library'; id: number }
| { name: 'home' }
| { name: 'browse'; kind?: string; libraryId?: number }
| { name: 'item'; id: number }
| { name: 'reader'; itemId: number; title: string }
| { name: 'search'; query: string }
| { name: 'libraries' }
| { name: 'users' }
| { name: 'settings' }
export const view = writable<View>({ name: 'libraries' })
export const view = writable<View>({ name: 'home' })
export function go(next: View) {
view.set(next)

View file

@ -12,6 +12,7 @@ export const libraries = writable<Library[]>([])
export const mediaTypes = writable<MediaType[]>([])
export const loadingLibraries = writable(false)
export const bootError = writable<string | null>(null)
export const counts = writable<Record<string, number>>({})
export async function bootstrap() {
try {