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:
co-authored by
Claude Opus 4.6
parent
aab55ef9ca
commit
99e380e1b2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user