Add related books section and linkable topic badges
This commit is contained in:
parent
287debeafc
commit
74988ffd1f
70
site/src/components/RelatedBooks.astro
Normal file
70
site/src/components/RelatedBooks.astro
Normal 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>
|
||||
)}
|
||||
45
site/src/lib/related-books.ts
Normal file
45
site/src/lib/related-books.ts
Normal 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 }));
|
||||
}
|
||||
@ -3,6 +3,8 @@ import Layout from '@/layouts/Layout.astro';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import EBookReader from '@/components/EBookReader';
|
||||
import { getCollection, type CollectionEntry } from 'astro:content';
|
||||
import { getRelatedBooks } from '@/lib/related-books';
|
||||
import RelatedBooks from '@/components/RelatedBooks.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const books = await getCollection('books');
|
||||
@ -30,6 +32,7 @@ const mimsBooks = allBooks
|
||||
const currentIndex = mimsBooks.findIndex(b => b.slug === book.slug);
|
||||
const prevBook = currentIndex > 0 ? mimsBooks[currentIndex - 1] : null;
|
||||
const nextBook = currentIndex < mimsBooks.length - 1 ? mimsBooks[currentIndex + 1] : null;
|
||||
const relatedBooks = getRelatedBooks(book, allBooks);
|
||||
---
|
||||
|
||||
<Layout title={shortTitle} description={description}>
|
||||
@ -68,16 +71,13 @@ const nextBook = currentIndex < mimsBooks.length - 1 ? mimsBooks[currentIndex +
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{topics.slice(0, 5).map((topic) => (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{topic.replace(/-/g, ' ')}
|
||||
</Badge>
|
||||
{topics.map((topic) => (
|
||||
<a href={`/mims?topics=${topic}`}>
|
||||
<Badge variant="secondary" className="text-xs hover:bg-primary hover:text-primary-foreground transition-colors">
|
||||
{topic.replace(/-/g, ' ')}
|
||||
</Badge>
|
||||
</a>
|
||||
))}
|
||||
{topics.length > 5 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{topics.length - 5} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -124,6 +124,9 @@ const nextBook = currentIndex < mimsBooks.length - 1 ? mimsBooks[currentIndex +
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Related Books -->
|
||||
<RelatedBooks relatedBooks={relatedBooks} currentCollection="mims" />
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="flex items-center justify-between pt-8 border-t border-border">
|
||||
{prevBook ? (
|
||||
|
||||
@ -3,6 +3,8 @@ import Layout from '@/layouts/Layout.astro';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import EBookReader from '@/components/EBookReader';
|
||||
import { getCollection, type CollectionEntry } from 'astro:content';
|
||||
import { getRelatedBooks } from '@/lib/related-books';
|
||||
import RelatedBooks from '@/components/RelatedBooks.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const books = await getCollection('books');
|
||||
@ -30,6 +32,7 @@ const uglysBooks = allBooks
|
||||
const currentIndex = uglysBooks.findIndex(b => b.slug === book.slug);
|
||||
const prevBook = currentIndex > 0 ? uglysBooks[currentIndex - 1] : null;
|
||||
const nextBook = currentIndex < uglysBooks.length - 1 ? uglysBooks[currentIndex + 1] : null;
|
||||
const relatedBooks = getRelatedBooks(book, allBooks);
|
||||
---
|
||||
|
||||
<Layout title={shortTitle} description={description}>
|
||||
@ -68,16 +71,13 @@ const nextBook = currentIndex < uglysBooks.length - 1 ? uglysBooks[currentIndex
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{topics.slice(0, 5).map((topic) => (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{topic.replace(/-/g, ' ')}
|
||||
</Badge>
|
||||
{topics.map((topic) => (
|
||||
<a href={`/uglys?topics=${topic}`}>
|
||||
<Badge variant="secondary" className="text-xs hover:bg-primary hover:text-primary-foreground transition-colors">
|
||||
{topic.replace(/-/g, ' ')}
|
||||
</Badge>
|
||||
</a>
|
||||
))}
|
||||
{topics.length > 5 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{topics.length - 5} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -124,6 +124,9 @@ const nextBook = currentIndex < uglysBooks.length - 1 ? uglysBooks[currentIndex
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Related Books -->
|
||||
<RelatedBooks relatedBooks={relatedBooks} currentCollection="uglys" />
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="flex items-center justify-between pt-8 border-t border-border">
|
||||
{prevBook ? (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user