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>