feat(content): add places, code mode, mobile context tab, and detail views

- Add Places/Restaurants content type with PlaceCard, PlaceDetail, PlaceGrid
- Add WebsiteDetail and MagazineSectionDetail views for Context panel
- Enhance MagazineGrid hero with background image and 3x taller header
- Add mobile 3-tab layout (Chat, Content, Context) with detail navigation
- Add /code command system: useCodeContext composable, ProjectGrid, FileTreeNode,
  CodeDetail for IDE-style code viewing across all three panels
- Fix /code bubble and prompt index clicks to re-activate code mode
- Fix updatePanelFromText overwriting code tab by skipping command messages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 09:31:18 +00:00
co-authored by Claude Opus 4.6
parent 48dd7a9c68
commit e8fc54cade
22 changed files with 1901 additions and 107 deletions
@@ -57,6 +57,15 @@
/>
</div>
<div v-if="inlinePlaces.length > 0" class="mt-3 space-y-1" @click.stop>
<PlaceCard
v-for="place in inlinePlaces"
:key="place.id"
:place="place"
@select="handlePlaceSelect"
/>
</div>
<div v-if="inlineNewsLinks.length > 0" class="mt-3 space-y-1" @click.stop>
<NewsCard
v-for="(link, i) in inlineNewsLinks"
@@ -106,6 +115,13 @@
>
View all {{ inlineImages.length }} images
</button>
<button
v-else-if="inlinePlaces.length > 1"
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlinePlaces.length }} places
</button>
<button
v-else-if="inlineSongs.length > 1"
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
@@ -149,15 +165,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Message, WebSearchResult } from '@aiui/core/types/message'
import type { Film, Song, Podcast, Book, TVSeries, ImageItem } from '@aiui/core/types/content'
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel, type MagazineSection } from '@/composables/useContentPanel'
import { useArticleOverlayStore } from '@/stores/articleOverlay'
import { useCodeContext } from '@/composables/useCodeContext'
import FilmCard from '@/components/content/FilmCard.vue'
import BookCard from '@/components/content/BookCard.vue'
import TVSeriesCard from '@/components/content/TVSeriesCard.vue'
import SongCard from '@/components/content/SongCard.vue'
import PodcastCard from '@/components/content/PodcastCard.vue'
import PlaceCard from '@/components/content/PlaceCard.vue'
import NewsCard from '@/components/content/NewsCard.vue'
const props = withDefaults(
@@ -170,13 +187,13 @@ const props = withDefaults(
)
const { isDark } = useTheme()
const { getContextualInlineContent, stripContentTags, stripMarkdownLinks, updatePanelFromText, openFilmDetail, openBookDetail, openTVSeriesDetail, openImageDetail, openSongDetail, openPodcastDetail, openArticleDetail, closeFilmDetail, closeBookDetail, closeTVSeriesDetail, closeImageDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
const overlayStore = useArticleOverlayStore()
const { getContextualInlineContent, stripContentTags, stripMarkdownLinks, updatePanelFromText, panelOpen, availableTabs, setActiveTab, openFilmDetail, openBookDetail, openTVSeriesDetail, openImageDetail, openPlaceDetail, openSongDetail, openPodcastDetail, openArticleDetail, openWebsiteDetail, closeFilmDetail, closeBookDetail, closeTVSeriesDetail, closeImageDetail, closePlaceDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
const codeContext = useCodeContext()
const isUser = computed(() => props.message.role === 'user')
const inlineContent = computed(() => {
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[] }
if (isUser.value) return { films: [] as Film[], books: [] as Book[], tvSeries: [] as TVSeries[], images: [] as ImageItem[], places: [] as Place[], 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 ?? [])
})
@@ -190,13 +207,19 @@ const inlineFilms = computed(() => inlineContent.value.films)
const inlineBooks = computed(() => inlineContent.value.books ?? [])
const inlineTVSeries = computed(() => inlineContent.value.tvSeries ?? [])
const inlineImages = computed(() => inlineContent.value.images ?? [])
const inlinePlaces = computed(() => inlineContent.value.places ?? [])
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 inlineMagazineSections = computed(() => inlineContent.value.magazineSections ?? [])
const isCodeResponse = computed(() =>
!isUser.value && props.triggeringQuery.trim().toLowerCase() === '/code'
)
const hasContext = computed(() => !isUser.value && (
isCodeResponse.value ||
inlineFilms.value.length > 0 ||
inlineBooks.value.length > 0 ||
inlineTVSeries.value.length > 0 ||
@@ -257,6 +280,14 @@ function handleSongSelect(song: Song) {
openSongDetail(song)
}
function handlePlaceSelect(place: Place) {
openPanelWithContext()
closeFilmDetail()
closeBookDetail()
closePlaceDetail()
openPlaceDetail(place)
}
function handlePodcastSelect(podcast: Podcast) {
openPanelWithContext()
closeFilmDetail()
@@ -279,10 +310,24 @@ function handleArticleSelect(article: WebSearchResult) {
}
function handleWebsiteSelect(article: WebSearchResult) {
overlayStore.open(article.url, article.title, undefined, article.imgSrc)
openPanelWithContext()
openWebsiteDetail(article)
}
function activateCodeMode() {
codeContext.enterCodeMode()
panelOpen.value = true
if (!availableTabs.value.includes('code')) {
availableTabs.value = [...availableTabs.value, 'code']
}
setActiveTab('code')
}
function handleBubbleClick() {
if (isCodeResponse.value) {
activateCodeMode()
return
}
if (hasContext.value) openPanel()
}
</script>
@@ -87,7 +87,9 @@ defineEmits<{
const chatStore = useChatStore()
const { sendMessage } = useAI()
const { isDark } = useTheme()
const { updatePanelFromText } = useContentPanel()
const { updatePanelFromText, panelOpen, activeTab, availableTabs, setActiveTab } = useContentPanel()
import { useCodeContext } from '@/composables/useCodeContext'
const codeContext = useCodeContext()
const messageListRef = ref<HTMLElement | null>(null)
const messages = computed(() => chatStore.messages)
@@ -121,10 +123,60 @@ function handleNewChat() {
}
async function handleSend(text: string) {
// Command handling
const trimmed = text.trim().toLowerCase()
if (trimmed === '/code') {
codeContext.enterCodeMode()
// Open the content panel with code tab
panelOpen.value = true
if (!availableTabs.value.includes('code')) {
availableTabs.value = [...availableTabs.value, 'code']
}
setActiveTab('code')
// Add system message to chat
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, {
role: 'user',
content: '/code',
})
chatStore.addMessage(convId, {
role: 'assistant',
content: 'Code mode activated. Select a project from the content panel to start coding.',
})
}
return
}
if (trimmed === '/code exit' || trimmed === '/exit') {
if (codeContext.isCodeMode.value) {
codeContext.exitCodeMode()
availableTabs.value = availableTabs.value.filter(t => t !== 'code')
const convId2 = chatStore.activeConversationId
if (convId2) {
chatStore.addMessage(convId2, {
role: 'assistant',
content: 'Code mode deactivated.',
})
}
return
}
}
await sendMessage(text)
}
function handlePromptSelect(_userMsg: Message, assistantMsg: Message | null) {
const userText = _userMsg.content?.trim().toLowerCase() ?? ''
if (userText === '/code') {
codeContext.enterCodeMode()
panelOpen.value = true
if (!availableTabs.value.includes('code')) {
availableTabs.value = [...availableTabs.value, 'code']
}
setActiveTab('code')
return
}
if (assistantMsg?.content) {
updatePanelFromText(assistantMsg.content, _userMsg.content, assistantMsg.webResults ?? [])
}
@@ -155,7 +207,11 @@ watch(
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 ?? [])
// Skip panel updates for command messages (e.g. /code, /exit)
const userText = lastUser?.content?.trim() ?? ''
if (!userText.startsWith('/')) {
updatePanelFromText(val.content, userText, lastMsg?.webResults ?? [])
}
}
},
{ deep: true, immediate: true }
@@ -90,6 +90,7 @@ const promptPairs = computed<PromptPair[]>(() => {
if ((content.books?.length ?? 0) > 0) badges.push('Books')
if ((content.tvSeries?.length ?? 0) > 0) badges.push('TV')
if ((content.images?.length ?? 0) > 0) badges.push('Images')
if ((content.places?.length ?? 0) > 0) badges.push('Places')
if (content.songs.length > 0) badges.push('Music')
if (content.podcasts.length > 0) badges.push('Podcasts')
if (content.magazineSections.length > 0) badges.push('Magazine')
@@ -0,0 +1,104 @@
<template>
<div class="code-detail h-full flex flex-col overflow-hidden"
:class="isDark ? 'bg-[#1a1a2e]' : 'bg-[#fafafa]'">
<!-- Header with file name + back button -->
<div class="shrink-0 flex items-center gap-2 px-3 py-2"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
<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" :class="isDark ? 'text-white/70' : 'text-gray-600'" 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="flex-1 min-w-0 pl-8">
<div class="flex items-center gap-2">
<!-- Language badge -->
<span class="shrink-0 text-[9px] px-1.5 py-0.5 rounded font-mono"
:class="isDark ? 'bg-white/10 text-white/50' : 'bg-black/5 text-gray-500'">
{{ language }}
</span>
<p class="text-xs font-mono truncate"
:class="isDark ? 'text-white/70' : 'text-gray-700'">
{{ fileName }}
</p>
</div>
<p v-if="projectName" class="text-[10px] font-mono mt-0.5 truncate"
:class="isDark ? 'text-white/25' : 'text-gray-400'">
{{ projectName }} / {{ filePath }}
</p>
</div>
</div>
<!-- Code content -->
<div class="flex-1 min-h-0 overflow-auto custom-scrollbar">
<div v-if="content" class="font-mono text-xs leading-relaxed">
<table class="w-full border-collapse">
<tbody>
<tr v-for="(line, i) in lines" :key="i"
class="hover:bg-white/[0.03]">
<td class="select-none text-right pr-4 pl-4 py-0 align-top w-1"
:class="isDark ? 'text-white/15' : 'text-gray-300'"
style="min-width: 3rem;">
{{ i + 1 }}
</td>
<td class="pr-4 py-0 whitespace-pre"
:class="isDark ? 'text-white/75' : 'text-gray-700'">{{ line }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty state -->
<div v-else class="flex items-center justify-center h-full">
<div class="text-center space-y-3 px-6">
<div class="w-16 h-16 rounded-2xl flex items-center justify-center mx-auto"
:class="isDark ? 'bg-white/5' : 'bg-black/5'">
<svg class="w-7 h-7" :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="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</div>
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
Select a file to view its contents.
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useCodeContext } from '@/composables/useCodeContext'
defineEmits<{
back: []
}>()
const { isDark } = useTheme()
const { activeFile, activeFileContent, activeFileLanguage, activeProject } = useCodeContext()
const content = computed(() => activeFileContent.value)
const language = computed(() => activeFileLanguage.value)
const filePath = computed(() => activeFile.value ?? '')
const fileName = computed(() => filePath.value.split('/').pop() ?? '')
const projectName = computed(() => activeProject.value?.name ?? '')
const lines = computed(() => {
if (!content.value) return []
return content.value.split('\n')
})
</script>
<style scoped>
.code-detail table {
font-variant-numeric: tabular-nums;
}
</style>
@@ -48,6 +48,18 @@
</slot>
</template>
</ImageGrid>
<PlaceGrid
v-else-if="activeTab === 'place'"
:places="panelPlaces"
:title="panelTitle"
@select-place="openPlaceDetail"
>
<template #header-actions>
<slot name="header-actions">
<CloseButton @click="$emit('close')" />
</slot>
</template>
</PlaceGrid>
<SongGrid
v-else-if="activeTab === 'song'"
:songs="panelSongs"
@@ -109,11 +121,20 @@
</slot>
</template>
</PodcastGrid>
<ProjectGrid
v-else-if="activeTab === 'code'"
>
<template #header-actions>
<slot name="header-actions">
<CloseButton @click="$emit('close')" />
</slot>
</template>
</ProjectGrid>
</div>
</template>
<script setup lang="ts">
import type { Film, Song, Podcast, Book, TVSeries, ImageItem } from '@aiui/core/types/content'
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
import type { WebSearchResult } from '@aiui/core/types/message'
import type { ContentTab, MagazineSection } from '@/composables/useContentPanel'
import { useContentPanel } from '@/composables/useContentPanel'
@@ -121,11 +142,13 @@ import FilmGrid from './FilmGrid.vue'
import BookGrid from './BookGrid.vue'
import TVSeriesGrid from './TVSeriesGrid.vue'
import ImageGrid from './ImageGrid.vue'
import PlaceGrid from './PlaceGrid.vue'
import SongGrid from './SongGrid.vue'
import PodcastGrid from './PodcastGrid.vue'
import MagazineGrid from './MagazineGrid.vue'
import NewsGrid from './NewsGrid.vue'
import CloseButton from './CloseButton.vue'
import ProjectGrid from './ProjectGrid.vue'
defineProps<{
activeTab: ContentTab
@@ -133,6 +156,7 @@ defineProps<{
panelBooks: Book[]
panelTVSeries: TVSeries[]
panelImages: ImageItem[]
panelPlaces: Place[]
panelSongs: Song[]
panelPodcasts: Podcast[]
panelWebResults: WebSearchResult[]
@@ -150,6 +174,7 @@ const {
openBookDetail,
openTVSeriesDetail,
openImageDetail,
openPlaceDetail,
openSongDetail,
openPodcastDetail,
} = useContentPanel()
@@ -137,6 +137,9 @@
:query="panelQuery"
:hero-image="panelMagazineHeroImage ?? undefined"
/>
<ProjectGrid
v-else-if="activeTab === 'code'"
/>
</div>
</aside>
</Transition>
@@ -159,6 +162,7 @@ import PodcastDetail from './PodcastDetail.vue'
import NewsGrid from './NewsGrid.vue'
import ArticleDetail from './ArticleDetail.vue'
import MagazineGrid from './MagazineGrid.vue'
import ProjectGrid from './ProjectGrid.vue'
const { isDark } = useTheme()
const {
@@ -213,11 +217,13 @@ const TAB_LABELS: Record<ContentTab, string> = {
book: 'Books',
tvshow: 'TV',
image: 'Images',
place: 'Places',
song: 'Music',
podcast: 'Podcasts',
news: 'News',
websites: 'Web',
magazine: 'Brief',
code: 'Code',
}
function tabLabel(tab: ContentTab): string {
@@ -29,11 +29,33 @@
:image="selectedImage"
@back="closeImageDetail"
/>
<PlaceDetail
v-else-if="selectedPlace"
:place="selectedPlace"
@back="closePlaceDetail"
/>
<ArticleDetail
v-else-if="selectedArticle"
:article="selectedArticle"
@back="closeArticleDetail"
/>
<WebsiteDetail
v-else-if="selectedWebsite"
:website="selectedWebsite"
@back="closeWebsiteDetail"
/>
<MagazineSectionDetail
v-else-if="selectedMagazineSection"
:section="selectedMagazineSection"
:current-index="magazineSectionIndex"
:total-sections="panelMagazineSections.length"
@back="closeMagazineSectionDetail"
@navigate="navigateMagazineSection"
/>
<CodeDetail
v-else-if="isCodeMode && activeCodeFile"
@back="closeCodeFile"
/>
</template>
<script setup lang="ts">
@@ -44,13 +66,27 @@ import TVSeriesDetail from './TVSeriesDetail.vue'
import SongDetail from './SongDetail.vue'
import PodcastDetail from './PodcastDetail.vue'
import ImageDetail from './ImageDetail.vue'
import PlaceDetail from './PlaceDetail.vue'
import ArticleDetail from './ArticleDetail.vue'
import WebsiteDetail from './WebsiteDetail.vue'
import MagazineSectionDetail from './MagazineSectionDetail.vue'
import CodeDetail from './CodeDetail.vue'
import { useCodeContext } from '@/composables/useCodeContext'
const { isCodeMode, activeFile: activeCodeFile } = useCodeContext()
function closeCodeFile() {
const { activeFile, activeFileContent } = useCodeContext()
activeFile.value = null
activeFileContent.value = ''
}
const {
selectedFilm,
selectedBook,
selectedTVSeries,
selectedImage,
selectedPlace,
selectedSong,
selectedPodcast,
selectedArticle,
@@ -58,8 +94,16 @@ const {
closeBookDetail,
closeTVSeriesDetail,
closeImageDetail,
closePlaceDetail,
closeSongDetail,
closePodcastDetail,
closeArticleDetail,
selectedWebsite,
closeWebsiteDetail,
selectedMagazineSection,
magazineSectionIndex,
panelMagazineSections,
closeMagazineSectionDetail,
navigateMagazineSection,
} = useContentPanel()
</script>
@@ -0,0 +1,79 @@
<template>
<div>
<button
class="w-full text-left flex items-center gap-1.5 py-1 px-2 rounded-lg text-xs transition-colors"
:class="[
isActive
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
: isDark ? 'text-white/60 hover:bg-white/[0.04] hover:text-white/80' : 'text-gray-600 hover:bg-black/[0.03] hover:text-gray-800',
]"
:style="{ paddingLeft: `${depth * 12 + 8}px` }"
@click="handleClick"
>
<!-- Expand/collapse for directories -->
<svg
v-if="entry.isDirectory"
class="w-3 h-3 shrink-0 transition-transform duration-150"
:class="expanded ? 'rotate-90' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<!-- File/folder icon -->
<svg class="w-3.5 h-3.5 shrink-0"
:class="entry.isDirectory
? 'text-accent/70'
: isDark ? 'text-white/30' : 'text-gray-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="entry.isDirectory" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="truncate">{{ entry.name }}</span>
</button>
<!-- Children (when expanded) -->
<div v-if="entry.isDirectory && expanded && entry.children">
<FileTreeNode
v-for="child in entry.children"
:key="child.path"
:entry="child"
:active-file="activeFile"
:depth="depth + 1"
@select="$emit('select', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import type { FileEntry } from '@/composables/useCodeContext'
const props = defineProps<{
entry: FileEntry
activeFile: string | null
depth: number
}>()
const emit = defineEmits<{
select: [path: string]
}>()
const { isDark } = useTheme()
const expanded = ref(props.depth < 1) // Auto-expand first level
const isActive = computed(() => !props.entry.isDirectory && props.activeFile === props.entry.path)
function handleClick() {
if (props.entry.isDirectory) {
expanded.value = !expanded.value
} else {
emit('select', props.entry.path)
}
}
</script>
@@ -14,17 +14,38 @@
</header>
<div class="flex-1 overflow-y-auto custom-scrollbar">
<!-- Query context -->
<div v-if="headlineText" class="px-5 pt-4 pb-3">
<p class="text-[9px] uppercase tracking-[0.3em] font-medium"
:class="isDark ? 'text-white/30' : 'text-black/40'">
In response to
</p>
<p class="font-serif text-2xl mt-1 italic leading-tight"
:class="isDark ? 'text-white/60' : 'text-black/50'">
{{ headlineText }}
</p>
<div class="mt-3 h-px" :class="isDark ? 'bg-white/8' : 'bg-black/6'" />
<!-- Query context hero -->
<div v-if="headlineText" class="relative overflow-hidden"
:style="{ minHeight: '180px' }">
<!-- Background image or gradient -->
<div class="absolute inset-0">
<img v-if="heroImageUrl"
:src="heroImageUrl"
alt=""
class="w-full h-full object-cover"
style="filter: saturate(0.3) contrast(1.1);" />
<div v-else class="w-full h-full"
:class="isDark
? 'bg-gradient-to-br from-white/[0.04] via-white/[0.02] to-transparent'
: 'bg-gradient-to-br from-black/[0.06] via-black/[0.03] to-transparent'" />
</div>
<!-- Dark overlay -->
<div class="absolute inset-0"
:class="isDark
? 'bg-gradient-to-t from-[#0a0a0a] via-[#0a0a0a]/80 to-[#0a0a0a]/60'
: 'bg-gradient-to-t from-[#faf9f6] via-[#faf9f6]/85 to-[#faf9f6]/65'" />
<!-- Content -->
<div class="relative z-10 flex flex-col justify-end h-full px-5 pb-5 pt-12"
style="min-height: 180px;">
<p class="text-[9px] uppercase tracking-[0.3em] font-medium mb-2"
:class="isDark ? 'text-white/40' : 'text-black/40'">
In response to
</p>
<p class="font-serif text-2xl italic leading-tight"
:class="isDark ? 'text-white/70' : 'text-black/60'">
{{ headlineText }}
</p>
</div>
</div>
<!-- Tile grid -->
@@ -59,7 +80,7 @@
:class="isDark
? 'bg-[#0a0a0a] hover:bg-white/[0.03]'
: 'bg-[#faf9f6] hover:bg-black/[0.02]'"
@click="tile.section?.url && openLink(tile.section.url)">
@click="tile.section && openTile(tile.section)">
<p v-if="tile.label"
class="text-[9px] uppercase tracking-[0.3em] font-semibold mb-2"
:class="isDark ? 'text-white/30' : 'text-black/35'">
@@ -91,7 +112,7 @@
? isDark ? 'bg-white/[0.04]' : 'bg-black/[0.04]'
: ''
]"
@click="tile.section?.url && openLink(tile.section.url)">
@click="tile.section && openTile(tile.section)">
<p v-if="tile.label"
class="text-[8px] uppercase tracking-[0.25em] font-semibold mb-1.5"
:class="isDark ? 'text-white/25' : 'text-black/30'">
@@ -127,8 +148,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useArticleOverlayStore } from '@/stores/articleOverlay'
import type { MagazineSection } from '@/composables/useContentPanel'
import { useContentPanel, type MagazineSection } from '@/composables/useContentPanel'
interface Tile {
type: 'wide' | 'half' | 'dark' | 'banner'
@@ -152,7 +172,7 @@ const props = withDefaults(defineProps<{
})
const { isDark } = useTheme()
const overlayStore = useArticleOverlayStore()
const { openWebsiteDetail, openMagazineSectionDetail } = useContentPanel()
const bannerIcons = ['compass', 'bookmark', 'lightning', 'lines'] as const
const bannerLabels = ['Perspectives', 'Worth Noting', 'Key Signals', 'Analysis']
@@ -277,8 +297,9 @@ function cleanBulletTitle(text: string): string {
return text.replace(/^\*\*[^*]+\*\*\s*[-–—:]\s*/, '').trim()
}
function openLink(url: string) {
overlayStore.open(url, '', undefined, undefined)
function openTile(section: MagazineSection) {
const idx = props.sections.indexOf(section)
openMagazineSectionDetail(section, idx >= 0 ? idx : 0)
}
const headlineText = computed(() => {
@@ -0,0 +1,157 @@
<template>
<div class="magazine-section-detail h-full flex flex-col overflow-hidden"
:class="isDark ? 'bg-[#0a0a0a]' : 'bg-[#faf9f6]'"
style="font-family: Georgia, 'Times New Roman', Times, serif;">
<!-- Header with back + nav counter -->
<div class="shrink-0 flex items-center justify-between px-4 py-3"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
<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" :class="isDark ? 'text-white/70' : 'text-gray-600'" 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="flex-1 text-center pl-8">
<span class="text-[9px] uppercase tracking-[0.3em] font-semibold"
:class="isDark ? 'text-white/30' : 'text-black/30'">
AI Brief
</span>
</div>
<span class="text-[10px] font-mono tabular-nums shrink-0"
:class="isDark ? 'text-white/25' : 'text-black/25'">
{{ currentIndex + 1 }}/{{ totalSections }}
</span>
</div>
<!-- Content area -->
<div class="flex-1 min-h-0 overflow-y-auto custom-scrollbar flex flex-col">
<div class="px-6 py-8 md:px-8 md:py-10 max-w-lg mx-auto my-auto">
<!-- Group label -->
<p v-if="section.group"
class="text-[9px] uppercase tracking-[0.3em] font-semibold mb-4"
:class="isDark ? 'text-white/25' : 'text-black/30'">
{{ section.group }}
</p>
<!-- Title -->
<h2 class="text-2xl md:text-3xl font-bold leading-tight mb-4"
:class="isDark ? 'text-white/95' : 'text-black/90'">
{{ section.title }}
</h2>
<!-- Author -->
<p v-if="section.author"
class="text-xs mb-6"
:class="isDark ? 'text-white/40' : 'text-black/40'">
By {{ section.author }}
</p>
<!-- Decorative rule -->
<div class="w-12 h-px mb-6"
:class="isDark ? 'bg-white/15' : 'bg-black/15'" />
<!-- Content as quote-style paragraphs -->
<div class="space-y-4">
<p v-for="(paragraph, i) in paragraphs" :key="i"
class="text-base md:text-lg leading-relaxed"
:class="[
isDark ? 'text-white/75' : 'text-black/65',
i === 0 ? 'first-letter:text-3xl first-letter:font-bold first-letter:float-left first-letter:mr-1.5 first-letter:leading-none' : ''
]"
:style="i === 0 ? `first-letter { color: ${isDark ? 'rgba(255,255,255,0.95)' : 'rgba(0,0,0,0.9)'} }` : ''">
{{ paragraph }}
</p>
</div>
<!-- Source link -->
<a v-if="section.url"
:href="section.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 mt-6 text-xs transition-colors"
:class="isDark ? 'text-white/40 hover:text-white/70' : 'text-black/40 hover:text-black/70'">
<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>
Source
</a>
</div>
</div>
<!-- Navigation footer -->
<div class="shrink-0 flex items-center justify-between px-4 py-3"
:style="isDark
? 'border-top: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-top: 1px solid rgba(0, 0, 0, 0.06)'">
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs transition-colors"
:class="isDark
? 'text-white/50 hover:text-white/80 hover:bg-white/5'
: 'text-black/40 hover:text-black/70 hover:bg-black/5'"
@click="$emit('navigate', 'prev')"
>
<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>
Prev
</button>
<!-- Dot indicators -->
<div class="flex items-center gap-1">
<div v-for="n in totalSections" :key="n"
class="w-1.5 h-1.5 rounded-full transition-all duration-200"
:class="n - 1 === currentIndex
? isDark ? 'bg-white/70 scale-125' : 'bg-black/60 scale-125'
: isDark ? 'bg-white/15' : 'bg-black/15'" />
</div>
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs transition-colors"
:class="isDark
? 'text-white/50 hover:text-white/80 hover:bg-white/5'
: 'text-black/40 hover:text-black/70 hover:bg-black/5'"
@click="$emit('navigate', 'next')"
>
Next
<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="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { MagazineSection } from '@/composables/useContentPanel'
import { useTheme } from '@/composables/useTheme'
const props = defineProps<{
section: MagazineSection
currentIndex: number
totalSections: number
}>()
defineEmits<{
back: []
navigate: [direction: 'prev' | 'next']
}>()
const { isDark } = useTheme()
const paragraphs = computed(() => {
const text = props.section.content
return text
.replace(/\*\*/g, '')
.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '')
.replace(/^#+\s*/gm, '')
.split(/\n{2,}|\n\s*[-•]\s+/)
.map(p => p.replace(/^[-•]\s*/, '').trim())
.filter(p => p.length > 0)
})
</script>
@@ -122,7 +122,6 @@ 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[]
@@ -138,8 +137,7 @@ const props = withDefaults(defineProps<{
})
const { isDark } = useTheme()
const { openArticleDetail } = useContentPanel()
const overlayStore = useArticleOverlayStore()
const { openArticleDetail, openWebsiteDetail } = useContentPanel()
const search = ref('')
const failedImgs = ref<Set<string>>(new Set())
@@ -172,7 +170,7 @@ function faviconUrl(url: string): string | null {
function openArticle(article: WebSearchResult) {
if (props.variant === 'websites') {
overlayStore.open(article.url, article.title, undefined, article.imgSrc)
openWebsiteDetail(article)
} else {
openArticleDetail(article)
}
@@ -0,0 +1,68 @@
<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', place)"
>
<div class="shrink-0 w-12 h-12 rounded-lg overflow-hidden">
<img
v-if="place.photoUrl"
:src="place.photoUrl"
:alt="place.name"
class="w-full h-full object-cover rounded-[6px] transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
<div
v-else
class="w-full h-full rounded-[6px] bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackPhoto})` }"
/>
</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'">{{ place.name }}</p>
<p class="text-[11px] mt-0.5 truncate"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
{{ place.cuisine || place.category }}<template v-if="place.city"> · {{ place.city }}</template>
</p>
<div class="flex items-center gap-1.5 mt-1.5">
<span v-if="place.rating && place.rating > 0"
class="text-[10px] font-semibold px-1.5 py-0.5 rounded"
:class="ratingClass">
{{ place.rating.toFixed(1) }}
</span>
<span v-if="place.priceLevel"
class="text-[9px] px-1.5 py-0.5 rounded font-medium"
:class="isDark ? 'bg-white/8 text-white/50' : 'bg-black/5 text-gray-500'">
{{ '$'.repeat(place.priceLevel) }}
</span>
</div>
</div>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Place } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePlaceFallback } from '@/composables/useImageFallback'
const props = defineProps<{ place: Place }>()
defineEmits<{ select: [place: Place] }>()
const { isDark } = useTheme()
const fallbackPhoto = computed(() =>
generatePlaceFallback(props.place.name, props.place.cuisine || props.place.category)
)
const ratingClass = computed(() => {
const r = props.place.rating ?? 0
if (r >= 4.5) return isDark.value ? 'bg-success/20 text-success' : 'bg-success/10 text-green-700'
if (r >= 4.0) return isDark.value ? 'bg-accent/20 text-accent' : 'bg-accent/10 text-amber-700'
return isDark.value ? 'bg-white/10 text-white/50' : 'bg-black/5 text-gray-500'
})
</script>
@@ -0,0 +1,197 @@
<template>
<div class="place-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<div class="relative w-full overflow-hidden">
<div class="w-full aspect-[16/9] flex items-center justify-center overflow-hidden bg-black/20">
<img
v-if="place.photoUrl"
:src="place.photoUrl"
:alt="place.name"
class="w-full h-full object-cover object-center block"
/>
<div
v-else
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackCover})` }"
/>
</div>
<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"
@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 class="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ place.name }}</h2>
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-1 text-xs text-white/60">
<span v-if="place.cuisine || place.category">{{ place.cuisine || place.category }}</span>
<span v-if="place.city">{{ place.city }}</span>
<span v-if="place.rating" class="text-amber-400"> {{ place.rating.toFixed(1) }}</span>
<span v-if="place.priceLevel" class="text-white/50">{{ '$'.repeat(place.priceLevel) }}</span>
</div>
</div>
</div>
<div class="p-4 space-y-4">
<p v-if="place.description" class="text-sm leading-relaxed"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ place.description }}
</p>
<div v-if="place.address" class="flex items-start gap-2.5">
<svg class="w-4 h-4 shrink-0 mt-0.5" :class="isDark ? 'text-white/30' : 'text-gray-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span class="text-xs" :class="isDark ? 'text-white/60' : 'text-gray-600'">
{{ place.address }}
</span>
</div>
<div v-if="place.phone" class="flex items-center gap-2.5">
<svg class="w-4 h-4 shrink-0" :class="isDark ? 'text-white/30' : 'text-gray-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
<span class="text-xs" :class="isDark ? 'text-white/60' : 'text-gray-600'">
{{ place.phone }}
</span>
</div>
<div v-if="place.hours" class="flex items-start gap-2.5">
<svg class="w-4 h-4 shrink-0 mt-0.5" :class="isDark ? 'text-white/30' : 'text-gray-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-xs" :class="isDark ? 'text-white/60' : 'text-gray-600'">
{{ place.hours }}
</span>
</div>
<div v-if="place.website" class="flex items-center gap-2.5">
<svg class="w-4 h-4 shrink-0" :class="isDark ? 'text-white/30' : 'text-gray-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
<a :href="place.website" target="_blank" rel="noopener"
class="text-xs underline underline-offset-2"
:class="isDark ? 'text-white/60 hover:text-white/80' : 'text-gray-600 hover:text-gray-800'">
{{ websiteDomain }}
</a>
</div>
<div>
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Find on</h4>
<div class="space-y-2">
<a
v-for="src in (place.sources ?? [])"
:key="src.url"
:href="src.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<span class="text-sm">{{ sourceIcon(src.type) }}</span>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ src.name }}</p>
</div>
<svg class="w-4 h-4" :class="isDark ? 'text-white/30' : '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>
</a>
<a
v-for="link in mapLinks"
:key="link.url"
:href="link.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<span class="text-sm">{{ link.icon }}</span>
<div>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ link.name }}</p>
<p v-if="link.desc" class="text-[10px]"
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ link.desc }}</p>
</div>
</div>
<svg class="w-4 h-4" :class="isDark ? 'text-white/30' : '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>
</a>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Place } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePlaceFallback } from '@/composables/useImageFallback'
const props = defineProps<{ place: Place }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const fallbackCover = computed(() =>
generatePlaceFallback(props.place.name, props.place.cuisine || props.place.category)
)
const websiteDomain = computed(() => {
if (!props.place.website) return ''
try {
return new URL(props.place.website).hostname.replace(/^www\./, '')
} catch {
return props.place.website
}
})
const q = computed(() =>
`${props.place.name} ${props.place.city ?? ''}`.trim().replace(/\s+/g, '+'),
)
const mapLinks = computed(() => {
if ((props.place.sources ?? []).length > 0) return []
const links = [
{ name: 'OpenStreetMap', url: `https://www.openstreetmap.org/search?query=${q.value}`, icon: '🗺️', desc: 'Open source maps' },
{ name: 'Google Maps', url: `https://www.google.com/maps/search/${q.value}`, icon: '📍', desc: 'Directions & reviews' },
]
if (props.place.lat && props.place.lng) {
links.unshift({
name: 'OpenStreetMap',
url: `https://www.openstreetmap.org/?mlat=${props.place.lat}&mlon=${props.place.lng}#map=17/${props.place.lat}/${props.place.lng}`,
icon: '🗺️',
desc: 'Open source maps',
})
links.splice(2) // Remove the search-based OSM link
}
return links
})
function sourceIcon(type: string): string {
const icons: Record<string, string> = {
gmaps: '📍',
osm: '🗺️',
yelp: '⭐',
tripadvisor: '🦉',
foursquare: '📌',
local: '💾',
}
return icons[type] ?? '📍'
}
</script>
@@ -0,0 +1,142 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-base font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ title || 'Places' }}
</h3>
<div class="shrink-0 flex items-center gap-2">
<span class="text-[10px]" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredPlaces.length }} places
</span>
<slot name="header-actions" />
</div>
</div>
<input
v-model="search"
type="text"
:placeholder="`Search places...`"
class="w-full text-xs px-3 py-2 rounded-lg outline-none transition-colors"
:class="isDark
? 'bg-white/5 text-white/80 placeholder-white/25 focus:bg-white/8'
: 'bg-black/5 text-gray-800 placeholder-gray-400 focus:bg-black/8'"
/>
<div v-if="topCategories.length > 1" class="flex flex-wrap gap-1.5">
<button
v-for="cat in topCategories"
:key="cat"
class="text-[9px] px-2 py-1 rounded-md font-medium transition-all duration-150"
:class="activeCategory === cat
? 'nav-tab-active'
: isDark
? 'bg-white/5 text-white/40 hover:text-white/70'
: 'bg-black/5 text-gray-500 hover:text-gray-800'"
@click="activeCategory = activeCategory === cat ? null : cat"
>
{{ cat }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pb-16">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<button
v-for="place in filteredPlaces"
:key="place.id"
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('selectPlace', place)"
>
<div class="aspect-[4/3] relative w-full overflow-hidden rounded-t-[10px]">
<img
v-if="place.photoUrl"
:src="place.photoUrl"
:alt="place.name"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
/>
<div
v-else
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(place)})` }"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
<div class="absolute bottom-0 left-0 right-0 p-2">
<p class="text-[11px] font-semibold text-white/90 truncate">{{ place.name }}</p>
<p class="text-[9px] text-white/40 truncate mt-0.5">{{ place.cuisine || place.category }}</p>
</div>
<div v-if="place.rating" class="absolute top-1.5 right-1.5">
<span class="text-[8px] px-1.5 py-0.5 rounded bg-black/60 text-amber-400 backdrop-blur-sm font-semibold">
{{ place.rating.toFixed(1) }}
</span>
</div>
<div v-if="place.priceLevel" class="absolute top-1.5 left-1.5">
<span class="text-[8px] px-1 py-0.5 rounded bg-black/60 text-white/70 backdrop-blur-sm">
{{ '$'.repeat(place.priceLevel) }}
</span>
</div>
</div>
</button>
</div>
<div v-if="filteredPlaces.length === 0" class="flex items-center justify-center py-12">
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No places match your search
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { Place } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePlaceFallback } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
places: Place[]
title?: string
}>(), {
title: 'Places',
})
defineEmits<{ selectPlace: [place: Place] }>()
const { isDark } = useTheme()
const search = ref('')
const activeCategory = ref<string | null>(null)
function fallbackFor(place: Place): string {
return generatePlaceFallback(place.name, place.cuisine || place.category)
}
const topCategories = computed(() => {
const counts = new Map<string, number>()
for (const p of props.places) {
const cat = p.cuisine || p.category
if (cat) counts.set(cat, (counts.get(cat) ?? 0) + 1)
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([g]) => g)
})
const filteredPlaces = computed(() => {
let result = props.places
if (search.value) {
const q = search.value.toLowerCase()
result = result.filter(p =>
p.name.toLowerCase().includes(q) ||
(p.cuisine?.toLowerCase().includes(q)) ||
(p.category?.toLowerCase().includes(q)) ||
(p.city?.toLowerCase().includes(q)) ||
(p.address?.toLowerCase().includes(q))
)
}
if (activeCategory.value) {
result = result.filter(p =>
p.cuisine === activeCategory.value || p.category === activeCategory.value
)
}
return result
})
</script>
@@ -0,0 +1,140 @@
<template>
<div class="flex flex-col h-full">
<!-- Header -->
<div class="shrink-0 px-4 py-3 flex items-center justify-between"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
<div>
<h2 class="text-sm font-semibold"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ activeProject ? activeProject.name : 'Projects' }}
</h2>
<p class="text-[10px] mt-0.5"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ activeProject ? activeProject.language : `${projectList.length} repositories` }}
</p>
</div>
<div class="flex items-center gap-2">
<button
v-if="activeProject"
class="text-[10px] px-2.5 py-1 rounded-lg font-medium transition-colors"
:class="isDark
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
@click="backToProjects"
>
All Projects
</button>
<slot name="header-actions" />
</div>
</div>
<!-- Project list (when no project selected) -->
<div v-if="!activeProject" class="flex-1 overflow-y-auto custom-scrollbar p-3">
<!-- Search -->
<div class="mb-3">
<input
v-model="search"
type="text"
placeholder="Search projects..."
class="w-full px-3 py-2 rounded-lg text-xs bg-transparent outline-none"
:class="isDark
? 'text-white/80 placeholder:text-white/20 border border-white/10 focus:border-white/25'
: 'text-gray-800 placeholder:text-gray-400 border border-black/10 focus:border-black/20'"
/>
</div>
<div class="grid grid-cols-2 gap-2">
<button
v-for="project in filteredProjects"
:key="project.path"
class="text-left p-3 rounded-xl transition-all duration-150"
:class="isDark
? 'bg-white/[0.03] hover:bg-white/[0.07] border border-white/5'
: 'bg-black/[0.02] hover:bg-black/[0.05] border border-black/5'"
@click="selectProject(project)"
>
<!-- Folder icon -->
<div class="w-8 h-8 rounded-lg flex items-center justify-center mb-2"
:class="isDark ? 'bg-white/5' : 'bg-black/5'">
<svg class="w-4 h-4" :class="project.isGit ? 'text-accent' : isDark ? 'text-white/40' : 'text-gray-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="project.isGit" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<p class="text-xs font-medium truncate"
:class="isDark ? 'text-white/80' : 'text-gray-800'">
{{ project.name }}
</p>
<p class="text-[10px] mt-0.5 truncate"
:class="isDark ? 'text-white/25' : 'text-gray-400'">
{{ project.language }}
</p>
</button>
</div>
</div>
<!-- File tree (when project is selected) -->
<div v-else class="flex-1 overflow-y-auto custom-scrollbar p-2">
<FileTreeNode
v-for="entry in fileTree"
:key="entry.path"
:entry="entry"
:active-file="activeFile"
:depth="0"
@select="handleFileSelect"
/>
<div v-if="fileTree.length === 0" class="flex items-center justify-center py-12">
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
Loading file tree...
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useCodeContext, type ProjectInfo } from '@/composables/useCodeContext'
import FileTreeNode from './FileTreeNode.vue'
const { isDark } = useTheme()
const {
projectList,
activeProject,
fileTree,
activeFile,
selectProject: doSelectProject,
openFile,
exitCodeMode,
} = useCodeContext()
const search = ref('')
const filteredProjects = computed(() => {
const q = search.value.toLowerCase()
if (!q) return projectList.value
return projectList.value.filter(p =>
p.name.toLowerCase().includes(q) || (p.language ?? '').toLowerCase().includes(q)
)
})
function selectProject(project: ProjectInfo) {
doSelectProject(project)
}
function backToProjects() {
// Clear active project to go back to project list
const { activeProject: ap, fileTree: ft, activeFile: af, activeFileContent: afc } = useCodeContext()
ap.value = null
ft.value = []
af.value = null
afc.value = ''
}
function handleFileSelect(filePath: string) {
openFile(filePath)
}
</script>
@@ -0,0 +1,76 @@
<template>
<div class="website-detail h-full flex flex-col overflow-hidden">
<div class="shrink-0 flex items-center gap-2 px-3 py-2.5"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
<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 class="flex-1 min-w-0 pl-8">
<p class="text-sm font-medium truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ website.title || 'Website' }}
</p>
<p v-if="domain" class="text-[10px] truncate"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ domain }}
</p>
</div>
<a
:href="website.url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center w-8 h-8 rounded-lg transition-colors shrink-0"
:class="isDark ? 'hover:bg-white/10 text-white/50' : 'hover:bg-black/5 text-gray-400'"
aria-label="Open in new tab"
title="Open in new tab"
@click.stop
>
<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>
<div class="flex-1 min-h-0 relative bg-black/20">
<iframe
:key="website.url"
:src="website.url"
class="absolute inset-0 w-full h-full border-0"
style="-ms-overflow-style: none; scrollbar-width: none;"
title="Website content"
/>
</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<{ website: WebSearchResult }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const domain = computed(() => {
if (!props.website.url) return ''
try {
return new URL(props.website.url).hostname.replace(/^www\./, '')
} catch {
return ''
}
})
</script>
<style scoped>
iframe::-webkit-scrollbar {
display: none;
}
</style>