Merge feature/related-books: related books section, linkable topic badges

This commit is contained in:
Ryan Malloy 2026-02-13 06:22:06 -07:00
commit cf37337d6f
4 changed files with 139 additions and 18 deletions

View File

@ -0,0 +1,70 @@
---
import { Badge } from '@/components/ui/badge';
import type { CollectionEntry } from 'astro:content';
interface RelatedBookResult {
book: CollectionEntry<'books'>;
sharedTopics: string[];
overlapCount: number;
}
interface Props {
relatedBooks: RelatedBookResult[];
currentCollection: string;
}
const { relatedBooks, currentCollection } = Astro.props;
---
{relatedBooks.length > 0 && (
<section class="space-y-4">
<h2 class="text-lg font-semibold title-accent text-foreground">Related Notebooks</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{relatedBooks.map(({ book, sharedTopics }) => {
const slug = book.slug.split('/').pop();
const isCrossCollection = book.data.collection !== currentCollection;
return (
<a
href={`/${book.data.collection}/${slug}`}
class="book-card block bg-card rounded-lg border border-border overflow-hidden hover:border-primary/50"
>
<div class="relative">
{book.data.coverImage ? (
<img
src={book.data.coverImage}
alt={`Cover of ${book.data.shortTitle}`}
class="w-full aspect-[3/4] object-cover object-top"
loading="lazy"
/>
) : (
<div class="w-full aspect-[3/4] bg-muted flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
</svg>
</div>
)}
{isCrossCollection && (
<div class="absolute top-2 left-2 bg-card/90 backdrop-blur-sm text-[10px] font-medium px-1.5 py-0.5 rounded border border-border">
{book.data.collection === 'mims' ? 'Mims' : "Ugly's"}
</div>
)}
</div>
<div class="p-3 space-y-2">
<h3 class="text-sm font-semibold text-foreground title-accent line-clamp-2 leading-tight">
{book.data.shortTitle}
</h3>
<div class="flex flex-wrap gap-1">
{sharedTopics.slice(0, 2).map((topic) => (
<Badge variant="secondary" className="text-[10px]">
{topic.replace(/-/g, ' ')}
</Badge>
))}
</div>
</div>
</a>
);
})}
</div>
</section>
)}

View File

@ -0,0 +1,45 @@
import type { CollectionEntry } from 'astro:content';
interface RelatedBookResult {
book: CollectionEntry<'books'>;
sharedTopics: string[];
overlapCount: number;
}
function normalizeTopic(topic: string): string {
return topic.toLowerCase().replace(/[\s_]+/g, '-');
}
export function getRelatedBooks(
currentBook: CollectionEntry<'books'>,
allBooks: CollectionEntry<'books'>[],
maxResults = 4
): RelatedBookResult[] {
const currentTopics = new Set(currentBook.data.topics.map(normalizeTopic));
return allBooks
.filter((b) => b.slug !== currentBook.slug)
.map((book) => {
const bookTopics = new Set(book.data.topics.map(normalizeTopic));
const sharedTopics = [...currentTopics].filter((t) => bookTopics.has(t));
const union = new Set([...currentTopics, ...bookTopics]);
const jaccard = union.size > 0 ? sharedTopics.length / union.size : 0;
return {
book,
sharedTopics: sharedTopics.map((t) =>
book.data.topics.find((orig) => normalizeTopic(orig) === t) || t
),
overlapCount: sharedTopics.length,
jaccard,
};
})
.filter((r) => r.overlapCount > 0)
.sort((a, b) => {
if (b.overlapCount !== a.overlapCount) return b.overlapCount - a.overlapCount;
if (b.jaccard !== a.jaccard) return b.jaccard - a.jaccard;
return a.book.data.sortOrder - b.book.data.sortOrder;
})
.slice(0, maxResults)
.map(({ book, sharedTopics, overlapCount }) => ({ book, sharedTopics, overlapCount }));
}

View File

