Add Goodreads series and group them on the about page

This commit is contained in:
SirBlob committed 2026-11-01 20:57:37 -05:00
1 parent 737700a9a8
commit 2318cdeaac
11 files changed
+651 -12

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
import type { Book, BookShelf } from '#lib/content';
export interface BookSeriesGroup {
name: string;
url: string;
books: Book[];
counts: Record<BookShelf, number>;
}
export interface GroupedBooks {
series: BookSeriesGroup[];
standalone: Book[];
}
const positionGroups = { single: 0, range: 1, none: 2 };
function seriesOrder(first: Book, second: Book): number {
const [firstGroup, firstNumber] = positionKey(first);
const [secondGroup, secondNumber] = positionKey(second);
return firstGroup - secondGroup || firstNumber - secondNumber;
}
function positionKey(book: Book): [number, number] {
const position = book.series?.position ?? '';
const match = position.match(/\d+(?:\.\d+)?/);
if (!match) return [positionGroups.none, 0];
const isRange = /\d\s*[-–]\s*\d/.test(position);
return [isRange ? positionGroups.range : positionGroups.single, Number(match[0])];
}
function shelfCounts(books: Book[]): Record<BookShelf, number> {
return {
read: books.filter((book) => book.shelf === 'read').length,
reading: books.filter((book) => book.shelf === 'reading').length,
'want-to-read': books.filter((book) => book.shelf === 'want-to-read').length
};
}
export function groupBySeries(books: Book[]): GroupedBooks {
const groups = new Map<string, Book[]>();
const standalone: Book[] = [];
for (const book of books) {
if (!book.series) {
standalone.push(book);
continue;
}
const key = book.series.url || book.series.name;
groups.set(key, [...(groups.get(key) ?? []), book]);
}
const series = [...groups.values()]
.map((seriesBooks) => {
const ordered = [...seriesBooks].sort(seriesOrder);
return { name: ordered[0].series!.name, url: ordered[0].series!.url, books: ordered, counts: shelfCounts(ordered) };
})
.sort((first, second) => second.counts.read - first.counts.read || first.name.localeCompare(second.name));
return { series, standalone };
}
+43 -3
View File
@@ -2,7 +2,9 @@
import Icon from '@iconify/svelte';
import type { Book } from '#lib/content';
let { book }: { book: Book } = $props();
let { book, showShelf = false }: { book: Book; showShelf?: boolean } = $props();
const shelfLabels = { read: 'read', reading: 'reading', 'want-to-read': 'want to read' };
let coverFailed = $state(false);
@@ -26,14 +28,22 @@
<span class="rating community" title="Goodreads average"><Icon icon="mdi:star-outline" width="13" />{book.averageRating}</span>
{/if}
{#if showShelf}
<span class="shelf {book.shelf}">{shelfLabels[book.shelf]}</span>
{/if}
{#if book.description}
<p class="description">{book.description}</p>
{/if}
</div>
<span class="title">{book.title}</span>
<span class="meta">{book.authors.join(', ')}</span>
{#if book.genres.length}
{#if book.series?.position}
<span class="meta position">{book.series.position}</span>
{:else}
<span class="meta">{book.authors.join(', ')}</span>
{/if}
{#if book.genres.length && !showShelf}
<span class="meta">{book.genres.slice(0, 3).join(' · ')}</span>
{/if}
</a>
@@ -104,6 +114,36 @@
color: var(--muted);
}
.shelf {
position: absolute;
bottom: 0.35rem;
left: 0.35rem;
padding: 0 0.35rem;
color: var(--background);
font-size: 0.7rem;
font-weight: 700;
}
.shelf.read {
background: var(--green);
}
.shelf.reading {
background: var(--blue);
}
.shelf.want-to-read {
background: var(--magenta);
}
.book-card:hover .shelf {
opacity: 0;
}
.position {
color: var(--foreground);
}
.description {
position: absolute;
right: 0;
@@ -0,0 +1,152 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { BookSeriesGroup } from '#lib/books/series';
import BookCard from './BookCard.svelte';
let { series }: { series: BookSeriesGroup } = $props();
const collapsedCount = 8;
let showAll = $state(false);
const shownBooks = $derived(showAll ? series.books : series.books.slice(0, collapsedCount));
const total = $derived(series.books.length);
const shelves = $derived([
{ id: 'read', label: 'read', count: series.counts.read },
{ id: 'reading', label: 'reading', count: series.counts.reading },
{ id: 'want-to-read', label: 'want to read', count: series.counts['want-to-read'] }
]);
</script>
<section class="series">
<header class="series-header">
<a class="series-name" href={series.url} target="_blank" rel="noopener noreferrer">
<Icon icon="mdi:bookshelf" width="16" />
{series.name}
</a>
<span class="series-counts">
{#each shelves.filter((shelf) => shelf.count > 0) as shelf (shelf.id)}
<span class="count {shelf.id}">{shelf.count} {shelf.label}</span>
{/each}
</span>
</header>
<div class="progress" role="img" aria-label="{series.counts.read} of {total} read">
{#each shelves as shelf (shelf.id)}
{#if shelf.count}
<span class="segment {shelf.id}" style:flex-grow={shelf.count}></span>
{/if}
{/each}
</div>
<div class="book-grid">
{#each shownBooks as book (book.goodreadsId)}
<BookCard {book} showShelf />
{/each}
</div>
{#if total > collapsedCount}
<button class="toggle" onclick={() => (showAll = !showAll)}>
<Icon icon={showAll ? 'mdi:chevron-up' : 'mdi:chevron-down'} width="16" />
{showAll ? 'Show less' : `Show all ${total}`}
</button>
{/if}
</section>
<style>
.series {
padding: 0.75rem;
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
}
.series-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.4rem 1rem;
}
.series-name {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--accent);
font-weight: 700;
}
.series-name:hover {
text-decoration: underline;
}
.series-counts {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.78rem;
}
.count.read {
color: var(--green);
}
.count.reading {
color: var(--blue);
}
.count.want-to-read {
color: var(--magenta);
}
.progress {
display: flex;
gap: 2px;
height: 4px;
margin: 0.5rem 0 0.85rem;
}
.segment {
flex-basis: 0;
}
.segment.read {
background: var(--green);
}
.segment.reading {
background: var(--blue);
}
.segment.want-to-read {
background: var(--magenta);
}
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.9rem 0.75rem;
}
.toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.75rem;
padding: 0.3rem 0.7rem;
background: none;
border: 1px solid color-mix(in srgb, var(--foreground) 30%, transparent);
color: var(--foreground);
font-size: 0.85rem;
}
.toggle:hover {
border-color: var(--accent);
color: var(--accent);
}
@container (max-width: 520px) {
.book-grid {
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
}
}
</style>
@@ -1,8 +1,10 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Book } from '#lib/content';
import { groupBySeries } from '#lib/books/series';
import Panel from '#lib/components/ui/Panel.svelte';
import BookShelfGrid from './BookShelfGrid.svelte';
import BookSeriesGroup from './BookSeriesGroup.svelte';
let { books }: { books: Book[] } = $props();
@@ -20,6 +22,9 @@
{ label: 'Want to read', count: wantToRead.length, color: shelfColors['want-to-read'] }
]);
const shownCounts = $derived(shelfCounts.filter((shelf) => shelf.count > 0));
const grouped = $derived(groupBySeries(books));
const standaloneRead = $derived(read.filter((book) => !book.series));
const standaloneWantToRead = $derived(wantToRead.filter((book) => !book.series));
const pagesRead = $derived(read.reduce((total, book) => total + (book.pages ?? 0), 0));
const authorCount = $derived(new Set(read.flatMap((book) => book.authors)).size);
const topGenres = $derived.by(() => {
@@ -78,15 +83,39 @@
{/if}
</div>
{#if read.length}
<BookShelfGrid title="Read" icon="mdi:check-circle-outline" books={read} />
{#if grouped.series.length}
<h3 class="subheading"><Icon icon="mdi:bookshelf" width="16" /> Series <span class="muted">{grouped.series.length}</span></h3>
<div class="series-list">
{#each grouped.series as series (series.url || series.name)}
<BookSeriesGroup {series} />
{/each}
</div>
{/if}
{#if wantToRead.length}
<BookShelfGrid title="Want to read" icon="mdi:bookmark-outline" books={wantToRead} />
{#if standaloneRead.length}
<BookShelfGrid title="Read" icon="mdi:check-circle-outline" books={standaloneRead} />
{/if}
{#if standaloneWantToRead.length}
<BookShelfGrid title="Want to read" icon="mdi:bookmark-outline" books={standaloneWantToRead} />
{/if}
<style>
.subheading {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 1.25rem 0 0.6rem;
color: var(--foreground);
font-size: 0.9rem;
}
.series-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.overview {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+7
View File
@@ -156,6 +156,13 @@ export interface Book {
shelf: BookShelf;
rating?: number;
added?: string;
series?: BookSeries;
}
export interface BookSeries {
name: string;
url: string;
position?: string;
}
export interface LabeledValue {