fix(app): fix image fallbacks across all content grids, refactor into useContentImages

- Create useContentImages<T> composable eliminating ~240 lines of duplicated
  image loading/fallback logic across 6 grid components
- Fix book covers: use full fetchBookImage chain (Open Library → Google Books → Wikipedia)
- Fix TV series images: try disambiguated Wikipedia title first (e.g. "Chernobyl (TV series)")
- Add Wikipedia image fetching for places (fetchPlaceImage)
- Rewrite all SVG fallbacks to consistent text-only style (no icons)
- Add generateWebsiteFallback for NewsGrid websites variant
- Fix song extraction regex catching raw song_ext: prefix in titles
- Fix player bar: clean song_ext: prefix from display, mute error text
- Fix Code panel: auto-load projects on mount when list is empty
- Improve chat bubble spacing (py-2.5) and first message top margin (pt-6)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-06 15:55:40 +00:00
co-authored by Claude Opus 4.6
parent 1eaf30ae12
commit 2304e5904e
17 changed files with 294 additions and 310 deletions
@@ -75,7 +75,7 @@
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}"
class="px-4 py-1.5"
:class="['px-4 py-2.5', virtualRow.index === 0 ? 'pt-6' : '']"
>
<ErrorBoundary title="Message failed to render">
<ChatMessage
@@ -44,7 +44,7 @@
import { ref, computed, onMounted } from 'vue'
import type { Book } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateBookCoverFallback, fetchBookCover } from '@/composables/useImageFallback'
import { generateBookCoverFallback, fetchBookImage } from '@/composables/useImageFallback'
const props = defineProps<{ book: Book }>()
defineEmits<{ select: [book: Book] }>()
@@ -64,7 +64,7 @@ const fallbackCover = computed(() =>
onMounted(() => {
if (props.book.coverUrl) return
fetchBookCover(props.book.title, props.book.author).then((url) => {
fetchBookImage(props.book.title, props.book.author).then((url) => {
if (url) fetchedCover.value = url
})
})
@@ -54,19 +54,20 @@
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(book) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="!coverSrc(book) && !failedCovers.has(book.id)" class="absolute inset-0 animate-shimmer" />
<div v-if="isLoading(book)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(book)"
:src="coverSrc(book)!"
:alt="`${book.title} by ${book.author}`"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onCoverError(book)"
@error="onError(book)"
/>
<div
v-else-if="failedCovers.has(book.id)"
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(book)})` }"
<img
v-else-if="!isLoading(book)"
:src="fallbackSrc(book)"
:alt="book.title"
class="w-full h-full object-cover"
/>
<div v-if="coverSrc(book)" class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
@@ -103,10 +104,11 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, watch } from 'vue'
import { ref, computed, toRef } from 'vue'
import type { Book } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateBookCoverFallback, fetchBookCover } from '@/composables/useImageFallback'
import { useContentImages } from '@/composables/useContentImages'
import { generateBookCoverFallback, fetchBookImage } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
books: Book[]
@@ -120,39 +122,14 @@ defineEmits<{ selectBook: [book: Book] }>()
const { isDark } = useTheme()
const search = ref('')
const activeGenre = ref<string | null>(null)
const failedCovers = ref<Set<string>>(new Set())
const fetchedCovers = reactive<Map<string, string>>(new Map())
function coverSrc(book: Book): string | null {
if (failedCovers.value.has(book.id)) return null
return book.coverUrl || fetchedCovers.get(book.id) || null
}
function fallbackFor(book: Book): string {
return generateBookCoverFallback(book.title, book.author)
}
function onCoverError(book: Book) {
failedCovers.value.add(book.id)
failedCovers.value = new Set(failedCovers.value)
}
function fetchCoversFor(books: Book[]) {
for (const book of books) {
if (book.coverUrl || fetchedCovers.has(book.id) || failedCovers.value.has(book.id)) continue
fetchBookCover(book.title, book.author).then((url) => {
if (url) {
fetchedCovers.set(book.id, url)
} else {
failedCovers.value = new Set([...failedCovers.value, book.id])
}
}).catch(() => {
failedCovers.value = new Set([...failedCovers.value, book.id])
})
}
}
watch(() => props.books, (books) => fetchCoversFor(books), { immediate: true })
const { coverSrc, fallbackSrc, onError, isLoading } = useContentImages({
items: toRef(props, 'books'),
id: (b) => b.id,
existingUrl: (b) => b.coverUrl,
fetch: (b) => fetchBookImage(b.title, b.author),
fallback: (b) => generateBookCoverFallback(b.title, b.author),
})
const topGenres = computed(() => {
const counts = new Map<string, number>()
@@ -54,19 +54,20 @@
>
<div class="poster-card flex-1 min-h-0">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(film) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="!coverSrc(film) && !failedCovers.has(film.id)" class="absolute inset-0 animate-shimmer" />
<div v-if="isLoading(film)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(film)"
:src="coverSrc(film)!"
:alt="`${film.title} (${film.year}) directed by ${film.director}`"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onCoverError(film)"
@error="onError(film)"
/>
<div
v-else-if="failedCovers.has(film.id)"
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(film)})` }"
<img
v-else-if="!isLoading(film)"
:src="fallbackSrc(film)"
:alt="film.title"
class="w-full h-full object-cover"
/>
<div v-if="coverSrc(film)" class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
@@ -104,10 +105,11 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, watch } from 'vue'
import { ref, computed, toRef } from 'vue'
import type { Film } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { handleImgError, fetchFilmImage, generatePosterFallback } from '@/composables/useImageFallback'
import { useContentImages } from '@/composables/useContentImages'
import { fetchFilmImage, generatePosterFallback } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
films: Film[]
@@ -121,40 +123,14 @@ defineEmits<{ selectFilm: [film: Film] }>()
const { isDark } = useTheme()
const search = ref('')
const activeGenre = ref<string | null>(null)
const failedCovers = ref<Set<string>>(new Set())
const fetchedCovers = reactive<Map<string, string>>(new Map())
function coverSrc(film: Film): string | null {
if (failedCovers.value.has(film.id)) return null
const url = film.posterUrl || film.backdropUrl || fetchedCovers.get(film.id)
return url || null
}
function fallbackFor(film: Film): string {
return generatePosterFallback(film.title, film.year)
}
function onCoverError(film: Film) {
failedCovers.value.add(film.id)
failedCovers.value = new Set(failedCovers.value)
}
function fetchCoversFor(films: Film[]) {
for (const film of films) {
if (film.posterUrl || fetchedCovers.has(film.id) || failedCovers.value.has(film.id)) continue
fetchFilmImage(film.title, film.year).then((result) => {
if (result.posterUrl) {
fetchedCovers.set(film.id, result.posterUrl)
} else {
failedCovers.value = new Set([...failedCovers.value, film.id])
}
}).catch(() => {
failedCovers.value = new Set([...failedCovers.value, film.id])
})
}
}
watch(() => props.films, (films) => fetchCoversFor(films), { immediate: true })
const { coverSrc, fallbackSrc, onError, isLoading } = useContentImages({
items: toRef(props, 'films'),
id: (f) => f.id,
existingUrl: (f) => f.posterUrl || f.backdropUrl,
fetch: (f) => fetchFilmImage(f.title, f.year).then((r) => r.posterUrl),
fallback: (f) => generatePosterFallback(f.title, f.year),
})
const topGenres = computed(() => {
const counts = new Map<string, number>()
@@ -95,7 +95,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'
import { generateNewsFallback, generateWebsiteFallback } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
articles: WebSearchResult[]
@@ -133,6 +133,9 @@ function formatDomain(url: string): string {
}
function newsFallback(article: WebSearchResult): string {
if (props.variant === 'websites') {
return generateWebsiteFallback(article.title, formatDomain(article.url))
}
return generateNewsFallback(article.title, formatDomain(article.url))
}
@@ -46,20 +46,28 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import type { Place } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePlaceFallback } from '@/composables/useImageFallback'
import { generatePlaceFallback, fetchPlaceImage } from '@/composables/useImageFallback'
const props = defineProps<{ place: Place }>()
defineEmits<{ select: [place: Place] }>()
const { isDark } = useTheme()
const photoFailed = ref(false)
const fetchedPhoto = ref<string | null>(null)
const photoSrc = computed(() => {
if (photoFailed.value) return null
return props.place.photoUrl || null
return props.place.photoUrl || fetchedPhoto.value || null
})
onMounted(() => {
if (props.place.photoUrl) return
fetchPlaceImage(props.place.name, props.place.city).then((url) => {
if (url) fetchedPhoto.value = url
})
})
const fallbackPhoto = computed(() =>
@@ -3,15 +3,16 @@
<div class="relative w-full overflow-hidden">
<div class="w-full aspect-[16/9] flex items-center justify-center overflow-hidden bg-black/20">
<img
v-if="place.photoUrl"
:src="place.photoUrl"
v-if="place.photoUrl || fetchedPhoto"
:src="(place.photoUrl || fetchedPhoto)!"
:alt="place.name"
class="w-full h-full object-cover object-center block"
/>
<div
<img
v-else
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackCover})` }"
:src="fallbackCover"
:alt="place.name"
class="w-full h-full object-cover"
/>
</div>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
@@ -138,20 +139,28 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import type { Place } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePlaceFallback } from '@/composables/useImageFallback'
import { generatePlaceFallback, fetchPlaceImage } from '@/composables/useImageFallback'
const props = defineProps<{ place: Place }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const fetchedPhoto = ref<string | null>(null)
const fallbackCover = computed(() =>
generatePlaceFallback(props.place.name, props.place.cuisine || props.place.category)
)
onMounted(() => {
if (props.place.photoUrl) return
fetchPlaceImage(props.place.name, props.place.city).then((url) => {
if (url) fetchedPhoto.value = url
})
})
const websiteDomain = computed(() => {
if (!props.place.website) return ''
try {
@@ -47,20 +47,22 @@
@click="$emit('selectPlace', place)"
>
<div class="aspect-[4/3] relative w-full overflow-hidden rounded-t-[10px]">
<div v-if="isLoading(place)" class="absolute inset-0 animate-shimmer" />
<img
v-if="place.photoUrl && !failedPhotos.has(place.id)"
:src="place.photoUrl"
v-if="coverSrc(place)"
:src="coverSrc(place)!"
:alt="place.name"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onPhotoError(place.id)"
@error="onError(place)"
/>
<div
v-if="!place.photoUrl || failedPhotos.has(place.id)"
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(place)})` }"
<img
v-else-if="!isLoading(place)"
:src="fallbackSrc(place)"
:alt="place.name"
class="w-full h-full object-cover"
/>
<div v-if="place.photoUrl && !failedPhotos.has(place.id)" class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
<div v-if="coverSrc(place)" class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
<div class="absolute bottom-0 left-0 right-0 p-2">
<p class="text-xs font-semibold text-white/90 truncate">{{ place.name }}</p>
<p class="text-xs text-white/40 truncate mt-0.5">{{ place.cuisine || place.category }}</p>
@@ -88,10 +90,11 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, toRef } from 'vue'
import type { Place } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePlaceFallback } from '@/composables/useImageFallback'
import { useContentImages } from '@/composables/useContentImages'
import { generatePlaceFallback, fetchPlaceImage } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
places: Place[]
@@ -105,15 +108,14 @@ 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)
}
const { coverSrc, fallbackSrc, onError, isLoading } = useContentImages({
items: toRef(props, 'places'),
id: (p) => p.id,
existingUrl: (p) => p.photoUrl,
fetch: (p) => fetchPlaceImage(p.name, p.city),
fallback: (p) => generatePlaceFallback(p.name, p.cuisine || p.category),
})
const topCategories = computed(() => {
const counts = new Map<string, number>()
@@ -54,19 +54,20 @@
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-square relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(podcast) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="!coverSrc(podcast) && !failedCovers.has(podcast.id)" class="absolute inset-0 animate-shimmer" />
<div v-if="isLoading(podcast)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(podcast)"
:src="coverSrc(podcast)!"
:alt="podcast.title"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onCoverError(podcast)"
@error="onError(podcast)"
/>
<div
v-else-if="failedCovers.has(podcast.id)"
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(podcast)})` }"
<img
v-else-if="!isLoading(podcast)"
:src="fallbackSrc(podcast)"
:alt="podcast.title"
class="w-full h-full object-cover"
/>
<div v-if="coverSrc(podcast)" class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
@@ -101,9 +102,10 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, watch } from 'vue'
import { ref, computed, toRef } from 'vue'
import type { Podcast } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useContentImages } from '@/composables/useContentImages'
import { generatePodcastCoverFallback, fetchPodcastCover } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
@@ -118,39 +120,14 @@ defineEmits<{ selectPodcast: [podcast: Podcast] }>()
const { isDark } = useTheme()
const search = ref('')
const activeGenre = ref<string | null>(null)
const failedCovers = ref<Set<string>>(new Set())
const fetchedCovers = reactive<Map<string, string>>(new Map())
function coverSrc(podcast: Podcast): string | null {
if (failedCovers.value.has(podcast.id)) return null
return podcast.coverUrl || fetchedCovers.get(podcast.id) || null
}
function fetchCoversFor(podcasts: Podcast[]) {
for (const podcast of podcasts) {
if (podcast.coverUrl || fetchedCovers.has(podcast.id) || failedCovers.value.has(podcast.id)) continue
fetchPodcastCover(podcast.title, podcast.host).then((url) => {
if (url) {
fetchedCovers.set(podcast.id, url)
} else {
failedCovers.value = new Set([...failedCovers.value, podcast.id])
}
}).catch(() => {
failedCovers.value = new Set([...failedCovers.value, podcast.id])
})
}
}
watch(() => props.podcasts, (p) => fetchCoversFor(p), { immediate: true })
function fallbackFor(podcast: Podcast): string {
return generatePodcastCoverFallback(podcast.title, podcast.host)
}
function onCoverError(podcast: Podcast) {
failedCovers.value.add(podcast.id)
failedCovers.value = new Set(failedCovers.value)
}
const { coverSrc, fallbackSrc, onError, isLoading } = useContentImages({
items: toRef(props, 'podcasts'),
id: (p) => p.id,
existingUrl: (p) => p.coverUrl,
fetch: (p) => fetchPodcastCover(p.title, p.host),
fallback: (p) => generatePodcastCoverFallback(p.title, p.host),
})
const topGenres = computed(() => {
const counts = new Map<string, number>()
@@ -156,7 +156,7 @@
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { ref, computed, nextTick, onMounted } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useCodeContext, type ProjectInfo } from '@/composables/useCodeContext'
import FileTreeNode from './FileTreeNode.vue'
@@ -180,8 +180,13 @@ const {
clearActiveFile,
toggleFileSelection,
isFileSelected,
loadProjects,
} = useCodeContext()
onMounted(() => {
if (projectList.value.length === 0) loadProjects()
})
const search = ref('')
const isCreatingProject = ref(false)
const newProjectName = ref('')
@@ -54,7 +54,7 @@
>
<div class="cover-card flex-1 min-h-0 relative flex items-center justify-center">
<div class="aspect-square relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(song) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="!coverSrc(song) && !failedCovers.has(song.id)" class="absolute inset-0 animate-shimmer" />
<div v-if="isLoading(song)" class="absolute inset-0 animate-shimmer" />
<button
class="absolute inset-0 flex items-center justify-center z-10 backdrop-blur-sm bg-black/30 opacity-0 group-hover:opacity-100 transition-all duration-200"
aria-label="Play"
@@ -72,12 +72,13 @@
:alt="`${song.title} by ${song.artist}`"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onCoverError(song)"
@error="onError(song)"
/>
<div
v-else-if="failedCovers.has(song.id)"
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(song)})` }"
<img
v-else-if="!isLoading(song)"
:src="fallbackSrc(song)"
:alt="song.title"
class="w-full h-full object-cover"
/>
<div v-if="coverSrc(song)" class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
@@ -112,10 +113,11 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, watch } from 'vue'
import { ref, computed, toRef } from 'vue'
import type { Song } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { usePlayer } from '@/composables/usePlayer'
import { useContentImages } from '@/composables/useContentImages'
import { generateSongCoverFallback, fetchMusicCover } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
@@ -131,40 +133,14 @@ const { isDark } = useTheme()
const { play } = usePlayer()
const search = ref('')
const activeGenre = ref<string | null>(null)
const failedCovers = ref<Set<string>>(new Set())
const fetchedCovers = reactive<Map<string, string>>(new Map())
function coverSrc(song: Song): string | null {
if (failedCovers.value.has(song.id)) return null
const url = song.coverUrl || fetchedCovers.get(song.id)
return url || null
}
function fallbackFor(song: Song): string {
return generateSongCoverFallback(song.title, song.artist)
}
function onCoverError(song: Song) {
failedCovers.value.add(song.id)
failedCovers.value = new Set(failedCovers.value)
}
function fetchCoversFor(songs: Song[]) {
for (const song of songs) {
if (song.coverUrl || fetchedCovers.has(song.id) || failedCovers.value.has(song.id)) continue
fetchMusicCover(song.title, song.artist, song.album).then((url) => {
if (url) {
fetchedCovers.set(song.id, url)
} else {
failedCovers.value = new Set([...failedCovers.value, song.id])
}
}).catch(() => {
failedCovers.value = new Set([...failedCovers.value, song.id])
})
}
}
watch(() => props.songs, (songs) => fetchCoversFor(songs), { immediate: true })
const { coverSrc, fallbackSrc, onError, isLoading } = useContentImages({
items: toRef(props, 'songs'),
id: (s) => s.id,
existingUrl: (s) => s.coverUrl,
fetch: (s) => fetchMusicCover(s.title, s.artist, s.album),
fallback: (s) => generateSongCoverFallback(s.title, s.artist),
})
const topGenres = computed(() => {
const counts = new Map<string, number>()
@@ -64,7 +64,7 @@
import { ref, computed, onMounted } from 'vue'
import type { TVSeries } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateTVSeriesFallback, fetchTmdbTVPoster } from '@/composables/useImageFallback'
import { generateTVSeriesFallback, fetchTVImage } from '@/composables/useImageFallback'
const props = defineProps<{ series: TVSeries }>()
defineEmits<{ select: [series: TVSeries] }>()
@@ -100,7 +100,7 @@ const ratingClass = computed(() => {
onMounted(() => {
if (props.series.posterUrl) return
fetchTmdbTVPoster(props.series.title, props.series.year).then((result) => {
fetchTVImage(props.series.title, props.series.year).then((result) => {
if (result.posterUrl) fetchedPoster.value = result.posterUrl
})
})
@@ -54,19 +54,20 @@
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(s) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="!coverSrc(s) && !failedCovers.has(s.id)" class="absolute inset-0 animate-shimmer" />
<div v-if="isLoading(s)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(s)"
:src="coverSrc(s)!"
:alt="`${s.title} — TV Series`"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onCoverError(s)"
@error="onError(s)"
/>
<div
v-else-if="failedCovers.has(s.id)"
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(s)})` }"
<img
v-else-if="!isLoading(s)"
:src="fallbackSrc(s)"
:alt="s.title"
class="w-full h-full object-cover"
/>
<div v-if="coverSrc(s)" class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
@@ -115,9 +116,10 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, watch } from 'vue'
import { ref, computed, toRef } from 'vue'
import type { TVSeries } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useContentImages } from '@/composables/useContentImages'
import { generateTVSeriesFallback, fetchTVImage } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
@@ -132,22 +134,14 @@ defineEmits<{ selectSeries: [series: TVSeries] }>()
const { isDark } = useTheme()
const search = ref('')
const activeGenre = ref<string | null>(null)
const failedCovers = ref<Set<string>>(new Set())
const fetchedCovers = reactive<Map<string, string>>(new Map())
function coverSrc(s: TVSeries): string | null {
if (failedCovers.value.has(s.id)) return null
return s.posterUrl || s.backdropUrl || fetchedCovers.get(s.id) || null
}
function fallbackFor(s: TVSeries): string {
return generateTVSeriesFallback(s.title, s.year)
}
function onCoverError(s: TVSeries) {
failedCovers.value.add(s.id)
failedCovers.value = new Set(failedCovers.value)
}
const { coverSrc, fallbackSrc, onError, isLoading } = useContentImages({
items: toRef(props, 'series'),
id: (s) => s.id,
existingUrl: (s) => s.posterUrl || s.backdropUrl,
fetch: (s) => fetchTVImage(s.title, s.year).then((r) => r.posterUrl),
fallback: (s) => generateTVSeriesFallback(s.title, s.year),
})
function yearDisplay(s: TVSeries): string {
if (!s.year) return ''
@@ -156,23 +150,6 @@ function yearDisplay(s: TVSeries): string {
return String(s.year)
}
function fetchCoversFor(list: TVSeries[]) {
for (const s of list) {
if (s.posterUrl || fetchedCovers.has(s.id) || failedCovers.value.has(s.id)) continue
fetchTVImage(s.title, s.year).then((result) => {
if (result.posterUrl) {
fetchedCovers.set(s.id, result.posterUrl)
} else {
failedCovers.value = new Set([...failedCovers.value, s.id])
}
}).catch(() => {
failedCovers.value = new Set([...failedCovers.value, s.id])
})
}
}
watch(() => props.series, (list) => fetchCoversFor(list), { immediate: true })
const topGenres = computed(() => {
const counts = new Map<string, number>()
for (const s of props.series) {
@@ -40,7 +40,7 @@
<span v-else class="text-base">🎵</span>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold truncate text-white/90">{{ currentSong!.title }}</p>
<p class="text-sm font-semibold truncate text-white/90">{{ cleanTitle(currentSong!.title) }}</p>
<p class="text-xs truncate text-white/50">{{ currentSong!.artist }}</p>
</div>
<button
@@ -91,7 +91,7 @@
</svg>
</button>
</div>
<p v-if="error" class="text-xs text-red-400 px-3 pb-2">{{ error }}</p>
<p v-if="error" class="text-xs text-red-400/60 px-3 pb-1 truncate">{{ error }}</p>
</div>
<!-- Desktop layout: full controls with scrubber -->
@@ -107,7 +107,7 @@
<span v-else class="text-lg">🎵</span>
</div>
<div class="min-w-0">
<p class="text-sm font-semibold truncate text-white/90">{{ currentSong!.title }}</p>
<p class="text-sm font-semibold truncate text-white/90">{{ cleanTitle(currentSong!.title) }}</p>
<p class="text-xs truncate text-white/50">{{ currentSong!.artist }}</p>
</div>
</div>
@@ -168,7 +168,7 @@
{{ formatTime(duration) }}
</span>
</div>
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
<p v-if="error" class="text-xs text-red-400/60 truncate">{{ error }}</p>
</div>
<span
@@ -229,6 +229,10 @@ const coverUrl = computed(() => {
return song.coverUrl || fetchedCover.value
})
function cleanTitle(raw: string): string {
return raw.replace(/^(?:song|film|podcast|book|tv)_ext:/i, '').replace(/\|.*$/, '')
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
@@ -581,7 +581,8 @@ function extractSongsFromPatterns(text: string): Song[] {
if (title.length < 2 || artist.length < 2) continue
if (!looksLikeSong(title, artist)) continue
if (/^\d{4}$/.test(title) || /^\d{4}$/.test(artist)) continue
if (/\[\[(film|song)(_ext)?:/.test(title) || /\[\[(film|song)(_ext)?:/.test(artist)) continue
if (/(\[\[)?(film|song|podcast|book|tv|place|recipe|event)(_ext)?:/i.test(title)) continue
if (/(\[\[)?(film|song|podcast|book|tv|place|recipe|event)(_ext)?:/i.test(artist)) continue
if (/\*\*\[\[/.test(title) || title.includes(']]**')) continue
const key = `${title.toLowerCase()}|${artist.toLowerCase()}`
if (seen.has(key)) continue
@@ -0,0 +1,59 @@
import { ref, reactive, watch, isRef, type Ref } from 'vue'
interface UseContentImagesOptions<T> {
items: Ref<T[]> | (() => T[])
id: (item: T) => string
existingUrl: (item: T) => string | undefined | null
fetch: (item: T) => Promise<string | null>
fallback: (item: T) => string
}
export function useContentImages<T>(options: UseContentImagesOptions<T>) {
const failed = ref<Set<string>>(new Set())
const fetched = reactive<Map<string, string>>(new Map())
function markFailed(id: string) {
failed.value = new Set([...failed.value, id])
}
function coverSrc(item: T): string | null {
const itemId = options.id(item)
if (failed.value.has(itemId)) return null
return options.existingUrl(item) || fetched.get(itemId) || null
}
function fallbackSrc(item: T): string {
return options.fallback(item)
}
function onError(item: T) {
markFailed(options.id(item))
}
function isLoading(item: T): boolean {
const itemId = options.id(item)
return !coverSrc(item) && !failed.value.has(itemId)
}
function fetchCovers(list: T[]) {
for (const item of list) {
const itemId = options.id(item)
if (options.existingUrl(item) || fetched.has(itemId) || failed.value.has(itemId)) continue
options.fetch(item).then((url) => {
if (url) {
fetched.set(itemId, url)
} else {
markFailed(itemId)
}
}).catch(() => {
markFailed(itemId)
})
}
}
const src = options.items
const itemsGetter: () => T[] = isRef(src) ? () => src.value : src
watch(itemsGetter, (list) => fetchCovers(list), { immediate: true })
return { coverSrc, fallbackSrc, onError, isLoading }
}
@@ -79,65 +79,47 @@ function savePodcastCache(): void {
loadPodcastCache()
const MICROPHONE_PATH = 'M45.04,95.45v24.11c0,1.83-1.49,3.32-3.32,3.32c-1.83,0-3.32-1.49-3.32-3.32V95.45c-10.16-0.81-19.32-5.3-26.14-12.12C4.69,75.77,0,65.34,0,53.87c0-1.83,1.49-3.32,3.32-3.32s3.32,1.49,3.32,3.32c0,9.64,3.95,18.41,10.31,24.77c6.36,6.36,15.13,10.31,24.77,10.31h0c9.64,0,18.41-3.95,24.77-10.31c6.36-6.36,10.31-15.13,10.31-24.77c0-1.83,1.49-3.32,3.32-3.32s3.32,1.49,3.32,3.32c0,11.48-4.69,21.91-12.25,29.47C64.36,90.16,55.2,94.64,45.04,95.45z M41.94,0c6.38,0,12.18,2.61,16.38,6.81c4.2,4.2,6.81,10,6.81,16.38v30c0,6.38-2.61,12.18-6.81,16.38c-4.2,4.2-10,6.81-16.38,6.81s-12.18-2.61-16.38-6.81c-4.2-4.2-6.81-10-6.81-16.38v-30c0-6.38,2.61-12.18,6.81-16.38C29.76,2.61,35.56,0,41.94,0z M53.62,11.51c-3-3-7.14-4.86-11.68-4.86c-4.55,0-8.68,1.86-11.68,4.86c-3,3-4.86,7.14-4.86,11.68v30c0,4.55,1.86,8.68,4.86,11.68c3,3,7.14,4.86,11.68,4.86c4.55,0,8.68-1.86,11.68-4.86c3-3,4.86-7.14,4.86-11.68v-30C58.49,18.64,56.62,14.51,53.62,11.51z'
export function generatePodcastCoverFallback(title: string, _host?: string): string {
const hue = [...title].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
/** Helper to build a consistent text-only SVG fallback (matches Film/TV Series style) */
function buildSquareFallback(
label: string, title: string, subtitle: string | undefined,
hue: number, sat: number,
): string {
const cx = 100
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<rect width="200" height="200" fill="hsl(${hue}, 30%, 28%)"/>
<rect x="3" y="3" width="194" height="194" rx="12" fill="none" stroke="hsl(${hue}, 35%, 38%)" stroke-width="1"/>
<text x="100" y="78" text-anchor="middle" fill="hsl(${hue}, 35%, 50%)" font-family="system-ui,sans-serif" font-size="10" font-weight="500" letter-spacing="3">PODCAST</text>
<g fill="hsl(${hue}, 40%, 65%)" transform="translate(58 82) scale(1.0)">
<path d="${MICROPHONE_PATH}"/>
</g>
<rect width="200" height="200" fill="hsl(${hue}, ${sat}%, 28%)"/>
<rect x="3" y="3" width="194" height="194" rx="12" fill="none" stroke="hsl(${hue}, ${sat + 5}%, 38%)" stroke-width="1"/>
<text x="${cx}" y="82" text-anchor="middle" fill="hsl(${hue}, ${sat}%, 48%)" font-family="system-ui,sans-serif" font-size="10" font-weight="400" letter-spacing="4">${label}</text>
<line x1="60" y1="92" x2="140" y2="92" stroke="hsl(${hue}, ${sat + 5}%, 40%)" stroke-width="1"/>
<text x="${cx}" y="118" text-anchor="middle" fill="hsl(${hue}, ${sat + 15}%, 78%)" font-family="system-ui,sans-serif" font-size="13" font-weight="700">${escapeXml(title.length > 18 ? title.slice(0, 16) + '…' : title)}</text>
${subtitle ? `<text x="${cx}" y="138" text-anchor="middle" fill="hsl(${hue}, ${sat}%, 60%)" font-family="system-ui,sans-serif" font-size="10" font-weight="300">${escapeXml(subtitle.length > 22 ? subtitle.slice(0, 20) + '…' : subtitle)}</text>` : ''}
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
export function generatePodcastCoverFallback(title: string, host?: string): string {
const hue = [...(title + (host ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
return buildSquareFallback('PODCAST', title, host, hue, 30)
}
export function generateSongCoverFallback(title: string, artist?: string): string {
const hue = [...(title + (artist ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<rect width="200" height="200" fill="hsl(${hue}, 30%, 28%)"/>
<rect x="3" y="3" width="194" height="194" rx="12" fill="none" stroke="hsl(${hue}, 40%, 38%)" stroke-width="1"/>
<text x="100" y="80" text-anchor="middle" fill="hsl(${hue}, 35%, 50%)" font-family="system-ui,sans-serif" font-size="10" font-weight="500" letter-spacing="3">MUSIC</text>
<circle cx="100" cy="120" r="28" fill="none" stroke="hsl(${hue}, 45%, 52%)" stroke-width="1.5"/>
<circle cx="100" cy="120" r="8" fill="hsl(${hue}, 45%, 52%)"/>
<text x="100" y="170" text-anchor="middle" fill="hsl(${hue}, 50%, 80%)" font-family="system-ui,sans-serif" font-size="11" font-weight="600">${escapeXml(title.length > 18 ? title.slice(0, 16) + '…' : title)}</text>
${artist ? `<text x="100" y="185" text-anchor="middle" fill="hsl(${hue}, 30%, 65%)" font-family="system-ui,sans-serif" font-size="9">${escapeXml(artist.length > 20 ? artist.slice(0, 18) + '…' : artist)}</text>` : ''}
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
return buildSquareFallback('MUSIC', title, artist, hue, 30)
}
/** News article fallback — newspaper style with category badge */
export function generateNewsFallback(title: string, source?: string): string {
const hue = [...(title + (source ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<rect width="200" height="200" fill="hsl(${hue}, 22%, 28%)"/>
<rect x="3" y="3" width="194" height="194" rx="12" fill="none" stroke="hsl(${hue}, 28%, 38%)" stroke-width="1"/>
<text x="100" y="72" text-anchor="middle" fill="hsl(${hue}, 25%, 50%)" font-family="Georgia,'Times New Roman',serif" font-size="10" font-weight="400" letter-spacing="3">NEWS</text>
<line x1="50" y1="82" x2="150" y2="82" stroke="hsl(${hue}, 28%, 38%)" stroke-width="0.8"/>
<line x1="40" y1="100" x2="160" y2="100" stroke="hsl(${hue}, 22%, 40%)" stroke-width="3"/>
<line x1="50" y1="112" x2="150" y2="112" stroke="hsl(${hue}, 22%, 37%)" stroke-width="2"/>
<line x1="55" y1="122" x2="145" y2="122" stroke="hsl(${hue}, 22%, 35%)" stroke-width="1.5"/>
<line x1="60" y1="132" x2="140" y2="132" stroke="hsl(${hue}, 22%, 34%)" stroke-width="1"/>
${source ? `<text x="100" y="165" text-anchor="middle" fill="hsl(${hue}, 30%, 65%)" font-family="system-ui,sans-serif" font-size="9" font-weight="400">${escapeXml(source.length > 22 ? source.slice(0, 20) + '…' : source)}</text>` : ''}
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
return buildSquareFallback('NEWS', title, source, hue, 22)
}
/** Image fallback — picture frame style */
export function generateImageFallback(title: string): string {
const hue = [...title].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<rect width="200" height="200" fill="hsl(${hue}, 20%, 28%)"/>
<rect x="3" y="3" width="194" height="194" rx="12" fill="none" stroke="hsl(${hue}, 25%, 38%)" stroke-width="1"/>
<text x="100" y="72" text-anchor="middle" fill="hsl(${hue}, 22%, 48%)" font-family="system-ui,sans-serif" font-size="10" font-weight="500" letter-spacing="3">IMAGE</text>
<rect x="60" y="85" width="80" height="60" rx="4" fill="none" stroke="hsl(${hue}, 30%, 48%)" stroke-width="1.5"/>
<circle cx="80" cy="102" r="6" fill="hsl(${hue}, 35%, 50%)"/>
<path d="M60 135 L85 115 L105 128 L120 118 L140 135 Z" fill="hsl(${hue}, 30%, 40%)"/>
<text x="100" y="170" text-anchor="middle" fill="hsl(${hue}, 35%, 68%)" font-family="system-ui,sans-serif" font-size="10" font-weight="500">${escapeXml(title.length > 20 ? title.slice(0, 18) + '…' : title)}</text>
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
return buildSquareFallback('IMAGE', title, undefined, hue, 20)
}
/** Website fallback — domain-based */
export function generateWebsiteFallback(title: string, domain?: string): string {
const hue = [...(title + (domain ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
return buildSquareFallback('WEBSITE', title, domain, hue, 25)
}
/** Film poster fallback — cinematic sans-serif */
@@ -320,19 +302,17 @@ function saveBookCache(): void {
loadBookCache()
/** Book cover fallback — elegant serif with spine detail */
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}, 20%, 28%)"/>
<rect x="3" y="3" width="274" height="414" rx="3" fill="none" stroke="hsl(${hue}, 25%, 38%)" stroke-width="1"/>
<line x1="22" y1="3" x2="22" y2="417" stroke="hsl(${hue}, 25%, 36%)" stroke-width="1.5"/>
<text x="155" y="170" text-anchor="middle" fill="hsl(${hue}, 18%, 48%)" font-family="Georgia,'Times New Roman',serif" font-size="11" font-weight="400" letter-spacing="4">BOOK</text>
<line x1="95" y1="182" x2="215" y2="182" stroke="hsl(${hue}, 22%, 40%)" stroke-width="0.8"/>
<text x="155" y="215" text-anchor="middle" fill="hsl(${hue}, 35%, 78%)" font-family="Georgia,'Times New Roman',serif" font-size="17" font-weight="700" font-style="italic">
${escapeXml(title.length > 22 ? title.slice(0, 20) + '…' : title)}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 342 513">
<rect width="342" height="513" fill="hsl(${hue}, 20%, 28%)"/>
<rect x="3" y="3" width="336" height="507" rx="6" fill="none" stroke="hsl(${hue}, 25%, 38%)" stroke-width="1"/>
<text x="171" y="210" text-anchor="middle" fill="hsl(${hue}, 18%, 48%)" font-family="Georgia,'Times New Roman',serif" font-size="13" font-weight="400" letter-spacing="5">BOOK</text>
<line x1="121" y1="225" x2="221" y2="225" stroke="hsl(${hue}, 22%, 40%)" stroke-width="1"/>
<text x="171" y="265" text-anchor="middle" fill="hsl(${hue}, 35%, 78%)" font-family="Georgia,'Times New Roman',serif" font-size="18" font-weight="700">
${escapeXml(title.length > 20 ? title.slice(0, 18) + '…' : title)}
</text>
${author ? `<text x="155" y="248" text-anchor="middle" fill="hsl(${hue}, 22%, 65%)" font-family="Georgia,'Times New Roman',serif" font-size="11" font-weight="400">${escapeXml(author.length > 26 ? author.slice(0, 24) + '…' : author)}</text>` : ''}
${author ? `<text x="171" y="295" text-anchor="middle" fill="hsl(${hue}, 22%, 60%)" font-family="Georgia,'Times New Roman',serif" font-size="14" font-weight="300">${escapeXml(author.length > 24 ? author.slice(0, 22) + '…' : author)}</text>` : ''}
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
@@ -364,20 +344,25 @@ export async function fetchBookCover(
}
}
/** Place/restaurant fallback — map pin with cuisine hint */
export function generatePlaceFallback(name: string, cuisine?: string): string {
const hue = [...(name + (cuisine ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<rect width="200" height="200" fill="hsl(${hue}, 20%, 28%)"/>
<rect x="3" y="3" width="194" height="194" rx="12" fill="none" stroke="hsl(${hue}, 25%, 38%)" stroke-width="1"/>
<text x="100" y="72" text-anchor="middle" fill="hsl(${hue}, 20%, 48%)" font-family="system-ui,sans-serif" font-size="9" font-weight="500" letter-spacing="3">PLACE</text>
<g transform="translate(100 120) scale(1.8)" fill="none" stroke="hsl(${hue}, 35%, 65%)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M0-14C-7.7-14-14-7.7-14 0c0 10.5 14 22 14 22s14-11.5 14-22c0-7.7-6.3-14-14-14z"/>
<circle cx="0" cy="0" r="5"/>
</g>
${cuisine ? `<text x="100" y="170" text-anchor="middle" fill="hsl(${hue}, 25%, 65%)" font-family="system-ui,sans-serif" font-size="9" font-weight="400">${escapeXml(cuisine.length > 22 ? cuisine.slice(0, 20) + '…' : cuisine)}</text>` : ''}
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
return buildSquareFallback('PLACE', name, cuisine, hue, 20)
}
/** Place image: Wikipedia (try restaurant/place name) */
export async function fetchPlaceImage(
name: string,
city?: string,
): Promise<string | null> {
// Try Wikipedia with city disambiguation first
if (city) {
const wiki = await fetchWikipediaImage(name, city)
if (wiki) return wiki
}
// Try just the name (for well-known places/chains)
const wiki = await fetchWikipediaImage(name, 'restaurant')
if (wiki) return wiki
return null
}
export async function fetchMusicCover(
@@ -389,7 +374,7 @@ export async function fetchMusicCover(
const cached = musicCoverCache.get(key)
if (cached) return cached
// Try Wavlake first — our primary music source
// Try local API proxy first (works in dev with Vite middleware)
try {
const base = import.meta.env.BASE_URL || '/'
const params = new URLSearchParams({ q: title, title, artist })
@@ -402,6 +387,29 @@ export async function fetchMusicCover(
return wlData.coverUrl
}
}
} catch {
// Fall through to direct Wavlake
}
// Try Wavlake API directly (works in production without Vite middleware)
try {
const term = `${title} ${artist}`.trim()
const res = await fetch(
`https://wavlake.com/api/v1/content/search?term=${encodeURIComponent(term)}`,
{ headers: { Accept: 'application/json' } },
)
if (res.ok) {
const items = (await res.json()) as { type: string; albumArtUrl?: string; artistArtUrl?: string }[]
if (Array.isArray(items)) {
const track = items.find(i => i.type === 'track')
const coverUrl = track?.albumArtUrl ?? track?.artistArtUrl
if (coverUrl) {
musicCoverCache.set(key, coverUrl)
saveMusicCache()
return coverUrl
}
}
}
} catch {
// Fall through to iTunes
}
@@ -454,13 +462,15 @@ export async function fetchWikipediaImage(
}
}
// Try exact title first
let url = await tryTitle(title)
let url: string | null = null
// Try with disambiguator suffix if no result
if (!url && disambiguator) {
// Try disambiguated title first (more specific), then exact title
if (disambiguator) {
url = await tryTitle(`${title} (${disambiguator})`)
}
if (!url) {
url = await tryTitle(title)
}
wikiImageCache.set(key, url)
return url