Files
archy/packages/app/src/components/content/ContentPanel.vue
T
DorianandClaude Opus 4.6 cb22131909 feat(app): apps tab, archy mock data, prompt palette, code browser, detail keys, guide routing
- Add [[app_ext:...]] tag format and rewrite extractApps() for reliable app extraction
- Wire AppsGrid and RecipeGrid into ContentGridView (was missing on wide desktop)
- Add mock Archy node data for standalone dev testing (VITE_MOCK_ARCHY=true)
- Fix PromptPalette: z-50 + opaque bg so slash menu renders above chat content
- Fix detail banner not updating: add :key to all detail components in ContentPanel
- Guide page moved to /guide, chat is now root route, guide auto-selected on first load
- Code browser: click opens file in viewer, separate checkbox for chat context selection
- Restore folder context selector (round checkbox on hover) in FileTreeNode
- Demo projects for prod deployment instead of hardcoded personal paths
- Improve Archy context injection with media breakdown and better error logging
- Add 11 Claude Code skills for efficient development workflows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:39:41 +00:00

412 lines
13 KiB
Vue

<template>
<Transition name="panel">
<aside
v-if="panelOpen"
class="path-glass-card overflow-hidden flex flex-col"
:class="isMobile
? 'fixed inset-0 z-30'
: 'w-80 xl:w-96 shrink-0'"
>
<!-- Mobile header -->
<div
v-if="isMobile"
class="p-3 flex items-center justify-between shrink-0"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ panelTitle }}
</h3>
<button
class="touch-target rounded-lg transition-colors"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5'"
@click="closePanel"
>
<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>
</div>
<!-- Tab bar (when multiple tabs available) -->
<div
v-if="displayTabs.length > 1 && !hasDetailOpen"
class="flex items-center gap-2 px-3 pt-3 pb-1 shrink-0 overflow-x-auto scrollbar-hide"
>
<button
v-for="tab in displayTabs"
:key="tab"
class="text-xs px-2.5 min-h-[44px] flex items-center justify-center 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="tab === 'favorites' || tab === 'discover' ? (activeTab = tab) : setActiveTab(tab)"
>
{{ tabLabel(tab) }}
</button>
</div>
<!-- Content area -->
<div class="flex-1 min-h-0">
<ErrorBoundary title="Content failed to load">
<!-- Detail views (override tab content) -->
<component
:is="filmRenderer?.panelPlay"
v-if="selectedFilm && filmRenderer?.panelPlay"
:key="selectedFilm.id"
:film="selectedFilm"
@back="closeFilmDetail"
/>
<BookDetail
v-else-if="selectedBook"
:key="selectedBook.id"
:book="selectedBook"
@back="closeBookDetail"
/>
<TVSeriesDetail
v-else-if="selectedTVSeries"
:key="selectedTVSeries.id"
:series="selectedTVSeries"
@back="closeTVSeriesDetail"
/>
<component
:is="songRenderer?.panelPlay"
v-else-if="selectedSong && songRenderer?.panelPlay"
:key="selectedSong.id"
:song="selectedSong"
@back="closeSongDetail"
/>
<PodcastDetail
v-else-if="selectedPodcast"
:key="selectedPodcast.id"
:podcast="selectedPodcast"
@back="closePodcastDetail"
/>
<ImageDetail
v-else-if="selectedImage"
:key="selectedImage.url"
:image="selectedImage"
@back="closeImageDetail"
/>
<PlaceDetail
v-else-if="selectedPlace"
:key="selectedPlace.id"
:place="selectedPlace"
@back="closePlaceDetail"
/>
<RecipeDetail
v-else-if="selectedRecipe"
:key="selectedRecipe.title"
:recipe="selectedRecipe"
@back="closeRecipeDetail"
/>
<AppDetail
v-else-if="selectedApp"
:key="selectedApp.id"
:app="selectedApp"
@back="closeAppDetail"
@select-app="openAppDetail"
/>
<ArticleDetail
v-else-if="selectedArticle"
:key="selectedArticle.url"
:article="selectedArticle"
@back="closeArticleDetail"
/>
<ArticleReader
v-else-if="longFormArticle"
:content="longFormArticle.content"
:title="longFormArticle.title"
@back="closeLongFormArticle"
/>
<PdfViewer
v-else-if="pdfUrl"
:url="pdfUrl.url"
:title="pdfUrl.title"
@back="closePdfViewer"
/>
<MapRenderer
v-else-if="mapPlaces.length > 0"
:places="mapPlaces"
@back="closeMapView"
/>
<!-- Grid views by active tab -->
<component
:is="filmRenderer?.panelPreview"
v-else-if="activeTab === 'film' && filmRenderer?.panelPreview"
:films="panelFilms"
:title="panelTitle"
@select-film="openFilmDetail"
/>
<BookGrid
v-else-if="activeTab === 'book'"
:books="panelBooks"
:title="panelTitle"
@select-book="openBookDetail"
/>
<TVSeriesGrid
v-else-if="activeTab === 'tvshow'"
:series="panelTVSeries"
:title="panelTitle"
@select-series="openTVSeriesDetail"
/>
<component
:is="songRenderer?.panelPreview"
v-else-if="activeTab === 'song' && songRenderer?.panelPreview"
:songs="panelSongs"
:title="panelTitle"
@select-song="openSongDetail"
/>
<PodcastGrid
v-else-if="activeTab === 'podcast'"
:podcasts="panelPodcasts"
:title="panelTitle"
@select-podcast="openPodcastDetail"
/>
<ImageGrid
v-else-if="activeTab === 'image'"
:images="panelImages"
:title="panelTitle"
@select-image="openImageDetail"
/>
<PlaceGrid
v-else-if="activeTab === 'place'"
:places="panelPlaces"
:title="panelTitle"
@select-place="openPlaceDetail"
/>
<RecipeGrid
v-else-if="activeTab === 'recipe'"
:recipes="panelRecipes"
:title="panelTitle"
@select-recipe="openRecipeDetail"
/>
<NewsGrid
v-else-if="activeTab === 'news'"
:articles="panelWebResults"
:title="panelTitle"
:query="panelQuery"
variant="news"
/>
<NewsGrid
v-else-if="activeTab === 'websites'"
:articles="panelWebsites"
:title="panelTitle"
:query="panelQuery"
variant="websites"
/>
<MagazineGrid
v-else-if="activeTab === 'magazine'"
:sections="panelMagazineSections"
:title="panelTitle"
:query="panelQuery"
:hero-image="panelMagazineHeroImage ?? undefined"
/>
<ArchyAppsGrid
v-else-if="activeTab === 'app' && isArchyEmbedded"
/>
<AppsGrid
v-else-if="activeTab === 'app'"
:apps="panelApps"
:title="panelTitle"
@select-app="openAppDetail"
/>
<ProjectGrid
v-else-if="activeTab === 'code'"
/>
<NostrGrid
v-else-if="activeTab === 'nostr'"
/>
<FavoritesGrid
v-else-if="activeTab === 'favorites'"
/>
<DiscoverPanel
v-else-if="activeTab === 'discover'"
/>
</ErrorBoundary>
</div>
</aside>
</Transition>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel, type ContentTab } from '@/composables/useContentPanel'
import { getRendererForContentType } from '@aiui/core'
import BookGrid from './BookGrid.vue'
import BookDetail from './BookDetail.vue'
import TVSeriesGrid from './TVSeriesGrid.vue'
import TVSeriesDetail from './TVSeriesDetail.vue'
import ImageGrid from './ImageGrid.vue'
import ImageDetail from './ImageDetail.vue'
import PlaceGrid from './PlaceGrid.vue'
import PlaceDetail from './PlaceDetail.vue'
import RecipeGrid from './RecipeGrid.vue'
import RecipeDetail from './RecipeDetail.vue'
import PodcastGrid from './PodcastGrid.vue'
import PodcastDetail from './PodcastDetail.vue'
import NewsGrid from './NewsGrid.vue'
import ArticleDetail from './ArticleDetail.vue'
import ArticleReader from '@/components/renderers/ArticleReader.vue'
const PdfViewer = defineAsyncComponent({
loader: () => import('@/components/renderers/PdfViewer.vue'),
loadingComponent: { template: '<div class="flex items-center justify-center h-32"><span class="text-sm text-white/50">Loading PDF viewer...</span></div>' },
})
const MapRenderer = defineAsyncComponent({
loader: () => import('@/components/renderers/MapRenderer.vue'),
loadingComponent: { template: '<div class="flex items-center justify-center h-32"><span class="text-sm text-white/50">Loading map...</span></div>' },
})
import MagazineGrid from './MagazineGrid.vue'
import AppsGrid from './AppsGrid.vue'
import ArchyAppsGrid from './ArchyAppsGrid.vue'
import AppDetail from './AppDetail.vue'
import ProjectGrid from './ProjectGrid.vue'
import NostrGrid from './NostrGrid.vue'
import FavoritesGrid from './FavoritesGrid.vue'
import DiscoverPanel from './DiscoverPanel.vue'
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import { useFavoritesStore } from '@/stores/favorites'
import { useArchy } from '@/composables/useArchy'
// Film and song renderers loaded from plugin registry
const filmRenderer = computed(() => getRendererForContentType('film'))
const songRenderer = computed(() => getRendererForContentType('song'))
const { isDark } = useTheme()
const favoritesStore = useFavoritesStore()
const { isEmbedded: isArchyEmbedded } = useArchy()
const {
panelOpen,
panelFilms,
panelBooks,
panelTVSeries,
panelSongs,
panelPodcasts,
panelWebResults,
panelWebsites,
panelImages,
panelPlaces,
panelRecipes,
panelApps,
panelMagazineSections,
panelMagazineHeroImage,
panelTitle,
panelQuery,
activeTab,
availableTabs,
selectedFilm,
selectedBook,
selectedTVSeries,
selectedSong,
selectedPodcast,
selectedImage,
selectedPlace,
selectedArticle,
selectedRecipe,
selectedApp,
selectedDesignSystemItem,
setActiveTab,
openFilmDetail,
closeFilmDetail,
openBookDetail,
closeBookDetail,
openTVSeriesDetail,
closeTVSeriesDetail,
openSongDetail,
closeSongDetail,
openPodcastDetail,
closePodcastDetail,
openImageDetail,
closeImageDetail,
openPlaceDetail,
closePlaceDetail,
openRecipeDetail,
closeRecipeDetail,
openAppDetail,
closeAppDetail,
closeArticleDetail,
longFormArticle,
closeLongFormArticle,
pdfUrl,
closePdfViewer,
mapPlaces,
closeMapView,
closePanel,
} = useContentPanel()
const hasDetailOpen = computed(() =>
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedImage.value || selectedPlace.value || selectedRecipe.value || selectedArticle.value || selectedApp.value || selectedDesignSystemItem.value || longFormArticle.value || pdfUrl.value || mapPlaces.value.length > 0)
)
const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value < 1024)
const displayTabs = computed(() => {
const tabs = [...availableTabs.value]
if (favoritesStore.items.length > 0 && !tabs.includes('favorites')) {
tabs.push('favorites')
}
if (!tabs.includes('discover')) {
tabs.push('discover')
}
return tabs
})
function onResize() {
windowWidth.value = window.innerWidth
}
const TAB_LABELS: Record<ContentTab, string> = {
film: 'Films',
book: 'Books',
tvshow: 'TV',
image: 'Images',
place: 'Places',
recipe: 'Recipes',
song: 'Music',
podcast: 'Podcasts',
news: 'News',
websites: 'Web',
magazine: 'Brief',
code: 'Code',
'design-system': 'Design',
app: 'Apps',
nostr: 'Nostr',
favorites: 'Favorites',
discover: 'Discover',
prompt: 'Prompt',
}
function tabLabel(tab: ContentTab): string {
return TAB_LABELS[tab] ?? tab
}
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))
</script>
<style scoped>
.panel-enter-active {
transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.panel-leave-active {
transition: all 0.2s ease-in;
}
.panel-enter-from {
opacity: 0;
transform: translateX(20px);
}
.panel-leave-to {
opacity: 0;
transform: translateX(20px);
}
</style>