refactor: redesign book detail page to match journal mockup styling
- Replace Pico CSS with index-style custom CSS variables - Redesign layout: back breadcrumb, info table, shelf/status selects - Update spine-book-model: add shelf_name, format_icon/label, tags_display, date display fields, rating display, status/shelf options - Remove unused fields: meta, progress_label, formats array - Update spine-book-model-test.el: 23 tests cover all new fields - Add --text-danger CSS variable for remove button
This commit is contained in:
@@ -69,12 +69,17 @@ Returns the rendered string."
|
|||||||
("audiobook" "ti-headphones" "Audiobook"))
|
("audiobook" "ti-headphones" "Audiobook"))
|
||||||
"Alist of (format-key icon label) for book format display.")
|
"Alist of (format-key icon label) for book format display.")
|
||||||
|
|
||||||
(defun spine--format-date (org-date-str)
|
(defconst spine--status-defs
|
||||||
"Format Org date \"[2026-02-20]\" to \"20 Feb\"."
|
'(("want" "Want") ("reading" "Reading") ("read" "Read"))
|
||||||
|
"Alist of (key label) for reading status options.")
|
||||||
|
|
||||||
|
(defun spine--format-date (org-date-str &optional include-year)
|
||||||
|
"Format Org date \"[2026-02-20]\" to \"20 Feb\".
|
||||||
|
With optional INCLUDE-YEAR, returns \"20 Feb 2026\"."
|
||||||
(when (and org-date-str (> (length org-date-str) 0))
|
(when (and org-date-str (> (length org-date-str) 0))
|
||||||
(let ((clean (replace-regexp-in-string "\\[\\|\\]" "" org-date-str)))
|
(let ((clean (replace-regexp-in-string "\\[\\|\\]" "" org-date-str)))
|
||||||
(condition-case nil
|
(condition-case nil
|
||||||
(format-time-string "%e %b" (date-to-time clean))
|
(format-time-string (if include-year "%e %b %Y" "%e %b") (date-to-time clean))
|
||||||
(error org-date-str)))))
|
(error org-date-str)))))
|
||||||
|
|
||||||
(defun spine--parse-title-status (title)
|
(defun spine--parse-title-status (title)
|
||||||
@@ -92,33 +97,35 @@ TITLE may have a leading TODO prefix like \"WANT \", \"READING \", \"READ \"."
|
|||||||
(defun spine-book-model (book)
|
(defun spine-book-model (book)
|
||||||
"Build view model `ht` for a single BOOK plist, for templates/book.mustache."
|
"Build view model `ht` for a single BOOK plist, for templates/book.mustache."
|
||||||
(let* ((id (plist-get book :id))
|
(let* ((id (plist-get book :id))
|
||||||
(title (or (plist-get book :title) ""))
|
(raw-title (or (plist-get book :title) ""))
|
||||||
(author (or (plist-get book :author) ""))
|
(author (or (plist-get book :author) ""))
|
||||||
(fmt (plist-get book :format))
|
(fmt (plist-get book :format))
|
||||||
(added (plist-get book :added))
|
(added (plist-get book :added))
|
||||||
(notes (plist-get book :notes))
|
(notes (plist-get book :notes))
|
||||||
(rec-by (plist-get book :rec_by))
|
(rec-by (plist-get book :rec_by))
|
||||||
(rec-note (plist-get book :rec_note))
|
(rec-note (plist-get book :rec_note))
|
||||||
(title-info (spine--parse-title-status title))
|
(tags (plist-get book :tags))
|
||||||
|
(rating (plist-get book :rating))
|
||||||
|
(shelf-name (plist-get book :shelf))
|
||||||
|
(title-info (spine--parse-title-status raw-title))
|
||||||
(clean-title (nth 0 title-info))
|
(clean-title (nth 0 title-info))
|
||||||
(status-class (nth 1 title-info))
|
(status-class (nth 1 title-info))
|
||||||
(status-label (nth 2 title-info))
|
(status-label (nth 2 title-info))
|
||||||
(meta (cond
|
(format-def (and fmt (assoc fmt spine--format-defs)))
|
||||||
((and added fmt)
|
(format-icon (if format-def (nth 1 format-def) "ti-book"))
|
||||||
(format "started %s \302\267 %s" (spine--format-date added) fmt))
|
(format-label (if format-def (nth 2 format-def) ""))
|
||||||
(added (format "started %s" (spine--format-date added)))
|
(rating-display
|
||||||
(fmt (format "format: %s" fmt))
|
(cond
|
||||||
(t "")))
|
((and rating (> (length rating) 0))
|
||||||
(formats (mapcar
|
(make-string (string-to-number rating) ?\u2605))
|
||||||
(lambda (fd)
|
((equal status-class "read") "rated —")
|
||||||
(ht ("icon" (nth 1 fd))
|
(t "rate when finished")))
|
||||||
("label" (nth 2 fd))
|
(has-rating (and rating (> (length rating) 0)))
|
||||||
("active" (and fmt (equal (car fd) fmt)))))
|
|
||||||
spine--format-defs))
|
|
||||||
(notes-model
|
(notes-model
|
||||||
(mapcar (lambda (n)
|
(mapcar (lambda (n)
|
||||||
(ht ("date" (car n))
|
(ht ("date" (car n))
|
||||||
("text" (cadr n))))
|
("text" (cadr n))
|
||||||
|
("edit_url" (format "/edit?id=%s&action=note&id=%s" id (car n)))))
|
||||||
notes))
|
notes))
|
||||||
(recommendation
|
(recommendation
|
||||||
(when (and rec-by (> (length rec-by) 0))
|
(when (and rec-by (> (length rec-by) 0))
|
||||||
@@ -130,16 +137,36 @@ TITLE may have a leading TODO prefix like \"WANT \", \"READING \", \"READ \"."
|
|||||||
(split-string rec-by " +" t) "")))
|
(split-string rec-by " +" t) "")))
|
||||||
(ht ("initials" initials)
|
(ht ("initials" initials)
|
||||||
("by" rec-by)
|
("by" rec-by)
|
||||||
("note" (or rec-note "")))))))
|
("note" (or rec-note ""))))))
|
||||||
|
(status-options
|
||||||
|
(mapcar (lambda (sd)
|
||||||
|
(ht ("name" (cadr sd))
|
||||||
|
("selected" (equal (car sd) status-class))))
|
||||||
|
spine--status-defs))
|
||||||
|
(shelf-options
|
||||||
|
(let ((shelves (or (spine-shelves) '())))
|
||||||
|
(mapcar (lambda (s)
|
||||||
|
(ht ("name" s)
|
||||||
|
("selected" (and shelf-name (equal s shelf-name)))))
|
||||||
|
shelves))))
|
||||||
(ht ("cover_icon" "ti-book")
|
(ht ("cover_icon" "ti-book")
|
||||||
("id" id)
|
("id" id)
|
||||||
("title" clean-title)
|
("title" clean-title)
|
||||||
("author" author)
|
("author" author)
|
||||||
("status_class" status-class)
|
("status_class" status-class)
|
||||||
("status_label" status-label)
|
("status_label" status-label)
|
||||||
("meta" meta)
|
("shelf_name" (or shelf-name ""))
|
||||||
("progress_label" "")
|
("format_icon" format-icon)
|
||||||
("formats" formats)
|
("format_label" format-label)
|
||||||
|
("tags_display"
|
||||||
|
(if tags (mapconcat (lambda (tag) (format "<span class=\"tag\">%s</span>" tag)) tags " ") ""))
|
||||||
|
("added_display" (if added (spine--format-date added t) ""))
|
||||||
|
("started_display" (if added (spine--format-date added t) ""))
|
||||||
|
("finished_display" "—")
|
||||||
|
("rating_display" rating-display)
|
||||||
|
("has_rating" has-rating)
|
||||||
|
("status_options" status-options)
|
||||||
|
("shelf_options" shelf-options)
|
||||||
("notes" notes-model)
|
("notes" notes-model)
|
||||||
("recommendation" recommendation)
|
("recommendation" recommendation)
|
||||||
("back_url" (format "/index?id=%s" id)))))
|
("back_url" (format "/index?id=%s" id)))))
|
||||||
|
|||||||
+135
-78
@@ -5,100 +5,157 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
<title>Spine — {{title}}</title>
|
<title>Spine — {{title}}</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" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabler-icons/3.31.0/iconfont/tabler-icons.min.css" />
|
||||||
<style>
|
<style>
|
||||||
.book { max-width: 560px; margin-inline: auto; }
|
:root {
|
||||||
.book hgroup { margin-bottom: .4rem; }
|
--bg-page: #efeee9; --bg-primary: #ffffff; --bg-secondary: #f5f4ef;
|
||||||
.book hgroup h3 { margin-bottom: .1rem; }
|
--text-primary: #1d1d1b; --text-secondary: #5f5e5a; --text-tertiary: #8a8980;
|
||||||
.book hgroup p { color: var(--pico-muted-color); margin: 0; }
|
--bg-info: #e6f1fb; --text-info: #0c447c; --border-info: #378add;
|
||||||
.cover { width: 44px; height: 60px; border-radius: var(--pico-border-radius);
|
--bg-warning: #faeeda; --text-warning: #854f0b;
|
||||||
background: var(--pico-primary-background); color: var(--pico-primary-inverse);
|
--bg-success: #e1f5ee; --text-success: #085041;
|
||||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
--text-danger: #c62828;
|
||||||
.pill { padding: .1rem .5rem; border-radius: var(--pico-border-radius); font-size: .75rem; font-weight: 500; }
|
--border-tertiary: rgba(0,0,0,0.12); --border-secondary: rgba(0,0,0,0.22);
|
||||||
.pill.want { background: #e6f1fb; color: #0c447c; }
|
--radius-md: 8px; --radius-lg: 12px;
|
||||||
.pill.reading { background: #faeeda; color: #854f0b; }
|
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
.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; }
|
|
||||||
}
|
}
|
||||||
|
@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;
|
||||||
|
--text-danger: #ef9a9a;
|
||||||
|
--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: 600px; 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: 48px; height: 66px; border-radius: var(--radius-md); background: var(--bg-info);
|
||||||
|
display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||||
|
.pill { padding: 2px 9px; border-radius: var(--radius-md); font-size: 12px; }
|
||||||
|
.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); }
|
||||||
|
.tag { background: var(--bg-secondary); padding: 1px 7px; border-radius: var(--radius-md); margin-left: 4px; font-size: 12px; }
|
||||||
|
.tt { font-size: 12px; color: var(--text-tertiary); }
|
||||||
|
input, select, button { font-family: var(--sans); font-size: 13px; }
|
||||||
|
input, select { height: 34px; padding: 0 8px; border: 0.5px solid var(--border-secondary);
|
||||||
|
border-radius: var(--radius-md); background: var(--bg-primary); color: var(--text-primary); width: 100%; }
|
||||||
|
input::placeholder { color: var(--text-tertiary); }
|
||||||
|
button { height: auto; padding: 2px 4px; border: none; background: transparent; color: var(--text-secondary); cursor: pointer; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="container">
|
<div class="wrap">
|
||||||
<article class="book">
|
<div class="surface">
|
||||||
<p><a href="{{back_url}}" style="font-size:.85rem;">← Back to shelf</a></p>
|
<div class="card">
|
||||||
|
|
||||||
<div style="display: flex; gap: 14px; align-items: flex-start; margin-bottom: 1rem;">
|
<div style="display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-tertiary); margin-bottom: 14px;">
|
||||||
<div class="cover"><i class="ti {{cover_icon}}" style="font-size: 22px;" aria-hidden="true"></i></div>
|
<a href="{{back_url}}" style="color: var(--text-tertiary); text-decoration: none; display: flex; align-items: center; gap: 8px;">
|
||||||
<div style="flex: 1; min-width: 0;">
|
<i class="ti ti-arrow-left" style="font-size: 16px;" aria-hidden="true"></i>
|
||||||
<hgroup>
|
<i class="ti ti-folder" style="font-size: 15px;" aria-hidden="true"></i>
|
||||||
<h3>{{title}}</h3>
|
<span>{{shelf_name}}</span>
|
||||||
<p>{{author}}</p>
|
</a>
|
||||||
</hgroup>
|
|
||||||
{{#status_class}}
|
|
||||||
<span class="pill {{status_class}}">{{status_label}}</span>
|
|
||||||
{{/status_class}}
|
|
||||||
{{#meta}}
|
|
||||||
<span class="muted" style="color: var(--pico-muted-color); font-size: .85rem; margin-left: .5rem;">{{meta}}</span>
|
|
||||||
{{/meta}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="formats" role="group">
|
<div style="display: flex; gap: 14px; align-items: flex-start; margin-bottom: 18px;">
|
||||||
{{#formats}}
|
<div class="cover"><i class="ti {{cover_icon}}" style="font-size: 24px; color: var(--text-info);" aria-hidden="true"></i></div>
|
||||||
<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>
|
<div style="flex: 1; min-width: 0;">
|
||||||
{{/formats}}
|
<p style="font-weight: 500; font-size: 18px; margin: 0 0 2px;">{{title}}</p>
|
||||||
</div>
|
<p style="font-size: 14px; color: var(--text-secondary); margin: 0 0 8px;">{{author}}</p>
|
||||||
|
{{#status_class}}
|
||||||
|
<span class="pill {{status_class}}">{{status_label}}</span>
|
||||||
|
{{/status_class}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 18px;">
|
||||||
|
<div>
|
||||||
|
<label style="font-size: 13px; color: var(--text-secondary); display: block; margin-bottom: 4px;">
|
||||||
|
<i class="ti ti-folder" style="font-size: 14px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Shelf
|
||||||
|
</label>
|
||||||
|
<select>
|
||||||
|
{{#shelf_options}}
|
||||||
|
<option{{#selected}} selected{{/selected}}>{{name}}</option>
|
||||||
|
{{/shelf_options}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size: 13px; color: var(--text-secondary); display: block; margin-bottom: 4px;">
|
||||||
|
<i class="ti ti-bookmark" style="font-size: 14px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Status
|
||||||
|
</label>
|
||||||
|
<select>
|
||||||
|
{{#status_options}}
|
||||||
|
<option{{#selected}} selected{{/selected}}>{{name}}</option>
|
||||||
|
{{/status_options}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border-top: 0.5px solid var(--border-tertiary); padding-top: 14px; margin-bottom: 16px;">
|
||||||
|
<table style="width: 100%; font-size: 13px; border-collapse: collapse;">
|
||||||
|
{{#format_label}}
|
||||||
|
<tr><td style="color: var(--text-secondary); padding: 4px 0; width: 38%;">Format</td>
|
||||||
|
<td style="text-align: right; padding: 4px 0;"><i class="ti {{format_icon}}" style="font-size: 15px; vertical-align: -2px; margin-right: 5px; color: var(--text-tertiary);" aria-hidden="true"></i>{{format_label}}</td></tr>
|
||||||
|
{{/format_label}}
|
||||||
|
{{#tags_display}}
|
||||||
|
<tr><td style="color: var(--text-secondary); padding: 4px 0;">Tags</td>
|
||||||
|
<td style="text-align: right; padding: 4px 0;">{{{tags_display}}}</td></tr>
|
||||||
|
{{/tags_display}}
|
||||||
|
{{#added_display}}
|
||||||
|
<tr><td style="color: var(--text-secondary); padding: 4px 0;">Added</td>
|
||||||
|
<td style="text-align: right; padding: 4px 0;">{{added_display}}</td></tr>
|
||||||
|
{{/added_display}}
|
||||||
|
{{#started_display}}
|
||||||
|
<tr><td style="color: var(--text-secondary); padding: 4px 0;">Started</td>
|
||||||
|
<td style="text-align: right; padding: 4px 0;">{{started_display}}</td></tr>
|
||||||
|
{{/started_display}}
|
||||||
|
<tr><td style="color: var(--text-secondary); padding: 4px 0;">Finished</td>
|
||||||
|
<td style="text-align: right; padding: 4px 0;{{^finished_display}} color: var(--text-tertiary);{{/finished_display}}">{{finished_display}}</td></tr>
|
||||||
|
<tr><td style="color: var(--text-secondary); padding: 4px 0;">Rating</td>
|
||||||
|
<td style="text-align: right; padding: 4px 0;{{^has_rating}} color: var(--text-tertiary);{{/has_rating}}">{{rating_display}}</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{#notes}}
|
|
||||||
<p class="seclabel">Reading log</p>
|
|
||||||
<div class="thread">
|
|
||||||
{{#notes}}
|
{{#notes}}
|
||||||
<div class="entry">
|
<div style="border-top: 0.5px solid var(--border-tertiary); padding-top: 14px; margin-bottom: 16px;">
|
||||||
<div class="edate">{{date}}</div>
|
<p style="font-size: 13px; color: var(--text-tertiary); margin: 0 0 10px;">Reading log</p>
|
||||||
<div class="etext">{{text}}</div>
|
{{#notes}}
|
||||||
|
<div style="display: flex; gap: 10px; align-items: flex-start; margin-bottom: 10px;">
|
||||||
|
<span style="font-size: 12px; color: var(--text-tertiary); white-space: nowrap; padding-top: 1px;">{{date}}</span>
|
||||||
|
<span style="flex: 1; font-size: 14px; line-height: 1.5;">{{text}}</span>
|
||||||
|
</div>
|
||||||
|
{{/notes}}
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<input type="text" placeholder="Add a note…" style="flex: 1;" />
|
||||||
|
<button style="width: auto; padding: 0 12px; border: 0.5px solid var(--border-secondary); border-radius: var(--radius-md);"><i class="ti ti-plus" style="font-size: 15px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Log note</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{/notes}}
|
{{/notes}}
|
||||||
</div>
|
|
||||||
{{/notes}}
|
|
||||||
|
|
||||||
<div class="compose" role="group">
|
{{#recommendation}}
|
||||||
<input type="text" placeholder="Add a note…" />
|
<div style="border-top: 0.5px solid var(--border-tertiary); padding-top: 14px; margin-bottom: 4px;">
|
||||||
<button><i class="ti ti-plus" style="font-size: 15px; vertical-align: -2px; margin-right: 4px;" aria-hidden="true"></i>Log note</button>
|
<p style="font-size: 13px; color: var(--text-tertiary); margin: 0 0 10px;">On your radar</p>
|
||||||
</div>
|
<div style="display: flex; align-items: flex-start; gap: 10px;">
|
||||||
|
<div style="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;">{{initials}}</div>
|
||||||
|
<div style="font-size: 14px; line-height: 1.5;"><span style="font-weight: 500;">{{by}}</span><br />"{{note}}"</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/recommendation}}
|
||||||
|
|
||||||
<hr />
|
<div style="border-top: 0.5px solid var(--border-tertiary); padding-top: 12px; margin-top: 14px; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<button style="color: var(--text-danger); font-size: 13px;"><i class="ti ti-trash" style="font-size: 14px; vertical-align: -2px; margin-right: 3px;" aria-hidden="true"></i>Remove</button>
|
||||||
|
<button style="color: var(--text-secondary); font-size: 13px;"><i class="ti ti-external-link" style="font-size: 14px; vertical-align: -2px; margin-right: 3px;" aria-hidden="true"></i>Open in Emacs</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{#recommendation}}
|
|
||||||
<p class="seclabel">On your radar</p>
|
|
||||||
<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><br />"{{note}}"</div>
|
|
||||||
</div>
|
</div>
|
||||||
{{/recommendation}}
|
</div>
|
||||||
<div class="recform" role="group">
|
</div>
|
||||||
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+117
-42
@@ -15,19 +15,29 @@
|
|||||||
(cl-find "8c1e-uow" (spine-books)
|
(cl-find "8c1e-uow" (spine-books)
|
||||||
:key (lambda (b) (plist-get b :id))
|
:key (lambda (b) (plist-get b :id))
|
||||||
:test #'equal)
|
:test #'equal)
|
||||||
"Use of Weapons fixture.")
|
"Use of Weapons fixture (READING, audiobook, tags=scifi+literary, rec_by=Priya).")
|
||||||
|
|
||||||
(defconst spine-book-model-test--aj
|
(defconst spine-book-model-test--aj
|
||||||
(cl-find "3a1b-aj" (spine-books)
|
(cl-find "3a1b-aj" (spine-books)
|
||||||
:key (lambda (b) (plist-get b :id))
|
:key (lambda (b) (plist-get b :id))
|
||||||
:test #'equal)
|
:test #'equal)
|
||||||
"Ancillary Justice fixture.")
|
"Ancillary Justice fixture (READ, ebook, rating=5, tags=scifi).")
|
||||||
|
|
||||||
(defconst spine-book-model-test--pir
|
(defconst spine-book-model-test--pir
|
||||||
(cl-find "5e8d-pir" (spine-books)
|
(cl-find "5e8d-pir" (spine-books)
|
||||||
:key (lambda (b) (plist-get b :id))
|
:key (lambda (b) (plist-get b :id))
|
||||||
:test #'equal)
|
:test #'equal)
|
||||||
"Piranesi fixture.")
|
"Piranesi fixture (WANT, no format, no tags, no rec).")
|
||||||
|
|
||||||
|
(defconst spine-book-model-test--tcm
|
||||||
|
(let* ((books (spine-books))
|
||||||
|
(b (cl-find "b2c1-tcm" books
|
||||||
|
:key (lambda (x) (plist-get x :id))
|
||||||
|
:test #'equal)))
|
||||||
|
b)
|
||||||
|
"The Checklist Manifesto fixture (no TODO, no format, no rec).")
|
||||||
|
|
||||||
|
;; --- Title & status ----------------------------------------------------
|
||||||
|
|
||||||
(ert-deftest spine-book-model-title-strips-todo ()
|
(ert-deftest spine-book-model-title-strips-todo ()
|
||||||
"Title has TODO prefix stripped."
|
"Title has TODO prefix stripped."
|
||||||
@@ -35,47 +45,123 @@
|
|||||||
(should (equal (ht-get model "title") "Use of Weapons"))))
|
(should (equal (ht-get model "title") "Use of Weapons"))))
|
||||||
|
|
||||||
(ert-deftest spine-book-model-status-reading ()
|
(ert-deftest spine-book-model-status-reading ()
|
||||||
"READING book has status_class reading."
|
"READING status_class and label."
|
||||||
(let ((model (spine-book-model spine-book-model-test--uow)))
|
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||||
(should (equal (ht-get model "status_class") "reading"))
|
(should (equal (ht-get model "status_class") "reading"))
|
||||||
(should (equal (ht-get model "status_label") "Reading"))))
|
(should (equal (ht-get model "status_label") "Reading"))))
|
||||||
|
|
||||||
(ert-deftest spine-book-model-status-read ()
|
(ert-deftest spine-book-model-status-read ()
|
||||||
"READ book has status_class read."
|
"READ status_class and label."
|
||||||
(let ((model (spine-book-model spine-book-model-test--aj)))
|
(let ((model (spine-book-model spine-book-model-test--aj)))
|
||||||
(should (equal (ht-get model "status_class") "read"))
|
(should (equal (ht-get model "status_class") "read"))
|
||||||
(should (equal (ht-get model "status_label") "Read"))))
|
(should (equal (ht-get model "status_label") "Read"))))
|
||||||
|
|
||||||
(ert-deftest spine-book-model-status-want ()
|
(ert-deftest spine-book-model-status-want ()
|
||||||
"WANT book has status_class want."
|
"WANT status_class and label."
|
||||||
(let ((model (spine-book-model spine-book-model-test--pir)))
|
(let ((model (spine-book-model spine-book-model-test--pir)))
|
||||||
(should (equal (ht-get model "status_class") "want"))
|
(should (equal (ht-get model "status_class") "want"))
|
||||||
(should (equal (ht-get model "status_label") "Want"))))
|
(should (equal (ht-get model "status_label") "Want"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-no-todo-prefix ()
|
||||||
|
"No TODO prefix shows title as-is and empty status."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--tcm)))
|
||||||
|
(should (equal (ht-get model "title") "The Checklist Manifesto"))
|
||||||
|
(should (equal (ht-get model "status_class") ""))
|
||||||
|
(should (equal (ht-get model "status_label") ""))))
|
||||||
|
|
||||||
|
;; --- Metadata fields ----------------------------------------------------
|
||||||
|
|
||||||
(ert-deftest spine-book-model-author ()
|
(ert-deftest spine-book-model-author ()
|
||||||
"Author is passed through."
|
"Author passed through."
|
||||||
(let ((model (spine-book-model spine-book-model-test--uow)))
|
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||||
(should (equal (ht-get model "author") "Iain M. Banks"))))
|
(should (equal (ht-get model "author") "Iain M. Banks"))))
|
||||||
|
|
||||||
(ert-deftest spine-book-model-meta-with-format-and-date ()
|
(ert-deftest spine-book-model-shelf-name ()
|
||||||
"Meta includes format and date when both present."
|
"Shelf_name passed through."
|
||||||
(let ((model (spine-book-model spine-book-model-test--uow)))
|
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||||
(should (string-match "started.*audiobook" (ht-get model "meta")))))
|
(should (equal (ht-get model "shelf_name") "Fiction"))))
|
||||||
|
|
||||||
(ert-deftest spine-book-model-format-active ()
|
(ert-deftest spine-book-model-format-fields ()
|
||||||
"Format list has correct active flag."
|
"Format icon and label match the book's format."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||||
|
(should (equal (ht-get model "format_icon") "ti-headphones"))
|
||||||
|
(should (equal (ht-get model "format_label") "Audiobook"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-format-none ()
|
||||||
|
"No format yields empty label and default icon."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--pir)))
|
||||||
|
(should (equal (ht-get model "format_icon") "ti-book"))
|
||||||
|
(should (equal (ht-get model "format_label") ""))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-tags-display ()
|
||||||
|
"Tags rendered as HTML badge spans."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||||
|
(let ((tags (ht-get model "tags_display")))
|
||||||
|
(should (string-match "scifi" tags))
|
||||||
|
(should (string-match "literary" tags)))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-no-tags ()
|
||||||
|
"No tags yields empty string."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--tcm)))
|
||||||
|
(should (equal (ht-get model "tags_display") ""))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-started-display ()
|
||||||
|
"started_display from ADDED property."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||||
|
(should (equal (ht-get model "started_display") "20 Feb 2026"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-added-display ()
|
||||||
|
"added_display from ADDED property."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||||
|
(should (equal (ht-get model "added_display") "20 Feb 2026"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-finished-default ()
|
||||||
|
"finished_display defaults to em-dash."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||||
|
(should (equal (ht-get model "finished_display") "—"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-rating-display ()
|
||||||
|
"Rating produces star characters."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--aj)))
|
||||||
|
(should (equal (ht-get model "rating_display") "★★★★★"))
|
||||||
|
(should (ht-get model "has_rating"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-rating-none ()
|
||||||
|
"No rating shows placeholder text."
|
||||||
|
(let ((model (spine-book-model spine-book-model-test--uow)))
|
||||||
|
(should (equal (ht-get model "rating_display") "rate when finished"))
|
||||||
|
(should-not (ht-get model "has_rating"))))
|
||||||
|
|
||||||
|
;; --- Format/status/shelf options ---------------------------------------
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-status-options ()
|
||||||
|
"status_options includes all statuses with one selected."
|
||||||
(let* ((model (spine-book-model spine-book-model-test--uow))
|
(let* ((model (spine-book-model spine-book-model-test--uow))
|
||||||
(formats (ht-get model "formats")))
|
(opts (ht-get model "status_options")))
|
||||||
(should (= (length formats) 3))
|
(should (= (length opts) 3))
|
||||||
(should (equal (ht-get (nth 0 formats) "label") "Hardcover"))
|
(should (equal (ht-get (nth 0 opts) "name") "Want"))
|
||||||
(should-not (ht-get (nth 0 formats) "active"))
|
(should-not (ht-get (nth 0 opts) "selected"))
|
||||||
(should (equal (ht-get (nth 1 formats) "label") "eBook"))
|
(should (equal (ht-get (nth 1 opts) "name") "Reading"))
|
||||||
(should-not (ht-get (nth 1 formats) "active"))
|
(should (ht-get (nth 1 opts) "selected"))
|
||||||
(should (equal (ht-get (nth 2 formats) "label") "Audiobook"))
|
(should (equal (ht-get (nth 2 opts) "name") "Read"))
|
||||||
(should (ht-get (nth 2 formats) "active"))))
|
(should-not (ht-get (nth 2 opts) "selected"))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-shelf-options ()
|
||||||
|
"shelf_options lists all shelves with current one selected."
|
||||||
|
(let* ((model (spine-book-model spine-book-model-test--uow))
|
||||||
|
(opts (ht-get model "shelf_options")))
|
||||||
|
(should (>= (length opts) 3))
|
||||||
|
(let ((fiction (cl-find "Fiction" opts :key (lambda (o) (ht-get o "name")) :test #'equal)))
|
||||||
|
(should fiction)
|
||||||
|
(should (ht-get fiction "selected")))
|
||||||
|
(let ((scifi (cl-find "Science Fiction" opts :key (lambda (o) (ht-get o "name")) :test #'equal)))
|
||||||
|
(should scifi)
|
||||||
|
(should-not (ht-get scifi "selected")))))
|
||||||
|
|
||||||
|
;; --- Notes -------------------------------------------------------------
|
||||||
|
|
||||||
(ert-deftest spine-book-model-notes ()
|
(ert-deftest spine-book-model-notes ()
|
||||||
"Notes are mapped correctly."
|
"Notes mapped with date and text."
|
||||||
(let* ((model (spine-book-model spine-book-model-test--uow))
|
(let* ((model (spine-book-model spine-book-model-test--uow))
|
||||||
(notes (ht-get model "notes")))
|
(notes (ht-get model "notes")))
|
||||||
(should (= (length notes) 3))
|
(should (= (length notes) 3))
|
||||||
@@ -83,6 +169,15 @@
|
|||||||
(should (equal (ht-get (nth 0 notes) "text")
|
(should (equal (ht-get (nth 0 notes) "text")
|
||||||
"The two-track structure is doing something I can't name yet."))))
|
"The two-track structure is doing something I can't name yet."))))
|
||||||
|
|
||||||
|
(ert-deftest spine-book-model-notes-empty ()
|
||||||
|
"No notes yields empty array."
|
||||||
|
(let* ((model (spine-book-model spine-book-model-test--tcm))
|
||||||
|
(notes (ht-get model "notes")))
|
||||||
|
(should (listp notes))
|
||||||
|
(should (= (length notes) 0))))
|
||||||
|
|
||||||
|
;; --- Recommendation ----------------------------------------------------
|
||||||
|
|
||||||
(ert-deftest spine-book-model-recommendation ()
|
(ert-deftest spine-book-model-recommendation ()
|
||||||
"Recommendation includes initials, by, and note."
|
"Recommendation includes initials, by, and note."
|
||||||
(let* ((model (spine-book-model spine-book-model-test--uow))
|
(let* ((model (spine-book-model spine-book-model-test--uow))
|
||||||
@@ -97,27 +192,7 @@
|
|||||||
(let ((model (spine-book-model spine-book-model-test--aj)))
|
(let ((model (spine-book-model spine-book-model-test--aj)))
|
||||||
(should-not (ht-get model "recommendation"))))
|
(should-not (ht-get model "recommendation"))))
|
||||||
|
|
||||||
(ert-deftest spine-book-model-no-todo-prefix ()
|
;; --- Navigation --------------------------------------------------------
|
||||||
"Book with no TODO prefix shows title as-is and empty status."
|
|
||||||
(let* ((books (spine-books))
|
|
||||||
(tcm (cl-find "b2c1-tcm" books
|
|
||||||
:key (lambda (b) (plist-get b :id))
|
|
||||||
:test #'equal)))
|
|
||||||
(should tcm)
|
|
||||||
(let ((model (spine-book-model tcm)))
|
|
||||||
(should (equal (ht-get model "title") "The Checklist Manifesto"))
|
|
||||||
(should (equal (ht-get model "status_class") ""))
|
|
||||||
(should (equal (ht-get model "status_label") "")))))
|
|
||||||
|
|
||||||
(ert-deftest spine-book-model-has-date-no-format ()
|
|
||||||
"Has date but no format → meta shows started date only."
|
|
||||||
(let* ((books (spine-books))
|
|
||||||
(tcm (cl-find "b2c1-tcm" books
|
|
||||||
:key (lambda (b) (plist-get b :id))
|
|
||||||
:test #'equal)))
|
|
||||||
(should tcm)
|
|
||||||
(let ((model (spine-book-model tcm)))
|
|
||||||
(should (equal (ht-get model "meta") "started 15 Jun")))))
|
|
||||||
|
|
||||||
(ert-deftest spine-book-model-back-url ()
|
(ert-deftest spine-book-model-back-url ()
|
||||||
"back_url links to index with this book's id."
|
"back_url links to index with this book's id."
|
||||||
|
|||||||
Reference in New Issue
Block a user