Files
archy/packages/app/src/composables/useBannerFallback.ts
T
DorianandClaude Opus 4.6 00bdc055ba 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>
2026-03-03 13:08:32 +00:00

104 lines
3.0 KiB
TypeScript

import { ref, computed, type ComputedRef } from 'vue'
export interface BannerFallbackOptions {
/** Primary image URL candidates, tried in order */
primaryUrls: () => (string | undefined | null)[]
/** Async fetch to try when all primary URLs fail */
apiFetch: () => Promise<{ posterUrl: string | null; backdropUrl: string | null }>
/** Title for gradient generation */
title: () => string
/** Optional seed override for gradient hue */
gradientSeed?: () => string
}
export interface BannerFallbackReturn {
bannerSrc: ComputedRef<string | null>
fallbackGradient: ComputedRef<string>
onBannerError: () => void
}
export function useBannerFallback(options: BannerFallbackOptions): BannerFallbackReturn {
const primaryIndex = ref(0)
const stage = ref<'primary' | 'api' | 'done'>('primary')
const apiUrl = ref<string | null>(null)
let apiFetching = false
const bannerSrc = computed<string | null>(() => {
if (stage.value === 'done') return null
if (stage.value === 'primary') {
const urls = options.primaryUrls()
// Find first non-null URL starting from primaryIndex
for (let i = primaryIndex.value; i < urls.length; i++) {
if (urls[i]) return urls[i]!
}
// No primary URLs available — skip to API immediately
return null
}
if (stage.value === 'api' && apiUrl.value) return apiUrl.value
return null
})
const fallbackGradient = computed(() => {
const seed = options.gradientSeed ? options.gradientSeed() : options.title()
const hue = [...seed].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
return `linear-gradient(135deg, hsl(${hue}, 25%, 12%) 0%, hsl(${(hue + 40) % 360}, 20%, 8%) 100%)`
})
async function onBannerError() {
if (stage.value === 'primary') {
const urls = options.primaryUrls()
// Advance to next primary URL
let nextIdx = primaryIndex.value + 1
while (nextIdx < urls.length && !urls[nextIdx]) nextIdx++
if (nextIdx < urls.length) {
primaryIndex.value = nextIdx
return
}
// All primaries exhausted — try API
if (!apiFetching) {
apiFetching = true
try {
const result = await options.apiFetch()
const url = result.backdropUrl ?? result.posterUrl
if (url) {
apiUrl.value = url
stage.value = 'api'
return
}
} catch { /* ignore */ }
}
stage.value = 'done'
return
}
if (stage.value === 'api') {
stage.value = 'done'
}
}
// If no primary URLs at all, trigger API fetch on first render
const urls = options.primaryUrls()
const hasAnyPrimary = urls.some(u => !!u)
if (!hasAnyPrimary && !apiFetching) {
apiFetching = true
options.apiFetch().then(result => {
const url = result.backdropUrl ?? result.posterUrl
if (url) {
apiUrl.value = url
stage.value = 'api'
} else {
stage.value = 'done'
}
}).catch(() => {
stage.value = 'done'
})
}
return { bannerSrc, fallbackGradient, onBannerError }
}