feat(content): add Images content type with masonry grid layout

Extract markdown images and raw image URLs from AI responses into a
browsable image gallery. Masonry (columns) layout in the grid,
full-size detail view with source links. Also fix ContextLoader to
accept all content types for loading state display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 07:15:02 +00:00
co-authored by Claude Opus 4.6
parent 1973b24eaa
commit e8ebe56d9e
10 changed files with 394 additions and 13 deletions
@@ -99,6 +99,13 @@
> >
View all {{ inlineTVSeries.length }} series View all {{ inlineTVSeries.length }} series
</button> </button>
<button
v-else-if="inlineImages.length > 1"
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineImages.length }} images
</button>
<button <button
v-else-if="inlineSongs.length > 1" v-else-if="inlineSongs.length > 1"
class="text-[10px] text-accent/70 hover:text-accent transition-colors" class="text-[10px] text-accent/70 hover:text-accent transition-colors"
@@ -142,7 +149,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { Message, WebSearchResult } from '@aiui/core/types/message' import type { Message, WebSearchResult } from '@aiui/core/types/message'
import type { Film, Song, Podcast, Book, TVSeries } from '@aiui/core/types/content' import type { Film, Song, Podcast, Book, TVSeries, ImageItem } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import { useContentPanel, type MagazineSection } from '@/composables/useContentPanel' import { useContentPanel, type MagazineSection } from '@/composables/useContentPanel'
import { useArticleOverlayStore } from '@/stores/articleOverlay' import { useArticleOverlayStore } from '@/stores/articleOverlay'
@@ -163,13 +170,13 @@ const props = withDefaults(
) )
const { isDark } = useTheme() const { isDark } = useTheme()
const { getContextualInlineContent, stripContentTags, stripMarkdownLinks, updatePanelFromText, openFilmDetail, openBookDetail, openTVSeriesDetail, openSongDetail, openPodcastDetail, openArticleDetail, closeFilmDetail, closeBookDetail, closeTVSeriesDetail, closeSongDetail, closePodcastDetail } = useContentPanel() const { getContextualInlineContent, stripContentTags, stripMarkdownLinks, updatePanelFromText, openFilmDetail, openBookDetail, openTVSeriesDetail, openImageDetail, openSongDetail, openPodcastDetail, openArticleDetail, closeFilmDetail, closeBookDetail, closeTVSeriesDetail, closeImageDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
const overlayStore = useArticleOverlayStore() const overlayStore = useArticleOverlayStore()
const isUser = computed(() => props.message.role === 'user') const isUser = computed(() => props.message.role === 'user')
const inlineContent = computed(() => { const inlineContent = computed(() => {
if (isUser.value) return { films: [] as Film[], books: [] as Book[], tvSeries: [] as TVSeries[], songs: [] as Song[], podcasts: [] as Podcast[], newsLinks: [] as WebSearchResult[], websitesLinks: [] as WebSearchResult[], magazineSections: [] as MagazineSection[] } if (isUser.value) return { films: [] as Film[], books: [] as Book[], tvSeries: [] as TVSeries[], images: [] as ImageItem[], songs: [] as Song[], podcasts: [] as Podcast[], newsLinks: [] as WebSearchResult[], websitesLinks: [] as WebSearchResult[], magazineSections: [] as MagazineSection[] }
return getContextualInlineContent(props.message.content, props.triggeringQuery, props.message.webResults ?? []) return getContextualInlineContent(props.message.content, props.triggeringQuery, props.message.webResults ?? [])
}) })
@@ -182,6 +189,7 @@ const bubbleClasses = computed(() =>
const inlineFilms = computed(() => inlineContent.value.films) const inlineFilms = computed(() => inlineContent.value.films)
const inlineBooks = computed(() => inlineContent.value.books ?? []) const inlineBooks = computed(() => inlineContent.value.books ?? [])
const inlineTVSeries = computed(() => inlineContent.value.tvSeries ?? []) const inlineTVSeries = computed(() => inlineContent.value.tvSeries ?? [])
const inlineImages = computed(() => inlineContent.value.images ?? [])
const inlineSongs = computed(() => inlineContent.value.songs) const inlineSongs = computed(() => inlineContent.value.songs)
const inlinePodcasts = computed(() => inlineContent.value.podcasts) const inlinePodcasts = computed(() => inlineContent.value.podcasts)
const inlineNewsLinks = computed(() => inlineContent.value.newsLinks ?? []) const inlineNewsLinks = computed(() => inlineContent.value.newsLinks ?? [])
@@ -192,6 +200,7 @@ const hasContext = computed(() => !isUser.value && (
inlineFilms.value.length > 0 || inlineFilms.value.length > 0 ||
inlineBooks.value.length > 0 || inlineBooks.value.length > 0 ||
inlineTVSeries.value.length > 0 || inlineTVSeries.value.length > 0 ||
inlineImages.value.length > 0 ||
inlineSongs.value.length > 0 || inlineSongs.value.length > 0 ||
inlinePodcasts.value.length > 0 || inlinePodcasts.value.length > 0 ||
inlineNewsLinks.value.length > 0 || inlineNewsLinks.value.length > 0 ||
@@ -89,6 +89,7 @@ const promptPairs = computed<PromptPair[]>(() => {
if (content.films.length > 0) badges.push('Films') if (content.films.length > 0) badges.push('Films')
if ((content.books?.length ?? 0) > 0) badges.push('Books') if ((content.books?.length ?? 0) > 0) badges.push('Books')
if ((content.tvSeries?.length ?? 0) > 0) badges.push('TV') if ((content.tvSeries?.length ?? 0) > 0) badges.push('TV')
if ((content.images?.length ?? 0) > 0) badges.push('Images')
if (content.songs.length > 0) badges.push('Music') if (content.songs.length > 0) badges.push('Music')
if (content.podcasts.length > 0) badges.push('Podcasts') if (content.podcasts.length > 0) badges.push('Podcasts')
if (content.magazineSections.length > 0) badges.push('Magazine') if (content.magazineSections.length > 0) badges.push('Magazine')
@@ -212,6 +212,7 @@ const TAB_LABELS: Record<ContentTab, string> = {
film: 'Films', film: 'Films',
book: 'Books', book: 'Books',
tvshow: 'TV', tvshow: 'TV',
image: 'Images',
song: 'Music', song: 'Music',
podcast: 'Podcasts', podcast: 'Podcasts',
news: 'News', news: 'News',
@@ -1,7 +1,7 @@
<template> <template>
<div class="relative flex-1 flex flex-col min-h-0 overflow-hidden"> <div class="relative flex-1 flex flex-col min-h-0 overflow-hidden">
<div <div
v-if="['film','song','podcast','news','websites','magazine'].includes(contextType)" v-if="['film','song','podcast','book','tvshow','image','news','websites','magazine'].includes(contextType)"
class="flex-1 flex flex-col min-h-0" class="flex-1 flex flex-col min-h-0"
> >
<div <div
@@ -75,7 +75,7 @@ import LoadingFilmGrid from './LoadingFilmGrid.vue'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
contextType?: 'film' | 'song' | 'podcast' | 'news' | 'websites' | 'magazine' | 'generic' contextType?: 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'news' | 'websites' | 'magazine' | 'generic'
}>(), }>(),
{ contextType: 'film' } { contextType: 'film' }
) )
@@ -86,6 +86,9 @@ const contextLabel = computed(() => {
if (props.contextType === 'film') return 'Film recommendations' if (props.contextType === 'film') return 'Film recommendations'
if (props.contextType === 'song') return 'Song recommendations' if (props.contextType === 'song') return 'Song recommendations'
if (props.contextType === 'podcast') return 'Podcast recommendations' if (props.contextType === 'podcast') return 'Podcast recommendations'
if (props.contextType === 'book') return 'Book recommendations'
if (props.contextType === 'tvshow') return 'TV Series recommendations'
if (props.contextType === 'image') return 'Images'
if (props.contextType === 'news') return 'Articles' if (props.contextType === 'news') return 'Articles'
if (props.contextType === 'websites') return 'Websites' if (props.contextType === 'websites') return 'Websites'
if (props.contextType === 'magazine') return 'Brief' if (props.contextType === 'magazine') return 'Brief'
@@ -0,0 +1,57 @@
<template>
<button
class="flex items-start gap-3 w-full text-left p-2.5 rounded-xl transition-all duration-150"
:class="isDark
? 'hover:bg-white/5'
: 'hover:bg-black/3'"
@click="$emit('select', image)"
>
<div class="w-16 shrink-0 rounded-lg overflow-hidden">
<div class="aspect-[4/3] relative bg-black/10">
<img
v-if="!imgFailed"
:src="image.url"
:alt="image.alt || image.title || 'Image'"
class="w-full h-full object-cover"
loading="lazy"
@error="imgFailed = true"
/>
<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>
</div>
</div>
<div class="flex-1 min-w-0 py-0.5">
<p v-if="image.title" class="text-sm font-medium leading-snug line-clamp-1"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ image.title }}
</p>
<p v-if="image.source" class="text-xs mt-0.5 truncate"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ image.source }}
</p>
<p v-if="image.description" class="text-[11px] mt-1 line-clamp-2 leading-relaxed"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
{{ image.description }}
</p>
</div>
</button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { ImageItem } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
defineProps<{ image: ImageItem }>()
defineEmits<{ select: [image: ImageItem] }>()
const { isDark } = useTheme()
const imgFailed = ref(false)
</script>
@@ -0,0 +1,87 @@
<template>
<div class="image-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<div class="relative w-full overflow-hidden bg-black/20">
<img
v-if="!imgFailed"
:src="image.url"
:alt="image.alt || image.title || 'Image'"
class="w-full block max-h-[60vh] object-contain bg-black/40"
@error="imgFailed = true"
/>
<div
v-else
class="w-full aspect-video flex items-center justify-center"
:class="isDark ? 'bg-white/5' : 'bg-black/5'"
>
<svg class="w-12 h-12" :class="isDark ? 'text-white/15' : '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>
<button
class="absolute top-3 left-3 p-2 rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
@click="$emit('back')"
>
<svg class="w-4 h-4 text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
<div class="p-4 space-y-3">
<h2 v-if="image.title" class="text-base font-bold"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ image.title }}
</h2>
<p v-if="image.description" class="text-sm leading-relaxed"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ image.description }}
</p>
<div v-if="image.attribution" class="text-xs"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ image.attribution }}
</div>
<div v-if="image.source" class="text-xs"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
Source: {{ image.source }}
</div>
<div v-if="image.width && image.height" class="text-[10px]"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ image.width }} &times; {{ image.height }}
</div>
<div class="pt-2">
<a
:href="image.url"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-medium transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10 text-white/80'
: 'bg-black/3 hover:bg-black/5 text-gray-800'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open original
</a>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { ImageItem } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
defineProps<{ image: ImageItem }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const imgFailed = ref(false)
</script>
@@ -0,0 +1,84 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 space-y-3" :style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ title }}
</h3>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[10px] font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ images.length }} images
</span>
<slot name="header-actions" />
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="columns-2 sm:columns-3 gap-3 space-y-3">
<button
v-for="img in images"
:key="img.id"
class="group w-full break-inside-avoid text-left rounded-xl overflow-hidden transition-all duration-200 hover:brightness-110 relative"
:class="isDark ? 'bg-white/5' : 'bg-black/3'"
@click="$emit('selectImage', img)"
>
<img
v-if="!failedIds.has(img.id)"
:src="img.url"
:alt="img.alt || img.title || 'Image'"
class="w-full block transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy"
@error="onError(img)"
/>
<div
v-else
class="w-full aspect-[4/3] flex items-center justify-center"
:class="isDark ? 'bg-white/5' : 'bg-black/5'"
>
<svg class="w-8 h-8" :class="isDark ? 'text-white/15' : '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>
<div v-if="img.title || img.source"
class="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent">
<p v-if="img.title" class="text-[10px] font-medium text-white/90 truncate">{{ img.title }}</p>
<p v-if="img.source" class="text-[8px] text-white/50 truncate">{{ img.source }}</p>
</div>
</button>
</div>
<div v-if="images.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No images found
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { ImageItem } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
withDefaults(defineProps<{
images: ImageItem[]
title?: string
}>(), {
title: 'Images',
})
defineEmits<{ selectImage: [image: ImageItem] }>()
const { isDark } = useTheme()
const failedIds = ref<Set<string>>(new Set())
function onError(img: ImageItem) {
failedIds.value.add(img.id)
failedIds.value = new Set(failedIds.value)
}
</script>
@@ -1,5 +1,5 @@
import { ref } from 'vue' import { ref } from 'vue'
import type { Film, Song, Podcast, Book, TVSeries } from '@aiui/core/types/content' import type { Film, Song, Podcast, Book, TVSeries, ImageItem } from '@aiui/core/types/content'
import type { WebSearchResult } from '@aiui/core/types/message' import type { WebSearchResult } from '@aiui/core/types/message'
import { mockFilms } from '@/mocks/films' import { mockFilms } from '@/mocks/films'
import { mockSongs } from '@/mocks/songs' import { mockSongs } from '@/mocks/songs'
@@ -7,7 +7,7 @@ import { mockPodcasts } from '@/mocks/podcasts'
import { generatePosterFallback, generateSongCoverFallback, generateBookCoverFallback } from '@/composables/useImageFallback' import { generatePosterFallback, generateSongCoverFallback, generateBookCoverFallback } from '@/composables/useImageFallback'
import { fetchRssFromUrls } from '@/composables/useRssFetch' import { fetchRssFromUrls } from '@/composables/useRssFetch'
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'news' | 'websites' | 'magazine' export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'news' | 'websites' | 'magazine'
export interface MagazineSection { export interface MagazineSection {
title: string title: string
@@ -31,12 +31,14 @@ const panelMagazineSections = ref<MagazineSection[]>([])
const panelMagazineHeroImage = ref<string | null>(null) const panelMagazineHeroImage = ref<string | null>(null)
const panelSongs = ref<Song[]>([]) const panelSongs = ref<Song[]>([])
const panelPodcasts = ref<Podcast[]>([]) const panelPodcasts = ref<Podcast[]>([])
const panelImages = ref<ImageItem[]>([])
const selectedFilm = ref<Film | null>(null) const selectedFilm = ref<Film | null>(null)
const selectedBook = ref<Book | null>(null) const selectedBook = ref<Book | null>(null)
const selectedTVSeries = ref<TVSeries | null>(null) const selectedTVSeries = ref<TVSeries | null>(null)
const selectedSong = ref<Song | null>(null) const selectedSong = ref<Song | null>(null)
const selectedPodcast = ref<Podcast | null>(null) const selectedPodcast = ref<Podcast | null>(null)
const selectedArticle = ref<WebSearchResult | null>(null) const selectedArticle = ref<WebSearchResult | null>(null)
const selectedImage = ref<ImageItem | null>(null)
const panelTitle = ref('Recommended Films') const panelTitle = ref('Recommended Films')
const panelQuery = ref('') const panelQuery = ref('')
const contentType = ref<'film' | 'song' | 'podcast'>('film') const contentType = ref<'film' | 'song' | 'podcast'>('film')
@@ -260,6 +262,7 @@ function preferredFirstTab(userQuery: string): ContentTab | null {
if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast' if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast'
if (/\b(book|books|read|reading|novel|author|nonfiction|non-fiction)\b/.test(q)) return 'book' if (/\b(book|books|read|reading|novel|author|nonfiction|non-fiction)\b/.test(q)) return 'book'
if (/\b(tv show|tv series|series|television|streaming|binge|watch)\b/.test(q)) return 'tvshow' if (/\b(tv show|tv series|series|television|streaming|binge|watch)\b/.test(q)) return 'tvshow'
if (/\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/.test(q)) return 'image'
if (isNewsQuery(q)) return 'news' if (isNewsQuery(q)) return 'news'
if (isWebsitesQuery(q)) return 'websites' if (isWebsitesQuery(q)) return 'websites'
return null return null
@@ -274,6 +277,7 @@ function filterTabsByContext(
hasPodcasts: boolean, hasPodcasts: boolean,
hasBooks: boolean, hasBooks: boolean,
hasTVSeries: boolean, hasTVSeries: boolean,
hasImages: boolean,
hasNews: boolean, hasNews: boolean,
hasWebsites: boolean, hasWebsites: boolean,
hasMagazine: boolean, hasMagazine: boolean,
@@ -290,11 +294,11 @@ function filterTabsByContext(
return tabs return tabs
} }
if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasNews && !hasWebsites) { if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasNews && !hasWebsites) {
return ['magazine'] return ['magazine']
} }
if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasNews && !hasMagazine) { if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasNews && !hasMagazine) {
return ['websites'] return ['websites']
} }
@@ -302,6 +306,7 @@ function filterTabsByContext(
if (hasFilms) all.push('film') if (hasFilms) all.push('film')
if (hasBooks) all.push('book') if (hasBooks) all.push('book')
if (hasTVSeries) all.push('tvshow') if (hasTVSeries) all.push('tvshow')
if (hasImages) all.push('image')
if (hasSongs) all.push('song') if (hasSongs) all.push('song')
if (hasPodcasts) all.push('podcast') if (hasPodcasts) all.push('podcast')
if (hasMagazine) all.push('magazine') if (hasMagazine) all.push('magazine')
@@ -883,6 +888,58 @@ export function useContentPanel() {
return extractTVSeriesFromPatterns(text) return extractTVSeriesFromPatterns(text)
} }
/** Extract images from markdown image syntax and raw image URLs */
function extractImages(text: string): ImageItem[] {
const images: ImageItem[] = []
const seen = new Set<string>()
// Match markdown images: ![alt](url)
const mdImgRe = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/gi
let m: RegExpExecArray | null
while ((m = mdImgRe.exec(text)) !== null) {
const alt = m[1].trim()
const url = m[2].trim()
if (seen.has(url)) continue
seen.add(url)
const domain = new URL(url).hostname.replace(/^www\./, '')
images.push({
id: `img-${seen.size}`,
url,
alt: alt || undefined,
title: alt || undefined,
source: domain,
})
}
// Match raw image URLs not already captured
const urlRe = /(https?:\/\/[^\s)"'>]+\.(?:jpg|jpeg|png|gif|webp|svg|avif|bmp|tiff)(?:\?[^\s)"'>]*)?)/gi
while ((m = urlRe.exec(text)) !== null) {
const url = m[1].trim()
if (seen.has(url)) continue
seen.add(url)
const domain = new URL(url).hostname.replace(/^www\./, '')
images.push({
id: `img-${seen.size}`,
url,
source: domain,
})
}
return images
}
function isImageQuery(q: string): boolean {
return /\b(image|images|photo|photos|picture|pictures|screenshot|screenshots|gallery|artwork|illustration|visual|infographic|diagram|chart)\b/i.test(q)
}
function extractAllImages(text: string, userQuery: string): ImageItem[] {
const images = extractImages(text)
// Only surface as a tab if user asked about images or there are multiple images
if (images.length === 0) return []
if (isImageQuery(userQuery) || images.length >= 2) return images
return []
}
function updatePanelFromText(text: string, userQuery = '', webResults: WebSearchResult[] = []) { function updatePanelFromText(text: string, userQuery = '', webResults: WebSearchResult[] = []) {
panelQuery.value = userQuery.trim() panelQuery.value = userQuery.trim()
const songs = extractAllSongs(text) const songs = extractAllSongs(text)
@@ -931,13 +988,16 @@ export function useContentPanel() {
}) })
} }
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, hasNews, hasWebsites, hasMagazine) const images = extractAllImages(text, userQuery)
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, hasNews, hasWebsites, hasMagazine)
availableTabs.value = tabs.length > 0 ? tabs : ['film'] availableTabs.value = tabs.length > 0 ? tabs : ['film']
activeTab.value = tabs[0] ?? 'film' activeTab.value = tabs[0] ?? 'film'
const showFilms = tabs.includes('film') const showFilms = tabs.includes('film')
const showBooks = tabs.includes('book') const showBooks = tabs.includes('book')
const showTVSeries = tabs.includes('tvshow') const showTVSeries = tabs.includes('tvshow')
const showImages = tabs.includes('image')
const showSongs = tabs.includes('song') const showSongs = tabs.includes('song')
const showPodcasts = tabs.includes('podcast') const showPodcasts = tabs.includes('podcast')
const showNews = tabs.includes('news') const showNews = tabs.includes('news')
@@ -947,6 +1007,7 @@ export function useContentPanel() {
const visibleFilms = showFilms ? films : [] const visibleFilms = showFilms ? films : []
const visibleBooks = showBooks ? books : [] const visibleBooks = showBooks ? books : []
const visibleTVSeries = showTVSeries ? tvSeries : [] const visibleTVSeries = showTVSeries ? tvSeries : []
const visibleImages = showImages ? images : []
const visibleSongs = showSongs ? songs : [] const visibleSongs = showSongs ? songs : []
const visiblePodcasts = showPodcasts ? podcasts : [] const visiblePodcasts = showPodcasts ? podcasts : []
const visibleNews = showNews ? mergedNews : [] const visibleNews = showNews ? mergedNews : []
@@ -956,6 +1017,7 @@ export function useContentPanel() {
panelFilms.value = visibleFilms panelFilms.value = visibleFilms
panelBooks.value = visibleBooks panelBooks.value = visibleBooks
panelTVSeries.value = visibleTVSeries panelTVSeries.value = visibleTVSeries
panelImages.value = visibleImages
panelSongs.value = visibleSongs panelSongs.value = visibleSongs
panelPodcasts.value = visiblePodcasts panelPodcasts.value = visiblePodcasts
panelWebResults.value = visibleNews panelWebResults.value = visibleNews
@@ -967,6 +1029,7 @@ export function useContentPanel() {
selectedFilm.value = null selectedFilm.value = null
selectedBook.value = null selectedBook.value = null
selectedTVSeries.value = null selectedTVSeries.value = null
selectedImage.value = null
selectedSong.value = null selectedSong.value = null
selectedPodcast.value = null selectedPodcast.value = null
@@ -984,6 +1047,8 @@ export function useContentPanel() {
panelTitle.value = visibleTVSeries.length === 1 ? visibleTVSeries[0].title : `${visibleTVSeries.length} TV Series` panelTitle.value = visibleTVSeries.length === 1 ? visibleTVSeries[0].title : `${visibleTVSeries.length} TV Series`
} else if (primary === 'book' && visibleBooks.length > 0) { } else if (primary === 'book' && visibleBooks.length > 0) {
panelTitle.value = visibleBooks.length === 1 ? visibleBooks[0].title : `${visibleBooks.length} Books` panelTitle.value = visibleBooks.length === 1 ? visibleBooks[0].title : `${visibleBooks.length} Books`
} else if (primary === 'image' && visibleImages.length > 0) {
panelTitle.value = `${visibleImages.length} Images`
} else if (visibleFilms.length === 1) panelTitle.value = visibleFilms[0].title } else if (visibleFilms.length === 1) panelTitle.value = visibleFilms[0].title
else if (visibleFilms.length > 1) panelTitle.value = `${visibleFilms.length} Films` else if (visibleFilms.length > 1) panelTitle.value = `${visibleFilms.length} Films`
else if (visibleBooks.length === 1) panelTitle.value = visibleBooks[0].title else if (visibleBooks.length === 1) panelTitle.value = visibleBooks[0].title
@@ -1032,11 +1097,13 @@ export function useContentPanel() {
const websitesFromMd = hasLinkableContent ? fromMarkdown : [] const websitesFromMd = hasLinkableContent ? fromMarkdown : []
const websitesLinks = mergeNewsResults(websitesFromMd, boldDomains) const websitesLinks = mergeNewsResults(websitesFromMd, boldDomains)
const hasWebsites = websitesLinks.length > 0 const hasWebsites = websitesLinks.length > 0
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, hasNews, hasWebsites, hasMagazine) const images = extractAllImages(text, userQuery)
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, hasNews, hasWebsites, hasMagazine)
return { return {
films: tabs.includes('film') ? films : [], films: tabs.includes('film') ? films : [],
books: tabs.includes('book') ? books : [], books: tabs.includes('book') ? books : [],
tvSeries: tabs.includes('tvshow') ? tvSeries : [], tvSeries: tabs.includes('tvshow') ? tvSeries : [],
images: tabs.includes('image') ? images : [],
songs: tabs.includes('song') ? songs : [], songs: tabs.includes('song') ? songs : [],
podcasts: tabs.includes('podcast') ? podcasts : [], podcasts: tabs.includes('podcast') ? podcasts : [],
newsLinks: tabs.includes('news') ? newsLinks : [], newsLinks: tabs.includes('news') ? newsLinks : [],
@@ -1101,6 +1168,7 @@ export function useContentPanel() {
selectedFilm.value = film selectedFilm.value = film
selectedBook.value = null selectedBook.value = null
selectedTVSeries.value = null selectedTVSeries.value = null
selectedImage.value = null
selectedSong.value = null selectedSong.value = null
selectedPodcast.value = null selectedPodcast.value = null
selectedArticle.value = null selectedArticle.value = null
@@ -1114,6 +1182,7 @@ export function useContentPanel() {
selectedBook.value = book selectedBook.value = book
selectedFilm.value = null selectedFilm.value = null
selectedTVSeries.value = null selectedTVSeries.value = null
selectedImage.value = null
selectedSong.value = null selectedSong.value = null
selectedPodcast.value = null selectedPodcast.value = null
selectedArticle.value = null selectedArticle.value = null
@@ -1128,6 +1197,7 @@ export function useContentPanel() {
selectedFilm.value = null selectedFilm.value = null
selectedBook.value = null selectedBook.value = null
selectedTVSeries.value = null selectedTVSeries.value = null
selectedImage.value = null
selectedPodcast.value = null selectedPodcast.value = null
selectedArticle.value = null selectedArticle.value = null
} }
@@ -1141,6 +1211,7 @@ export function useContentPanel() {
selectedFilm.value = null selectedFilm.value = null
selectedBook.value = null selectedBook.value = null
selectedTVSeries.value = null selectedTVSeries.value = null
selectedImage.value = null
selectedSong.value = null selectedSong.value = null
selectedArticle.value = null selectedArticle.value = null
} }
@@ -1154,6 +1225,7 @@ export function useContentPanel() {
selectedFilm.value = null selectedFilm.value = null
selectedBook.value = null selectedBook.value = null
selectedTVSeries.value = null selectedTVSeries.value = null
selectedImage.value = null
selectedSong.value = null selectedSong.value = null
selectedPodcast.value = null selectedPodcast.value = null
panelOpen.value = true panelOpen.value = true
@@ -1167,6 +1239,7 @@ export function useContentPanel() {
selectedTVSeries.value = series selectedTVSeries.value = series
selectedFilm.value = null selectedFilm.value = null
selectedBook.value = null selectedBook.value = null
selectedImage.value = null
selectedSong.value = null selectedSong.value = null
selectedPodcast.value = null selectedPodcast.value = null
selectedArticle.value = null selectedArticle.value = null
@@ -1176,11 +1249,26 @@ export function useContentPanel() {
selectedTVSeries.value = null selectedTVSeries.value = null
} }
function openImageDetail(image: ImageItem) {
selectedImage.value = image
selectedFilm.value = null
selectedBook.value = null
selectedTVSeries.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
}
function closeImageDetail() {
selectedImage.value = null
}
function closePanel() { function closePanel() {
panelOpen.value = false panelOpen.value = false
selectedFilm.value = null selectedFilm.value = null
selectedBook.value = null selectedBook.value = null
selectedTVSeries.value = null selectedTVSeries.value = null
selectedImage.value = null
selectedSong.value = null selectedSong.value = null
selectedPodcast.value = null selectedPodcast.value = null
selectedArticle.value = null selectedArticle.value = null
@@ -1232,6 +1320,7 @@ export function useContentPanel() {
panelFilms, panelFilms,
panelBooks, panelBooks,
panelTVSeries, panelTVSeries,
panelImages,
panelSongs, panelSongs,
panelPodcasts, panelPodcasts,
panelWebResults, panelWebResults,
@@ -1241,6 +1330,7 @@ export function useContentPanel() {
selectedFilm, selectedFilm,
selectedBook, selectedBook,
selectedTVSeries, selectedTVSeries,
selectedImage,
selectedSong, selectedSong,
selectedPodcast, selectedPodcast,
selectedArticle, selectedArticle,
@@ -1259,6 +1349,8 @@ export function useContentPanel() {
closeBookDetail, closeBookDetail,
openTVSeriesDetail, openTVSeriesDetail,
closeTVSeriesDetail, closeTVSeriesDetail,
openImageDetail,
closeImageDetail,
extractSongIds, extractSongIds,
resolveSongs, resolveSongs,
extractAllSongs, extractAllSongs,
+37 -2
View File
@@ -17,11 +17,11 @@
class="flex-1 min-w-0 path-glass-card overflow-hidden flex flex-col relative" class="flex-1 min-w-0 path-glass-card overflow-hidden flex flex-col relative"
:class="[ :class="[
panelSide === 'left' ? 'order-last' : 'order-first', panelSide === 'left' ? 'order-last' : 'order-first',
(selectedFilm || selectedBook || selectedTVSeries || selectedSong || selectedPodcast || selectedArticle) && 'detail-active' (selectedFilm || selectedBook || selectedTVSeries || selectedImage || selectedSong || selectedPodcast || selectedArticle) && 'detail-active'
]" ]"
> >
<button <button
v-if="(selectedFilm || selectedBook || selectedTVSeries || selectedSong || selectedPodcast || selectedArticle) && (panelOpen || chatStore.isStreaming)" v-if="(selectedFilm || selectedBook || selectedTVSeries || selectedImage || selectedSong || selectedPodcast || selectedArticle) && (panelOpen || chatStore.isStreaming)"
class="absolute top-3 right-3 z-10 p-2 rounded-lg path-glass-icon transition-colors" class="absolute top-3 right-3 z-10 p-2 rounded-lg path-glass-icon transition-colors"
:class="isDark :class="isDark
? 'text-white/70 hover:bg-white/10' ? 'text-white/70 hover:bg-white/10'
@@ -61,6 +61,11 @@
:series="selectedTVSeries" :series="selectedTVSeries"
@back="closeTVSeriesDetail" @back="closeTVSeriesDetail"
/> />
<ImageDetail
v-else-if="selectedImage"
:image="selectedImage"
@back="closeImageDetail"
/>
<ArticleDetail <ArticleDetail
v-else-if="selectedArticle" v-else-if="selectedArticle"
:article="selectedArticle" :article="selectedArticle"
@@ -156,6 +161,28 @@
</button> </button>
</template> </template>
</TVSeriesGrid> </TVSeriesGrid>
<ImageGrid
v-else-if="activeTab === 'image'"
:images="panelImages"
:title="panelTitle"
@select-image="openImageDetail"
>
<template #header-actions>
<button
class="flex items-center justify-center p-2 rounded-lg transition-colors -mr-1"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
title="Clear content"
aria-label="Clear content"
@click="closePanel"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
</ImageGrid>
<SongGrid <SongGrid
v-else-if="activeTab === 'song'" v-else-if="activeTab === 'song'"
:songs="panelSongs" :songs="panelSongs"
@@ -349,6 +376,8 @@ import PodcastGrid from '@/components/content/PodcastGrid.vue'
import PodcastDetail from '@/components/content/PodcastDetail.vue' import PodcastDetail from '@/components/content/PodcastDetail.vue'
import MagazineGrid from '@/components/content/MagazineGrid.vue' import MagazineGrid from '@/components/content/MagazineGrid.vue'
import NewsGrid from '@/components/content/NewsGrid.vue' import NewsGrid from '@/components/content/NewsGrid.vue'
import ImageGrid from '@/components/content/ImageGrid.vue'
import ImageDetail from '@/components/content/ImageDetail.vue'
import ContextLoader from '@/components/content/ContextLoader.vue' import ContextLoader from '@/components/content/ContextLoader.vue'
import PlayerBar from '@/components/player/PlayerBar.vue' import PlayerBar from '@/components/player/PlayerBar.vue'
import { usePlayer } from '@/composables/usePlayer' import { usePlayer } from '@/composables/usePlayer'
@@ -364,6 +393,7 @@ const {
panelFilms, panelFilms,
panelBooks, panelBooks,
panelTVSeries, panelTVSeries,
panelImages,
panelSongs, panelSongs,
panelPodcasts, panelPodcasts,
panelWebResults, panelWebResults,
@@ -379,6 +409,7 @@ const {
selectedFilm, selectedFilm,
selectedBook, selectedBook,
selectedTVSeries, selectedTVSeries,
selectedImage,
selectedSong, selectedSong,
selectedPodcast, selectedPodcast,
selectedArticle, selectedArticle,
@@ -388,6 +419,8 @@ const {
closeBookDetail, closeBookDetail,
openTVSeriesDetail, openTVSeriesDetail,
closeTVSeriesDetail, closeTVSeriesDetail,
openImageDetail,
closeImageDetail,
openSongDetail, openSongDetail,
closeSongDetail, closeSongDetail,
openPodcastDetail, openPodcastDetail,
@@ -403,6 +436,7 @@ const TAB_LABELS: Record<ContentTab, string> = {
film: 'Films', film: 'Films',
book: 'Books', book: 'Books',
tvshow: 'TV', tvshow: 'TV',
image: 'Images',
song: 'Songs', song: 'Songs',
podcast: 'Podcasts', podcast: 'Podcasts',
news: 'News', news: 'News',
@@ -422,6 +456,7 @@ const loaderContextType = computed(() => {
if (/\b(song|music|track|album|band|artist|listen)\b/.test(q)) return 'song' if (/\b(song|music|track|album|band|artist|listen)\b/.test(q)) return 'song'
if (/\b(book|novel|read|author|fiction|nonfiction|memoir)\b/.test(q)) return 'book' if (/\b(book|novel|read|author|fiction|nonfiction|memoir)\b/.test(q)) return 'book'
if (/\b(tv show|tv series|series|television|binge|season)\b/.test(q)) return 'tvshow' if (/\b(tv show|tv series|series|television|binge|season)\b/.test(q)) return 'tvshow'
if (/\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/.test(q)) return 'image'
if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast' if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast'
if (/\b(news|latest|recent|current|what'?s happening|what are people saying)\b/.test(q)) return 'news' if (/\b(news|latest|recent|current|what'?s happening|what are people saying)\b/.test(q)) return 'news'
if (/\b(bip|protocol|debate|sentiment|bearish|bull case|macro)\b/.test(q)) return 'magazine' if (/\b(bip|protocol|debate|sentiment|bearish|bull case|macro)\b/.test(q)) return 'magazine'
+12
View File
@@ -125,3 +125,15 @@ export interface BookSource {
url: string url: string
icon?: string icon?: string
} }
export interface ImageItem {
id: string
url: string
title?: string
description?: string
alt?: string
width?: number
height?: number
source?: string
attribution?: string
}