feat(app): add real images to all seed content + universal SVG text fallbacks

- Songs: 19/21 now have iTunes album art URLs (2 niche Bitcoin artists use fallback)
- Podcasts: 21/21 now have iTunes artwork URLs
- News: 21/21 now have Unsplash topic images
- Books: Fixed The Network State cover URL
- Places: 21/21 now have photos (Unsplash + Wikimedia)
- Added generateNewsFallback() and generateImageFallback() SVG generators
- Updated NewsCard, NewsGrid, ImageCard to use SVG text fallback instead of emoji
- Added error handling to PlaceCard and PlaceGrid for failed photo loads

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 20:11:15 +00:00
co-authored by Claude Opus 4.6
parent 982a0c6aa6
commit c88e837cc9
11 changed files with 156 additions and 153 deletions
@@ -18,13 +18,9 @@
/>
<div
v-else
class="w-full h-full flex items-center justify-center"
:class="isDark ? 'bg-white/5' : 'bg-black/5'"
>
<svg class="w-5 h-5" :class="isDark ? 'text-white/20' : 'text-gray-300'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackImg})` }"
/>
</div>
</div>
<div class="flex-1 min-w-0 py-0.5">
@@ -45,13 +41,18 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import type { ImageItem } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateImageFallback } from '@/composables/useImageFallback'
defineProps<{ image: ImageItem }>()
const props = defineProps<{ image: ImageItem }>()
defineEmits<{ select: [image: ImageItem] }>()
const { isDark } = useTheme()
const imgFailed = ref(false)
const fallbackImg = computed(() =>
generateImageFallback(props.image.title || props.image.alt || 'Image')
)
</script>
@@ -17,13 +17,9 @@
/>
<div
v-else
class="w-full h-full rounded-[6px] bg-cover bg-center flex items-center justify-center"
:style="{ backgroundImage: faviconUrl ? `url(${faviconUrl})` : undefined }"
>
<span v-if="!faviconUrl"
class="text-lg opacity-40"
:class="isDark ? 'text-white' : 'text-gray-600'">📰</span>
</div>
class="w-full h-full rounded-[6px] bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackImg})` }"
/>
</div>
<div class="min-w-0 flex-1 py-0.5">
@@ -52,6 +48,7 @@ import { ref, computed } from 'vue'
import type { WebSearchResult } from '@aiui/core/types/message'
import { useTheme } from '@/composables/useTheme'
import { isSafeUrl, formatDomain } from '@/utils/html'
import { generateNewsFallback } from '@/composables/useImageFallback'
const props = defineProps<{ article: WebSearchResult }>()
defineEmits<{ 'select-article': [article: WebSearchResult] }>()
@@ -65,15 +62,8 @@ const imgSrc = computed(() => {
return isSafeUrl(u) ? u : null
})
const faviconUrl = computed(() => {
if (imgSrc.value) return null
try {
const u = new URL(props.article.url)
if (!/^https?:\/\//i.test(props.article.url)) return null
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(u.hostname)}&sz=64`
} catch {
return null
}
})
const fallbackImg = computed(() =>
generateNewsFallback(props.article.title, formatDomain(props.article.url))
)
</script>
@@ -49,37 +49,9 @@
/>
<div
v-else
class="absolute inset-0 flex items-center justify-center"
:class="isDark ? 'bg-white/5' : 'bg-black/[0.04]'"
>
<!-- Websites: glassmorphic circle -->
<div
v-if="variant === 'websites'"
class="w-14 h-14 rounded-full flex items-center justify-center path-glass-icon shrink-0"
>
<img
v-if="faviconUrl(article.url)"
:src="faviconUrl(article.url)!"
:alt="formatDomain(article.url)"
class="w-7 h-7 object-contain"
/>
<span v-else
class="text-xl opacity-70"
:class="isDark ? 'text-white' : 'text-gray-600'">🌐</span>
</div>
<!-- News: icon -->
<template v-else>
<img
v-if="faviconUrl(article.url)"
:src="faviconUrl(article.url)!"
:alt="formatDomain(article.url)"
class="w-8 h-8 object-contain opacity-70"
/>
<span v-else
class="text-2xl opacity-40"
:class="isDark ? 'text-white' : 'text-gray-600'">📰</span>
</template>
</div>
class="absolute inset-0 bg-cover bg-center"
:style="{ backgroundImage: `url(${newsFallback(article)})` }"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent pointer-events-none" />
</div>
@@ -122,6 +94,7 @@ import { ref, computed } from 'vue'
import type { WebSearchResult } from '@aiui/core/types/message'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel } from '@/composables/useContentPanel'
import { generateNewsFallback } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
articles: WebSearchResult[]
@@ -158,14 +131,8 @@ function formatDomain(url: string): string {
}
}
function faviconUrl(url: string): string | null {
try {
if (!/^https?:\/\//i.test(url)) return null
const u = new URL(url)
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(u.hostname)}&sz=64`
} catch {
return null
}
function newsFallback(article: WebSearchResult): string {
return generateNewsFallback(article.title, formatDomain(article.url))
}
function openArticle(article: WebSearchResult) {
@@ -8,11 +8,12 @@
>
<div class="shrink-0 w-12 h-12 rounded-lg overflow-hidden">
<img
v-if="place.photoUrl"
:src="place.photoUrl"
v-if="photoSrc"
:src="photoSrc"
:alt="place.name"
class="w-full h-full object-cover rounded-[6px] transition-transform duration-300 group-hover:scale-105"
loading="lazy"
@error="photoFailed = true"
/>
<div
v-else
@@ -45,7 +46,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed } from 'vue'
import type { Place } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePlaceFallback } from '@/composables/useImageFallback'
@@ -54,6 +55,12 @@ const props = defineProps<{ place: Place }>()
defineEmits<{ select: [place: Place] }>()
const { isDark } = useTheme()
const photoFailed = ref(false)
const photoSrc = computed(() => {
if (photoFailed.value) return null
return props.place.photoUrl || null
})
const fallbackPhoto = computed(() =>
generatePlaceFallback(props.place.name, props.place.cuisine || props.place.category)
@@ -47,14 +47,15 @@
>
<div class="aspect-[4/3] relative w-full overflow-hidden rounded-t-[10px]">
<img
v-if="place.photoUrl"
v-if="place.photoUrl && !failedPhotos.has(place.id)"
:src="place.photoUrl"
:alt="place.name"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onPhotoError(place.id)"
/>
<div
v-else
v-if="!place.photoUrl || failedPhotos.has(place.id)"
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(place)})` }"
/>
@@ -103,6 +104,11 @@ defineEmits<{ selectPlace: [place: Place] }>()
const { isDark } = useTheme()
const search = ref('')
const activeCategory = ref<string | null>(null)
const failedPhotos = ref<Set<string>>(new Set())
function onPhotoError(id: string) {
failedPhotos.value = new Set([...failedPhotos.value, id])
}
function fallbackFor(place: Place): string {
return generatePlaceFallback(place.name, place.cuisine || place.category)