feat(chat): enhance chat functionality with web search and article integration

- Updated ChatMessage and ChatWindow components to support inline web search results and articles.
- Integrated new web search and RSS plugins into the chat system for real-time information retrieval.
- Enhanced useContentPanel to manage web search results alongside existing media types.
- Added ArticleOverlay component for displaying selected articles from search results.
- Improved UI elements and styles for better user interaction with web search features.

Made-with: Cursor
This commit is contained in:
Dorian
2026-03-02 21:29:50 +00:00
parent 49ec6c09b6
commit 2d056f9498
36 changed files with 2616 additions and 303 deletions
+75 -89
View File
@@ -1,104 +1,43 @@
<template>
<div
ref="headerRef"
class="path-glass-card rounded-t-2xl rounded-b-none flex items-center justify-between px-4 py-3 relative z-[60] shrink-0 border-t-0 border-x-0"
:class="isDark ? '!border-b-white/10' : '!border-b-black/8'"
class="flex flex-col gap-0 p-3 relative z-[60] shrink-0"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="w-8 h-8 rounded-xl path-glass-icon flex items-center justify-center shrink-0 overflow-hidden">
<span class="text-base" :class="isDark ? 'text-[#fafafa]' : 'text-gray-800'"></span>
</div>
<div class="min-w-0 flex-1 relative">
<button
ref="chatListTriggerRef"
class="w-full text-left group/btn"
:class="conversationList.length > 0 ? 'cursor-pointer' : ''"
@click="conversationList.length > 0 && (showChatList = !showChatList)"
>
<h2 class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/96' : 'text-gray-900'">{{ title }}</h2>
<div class="flex items-center gap-1.5">
<p class="text-[10px] truncate font-mono"
:class="isDark ? 'text-white/40' : 'text-gray-400'">{{ conversationId }}</p>
<span class="text-[10px]" :class="isDark ? 'text-white/20' : 'text-gray-300'">·</span>
<button
ref="modelPickerTriggerRef"
class="text-[10px] text-accent/70 hover:text-accent transition-colors truncate"
@click.stop="showModelPicker = !showModelPicker; showChatList = false"
>
{{ modelDisplayName }}
</button>
</div>
</button>
<Teleport to="body">
<div v-if="showChatList && conversationList.length > 0" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showChatList = false" />
<Transition name="picker">
<div
v-if="showChatList && conversationList.length > 0"
class="fixed z-[9999] mt-1 p-2 max-h-48 overflow-y-auto animate-fade-up-fast shadow-2xl rounded-xl min-w-[200px]"
:class="isDark ? 'bg-[#161618] border border-white/10' : 'bg-white border border-black/10'"
:style="chatListDropdownStyle"
@click.stop
>
<p class="text-[10px] font-semibold uppercase tracking-wider mb-2 px-1"
:class="isDark ? 'text-white/40' : 'text-gray-400'">Saved chats</p>
<button
v-for="c in conversationList"
:key="c.id"
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all"
:class="c.id === activeConversationId
? 'nav-tab-active'
: isDark
? 'text-white/60 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
@click="selectChat(c.id)"
>
{{ c.title || 'Untitled' }}
</button>
</div>
</Transition>
</Teleport>
</div>
</div>
<div class="flex items-center gap-1">
<div class="flex items-center justify-between gap-2">
<button
class="p-2 rounded-lg transition-colors"
ref="modelPickerTriggerRef"
class="w-8 h-8 rounded-xl path-glass-icon flex items-center justify-center shrink-0 transition-colors cursor-pointer"
:class="isDark
? 'text-white/70 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
:title="isDark ? 'Light mode' : 'Dark mode'"
aria-label="Toggle theme"
@click="toggleTheme"
? 'text-[#fafafa] hover:text-white'
: 'text-gray-800 hover:text-gray-900'"
:title="`AI model: ${modelDisplayName}`"
aria-label="Select AI model"
@click="showModelPicker = !showModelPicker; showChatList = false"
>
<svg v-if="isDark" 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="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<svg v-else 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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
<span class="text-base"></span>
</button>
<div class="flex items-center gap-2 shrink-0">
<button
class="p-2 rounded-lg transition-colors"
:class="isDark
? 'text-white/70 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
:title="side === 'right' ? 'Move panel to left' : 'Move panel to right'"
aria-label="Switch panel side"
@click="$emit('switchSide')"
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors"
:class="webSearchEnabled
? 'text-accent'
: isDark
? 'text-white/70 hover:text-white'
: 'text-gray-500 hover:text-gray-900'"
:title="webSearchEnabled ? 'Web search on' : 'Web search off'"
aria-label="Toggle web search"
@click="chatStore.webSearchEnabled = !chatStore.webSearchEnabled"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="side === 'right'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button
class="p-2 rounded-lg transition-colors"
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors"
:class="isDark
? 'text-white/70 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
? 'text-white/70 hover:text-white'
: 'text-gray-500 hover:text-gray-900'"
aria-label="New conversation"
@click="$emit('newChat')"
>
@@ -109,10 +48,10 @@
<button
v-if="showClose"
class="p-2 rounded-lg transition-colors"
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors"
:class="isDark
? 'text-white/70 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
? 'text-white/70 hover:text-white'
: 'text-gray-500 hover:text-gray-900'"
aria-label="Close"
@click="$emit('close')"
>
@@ -120,8 +59,54 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<button
ref="chatListTriggerRef"
class="w-full text-left pt-3 pb-1 min-w-0"
:class="conversationList.length > 0 ? 'cursor-pointer' : ''"
@click="conversationList.length > 0 && (showChatList = !showChatList)"
>
<h2 class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/96' : 'text-gray-900'">{{ title }}</h2>
<div class="flex items-center gap-1.5 mt-0.5">
<p class="text-[10px] truncate font-mono"
:class="isDark ? 'text-white/40' : 'text-gray-400'">{{ conversationId }}</p>
<span class="text-[10px]" :class="isDark ? 'text-white/20' : 'text-gray-300'">·</span>
<span class="text-[10px] truncate"
:class="isDark ? 'text-white/50' : 'text-gray-500'">{{ modelDisplayName }}</span>
</div>
</button>
<Teleport to="body">
<div v-if="showChatList && conversationList.length > 0" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showChatList = false" />
<Transition name="picker">
<div
v-if="showChatList && conversationList.length > 0"
class="fixed z-[9999] path-glass-card p-3 max-h-48 overflow-y-auto animate-fade-up-fast shadow-2xl min-w-[200px]"
:style="chatListDropdownStyle"
@click.stop
>
<p class="text-[10px] font-semibold uppercase tracking-wider mb-2 px-1"
:class="isDark ? 'text-white/40' : 'text-gray-400'">Saved chats</p>
<button
v-for="c in conversationList"
:key="c.id"
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all"
:class="c.id === activeConversationId
? 'nav-tab-active'
: isDark
? 'text-white/60 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
@click="selectChat(c.id)"
>
{{ c.title || 'Untitled' }}
</button>
</div>
</Transition>
</Teleport>
<Teleport to="body">
<div v-if="showModelPicker" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showModelPicker = false" />
<Transition name="picker">
@@ -178,8 +163,9 @@ defineEmits<{
}>()
const { activeProvider, activeModel, availableProviders, setProvider, setModel } = useAI()
const { isDark, toggleTheme } = useTheme()
const { isDark } = useTheme()
const chatStore = useChatStore()
const webSearchEnabled = computed(() => chatStore.webSearchEnabled)
const showModelPicker = ref(false)
const showChatList = ref(false)
const headerRef = ref<HTMLElement | null>(null)
@@ -39,6 +39,24 @@
/>
</div>
<div v-if="inlineNewsLinks.length > 0" class="mt-3 space-y-1" @click.stop>
<NewsCard
v-for="(link, i) in inlineNewsLinks"
:key="i"
:article="link"
@select-article="handleArticleSelect"
/>
</div>
<div v-if="inlineWebsitesLinks.length > 0" class="mt-3 space-y-1" @click.stop>
<NewsCard
v-for="(link, i) in inlineWebsitesLinks"
:key="`web-${i}`"
:article="link"
@select-article="handleWebsiteSelect"
/>
</div>
<div class="flex items-center gap-2 mt-1.5">
<span class="text-[10px] select-none"
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ formattedTime }}</span>
@@ -63,6 +81,20 @@
>
View all {{ inlinePodcasts.length }} podcasts
</button>
<button
v-else-if="inlineNewsLinks.length > 1"
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineNewsLinks.length }} articles
</button>
<button
v-else-if="inlineWebsitesLinks.length > 1"
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineWebsitesLinks.length }} websites
</button>
</div>
</div>
</div>
@@ -70,50 +102,61 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Message } from '@aiui/core/types/message'
import type { Message, WebSearchResult } from '@aiui/core/types/message'
import type { Film, Song, Podcast } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel } from '@/composables/useContentPanel'
import { useArticleOverlayStore } from '@/stores/articleOverlay'
import FilmCard from '@/components/content/FilmCard.vue'
import SongCard from '@/components/content/SongCard.vue'
import PodcastCard from '@/components/content/PodcastCard.vue'
import NewsCard from '@/components/content/NewsCard.vue'
const props = defineProps<{
message: Message
index: number
}>()
const props = withDefaults(
defineProps<{
message: Message
index: number
triggeringQuery?: string
}>(),
{ triggeringQuery: '' }
)
const { isDark } = useTheme()
const { extractAllFilms, extractAllSongs, extractAllPodcasts, stripContentTags, updatePanelFromText, openFilmDetail, openSongDetail, openPodcastDetail, closeFilmDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
const { getContextualInlineContent, stripContentTags, stripMarkdownLinks, updatePanelFromText, openFilmDetail, openSongDetail, openPodcastDetail, openArticleDetail, closeFilmDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
const overlayStore = useArticleOverlayStore()
const isUser = computed(() => props.message.role === 'user')
const inlineContent = computed(() => {
if (isUser.value) return { films: [] as Film[], songs: [] as Song[], podcasts: [] as Podcast[], newsLinks: [], websitesLinks: [] }
return getContextualInlineContent(props.message.content, props.triggeringQuery, props.message.webResults ?? [])
})
const bubbleClasses = computed(() =>
isUser.value
? 'path-glass-bubble-user rounded-br-md rounded-2xl'
: 'path-glass-bubble rounded-bl-md rounded-2xl'
)
const inlineFilms = computed(() => {
if (isUser.value) return []
return extractAllFilms(props.message.content)
})
const inlineFilms = computed(() => inlineContent.value.films)
const inlineSongs = computed(() => inlineContent.value.songs)
const inlinePodcasts = computed(() => inlineContent.value.podcasts)
const inlineNewsLinks = computed(() => inlineContent.value.newsLinks ?? [])
const inlineWebsitesLinks = computed(() => inlineContent.value.websitesLinks ?? [])
const inlineSongs = computed(() => {
if (isUser.value) return []
return extractAllSongs(props.message.content)
})
const inlinePodcasts = computed(() => {
if (isUser.value) return []
return extractAllPodcasts(props.message.content)
})
const hasContext = computed(() => !isUser.value && (inlineFilms.value.length > 0 || inlineSongs.value.length > 0 || inlinePodcasts.value.length > 0))
const hasContext = computed(() => !isUser.value && (
inlineFilms.value.length > 0 ||
inlineSongs.value.length > 0 ||
inlinePodcasts.value.length > 0 ||
inlineNewsLinks.value.length > 0 ||
inlineWebsitesLinks.value.length > 0
))
const displayText = computed(() => {
if (isUser.value) return props.message.content
return stripContentTags(props.message.content)
let text = stripContentTags(props.message.content)
if (inlineNewsLinks.value.length > 0 || inlineWebsitesLinks.value.length > 0) text = stripMarkdownLinks(text)
return text
})
const formattedTime = computed(() => {
@@ -121,22 +164,26 @@ const formattedTime = computed(() => {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
})
function openPanelWithContext() {
updatePanelFromText(props.message.content, props.triggeringQuery, props.message.webResults ?? [])
}
function handleFilmSelect(film: Film) {
updatePanelFromText(props.message.content)
openPanelWithContext()
closeSongDetail()
closePodcastDetail()
openFilmDetail(film)
}
function handleSongSelect(song: Song) {
updatePanelFromText(props.message.content)
openPanelWithContext()
closeFilmDetail()
closePodcastDetail()
openSongDetail(song)
}
function handlePodcastSelect(podcast: Podcast) {
updatePanelFromText(props.message.content)
openPanelWithContext()
closeFilmDetail()
closeSongDetail()
openPodcastDetail(podcast)
@@ -146,7 +193,16 @@ function openPanel() {
closeFilmDetail()
closeSongDetail()
closePodcastDetail()
updatePanelFromText(props.message.content)
openPanelWithContext()
}
function handleArticleSelect(article: WebSearchResult) {
openPanelWithContext()
openArticleDetail(article)
}
function handleWebsiteSelect(article: WebSearchResult) {
overlayStore.open(article.url, article.title, undefined, article.imgSrc)
}
function handleBubbleClick() {
@@ -30,6 +30,7 @@
:key="msg.id"
:message="msg"
:index="i"
:triggering-query="getTriggeringQuery(messages, i)"
/>
<StreamingDots v-if="isStreaming && lastMessageEmpty" />
@@ -95,6 +96,14 @@ const lastMessageEmpty = computed(() => {
return msgs[msgs.length - 1].content === ''
})
function getTriggeringQuery(msgs: typeof messages.value, idx: number): string {
if (msgs[idx]?.role !== 'assistant') return ''
for (let i = idx - 1; i >= 0; i--) {
if (msgs[i]?.role === 'user') return msgs[i].content ?? ''
}
return ''
}
function handleNewChat() {
chatStore.createConversation()
}
@@ -114,15 +123,23 @@ watch(
)
watch(
() => messages.value[messages.value.length - 1]?.content,
(content) => {
() => {
const msgs = messages.value
const last = msgs[msgs.length - 1]
return last ? { content: last.content, webResults: last.webResults } : null
},
(val) => {
nextTick(() => {
const el = messageListRef.value
if (el) el.scrollTop = el.scrollHeight
})
if (content) {
updatePanelFromText(content)
if (val?.content) {
const msgs = messages.value
const lastMsg = msgs[msgs.length - 1]
const lastUser = [...msgs].reverse().find((m) => m.role === 'user')
updatePanelFromText(val.content, lastUser?.content ?? '', lastMsg?.webResults ?? [])
}
}
},
{ deep: true, immediate: true }
)
</script>
@@ -0,0 +1,131 @@
<template>
<div class="article-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<div class="relative w-full overflow-hidden aspect-[16/7] shrink-0">
<img
v-if="article.imgSrc && isSafeImgSrc(article.imgSrc)"
:src="article.imgSrc"
:alt="article.title"
class="absolute inset-0 w-full h-full object-cover object-center block"
/>
<div
v-else
class="absolute inset-0"
:style="{ background: fallbackGradient }"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
<button
class="absolute top-3 left-3 p-2 rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10 text-white/80"
@click="$emit('back')"
>
<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="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ article.title }}</h2>
<p v-if="articleDomain" class="text-xs text-white/60 mt-1">{{ articleDomain }}</p>
</div>
</div>
<div class="p-4 space-y-4">
<article
v-if="article.content"
class="[&_p]:mb-3 [&_ul]:list-disc [&_ol]:list-decimal [&_li]:ml-4 [&_a]:underline [&_a]:underline-offset-2 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:italic"
:class="isDark ? 'text-white/90' : 'text-gray-900'"
>
<div v-html="sanitizedContent" />
</article>
<div v-else class="py-4">
<p class="text-sm" :class="isDark ? 'text-white/50' : 'text-gray-500'">
Full article content is not available. Open the link below to read on the source site.
</p>
</div>
<a
v-if="article.url"
:href="article.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/10 hover:bg-white/15 text-white/90'
: 'bg-black/5 hover:bg-black/10 text-gray-800'"
>
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Read full article
</a>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { WebSearchResult } from '@aiui/core/types/message'
import { useTheme } from '@/composables/useTheme'
const props = defineProps<{ article: WebSearchResult }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const articleDomain = computed(() => {
try {
return new URL(props.article.url).hostname.replace(/^www\./, '')
} catch {
return ''
}
})
const fallbackGradient = computed(() => {
const hue = [...props.article.title].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%)`
})
function isSafeImgSrc(src: string): boolean {
try {
const u = new URL(src)
return /^https?:$/i.test(u.protocol)
} catch {
return false
}
}
/** Allow safe HTML tags; strip scripts and dangerous attributes */
function sanitizeHtml(html: string): string {
const div = document.createElement('div')
div.innerHTML = html
const allowed = new Set(['p', 'br', 'a', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'span', 'div'])
const walk = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? ''
if (node.nodeType !== Node.ELEMENT_NODE) return ''
const el = node as Element
const tag = el.tagName.toLowerCase()
if (tag === 'script' || tag === 'style' || tag === 'iframe' || tag === 'object' || tag === 'embed') return ''
if (!allowed.has(tag)) return [...node.childNodes].map(walk).join('')
const attrs: string[] = []
if (tag === 'a' && el.getAttribute('href')) {
const href = el.getAttribute('href') ?? ''
if (/^https?:\/\//i.test(href) && !/javascript:/i.test(href)) attrs.push(`href="${href.replace(/"/g, '&quot;')}"`)
}
if (tag === 'img' && el.getAttribute('src')) {
const src = el.getAttribute('src') ?? ''
if (/^https?:\/\//i.test(src)) attrs.push(`src="${src.replace(/"/g, '&quot;')}"`)
}
const inner = [...node.childNodes].map(walk).join('')
return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>${inner}</${tag}>`
}
return [...div.childNodes].map(walk).join('')
}
const sanitizedContent = computed(() => {
const c = props.article.content
if (!c) return ''
if (/<[a-z][\s\S]*>/i.test(c)) return sanitizeHtml(c)
return `<p class="whitespace-pre-wrap">${c.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>`
})
</script>
@@ -0,0 +1,233 @@
<template>
<Teleport to="body">
<Transition name="app-launcher">
<div
v-if="store.isOpen"
class="fixed inset-0 z-[2400] flex items-center justify-center p-6 md:p-10"
@click.self="store.close()"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-md" />
<div
class="article-overlay-panel relative z-10 flex flex-col overflow-hidden rounded-2xl shadow-2xl path-glass-card"
:class="panelClasses"
>
<div class="flex items-center gap-3 px-4 py-3 shrink-0"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
<button
v-if="!store.content"
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors transition-transform duration-300 disabled:opacity-70 disabled:cursor-not-allowed"
:class="isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-black/5 text-gray-600'"
aria-label="Refresh page"
title="Refresh"
:disabled="isRefreshing"
@click="refreshIframe"
>
<svg
class="w-5 h-5"
:class="{ 'animate-spin': isRefreshing }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<span class="flex-1 truncate text-sm font-medium min-w-0"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ store.title || 'Article' }}
</span>
<a
v-if="store.url"
:href="store.url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors shrink-0"
:class="isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-black/5 text-gray-600'"
aria-label="Open in new tab"
title="Open in new tab"
@click.stop
>
<svg class="w-5 h-5" 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>
</a>
<button
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors shrink-0"
:class="isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-black/5 text-gray-600'"
aria-label="Close"
@click="store.close()"
>
<svg class="w-5 h-5" 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>
</div>
<div class="relative flex-1 min-h-0 bg-black/20 overflow-hidden">
<!-- When we have RSS/article content, render it; otherwise load URL in iframe -->
<div
v-if="store.content"
class="absolute inset-0 overflow-y-auto p-4 md:p-6 text-sm leading-relaxed"
:class="isDark ? 'text-white/90' : 'text-gray-900'"
>
<article
class="[&_p]:mb-3 [&_ul]:list-disc [&_ol]:list-decimal [&_li]:ml-4 [&_a]:underline [&_a]:underline-offset-2 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:italic"
>
<img
v-if="store.imgSrc && isSafeImgSrc(store.imgSrc)"
:src="store.imgSrc"
:alt="store.title"
class="w-full rounded-lg object-cover max-h-48 mb-4"
/>
<div v-html="sanitizedContent" />
</article>
<a
:href="store.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 mt-4 text-sm"
:class="isDark ? 'text-white/70 hover:text-white' : 'text-gray-500 hover:text-gray-800'"
>
Read full article
<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>
</a>
</div>
<iframe
v-else-if="store.url"
ref="iframeRef"
:key="iframeRefreshKey"
:src="store.url"
class="absolute inset-0 w-full h-full border-0"
style="-ms-overflow-style: none; scrollbar-width: none;"
title="Article content"
@load="onIframeLoad"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useArticleOverlayStore } from '@/stores/articleOverlay'
import { useTheme } from '@/composables/useTheme'
const store = useArticleOverlayStore()
const { isDark } = useTheme()
/** Allow safe HTML tags; strip scripts and dangerous attributes */
function sanitizeHtml(html: string): string {
const div = document.createElement('div')
div.innerHTML = html
const allowed = new Set(['p', 'br', 'a', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'span', 'div'])
const walk = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? ''
if (node.nodeType !== Node.ELEMENT_NODE) return ''
const el = node as Element
const tag = el.tagName.toLowerCase()
if (tag === 'script' || tag === 'style' || tag === 'iframe' || tag === 'object' || tag === 'embed') return ''
if (!allowed.has(tag)) return [...node.childNodes].map(walk).join('')
const attrs: string[] = []
if (tag === 'a' && el.getAttribute('href')) {
const href = el.getAttribute('href') ?? ''
if (/^https?:\/\//i.test(href) && !/javascript:/i.test(href)) attrs.push(`href="${href.replace(/"/g, '&quot;')}"`)
}
if (tag === 'img' && el.getAttribute('src')) {
const src = el.getAttribute('src') ?? ''
if (/^https?:\/\//i.test(src)) attrs.push(`src="${src.replace(/"/g, '&quot;')}"`)
}
const inner = [...node.childNodes].map(walk).join('')
return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>${inner}</${tag}>`
}
return [...div.childNodes].map(walk).join('')
}
const sanitizedContent = computed(() => {
const c = store.content
if (!c) return ''
if (/<[a-z][\s\S]*>/i.test(c)) return sanitizeHtml(c)
return `<p class="whitespace-pre-wrap">${c.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>`
})
function isSafeImgSrc(src: string): boolean {
try {
const u = new URL(src)
return /^https?:$/i.test(u.protocol)
} catch {
return false
}
}
const iframeRef = ref<HTMLIFrameElement | null>(null)
const iframeRefreshKey = ref(0)
const isRefreshing = ref(false)
function refreshIframe() {
isRefreshing.value = true
iframeRefreshKey.value++
}
function onIframeLoad() {
isRefreshing.value = false
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && store.isOpen) {
store.close()
e.preventDefault()
e.stopPropagation()
}
}
watch(
() => store.isOpen,
(open) => {
if (!open) isRefreshing.value = false
}
)
onMounted(() => {
window.addEventListener('keydown', onKeyDown, true)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeyDown, true)
})
const panelClasses = [
'w-full max-w-[calc(100vw-3rem)] h-[80vh] max-h-[calc(100vh-5rem)]',
'md:max-w-[calc(100vw-5rem)]',
]
</script>
<style scoped>
iframe::-webkit-scrollbar {
display: none;
}
.app-launcher-enter-active,
.app-launcher-leave-active {
transition: opacity 0.25s ease;
}
.app-launcher-enter-active .article-overlay-panel,
.app-launcher-leave-active .article-overlay-panel {
transition: transform 0.25s ease, opacity 0.25s ease;
}
.app-launcher-enter-from,
.app-launcher-leave-to {
opacity: 0;
}
.app-launcher-enter-from .article-overlay-panel,
.app-launcher-leave-to .article-overlay-panel {
transform: scale(0.96);
opacity: 0;
}
</style>
@@ -1,7 +1,7 @@
<template>
<div class="relative flex-1 flex flex-col min-h-0 overflow-hidden">
<div
v-if="contextType === 'film' || contextType === 'song' || contextType === 'podcast'"
v-if="contextType === 'film' || contextType === 'song' || contextType === 'podcast' || contextType === 'websites'"
class="flex-1 flex flex-col min-h-0"
>
<div
@@ -75,7 +75,7 @@ import LoadingFilmGrid from './LoadingFilmGrid.vue'
const props = withDefaults(
defineProps<{
contextType?: 'film' | 'song' | 'podcast' | 'generic'
contextType?: 'film' | 'song' | 'podcast' | 'websites' | 'generic'
}>(),
{ contextType: 'film' }
)
@@ -86,6 +86,7 @@ const contextLabel = computed(() => {
if (props.contextType === 'film') return 'Film recommendations'
if (props.contextType === 'song') return 'Song recommendations'
if (props.contextType === 'podcast') return 'Podcast recommendations'
if (props.contextType === 'websites') return 'Websites'
return 'Content'
})
</script>
@@ -1,22 +1,22 @@
<template>
<div class="film-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<div class="relative w-full overflow-hidden">
<div class="relative w-full overflow-hidden aspect-[16/7] shrink-0">
<img
v-if="bannerSrc"
:src="bannerSrc"
:alt="film.title"
class="w-full aspect-[16/7] object-cover block"
class="absolute inset-0 w-full h-full object-cover object-center block"
@error="onBannerError"
/>
<div
v-else
class="w-full aspect-[16/7]"
class="absolute inset-0"
:style="{ background: fallbackGradient }"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
<button
class="absolute top-3 left-3 p-2 rounded-lg backdrop-blur-md transition-colors bg-black/30 text-white/80 hover:bg-black/50"
class="absolute top-3 left-3 p-2 rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10 text-white/80"
@click="$emit('back')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -43,12 +43,12 @@
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-3">
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<button
v-for="film in filteredFilms"
:key="film.id"
class="group flex flex-col items-stretch text-left w-full"
class="group flex flex-col items-stretch text-left w-full path-glass-bubble rounded-2xl overflow-hidden transition-all duration-200 hover:brightness-105"
@click="$emit('selectFilm', film)"
>
<div class="poster-card flex-1 min-h-0">
@@ -83,10 +83,6 @@
</div>
</div>
</div>
<p class="text-xs font-semibold mt-2 truncate px-0.5"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ film.title }}
</p>
</button>
</div>
@@ -1,68 +1,12 @@
<template>
<div class="flex w-full animate-pulse flex-col space-y-3">
<div
class="aspect-[2/3] rounded-xl overflow-hidden relative"
:class="isDark ? 'text-white/20' : 'text-gray-400'"
>
<svg
viewBox="0 0 200 300"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="w-full h-full object-cover"
>
<defs>
<clipPath :id="clipId">
<rect width="200" height="300" rx="12" fill="white"/>
</clipPath>
</defs>
<g :clip-path="`url(#${clipId})`">
<rect width="200" height="300" fill="currentColor" fill-opacity="0.4"/>
<g fill="currentColor" fill-opacity="0.5">
<rect x="12" y="16" width="24" height="36" rx="3"/>
<rect x="52" y="16" width="24" height="36" rx="3"/>
<rect x="92" y="16" width="24" height="36" rx="3"/>
<rect x="132" y="16" width="24" height="36" rx="3"/>
<rect x="12" y="64" width="24" height="36" rx="3"/>
<rect x="52" y="64" width="24" height="36" rx="3"/>
<rect x="92" y="64" width="24" height="36" rx="3"/>
<rect x="132" y="64" width="24" height="36" rx="3"/>
</g>
</g>
</svg>
</div>
<div v-if="hasText" class="space-y-2">
<div
class="h-5 w-24 rounded-full"
:class="isDark ? 'bg-white/20' : 'bg-gray-300'"
/>
<div class="flex items-center gap-2">
<div
class="h-4 w-16 rounded-full"
:class="isDark ? 'bg-white/15' : 'bg-gray-200'"
/>
<div
class="size-1.5 rounded-full shrink-0"
:class="isDark ? 'bg-white/15' : 'bg-gray-300'"
/>
<div
class="h-4 w-14 rounded-full"
:class="isDark ? 'bg-white/15' : 'bg-gray-200'"
/>
</div>
</div>
</div>
<div
class="aspect-[2/3] rounded-xl animate-pulse"
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
/>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
withDefaults(
defineProps<{
hasText?: boolean
}>(),
{ hasText: true }
)
const { isDark } = useTheme()
const clipId = `loading-poster-${Math.random().toString(36).slice(2, 10)}`
</script>
@@ -4,7 +4,6 @@
<LoadingFilmCard
v-for="i in count"
:key="i"
:has-text="hasText"
/>
</div>
</div>
@@ -16,8 +15,7 @@ import LoadingFilmCard from './LoadingFilmCard.vue'
withDefaults(
defineProps<{
count?: number
hasText?: boolean
}>(),
{ count: 12, hasText: true }
{ count: 12 }
)
</script>
@@ -0,0 +1,289 @@
<template>
<div class="h-full flex flex-col">
<!-- Masthead: AI Brief branding -->
<header
class="shrink-0 px-4 py-2 flex items-center justify-between gap-2"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<p class="text-[10px] uppercase tracking-[0.2em] font-semibold shrink-0"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
AI Brief
</p>
<div class="flex-1" />
<div class="shrink-0">
<slot name="header-actions" />
</div>
</header>
<div class="flex-1 overflow-y-auto custom-scrollbar pb-16">
<!-- Headline banner: edge-to-edge, tech style -->
<div
v-if="headlineText"
class="relative w-full pt-4 overflow-hidden"
:class="isDark ? 'bg-white/[0.02]' : 'bg-black/[0.02]'"
>
<!-- Tech lines decoration -->
<div class="absolute inset-0 pointer-events-none overflow-hidden">
<div
class="absolute left-0 top-0 bottom-0 w-px"
:class="isDark ? 'bg-gradient-to-b from-transparent via-white/20 to-transparent' : 'bg-gradient-to-b from-transparent via-black/10 to-transparent'"
/>
<div
class="absolute left-12 top-0 bottom-0 w-px"
:class="isDark ? 'bg-white/5' : 'bg-black/5'"
/>
<div
class="absolute right-0 top-0 bottom-0 w-px"
:class="isDark ? 'bg-gradient-to-b from-transparent via-white/20 to-transparent' : 'bg-gradient-to-b from-transparent via-black/10 to-transparent'"
/>
<div
class="absolute left-0 right-0 bottom-0 h-px"
:class="isDark ? 'bg-gradient-to-r from-transparent via-white/15 to-transparent' : 'bg-gradient-to-r from-transparent via-black/10 to-transparent'"
/>
<!-- Scan line accent -->
<div
class="absolute left-0 right-0 top-1/2 h-px"
:class="isDark ? 'bg-white/5' : 'bg-black/5'"
/>
</div>
<div class="relative flex items-start gap-3 px-4 py-5 sm:py-6">
<!-- News icon -->
<div
class="shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center"
:class="isDark ? 'bg-white/10' : 'bg-black/5'"
>
<svg class="w-5 h-5 sm:w-6 sm:h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
<path d="M18 14h-8" />
<path d="M15 18h-5" />
<path d="M10 6h8v4h-8V6Z" />
</svg>
</div>
<div class="min-w-0 flex-1">
<span
class="text-[9px] uppercase tracking-[0.25em] font-bold block mb-1"
:class="isDark ? 'text-white/40' : 'text-gray-500'"
>
What you asked
</span>
<h1
class="text-xl sm:text-2xl font-bold leading-snug"
:class="isDark ? 'text-white/95' : 'text-gray-900'"
>
{{ headlineText }}
</h1>
</div>
</div>
</div>
<!-- News homepage body -->
<div
class="space-y-4 px-4 pt-5"
>
<!-- Lead story: first section -->
<article
v-if="sections[0]"
class="path-glass-bubble rounded-2xl p-4 sm:p-5 border-l relative"
:class="isDark ? 'border-white/10' : 'border-black/5'"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<span
class="text-[9px] uppercase tracking-widest font-bold block mb-1.5"
:class="isDark ? 'text-white/40' : 'text-gray-500'"
>
Lead
</span>
<h2 class="text-base font-bold mb-2"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ sections[0].title }}
</h2>
<p v-if="sections[0].author"
class="text-[10px] mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ sections[0].author }}
</p>
<p class="text-sm leading-relaxed"
:class="isDark ? 'text-white/80' : 'text-gray-700'"
v-html="formatContent(sections[0].content)"
/>
</div>
<button
v-if="sections[0].url"
class="shrink-0 p-2 rounded-lg transition-colors"
:class="isDark ? 'text-white/50 hover:bg-white/10 hover:text-white/70' : 'text-gray-500 hover:bg-black/5 hover:text-gray-700'"
title="Open in new window"
aria-label="Open link"
@click.stop="openLink(sections[0].url!)"
>
<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>
</button>
</div>
</article>
<!-- Secondary stories grid -->
<div class="grid sm:grid-cols-2 gap-4">
<article
v-for="(section, i) in sections.slice(1)"
:key="i"
class="path-glass-bubble rounded-2xl p-3 sm:p-4 relative"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<h3 class="text-sm font-bold mb-1"
:class="isDark ? 'text-white/85' : 'text-gray-800'">
{{ section.title }}
</h3>
<p v-if="section.author"
class="text-[10px] mb-2"
:class="isDark ? 'text-white/45' : 'text-gray-500'">
{{ section.author }}
</p>
<p class="text-xs leading-relaxed"
:class="isDark ? 'text-white/75' : 'text-gray-600'"
v-html="formatContent(section.content)"
/>
</div>
<button
v-if="section.url"
class="shrink-0 p-1.5 rounded-lg transition-colors"
:class="isDark ? 'text-white/40 hover:bg-white/10 hover:text-white/60' : 'text-gray-400 hover:bg-black/5 hover:text-gray-600'"
title="Open in new window"
aria-label="Open link"
@click.stop="openLink(section.url!)"
>
<svg class="w-3.5 h-3.5" 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>
</button>
</div>
</article>
</div>
</div>
<div v-if="sections.length === 0" class="flex items-center justify-center py-12 px-4 pt-5">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No sections to display
</p>
</div>
<!-- Images below content: equally sized containers -->
<div class="px-4 pt-6 grid grid-cols-2 gap-4">
<div
class="path-glass-bubble rounded-2xl overflow-hidden aspect-[4/3]"
>
<div class="relative w-full h-full overflow-hidden">
<img
:src="heroImageDisplay"
alt=""
class="absolute inset-0 w-full h-full object-cover"
loading="lazy"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent pointer-events-none" />
</div>
</div>
<div
class="path-glass-bubble rounded-2xl overflow-hidden aspect-[4/3]"
>
<div class="relative w-full h-full overflow-hidden">
<img
:src="memeImageUrl"
alt=""
class="absolute inset-0 w-full h-full object-cover"
loading="lazy"
/>
<div class="absolute bottom-0 left-0 right-0 p-2 text-center"
:class="isDark ? 'bg-black/70' : 'bg-black/50'">
<p class="text-[11px] font-bold"
:class="isDark ? 'text-white/95' : 'text-white'">
{{ memeCaption }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useArticleOverlayStore } from '@/stores/articleOverlay'
import type { MagazineSection } from '@/composables/useContentPanel'
const props = withDefaults(defineProps<{
sections: MagazineSection[]
/** Hero image URL (from response or web results) */
heroImageUrl?: string | null
title?: string
/** User's prompt for headline banner */
query?: string
}>(), {
heroImageUrl: null,
title: 'Brief',
query: '',
})
const { isDark } = useTheme()
const overlayStore = useArticleOverlayStore()
function isSafeImgUrl(u: string | undefined | null): u is string {
return !!u && typeof u === 'string' && /^https?:\/\//i.test(u.trim())
}
/** Hero image: use extracted/web result, or picsum fallback seeded by query */
const heroImageDisplay = computed(() => {
if (props.heroImageUrl && isSafeImgUrl(props.heroImageUrl)) return props.heroImageUrl
const seed = (props.query || 'magazine').toLowerCase().replace(/\s+/g, '-').slice(0, 30) || 'brief'
return `https://picsum.photos/seed/${seed}/800/450`
})
/** Meme image: contextual meme templates (imgflip) */
const memeImageUrl = computed(() => {
const text = props.sections.map((s) => s.title + ' ' + s.content).join(' ').toLowerCase()
const q = (props.query || '').toLowerCase()
const combined = text + ' ' + q
if (/\bbearish|bear|fear|dump|crash|extreme fear\b/.test(combined)) return 'https://i.imgflip.com/wxica.jpg' // This is Fine
if (/\bbull|rally|moon|pump|buy the dip\b/.test(combined)) return 'https://i.imgflip.com/1bhk.jpg' // Success Kid
if (/\bbitcoin|btc\b/.test(combined)) return 'https://i.imgflip.com/30b1gx.jpg' // Drake
if (/\bmacro|fed|rate|inflation\b/.test(combined)) return 'https://i.imgflip.com/1ur9b0.jpg' // Distracted Boyfriend
return 'https://i.imgflip.com/1bij.jpg' // One does not simply
})
/** Meme caption: short contextual phrase */
const memeCaption = computed(() => {
const text = props.sections.map((s) => s.title + ' ' + s.content).join(' ').toLowerCase()
if (/\bbearish|fear|extreme fear\b/.test(text)) return 'Me checking my portfolio'
if (/\bbull|rally|moon\b/.test(text)) return 'Buying the dip'
if (/\bmacro|fed|inflation\b/.test(text)) return 'The economy rn'
if (/\bbitcoin|btc\b/.test(text)) return 'Bitcoin holders'
return 'Markets be like'
})
function openLink(url: string) {
overlayStore.open(url, '', undefined, undefined)
}
const headlineText = computed(() => {
const q = (props.query ?? '').trim()
if (!q) return props.title
return q.length > 100 ? q.slice(0, 97) + '…' : q
})
/** Render content with **bold** preserved (sanitized). */
function formatContent(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
}
</script>
@@ -0,0 +1,89 @@
<template>
<button
class="flex gap-3 p-2 rounded-xl transition-all duration-200 text-left w-full group overflow-hidden"
:class="isDark
? 'hover:bg-white/5 active:bg-white/10'
: 'hover:bg-black/[0.03] active:bg-black/5'"
@click="$emit('select-article', article)"
>
<div class="cover-card-sm shrink-0 w-12 h-12 rounded-lg overflow-hidden">
<img
v-if="imgSrc"
:src="imgSrc"
:alt="article.title"
class="w-full h-full object-cover rounded-[6px] transition-transform duration-300 group-hover:scale-105"
loading="lazy"
@error="imgFailed = true"
/>
<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>
</div>
<div class="min-w-0 flex-1 py-0.5">
<p class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">{{ article.title }}</p>
<p v-if="article.content"
class="text-[11px] mt-0.5 line-clamp-2"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
{{ article.content }}
</p>
<p class="text-[10px] mt-1 truncate"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ formatDomain(article.url) }}
</p>
</div>
<svg class="w-4 h-4 shrink-0 self-center opacity-50"
:class="isDark ? 'text-white/50' : 'text-gray-400'"
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>
</button>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { WebSearchResult } from '@aiui/core/types/message'
import { useTheme } from '@/composables/useTheme'
const props = defineProps<{ article: WebSearchResult }>()
defineEmits<{ 'select-article': [article: WebSearchResult] }>()
const { isDark } = useTheme()
const imgFailed = ref(false)
function isSafeImgUrl(u: string | undefined): u is string {
return !!u && typeof u === 'string' && /^https?:\/\//i.test(u.trim())
}
const imgSrc = computed(() => {
if (imgFailed.value) return null
const u = props.article.imgSrc
return isSafeImgUrl(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
}
})
function formatDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '')
} catch {
return url
}
}
</script>
@@ -0,0 +1,214 @@
<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'">
{{ filteredArticles.length }} {{ variant === 'websites' ? 'websites' : 'articles' }}
</span>
<slot name="header-actions" />
</div>
</div>
<input
v-model="search"
type="text"
:placeholder="variant === 'websites' ? 'Search websites...' : 'Search articles...'"
class="w-full px-3 py-2 rounded-lg text-xs outline-none transition-colors"
:class="isDark
? 'bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10'
: 'bg-black/3 text-gray-800 placeholder:text-gray-400 focus:bg-black/5'"
/>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<button
v-for="(article, i) in filteredArticles"
:key="i"
class="group flex flex-col items-stretch text-left w-full path-glass-bubble rounded-2xl overflow-hidden transition-all duration-200 hover:brightness-105"
@click="openArticle(article)"
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-[4/3] flex flex-col w-full overflow-hidden rounded-[10px]">
<!-- Top: image or icon area (edge-to-edge with title bar below) -->
<div class="flex-1 min-h-0 relative">
<img
v-if="isSafeImgUrl(article.imgSrc) && !failedImgs.has(article.url)"
:src="article.imgSrc"
:alt="article.title"
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onImgError(article.url)"
/>
<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>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent pointer-events-none" />
</div>
<div
class="shrink-0 p-2 backdrop-blur-md rounded-b-[10px]"
:class="[
isDark ? 'bg-black shadow-[inset_0_1px_0_rgba(255,255,255,0.12)]' : 'bg-white shadow-[inset_0_1px_0_rgba(0,0,0,0.06)]'
]"
>
<p class="text-[11px] font-semibold leading-tight line-clamp-2"
:class="isDark ? 'text-white/95' : 'text-gray-900'">
{{ article.title }}
</p>
<p v-if="article.content"
class="text-[9px] line-clamp-1 mt-0.5"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ article.content }}
</p>
<p class="text-[8px] truncate mt-0.5"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ formatDomain(article.url) }}
</p>
</div>
</div>
</div>
</button>
</div>
<div v-if="filteredArticles.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ variant === 'websites' ? 'No websites match your search' : 'No articles match your search' }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { WebSearchResult } from '@aiui/core/types/message'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel } from '@/composables/useContentPanel'
import { useArticleOverlayStore } from '@/stores/articleOverlay'
const props = withDefaults(defineProps<{
articles: WebSearchResult[]
title?: string
/** User query for contextual sorting (most relevant first) */
query?: string
/** 'news' = icon in upper area; 'websites' = icon in glassmorphic circle centered */
variant?: 'news' | 'websites'
}>(), {
title: 'News & Articles',
query: '',
variant: 'news',
})
const { isDark } = useTheme()
const { openArticleDetail } = useContentPanel()
const overlayStore = useArticleOverlayStore()
const search = ref('')
const failedImgs = ref<Set<string>>(new Set())
function isSafeImgUrl(u: string | undefined): u is string {
if (!u || typeof u !== 'string') return false
return /^https?:\/\//i.test(u.trim())
}
function onImgError(url: string) {
failedImgs.value = new Set([...failedImgs.value, url])
}
function formatDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '')
} catch {
return url
}
}
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 openArticle(article: WebSearchResult) {
if (props.variant === 'websites') {
overlayStore.open(article.url, article.title, undefined, article.imgSrc)
} else {
openArticleDetail(article)
}
}
function relevanceScore(article: WebSearchResult, query: string): number {
if (!query.trim()) return 0
const q = query.toLowerCase()
const terms = q.split(/\s+/).filter((t) => t.length > 1)
if (terms.length === 0) return 0
const title = article.title.toLowerCase()
const content = (article.content ?? '').toLowerCase()
const url = article.url.toLowerCase()
let score = 0
for (const term of terms) {
if (title.includes(term)) score += 3
if (content.includes(term)) score += 2
if (url.includes(term)) score += 1
}
return score
}
const filteredArticles = computed(() => {
let list = props.articles
if (search.value.trim()) {
const q = search.value.toLowerCase()
list = list.filter(
(a) =>
a.title.toLowerCase().includes(q) ||
(a.content ?? '').toLowerCase().includes(q) ||
a.url.toLowerCase().includes(q),
)
}
if (props.query.trim()) {
return [...list].sort((a, b) => relevanceScore(b, props.query) - relevanceScore(a, props.query))
}
return list
})
</script>
@@ -49,20 +49,28 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import type { Podcast } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePodcastCoverFallback } from '@/composables/useImageFallback'
import { generatePodcastCoverFallback, fetchPodcastCover } from '@/composables/useImageFallback'
const props = defineProps<{ podcast: Podcast }>()
defineEmits<{ select: [podcast: Podcast] }>()
const { isDark } = useTheme()
const coverFailed = ref(false)
const fetchedCover = ref<string | null>(null)
const coverSrc = computed(() => {
if (coverFailed.value) return null
return props.podcast.coverUrl || null
return props.podcast.coverUrl || fetchedCover.value || null
})
onMounted(() => {
if (props.podcast.coverUrl) return
fetchPodcastCover(props.podcast.title, props.podcast.host).then((url) => {
if (url) fetchedCover.value = url
})
})
const fallbackCover = computed(() =>
@@ -112,20 +112,28 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import type { Podcast } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePodcastCoverFallback } from '@/composables/useImageFallback'
import { generatePodcastCoverFallback, fetchPodcastCover } from '@/composables/useImageFallback'
const props = defineProps<{ podcast: Podcast }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const coverFailed = ref(false)
const fetchedCover = ref<string | null>(null)
const coverSrc = computed(() => {
if (coverFailed.value) return null
return props.podcast.coverUrl || null
return props.podcast.coverUrl || fetchedCover.value || null
})
onMounted(() => {
if (props.podcast.coverUrl) return
fetchPodcastCover(props.podcast.title, props.podcast.host).then((url) => {
if (url) fetchedCover.value = url
})
})
const fallbackCover = computed(() =>
@@ -43,12 +43,12 @@
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-3">
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<button
v-for="podcast in filteredPodcasts"
:key="podcast.id"
class="group flex flex-col items-stretch text-left w-full"
class="group flex flex-col items-stretch text-left w-full path-glass-bubble rounded-2xl overflow-hidden transition-all duration-200 hover:brightness-105"
@click="$emit('selectPodcast', podcast)"
>
<div class="cover-card flex-1 min-h-0 relative">
@@ -86,10 +86,6 @@
</div>
</div>
</div>
<p class="text-xs font-semibold mt-2 truncate px-0.5"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ podcast.title }}
</p>
</button>
</div>
@@ -103,10 +99,10 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, reactive, onMounted, watch } from 'vue'
import type { Podcast } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePodcastCoverFallback } from '@/composables/useImageFallback'
import { generatePodcastCoverFallback, fetchPodcastCover } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
podcasts: Podcast[]
@@ -121,12 +117,25 @@ 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 || 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)) continue
fetchPodcastCover(podcast.title, podcast.host).then((url) => {
if (url) fetchedCovers.set(podcast.id, url)
})
}
}
onMounted(() => fetchCoversFor(props.podcasts))
watch(() => props.podcasts, (p) => fetchCoversFor(p), { immediate: false })
function fallbackFor(podcast: Podcast): string {
return generatePodcastCoverFallback(podcast.title, podcast.host)
}
@@ -43,19 +43,19 @@
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-3">
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<button
v-for="song in filteredSongs"
:key="song.id"
class="group flex flex-col items-stretch text-left w-full"
class="group flex flex-col items-stretch text-left w-full path-glass-bubble rounded-2xl overflow-hidden transition-all duration-200 hover:brightness-105"
@click="$emit('selectSong', song)"
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-square relative w-full overflow-hidden rounded-[10px]">
<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"
title="Play"
aria-label="Play"
@click.stop="onPlayClick(song)"
>
<span class="w-16 h-16 rounded-full flex items-center justify-center path-glass-icon">