feat(app): content client and side panel
About, Listen & lyrics (real player, synced lyrics, Watch), Paths (strip when the timeline is hidden, motif rows, nine-place chart), Fan works. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
38c4614726
commit
2fbf3a6980
13 files changed
+1734
-1
No files matched your search
@@ -0,0 +1,45 @@
|
||||
// The reading view: the song's book of the Odyssey (Butcher & Lang, which keeps the Greek names), opened at
|
||||
// the first paragraph naming one of the song's keywords, with the paragraphs before it folded away.
|
||||
import type { AlbumSong } from "../../data/album";
|
||||
import { keywordRe, loadBook, passageStart, roman, stripFootnotes } from "../content";
|
||||
import { esc } from "./util";
|
||||
|
||||
/** `readAll` shows the whole book from the start, keeping the passage marker. */
|
||||
export function readingView(song: AlbumSong, readAll: boolean): HTMLElement {
|
||||
const o = song.odyssey!;
|
||||
const el = document.createElement("div");
|
||||
el.className = "reading";
|
||||
el.innerHTML = `
|
||||
<button type="button" class="reading__back" data-act="unread"><span aria-hidden="true">←</span> Back to About</button>
|
||||
<p class="reading__eyebrow">The Odyssey · Book ${roman(o.book)} · tr. Butcher & Lang</p>
|
||||
<div class="reading__body" aria-busy="true"><p class="hint">Opening Book ${roman(o.book)}…</p></div>`;
|
||||
const body = el.querySelector<HTMLElement>(".reading__body")!;
|
||||
loadBook(o.book).then((book) => {
|
||||
if (!el.isConnected) return; // the reader has moved on
|
||||
body.removeAttribute("aria-busy");
|
||||
if (!book) {
|
||||
body.innerHTML = `<p class="note">Book ${roman(o.book)} could not be loaded from the content folder.
|
||||
<a href="${esc(o.url)}" target="_blank" rel="noopener noreferrer">Read it on Project Gutenberg</a>.</p>`;
|
||||
return;
|
||||
}
|
||||
const at = passageStart(book, o.keywords);
|
||||
const re = keywordRe(o.keywords);
|
||||
const para = (t: string) => {
|
||||
let h = esc(stripFootnotes(t));
|
||||
if (re) h = h.replace(re, "<mark>$1</mark>");
|
||||
return `<p class="reading__p">${h}</p>`;
|
||||
};
|
||||
const from = readAll ? 0 : at.index;
|
||||
const marker = `<p class="reading__mark" id="reading-mark">${at.word
|
||||
? `The passage for ${esc(song.title)} begins here · first mention of “${esc(at.word)}”`
|
||||
: `No keyword for ${esc(song.title)} found · Book ${roman(o.book)} from the start`}</p>`;
|
||||
body.innerHTML = `
|
||||
<h3 class="reading__title">${esc(book.heading.replace(/^BOOK\b/, "Book").replace(/\.$/, ""))}</h3>
|
||||
${book.summary ? `<p class="reading__sum">${esc(book.summary)}</p>` : ""}
|
||||
${from > 0 ? `<button type="button" class="reading__more" data-act="read-all">Show the ${from} paragraph${from === 1 ? "" : "s"} before this passage</button>` : ""}
|
||||
${book.paragraphs.slice(from).map((t, i) => (from + i === at.index ? marker : "") + para(t)).join("")}
|
||||
<p class="reading__end">End of Book ${roman(o.book)} · Project Gutenberg #1728</p>`;
|
||||
if (readAll) el.querySelector<HTMLElement>("#reading-mark")?.scrollIntoView?.({ block: "start" });
|
||||
});
|
||||
return el;
|
||||
}
|
||||
Reference in new issue
Block a user