feat(layout): responsive layout with side-by-side detail and mobile tabs

Desktop (>= 1024px):
- Content grid and detail pane shown side-by-side when a detail is selected
- Three-column layout: chat | grid | detail
- Grid takes 45% width, detail fills remaining space

Mobile (< 1024px):
- Bottom tab bar with "Chat" and "Content" tabs
- Only one view visible at a time
- Auto-switches to Content tab when panel opens
- Orange dot indicator on Content tab when content is available

Extracted reusable components:
- ContentGridView: all grid renderers in one component
- DetailView: all detail views in one component
- CloseButton: reusable close/dismiss button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 07:41:59 +00:00
co-authored by Claude Opus 4.6
parent 7f8dc72dd7
commit 48dd7a9c68
4 changed files with 515 additions and 345 deletions
@@ -0,0 +1,22 @@
<template>
<button
class="absolute top-3 right-3 z-10 p-2 rounded-lg path-glass-icon transition-colors"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
title="Close"
aria-label="Close"
@click="$emit('click')"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
const { isDark } = useTheme()
defineEmits<{ click: [] }>()
</script>
@@ -0,0 +1,156 @@
<template>
<div class="flex-1 min-h-0 flex flex-col">
<FilmGrid
v-if="activeTab === 'film'"
:films="panelFilms"
:title="panelTitle"
@select-film="openFilmDetail"
>
<template #header-actions>
<slot name="header-actions">
<CloseButton @click="$emit('close')" />
</slot>
</template>
</FilmGrid>
<BookGrid
v-else-if="activeTab === 'book'"
:books="panelBooks"
:title="panelTitle"
@select-book="openBookDetail"
>
<template #header-actions>
<slot name="header-actions">
<CloseButton @click="$emit('close')" />
</slot>
</template>
</BookGrid>
<TVSeriesGrid
v-else-if="activeTab === 'tvshow'"
:series="panelTVSeries"
:title="panelTitle"
@select-series="openTVSeriesDetail"
>
<template #header-actions>
<slot name="header-actions">
<CloseButton @click="$emit('close')" />
</slot>
</template>
</TVSeriesGrid>
<ImageGrid
v-else-if="activeTab === 'image'"
:images="panelImages"
:title="panelTitle"
@select-image="openImageDetail"
>
<template #header-actions>
<slot name="header-actions">
<CloseButton @click="$emit('close')" />
</slot>
</template>
</ImageGrid>
<SongGrid
v-else-if="activeTab === 'song'"
:songs="panelSongs"
:title="panelTitle"
@select-song="openSongDetail"
>
<template #header-actions>
<slot name="header-actions">
<CloseButton @click="$emit('close')" />
</slot>
</template>
</SongGrid>
<MagazineGrid
v-else-if="activeTab === 'magazine'"
:sections="panelMagazineSections"
:hero-image-url="panelMagazineHeroImage"
:title="panelTitle"
:query="panelQuery"
>
<template #header-actions>
<slot name="header-actions">
<CloseButton @click="$emit('close')" />
</slot>
</template>
</MagazineGrid>
<NewsGrid
v-else-if="activeTab === 'news'"
:articles="panelWebResults"
:title="panelTitle"
:query="panelQuery"
>
<template #header-actions>
<slot name="header-actions">
<CloseButton @click="$emit('close')" />
</slot>
</template>
</NewsGrid>
<NewsGrid
v-else-if="activeTab === 'websites'"
:articles="panelWebsites"
:title="panelTitle"
variant="websites"
>
<template #header-actions>
<slot name="header-actions">
<CloseButton @click="$emit('close')" />
</slot>
</template>
</NewsGrid>
<PodcastGrid
v-else-if="activeTab === 'podcast'"
:podcasts="panelPodcasts"
:title="panelTitle"
@select-podcast="openPodcastDetail"
>
<template #header-actions>
<slot name="header-actions">
<CloseButton @click="$emit('close')" />
</slot>
</template>
</PodcastGrid>
</div>
</template>
<script setup lang="ts">
import type { Film, Song, Podcast, Book, TVSeries, ImageItem } 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'
import FilmGrid from './FilmGrid.vue'
import BookGrid from './BookGrid.vue'
import TVSeriesGrid from './TVSeriesGrid.vue'
import ImageGrid from './ImageGrid.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'
defineProps<{
activeTab: ContentTab
panelFilms: Film[]
panelBooks: Book[]
panelTVSeries: TVSeries[]
panelImages: ImageItem[]
panelSongs: Song[]
panelPodcasts: Podcast[]
panelWebResults: WebSearchResult[]
panelWebsites: WebSearchResult[]
panelMagazineSections: MagazineSection[]
panelMagazineHeroImage: string | null
panelTitle: string
panelQuery: string
}>()
defineEmits<{ close: [] }>()
const {
openFilmDetail,
openBookDetail,
openTVSeriesDetail,
openImageDetail,
openSongDetail,
openPodcastDetail,
} = useContentPanel()
</script>
@@ -0,0 +1,65 @@
<template>
<FilmDetail
v-if="selectedFilm"
:film="selectedFilm"
@back="closeFilmDetail"
/>
<SongDetail
v-else-if="selectedSong"
:song="selectedSong"
@back="closeSongDetail"
/>
<PodcastDetail
v-else-if="selectedPodcast"
:podcast="selectedPodcast"
@back="closePodcastDetail"
/>
<BookDetail
v-else-if="selectedBook"
:book="selectedBook"
@back="closeBookDetail"
/>
<TVSeriesDetail
v-else-if="selectedTVSeries"
:series="selectedTVSeries"
@back="closeTVSeriesDetail"
/>
<ImageDetail
v-else-if="selectedImage"
:image="selectedImage"
@back="closeImageDetail"
/>
<ArticleDetail
v-else-if="selectedArticle"
:article="selectedArticle"
@back="closeArticleDetail"
/>
</template>
<script setup lang="ts">
import { useContentPanel } from '@/composables/useContentPanel'
import FilmDetail from './FilmDetail.vue'
import BookDetail from './BookDetail.vue'
import TVSeriesDetail from './TVSeriesDetail.vue'
import SongDetail from './SongDetail.vue'
import PodcastDetail from './PodcastDetail.vue'
import ImageDetail from './ImageDetail.vue'
import ArticleDetail from './ArticleDetail.vue'
const {
selectedFilm,
selectedBook,
selectedTVSeries,
selectedImage,
selectedSong,
selectedPodcast,
selectedArticle,
closeFilmDetail,
closeBookDetail,
closeTVSeriesDetail,
closeImageDetail,
closeSongDetail,
closePodcastDetail,
closeArticleDetail,
} = useContentPanel()
</script>
+272 -345
View File
@@ -10,336 +10,153 @@
>
<div v-if="isDark" class="absolute inset-0 pointer-events-none bg-black/20" />
<div class="flex-1 flex h-full p-3 md:p-4 gap-3 md:gap-4">
<!-- Desktop layout: side-by-side -->
<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 flex-col relative"
class="flex-1 min-w-0 path-glass-card overflow-hidden flex relative"
:class="[
panelSide === 'left' ? 'order-last' : 'order-first',
(selectedFilm || selectedBook || selectedTVSeries || selectedImage || selectedSong || selectedPodcast || selectedArticle) && 'detail-active'
hasDetailOpen && !hasGridContent && 'detail-active'
]"
>
<button
v-if="(selectedFilm || selectedBook || selectedTVSeries || selectedImage || selectedSong || selectedPodcast || selectedArticle) && (panelOpen || chatStore.isStreaming)"
class="absolute top-3 right-3 z-10 p-2 rounded-lg path-glass-icon transition-colors"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
title="Clear content"
aria-label="Clear content"
@click="closePanel"
<!-- Grid/list panel -->
<div
v-if="panelOpen && hasGridContent && !hasDetailOpen"
class="flex-1 min-w-0 flex flex-col"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<Transition name="content-fade" mode="out-in">
<div v-if="panelOpen" key="content" class="flex-1 min-h-0 flex flex-col">
<FilmDetail
v-if="selectedFilm"
:film="selectedFilm"
@back="closeFilmDetail"
/>
<SongDetail
v-else-if="selectedSong"
:song="selectedSong"
@back="closeSongDetail"
/>
<PodcastDetail
v-else-if="selectedPodcast"
:podcast="selectedPodcast"
@back="closePodcastDetail"
/>
<BookDetail
v-else-if="selectedBook"
:book="selectedBook"
@back="closeBookDetail"
/>
<TVSeriesDetail
v-else-if="selectedTVSeries"
:series="selectedTVSeries"
@back="closeTVSeriesDetail"
/>
<ImageDetail
v-else-if="selectedImage"
:image="selectedImage"
@back="closeImageDetail"
/>
<ArticleDetail
v-else-if="selectedArticle"
:article="selectedArticle"
@back="closeArticleDetail"
/>
<template v-else>
<div
v-if="availableTabs.length > 1"
class="shrink-0 flex items-center justify-between 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>
<FilmGrid
v-if="activeTab === 'film'"
:films="panelFilms"
:title="panelTitle"
@select-film="openFilmDetail"
>
<template #header-actions>
<button
class="flex items-center justify-center p-2 rounded-lg transition-colors -mr-1"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
title="Clear content"
aria-label="Clear content"
@click="closePanel"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
</FilmGrid>
<BookGrid
v-else-if="activeTab === 'book'"
:books="panelBooks"
:title="panelTitle"
@select-book="openBookDetail"
>
<template #header-actions>
<button
class="flex items-center justify-center p-2 rounded-lg transition-colors -mr-1"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
title="Clear content"
aria-label="Clear content"
@click="closePanel"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
</BookGrid>
<TVSeriesGrid
v-else-if="activeTab === 'tvshow'"
:series="panelTVSeries"
:title="panelTitle"
@select-series="openTVSeriesDetail"
>
<template #header-actions>
<button
class="flex items-center justify-center p-2 rounded-lg transition-colors -mr-1"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
title="Clear content"
aria-label="Clear content"
@click="closePanel"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
</TVSeriesGrid>
<ImageGrid
v-else-if="activeTab === 'image'"
:images="panelImages"
:title="panelTitle"
@select-image="openImageDetail"
>
<template #header-actions>
<button
class="flex items-center justify-center p-2 rounded-lg transition-colors -mr-1"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
title="Clear content"
aria-label="Clear content"
@click="closePanel"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
</ImageGrid>
<SongGrid
v-else-if="activeTab === 'song'"
:songs="panelSongs"
:title="panelTitle"
@select-song="openSongDetail"
>
<template #header-actions>
<button
class="flex items-center justify-center p-2 rounded-lg transition-colors -mr-1"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
title="Clear content"
aria-label="Clear content"
@click="closePanel"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
</SongGrid>
<MagazineGrid
v-else-if="activeTab === 'magazine'"
:sections="panelMagazineSections"
:hero-image-url="panelMagazineHeroImage"
:title="panelTitle"
:query="panelQuery"
>
<template #header-actions>
<button
class="flex items-center justify-center p-2 rounded-lg transition-colors -mr-1"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
title="Clear content"
aria-label="Clear content"
@click="closePanel"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
</MagazineGrid>
<NewsGrid
v-else-if="activeTab === 'news'"
:articles="panelWebResults"
:title="panelTitle"
:query="panelQuery"
>
<template #header-actions>
<button
class="flex items-center justify-center p-2 rounded-lg transition-colors -mr-1"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
title="Clear content"
aria-label="Clear content"
@click="closePanel"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
</NewsGrid>
<NewsGrid
v-else-if="activeTab === 'websites'"
:articles="panelWebsites"
:title="panelTitle"
variant="websites"
>
<template #header-actions>
<button
class="flex items-center justify-center p-2 rounded-lg transition-colors -mr-1"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
title="Clear content"
aria-label="Clear content"
@click="closePanel"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
</NewsGrid>
<PodcastGrid
v-else-if="activeTab === 'podcast'"
:podcasts="panelPodcasts"
:title="panelTitle"
@select-podcast="openPodcastDetail"
>
<template #header-actions>
<button
class="flex items-center justify-center p-2 rounded-lg transition-colors -mr-1"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
title="Clear content"
aria-label="Clear content"
@click="closePanel"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
</PodcastGrid>
</template>
</div>
<ContextLoader
v-else-if="chatStore.isStreaming"
key="loader"
:context-type="loaderContextType"
<CloseButton @click="closePanel" />
<div
v-if="availableTabs.length > 1"
class="shrink-0 flex items-center justify-between 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)'"
>
<template #header-actions>
<div class="flex flex-wrap gap-1.5 flex-1 min-w-0 justify-center">
<button
class="flex items-center justify-center p-2 rounded-lg transition-colors -mr-1"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
title="Clear content"
aria-label="Clear content"
@click="closePanel"
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)"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
{{ tabLabel(tab) }}
</button>
</template>
</ContextLoader>
<div v-else key="empty" class="flex-1 flex items-center justify-center">
<div class="text-center space-y-4 animate-fade-up max-w-sm px-6">
<div class="empty-state-icon w-20 h-20 rounded-2xl path-glass-icon flex items-center justify-center mx-auto overflow-hidden">
<span class="text-3xl" :class="isDark ? 'text-[#fafafa]' : 'text-gray-800'"></span>
</div>
<div>
<h2 class="text-lg font-bold mb-1"
:class="isDark ? 'text-white/80' : 'text-gray-800'">
Content Surface
</h2>
<p class="text-sm leading-relaxed"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
Ask about films, songs, or podcasts in the chat to see rich content here.
</p>
</div>
</div>
</div>
</Transition>
<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>
<!-- 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" />
<DetailView />
</div>
<!-- Loading state -->
<ContextLoader
v-else-if="chatStore.isStreaming"
:context-type="loaderContextType"
>
<template #header-actions>
<CloseButton @click="closePanel" />
</template>
</ContextLoader>
<!-- Empty state -->
<div v-else class="flex-1 flex items-center justify-center">
<div class="text-center space-y-4 animate-fade-up max-w-sm px-6">
<div class="empty-state-icon w-20 h-20 rounded-2xl path-glass-icon flex items-center justify-center mx-auto overflow-hidden">
<span class="text-3xl" :class="isDark ? 'text-[#fafafa]' : 'text-gray-800'"></span>
</div>
<div>
<h2 class="text-lg font-bold mb-1"
:class="isDark ? 'text-white/80' : 'text-gray-800'">
Content Surface
</h2>
<p class="text-sm leading-relaxed"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
Ask about films, songs, or podcasts in the chat to see rich content here.
</p>
</div>
</div>
</div>
</main>
<!-- Chat panel: high z-index so it + dropdowns stay above content -->
<!-- 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'"
@@ -349,7 +166,111 @@
@switch-side="chatStore.switchSide()"
/>
</aside>
</div>
<!-- Mobile layout -->
<div v-if="isMobile" class="flex-1 flex flex-col min-h-0">
<!-- Chat view -->
<div v-show="mobileTab === 'chat'" 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">
<ChatWindow
variant="standalone"
:show-close="false"
/>
</div>
</div>
<!-- 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">
<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"
>
<button
v-for="tab in availableTabs"
:key="tab"
class="text-[10px] px-2.5 py-1.5 rounded-lg font-medium whitespace-nowrap 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>
<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"
/>
</template>
<template v-else-if="chatStore.isStreaming">
<ContextLoader :context-type="loaderContextType" />
</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">
<span class="text-2xl" :class="isDark ? 'text-[#fafafa]' : 'text-gray-800'"></span>
</div>
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
Ask about something in the chat to see content here.
</p>
</div>
</div>
</div>
</div>
<!-- Bottom tab bar -->
<div
class="shrink-0 flex items-center px-2 py-1.5 gap-1"
>
<button
class="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-xl text-xs font-medium transition-all duration-150"
:class="mobileTab === 'chat'
? 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 = 'chat'"
>
<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="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
Chat
</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 === 'content'
? 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 = 'content'"
>
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
Content
<span
v-if="panelOpen && mobileTab === 'chat'"
class="absolute top-1 right-[30%] w-1.5 h-1.5 rounded-full bg-accent"
/>
</button>
</div>
</div>
<PlayerBar />
@@ -357,27 +278,15 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useTheme } from '@/composables/useTheme'
import { useAI } from '@/composables/useAI'
import { useContentPanel, type ContentTab } from '@/composables/useContentPanel'
import ChatWindow from '@/components/chat/ChatWindow.vue'
import FilmGrid from '@/components/content/FilmGrid.vue'
import ArticleDetail from '@/components/content/ArticleDetail.vue'
import FilmDetail from '@/components/content/FilmDetail.vue'
import BookGrid from '@/components/content/BookGrid.vue'
import BookDetail from '@/components/content/BookDetail.vue'
import TVSeriesGrid from '@/components/content/TVSeriesGrid.vue'
import TVSeriesDetail from '@/components/content/TVSeriesDetail.vue'
import SongGrid from '@/components/content/SongGrid.vue'
import SongDetail from '@/components/content/SongDetail.vue'
import PodcastGrid from '@/components/content/PodcastGrid.vue'
import PodcastDetail from '@/components/content/PodcastDetail.vue'
import MagazineGrid from '@/components/content/MagazineGrid.vue'
import NewsGrid from '@/components/content/NewsGrid.vue'
import ImageGrid from '@/components/content/ImageGrid.vue'
import ImageDetail from '@/components/content/ImageDetail.vue'
import ContentGridView from '@/components/content/ContentGridView.vue'
import DetailView from '@/components/content/DetailView.vue'
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'
@@ -413,25 +322,51 @@ const {
selectedSong,
selectedPodcast,
selectedArticle,
openFilmDetail,
closeFilmDetail,
openBookDetail,
closeBookDetail,
openTVSeriesDetail,
closeTVSeriesDetail,
openImageDetail,
closeImageDetail,
openSongDetail,
closeSongDetail,
openPodcastDetail,
closePodcastDetail,
openArticleDetail,
closeArticleDetail,
closePanel,
} = useContentPanel()
const panelSide = computed(() => chatStore.panelSide)
// Mobile detection
const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value < 1024)
const mobileTab = ref<'chat' | 'content'>('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
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)
)
const hasGridContent = computed(() =>
panelOpen.value && availableTabs.value.length > 0
)
function closeAllDetails() {
closeFilmDetail()
closeBookDetail()
closeTVSeriesDetail()
closeImageDetail()
closeSongDetail()
closePodcastDetail()
closeArticleDetail()
}
const TAB_LABELS: Record<ContentTab, string> = {
film: 'Films',
book: 'Books',
@@ -448,7 +383,6 @@ function tabLabel(tab: ContentTab): string {
return TAB_LABELS[tab] ?? tab
}
// Infer loader type from last user message when streaming (before tags are parsed)
const loaderContextType = computed(() => {
if (panelOpen.value) return contentType.value
const lastUser = [...chatStore.messages].reverse().find((m) => m.role === 'user')
@@ -474,13 +408,6 @@ const loaderContextType = computed(() => {
.content-fade-leave-to {
opacity: 0;
}
/*
* When film detail is active, hide the border so the hero image
* fills edge-to-edge with no visible seam lines.
* The card keeps its border-radius and overflow:hidden,
* so the image is still clipped to rounded corners.
*/
.detail-active {
border-color: transparent !important;
}