@ -3,6 +3,8 @@ import Layout from '@/layouts/Layout.astro';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import EBookReader from '@/components/EBookReader'; import EBookReader from '@/components/EBookReader';
import { getCollection, type CollectionEntry } from 'astro:content'; import { getCollection, type CollectionEntry } from 'astro:content';
import { getRelatedBooks } from '@/lib/related-books';
import RelatedBooks from '@/components/RelatedBooks.astro';
export async function getStaticPaths() { export async function getStaticPaths() {
const books = await getCollection('books'); const books = await getCollection('books');
@ -30,6 +32,7 @@ const mimsBooks = allBooks
const currentIndex = mimsBooks.findIndex(b => b.slug === book.slug); const currentIndex = mimsBooks.findIndex(b => b.slug === book.slug);
const prevBook = currentIndex > 0 ? mimsBooks[currentIndex - 1] : null; const prevBook = currentIndex > 0 ? mimsBooks[currentIndex - 1] : null;
const nextBook = currentIndex < mimsBooks.length - 1 ? mimsBooks[currentIndex + 1] : null; const nextBook = currentIndex < mimsBooks.length - 1 ? mimsBooks[currentIndex + 1] : null;
const relatedBooks = getRelatedBooks(book, allBooks);
--- ---
<Layout title={shortTitle} description={description}> <Layout title={shortTitle} description={description}>
@ -68,16 +71,13 @@ const nextBook = currentIndex < mimsBooks.length - 1 ? mimsBooks[currentIndex +
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{topics.slice(0, 5).map((topic) => ( {topics.map((topic) => (
<Badge variant="secondary" className="text-xs"> <a href={`/mims?topics=${topic}`}>
{topic.replace(/-/g, ' ')} <Badge variant="secondary" className="text-xs hover:bg-primary hover:text-primary-foreground transition-colors">
</Badge> {topic.replace(/-/g, ' ')}
</Badge>
</a>
))} ))}
{topics.length > 5 && (
<Badge variant="outline" className="text-xs">
+{topics.length - 5} more
</Badge>
)}
</div> </div>
</div> </div>
@ -124,6 +124,9 @@ const nextBook = currentIndex < mimsBooks.length - 1 ? mimsBooks[currentIndex +
/> />
</div> </div>
<!-- Related Books -->
<RelatedBooks relatedBooks={relatedBooks} currentCollection="mims" />
<!-- Navigation --> <!-- Navigation -->
<div class="flex items-center justify-between pt-8 border-t border-border"> <div class="flex items-center justify-between pt-8 border-t border-border">
{prevBook ? ( {prevBook ? (

View File

@ -3,6 +3,8 @@ import Layout from '@/layouts/Layout.astro';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import EBookReader from '@/components/EBookReader'; import EBookReader from '@/components/EBookReader';
import { getCollection, type CollectionEntry } from 'astro:content'; import { getCollection, type CollectionEntry } from 'astro:content';
import { getRelatedBooks } from '@/lib/related-books';
import RelatedBooks from '@/components/RelatedBooks.astro';
export async function getStaticPaths() { export async function getStaticPaths() {
const books = await getCollection('books'); const books = await getCollection('books');
@ -30,6 +32,7 @@ const uglysBooks = allBooks
const currentIndex = uglysBooks.findIndex(b => b.slug === book.slug); const currentIndex = uglysBooks.findIndex(b => b.slug === book.slug);
const prevBook = currentIndex > 0 ? uglysBooks[currentIndex - 1] : null; const prevBook = currentIndex > 0 ? uglysBooks[currentIndex - 1] : null;
const nextBook = currentIndex < uglysBooks.length - 1 ? uglysBooks[currentIndex + 1] : null; const nextBook = currentIndex < uglysBooks.length - 1 ? uglysBooks[currentIndex + 1] : null;
const relatedBooks = getRelatedBooks(book, allBooks);
--- ---
<Layout title={shortTitle} description={description}> <Layout title={shortTitle} description={description}>
@ -68,16 +71,13 @@ const nextBook = currentIndex < uglysBooks.length - 1 ? uglysBooks[currentIndex
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{topics.slice(0, 5).map((topic) => ( {topics.map((topic) => (
<Badge variant="secondary" className="text-xs"> <a href={`/uglys?topics=${topic}`}>
{topic.replace(/-/g, ' ')} <Badge variant="secondary" className="text-xs hover:bg-primary hover:text-primary-foreground transition-colors">
</Badge> {topic.replace(/-/g, ' ')}
</Badge>
</a>
))} ))}
{topics.length > 5 && (
<Badge variant="outline" className="text-xs">
+{topics.length - 5} more
</Badge>
)}
</div> </div>
</div> </div>
@ -124,6 +124,9 @@ const nextBook = currentIndex < uglysBooks.length - 1 ? uglysBooks[currentIndex
/> />
</div> </div>
<!-- Related Books -->
<RelatedBooks relatedBooks={relatedBooks} currentCollection="uglys" />
<!-- Navigation --> <!-- Navigation -->
<div class="flex items-center justify-between pt-8 border-t border-border"> <div class="flex items-center justify-between pt-8 border-t border-border">
{prevBook ? ( {prevBook ? (