feat(content): add Books content type and ContentPanel tab system

Wire up ContentPanel with tab navigation for all existing content types
(films, songs, podcasts, news, websites, magazine). Add complete Books
pipeline: type definition, extraction (tags + pattern matching), cover
fetching from Open Library, BookCard/BookGrid/BookDetail components,
inline chat cards, and prompt index badges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 01:27:00 +00:00
co-authored by Claude Opus 4.6
parent aab55ef9ca
commit 99e380e1b2
9 changed files with 828 additions and 14 deletions
@@ -214,6 +214,74 @@ export async function fetchPodcastCover(
}
/** Fetch album artwork from iTunes Search API (free, no key). Returns hi-res URL (600x600). */
const bookCoverCache = new Map<string, string>()
const SESSION_BOOK_KEY = 'aiui-book-cover-cache'
function bookCacheKey(title: string, author: string): string {
return `${title.toLowerCase().trim()}|${author.toLowerCase().trim()}`
}
function loadBookCache(): void {
try {
const raw = sessionStorage.getItem(SESSION_BOOK_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Record<string, string>
Object.entries(parsed).forEach(([k, v]) => bookCoverCache.set(k, v))
}
} catch { /* ignore */ }
}
function saveBookCache(): void {
try {
const entries = [...bookCoverCache.entries()].slice(-200)
sessionStorage.setItem(SESSION_BOOK_KEY, JSON.stringify(Object.fromEntries(entries)))
} catch { /* ignore */ }
}
loadBookCache()
export function generateBookCoverFallback(title: string, author?: string): string {
const hue = [...(title + (author ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 420">
<rect width="280" height="420" fill="hsl(${hue}, 25%, 12%)"/>
<rect x="2" y="2" width="276" height="416" rx="4" fill="none" stroke="hsl(${hue}, 35%, 22%)" stroke-width="2"/>
<rect x="12" y="12" width="256" height="396" rx="2" fill="none" stroke="hsl(${hue}, 30%, 18%)" stroke-width="1"/>
<line x1="28" y1="12" x2="28" y2="408" stroke="hsl(${hue}, 30%, 20%)" stroke-width="2"/>
<text x="154" y="190" text-anchor="middle" fill="hsl(${hue}, 50%, 70%)" font-family="Georgia,serif" font-size="18" font-weight="700">
${escapeXml(title.length > 22 ? title.slice(0, 20) + '…' : title)}
</text>
${author ? `<text x="154" y="220" text-anchor="middle" fill="hsl(${hue}, 30%, 50%)" font-family="system-ui" font-size="12">${escapeXml(author.length > 26 ? author.slice(0, 24) + '…' : author)}</text>` : ''}
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
/** Fetch book cover from Open Library Covers API (free, no key). */
export async function fetchBookCover(
title: string,
author: string,
): Promise<string | null> {
const key = bookCacheKey(title, author)
const cached = bookCoverCache.get(key)
if (cached) return cached
try {
const q = `${title} ${author}`.trim()
const res = await fetch(
`https://openlibrary.org/search.json?q=${encodeURIComponent(q)}&limit=1&fields=cover_i`,
)
if (!res.ok) return null
const data = (await res.json()) as { docs?: { cover_i?: number }[] }
const coverId = data.docs?.[0]?.cover_i
if (!coverId) return null
const url = `https://covers.openlibrary.org/b/id/${coverId}-L.jpg`
bookCoverCache.set(key, url)
saveBookCache()
return url
} catch {
return null
}
}
export async function fetchMusicCover(
title: string,
artist: string,