feat(app): add design system viewer, nostr feed, stop generation, and content refactor

- Design system browser with grid/detail views for tokens and components
- Nostr feed tab with note/article/zap filtering and relay status
- Stop generation button to abort AI streaming mid-response
- Paste & extract content without sending to AI
- Refactor useContentPanel into contentExtraction.ts and contentFiltering.ts
- Banner fallback composable for 3-stage image loading
- Wikipedia and Google Books as fallback image sources
- Loading skeletons with variant-specific shapes
- Mobile UX: auto-switch to content, back button, detail flow
- Project grid with breadcrumb nav and inline creation
- Filesystem Vite plugin for local project browsing
- Magazine text cleanup and song grid polish

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 13:08:32 +00:00
co-authored by Claude Opus 4.6
parent e8fc54cade
commit 00bdc055ba
31 changed files with 3175 additions and 1555 deletions
@@ -207,6 +207,15 @@ export async function handleImgError(
}
}
if (img.dataset.fallback !== 'wiki') {
const wiki = await fetchWikipediaImage(title, 'film')
if (wiki) {
img.dataset.fallback = 'wiki'
img.src = wiki
return
}
}
img.dataset.fallback = 'done'
img.src = generatePosterFallback(title, year)
}
@@ -364,3 +373,132 @@ export async function fetchMusicCover(
return null
}
}
// ---------------------------------------------------------------------------
// Wikipedia image source (free, no key)
// ---------------------------------------------------------------------------
const wikiImageCache = new Map<string, string | null>()
/** Fetch an image from Wikipedia REST API. Free, no API key needed. */
export async function fetchWikipediaImage(
title: string,
disambiguator?: string,
): Promise<string | null> {
const key = `${title.toLowerCase().trim()}|${(disambiguator ?? '').toLowerCase()}`
if (wikiImageCache.has(key)) return wikiImageCache.get(key) ?? null
const tryTitle = async (t: string): Promise<string | null> => {
try {
const encoded = encodeURIComponent(t.trim().replace(/\s+/g, '_'))
const res = await fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encoded}`)
if (!res.ok) return null
const data = (await res.json()) as {
thumbnail?: { source?: string }
originalimage?: { source?: string }
}
return data.originalimage?.source ?? data.thumbnail?.source ?? null
} catch {
return null
}
}
// Try exact title first
let url = await tryTitle(title)
// Try with disambiguator suffix if no result
if (!url && disambiguator) {
url = await tryTitle(`${title} (${disambiguator})`)
}
wikiImageCache.set(key, url)
return url
}
// ---------------------------------------------------------------------------
// Google Books image source (free, no key)
// ---------------------------------------------------------------------------
/** Fetch book cover from Google Books API. Free, no API key needed. */
export async function fetchGoogleBooksImage(
title: string,
author?: string,
): Promise<string | null> {
const key = bookCacheKey(title, author ?? '')
const gKey = `gbooks:${key}`
if (wikiImageCache.has(gKey)) return wikiImageCache.get(gKey) ?? null
try {
const q = author ? `intitle:${title}+inauthor:${author}` : `intitle:${title}`
const res = await fetch(
`https://www.googleapis.com/books/v1/volumes?q=${encodeURIComponent(q)}&maxResults=1`,
)
if (!res.ok) return null
const data = (await res.json()) as {
items?: { volumeInfo?: { imageLinks?: { thumbnail?: string; smallThumbnail?: string } } }[]
}
const links = data.items?.[0]?.volumeInfo?.imageLinks
let url = links?.thumbnail ?? links?.smallThumbnail ?? null
// Google Books returns http URLs and small sizes — upgrade
if (url) {
url = url.replace(/^http:/, 'https:').replace(/&edge=curl/g, '')
// Request larger zoom
if (!url.includes('zoom=')) url += '&zoom=2'
}
wikiImageCache.set(gKey, url)
return url
} catch {
return null
}
}
// ---------------------------------------------------------------------------
// Chained fetchers — try multiple sources in order
// ---------------------------------------------------------------------------
/** Film image: TMDB → Wikipedia */
export async function fetchFilmImage(
title: string,
year?: number,
): Promise<{ posterUrl: string | null; backdropUrl: string | null }> {
// Try TMDB first
const tmdb = await fetchTmdbPoster(title, year)
if (tmdb.posterUrl || tmdb.backdropUrl) return tmdb
// Fall back to Wikipedia
const wiki = await fetchWikipediaImage(title, 'film')
if (wiki) return { posterUrl: wiki, backdropUrl: null }
return { posterUrl: null, backdropUrl: null }
}
/** TV series image: TMDB → Wikipedia */
export async function fetchTVImage(
title: string,
year?: number,
): Promise<{ posterUrl: string | null; backdropUrl: string | null }> {
const tmdb = await fetchTmdbTVPoster(title, year)
if (tmdb.posterUrl || tmdb.backdropUrl) return tmdb
const wiki = await fetchWikipediaImage(title, 'TV series')
if (wiki) return { posterUrl: wiki, backdropUrl: null }
return { posterUrl: null, backdropUrl: null }
}
/** Book image: Open Library → Google Books → Wikipedia */
export async function fetchBookImage(
title: string,
author?: string,
): Promise<string | null> {
// Try Open Library first
const ol = await fetchBookCover(title, author ?? '')
if (ol) return ol
// Try Google Books
const gb = await fetchGoogleBooksImage(title, author)
if (gb) return gb
// Try Wikipedia
const wiki = await fetchWikipediaImage(title, 'novel')
return wiki
}