reading: finished-books widget in the rail + full list at /reading/finished
Sidebar widget (FinishedBooks.astro, next to ReadingNow) shows the 5 most recent finished books, title-only-linked to their Quotes page when one exists, with a "see all" link once there are more than 5 — that link is the sole path to the full page, which carries the fuller kobo-dashboard-style detail (duration, pages, pages/hr, gap-since-last-finish). Both fetch client-side against the new api.c0smere.net/reading/finished, same fail-closed pattern as ReadingNow.
This commit is contained in:
@@ -0,0 +1,115 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* "Finished" — the 5 most recently finished books, with a link to the full list.
|
||||||
|
*
|
||||||
|
* import FinishedBooks from '../components/FinishedBooks.astro';
|
||||||
|
* <FinishedBooks />
|
||||||
|
*
|
||||||
|
* Data: api.c0smere.net/reading/finished — the same array /reading/finished.astro renders in
|
||||||
|
* full; this widget just takes the first 5 client-side rather than adding a ?limit param to an
|
||||||
|
* endpoint whose only other consumer wants everything.
|
||||||
|
*
|
||||||
|
* Deliberately terse, matching ReadingNow at this rail width (13rem): title + date only, no
|
||||||
|
* duration/pages/author — those live on the full page. Title links to the book's Quotes page
|
||||||
|
* only when quote_slug is present; see api.reading_finished()'s comment (grants_phlegethon.sql)
|
||||||
|
* for why a non-null slug is always a real page.
|
||||||
|
*/
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="finished-books" id="finished-books" hidden>
|
||||||
|
<h2 class="finished-books__label">Finished</h2>
|
||||||
|
<ol class="finished-books__list" id="fb-list"></ol>
|
||||||
|
<p class="finished-books__more" id="fb-more" hidden>
|
||||||
|
<a href="/reading/finished">see all →</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Fail closed, like ReadingNow: the section ships hidden and only a good response reveals
|
||||||
|
// it, so a 404 (nothing finished yet), a dead API, or an empty array leaves a plain rail.
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://api.c0smere.net/reading/finished');
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
const books = await res.json();
|
||||||
|
if (!Array.isArray(books) || books.length === 0) throw new Error('empty');
|
||||||
|
|
||||||
|
const list = document.getElementById('fb-list')!;
|
||||||
|
for (const b of books.slice(0, 5)) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'finished-books__item';
|
||||||
|
|
||||||
|
const title = document.createElement(b.quote_slug ? 'a' : 'span');
|
||||||
|
title.className = 'finished-books__title';
|
||||||
|
title.textContent = b.title;
|
||||||
|
if (b.quote_slug) title.setAttribute('href', `/Quotes/${b.quote_slug}`);
|
||||||
|
li.appendChild(title);
|
||||||
|
|
||||||
|
const date = document.createElement('span');
|
||||||
|
date.className = 'finished-books__date';
|
||||||
|
date.textContent = b.finished_on;
|
||||||
|
li.appendChild(date);
|
||||||
|
|
||||||
|
list.appendChild(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (books.length > 5) {
|
||||||
|
document.getElementById('fb-more')!.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('finished-books')!.hidden = false;
|
||||||
|
} catch {
|
||||||
|
/* API down, blocked, or nothing finished yet: the slot stays hidden */
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.finished-books {
|
||||||
|
margin-top: 1.2rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--hair);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.finished-books[hidden] { display: none; }
|
||||||
|
|
||||||
|
.finished-books__label {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished-books__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.finished-books__item { margin: 0 0 0.55rem; }
|
||||||
|
.finished-books__item:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.finished-books__title {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
text-decoration: none;
|
||||||
|
/* Titles run long ("Introduction to Linear Algebra (Ed 5) …") on a 13rem rail. */
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
a.finished-books__title:hover { color: var(--link-bright); }
|
||||||
|
|
||||||
|
.finished-books__date {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished-books__more { margin: 0.6rem 0 0; }
|
||||||
|
.finished-books__more[hidden] { display: none; }
|
||||||
|
.finished-books__more a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.finished-books__more a:hover { color: var(--link-bright); }
|
||||||
|
</style>
|
||||||
@@ -6,6 +6,7 @@ import { COMMIT, BUILT_AT } from '../lib/build-info.mjs';
|
|||||||
import NavTree from '../components/NavTree.astro';
|
import NavTree from '../components/NavTree.astro';
|
||||||
import Toc from '../components/Toc.astro';
|
import Toc from '../components/Toc.astro';
|
||||||
import ReadingNow from '../components/ReadingNow.astro';
|
import ReadingNow from '../components/ReadingNow.astro';
|
||||||
|
import FinishedBooks from '../components/FinishedBooks.astro';
|
||||||
import { loadNotebooks, notebookUrl } from '../lib/notebooks.mjs';
|
import { loadNotebooks, notebookUrl } from '../lib/notebooks.mjs';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -205,6 +206,7 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
|
|||||||
<aside class="toc-col">
|
<aside class="toc-col">
|
||||||
{showToc && <Toc headings={headings} />}
|
{showToc && <Toc headings={headings} />}
|
||||||
<ReadingNow />
|
<ReadingNow />
|
||||||
|
<FinishedBooks />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
---
|
||||||
|
import Base from '../../layouts/Base.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base
|
||||||
|
title="Finished books"
|
||||||
|
description="Every book I've finished reading, in order, with what it took and the gaps in between."
|
||||||
|
>
|
||||||
|
<h1>Finished books</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The rail on every page carries the five most recent. This is the whole list: when I
|
||||||
|
finished each one, how long it took by the device's own cumulative counter, and — where
|
||||||
|
I took any highlights — a link through to <a href="/Quotes/">the quotes I kept</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol class="finished-list" id="finished-list" hidden></ol>
|
||||||
|
<p class="finished-empty" id="finished-empty" hidden>Nothing finished yet.</p>
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://api.c0smere.net/reading/finished');
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
const books = await res.json();
|
||||||
|
if (!Array.isArray(books) || books.length === 0) throw new Error('empty');
|
||||||
|
|
||||||
|
const list = document.getElementById('finished-list')!;
|
||||||
|
|
||||||
|
books.forEach((book, idx) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'finished-item';
|
||||||
|
|
||||||
|
const when = document.createElement('span');
|
||||||
|
when.className = 'finished-when';
|
||||||
|
const dateEl = document.createElement('span');
|
||||||
|
dateEl.className = 'finished-date';
|
||||||
|
dateEl.textContent = longDay(book.finished_on);
|
||||||
|
when.appendChild(dateEl);
|
||||||
|
// How long after the *previous* finish this one landed. The list runs newest-first,
|
||||||
|
// so the previous book chronologically is the next row down — and the oldest book has
|
||||||
|
// nothing before it to measure against. Ported from kobo-dashboard's FinishedShelf.
|
||||||
|
if (idx < books.length - 1) {
|
||||||
|
const gapEl = document.createElement('span');
|
||||||
|
gapEl.className = 'finished-gap';
|
||||||
|
gapEl.textContent = gapLabel(books[idx + 1].finished_on, book.finished_on);
|
||||||
|
when.appendChild(gapEl);
|
||||||
|
}
|
||||||
|
li.appendChild(when);
|
||||||
|
|
||||||
|
const what = document.createElement('span');
|
||||||
|
what.className = 'finished-what';
|
||||||
|
const titleEl = document.createElement(book.quote_slug ? 'a' : 'span');
|
||||||
|
titleEl.className = 'finished-title';
|
||||||
|
titleEl.textContent = book.title;
|
||||||
|
if (book.quote_slug) titleEl.setAttribute('href', `/Quotes/${book.quote_slug}`);
|
||||||
|
what.appendChild(titleEl);
|
||||||
|
if (book.author) {
|
||||||
|
const a = document.createElement('span');
|
||||||
|
a.className = 'finished-author';
|
||||||
|
a.textContent = book.author;
|
||||||
|
what.appendChild(a);
|
||||||
|
}
|
||||||
|
if (book.series) {
|
||||||
|
const s = document.createElement('span');
|
||||||
|
s.className = 'finished-series';
|
||||||
|
s.textContent = book.series;
|
||||||
|
what.appendChild(s);
|
||||||
|
}
|
||||||
|
li.appendChild(what);
|
||||||
|
|
||||||
|
const metrics = document.createElement('span');
|
||||||
|
metrics.className = 'finished-metrics';
|
||||||
|
let text = formatDuration(book.minutes);
|
||||||
|
if (book.pages !== null) {
|
||||||
|
text += ` · ${book.pages} pages`;
|
||||||
|
if (book.minutes > 0) {
|
||||||
|
text += ` · ${Math.round((book.pages / book.minutes) * 60)} pages/hr`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metrics.textContent = text;
|
||||||
|
li.appendChild(metrics);
|
||||||
|
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
list.hidden = false;
|
||||||
|
} catch {
|
||||||
|
document.getElementById('finished-empty')!.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long after `earlierISO` the book finished on `laterISO` was finished, in words. */
|
||||||
|
function gapLabel(earlierISO: string, laterISO: string): string {
|
||||||
|
const days = daysBetween(earlierISO, laterISO);
|
||||||
|
if (days <= 0) return 'same day';
|
||||||
|
if (days === 1) return 'next day';
|
||||||
|
if (days < 31) return `${days} days later`;
|
||||||
|
const months = Math.round(days / 30.44);
|
||||||
|
return months === 1 ? 'a month later' : `${months} months later`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calendar-day difference between two 'YYYY-MM-DD' keys, DST-proof via UTC. */
|
||||||
|
function daysBetween(from: string, to: string): number {
|
||||||
|
const [fy, fm, fd] = from.split('-').map(Number);
|
||||||
|
const [ty, tm, td] = to.split('-').map(Number);
|
||||||
|
return (Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / 86_400_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function longDay(iso: string): string {
|
||||||
|
const [y, m, d] = iso.split('-').map(Number);
|
||||||
|
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(minutes: number): string {
|
||||||
|
if (minutes < 60) return `${minutes} min`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const rest = minutes % 60;
|
||||||
|
return rest === 0 ? `${hours} hr` : `${hours} hr ${rest} min`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.finished-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 1.5rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.finished-list[hidden] { display: none; }
|
||||||
|
|
||||||
|
.finished-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 9rem 1fr;
|
||||||
|
gap: 0.15rem 1rem;
|
||||||
|
padding: 0.85rem 0;
|
||||||
|
border-top: 1px solid var(--hair);
|
||||||
|
}
|
||||||
|
.finished-item:first-child { border-top: none; }
|
||||||
|
|
||||||
|
.finished-when {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.finished-gap {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished-what {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0 0.6rem;
|
||||||
|
}
|
||||||
|
.finished-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a.finished-title:hover { color: var(--link-bright); }
|
||||||
|
.finished-author,
|
||||||
|
.finished-series {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished-metrics {
|
||||||
|
grid-column: 2;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished-empty { color: var(--muted); }
|
||||||
|
.finished-empty[hidden] { display: none; }
|
||||||
|
|
||||||
|
@media (max-width: 32rem) {
|
||||||
|
.finished-item { grid-template-columns: 1fr; }
|
||||||
|
.finished-metrics { grid-column: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user