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:
co-authored by
Claude Opus 4.6
parent
7f8dc72dd7
commit
48dd7a9c68
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user