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:
co-authored by
Claude Opus 4.6
parent
48dd7a9c68
commit
e8fc54cade
@@ -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>
|
||||
@@ -43,6 +43,8 @@ Prioritize Podcasting 2.0–friendly platforms: Fountain.fm, Podcast Index, Cast
|
||||
|
||||
**TV Series:** When recommending or discussing TV series/shows, use [[tv_ext:Title|Year|Creator]], e.g. [[tv_ext:Breaking Bad|2008|Vince Gilligan]]. Do NOT use [[film_ext:...]] for TV series — use [[tv_ext:...]] instead. Write a brief reason why the show is worth watching on the same line.
|
||||
|
||||
**Places/Restaurants:** When recommending restaurants, cafes, bars, or other places to visit, use [[place_ext:Name|Cuisine|City|Rating|PriceLevel|Address]], e.g. [[place_ext:Sushi Nakazawa|Japanese|New York|4.7|3|23 Commerce St]]. Rating is out of 5, PriceLevel is 1-4 ($ to $$$$). Omit fields you don't know. Write a brief description on the same line.
|
||||
|
||||
**Websites / "Best places to check":** When listing resources, places to check online, or websites for the user to visit, use markdown links: [Name](https://full-url). For simple domains use **Name** (domain.com), e.g. **Bitcoin Mailing List** (gnusha.org).
|
||||
|
||||
**Music discovery:** For genre-based requests (e.g. "best math rock"), pick from the user's library when relevant, or use [[song_ext:...]] for others. Prioritize indie-friendly platforms: Wavlake, Bandcamp, Internet Archive, SoundCloud, Odysee, Jamendo.
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { ref, computed, shallowRef } from 'vue'
|
||||
|
||||
export interface ProjectInfo {
|
||||
name: string
|
||||
path: string
|
||||
isGit: boolean
|
||||
language?: string
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
name: string
|
||||
path: string
|
||||
isDirectory: boolean
|
||||
children?: FileEntry[]
|
||||
}
|
||||
|
||||
// Module-level singleton state
|
||||
const codeMode = ref(false)
|
||||
const activeProject = ref<ProjectInfo | null>(null)
|
||||
const projectList = shallowRef<ProjectInfo[]>([])
|
||||
const fileTree = shallowRef<FileEntry[]>([])
|
||||
const activeFile = ref<string | null>(null)
|
||||
const activeFileContent = ref<string>('')
|
||||
const activeFileLanguage = ref<string>('plaintext')
|
||||
|
||||
// Demo projects path
|
||||
const PROJECTS_ROOT = '/Users/dorian/Projects'
|
||||
|
||||
function detectLanguage(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
||||
const map: Record<string, string> = {
|
||||
ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
|
||||
vue: 'vue', svelte: 'svelte', py: 'python', rs: 'rust', go: 'go',
|
||||
java: 'java', kt: 'kotlin', swift: 'swift', rb: 'ruby', php: 'php',
|
||||
css: 'css', scss: 'scss', html: 'html', json: 'json', yaml: 'yaml',
|
||||
yml: 'yaml', md: 'markdown', toml: 'toml', sh: 'shell', bash: 'shell',
|
||||
sql: 'sql', graphql: 'graphql', dockerfile: 'dockerfile',
|
||||
c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp',
|
||||
}
|
||||
return map[ext] ?? 'plaintext'
|
||||
}
|
||||
|
||||
function detectProjectLanguage(files: string[]): string {
|
||||
if (files.includes('package.json')) return 'TypeScript/JavaScript'
|
||||
if (files.includes('Cargo.toml')) return 'Rust'
|
||||
if (files.includes('go.mod')) return 'Go'
|
||||
if (files.includes('requirements.txt') || files.includes('setup.py') || files.includes('pyproject.toml')) return 'Python'
|
||||
if (files.includes('pom.xml') || files.includes('build.gradle')) return 'Java'
|
||||
if (files.includes('Package.swift')) return 'Swift'
|
||||
if (files.includes('Gemfile')) return 'Ruby'
|
||||
if (files.includes('composer.json')) return 'PHP'
|
||||
if (files.some(f => f.endsWith('.csproj') || f.endsWith('.sln'))) return 'C#'
|
||||
return 'Unknown'
|
||||
}
|
||||
|
||||
export function useCodeContext() {
|
||||
const isCodeMode = computed(() => codeMode.value)
|
||||
const hasActiveProject = computed(() => activeProject.value !== null)
|
||||
|
||||
async function loadProjects(): Promise<void> {
|
||||
// In dev/demo mode, scan the Projects folder
|
||||
// This would be replaced by Archy integration later
|
||||
try {
|
||||
const response = await fetch(`/api/fs/list?path=${encodeURIComponent(PROJECTS_ROOT)}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
projectList.value = data.projects ?? []
|
||||
}
|
||||
} catch {
|
||||
// Fallback: use hardcoded list from build time
|
||||
// In real app, this would come from local filesystem or Archy nodes
|
||||
projectList.value = getDemoProjects()
|
||||
}
|
||||
}
|
||||
|
||||
function getDemoProjects(): ProjectInfo[] {
|
||||
// Hardcoded demo list matching actual ~/Projects folder
|
||||
return [
|
||||
{ name: 'AIUI', path: `${PROJECTS_ROOT}/AIUI`, isGit: true, language: 'TypeScript/JavaScript' },
|
||||
{ name: 'archy', path: `${PROJECTS_ROOT}/archy`, isGit: true, language: 'TypeScript/JavaScript' },
|
||||
{ name: 'angor', path: `${PROJECTS_ROOT}/angor`, isGit: true, language: 'C#' },
|
||||
{ name: 'angor-prototype', path: `${PROJECTS_ROOT}/angor-prototype`, isGit: true, language: 'TypeScript/JavaScript' },
|
||||
{ name: 'archipelago', path: `${PROJECTS_ROOT}/archipelago`, isGit: true, language: 'Unknown' },
|
||||
{ name: 'archipelago-foundation', path: `${PROJECTS_ROOT}/archipelago-foundation`, isGit: true, language: 'Unknown' },
|
||||
{ name: 'blossom', path: `${PROJECTS_ROOT}/blossom`, isGit: true, language: 'TypeScript/JavaScript' },
|
||||
{ name: 'fedimint', path: `${PROJECTS_ROOT}/fedimint`, isGit: true, language: 'Rust' },
|
||||
{ name: 'Syntopy', path: `${PROJECTS_ROOT}/Syntopy`, isGit: true, language: 'Unknown' },
|
||||
{ name: 'Syntropy-Institute', path: `${PROJECTS_ROOT}/Syntropy-Institute`, isGit: true, language: 'Unknown' },
|
||||
{ name: 'LoRaBell', path: `${PROJECTS_ROOT}/LoRaBell`, isGit: true, language: 'Unknown' },
|
||||
{ name: 'satoshi-services', path: `${PROJECTS_ROOT}/satoshi-services`, isGit: true, language: 'Unknown' },
|
||||
{ name: 'Proux', path: `${PROJECTS_ROOT}/Proux`, isGit: true, language: 'Unknown' },
|
||||
{ name: 'KYC', path: `${PROJECTS_ROOT}/KYC`, isGit: true, language: 'Unknown' },
|
||||
{ name: 'k484', path: `${PROJECTS_ROOT}/k484`, isGit: true, language: 'Unknown' },
|
||||
{ name: 'tbf', path: `${PROJECTS_ROOT}/tbf`, isGit: true, language: 'Unknown' },
|
||||
{ name: 'Icon', path: `${PROJECTS_ROOT}/Icon`, isGit: false, language: 'Unknown' },
|
||||
{ name: 'indeehub-frontend', path: `${PROJECTS_ROOT}/indeehub-frontend`, isGit: true, language: 'TypeScript/JavaScript' },
|
||||
{ name: 'Indeedhub Prototype', path: `${PROJECTS_ROOT}/Indeedhub Prototype`, isGit: true, language: 'Unknown' },
|
||||
{ name: '21', path: `${PROJECTS_ROOT}/21`, isGit: true, language: 'Unknown' },
|
||||
]
|
||||
}
|
||||
|
||||
function enterCodeMode(): void {
|
||||
codeMode.value = true
|
||||
loadProjects()
|
||||
}
|
||||
|
||||
function exitCodeMode(): void {
|
||||
codeMode.value = false
|
||||
activeProject.value = null
|
||||
activeFile.value = null
|
||||
activeFileContent.value = ''
|
||||
fileTree.value = []
|
||||
}
|
||||
|
||||
function selectProject(project: ProjectInfo): void {
|
||||
activeProject.value = project
|
||||
loadFileTree(project.path)
|
||||
}
|
||||
|
||||
async function loadFileTree(projectPath: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`/api/fs/tree?path=${encodeURIComponent(projectPath)}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
fileTree.value = data.files ?? []
|
||||
}
|
||||
} catch {
|
||||
// Demo fallback: generate a simple tree
|
||||
fileTree.value = getDemoFileTree()
|
||||
}
|
||||
}
|
||||
|
||||
function getDemoFileTree(): FileEntry[] {
|
||||
// Generic project structure for demo
|
||||
return [
|
||||
{ name: 'src', path: 'src', isDirectory: true, children: [
|
||||
{ name: 'index.ts', path: 'src/index.ts', isDirectory: false },
|
||||
{ name: 'app.ts', path: 'src/app.ts', isDirectory: false },
|
||||
{ name: 'utils.ts', path: 'src/utils.ts', isDirectory: false },
|
||||
]},
|
||||
{ name: 'package.json', path: 'package.json', isDirectory: false },
|
||||
{ name: 'tsconfig.json', path: 'tsconfig.json', isDirectory: false },
|
||||
{ name: 'README.md', path: 'README.md', isDirectory: false },
|
||||
]
|
||||
}
|
||||
|
||||
async function openFile(filePath: string): Promise<void> {
|
||||
activeFile.value = filePath
|
||||
activeFileLanguage.value = detectLanguage(filePath)
|
||||
|
||||
try {
|
||||
const fullPath = activeProject.value
|
||||
? `${activeProject.value.path}/${filePath}`
|
||||
: filePath
|
||||
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(fullPath)}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
activeFileContent.value = data.content ?? ''
|
||||
}
|
||||
} catch {
|
||||
// Demo fallback
|
||||
activeFileContent.value = getDemoFileContent(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
function getDemoFileContent(filePath: string): string {
|
||||
const name = filePath.split('/').pop() ?? filePath
|
||||
if (name === 'package.json') {
|
||||
return JSON.stringify({
|
||||
name: activeProject.value?.name?.toLowerCase() ?? 'project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
scripts: { dev: 'vite', build: 'vite build', test: 'vitest' },
|
||||
dependencies: {},
|
||||
}, null, 2)
|
||||
}
|
||||
if (name === 'README.md') {
|
||||
return `# ${activeProject.value?.name ?? 'Project'}\n\nA project in the AIUI ecosystem.\n`
|
||||
}
|
||||
if (name.endsWith('.ts') || name.endsWith('.js')) {
|
||||
return `// ${name}\n// ${activeProject.value?.name ?? 'Project'}\n\nexport function main() {\n console.log('Hello from ${name}')\n}\n`
|
||||
}
|
||||
return `// ${name}\n`
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
codeMode,
|
||||
isCodeMode,
|
||||
activeProject,
|
||||
hasActiveProject,
|
||||
projectList,
|
||||
fileTree,
|
||||
activeFile,
|
||||
activeFileContent,
|
||||
activeFileLanguage,
|
||||
|
||||
// Actions
|
||||
enterCodeMode,
|
||||
exitCodeMode,
|
||||
selectProject,
|
||||
openFile,
|
||||
loadProjects,
|
||||
detectLanguage,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
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 { mockFilms } from '@/mocks/films'
|
||||
import { mockSongs } from '@/mocks/songs'
|
||||
@@ -7,7 +7,7 @@ import { mockPodcasts } from '@/mocks/podcasts'
|
||||
import { generatePosterFallback, generateSongCoverFallback, generateBookCoverFallback } from '@/composables/useImageFallback'
|
||||
import { fetchRssFromUrls } from '@/composables/useRssFetch'
|
||||
|
||||
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'news' | 'websites' | 'magazine'
|
||||
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'place' | 'news' | 'websites' | 'magazine' | 'code'
|
||||
|
||||
export interface MagazineSection {
|
||||
title: string
|
||||
@@ -34,6 +34,7 @@ const panelMagazineHeroImage = ref<string | null>(null)
|
||||
const panelSongs = ref<Song[]>([])
|
||||
const panelPodcasts = ref<Podcast[]>([])
|
||||
const panelImages = ref<ImageItem[]>([])
|
||||
const panelPlaces = ref<Place[]>([])
|
||||
const selectedFilm = ref<Film | null>(null)
|
||||
const selectedBook = ref<Book | null>(null)
|
||||
const selectedTVSeries = ref<TVSeries | null>(null)
|
||||
@@ -41,6 +42,10 @@ const selectedSong = ref<Song | null>(null)
|
||||
const selectedPodcast = ref<Podcast | null>(null)
|
||||
const selectedArticle = ref<WebSearchResult | null>(null)
|
||||
const selectedImage = ref<ImageItem | null>(null)
|
||||
const selectedPlace = ref<Place | null>(null)
|
||||
const selectedWebsite = ref<WebSearchResult | null>(null)
|
||||
const selectedMagazineSection = ref<MagazineSection | null>(null)
|
||||
const magazineSectionIndex = ref(0)
|
||||
const panelTitle = ref('Recommended Films')
|
||||
const panelQuery = ref('')
|
||||
const contentType = ref<'film' | 'song' | 'podcast'>('film')
|
||||
@@ -295,6 +300,7 @@ function preferredFirstTab(userQuery: string): ContentTab | null {
|
||||
if (/\b(book|books|read|reading|novel|author|nonfiction|non-fiction)\b/.test(q)) return 'book'
|
||||
if (/\b(tv show|tv series|series|television|streaming|binge|watch)\b/.test(q)) return 'tvshow'
|
||||
if (/\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/.test(q)) return 'image'
|
||||
if (/\b(restaurant|restaurants|place|places|food|eat|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse)\b/.test(q)) return 'place'
|
||||
if (isNewsQuery(q)) return 'news'
|
||||
if (isWebsitesQuery(q)) return 'websites'
|
||||
return null
|
||||
@@ -310,6 +316,7 @@ function filterTabsByContext(
|
||||
hasBooks: boolean,
|
||||
hasTVSeries: boolean,
|
||||
hasImages: boolean,
|
||||
hasPlaces: boolean,
|
||||
hasNews: boolean,
|
||||
hasWebsites: boolean,
|
||||
hasMagazine: boolean,
|
||||
@@ -326,11 +333,11 @@ function filterTabsByContext(
|
||||
return tabs
|
||||
}
|
||||
|
||||
if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasNews && !hasWebsites) {
|
||||
if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasPlaces && !hasNews && !hasWebsites) {
|
||||
return ['magazine']
|
||||
}
|
||||
|
||||
if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasNews && !hasMagazine) {
|
||||
if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasPlaces && !hasNews && !hasMagazine) {
|
||||
return ['websites']
|
||||
}
|
||||
|
||||
@@ -339,6 +346,7 @@ function filterTabsByContext(
|
||||
if (hasBooks) all.push('book')
|
||||
if (hasTVSeries) all.push('tvshow')
|
||||
if (hasImages) all.push('image')
|
||||
if (hasPlaces) all.push('place')
|
||||
if (hasSongs) all.push('song')
|
||||
if (hasPodcasts) all.push('podcast')
|
||||
if (hasMagazine) all.push('magazine')
|
||||
@@ -386,6 +394,7 @@ const BOOK_TAG_RE = /\[\[book:(b?\d+)\]\]/gi
|
||||
const BOOK_EXT_RE = /\[\[book_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
|
||||
|
||||
const TV_EXT_RE = /\[\[tv_ext:([^|]+)\|([^|]*)(?:\|(\d{4}))?\]\]/gi
|
||||
const PLACE_EXT_RE = /\[\[place_ext:([^|]+)\|([^|]*)(?:\|([^|]*))?(?:\|([^|]*))?(?:\|([^|]*))?(?:\|([^|]*))?\]\]/gi
|
||||
|
||||
/** Reject obvious non-podcast phrases (documentation, mailing lists, etc.) */
|
||||
function looksLikePodcast(title: string, host: string): boolean {
|
||||
@@ -972,6 +981,101 @@ export function useContentPanel() {
|
||||
return []
|
||||
}
|
||||
|
||||
function isPlaceQuery(q: string): boolean {
|
||||
return /\b(restaurant|restaurants|place|places|food|eat|eating|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse|where to eat|good food|best food|where should i eat|recommend.*eat|recommend.*restaurant|recommend.*place)\b/i.test(q)
|
||||
}
|
||||
|
||||
function isPlaceLikeResponse(text: string): boolean {
|
||||
return /\b(restaurant|cuisine|menu|reserv|dining|address|open|hours|price range|\$\$|\$\$\$|michelin|yelp|rating)\b/i.test(text) &&
|
||||
(text.match(/\b(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill)\b/gi)?.length ?? 0) >= 2
|
||||
}
|
||||
|
||||
/** Extract places from [[place_ext:Name|Cuisine|City|Rating|PriceLevel|Address]] tags */
|
||||
function extractExternalPlaces(text: string): Place[] {
|
||||
const places: Place[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
const re = new RegExp(PLACE_EXT_RE.source, 'gi')
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const name = match[1].trim()
|
||||
const cuisine = match[2]?.trim() || undefined
|
||||
const city = match[3]?.trim() || undefined
|
||||
const rating = match[4] ? parseFloat(match[4]) : undefined
|
||||
const priceLevel = match[5] ? parseInt(match[5], 10) : undefined
|
||||
const address = match[6]?.trim() || undefined
|
||||
const key = name.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
places.push({
|
||||
id: `ext-place-${key.replace(/\W/g, '-')}`,
|
||||
name,
|
||||
cuisine,
|
||||
city,
|
||||
rating: rating && !isNaN(rating) ? rating : undefined,
|
||||
priceLevel: priceLevel && priceLevel >= 1 && priceLevel <= 4 ? priceLevel : undefined,
|
||||
address,
|
||||
description: extractDescriptionForTag(text, match.index, match[0].length),
|
||||
sources: [],
|
||||
})
|
||||
}
|
||||
return places
|
||||
}
|
||||
|
||||
/** Extract place-like patterns from AI response text */
|
||||
function extractPlacesFromPatterns(text: string): Place[] {
|
||||
const places: { name: string; cuisine?: string; city?: string; rating?: number; priceLevel?: number; desc: string; pos: number }[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const patterns: RegExp[] = [
|
||||
// **Name** — cuisine/category, details
|
||||
/\*\*([^*]{2,60})\*\*\s*[-–—:]\s*(?:a |an )?(?:(\w[\w\s]{1,30}?)\s+)?(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill|eatery|spot|joint)/gi,
|
||||
// Numbered list: 1. **Name** (cuisine) or 1. Name — description
|
||||
/(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n]{2,60}?)\*{0,2}\s*[-–—(]\s*(?:(\w[\w\s&]{1,30}?)\s+)?(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill|cuisine|food|dining)/gim,
|
||||
]
|
||||
|
||||
for (const re of patterns) {
|
||||
let m: RegExpExecArray | null
|
||||
const rx = new RegExp(re.source, re.flags)
|
||||
while ((m = rx.exec(text)) !== null) {
|
||||
const name = m[1].trim().replace(/^\*\*|\*\*$/g, '').replace(/^\[|\]$/g, '')
|
||||
if (name.length < 2) continue
|
||||
if (/\[\[(film|song|podcast|book|tv|place)(_ext)?:/.test(name)) continue
|
||||
const key = name.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
const cuisine = m[2]?.trim()
|
||||
const desc = extractDescriptionForTag(text, m.index, m[0].length)
|
||||
// Try to extract rating from nearby text
|
||||
const nearby = text.slice(m.index, m.index + 300)
|
||||
const ratingMatch = /(\d\.?\d?)\s*(?:\/\s*5|stars?|★)/i.exec(nearby)
|
||||
const rating = ratingMatch ? parseFloat(ratingMatch[1]) : undefined
|
||||
const priceMatch = /(\${1,4})\b/.exec(nearby)
|
||||
const priceLevel = priceMatch ? priceMatch[1].length : undefined
|
||||
places.push({ name, cuisine, desc, rating, priceLevel, pos: m.index })
|
||||
}
|
||||
}
|
||||
|
||||
return places
|
||||
.sort((a, b) => a.pos - b.pos)
|
||||
.map(({ name, cuisine, desc, rating, priceLevel }) => ({
|
||||
id: `ext-place-${name.toLowerCase().replace(/\W/g, '-')}`,
|
||||
name,
|
||||
cuisine,
|
||||
rating,
|
||||
priceLevel,
|
||||
description: desc,
|
||||
sources: [],
|
||||
}))
|
||||
}
|
||||
|
||||
function extractAllPlaces(text: string, userQuery: string): Place[] {
|
||||
const external = extractExternalPlaces(text)
|
||||
if (external.length > 0) return external
|
||||
if (!isPlaceQuery(userQuery) && !isPlaceLikeResponse(text)) return []
|
||||
if (isNewsLikeResponse(text)) return []
|
||||
return extractPlacesFromPatterns(text)
|
||||
}
|
||||
|
||||
function updatePanelFromText(text: string, userQuery = '', webResults: WebSearchResult[] = []) {
|
||||
panelQuery.value = userQuery.trim()
|
||||
const songs = extractAllSongs(text)
|
||||
@@ -1021,8 +1125,9 @@ export function useContentPanel() {
|
||||
}
|
||||
|
||||
const images = extractAllImages(text, userQuery)
|
||||
const places = extractAllPlaces(text, userQuery)
|
||||
|
||||
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, hasNews, hasWebsites, hasMagazine)
|
||||
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine)
|
||||
availableTabs.value = tabs.length > 0 ? tabs : ['film']
|
||||
activeTab.value = tabs[0] ?? 'film'
|
||||
|
||||
@@ -1030,6 +1135,7 @@ export function useContentPanel() {
|
||||
const showBooks = tabs.includes('book')
|
||||
const showTVSeries = tabs.includes('tvshow')
|
||||
const showImages = tabs.includes('image')
|
||||
const showPlaces = tabs.includes('place')
|
||||
const showSongs = tabs.includes('song')
|
||||
const showPodcasts = tabs.includes('podcast')
|
||||
const showNews = tabs.includes('news')
|
||||
@@ -1040,6 +1146,7 @@ export function useContentPanel() {
|
||||
const visibleBooks = showBooks ? books : []
|
||||
const visibleTVSeries = showTVSeries ? tvSeries : []
|
||||
const visibleImages = showImages ? images : []
|
||||
const visiblePlaces = showPlaces ? places : []
|
||||
const visibleSongs = showSongs ? songs : []
|
||||
const visiblePodcasts = showPodcasts ? podcasts : []
|
||||
const visibleNews = showNews ? mergedNews : []
|
||||
@@ -1050,6 +1157,7 @@ export function useContentPanel() {
|
||||
panelBooks.value = visibleBooks
|
||||
panelTVSeries.value = visibleTVSeries
|
||||
panelImages.value = visibleImages
|
||||
panelPlaces.value = visiblePlaces
|
||||
panelSongs.value = visibleSongs
|
||||
panelPodcasts.value = visiblePodcasts
|
||||
panelWebResults.value = visibleNews
|
||||
@@ -1062,6 +1170,7 @@ export function useContentPanel() {
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedImage.value = null
|
||||
selectedPlace.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
|
||||
@@ -1075,7 +1184,9 @@ export function useContentPanel() {
|
||||
|
||||
// Title follows the primary (first) tab
|
||||
const primary = tabs[0]
|
||||
if (primary === 'tvshow' && visibleTVSeries.length > 0) {
|
||||
if (primary === 'place' && visiblePlaces.length > 0) {
|
||||
panelTitle.value = visiblePlaces.length === 1 ? visiblePlaces[0].name : `${visiblePlaces.length} Places`
|
||||
} else if (primary === 'tvshow' && visibleTVSeries.length > 0) {
|
||||
panelTitle.value = visibleTVSeries.length === 1 ? visibleTVSeries[0].title : `${visibleTVSeries.length} TV Series`
|
||||
} else if (primary === 'book' && visibleBooks.length > 0) {
|
||||
panelTitle.value = visibleBooks.length === 1 ? visibleBooks[0].title : `${visibleBooks.length} Books`
|
||||
@@ -1089,6 +1200,8 @@ export function useContentPanel() {
|
||||
else if (visibleTVSeries.length > 1) panelTitle.value = `${visibleTVSeries.length} TV Series`
|
||||
else if (visibleSongs.length === 1) panelTitle.value = visibleSongs[0].title
|
||||
else if (visibleSongs.length > 1) panelTitle.value = `${visibleSongs.length} Songs`
|
||||
else if (visiblePlaces.length === 1) panelTitle.value = visiblePlaces[0].name
|
||||
else if (visiblePlaces.length > 1) panelTitle.value = `${visiblePlaces.length} Places`
|
||||
else if (visiblePodcasts.length === 1) panelTitle.value = visiblePodcasts[0].title
|
||||
else if (visiblePodcasts.length > 1) panelTitle.value = `${visiblePodcasts.length} Podcasts`
|
||||
else if (visibleNews.length > 0) {
|
||||
@@ -1130,12 +1243,14 @@ export function useContentPanel() {
|
||||
const websitesLinks = mergeNewsResults(websitesFromMd, boldDomains)
|
||||
const hasWebsites = websitesLinks.length > 0
|
||||
const images = extractAllImages(text, userQuery)
|
||||
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, hasNews, hasWebsites, hasMagazine)
|
||||
const places = extractAllPlaces(text, userQuery)
|
||||
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine)
|
||||
return {
|
||||
films: tabs.includes('film') ? films : [],
|
||||
books: tabs.includes('book') ? books : [],
|
||||
tvSeries: tabs.includes('tvshow') ? tvSeries : [],
|
||||
images: tabs.includes('image') ? images : [],
|
||||
places: tabs.includes('place') ? places : [],
|
||||
songs: tabs.includes('song') ? songs : [],
|
||||
podcasts: tabs.includes('podcast') ? podcasts : [],
|
||||
newsLinks: tabs.includes('news') ? newsLinks : [],
|
||||
@@ -1183,8 +1298,15 @@ export function useContentPanel() {
|
||||
.trim()
|
||||
}
|
||||
|
||||
function stripPlaceTags(text: string): string {
|
||||
return text
|
||||
.replace(PLACE_EXT_RE, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function stripContentTags(text: string): string {
|
||||
return stripFilmTags(stripSongTags(stripPodcastTags(stripBookTags(stripTVTags(text)))))
|
||||
return stripFilmTags(stripSongTags(stripPodcastTags(stripBookTags(stripTVTags(stripPlaceTags(text))))))
|
||||
}
|
||||
|
||||
/** Remove markdown links when surfacing as inline cards to avoid duplication */
|
||||
@@ -1201,9 +1323,12 @@ export function useContentPanel() {
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedImage.value = null
|
||||
selectedPlace.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
selectedWebsite.value = null
|
||||
selectedMagazineSection.value = null
|
||||
}
|
||||
|
||||
function closeFilmDetail() {
|
||||
@@ -1215,9 +1340,12 @@ export function useContentPanel() {
|
||||
selectedFilm.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedImage.value = null
|
||||
selectedPlace.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
selectedWebsite.value = null
|
||||
selectedMagazineSection.value = null
|
||||
}
|
||||
|
||||
function closeBookDetail() {
|
||||
@@ -1230,8 +1358,11 @@ export function useContentPanel() {
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedImage.value = null
|
||||
selectedPlace.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
selectedWebsite.value = null
|
||||
selectedMagazineSection.value = null
|
||||
}
|
||||
|
||||
function closeSongDetail() {
|
||||
@@ -1244,8 +1375,11 @@ export function useContentPanel() {
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedImage.value = null
|
||||
selectedPlace.value = null
|
||||
selectedSong.value = null
|
||||
selectedArticle.value = null
|
||||
selectedWebsite.value = null
|
||||
selectedMagazineSection.value = null
|
||||
}
|
||||
|
||||
function closePodcastDetail() {
|
||||
@@ -1258,8 +1392,11 @@ export function useContentPanel() {
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedImage.value = null
|
||||
selectedPlace.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedWebsite.value = null
|
||||
selectedMagazineSection.value = null
|
||||
panelOpen.value = true
|
||||
}
|
||||
|
||||
@@ -1267,14 +1404,63 @@ export function useContentPanel() {
|
||||
selectedArticle.value = null
|
||||
}
|
||||
|
||||
function openWebsiteDetail(website: WebSearchResult) {
|
||||
selectedWebsite.value = website
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedImage.value = null
|
||||
selectedPlace.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
panelOpen.value = true
|
||||
}
|
||||
|
||||
function closeWebsiteDetail() {
|
||||
selectedWebsite.value = null
|
||||
}
|
||||
|
||||
function openMagazineSectionDetail(section: MagazineSection, index: number) {
|
||||
selectedMagazineSection.value = section
|
||||
magazineSectionIndex.value = index
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedImage.value = null
|
||||
selectedPlace.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
selectedWebsite.value = null
|
||||
}
|
||||
|
||||
function closeMagazineSectionDetail() {
|
||||
selectedMagazineSection.value = null
|
||||
}
|
||||
|
||||
function navigateMagazineSection(direction: 'prev' | 'next') {
|
||||
const sections = panelMagazineSections.value
|
||||
if (!sections.length) return
|
||||
let idx = magazineSectionIndex.value
|
||||
idx += direction === 'next' ? 1 : -1
|
||||
if (idx < 0) idx = sections.length - 1
|
||||
if (idx >= sections.length) idx = 0
|
||||
magazineSectionIndex.value = idx
|
||||
selectedMagazineSection.value = sections[idx]
|
||||
}
|
||||
|
||||
function openTVSeriesDetail(series: TVSeries) {
|
||||
selectedTVSeries.value = series
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedImage.value = null
|
||||
selectedPlace.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
selectedWebsite.value = null
|
||||
selectedMagazineSection.value = null
|
||||
}
|
||||
|
||||
function closeTVSeriesDetail() {
|
||||
@@ -1286,24 +1472,47 @@ export function useContentPanel() {
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedPlace.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
selectedWebsite.value = null
|
||||
selectedMagazineSection.value = null
|
||||
}
|
||||
|
||||
function closeImageDetail() {
|
||||
selectedImage.value = null
|
||||
}
|
||||
|
||||
function openPlaceDetail(place: Place) {
|
||||
selectedPlace.value = place
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedImage.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
selectedWebsite.value = null
|
||||
selectedMagazineSection.value = null
|
||||
}
|
||||
|
||||
function closePlaceDetail() {
|
||||
selectedPlace.value = null
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panelOpen.value = false
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedImage.value = null
|
||||
selectedPlace.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
selectedWebsite.value = null
|
||||
selectedMagazineSection.value = null
|
||||
activeTab.value = 'film'
|
||||
availableTabs.value = []
|
||||
}
|
||||
@@ -1319,6 +1528,8 @@ export function useContentPanel() {
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
selectedWebsite.value = null
|
||||
selectedMagazineSection.value = null
|
||||
}
|
||||
|
||||
function showAllSongs() {
|
||||
@@ -1332,6 +1543,8 @@ export function useContentPanel() {
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
selectedWebsite.value = null
|
||||
selectedMagazineSection.value = null
|
||||
}
|
||||
|
||||
function showAllPodcasts() {
|
||||
@@ -1353,6 +1566,7 @@ export function useContentPanel() {
|
||||
panelBooks,
|
||||
panelTVSeries,
|
||||
panelImages,
|
||||
panelPlaces,
|
||||
panelSongs,
|
||||
panelPodcasts,
|
||||
panelWebResults,
|
||||
@@ -1363,9 +1577,13 @@ export function useContentPanel() {
|
||||
selectedBook,
|
||||
selectedTVSeries,
|
||||
selectedImage,
|
||||
selectedPlace,
|
||||
selectedSong,
|
||||
selectedPodcast,
|
||||
selectedArticle,
|
||||
selectedWebsite,
|
||||
selectedMagazineSection,
|
||||
magazineSectionIndex,
|
||||
panelTitle,
|
||||
panelQuery,
|
||||
contentType,
|
||||
@@ -1383,6 +1601,8 @@ export function useContentPanel() {
|
||||
closeTVSeriesDetail,
|
||||
openImageDetail,
|
||||
closeImageDetail,
|
||||
openPlaceDetail,
|
||||
closePlaceDetail,
|
||||
extractSongIds,
|
||||
resolveSongs,
|
||||
extractAllSongs,
|
||||
@@ -1404,6 +1624,11 @@ export function useContentPanel() {
|
||||
closePodcastDetail,
|
||||
openArticleDetail,
|
||||
closeArticleDetail,
|
||||
openWebsiteDetail,
|
||||
closeWebsiteDetail,
|
||||
openMagazineSectionDetail,
|
||||
closeMagazineSectionDetail,
|
||||
navigateMagazineSection,
|
||||
closePanel,
|
||||
showAllFilms,
|
||||
showAllSongs,
|
||||
|
||||
@@ -321,6 +321,22 @@ export async function fetchBookCover(
|
||||
}
|
||||
}
|
||||
|
||||
/** Place/restaurant fallback — map pin with cuisine hint */
|
||||
export function generatePlaceFallback(name: string, cuisine?: string): string {
|
||||
const hue = [...(name + (cuisine ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<rect width="200" height="200" fill="hsl(${hue}, 20%, 10%)"/>
|
||||
<rect x="3" y="3" width="194" height="194" rx="12" fill="none" stroke="hsl(${hue}, 25%, 16%)" stroke-width="1"/>
|
||||
<text x="100" y="72" text-anchor="middle" fill="hsl(${hue}, 20%, 22%)" font-family="system-ui,sans-serif" font-size="9" font-weight="500" letter-spacing="3">PLACE</text>
|
||||
<g transform="translate(100 120) scale(1.8)" fill="none" stroke="hsl(${hue}, 35%, 40%)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M0-14C-7.7-14-14-7.7-14 0c0 10.5 14 22 14 22s14-11.5 14-22c0-7.7-6.3-14-14-14z"/>
|
||||
<circle cx="0" cy="0" r="5"/>
|
||||
</g>
|
||||
${cuisine ? `<text x="100" y="170" text-anchor="middle" fill="hsl(${hue}, 25%, 40%)" font-family="system-ui,sans-serif" font-size="9" font-weight="400">${escapeXml(cuisine.length > 22 ? cuisine.slice(0, 20) + '…' : cuisine)}</text>` : ''}
|
||||
</svg>`
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
export async function fetchMusicCover(
|
||||
title: string,
|
||||
artist: string,
|
||||
|
||||
@@ -10,20 +10,22 @@
|
||||
>
|
||||
<div v-if="isDark" class="absolute inset-0 pointer-events-none bg-black/20" />
|
||||
|
||||
<!-- Desktop layout: side-by-side -->
|
||||
<!-- Desktop layout -->
|
||||
<div class="flex-1 flex h-full p-3 md:p-4 gap-3 md:gap-4" :class="isMobile ? 'hidden' : ''">
|
||||
|
||||
<!-- Content surface (main area) -->
|
||||
<main
|
||||
class="flex-1 min-w-0 path-glass-card overflow-hidden flex relative"
|
||||
:class="[
|
||||
panelSide === 'left' ? 'order-last' : 'order-first',
|
||||
hasDetailOpen && !hasGridContent && 'detail-active'
|
||||
isWideDesktop
|
||||
? (panelSide === 'left' ? 'order-2' : 'order-1')
|
||||
: (panelSide === 'left' ? 'order-last' : 'order-first'),
|
||||
!isWideDesktop && hasDetailOpen && !hasGridContent && 'detail-active'
|
||||
]"
|
||||
>
|
||||
<!-- Grid/list panel -->
|
||||
<!-- Grid/list panel (on wide desktop: always show grid; on regular: show when no detail) -->
|
||||
<div
|
||||
v-if="panelOpen && hasGridContent && !hasDetailOpen"
|
||||
v-if="panelOpen && hasGridContent && (isWideDesktop || !hasDetailOpen)"
|
||||
class="flex-1 min-w-0 flex flex-col"
|
||||
>
|
||||
<CloseButton @click="closePanel" />
|
||||
@@ -56,6 +58,7 @@
|
||||
:panel-books="panelBooks"
|
||||
:panelTVSeries="panelTVSeries"
|
||||
:panel-images="panelImages"
|
||||
:panel-places="panelPlaces"
|
||||
:panel-songs="panelSongs"
|
||||
:panel-podcasts="panelPodcasts"
|
||||
:panel-web-results="panelWebResults"
|
||||
@@ -68,61 +71,9 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Side-by-side: grid + detail on wide screens -->
|
||||
<template v-else-if="panelOpen && hasGridContent && hasDetailOpen">
|
||||
<div class="w-[45%] min-w-0 flex flex-col shrink-0"
|
||||
:style="isDark
|
||||
? 'border-right: 1px solid rgba(255, 255, 255, 0.08)'
|
||||
: 'border-right: 1px solid rgba(0, 0, 0, 0.06)'">
|
||||
<div
|
||||
v-if="availableTabs.length > 1"
|
||||
class="shrink-0 flex items-center gap-2 px-4 py-2"
|
||||
: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 flex-wrap gap-1.5 flex-1 min-w-0 justify-center">
|
||||
<button
|
||||
v-for="tab in availableTabs"
|
||||
:key="tab"
|
||||
class="text-[10px] px-2 py-1 rounded-md transition-all duration-150"
|
||||
:class="activeTab === tab
|
||||
? 'nav-tab-active'
|
||||
: isDark
|
||||
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
|
||||
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
|
||||
@click="setActiveTab(tab)"
|
||||
>
|
||||
{{ tabLabel(tab) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ContentGridView
|
||||
:active-tab="activeTab"
|
||||
:panel-films="panelFilms"
|
||||
:panel-books="panelBooks"
|
||||
:panelTVSeries="panelTVSeries"
|
||||
:panel-images="panelImages"
|
||||
:panel-songs="panelSongs"
|
||||
:panel-podcasts="panelPodcasts"
|
||||
:panel-web-results="panelWebResults"
|
||||
:panel-websites="panelWebsites"
|
||||
:panel-magazine-sections="panelMagazineSections"
|
||||
:panel-magazine-hero-image="panelMagazineHeroImage"
|
||||
:panel-title="panelTitle"
|
||||
:panel-query="panelQuery"
|
||||
@close="closePanel"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 flex flex-col">
|
||||
<CloseButton @click="closeAllDetails" />
|
||||
<DetailView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Detail only (no grid content — e.g. article overlay) -->
|
||||
<div v-else-if="panelOpen && hasDetailOpen" class="flex-1 min-w-0 flex flex-col detail-active">
|
||||
<CloseButton @click="closePanel" />
|
||||
<!-- Detail replaces grid on regular desktops -->
|
||||
<div v-else-if="!isWideDesktop && panelOpen && hasDetailOpen" class="flex-1 min-w-0 flex flex-col">
|
||||
<CloseButton @click="hasGridContent ? closeAllDetails() : closePanel()" />
|
||||
<DetailView />
|
||||
</div>
|
||||
|
||||
@@ -156,10 +107,39 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Detail panel (wide desktop only — persistent, no close/back) -->
|
||||
<div
|
||||
v-if="isWideDesktop"
|
||||
class="flex-1 min-w-0 path-glass-card overflow-hidden flex flex-col order-3 detail-persistent"
|
||||
>
|
||||
<template v-if="hasDetailOpen">
|
||||
<DetailView />
|
||||
</template>
|
||||
<div v-else class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center space-y-4 max-w-[200px] px-4">
|
||||
<div class="empty-state-icon w-16 h-16 rounded-2xl path-glass-icon flex items-center justify-center mx-auto overflow-hidden">
|
||||
<span class="text-2xl" :class="isDark ? 'text-[#fafafa]' : 'text-gray-800'">✦</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold mb-1"
|
||||
:class="isDark ? 'text-white/60' : 'text-gray-600'">
|
||||
Awaiting Context
|
||||
</h2>
|
||||
<p class="text-xs leading-relaxed"
|
||||
:class="isDark ? 'text-white/25' : 'text-gray-400'">
|
||||
Select an item to see details here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat panel -->
|
||||
<aside
|
||||
class="relative z-[100] w-80 xl:w-96 shrink-0 flex flex-col path-glass-card overflow-visible"
|
||||
:class="panelSide === 'left' ? 'order-first' : 'order-last'"
|
||||
:class="isWideDesktop
|
||||
? (panelSide === 'left' ? 'order-1' : 'order-2')
|
||||
: (panelSide === 'left' ? 'order-first' : 'order-last')"
|
||||
>
|
||||
<ChatWindow
|
||||
:side="panelSide"
|
||||
@@ -183,10 +163,7 @@
|
||||
<!-- Content view -->
|
||||
<div v-show="mobileTab === 'content'" class="flex-1 min-h-0 flex flex-col p-2 pb-0">
|
||||
<div class="flex-1 min-h-0 path-glass-card flex flex-col rounded-2xl overflow-hidden">
|
||||
<template v-if="panelOpen && hasDetailOpen">
|
||||
<DetailView />
|
||||
</template>
|
||||
<template v-else-if="panelOpen">
|
||||
<template v-if="panelOpen">
|
||||
<div
|
||||
v-if="availableTabs.length > 1"
|
||||
class="shrink-0 flex items-center gap-2 px-3 pt-2 pb-1 overflow-x-auto scrollbar-hide"
|
||||
@@ -211,6 +188,7 @@
|
||||
:panel-books="panelBooks"
|
||||
:panelTVSeries="panelTVSeries"
|
||||
:panel-images="panelImages"
|
||||
:panel-places="panelPlaces"
|
||||
:panel-songs="panelSongs"
|
||||
:panel-podcasts="panelPodcasts"
|
||||
:panel-web-results="panelWebResults"
|
||||
@@ -238,6 +216,67 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context view (detail panel) -->
|
||||
<div v-show="mobileTab === 'context'" class="flex-1 min-h-0 flex flex-col p-2 pb-0">
|
||||
<div class="flex-1 min-h-0 path-glass-card flex flex-col rounded-2xl overflow-hidden">
|
||||
<template v-if="hasDetailOpen">
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
<DetailView />
|
||||
</div>
|
||||
<!-- Navigation bar (skip for magazine sections which have their own) -->
|
||||
<div
|
||||
v-if="!selectedMagazineSection && contextItems.length > 1"
|
||||
class="shrink-0 flex items-center justify-between px-4 py-2"
|
||||
: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="navigateContext('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>
|
||||
<span class="text-[10px] font-mono tabular-nums"
|
||||
:class="isDark ? 'text-white/25' : 'text-black/25'">
|
||||
{{ currentContextIndex + 1 }}/{{ contextItems.length }}
|
||||
</span>
|
||||
<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="navigateContext('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>
|
||||
</template>
|
||||
<div v-else class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center space-y-3 px-6">
|
||||
<div class="empty-state-icon w-16 h-16 rounded-2xl path-glass-icon flex items-center justify-center mx-auto overflow-hidden">
|
||||
<svg class="w-7 h-7" :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="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
|
||||
Tap an item in Content to view details here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom tab bar -->
|
||||
<div
|
||||
class="shrink-0 flex items-center px-2 py-1.5 gap-1"
|
||||
@@ -270,6 +309,23 @@
|
||||
class="absolute top-1 right-[30%] w-1.5 h-1.5 rounded-full bg-accent"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-xl text-xs font-medium transition-all duration-150 relative"
|
||||
:class="mobileTab === 'context'
|
||||
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
|
||||
: isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'"
|
||||
@click="mobileTab = 'context'"
|
||||
>
|
||||
<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 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Context
|
||||
<span
|
||||
v-if="hasDetailOpen && mobileTab !== 'context'"
|
||||
class="absolute top-1 right-[30%] w-1.5 h-1.5 rounded-full bg-accent"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -290,8 +346,10 @@ import CloseButton from '@/components/content/CloseButton.vue'
|
||||
import ContextLoader from '@/components/content/ContextLoader.vue'
|
||||
import PlayerBar from '@/components/player/PlayerBar.vue'
|
||||
import { usePlayer } from '@/composables/usePlayer'
|
||||
import { useCodeContext } from '@/composables/useCodeContext'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const { activeFile: codeActiveFile, isCodeMode } = useCodeContext()
|
||||
const { hasTrack } = usePlayer()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
@@ -303,6 +361,7 @@ const {
|
||||
panelBooks,
|
||||
panelTVSeries,
|
||||
panelImages,
|
||||
panelPlaces,
|
||||
panelSongs,
|
||||
panelPodcasts,
|
||||
panelWebResults,
|
||||
@@ -319,17 +378,31 @@ const {
|
||||
selectedBook,
|
||||
selectedTVSeries,
|
||||
selectedImage,
|
||||
selectedPlace,
|
||||
selectedSong,
|
||||
selectedPodcast,
|
||||
selectedArticle,
|
||||
selectedWebsite,
|
||||
selectedMagazineSection,
|
||||
closeFilmDetail,
|
||||
closeBookDetail,
|
||||
closeTVSeriesDetail,
|
||||
closeImageDetail,
|
||||
closePlaceDetail,
|
||||
closeSongDetail,
|
||||
closePodcastDetail,
|
||||
closeArticleDetail,
|
||||
closeWebsiteDetail,
|
||||
closeMagazineSectionDetail,
|
||||
closePanel,
|
||||
openFilmDetail,
|
||||
openBookDetail,
|
||||
openTVSeriesDetail,
|
||||
openImageDetail,
|
||||
openPlaceDetail,
|
||||
openSongDetail,
|
||||
openPodcastDetail,
|
||||
openMagazineSectionDetail,
|
||||
} = useContentPanel()
|
||||
|
||||
const panelSide = computed(() => chatStore.panelSide)
|
||||
@@ -337,22 +410,28 @@ const panelSide = computed(() => chatStore.panelSide)
|
||||
// Mobile detection
|
||||
const windowWidth = ref(window.innerWidth)
|
||||
const isMobile = computed(() => windowWidth.value < 1024)
|
||||
const mobileTab = ref<'chat' | 'content'>('chat')
|
||||
const isWideDesktop = computed(() => windowWidth.value >= 1440)
|
||||
const mobileTab = ref<'chat' | 'content' | 'context'>('chat')
|
||||
|
||||
function onResize() { windowWidth.value = window.innerWidth }
|
||||
onMounted(() => window.addEventListener('resize', onResize))
|
||||
onUnmounted(() => window.removeEventListener('resize', onResize))
|
||||
|
||||
// Auto-switch to content tab on mobile when panel opens
|
||||
// Auto-switch to content tab on mobile when panel opens or detail is selected
|
||||
watch(panelOpen, (open) => {
|
||||
if (open && isMobile.value) mobileTab.value = 'content'
|
||||
})
|
||||
|
||||
const hasDetailOpen = computed(() =>
|
||||
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value ||
|
||||
selectedImage.value || selectedSong.value || selectedPodcast.value || selectedArticle.value)
|
||||
selectedImage.value || selectedPlace.value || selectedSong.value || selectedPodcast.value || selectedArticle.value || selectedWebsite.value || selectedMagazineSection.value ||
|
||||
(isCodeMode.value && codeActiveFile.value))
|
||||
)
|
||||
|
||||
watch(hasDetailOpen, (open) => {
|
||||
if (open && isMobile.value) mobileTab.value = 'context'
|
||||
})
|
||||
|
||||
const hasGridContent = computed(() =>
|
||||
panelOpen.value && availableTabs.value.length > 0
|
||||
)
|
||||
@@ -362,9 +441,83 @@ function closeAllDetails() {
|
||||
closeBookDetail()
|
||||
closeTVSeriesDetail()
|
||||
closeImageDetail()
|
||||
closePlaceDetail()
|
||||
closeSongDetail()
|
||||
closePodcastDetail()
|
||||
closeArticleDetail()
|
||||
closeWebsiteDetail()
|
||||
closeMagazineSectionDetail()
|
||||
}
|
||||
|
||||
// Context navigation — flat list of all detail-openable items
|
||||
interface ContextNavItem {
|
||||
open: () => void
|
||||
}
|
||||
|
||||
const contextItems = computed<ContextNavItem[]>(() => {
|
||||
const items: ContextNavItem[] = []
|
||||
panelFilms.value.forEach(f => items.push({ open: () => openFilmDetail(f) }))
|
||||
panelBooks.value.forEach(b => items.push({ open: () => openBookDetail(b) }))
|
||||
panelTVSeries.value.forEach(s => items.push({ open: () => openTVSeriesDetail(s) }))
|
||||
panelSongs.value.forEach(s => items.push({ open: () => openSongDetail(s) }))
|
||||
panelPodcasts.value.forEach(p => items.push({ open: () => openPodcastDetail(p) }))
|
||||
panelImages.value.forEach(img => items.push({ open: () => openImageDetail(img) }))
|
||||
panelPlaces.value.forEach(p => items.push({ open: () => openPlaceDetail(p) }))
|
||||
panelMagazineSections.value.forEach((s, i) => items.push({ open: () => openMagazineSectionDetail(s, i) }))
|
||||
return items
|
||||
})
|
||||
|
||||
const currentContextIndex = computed(() => {
|
||||
// Determine which item is currently selected
|
||||
if (selectedFilm.value) {
|
||||
const idx = panelFilms.value.indexOf(selectedFilm.value)
|
||||
return idx >= 0 ? idx : 0
|
||||
}
|
||||
if (selectedBook.value) {
|
||||
const idx = panelBooks.value.indexOf(selectedBook.value)
|
||||
return idx >= 0 ? panelFilms.value.length + idx : 0
|
||||
}
|
||||
if (selectedTVSeries.value) {
|
||||
const idx = panelTVSeries.value.indexOf(selectedTVSeries.value)
|
||||
return idx >= 0 ? panelFilms.value.length + panelBooks.value.length + idx : 0
|
||||
}
|
||||
if (selectedSong.value) {
|
||||
const base = panelFilms.value.length + panelBooks.value.length + panelTVSeries.value.length
|
||||
const idx = panelSongs.value.indexOf(selectedSong.value)
|
||||
return idx >= 0 ? base + idx : 0
|
||||
}
|
||||
if (selectedPodcast.value) {
|
||||
const base = panelFilms.value.length + panelBooks.value.length + panelTVSeries.value.length + panelSongs.value.length
|
||||
const idx = panelPodcasts.value.indexOf(selectedPodcast.value)
|
||||
return idx >= 0 ? base + idx : 0
|
||||
}
|
||||
if (selectedImage.value) {
|
||||
const base = panelFilms.value.length + panelBooks.value.length + panelTVSeries.value.length + panelSongs.value.length + panelPodcasts.value.length
|
||||
const idx = panelImages.value.indexOf(selectedImage.value)
|
||||
return idx >= 0 ? base + idx : 0
|
||||
}
|
||||
if (selectedPlace.value) {
|
||||
const base = panelFilms.value.length + panelBooks.value.length + panelTVSeries.value.length + panelSongs.value.length + panelPodcasts.value.length + panelImages.value.length
|
||||
const idx = panelPlaces.value.indexOf(selectedPlace.value)
|
||||
return idx >= 0 ? base + idx : 0
|
||||
}
|
||||
if (selectedMagazineSection.value) {
|
||||
const base = panelFilms.value.length + panelBooks.value.length + panelTVSeries.value.length + panelSongs.value.length + panelPodcasts.value.length + panelImages.value.length + panelPlaces.value.length
|
||||
const idx = panelMagazineSections.value.indexOf(selectedMagazineSection.value)
|
||||
return idx >= 0 ? base + idx : 0
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
function navigateContext(direction: 'prev' | 'next') {
|
||||
const items = contextItems.value
|
||||
if (!items.length) return
|
||||
let idx = currentContextIndex.value
|
||||
idx += direction === 'next' ? 1 : -1
|
||||
if (idx < 0) idx = items.length - 1
|
||||
if (idx >= items.length) idx = 0
|
||||
closeAllDetails()
|
||||
items[idx].open()
|
||||
}
|
||||
|
||||
const TAB_LABELS: Record<ContentTab, string> = {
|
||||
@@ -372,11 +525,13 @@ const TAB_LABELS: Record<ContentTab, string> = {
|
||||
book: 'Books',
|
||||
tvshow: 'TV',
|
||||
image: 'Images',
|
||||
place: 'Places',
|
||||
song: 'Songs',
|
||||
podcast: 'Podcasts',
|
||||
news: 'News',
|
||||
websites: 'Websites',
|
||||
magazine: 'Brief',
|
||||
code: 'Code',
|
||||
}
|
||||
|
||||
function tabLabel(tab: ContentTab): string {
|
||||
@@ -391,6 +546,7 @@ const loaderContextType = computed(() => {
|
||||
if (/\b(book|novel|read|author|fiction|nonfiction|memoir)\b/.test(q)) return 'book'
|
||||
if (/\b(tv show|tv series|series|television|binge|season)\b/.test(q)) return 'tvshow'
|
||||
if (/\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/.test(q)) return 'image'
|
||||
if (/\b(restaurant|restaurants|place|places|food|eat|dining|cafe|bar|pub|brunch|lunch|dinner)\b/.test(q)) return 'film'
|
||||
if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast'
|
||||
if (/\b(news|latest|recent|current|what'?s happening|what are people saying)\b/.test(q)) return 'news'
|
||||
if (/\b(bip|protocol|debate|sentiment|bearish|bull case|macro)\b/.test(q)) return 'magazine'
|
||||
@@ -411,4 +567,8 @@ const loaderContextType = computed(() => {
|
||||
.detail-active {
|
||||
border-color: transparent !important;
|
||||
}
|
||||
/* Hide back buttons in persistent detail panel on wide desktops */
|
||||
.detail-persistent :deep(button[class*="absolute"][class*="top-3"][class*="left-3"]) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -137,3 +137,29 @@ export interface ImageItem {
|
||||
source?: string
|
||||
attribution?: string
|
||||
}
|
||||
|
||||
export interface Place {
|
||||
id: string
|
||||
name: string
|
||||
address?: string
|
||||
city?: string
|
||||
cuisine?: string
|
||||
category?: string
|
||||
rating?: number
|
||||
priceLevel?: number // 1-4 ($-$$$$)
|
||||
phone?: string
|
||||
website?: string
|
||||
hours?: string
|
||||
description?: string
|
||||
photoUrl?: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
sources?: PlaceSource[]
|
||||
}
|
||||
|
||||
export interface PlaceSource {
|
||||
type: 'gmaps' | 'osm' | 'yelp' | 'tripadvisor' | 'foursquare' | 'local'
|
||||
name: string
|
||||
url: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user