- 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>
104 lines
3.0 KiB
TypeScript
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 }
|
|
}
|