feat: add book detail view with spine-book-model, httpd/book handler, and title links
- Add spine--format-defs defconst shared between index and book models - Add spine-book-model with status extraction, format selector, notes, recommendation - Add templates/book.mustache (Pico CSS, journal-style layout) - Add httpd/book handler serving the detail page - Link book titles in index view to /book?id=X - Add spine-book-model-test.el with 13 tests
This commit is contained in:
@@ -63,15 +63,92 @@ Returns the rendered string."
|
||||
(ht ("app_title" "spine")
|
||||
("shelves" (or (spine-shelves) '()))))
|
||||
|
||||
(defconst spine--format-defs
|
||||
'(("hardcover" "ti-book" "Hardcover")
|
||||
("ebook" "ti-device-tablet" "eBook")
|
||||
("audiobook" "ti-headphones" "Audiobook"))
|
||||
"Alist of (format-key icon label) for book format display.")
|
||||
|
||||
(defun spine--format-date (org-date-str)
|
||||
"Format Org date \"[2026-02-20]\" to \"20 Feb\"."
|
||||
(when (and org-date-str (> (length org-date-str) 0))
|
||||
(let ((clean (replace-regexp-in-string "\\[\\|\\]" "" org-date-str)))
|
||||
(condition-case nil
|
||||
(format-time-string "%e %b" (date-to-time clean))
|
||||
(error org-date-str)))))
|
||||
|
||||
(defun spine--parse-title-status (title)
|
||||
"Return (CLEAN-TITLE STATUS-CLASS STATUS-LABEL) from a book TITLE.
|
||||
TITLE may have a leading TODO prefix like \"WANT \", \"READING \", \"READ \"."
|
||||
(let ((case-fold-search nil))
|
||||
(if (string-match
|
||||
"\\`\\(WANT\\|READING\\|READ\\)[ \t]+\\(.*\\)\\'"
|
||||
title)
|
||||
(let ((kw (match-string 1 title))
|
||||
(rest (match-string 2 title)))
|
||||
(list rest (downcase kw) (capitalize (downcase kw))))
|
||||
(list title "" ""))))
|
||||
|
||||
(defun spine-book-model (book)
|
||||
"Build view model `ht` for a single BOOK plist, for templates/book.mustache."
|
||||
(let* ((id (plist-get book :id))
|
||||
(title (or (plist-get book :title) ""))
|
||||
(author (or (plist-get book :author) ""))
|
||||
(fmt (plist-get book :format))
|
||||
(added (plist-get book :added))
|
||||
(notes (plist-get book :notes))
|
||||
(rec-by (plist-get book :rec_by))
|
||||
(rec-note (plist-get book :rec_note))
|
||||
(title-info (spine--parse-title-status title))
|
||||
(clean-title (nth 0 title-info))
|
||||
(status-class (nth 1 title-info))
|
||||
(status-label (nth 2 title-info))
|
||||
(meta (cond
|
||||
((and added fmt)
|
||||
(format "started %s \302\267 %s" (spine--format-date added) fmt))
|
||||
(added (format "started %s" (spine--format-date added)))
|
||||
(fmt (format "format: %s" fmt))
|
||||
(t "")))
|
||||
(formats (mapcar
|
||||
(lambda (fd)
|
||||
(ht ("icon" (nth 1 fd))
|
||||
("label" (nth 2 fd))
|
||||
("active" (and fmt (equal (car fd) fmt)))))
|
||||
spine--format-defs))
|
||||
(notes-model
|
||||
(mapcar (lambda (n)
|
||||
(ht ("date" (car n))
|
||||
("text" (cadr n))))
|
||||
notes))
|
||||
(recommendation
|
||||
(when (and rec-by (> (length rec-by) 0))
|
||||
(let ((initials
|
||||
(mapconcat
|
||||
(lambda (s)
|
||||
(when (> (length s) 0)
|
||||
(upcase (substring s 0 1))))
|
||||
(split-string rec-by " +" t) "")))
|
||||
(ht ("initials" initials)
|
||||
("by" rec-by)
|
||||
("note" (or rec-note "")))))))
|
||||
(ht ("cover_icon" "ti-book")
|
||||
("id" id)
|
||||
("title" clean-title)
|
||||
("author" author)
|
||||
("status_class" status-class)
|
||||
("status_label" status-label)
|
||||
("meta" meta)
|
||||
("progress_label" "")
|
||||
("formats" formats)
|
||||
("notes" notes-model)
|
||||
("recommendation" recommendation)
|
||||
("back_url" (format "/index?id=%s" id)))))
|
||||
|
||||
(defun spine-index-model (books &optional shelf-filter selected-id)
|
||||
"Build the view model ht for templates/index.mustache from BOOKS.
|
||||
SHELF-FILTER limits to one shelf by name (string). nil = all shelves.
|
||||
SELECTED-ID expands the matching book's detail section."
|
||||
(let* ((format-icons
|
||||
'(("hardcover" . "ti-book")
|
||||
("ebook" . "ti-device-tablet")
|
||||
("audiobook" . "ti-headphones")))
|
||||
(grouped (make-hash-table :test 'equal))
|
||||
(let* ((grouped (make-hash-table :test 'equal))
|
||||
(all-shelves (or (spine-shelves) '()))
|
||||
(total (length books)))
|
||||
;; Group books by shelf
|
||||
@@ -101,7 +178,7 @@ SELECTED-ID expands the matching book's detail section."
|
||||
(model
|
||||
(ht
|
||||
("format_icon"
|
||||
(or (cdr (assoc fmt format-icons)) ""))
|
||||
(or (nth 1 (assoc fmt spine--format-defs)) ""))
|
||||
("title" (plist-get book :title))
|
||||
("author" (or (plist-get book :author) ""))
|
||||
("tags_inline"
|
||||
@@ -114,6 +191,7 @@ SELECTED-ID expands the matching book's detail section."
|
||||
(when (and rating (> (length rating) 0))
|
||||
(make-string (string-to-number rating) ?\u2605)))
|
||||
("selected" selected)
|
||||
("book_url" (format "/book?id=%s" id))
|
||||
("detail"
|
||||
(when selected
|
||||
(ht
|
||||
@@ -489,6 +567,16 @@ Query params:
|
||||
title id (format-time-string "%Y-%m-%d"))))))
|
||||
;; Unknown action or missing id: redirect to index
|
||||
(httpd-redirect proc "/index" 302))))
|
||||
|
||||
(defun httpd/book (proc uri-path query request)
|
||||
"Show the journal-style detail page for a single book."
|
||||
(let* ((id (cadr (assoc "id" query)))
|
||||
(books (spine-books))
|
||||
(book (cl-find id books :key (lambda (b) (plist-get b :id)) :test #'equal)))
|
||||
(if book
|
||||
(httpd-with-buffer proc "text/html"
|
||||
(insert (spine-render "book.mustache" (spine-book-model book))))
|
||||
(httpd-redirect proc "/index" 302))))
|
||||
(defun httpd/ (proc uri-path query request)
|
||||
"Redirect root to /index."
|
||||
(httpd-redirect proc "/index" 302))
|
||||
|
||||
Reference in New Issue
Block a user