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 Toc from '../components/Toc.astro';
|
||||
import ReadingNow from '../components/ReadingNow.astro';
|
||||
import FinishedBooks from '../components/FinishedBooks.astro';
|
||||
import { loadNotebooks, notebookUrl } from '../lib/notebooks.mjs';
|
||||
|
||||
interface Props {
|
||||
@@ -205,6 +206,7 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
|
||||
<aside class="toc-col">
|
||||
{showToc && <Toc headings={headings} />}
|
||||
<ReadingNow />
|
||||
<FinishedBooks />
|
||||
</aside>
|
||||
</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