fix(audio player + dashboard): markdown bookmark notes, horizontal volume, transport glyphs, client-side badges

Bookmarks: notes are markdown now — capture and drawer edit are
textareas (raw source), display renders through the reader's
markdown-it + DOMPurify pipeline (renderNoteHtml) as x-html; the
edit textarea drops the enter-to-save handler so newlines survive.

Volume: the panel dragger is horizontal (mute button beside it,
7.5rem slider) per user feedback — vertical orientation read as a
scrubber clone at a glance.

Play/pause: unicode glyphs replaced with SVG — the triangle is
optically centered (2px right-bias) and the pause bars are longer
and thicker rounded rects (4.2×15 at rx 1.7).

Dashboard: the client-side card renderer (dashboard.ts — the path the
JSON sections actually render through) now mirrors the templ BookCard:
cover-corner badges for audiobook/comic/manga (books unbadged) and the
hover action shows headphones with 'Listen to …' labels for audiobooks.
BookInfo type + Go struct carry format_group end to end.

Verified on-device: pause glyph centered/thicker, horizontal volume
panel, markdown note ('**bold**' + list) rendered in the bookmarks
drawer, badges on dashboard cards, sections JSON carrying
format_group.
This commit is contained in:
John O'Keefe committed 2026-10-15 18:49:42 -04:00
1 parent 5cf34102d6
commit 2c714dcb60
5 files changed
+65 -16

No files matched your search

+7
View File
@@ -13,6 +13,7 @@
// tracks) — the mobile-web native feel from handoff item 6.
import { Alpine } from "./alpine";
import { renderNoteHtml } from "./markdown";
import { showToast } from "./toast";
type AudioChapter = { index: number; title: string; start: number; end: number };
@@ -375,6 +376,12 @@ Alpine.data("audioReader", () => ({
}
},
// Markdown notes: the drawer renders sanitized HTML from the same
// pipeline the reader's annotations use; editing stays raw markdown.
bookmarkNoteHtml(b: Record<string, unknown>): string {
return renderNoteHtml(String(b.notes ?? ""));
},
bookmarkSeconds(position: string): number {
const n = parseInt(String(position).slice("audio:".length), 10);
return Number.isFinite(n) ? n : 0;
+36 -4
View File
@@ -286,29 +286,61 @@ const BOOK_OPEN_ICON =
'<path d="M3 4h6a3 3 0 0 1 3 3v14a2 2 0 0 0-2-2H3z"></path>' +
'<path d="M21 4h-6a3 3 0 0 0-3 3v14a2 2 0 0 1 2-2h7z"></path>' +
"</svg>";
const HEADPHONES_ICON =
'<svg class="reader-icon h-4 w-4" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M4 14v-2a8 8 0 0 1 16 0v2"></path>' +
'<rect x="3" y="14" width="4" height="7" rx="1.5"></rect>' +
'<rect x="17" y="14" width="4" height="7" rx="1.5"></rect>' +
"</svg>";
const COMIC_ICON =
'<svg class="reader-icon h-4 w-4" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M4 4h16v12l-3 4H7l-3-4z"></path>' +
'<path d="M4 9h16M9 9v11"></path>' +
"</svg>";
const MANGA_ICON =
'<svg class="reader-icon h-4 w-4" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M4 4h16v13a3 3 0 0 1-3 3H4z"></path>' +
'<path d="M4 4v16M10 4h10v8"></path>' +
"</svg>";
// Media-type identifier for mixed grids: '' for books (unbadged default)
function badgeIconFor(formatGroup: string): string {
if (formatGroup === "audiobook") return HEADPHONES_ICON;
if (formatGroup === "comic") return COMIC_ICON;
if (formatGroup === "manga") return MANGA_ICON;
return "";
}
function renderBookCard(book: BookInfo): string {
const coverUrl = book.cover_image_path || "/static/placeholder-book.svg";
const actionHref = book.has_conflict
? `/media/${book.media_item_id}`
: `/readers/${book.media_item_id}`;
const isAudio = book.format_group === "audiobook";
const actionLabel = book.has_conflict
? `Resolve progress conflict for ${book.title}`
: `Read ${book.title}`;
: isAudio
? `Listen to ${book.title}`
: `Read ${book.title}`;
const actionTitle = book.has_conflict
? "Resolve progress conflict"
: "Read";
: isAudio
? "Listen"
: "Read";
const actionIcon = isAudio ? HEADPHONES_ICON : BOOK_OPEN_ICON;
const badgeIcon = badgeIconFor(book.format_group || "");
return `
<div class="flex-shrink-0 w-36 sm:w-40 snap-start" data-media-item-id="${book.media_item_id}">
<div class="book-card relative w-full h-full rounded-xl overflow-hidden cursor-pointer">
<a href="/media/${book.media_item_id}" class="block h-full">
<div class="book-card-cover aspect-[2/3] overflow-hidden" style="background-color: color-mix(in srgb, var(--text-primary) 8%, transparent);">
<div class="book-card-cover relative aspect-[2/3] overflow-hidden" style="background-color: color-mix(in srgb, var(--text-primary) 8%, transparent);">
<img src="${coverUrl}"
alt="${book.title}"
class="w-full h-full object-cover"
loading="lazy"
onerror="this.src='/static/placeholder-book.svg'">
${badgeIcon ? `<span class="absolute top-2 right-2 z-10 flex items-center justify-center h-7 w-7 rounded-full shadow-sm" style="background-color: color-mix(in srgb, var(--bg-primary) 78%, transparent); color: var(--text-primary); backdrop-filter: blur(3px);" title="${isAudio ? "Audiobook" : book.format_group === "comic" ? "Comic" : "Manga"}">${badgeIcon}</span>` : ""}
</div>
<div class="book-card-meta">
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title="${book.title}">
@@ -321,7 +353,7 @@ function renderBookCard(book: BookInfo): string {
<a href="${actionHref}"
class="book-card-action-btn"
aria-label="${actionLabel}"
title="${actionTitle}">${BOOK_OPEN_ICON}</a>
title="${actionTitle}">${actionIcon}</a>
</div>
</div>
</div>
+1
View File
@@ -102,6 +102,7 @@ interface BookInfo {
author: string;
cover_image_path: string;
has_conflict: boolean;
format_group: string;
}
// Dashboard type definitions