feat: render index via Mustache template with spine-index-model
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
# Overview
|
||||||
|
|
||||||
|
Spine is a tool for managing your personal library. Spine keeps track of books you want to read, are reading and have read. Spine lets you track notes about the books and why you were interested in them in the first place.
|
||||||
|
|
||||||
|
# Design
|
||||||
|
|
||||||
|
Spine is primarily a web application where you view and enter details about books. To ensure your data is fully portable and independent of Spine itself, your book data is backed by a single Org file ( http://orgmode.org/ )
|
||||||
|
|
||||||
|
The application itself is written in Elisp and runs inside of Emacs but serves up its interface as HTML derived from your `spine.org` file.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Spine — Direction A (agenda)</title>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-page: #efeee9; --bg-primary: #ffffff; --bg-secondary: #f5f4ef;
|
||||||
|
--text-primary: #1d1d1b; --text-secondary: #5f5e5a; --text-tertiary: #8a8980;
|
||||||
|
--bg-info: #e6f1fb; --text-info: #0c447c; --border-info: #378add;
|
||||||
|
--bg-warning: #faeeda; --text-warning: #854f0b;
|
||||||
|
--bg-success: #e1f5ee; --text-success: #085041;
|
||||||
|
--border-tertiary: rgba(0,0,0,0.12); --border-secondary: rgba(0,0,0,0.22);
|
||||||
|
--radius-md: 8px; --radius-lg: 12px;
|
||||||
|
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg-page: #161615; --bg-primary: #232321; --bg-secondary: #1c1c1a;
|
||||||
|
--text-primary: #ededeb; --text-secondary: #a8a79f; --text-tertiary: #76756d;
|
||||||
|
--bg-info: #14304a; --text-info: #b5d4f4; --border-info: #378add;
|
||||||
|
--bg-warning: #3a2c12; --text-warning: #fac775;
|
||||||
|
--bg-success: #103a30; --text-success: #9fe1cb;
|
||||||
|
--border-tertiary: rgba(255,255,255,0.14); --border-secondary: rgba(255,255,255,0.24);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--bg-page); color: var(--text-primary);
|
||||||
|
font-family: var(--sans); padding: 28px 16px; }
|
||||||
|
.wrap { max-width: 720px; margin: 0 auto; }
|
||||||
|
.frame { font-family: var(--mono); border: 0.5px solid var(--border-secondary);
|
||||||
|
border-radius: var(--radius-lg); overflow: hidden; background: var(--bg-primary); }
|
||||||
|
.titlebar { display:flex; align-items:center; justify-content:space-between;
|
||||||
|
padding:10px 14px; border-bottom:0.5px solid var(--border-tertiary);
|
||||||
|
background: var(--bg-secondary); font-size:13px; }
|
||||||
|
.group { padding:10px 14px 4px; font-size:11px; letter-spacing:0.04em; color: var(--text-tertiary); }
|
||||||
|
.row { display:flex; align-items:center; gap:10px; padding:5px 14px; font-size:13px; }
|
||||||
|
.glyph { font-size:15px; color:var(--text-tertiary); width:16px; }
|
||||||
|
.pill { padding:1px 7px; border-radius:var(--radius-md); font-size:11px; }
|
||||||
|
.pill.want { background:var(--bg-info); color:var(--text-info); }
|
||||||
|
.pill.reading { background:var(--bg-warning); color:var(--text-warning); }
|
||||||
|
.pill.read { background:var(--bg-success); color:var(--text-success); }
|
||||||
|
.title { flex:1; min-width:0; }
|
||||||
|
.muted { color:var(--text-tertiary); }
|
||||||
|
.tags { color:var(--text-info); font-size:12px; }
|
||||||
|
.trail { color:var(--text-secondary); font-size:12px; }
|
||||||
|
.selected { background:var(--bg-secondary); border-left:2px solid var(--border-info); }
|
||||||
|
.detail { padding:8px 14px 12px 32px; background:var(--bg-secondary);
|
||||||
|
border-left:2px solid var(--border-info); font-size:12.5px; }
|
||||||
|
.logline { display:flex; gap:8px; margin-bottom:5px; }
|
||||||
|
.logdate { color:var(--text-tertiary); white-space:nowrap; }
|
||||||
|
.minibuffer { border-top:0.5px solid var(--border-tertiary); padding:9px 14px;
|
||||||
|
background:var(--bg-secondary); font-size:13px; display:flex; gap:8px; align-items:center; }
|
||||||
|
.caret { opacity:0.5; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="frame">
|
||||||
|
<div class="titlebar">
|
||||||
|
<span style="font-weight:500;">spine · agenda</span>
|
||||||
|
<span class="muted">6 books · 1 reading</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group">on deck · 3</div>
|
||||||
|
<div class="row">
|
||||||
|
<i class="ti ti-book glyph" aria-hidden="true"></i>
|
||||||
|
<span class="pill want">want</span>
|
||||||
|
<span class="title">Tomorrow, and Tomorrow, and Tomorrow <span class="muted">· Zevin</span></span>
|
||||||
|
<span class="trail">via Priya</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<i class="ti ti-headphones glyph" aria-hidden="true"></i>
|
||||||
|
<span class="pill want">want</span>
|
||||||
|
<span class="title">Children of Time <span class="muted">· Tchaikovsky</span></span>
|
||||||
|
<span class="tags">:scifi:</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<i class="ti ti-device-tablet glyph" aria-hidden="true"></i>
|
||||||
|
<span class="pill want">want</span>
|
||||||
|
<span class="title">The Spear Cuts Through Water <span class="muted">· Jimenez</span></span>
|
||||||
|
<span class="trail">via book club</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group">reading · 1</div>
|
||||||
|
<div class="row selected">
|
||||||
|
<i class="ti ti-book glyph" style="color:var(--text-secondary);" aria-hidden="true"></i>
|
||||||
|
<span class="pill reading">reading</span>
|
||||||
|
<span class="title" style="font-weight:500;">Use of Weapons <span class="muted" style="font-weight:400;">· Banks</span></span>
|
||||||
|
<span class="tags">:scifi:literary:</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail">
|
||||||
|
<div class="muted" style="margin-bottom:8px;">started [2026-02-20] · hardcover · 62% · rating —</div>
|
||||||
|
<div class="logline"><span class="logdate">[03-02]</span><span>The two-track structure is doing something I can't name yet. Suspicious of the chair.</span></div>
|
||||||
|
<div class="logline"><span class="logdate">[03-07]</span><span>Zakalwe's competence is starting to read as a wound. Banks never lets a skill be free.</span></div>
|
||||||
|
<div class="logline" style="margin-bottom:0;"><span class="logdate">[03-09]</span><span>The chapter numbering. Oh.</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group">read · 2</div>
|
||||||
|
<div class="row">
|
||||||
|
<i class="ti ti-headphones glyph" aria-hidden="true"></i>
|
||||||
|
<span class="pill read">read</span>
|
||||||
|
<span class="title">A Memory Called Empire <span class="muted">· Martine</span></span>
|
||||||
|
<span class="trail">★★★★★</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="padding-bottom:8px;">
|
||||||
|
<i class="ti ti-book glyph" aria-hidden="true"></i>
|
||||||
|
<span class="pill read">read</span>
|
||||||
|
<span class="title">Piranesi <span class="muted">· Clarke</span></span>
|
||||||
|
<span class="trail">★★★★☆</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="minibuffer">
|
||||||
|
<span class="muted">M-x</span>
|
||||||
|
<span>spine-note</span>
|
||||||
|
<span style="border-left:2px solid var(--text-info); padding-left:6px;">Banks lets the structure detonate the whole book<span class="caret">▏</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Spine — Direction B (journal)</title>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-page: #efeee9; --bg-primary: #ffffff; --bg-secondary: #f5f4ef;
|
||||||
|
--text-primary: #1d1d1b; --text-secondary: #5f5e5a; --text-tertiary: #8a8980;
|
||||||
|
--bg-info: #e6f1fb; --text-info: #0c447c; --border-info: #378add;
|
||||||
|
--bg-warning: #faeeda; --text-warning: #854f0b;
|
||||||
|
--border-tertiary: rgba(0,0,0,0.12); --border-secondary: rgba(0,0,0,0.22);
|
||||||
|
--radius-md: 8px; --radius-lg: 12px;
|
||||||
|
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg-page: #161615; --bg-primary: #232321; --bg-secondary: #1c1c1a;
|
||||||
|
--text-primary: #ededeb; --text-secondary: #a8a79f; --text-tertiary: #76756d;
|
||||||
|
--bg-info: #14304a; --text-info: #b5d4f4; --border-info: #378add;
|
||||||
|
--bg-warning: #3a2c12; --text-warning: #fac775;
|
||||||
|
--border-tertiary: rgba(255,255,255,0.14); --border-secondary: rgba(255,255,255,0.24);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--bg-page); color: var(--text-primary);
|
||||||
|
font-family: var(--sans); padding: 28px 16px; }
|
||||||
|
.wrap { max-width: 560px; margin: 0 auto; }
|
||||||
|
.surface { background: var(--bg-secondary); border-radius: var(--radius-lg); padding: 1.25rem; }
|
||||||
|
.card { background: var(--bg-primary); border:0.5px solid var(--border-tertiary);
|
||||||
|
border-radius: var(--radius-lg); padding: 1.1rem 1.25rem; }
|
||||||
|
.cover { width:44px; height:60px; border-radius:var(--radius-md); background:var(--bg-info);
|
||||||
|
display:flex; align-items:center; justify-content:center; flex-shrink:0; }
|
||||||
|
.pill { background:var(--bg-warning); color:var(--text-warning); padding:2px 9px;
|
||||||
|
border-radius:var(--radius-md); font-size:12px; }
|
||||||
|
.seg { flex:1; text-align:center; padding:7px 0; font-size:13px; border:0.5px solid var(--border-tertiary);
|
||||||
|
border-radius:var(--radius-md); color:var(--text-secondary); }
|
||||||
|
.seg.active { border:2px solid var(--border-info); color:var(--text-info); background:var(--bg-info); }
|
||||||
|
.seclabel { font-size:12px; letter-spacing:0.03em; color:var(--text-tertiary); margin-bottom:10px; }
|
||||||
|
.thread { border-left:2px solid var(--border-tertiary); padding-left:14px; margin-bottom:14px; }
|
||||||
|
.entry { margin-bottom:12px; }
|
||||||
|
.entry:last-child { margin-bottom:0; }
|
||||||
|
.edate { font-size:12px; color:var(--text-tertiary); margin-bottom:2px; }
|
||||||
|
.etext { font-size:14px; line-height:1.5; }
|
||||||
|
input, button { font-family: var(--sans); font-size:14px; }
|
||||||
|
input { height:36px; padding:0 10px; border:0.5px solid var(--border-secondary);
|
||||||
|
border-radius:var(--radius-md); background:var(--bg-primary); color:var(--text-primary); }
|
||||||
|
input::placeholder { color:var(--text-tertiary); }
|
||||||
|
button { height:36px; padding:0 12px; border:0.5px solid var(--border-secondary);
|
||||||
|
border-radius:var(--radius-md); background:transparent; color:var(--text-primary); cursor:pointer; }
|
||||||
|
button:hover { background:var(--bg-secondary); }
|
||||||
|
.avatar { width:30px; height:30px; border-radius:50%; background:var(--bg-info);
|
||||||
|
display:flex; align-items:center; justify-content:center; font-size:12px;
|
||||||
|
color:var(--text-info); flex-shrink:0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="surface">
|
||||||
|
<div class="card">
|
||||||
|
|
||||||
|
<div style="display:flex; gap:14px; align-items:flex-start; margin-bottom:16px;">
|
||||||
|
<div class="cover"><i class="ti ti-book" style="font-size:22px; color:var(--text-info);" aria-hidden="true"></i></div>
|
||||||
|
<div style="flex:1; min-width:0;">
|
||||||
|
<div style="font-weight:500; font-size:16px;">Use of Weapons</div>
|
||||||
|
<div style="font-size:14px; color:var(--text-secondary); margin-bottom:6px;">Iain M. Banks</div>
|
||||||
|
<span class="pill">Reading</span>
|
||||||
|
<span style="color:var(--text-tertiary); font-size:13px; margin-left:8px;">started 20 Feb · 62%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:8px; margin-bottom:18px;">
|
||||||
|
<div class="seg"><i class="ti ti-book" style="font-size:15px; vertical-align:-2px; margin-right:5px;" aria-hidden="true"></i>Hardcover</div>
|
||||||
|
<div class="seg"><i class="ti ti-device-tablet" style="font-size:15px; vertical-align:-2px; margin-right:5px;" aria-hidden="true"></i>eBook</div>
|
||||||
|
<div class="seg active"><i class="ti ti-headphones" style="font-size:15px; vertical-align:-2px; margin-right:5px;" aria-hidden="true"></i>Audiobook</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="seclabel">Reading log</div>
|
||||||
|
<div class="thread">
|
||||||
|
<div class="entry">
|
||||||
|
<div class="edate">2 Mar</div>
|
||||||
|
<div class="etext">The two-track structure is doing something I can't name yet. Suspicious of the chair.</div>
|
||||||
|
</div>
|
||||||
|
<div class="entry">
|
||||||
|
<div class="edate">7 Mar</div>
|
||||||
|
<div class="etext">Zakalwe's competence is starting to read as a wound. Banks never lets a skill be free.</div>
|
||||||
|
</div>
|
||||||
|
<div class="entry">
|
||||||
|
<div class="edate">9 Mar</div>
|
||||||
|
<div class="etext">The chapter numbering. Oh.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:8px; margin-bottom:20px;">
|
||||||
|
<input type="text" placeholder="Add a note at 62%…" style="flex:1;" />
|
||||||
|
<button><i class="ti ti-plus" style="font-size:15px; vertical-align:-2px; margin-right:4px;" aria-hidden="true"></i>Log note</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border-top:0.5px solid var(--border-tertiary); padding-top:14px;">
|
||||||
|
<div class="seclabel">On your radar</div>
|
||||||
|
<div style="display:flex; align-items:flex-start; gap:10px; margin-bottom:14px;">
|
||||||
|
<div class="avatar">PK</div>
|
||||||
|
<div style="font-size:14px; line-height:1.5;"><span style="font-weight:500;">Priya</span> <span style="color:var(--text-tertiary);">· 12 Jan</span><br>"If you liked Player of Games, this one will wreck you in a better way."</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<input type="text" placeholder="Recommended by…" style="width:34%;" />
|
||||||
|
<input type="text" placeholder="Why it's on your radar…" style="flex:1;" />
|
||||||
|
<button aria-label="Save recommendation"><i class="ti ti-check" style="font-size:16px;" aria-hidden="true"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<title>Spine — Direction B (journal)</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css" />
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
|
||||||
|
<style>
|
||||||
|
.book { max-width: 560px; margin-inline: auto; }
|
||||||
|
.book hgroup { margin-bottom: .4rem; }
|
||||||
|
.book hgroup h3 { margin-bottom: .1rem; }
|
||||||
|
.book hgroup p { color: var(--pico-muted-color); margin: 0; }
|
||||||
|
.cover { width: 44px; height: 60px; border-radius: var(--pico-border-radius);
|
||||||
|
background: var(--pico-primary-background); color: var(--pico-primary-inverse);
|
||||||
|
display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||||
|
.pill { padding: .1rem .5rem; border-radius: var(--pico-border-radius); font-size: .75rem; font-weight: 500; }
|
||||||
|
.pill.want { background: #e6f1fb; color: #0c447c; }
|
||||||
|
.pill.reading { background: #faeeda; color: #854f0b; }
|
||||||
|
.pill.read { background: #e1f5ee; color: #085041; }
|
||||||
|
.formats { margin-bottom: 1.1rem; }
|
||||||
|
.formats button { --pico-font-size: .85rem; }
|
||||||
|
.seclabel { font-size: .72rem; letter-spacing: .03em; color: var(--pico-muted-color);
|
||||||
|
text-transform: none; margin-bottom: .6rem; }
|
||||||
|
.thread { border-left: 2px solid var(--pico-muted-border-color); padding-left: .9rem; margin-bottom: .9rem; }
|
||||||
|
.entry { margin-bottom: .8rem; }
|
||||||
|
.entry:last-child { margin-bottom: 0; }
|
||||||
|
.edate { font-size: .75rem; color: var(--pico-muted-color); margin-bottom: .15rem; }
|
||||||
|
.etext { font-size: .9rem; line-height: 1.5; }
|
||||||
|
.avatar { width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
background: var(--pico-primary-background); color: var(--pico-primary-inverse);
|
||||||
|
display: flex; align-items: center; justify-content: center; font-size: .75rem; }
|
||||||
|
.compose, .recform { margin-bottom: 0; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.pill.want { background: #14304a; color: #b5d4f4; }
|
||||||
|
.pill.reading { background: #3a2c12; color: #fac775; }
|
||||||
|
.pill.read { background: #103a30; color: #9fe1cb; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{!
|
||||||
|
View model for this template:
|
||||||
|
{
|
||||||
|
cover_icon: string, Tabler class for the cover placeholder, e.g. "ti-book"
|
||||||
|
title: string,
|
||||||
|
author: string,
|
||||||
|
status_class: string, "want" | "reading" | "read"
|
||||||
|
status_label: string,
|
||||||
|
meta: string, e.g. "started 20 Feb · 62%"
|
||||||
|
progress_label: string, e.g. "62%" (used in the note composer placeholder)
|
||||||
|
formats: [ { icon: string, label: string, active: boolean } ], the format selector segments
|
||||||
|
notes: [ { date: string, text: string } ], reading log
|
||||||
|
recommendation: { optional
|
||||||
|
initials: string,
|
||||||
|
by: string,
|
||||||
|
date: string,
|
||||||
|
note: string
|
||||||
|
}?
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<main class="container">
|
||||||
|
<article class="book">
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 14px; align-items: flex-start; margin-bottom: 1rem;">
|
||||||
|
<div class="cover"><i class="ti {{cover_icon}}" style="font-size: 22px;" aria-hidden="true"></i></div>
|
||||||
|
<div style="flex: 1; min-width: 0;">
|
||||||
|
<hgroup>
|
||||||
|
<h3>{{title}}</h3>
|
||||||
|
<p>{{author}}</p>
|
||||||
|
</hgroup>
|
||||||
|
<span class="pill {{status_class}}">{{status_label}}</span>
|
||||||
|
<span class="muted" style="color: var(--pico-muted-color); font-size: .85rem; margin-left: .5rem;">{{meta}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="formats" role="group">
|
||||||
|
{{#formats}}
|
||||||
|
<button class="{{^active}}secondary outline{{/active}}"><i class="ti {{icon}}" style="font-size: 15px; vertical-align: -2px; margin-right: 5px;" aria-hidden="true"></i>{{label}}</button>
|
||||||
|
{{/formats}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="seclabel">Reading log</p>
|
||||||
|
<div class="thread">
|
||||||
|
{{#notes}}
|
||||||
|
<div class="entry">
|
||||||
|
<div class="edate">{{date}}</div>
|
||||||
|
<div class="etext">{{text}}</div>
|
||||||
|
</div>
|
||||||
|
{{/notes}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="compose" role="group">
|
||||||
|
<input type="text" placeholder="Add a note at {{progress_label}}…" />
|
||||||
|
<button><i class="ti ti-plus" style="font-size: 15px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Log note</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<p class="seclabel">On your radar</p>
|
||||||
|
{{#recommendation}}
|
||||||
|
<div style="display: flex; align-items: flex-start; gap: 10px; margin-bottom: 1rem;">
|
||||||
|
<div class="avatar">{{initials}}</div>
|
||||||
|
<div style="font-size: .9rem; line-height: 1.5;"><strong>{{by}}</strong> <span class="muted" style="color: var(--pico-muted-color);">· {{date}}</span><br>"{{note}}"</div>
|
||||||
|
</div>
|
||||||
|
{{/recommendation}}
|
||||||
|
<div class="recform" role="group">
|
||||||
|
<input type="text" placeholder="Recommended by…" style="flex: 0 0 34%;" />
|
||||||
|
<input type="text" placeholder="Why it's on your radar…" />
|
||||||
|
<button aria-label="Save recommendation"><i class="ti ti-check" style="font-size: 16px;" aria-hidden="true"></i></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
# Spine — design brief
|
||||||
|
|
||||||
|
A personal library and reading tracker. Tracks books I want to read, am reading,
|
||||||
|
have read, and what I think about them; captures notes repeatedly through a read;
|
||||||
|
and records who recommended a book and why.
|
||||||
|
|
||||||
|
This document is a starting point for a coding agent. Several decisions are
|
||||||
|
deliberately **left open** (see "Open decisions"). Do not invent answers for
|
||||||
|
those — scaffold around them or ask.
|
||||||
|
|
||||||
|
## Core features
|
||||||
|
|
||||||
|
- Track each book through a reading lifecycle: want → reading → read (or abandoned).
|
||||||
|
- Capture **multiple timestamped notes** across a single read, not one review at the end.
|
||||||
|
- Record **recommendations**: who recommended a book and a short "why it's on my radar"
|
||||||
|
note. This is context shown inline, not a search/analytics concern.
|
||||||
|
- Flag the **format** consumed: hardcover, ebook, or audiobook.
|
||||||
|
- Rate finished books.
|
||||||
|
- Browse "on deck" (want-to-read) and optionally filter by category.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Source of truth is a single Org file.** No database.
|
||||||
|
- **Emacs owns the file.** All reads and writes go through a running Emacs via
|
||||||
|
`emacsclient`, so `org-element` does the parsing and serialization canonically.
|
||||||
|
This avoids third-party Org parser fidelity problems on write.
|
||||||
|
- **No read/search index initially.** Queries are simple status + tag filters
|
||||||
|
answered live by `org-ql`. A derived SQLite index can be added later if the
|
||||||
|
recommendation data ever needs real querying; it is explicitly out of scope now.
|
||||||
|
- The **web layer is a thin client** over emacsclient. Its language and framework
|
||||||
|
are undecided (see Open decisions).
|
||||||
|
|
||||||
|
## Data model (Org)
|
||||||
|
|
||||||
|
Each book is a top-level headline whose TODO state is its lifecycle status.
|
||||||
|
|
||||||
|
```org
|
||||||
|
#+TITLE: Spine
|
||||||
|
#+TODO: WANT(w) READING(r) | READ(d) ABANDONED(a)
|
||||||
|
#+STARTUP: logdrawer
|
||||||
|
|
||||||
|
* READING Use of Weapons
|
||||||
|
:PROPERTIES:
|
||||||
|
:AUTHOR: Iain M. Banks
|
||||||
|
:FORMAT: audiobook
|
||||||
|
:REC_BY: Priya
|
||||||
|
:REC_NOTE: If you liked Player of Games, this one will wreck you in a better way.
|
||||||
|
:RATING:
|
||||||
|
:ADDED: [2026-02-20]
|
||||||
|
:ID: 8c1e-uow
|
||||||
|
:END:
|
||||||
|
:LOGBOOK:
|
||||||
|
- State "READING" from "WANT" [2026-02-20]
|
||||||
|
:END:
|
||||||
|
- [2026-03-02] The two-track structure is doing something I can't name yet.
|
||||||
|
- [2026-03-07] Zakalwe's competence reads as a wound. Banks never lets a skill be free.
|
||||||
|
- [2026-03-09] The chapter numbering. Oh.
|
||||||
|
```
|
||||||
|
|
||||||
|
Field notes:
|
||||||
|
|
||||||
|
- **Status**: the TODO keyword. `WANT READING | READ ABANDONED`.
|
||||||
|
- **Status history**: comes free from the LOGBOOK drawer when
|
||||||
|
`org-log-into-drawer` is enabled. Distinct from reading notes.
|
||||||
|
- **Reading notes**: a plain Org list in the headline body, each item prefixed
|
||||||
|
with an inactive timestamp `[YYYY-MM-DD]`. Appending a note is inserting one
|
||||||
|
list item. Notes are not individually queried.
|
||||||
|
- **Format**: single `:FORMAT:` property — `hardcover` | `ebook` | `audiobook`.
|
||||||
|
(See Open decisions for the multi-format case.)
|
||||||
|
- **Recommendation**: `:REC_BY:` plus optional `:REC_NOTE:`. Surfaced inline as
|
||||||
|
context, never ranked or searched.
|
||||||
|
- **Category**: Org **tags** on the headline (`:scifi:literary:`), not a property —
|
||||||
|
tags filter natively and inherit.
|
||||||
|
- **Rating**: `:RATING:` 1–5, set when moved to READ.
|
||||||
|
|
||||||
|
## emacsclient integration
|
||||||
|
|
||||||
|
### Read: on-deck list
|
||||||
|
|
||||||
|
```elisp
|
||||||
|
(require 'org-ql)
|
||||||
|
(require 'json)
|
||||||
|
|
||||||
|
(defun spine-ondeck (&optional tag)
|
||||||
|
"JSON of WANT books, optionally filtered by TAG."
|
||||||
|
(json-serialize
|
||||||
|
(vconcat
|
||||||
|
(org-ql-select "~/spine/books.org"
|
||||||
|
(if tag `(and (todo "WANT") (tags ,tag)) '(todo "WANT"))
|
||||||
|
:action
|
||||||
|
(lambda ()
|
||||||
|
(list :title (org-get-heading t t t t)
|
||||||
|
:author (org-entry-get nil "AUTHOR")
|
||||||
|
:format (org-entry-get nil "FORMAT")
|
||||||
|
:rec_by (org-entry-get nil "REC_BY")
|
||||||
|
:tags (vconcat (org-get-tags))))))))
|
||||||
|
|
||||||
|
(defun spine-ondeck-file (path &optional tag)
|
||||||
|
"Write `spine-ondeck' output to PATH (avoids emacsclient quoting)."
|
||||||
|
(with-temp-file path (insert (spine-ondeck tag))))
|
||||||
|
```
|
||||||
|
|
||||||
|
Invoke: `emacsclient --eval '(spine-ondeck-file "/tmp/spine.json")'`, then read
|
||||||
|
the file from the web layer. (`emacsclient --eval` prints values with `prin1`, so
|
||||||
|
returning a JSON string directly comes back quoted/escaped — the temp-file hop
|
||||||
|
sidesteps that. A small `simple-httpd` endpoint is the alternative; undecided.)
|
||||||
|
|
||||||
|
### Write: to implement
|
||||||
|
|
||||||
|
- `spine-add-note` — append a `[today] text` list item under a book's headline.
|
||||||
|
- `spine-add-book` — insert a new WANT headline with initial properties.
|
||||||
|
- `spine-set-status` / `spine-set-rating` / `spine-set-format`.
|
||||||
|
|
||||||
|
These set state through `org-element` / standard Org commands, not text munging.
|
||||||
|
Not yet written.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
Two directions are mocked as standalone HTML (`spine-mockup-a-agenda.html`,
|
||||||
|
`spine-mockup-b-journal.html`). They render the same Org record through different
|
||||||
|
front doors.
|
||||||
|
|
||||||
|
- **Direction A — agenda (TUI).** An org-agenda-style surface grouped by status.
|
||||||
|
Format shows as a leading glyph, the recommendation rides on the row as "why
|
||||||
|
it's here," the selected book expands inline to show its reading log, and
|
||||||
|
capture happens in a minibuffer rather than a form. This is the home surface.
|
||||||
|
|
||||||
|
- **Direction B — journal card.** A book detail card. Format is a segmented
|
||||||
|
control, the reading log is a thread you append to with an inline composer, and
|
||||||
|
the recommendation has an explicit "who / why" capture form. This is the
|
||||||
|
drill-in view.
|
||||||
|
|
||||||
|
**Recommended composition:** hybrid. Direction A as the home/browse surface,
|
||||||
|
Direction B as the detail view when a book is selected. Agenda answers "what now,"
|
||||||
|
card answers "where am I with this one." Not locked in — building one alone is
|
||||||
|
fine.
|
||||||
|
|
||||||
|
## Open decisions
|
||||||
|
|
||||||
|
- **Glue language + web framework — undecided.** Possibly elisp end-to-end
|
||||||
|
(Emacs serves the UI too), possibly an external thin client shelling out to
|
||||||
|
emacsclient. Leave this unbound; don't hardwire a stack.
|
||||||
|
- **Read transport:** temp-file hop vs `simple-httpd` endpoint in Emacs.
|
||||||
|
- **Per-note format:** book-level `:FORMAT:` is the default. If a book is regularly
|
||||||
|
consumed across media (hardcover at home, audio on a commute), format may need
|
||||||
|
to move to per-note. Single-value for now.
|
||||||
|
- **Hybrid vs single UI direction** — see UI section.
|
||||||
|
- **Search index:** deferred. Not now.
|
||||||
|
|
||||||
|
## Out of scope (for now)
|
||||||
|
|
||||||
|
- Recommender ranking / "hit rate" analytics.
|
||||||
|
- Full-text search across notes.
|
||||||
|
- Any external book-metadata API (covers, ISBN lookup) — placeholder cover blocks
|
||||||
|
in the mockups are decoration only.
|
||||||
@@ -57,6 +57,86 @@ Returns the rendered string."
|
|||||||
(setq s (replace-regexp-in-string "\"" """ s))
|
(setq s (replace-regexp-in-string "\"" """ s))
|
||||||
s)))
|
s)))
|
||||||
|
|
||||||
|
(defun spine-index-model (books &optional selected-id)
|
||||||
|
"Build the view model ht for templates/index.mustache from BOOKS.
|
||||||
|
SELECTED-ID expands the matching book's detail section."
|
||||||
|
(let* ((status-labels
|
||||||
|
'(("WANT" . "on deck")
|
||||||
|
("READING" . "reading")
|
||||||
|
("READ" . "read")
|
||||||
|
("ABANDONED" . "abandoned")))
|
||||||
|
(format-icons
|
||||||
|
'(("hardcover" . "ti-book")
|
||||||
|
("ebook" . "ti-device-tablet")
|
||||||
|
("audiobook" . "ti-headphones")))
|
||||||
|
(order '("WANT" "READING" "READ" "ABANDONED"))
|
||||||
|
(grouped (make-hash-table :test 'equal))
|
||||||
|
(total (length books))
|
||||||
|
(reading-count 0))
|
||||||
|
(dolist (book books)
|
||||||
|
(when (equal "READING" (plist-get book :status))
|
||||||
|
(cl-incf reading-count)))
|
||||||
|
(dolist (book books)
|
||||||
|
(let ((status (or (plist-get book :status) "WANT")))
|
||||||
|
(push book (gethash status grouped))))
|
||||||
|
(let ((groups nil))
|
||||||
|
(dolist (status order)
|
||||||
|
(let ((group-books (nreverse (gethash status grouped))))
|
||||||
|
(when group-books
|
||||||
|
(let ((book-models nil))
|
||||||
|
(dolist (book group-books)
|
||||||
|
(let* ((id (plist-get book :id))
|
||||||
|
(selected (equal id selected-id))
|
||||||
|
(fmt (plist-get book :format))
|
||||||
|
(rating (plist-get book :rating))
|
||||||
|
(notes (plist-get book :notes))
|
||||||
|
(status (or (plist-get book :status) "WANT"))
|
||||||
|
(model
|
||||||
|
(ht
|
||||||
|
("format_icon"
|
||||||
|
(or (cdr (assoc fmt format-icons)) ""))
|
||||||
|
("status_class" (downcase status))
|
||||||
|
("status_label" (downcase status))
|
||||||
|
("title" (plist-get book :title))
|
||||||
|
("author" (or (plist-get book :author) ""))
|
||||||
|
("tags_inline"
|
||||||
|
(let ((tags (plist-get book :tags)))
|
||||||
|
(when tags
|
||||||
|
(mapconcat (lambda (tag) (concat ":" tag ":"))
|
||||||
|
tags " "))))
|
||||||
|
("rec_via" (plist-get book :rec_by))
|
||||||
|
("rating_display"
|
||||||
|
(when (and rating (> (length rating) 0))
|
||||||
|
(make-string (string-to-number rating) ?\u2605)))
|
||||||
|
("selected" selected)
|
||||||
|
("detail"
|
||||||
|
(when selected
|
||||||
|
(ht
|
||||||
|
("meta"
|
||||||
|
(let ((added (plist-get book :added))
|
||||||
|
(fmt (plist-get book :format)))
|
||||||
|
(cond
|
||||||
|
((and added fmt)
|
||||||
|
(format "started %s · %s" added fmt))
|
||||||
|
(added (format "started %s" added))
|
||||||
|
(fmt (format "format: %s" fmt))
|
||||||
|
(t ""))))
|
||||||
|
("notes"
|
||||||
|
(when notes
|
||||||
|
(cl-loop for (date text) in notes
|
||||||
|
collect (ht
|
||||||
|
("date" (format "[%s]" date))
|
||||||
|
("text" text)))))))))))
|
||||||
|
(push model book-models)))
|
||||||
|
(push (ht ("label" (cdr (assoc status status-labels)))
|
||||||
|
("count" (length group-books))
|
||||||
|
("books" (nreverse book-models)))
|
||||||
|
groups)))))
|
||||||
|
(ht ("app_title" "spine")
|
||||||
|
("total_books" total)
|
||||||
|
("reading_count" reading-count)
|
||||||
|
("groups" (nreverse groups))))))
|
||||||
|
|
||||||
(defun spine--extract-notes (hl-begin hl-end)
|
(defun spine--extract-notes (hl-begin hl-end)
|
||||||
"Extract reading notes from headline body between HL-BEGIN and HL-END.
|
"Extract reading notes from headline body between HL-BEGIN and HL-END.
|
||||||
Returns list of (date-string text-string) pairs."
|
Returns list of (date-string text-string) pairs."
|
||||||
@@ -120,129 +200,6 @@ Returns nil if the file is missing or cannot be parsed."
|
|||||||
spine-org-file (error-message-string err))
|
spine-org-file (error-message-string err))
|
||||||
nil))))
|
nil))))
|
||||||
|
|
||||||
(defun spine-render-index (books &optional selected-id)
|
|
||||||
"Render the index page HTML for BOOKS, expanding the book matching SELECTED-ID."
|
|
||||||
(let ((order '("WANT" "READING" "READ" "ABANDONED"))
|
|
||||||
(grouped (make-hash-table :test 'equal))
|
|
||||||
(total 0)
|
|
||||||
(reading-count 0))
|
|
||||||
;; Count totals
|
|
||||||
(dolist (book books)
|
|
||||||
(cl-incf total)
|
|
||||||
(when (equal "READING" (plist-get book :status))
|
|
||||||
(cl-incf reading-count)))
|
|
||||||
;; Group books by status
|
|
||||||
(dolist (book books)
|
|
||||||
(let* ((status (or (plist-get book :status) "WANT")))
|
|
||||||
(push book (gethash status grouped))))
|
|
||||||
;; Render
|
|
||||||
(with-temp-buffer
|
|
||||||
(insert "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n")
|
|
||||||
(insert "<meta charset=\"utf-8\">\n")
|
|
||||||
(insert "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n")
|
|
||||||
(insert "<title>Spine</title>\n")
|
|
||||||
(insert "<link rel=\"stylesheet\""
|
|
||||||
" href=\"https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css\">\n")
|
|
||||||
(insert "<style>\n")
|
|
||||||
(insert ".spine-row{display:flex;align-items:center;gap:0.75rem;")
|
|
||||||
(insert "padding:0.4rem 0;border-bottom:1px solid var(--pico-muted-border-color)}\n")
|
|
||||||
(insert ".spine-row:last-child{border-bottom:none}\n")
|
|
||||||
(insert ".spine-selected{background:var(--pico-secondary-background)}\n")
|
|
||||||
(insert ".spine-glyph{width:1.5rem;text-align:center;flex-shrink:0}\n")
|
|
||||||
(insert ".spine-title{flex:1;min-width:0}\n")
|
|
||||||
(insert ".spine-trail{font-size:0.85em;color:var(--pico-muted-color)}\n")
|
|
||||||
(insert ".spine-tags{font-size:0.85em;color:var(--pico-muted-color)}\n")
|
|
||||||
(insert ".spine-detail{margin:0 0 0 2rem;border-left:2px solid var(--pico-primary)}\n")
|
|
||||||
(insert ".spine-detail p{margin-bottom:0.3rem}\n")
|
|
||||||
(insert ".spine-detail footer{margin-top:0.5rem}\n")
|
|
||||||
(insert "mark.want{background:var(--pico-mark-background);"
|
|
||||||
"color:var(--pico-mark-color)}\n")
|
|
||||||
(insert "mark.reading{background:#faeeda;color:#854f0b}\n")
|
|
||||||
(insert "mark.read{background:#e1f5ee;color:#085041}\n")
|
|
||||||
(insert "mark.abandoned{background:var(--pico-muted-color);"
|
|
||||||
"color:var(--pico-background-color)}\n")
|
|
||||||
(insert "article{margin-bottom:0}\n")
|
|
||||||
(insert "article header{padding-bottom:0.5rem;margin-bottom:0.5rem}\n")
|
|
||||||
(insert "</style>\n")
|
|
||||||
(insert "</head>\n<body>\n")
|
|
||||||
(insert "<main class=\"container\">\n")
|
|
||||||
(insert "<article>\n")
|
|
||||||
(insert (format "<header><strong>spine</strong>"
|
|
||||||
"<small>%d books · %d reading</small></header>\n"
|
|
||||||
total reading-count))
|
|
||||||
;; Each group in order
|
|
||||||
(dolist (status order)
|
|
||||||
(let ((group-books (nreverse (gethash status grouped))))
|
|
||||||
(when group-books
|
|
||||||
(insert (format "<hgroup><h2>%s</h2><small>%d</small></hgroup>\n"
|
|
||||||
status (length group-books)))
|
|
||||||
(dolist (book group-books)
|
|
||||||
(let ((selected (equal (plist-get book :id) selected-id)))
|
|
||||||
(insert (format "<div class=\"spine-row%s\">\n"
|
|
||||||
(if selected " spine-selected" "")))
|
|
||||||
;; Format glyph (emoji)
|
|
||||||
(let ((fmt (plist-get book :format)))
|
|
||||||
(insert (format "<span class=\"spine-glyph\">%s</span>\n"
|
|
||||||
(cond ((equal fmt "hardcover") "📖")
|
|
||||||
((equal fmt "ebook") "📱")
|
|
||||||
((equal fmt "audiobook") "🎧")
|
|
||||||
(t "")))))
|
|
||||||
;; Title + author
|
|
||||||
(insert "<span class=\"spine-title\">")
|
|
||||||
(insert (spine--h (plist-get book :title)))
|
|
||||||
(let ((author (plist-get book :author)))
|
|
||||||
(when author
|
|
||||||
(insert (format " <small>%s</small>"
|
|
||||||
(spine--h author)))))
|
|
||||||
(insert "</span>\n")
|
|
||||||
;; Tags
|
|
||||||
(let ((tags (plist-get book :tags)))
|
|
||||||
(when tags
|
|
||||||
(insert (format "<small class=\"spine-tags\">%s</small>\n"
|
|
||||||
(mapconcat (lambda (tag) (concat ":" tag ":"))
|
|
||||||
tags " ")))))
|
|
||||||
;; Recommendation trail
|
|
||||||
(let ((rec-by (plist-get book :rec_by))
|
|
||||||
(rec-note (plist-get book :rec_note)))
|
|
||||||
(when (and rec-by (> (length rec-by) 0))
|
|
||||||
(insert
|
|
||||||
(format "<span class=\"spine-trail\">%s%s</span>\n"
|
|
||||||
(spine--h rec-by)
|
|
||||||
(if (and rec-note (> (length rec-note) 0))
|
|
||||||
(concat ": " (spine--h rec-note))
|
|
||||||
"")))))
|
|
||||||
;; Status pill
|
|
||||||
(let ((status (or (plist-get book :status) "WANT")))
|
|
||||||
(insert (format "<mark class=\"%s\">%s</mark>\n"
|
|
||||||
(downcase status) (downcase status))))
|
|
||||||
;; Expanded detail
|
|
||||||
(when selected
|
|
||||||
(insert "<blockquote class=\"spine-detail\">\n")
|
|
||||||
(dolist (note (plist-get book :notes))
|
|
||||||
(insert (format "<p><small>[%s]</small> %s</p>\n"
|
|
||||||
(spine--h (car note))
|
|
||||||
(spine--h (cadr note)))))
|
|
||||||
(let ((parts nil))
|
|
||||||
(let ((rating (plist-get book :rating)))
|
|
||||||
(when (and rating (> (length rating) 0))
|
|
||||||
(push (format "Rating: %s/5" (spine--h rating)) parts)))
|
|
||||||
(let ((isbn (plist-get book :isbn)))
|
|
||||||
(when (and isbn (> (length isbn) 0))
|
|
||||||
(push (format "ISBN: %s" (spine--h isbn)) parts)))
|
|
||||||
(let ((fmt (plist-get book :format)))
|
|
||||||
(when (and fmt (> (length fmt) 0))
|
|
||||||
(push (format "Format: %s" (spine--h fmt)) parts)))
|
|
||||||
(let ((added (plist-get book :added)))
|
|
||||||
(when (and added (> (length added) 0))
|
|
||||||
(push (format "Added: %s" (spine--h added)) parts)))
|
|
||||||
(when parts
|
|
||||||
(insert (format "<footer>%s</footer>\n"
|
|
||||||
(mapconcat #'identity (nreverse parts) " · ")))))
|
|
||||||
(insert "</blockquote>\n"))
|
|
||||||
(insert "</div>\n"))))))
|
|
||||||
(insert "</article>\n")
|
|
||||||
(insert "</main>\n</body>\n</html>\n")
|
|
||||||
(buffer-string))))
|
|
||||||
|
|
||||||
(defun spine-render-empty-state ()
|
(defun spine-render-empty-state ()
|
||||||
"Render a page indicating no books file was found."
|
"Render a page indicating no books file was found."
|
||||||
@@ -269,7 +226,8 @@ Set by test harnesses that only need the model functions.")
|
|||||||
(defservlet index text/html (path query request)
|
(defservlet index text/html (path query request)
|
||||||
(let ((books (spine-books)))
|
(let ((books (spine-books)))
|
||||||
(if books
|
(if books
|
||||||
(insert (spine-render-index books (cadr (assoc "id" query))))
|
(insert (spine-render "index.mustache"
|
||||||
|
(spine-index-model books (cadr (assoc "id" query)))))
|
||||||
(insert (spine-render-empty-state)))))
|
(insert (spine-render-empty-state)))))
|
||||||
|
|
||||||
(defun httpd/ (proc uri-path query request)
|
(defun httpd/ (proc uri-path query request)
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<title>Spine — Direction A (agenda)</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css" />
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
|
||||||
|
<style>
|
||||||
|
.agenda { font-family: var(--pico-font-family-monospace); padding: 0; overflow: hidden; }
|
||||||
|
.agenda > header, .agenda > footer { margin: 0; padding: .6rem .9rem; font-size: .85rem; }
|
||||||
|
.agenda > header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.group { padding: .65rem .9rem .2rem; font-size: .7rem; letter-spacing: .04em; color: var(--pico-muted-color); }
|
||||||
|
.row { display: flex; align-items: center; gap: .6rem; padding: .3rem .9rem; font-size: .85rem; }
|
||||||
|
.row.selected { background: var(--pico-card-sectioning-background-color); border-left: 2px solid var(--pico-primary); }
|
||||||
|
.row.selected .title { font-weight: 500; }
|
||||||
|
.row.selected .glyph { color: var(--pico-color); }
|
||||||
|
.glyph { width: 1rem; color: var(--pico-muted-color); flex-shrink: 0; }
|
||||||
|
.title { flex: 1; min-width: 0; }
|
||||||
|
.muted { color: var(--pico-muted-color); }
|
||||||
|
.tags { color: var(--pico-primary); font-size: .8rem; }
|
||||||
|
.trail { color: var(--pico-muted-color); font-size: .8rem; }
|
||||||
|
.pill { padding: .05rem .45rem; border-radius: var(--pico-border-radius); font-size: .7rem; font-weight: 500; }
|
||||||
|
.pill.want { background: #e6f1fb; color: #0c447c; }
|
||||||
|
.pill.reading { background: #faeeda; color: #854f0b; }
|
||||||
|
.pill.read { background: #e1f5ee; color: #085041; }
|
||||||
|
.detail { padding: .5rem .9rem .7rem 2rem; background: var(--pico-card-sectioning-background-color);
|
||||||
|
border-left: 2px solid var(--pico-primary); font-size: .8rem; }
|
||||||
|
.logline { display: flex; gap: .5rem; margin-bottom: .3rem; }
|
||||||
|
.logline:last-child { margin-bottom: 0; }
|
||||||
|
.logdate { color: var(--pico-muted-color); white-space: nowrap; }
|
||||||
|
.endpad { height: .4rem; }
|
||||||
|
.minibuffer { display: flex; gap: .5rem; align-items: center; }
|
||||||
|
.minibuffer .text { border-left: 2px solid var(--pico-primary); padding-left: .4rem; }
|
||||||
|
.caret { opacity: .5; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.pill.want { background: #14304a; color: #b5d4f4; }
|
||||||
|
.pill.reading { background: #3a2c12; color: #fac775; }
|
||||||
|
.pill.read { background: #103a30; color: #9fe1cb; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{!
|
||||||
|
View model for this template:
|
||||||
|
{
|
||||||
|
app_title: string,
|
||||||
|
total_books: number,
|
||||||
|
reading_count: number,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
label: string, e.g. "on deck"
|
||||||
|
count: number,
|
||||||
|
books: [
|
||||||
|
{
|
||||||
|
format_icon: string, Tabler class, e.g. "ti-headphones" (data layer maps FORMAT -> icon)
|
||||||
|
status_class: string, "want" | "reading" | "read" (maps Org TODO state -> pill style)
|
||||||
|
status_label: string, text shown in the pill
|
||||||
|
title: string,
|
||||||
|
author: string,
|
||||||
|
tags_inline: string?, optional, e.g. ":scifi:literary:"
|
||||||
|
rec_via: string?, optional, REC_BY -> rendered as "via {name}"
|
||||||
|
rating_display: string?, optional, e.g. "★★★★☆"
|
||||||
|
selected: boolean?, marks the in-focus row (expands detail)
|
||||||
|
detail: { optional; render when a row is expanded
|
||||||
|
meta: string, e.g. "started [2026-02-20] · hardcover · 62% · rating —"
|
||||||
|
notes: [ { date: string, text: string } ] reading log
|
||||||
|
}?
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
minibuffer: { command: string, text: string }? optional capture line
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<main class="container">
|
||||||
|
<article class="agenda" style="max-width: 720px; margin-inline: auto;">
|
||||||
|
<header>
|
||||||
|
<strong>{{app_title}}</strong>
|
||||||
|
<span class="muted">{{total_books}} books · {{reading_count}} reading</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{#groups}}
|
||||||
|
<div class="group">{{label}} · {{count}}</div>
|
||||||
|
{{#books}}
|
||||||
|
<div class="row{{#selected}} selected{{/selected}}">
|
||||||
|
<i class="ti {{format_icon}} glyph" aria-hidden="true"></i>
|
||||||
|
<span class="pill {{status_class}}">{{status_label}}</span>
|
||||||
|
<span class="title">{{title}} <span class="muted">· {{author}}</span></span>
|
||||||
|
{{#tags_inline}}<span class="tags">{{tags_inline}}</span>{{/tags_inline}}
|
||||||
|
{{#rec_via}}<span class="trail">via {{rec_via}}</span>{{/rec_via}}
|
||||||
|
{{#rating_display}}<span class="trail">{{rating_display}}</span>{{/rating_display}}
|
||||||
|
</div>
|
||||||
|
{{#detail}}
|
||||||
|
<div class="detail">
|
||||||
|
<div class="muted" style="margin-bottom: .5rem;">{{meta}}</div>
|
||||||
|
{{#notes}}
|
||||||
|
<div class="logline"><span class="logdate">{{date}}</span><span>{{text}}</span></div>
|
||||||
|
{{/notes}}
|
||||||
|
</div>
|
||||||
|
{{/detail}}
|
||||||
|
{{/books}}
|
||||||
|
{{/groups}}
|
||||||
|
<div class="endpad"></div>
|
||||||
|
|
||||||
|
{{#minibuffer}}
|
||||||
|
<footer class="minibuffer">
|
||||||
|
<span class="muted">M-x</span>
|
||||||
|
<span>{{command}}</span>
|
||||||
|
<span class="text">{{text}}<span class="caret">▏</span></span>
|
||||||
|
</footer>
|
||||||
|
{{/minibuffer}}
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user