feat(app): add design system viewer, nostr feed, stop generation, and content refactor

- Design system browser with grid/detail views for tokens and components
- Nostr feed tab with note/article/zap filtering and relay status
- Stop generation button to abort AI streaming mid-response
- Paste & extract content without sending to AI
- Refactor useContentPanel into contentExtraction.ts and contentFiltering.ts
- Banner fallback composable for 3-stage image loading
- Wikipedia and Google Books as fallback image sources
- Loading skeletons with variant-specific shapes
- Mobile UX: auto-switch to content, back button, detail flow
- Project grid with breadcrumb nav and inline creation
- Filesystem Vite plugin for local project browsing
- Magazine text cleanup and song grid polish

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 13:08:32 +00:00
co-authored by Claude Opus 4.6
parent e8fc54cade
commit 00bdc055ba
31 changed files with 3175 additions and 1555 deletions
@@ -156,6 +156,21 @@
</button>
</div>
</div>
<!-- Design System -->
<div class="border-t mt-2 pt-2" :class="isDark ? 'border-white/10' : 'border-black/10'">
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all duration-200 flex items-center gap-2"
:class="isDark
? 'text-white/60 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
@click="openDesignSystem"
>
<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="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
Design System
</button>
</div>
</div>
</Transition>
</Teleport>
@@ -167,6 +182,7 @@ import { ref, computed, watch, nextTick } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useAI } from '@/composables/useAI'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel } from '@/composables/useContentPanel'
defineProps<{
title: string
@@ -250,6 +266,13 @@ function selectModel(providerId: string, modelId: string) {
setModel(modelId)
showModelPicker.value = false
}
const { enterDesignSystemMode } = useContentPanel()
function openDesignSystem() {
enterDesignSystemMode()
showModelPicker.value = false
}
</script>
<style scoped>
+56 -10
View File
@@ -1,7 +1,7 @@
<template>
<div class="p-3 md:p-4">
<div
class="path-glass-bubble rounded-2xl px-4 py-3 flex items-end gap-3 transition-all duration-300"
class="path-glass-bubble rounded-2xl px-4 py-3 flex items-end gap-2 transition-all duration-300"
:class="focused
? isDark
? 'border-white/30'
@@ -19,22 +19,50 @@
: 'text-gray-800 placeholder:text-gray-400'"
@keydown.enter.exact.prevent="send"
@input="autoResize"
@paste="onPaste"
@focus="focused = true"
@blur="focused = false"
/>
<button
:disabled="!canSend"
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200"
:class="canSend
? 'hover:opacity-80 active:scale-95'
: 'opacity-30 cursor-not-allowed'"
aria-label="Send message"
@click="send"
v-if="streaming"
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200 hover:opacity-80 active:scale-95"
aria-label="Stop generation"
@click="$emit('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="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
</button>
<template v-else>
<!-- Extract/contextualize button shown after paste -->
<button
v-if="hasPasted && canSend"
:disabled="!canSend"
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200 hover:opacity-80 active:scale-95"
:class="isDark ? 'text-accent/80' : 'text-accent'"
aria-label="Extract content"
title="Contextualize — extract media without sending to AI"
@click="extract"
>
<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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</button>
<!-- Send button -->
<button
:disabled="!canSend"
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200"
:class="canSend
? 'hover:opacity-80 active:scale-95'
: 'opacity-30 cursor-not-allowed'"
aria-label="Send message"
@click="send"
>
<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 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
</button>
</template>
</div>
</div>
</template>
@@ -46,21 +74,26 @@ import { useTheme } from '@/composables/useTheme'
const props = withDefaults(
defineProps<{
disabled?: boolean
streaming?: boolean
placeholder?: string
}>(),
{
disabled: false,
streaming: false,
placeholder: 'Message AIUI...',
}
)
const emit = defineEmits<{
send: [text: string]
extract: [text: string]
stop: []
}>()
const { isDark } = useTheme()
const text = ref('')
const focused = ref(false)
const hasPasted = ref(false)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const canSend = computed(() => text.value.trim().length > 0 && !props.disabled)
@@ -69,9 +102,22 @@ function send() {
if (!canSend.value) return
emit('send', text.value.trim())
text.value = ''
hasPasted.value = false
nextTick(autoResize)
}
function extract() {
if (!canSend.value) return
emit('extract', text.value.trim())
text.value = ''
hasPasted.value = false
nextTick(autoResize)
}
function onPaste() {
hasPasted.value = true
}
function autoResize() {
const el = textareaRef.value
if (!el) return
@@ -47,8 +47,11 @@
<ChatInput
:disabled="isStreaming"
:streaming="isStreaming"
:placeholder="isStreaming ? 'Waiting for response...' : 'Message AIUI...'"
@send="handleSend"
@extract="handleExtract"
@stop="handleStop"
/>
</div>
</template>
@@ -85,7 +88,7 @@ defineEmits<{
}>()
const chatStore = useChatStore()
const { sendMessage } = useAI()
const { sendMessage, stopGeneration } = useAI()
const { isDark } = useTheme()
const { updatePanelFromText, panelOpen, activeTab, availableTabs, setActiveTab } = useContentPanel()
import { useCodeContext } from '@/composables/useCodeContext'
@@ -122,6 +125,18 @@ function handleNewChat() {
chatStore.createConversation()
}
function handleStop() {
stopGeneration()
}
function handleExtract(text: string) {
// Run content extraction without sending to AI
const convId = chatStore.activeConversationId ?? chatStore.createConversation()
chatStore.addMessage(convId, { role: 'user', content: `[Extract] ${text.slice(0, 80)}${text.length > 80 ? '…' : ''}` })
chatStore.addMessage(convId, { role: 'assistant', content: text })
updatePanelFromText(text, '', [])
}
async function handleSend(text: string) {
// Command handling
const trimmed = text.trim().toLowerCase()
@@ -148,6 +163,20 @@ async function handleSend(text: string) {
return
}
if (trimmed === '/nostr') {
panelOpen.value = true
if (!availableTabs.value.includes('nostr')) {
availableTabs.value = [...availableTabs.value, 'nostr']
}
setActiveTab('nostr')
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, { role: 'user', content: '/nostr' })
chatStore.addMessage(convId, { role: 'assistant', content: 'Nostr feed opened. Browse notes, articles, and zaps from the network.' })
}
return
}
if (trimmed === '/code exit' || trimmed === '/exit') {
if (codeContext.isCodeMode.value) {
codeContext.exitCodeMode()
@@ -3,16 +3,16 @@
<div class="relative w-full overflow-hidden">
<div class="w-full aspect-[16/7] flex items-center justify-center overflow-hidden bg-black/20">
<img
v-if="coverSrc"
:src="coverSrc"
v-if="bannerSrc"
:src="bannerSrc"
:alt="book.title"
class="w-full h-full object-cover object-center block"
@error="coverFailed = true"
@error="onBannerError"
/>
<div
v-else
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackCover})` }"
class="w-full h-full"
:style="{ background: fallbackGradient }"
/>
</div>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
@@ -111,27 +111,27 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { computed } from 'vue'
import type { Book } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateBookCoverFallback, fetchBookCover } from '@/composables/useImageFallback'
import { useBannerFallback } from '@/composables/useBannerFallback'
import { fetchBookImage } from '@/composables/useImageFallback'
const props = defineProps<{ book: Book }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const coverFailed = ref(false)
const fetchedCover = ref<string | null>(null)
const coverSrc = computed(() => {
if (coverFailed.value) return null
return props.book.coverUrl || fetchedCover.value
const { bannerSrc, fallbackGradient, onBannerError } = useBannerFallback({
primaryUrls: () => [props.book.coverUrl],
apiFetch: async () => {
const url = await fetchBookImage(props.book.title, props.book.author)
return { posterUrl: url, backdropUrl: null }
},
title: () => props.book.title,
gradientSeed: () => props.book.title + (props.book.author ?? ''),
})
const fallbackCover = computed(() =>
generateBookCoverFallback(props.book.title, props.book.author)
)
const q = computed(() =>
`${props.book.title} ${props.book.author}`.trim().replace(/\s+/g, '+'),
)
@@ -155,10 +155,4 @@ function sourceIcon(type: string): string {
return icons[type] ?? '📚'
}
onMounted(() => {
if (props.book.coverUrl) return
fetchBookCover(props.book.title, props.book.author).then((url) => {
if (url) fetchedCover.value = url
})
})
</script>
@@ -5,131 +5,73 @@
: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>
/>
<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"
: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>
/>
<ProjectGrid
v-else-if="activeTab === 'code'"
>
<template #header-actions>
<slot name="header-actions">
<CloseButton @click="$emit('close')" />
</slot>
</template>
</ProjectGrid>
:is-wide-desktop="isWideDesktop"
:is-mobile="isMobile"
/>
<DesignSystemGrid
v-else-if="activeTab === 'design-system'"
/>
<NostrGrid
v-else-if="activeTab === 'nostr'"
/>
</div>
</template>
@@ -147,11 +89,14 @@ 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'
import DesignSystemGrid from './DesignSystemGrid.vue'
import NostrGrid from './NostrGrid.vue'
defineProps<{
activeTab: ContentTab
isWideDesktop?: boolean
isMobile?: boolean
panelFilms: Film[]
panelBooks: Book[]
panelTVSeries: TVSeries[]
@@ -167,8 +112,6 @@ defineProps<{
panelQuery: string
}>()
defineEmits<{ close: [] }>()
const {
openFilmDetail,
openBookDetail,
@@ -140,6 +140,9 @@
<ProjectGrid
v-else-if="activeTab === 'code'"
/>
<NostrGrid
v-else-if="activeTab === 'nostr'"
/>
</div>
</aside>
</Transition>
@@ -163,6 +166,7 @@ import NewsGrid from './NewsGrid.vue'
import ArticleDetail from './ArticleDetail.vue'
import MagazineGrid from './MagazineGrid.vue'
import ProjectGrid from './ProjectGrid.vue'
import NostrGrid from './NostrGrid.vue'
const { isDark } = useTheme()
const {
@@ -186,6 +190,7 @@ const {
selectedSong,
selectedPodcast,
selectedArticle,
selectedDesignSystemItem,
setActiveTab,
openFilmDetail,
closeFilmDetail,
@@ -202,7 +207,7 @@ const {
} = useContentPanel()
const hasDetailOpen = computed(() =>
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedArticle.value)
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedArticle.value || selectedDesignSystemItem.value)
)
const windowWidth = ref(window.innerWidth)
@@ -224,6 +229,8 @@ const TAB_LABELS: Record<ContentTab, string> = {
websites: 'Web',
magazine: 'Brief',
code: 'Code',
'design-system': 'Design',
nostr: 'Nostr',
}
function tabLabel(tab: ContentTab): string {
@@ -22,7 +22,7 @@
<slot name="header-actions" />
</div>
</div>
<LoadingFilmGrid :count="12" />
<LoadingContentGrid :variant="skeletonVariant" :count="skeletonCount" />
</div>
<div
@@ -71,7 +71,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import LoadingFilmGrid from './LoadingFilmGrid.vue'
import LoadingContentGrid from './LoadingContentGrid.vue'
const props = withDefaults(
defineProps<{
@@ -94,6 +94,19 @@ const contextLabel = computed(() => {
if (props.contextType === 'magazine') return 'Brief'
return 'Content'
})
const skeletonVariant = computed<'poster' | 'square' | 'list' | 'magazine'>(() => {
if (['song', 'podcast', 'image'].includes(props.contextType)) return 'square'
if (['news', 'websites'].includes(props.contextType)) return 'list'
if (props.contextType === 'magazine') return 'magazine'
return 'poster'
})
const skeletonCount = computed(() => {
if (props.contextType === 'magazine') return 6
if (['news', 'websites'].includes(props.contextType)) return 6
return 12
})
</script>
<style scoped>
@@ -0,0 +1,439 @@
<template>
<div class="h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<!-- Header -->
<div class="shrink-0 px-4 py-3 flex items-center gap-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="w-7 h-7 rounded-lg path-glass-icon flex items-center justify-center transition-colors shrink-0"
:class="isDark ? 'hover:bg-white/10' : 'hover:bg-black/5'"
@click="$emit('back')"
>
<svg class="w-3.5 h-3.5" :class="isDark ? 'text-white/70' : 'text-gray-500'"
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="min-w-0 flex-1">
<h2 class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ item.name }}
</h2>
<p class="text-[10px]"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
{{ categoryLabel }}
</p>
</div>
<button
class="text-[10px] px-2 py-1 rounded-md transition-colors"
:class="copied
? 'bg-emerald-500/20 text-emerald-400'
: isDark
? 'bg-white/5 text-white/50 hover:bg-white/10'
: 'bg-black/5 text-gray-500 hover:bg-black/10'"
@click="copyCode"
>
{{ copied ? 'Copied' : 'Copy' }}
</button>
</div>
<div class="p-4 space-y-4">
<!-- Description -->
<p class="text-sm leading-relaxed"
:class="isDark ? 'text-white/60' : 'text-gray-600'">
{{ item.description }}
</p>
<!-- Live preview -->
<div>
<h4 class="text-[10px] uppercase tracking-[0.2em] font-semibold mb-2"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
Preview
</h4>
<div class="rounded-xl p-4 overflow-hidden"
:class="isDark ? 'bg-white/[0.03] border border-white/10' : 'bg-black/[0.02] border border-black/10'">
<!-- Color preview -->
<div v-if="item.category === 'colors'" class="space-y-2">
<div class="h-12 rounded-lg border"
:class="isDark ? 'border-white/10' : 'border-black/10'"
:style="{ background: extractColorValue(item.code) }" />
<p class="text-[10px] font-mono text-center"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
{{ extractColorValue(item.code) }}
</p>
</div>
<!-- Typography preview -->
<div v-else-if="item.category === 'typography'" class="space-y-2">
<p class="text-2xl font-bold"
:class="isDark ? 'text-white/90' : 'text-gray-900'"
:style="fontStyle">
Aa Bb Cc 123
</p>
<p class="text-sm"
:class="isDark ? 'text-white/60' : 'text-gray-600'"
:style="fontStyle">
The quick brown fox jumps over the lazy dog.
</p>
</div>
<!-- Spacing preview -->
<div v-else-if="item.category === 'spacing'" class="flex items-end gap-2">
<div v-for="(size, i) in [4, 8, 12, 16, 20, 24, 32]" :key="i"
class="bg-accent/30 rounded-sm flex items-center justify-center"
:style="{ width: `${size}px`, height: `${size}px` }">
<span v-if="size >= 16" class="text-[7px] text-accent font-mono">{{ size }}</span>
</div>
</div>
<!-- Component preview (rendered as styled blocks) -->
<div v-else class="space-y-2">
<!-- Glass button preview -->
<div v-if="item.id === 'atom-glass-btn'" class="flex gap-2">
<button class="glass-button text-sm">Action</button>
</div>
<div v-else-if="item.id === 'atom-glass-btn-sm'" class="flex gap-2">
<button class="glass-button-sm text-xs">Small</button>
</div>
<div v-else-if="item.id === 'atom-gradient-btn'" class="flex gap-2">
<button class="gradient-button text-sm">Primary Action</button>
</div>
<div v-else-if="item.id === 'atom-icon-btn'" class="flex gap-3">
<button class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center"
:class="isDark ? 'text-white/70' : 'text-gray-500'">
<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="M12 4v16m8-8H4" />
</svg>
</button>
<button class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center"
:class="isDark ? 'text-white/70' : 'text-gray-500'">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
<button class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center"
:class="isDark ? 'text-white/70' : 'text-gray-500'">
<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>
<div v-else-if="item.id === 'atom-badge'" class="flex flex-wrap gap-1.5">
<span class="text-[10px] px-2 py-1 rounded-md font-medium"
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'">Science Fiction</span>
<span class="text-[10px] px-2 py-1 rounded-md font-medium"
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'">Drama</span>
<span class="text-[10px] px-2 py-1 rounded-md font-medium"
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'">Thriller</span>
</div>
<div v-else-if="item.id === 'mol-glass-card'">
<div class="glass-card p-4">
<h3 class="text-sm font-semibold mb-1" :class="isDark ? 'text-white/90' : 'text-gray-900'">Glass Card</h3>
<p class="text-xs" :class="isDark ? 'text-white/60' : 'text-gray-500'">Content with frosted glass background and subtle border.</p>
</div>
</div>
<!-- Nav tab preview -->
<div v-else-if="item.id === 'atom-nav-tab'" class="flex gap-1.5">
<button class="text-[10px] px-2.5 py-1 rounded-md font-medium bg-accent/20 text-accent">Films</button>
<button class="text-[10px] px-2.5 py-1 rounded-md font-medium"
:class="isDark ? 'bg-white/5 text-white/50' : 'bg-black/5 text-gray-500'">Songs</button>
<button class="text-[10px] px-2.5 py-1 rounded-md font-medium"
:class="isDark ? 'bg-white/5 text-white/50' : 'bg-black/5 text-gray-500'">Podcasts</button>
</div>
<!-- Text input preview -->
<div v-else-if="item.id === 'atom-input'">
<input
class="w-full px-3 py-2 rounded-lg text-xs outline-none transition-colors"
:class="isDark
? 'bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10'
: 'bg-black/5 text-gray-800 placeholder:text-gray-400 focus:bg-black/10'"
placeholder="Search..."
readonly
/>
</div>
<!-- Scrollbar preview -->
<div v-else-if="item.id === 'atom-scrollbar'" class="space-y-2">
<div class="h-16 overflow-y-auto rounded-lg px-3 py-2"
:class="isDark ? 'bg-white/5' : 'bg-black/5'"
style="scrollbar-width: thin;">
<p v-for="n in 8" :key="n" class="text-[10px] py-0.5"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
Scrollable content line {{ n }}
</p>
</div>
<p class="text-[10px] text-center" :class="isDark ? 'text-white/30' : 'text-gray-400'">
4px wide, translucent thumb
</p>
</div>
<!-- Gradient card preview -->
<div v-else-if="item.id === 'mol-gradient-card'">
<div class="gradient-card p-4 rounded-2xl">
<h3 class="text-sm font-semibold mb-1 text-white">Featured</h3>
<p class="text-xs text-white/70">Gradient background card for highlights.</p>
</div>
</div>
<!-- Source link row preview -->
<div v-else-if="item.id === 'mol-source-link'" class="space-y-1.5">
<div class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark ? 'bg-white/5' : 'bg-black/5'">
<div class="flex items-center gap-2.5">
<span class="text-sm">🎬</span>
<div>
<p class="text-xs font-medium" :class="isDark ? 'text-white/80' : 'text-gray-800'">Netflix</p>
<p class="text-[10px]" :class="isDark ? 'text-white/30' : 'text-gray-400'">Stream now</p>
</div>
</div>
<svg class="w-3.5 h-3.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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
</div>
<!-- Banner hero preview -->
<div v-else-if="item.id === 'mol-banner-hero'">
<div class="relative w-full aspect-[16/7] rounded-lg overflow-hidden">
<div class="absolute inset-0"
:style="{ background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)' }" />
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
<div class="absolute bottom-0 left-0 p-3">
<h3 class="text-sm font-bold text-white/90">Banner Title</h3>
<p class="text-[10px] text-white/50">Subtitle text</p>
</div>
</div>
</div>
<!-- Cover card preview -->
<div v-else-if="item.id === 'mol-cover-card'" class="flex gap-2">
<div v-for="n in 3" :key="n"
class="flex-1 rounded-xl overflow-hidden">
<div class="aspect-[2/3] relative"
:style="{ background: `linear-gradient(${120 * n}deg, ${['#2d1b69','#1b3a4b','#3b1b2b'][n-1]}, ${['#1a0a3e','#0a2030','#200a1a'][n-1]})` }">
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div class="absolute bottom-0 left-0 right-0 p-1.5">
<p class="text-[9px] text-white/80 font-medium truncate">{{ ['Film', 'Album', 'Series'][n-1] }}</p>
</div>
</div>
</div>
</div>
<!-- Chat bubble preview -->
<div v-else-if="item.id === 'org-chat-bubble'" class="space-y-2">
<div class="flex justify-end">
<div class="max-w-[80%] px-3 py-2 rounded-2xl text-[11px]"
:class="isDark ? 'bg-white/10 text-white/90' : 'bg-black/10 text-gray-800'">
What films should I watch?
</div>
</div>
<div class="flex justify-start">
<div class="max-w-[80%] px-3 py-2 text-[11px]"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
Here are some great picks from your library...
</div>
</div>
</div>
<!-- Content panel preview -->
<div v-else-if="item.id === 'org-content-panel'" class="space-y-2">
<div class="flex gap-1 pb-1.5"
:style="isDark ? 'border-bottom: 1px solid rgba(255,255,255,0.08)' : 'border-bottom: 1px solid rgba(0,0,0,0.06)'">
<span class="text-[9px] px-2 py-0.5 rounded font-medium bg-accent/20 text-accent">Films</span>
<span class="text-[9px] px-2 py-0.5 rounded font-medium"
:class="isDark ? 'text-white/40' : 'text-gray-400'">Songs</span>
<span class="text-[9px] px-2 py-0.5 rounded font-medium"
:class="isDark ? 'text-white/40' : 'text-gray-400'">Books</span>
</div>
<div class="grid grid-cols-3 gap-1">
<div v-for="n in 6" :key="n" class="aspect-[2/3] rounded-md"
:class="isDark ? 'bg-white/5' : 'bg-black/5'" />
</div>
</div>
<!-- Detail view preview -->
<div v-else-if="item.id === 'org-detail-view'" class="space-y-2">
<div class="relative aspect-[16/7] rounded-lg overflow-hidden"
:class="isDark ? 'bg-white/5' : 'bg-black/5'">
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div class="absolute top-1.5 left-1.5 w-4 h-4 rounded-md flex items-center justify-center"
:class="isDark ? 'bg-white/10' : 'bg-black/10'">
<svg class="w-2.5 h-2.5" :class="isDark ? 'text-white/60' : 'text-gray-500'"
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>
</div>
<div class="absolute bottom-1 left-2">
<p class="text-[10px] font-bold text-white/90">Title</p>
<p class="text-[8px] text-white/50">Meta</p>
</div>
</div>
<div class="space-y-1 px-1">
<div class="h-1.5 rounded-full w-full" :class="isDark ? 'bg-white/5' : 'bg-black/5'" />
<div class="h-1.5 rounded-full w-3/4" :class="isDark ? 'bg-white/5' : 'bg-black/5'" />
</div>
</div>
<!-- Magazine grid preview -->
<div v-else-if="item.id === 'org-magazine'">
<div class="grid grid-cols-2 gap-px rounded-lg overflow-hidden"
:class="isDark ? 'bg-white/[0.12]' : 'bg-black/[0.08]'">
<div class="col-span-2 px-3 py-3"
:class="isDark ? 'bg-[#0a0a0a]' : 'bg-white'">
<p class="text-[7px] uppercase tracking-[0.3em] mb-0.5"
:class="isDark ? 'text-white/30' : 'text-gray-400'">Editorial</p>
<p class="text-[11px] font-serif font-bold"
:class="isDark ? 'text-white/90' : 'text-gray-900'">Hero Headline</p>
</div>
<div class="px-2 py-2" :class="isDark ? 'bg-[#0a0a0a]' : 'bg-white'">
<p class="text-[9px] font-serif font-bold"
:class="isDark ? 'text-white/80' : 'text-gray-800'">Half Tile</p>
</div>
<div class="px-2 py-2" :class="isDark ? 'bg-[#0a0a0a]' : 'bg-white'">
<p class="text-[9px] font-serif font-bold"
:class="isDark ? 'text-white/80' : 'text-gray-800'">Half Tile</p>
</div>
</div>
</div>
<!-- Nostr note preview -->
<div v-else-if="item.id === 'org-nostr-note'">
<div class="p-3 rounded-xl"
:class="isDark ? 'bg-white/[0.03] border border-white/5' : 'bg-black/[0.02] border border-black/5'">
<div class="flex items-start gap-2.5">
<div class="w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0"
style="background: rgba(168, 85, 247, 0.2); color: rgba(168, 85, 247, 0.8);">
F
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="text-[11px] font-semibold" :class="isDark ? 'text-white/80' : 'text-gray-800'">fiatjaf</span>
<span class="text-[9px]" :class="isDark ? 'text-white/25' : 'text-gray-300'">2h</span>
</div>
<p class="text-[10px] mt-0.5 leading-relaxed"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
Nostr is the simplest open protocol...
</p>
<div class="flex gap-3 mt-1.5">
<span class="text-[9px]" :class="isDark ? 'text-white/25' : 'text-gray-300'">3 replies</span>
<span class="text-[9px] text-amber-500/70">21000 sats</span>
</div>
</div>
</div>
</div>
</div>
<!-- Fade up animation preview -->
<div v-else-if="item.id === 'anim-fade-up'" class="flex flex-col items-center gap-2">
<div :key="fadeUpKey" class="animate-fade-up px-4 py-2 rounded-lg text-xs font-medium"
:class="isDark ? 'bg-white/10 text-white/70' : 'bg-black/10 text-gray-600'">
Fade Up (900ms)
</div>
<button class="text-[10px] px-2 py-0.5 rounded transition-colors"
:class="isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'"
@click="fadeUpKey++">
Replay
</button>
</div>
<!-- Scale in animation preview -->
<div v-else-if="item.id === 'anim-scale-in'" class="flex flex-col items-center gap-2">
<div :key="scaleInKey" class="animate-scale-in px-4 py-2 rounded-lg text-xs font-medium"
:class="isDark ? 'bg-white/10 text-white/70' : 'bg-black/10 text-gray-600'">
Scale In (250ms)
</div>
<button class="text-[10px] px-2 py-0.5 rounded transition-colors"
:class="isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'"
@click="scaleInKey++">
Replay
</button>
</div>
<!-- Generic preview fallback -->
<div v-else class="text-center py-4">
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
See code below for usage pattern
</p>
</div>
</div>
</div>
</div>
<!-- Used In -->
<div v-if="item.usedIn">
<h4 class="text-[10px] uppercase tracking-[0.2em] font-semibold mb-2"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
Used In
</h4>
<div class="rounded-xl px-3 py-2.5"
:class="isDark ? 'bg-white/[0.03] border border-white/10' : 'bg-black/[0.02] border border-black/10'">
<p class="text-xs leading-relaxed"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ item.usedIn }}
</p>
</div>
</div>
<!-- Code block -->
<div>
<h4 class="text-[10px] uppercase tracking-[0.2em] font-semibold mb-2"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
Code
</h4>
<pre class="rounded-xl p-4 text-xs leading-relaxed font-mono overflow-x-auto"
:class="isDark
? 'bg-black/40 text-white/70 border border-white/10'
: 'bg-gray-50 text-gray-700 border border-gray-200'">{{ item.code }}</pre>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import type { DesignSystemItem } from '@/composables/useContentPanel'
const props = defineProps<{ item: DesignSystemItem }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const copied = ref(false)
const fadeUpKey = ref(0)
const scaleInKey = ref(0)
const categoryLabels: Record<string, string> = {
colors: 'Colors',
typography: 'Typography',
spacing: 'Spacing',
atoms: 'Atoms',
molecules: 'Molecules',
organisms: 'Organisms',
}
const categoryLabel = computed(() => categoryLabels[props.item.category] ?? props.item.category)
const fontStyle = computed(() => {
if (props.item.id === 'type-mono') return { fontFamily: 'Menlo, Monaco, "Courier New", monospace' }
if (props.item.id === 'type-serif') return { fontFamily: 'Georgia, "Times New Roman", Times, serif' }
return { fontFamily: 'Inter, system-ui, -apple-system, sans-serif' }
})
function extractColorValue(code: string): string {
const match = /(?:background-color|color|background):\s*([^;]+)/i.exec(code)
if (!match) return '#333'
return match[1].trim()
}
async function copyCode() {
try {
await navigator.clipboard.writeText(props.item.code)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch { /* ignore */ }
}
</script>
@@ -0,0 +1,174 @@
<template>
<div class="flex flex-col h-full">
<div class="shrink-0 px-4 py-3 flex items-center justify-between gap-2"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
<span class="text-sm font-semibold"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
Design System
</span>
<p class="text-[10px]"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredItems.length }} items
</p>
</div>
<!-- Category filter -->
<div class="shrink-0 px-4 py-2 flex gap-1.5 overflow-x-auto scrollbar-hide">
<button
v-for="cat in categories"
:key="cat.id"
class="text-[10px] px-2.5 py-1 rounded-md font-medium whitespace-nowrap transition-colors"
:class="activeCategory === cat.id
? 'bg-accent/20 text-accent'
: isDark
? 'bg-white/5 text-white/50 hover:bg-white/10'
: 'bg-black/5 text-gray-500 hover:bg-black/10'"
@click="activeCategory = cat.id"
>
{{ cat.label }}
</button>
</div>
<!-- Items grid -->
<div class="flex-1 overflow-y-auto px-4 py-3">
<div class="grid grid-cols-2 gap-2">
<button
v-for="item in filteredItems"
:key="item.id"
class="text-left p-3 rounded-xl transition-colors group cursor-pointer"
:class="isDark
? 'bg-white/[0.03] hover:bg-white/[0.07]'
: 'bg-black/[0.02] hover:bg-black/[0.05]'"
@click="selectItem(item)"
>
<!-- Preview swatch for colors -->
<div v-if="item.category === 'colors' && item.preview === 'inline'"
class="h-8 rounded-md mb-2 border"
:class="isDark ? 'border-white/10' : 'border-black/10'"
:style="{ background: extractColorValue(item.code) }" />
<!-- Preview for spacing -->
<div v-else-if="item.category === 'spacing' && item.preview === 'inline'"
class="h-8 flex items-end gap-0.5 mb-2">
<div class="bg-accent/40 rounded-sm" style="width: 4px; height: 30%" />
<div class="bg-accent/40 rounded-sm" style="width: 4px; height: 50%" />
<div class="bg-accent/40 rounded-sm" style="width: 4px; height: 70%" />
<div class="bg-accent/40 rounded-sm" style="width: 4px; height: 100%" />
</div>
<!-- Generic icon for components -->
<div v-else class="h-8 flex items-center mb-2">
<svg class="w-5 h-5 transition-colors"
:class="isDark ? 'text-white/20 group-hover:text-white/40' : 'text-black/15 group-hover:text-black/30'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="item.category === 'atoms'" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
<path v-else-if="item.category === 'molecules'" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
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" />
<path v-else-if="item.category === 'organisms'" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
</div>
<h3 class="text-xs font-semibold leading-tight mb-0.5"
:class="isDark ? 'text-white/80' : 'text-gray-800'">
{{ item.name }}
</h3>
<p class="text-[10px] leading-snug line-clamp-2"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
{{ item.description }}
</p>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel, type DesignSystemItem } from '@/composables/useContentPanel'
const { isDark } = useTheme()
const { openDesignSystemItem } = useContentPanel()
const activeCategory = ref<string>('all')
const categories = [
{ id: 'all', label: 'All' },
{ id: 'colors', label: 'Colors' },
{ id: 'typography', label: 'Typography' },
{ id: 'spacing', label: 'Spacing' },
{ id: 'atoms', label: 'Atoms' },
{ id: 'molecules', label: 'Molecules' },
{ id: 'organisms', label: 'Organisms' },
]
const items: DesignSystemItem[] = [
// Colors
{ id: 'color-bg', name: 'Background', category: 'colors', preview: 'inline', description: 'Primary app background', code: 'background-color: #0a0a0a;\n/* Tailwind: bg-[#0a0a0a] */', usedIn: 'ChatPage, all panels, base layout' },
{ id: 'color-accent', name: 'Accent / Bitcoin', category: 'colors', preview: 'inline', description: 'Primary action color, Bitcoin orange', code: 'color: #F7931A;\n/* Tailwind: text-accent */', usedIn: 'Gradient buttons, active tabs, zap counts, CTA elements' },
{ id: 'color-primary', name: 'Primary', category: 'colors', preview: 'inline', description: 'Primary neutral tone', code: 'color: #606060;\n/* Tailwind: text-primary */', usedIn: 'Secondary text, borders, muted elements' },
{ id: 'color-surface', name: 'Glass Surface', category: 'colors', preview: 'inline', description: 'Glass morphism panel background', code: 'background: rgba(0, 0, 0, 0.35);\nbackdrop-filter: blur(18px);\nborder: 1px solid rgba(255, 255, 255, 0.18);\n/* Tailwind: .glass */', usedIn: 'ChatInput, ContentPanel, all overlay panels' },
{ id: 'color-text-scale', name: 'Text Opacity Scale', category: 'colors', preview: 'inline', description: '/25 placeholder, /40 muted, /60 secondary, /80 body, /90 emphasis', code: '/* Text opacity scale */\n.placeholder { color: rgba(255,255,255, 0.25); }\n.muted { color: rgba(255,255,255, 0.40); }\n.secondary { color: rgba(255,255,255, 0.60); }\n.body { color: rgba(255,255,255, 0.80); }\n.emphasis { color: rgba(255,255,255, 0.90); }\n.heading { color: rgba(255,255,255, 0.96); }', usedIn: 'Every component — consistent hierarchy across the system' },
// Typography
{ id: 'type-body', name: 'Body Font', category: 'typography', description: 'Inter / system-ui for all body text', code: 'font-family: Inter, system-ui, -apple-system, sans-serif;\n/* Applied globally */', usedIn: 'Global default — ChatMessage, grids, detail views' },
{ id: 'type-mono', name: 'Monospace Font', category: 'typography', description: 'Menlo / Monaco for code and IDs', code: 'font-family: Menlo, Monaco, "Courier New", monospace;\n/* Tailwind: font-mono */', usedIn: 'CodeDetail, conversation IDs, relay URLs, metadata' },
{ id: 'type-serif', name: 'Serif Font', category: 'typography', description: 'Georgia for magazine/editorial layouts', code: 'font-family: Georgia, "Times New Roman", Times, serif;\n/* Used in MagazineGrid, AI Brief */', usedIn: 'MagazineGrid, MagazineSectionDetail, AI Brief' },
{ id: 'type-sizes', name: 'Text Sizes', category: 'typography', description: 'Compact scale: 10px labels to 2xl headings', code: '/* Key sizes used */\ntext-[10px] /* labels, metadata */\ntext-xs /* 12px - secondary text */\ntext-sm /* 14px - body text */\ntext-base /* 16px - primary text */\ntext-lg /* 18px - section headings */\ntext-xl /* 20px - page headings */\ntext-2xl /* 24px - hero text */', usedIn: 'Globally — see specific usage in each size bracket' },
// Spacing
{ id: 'space-grid', name: '4px Grid', category: 'spacing', preview: 'inline', description: 'All spacing follows a 4px base grid', code: '/* 4px grid system */\n1 = 4px /* micro gap */\n2 = 8px /* tight gap */\n3 = 12px /* small padding */\n4 = 16px /* standard padding */\n5 = 20px /* section padding */\n6 = 24px /* large gap */\n8 = 32px /* section spacing */\n12 = 48px /* large sections */', usedIn: 'Every layout — padding, margins, gaps between elements' },
{ id: 'space-radius', name: 'Border Radius', category: 'spacing', preview: 'inline', description: 'Rounded corners from subtle to full', code: '/* Border radius scale */\nrounded-md /* 6px - badges, tags */\nrounded-lg /* 8px - buttons, inputs */\nrounded-xl /* 12px - cards, panels */\nrounded-2xl /* 16px - large panels */\nrounded-full /* pill buttons */', usedIn: 'Badges (md), buttons (lg), cards (xl), panels (2xl)' },
// Atoms
{ id: 'atom-glass-btn', name: 'Glass Button', category: 'atoms', description: '48px height, glass morphism background', code: '<button class="glass-button">\n Action\n</button>\n\n/* glass-button:\n height: 48px\n background: rgba(0,0,0,0.6)\n backdrop-filter: blur(18px)\n border-radius: 12px\n border: 1px solid rgba(255,255,255,0.12)\n*/', usedIn: 'ChatInput send, modal actions, primary controls' },
{ id: 'atom-glass-btn-sm', name: 'Glass Button Small', category: 'atoms', description: 'Compact glass button variant', code: '<button class="glass-button-sm">\n Small\n</button>\n\n/* Compact variant of glass-button */', usedIn: 'ChatInput send/stop buttons, inline actions' },
{ id: 'atom-gradient-btn', name: 'Gradient Button', category: 'atoms', description: 'Primary CTA with accent gradient', code: '<button class="gradient-button">\n Primary Action\n</button>\n\n/* gradient-button:\n background: linear-gradient(135deg, #F7931A, #e8850f)\n height: 48px\n border-radius: 12px\n font-weight: 600\n*/', usedIn: 'Primary CTAs, onboarding, confirmation dialogs' },
{ id: 'atom-icon-btn', name: 'Icon Button', category: 'atoms', description: 'Path glass icon, 32-36px square', code: '<button class="w-9 h-9 rounded-xl path-glass-icon\n flex items-center justify-center">\n <svg class="w-4 h-4" ...>\n</button>\n\n/* path-glass-icon:\n background: transparent\n transition: colors\n hover: bg-white/10\n*/', usedIn: 'ChatHeader toolbar, detail back buttons, close buttons' },
{ id: 'atom-badge', name: 'Genre Badge', category: 'atoms', description: 'Tiny pill badge for tags/genres', code: '<span class="text-[10px] px-2 py-1 rounded-md\n font-medium bg-white/10 text-white/60">\n Science Fiction\n</span>', usedIn: 'FilmGrid, SongGrid, BookGrid, TVSeriesGrid genre filters' },
{ id: 'atom-nav-tab', name: 'Nav Tab', category: 'atoms', description: 'Content panel tab with active state', code: '<button class="nav-tab-active">\n Films\n</button>\n\n/* Active: accent underline\n Inactive: text-white/50 hover:text-white\n Transition: 200ms */', usedIn: 'ContentPanel tab bar, mobile content tab filters' },
{ id: 'atom-input', name: 'Text Input', category: 'atoms', description: 'Search/filter input field', code: '<input\n class="w-full px-3 py-2 rounded-lg text-xs\n outline-none transition-colors\n bg-white/5 text-white/80\n placeholder:text-white/25\n focus:bg-white/10"\n placeholder="Search..."\n/>', usedIn: 'All grid search bars, ProjectGrid new project' },
{ id: 'atom-scrollbar', name: 'Custom Scrollbar', category: 'atoms', description: 'Thin translucent scrollbar for scroll areas', code: '.custom-scrollbar::-webkit-scrollbar {\n width: 4px;\n}\n.custom-scrollbar::-webkit-scrollbar-thumb {\n background: rgba(255,255,255, 0.1);\n border-radius: 2px;\n}\n/* Also: .scrollbar-hide hides completely */', usedIn: 'Content grids, chat message list, file trees' },
// Molecules
{ id: 'mol-glass-card', name: 'Glass Card', category: 'molecules', description: 'Frosted glass card with border', code: '<div class="glass-card">\n <h3>Title</h3>\n <p>Content</p>\n</div>\n\n/* glass-card:\n background: rgba(0,0,0,0.65)\n backdrop-filter: blur(18px)\n border: 1px solid rgba(255,255,255,0.12)\n border-radius: 16px\n padding: 16px\n*/', usedIn: 'ChatWindow container, content panel wrapper' },
{ id: 'mol-gradient-card', name: 'Gradient Card', category: 'molecules', description: 'Card with gradient background', code: '<div class="gradient-card">\n <h3>Featured</h3>\n <p>Content</p>\n</div>\n\n/* gradient-card:\n background: linear-gradient(135deg, ...)\n border-radius: 16px\n*/', usedIn: 'Featured content highlights, promotional sections' },
{ id: 'mol-source-link', name: 'Source Link Row', category: 'molecules', description: 'Icon + label + external link arrow', code: '<a class="flex items-center justify-between\n p-3 rounded-xl bg-white/5\n hover:bg-white/10 transition-colors">\n <div class="flex items-center gap-2.5">\n <span class="text-sm">icon</span>\n <div>\n <p class="text-xs font-medium\n text-white/80">Name</p>\n <p class="text-[10px]\n text-white/30">Description</p>\n </div>\n </div>\n <svg><!-- external link icon --></svg>\n</a>', usedIn: 'FilmDetail, SongDetail, PodcastDetail sources' },
{ id: 'mol-banner-hero', name: 'Banner Hero', category: 'molecules', description: 'Aspect 16/7 image with gradient overlay', code: '<div class="relative w-full aspect-[16/7]\n overflow-hidden">\n <img :src="url" class="absolute inset-0\n w-full h-full object-cover" />\n <div class="absolute inset-0\n bg-gradient-to-t from-black/80\n via-black/30 to-transparent" />\n <div class="absolute bottom-0 p-4">\n <h2 class="text-lg font-bold\n text-white">Title</h2>\n </div>\n</div>', usedIn: 'FilmDetail, TVSeriesDetail, BookDetail banners' },
{ id: 'mol-cover-card', name: 'Cover Card', category: 'molecules', description: 'Poster/cover image card with overlay text', code: '<button class="group rounded-2xl overflow-hidden">\n <div class="aspect-[2/3] relative">\n <img class="w-full h-full object-cover\n group-hover:scale-110\n transition-transform duration-300" />\n <div class="absolute inset-0\n bg-gradient-to-t from-black/60\n to-transparent" />\n <div class="absolute bottom-0 p-2">\n <p class="text-[11px] text-white/90">\n Title</p>\n </div>\n </div>\n</button>', usedIn: 'FilmGrid, TVSeriesGrid, SongGrid, BookGrid cards' },
// Organisms
{ id: 'org-chat-bubble', name: 'Chat Bubble', category: 'organisms', description: 'AI/User message bubble with streaming', code: '<!-- User bubble -->\n<div class="flex justify-end">\n <div class="glass-card max-w-[85%]\n px-4 py-3 text-sm text-white/90">\n Message text\n </div>\n</div>\n\n<!-- AI bubble -->\n<div class="flex justify-start">\n <div class="max-w-[85%] px-4 py-3\n text-sm text-white/80">\n Response with markdown\n </div>\n</div>', usedIn: 'ChatMessage.vue — the primary chat interface' },
{ id: 'org-content-panel', name: 'Content Panel', category: 'organisms', description: 'Tabs + grid + detail navigation', code: '<!-- Structure -->\n<div class="flex flex-col h-full">\n <!-- Tab bar -->\n <div class="flex gap-1 px-3 py-2">\n <button class="nav-tab">Tab</button>\n </div>\n <!-- Grid view -->\n <ContentGridView />\n <!-- or Detail view -->\n <DetailView />\n</div>', usedIn: 'ChatPage middle column, mobile Content tab' },
{ id: 'org-detail-view', name: 'Detail View', category: 'organisms', description: 'Full detail with banner, back button, metadata', code: '<!-- Pattern: Banner → Meta → Content -->\n<div class="h-full overflow-y-auto">\n <!-- Banner with back button -->\n <div class="relative aspect-[16/7]">\n <img class="object-cover" />\n <div class="gradient-overlay" />\n <button class="absolute top-3 left-3\n path-glass-icon">Back</button>\n <div class="absolute bottom-0 p-4">\n <h2>Title</h2>\n <div>Metadata</div>\n </div>\n </div>\n <!-- Body -->\n <div class="p-4 space-y-4">\n <p>Description</p>\n <div>Genre badges</div>\n <div>Source links</div>\n </div>\n</div>', usedIn: 'FilmDetail, BookDetail, TVSeriesDetail, SongDetail, PodcastDetail' },
{ id: 'org-magazine', name: 'Magazine Grid', category: 'organisms', description: 'Editorial tile layout with hero, wide, and half tiles', code: '<!-- Magazine structure -->\n<div class="grid grid-cols-2 gap-px\n bg-white/12">\n <!-- Wide tile (col-span-2) -->\n <button class="col-span-2 px-5 py-5\n bg-[#0a0a0a]">\n <p class="text-[9px] uppercase\n tracking-[0.3em]">Label</p>\n <h2 class="font-serif text-lg\n font-bold">Title</h2>\n <p class="font-serif text-sm">Text</p>\n </button>\n <!-- Half tiles -->\n <button class="px-4 py-4 bg-[#0a0a0a]">\n <h3 class="font-serif text-sm\n font-bold">Title</h3>\n <p class="font-serif text-xs">Text</p>\n </button>\n</div>', usedIn: 'MagazineGrid.vue — AI Brief editorial view' },
{ id: 'org-nostr-note', name: 'Nostr Note', category: 'organisms', description: 'Note card with avatar, author, content, zaps', code: '<div class="p-3 rounded-xl bg-white/[0.03]\n border border-white/5">\n <div class="flex items-start gap-2.5">\n <div class="w-8 h-8 rounded-full\n bg-purple-500/20 text-purple-400">\n F\n </div>\n <div class="flex-1">\n <span class="text-xs font-semibold">\n author</span>\n <p class="text-[11px] text-white/60">\n Note content...</p>\n <span class="text-[9px]\n text-amber-500/70">21000 sats</span>\n </div>\n </div>\n</div>', usedIn: 'NostrGrid.vue — Nostr feed tab' },
// Animations
{ id: 'anim-fade-up', name: 'Fade Up', category: 'atoms', description: 'Entry animation: translate + opacity', code: '.animate-fade-up {\n animation: fadeUp 900ms ease-out;\n}\n@keyframes fadeUp {\n from {\n opacity: 0;\n transform: translateY(16px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n/* Also: animate-fade-up-fast (400ms) */', usedIn: 'Empty states, initial load elements, ChatWindow' },
{ id: 'anim-scale-in', name: 'Scale In', category: 'atoms', description: 'Micro entrance with scale and opacity', code: '.animate-scale-in {\n animation: scaleIn 250ms ease-out;\n}\n@keyframes scaleIn {\n from {\n opacity: 0;\n transform: scale(0.95);\n }\n to {\n opacity: 1;\n transform: scale(1);\n }\n}', usedIn: 'Modal entries, tooltip appearances, popovers' },
]
const filteredItems = computed(() => {
if (activeCategory.value === 'all') return items
return items.filter(i => i.category === activeCategory.value)
})
function selectItem(item: DesignSystemItem) {
openDesignSystemItem(item)
}
function extractColorValue(code: string): string {
const match = /(?:background-color|color|background):\s*([^;]+)/i.exec(code)
if (!match) return '#333'
const val = match[1].trim()
if (val.startsWith('#') || val.startsWith('rgb') || val.startsWith('hsl')) return val
return '#333'
}
</script>
@@ -56,6 +56,11 @@
v-else-if="isCodeMode && activeCodeFile"
@back="closeCodeFile"
/>
<DesignSystemDetail
v-else-if="selectedDesignSystemItem"
:item="selectedDesignSystemItem"
@back="closeDesignSystemItem"
/>
</template>
<script setup lang="ts">
@@ -71,6 +76,7 @@ import ArticleDetail from './ArticleDetail.vue'
import WebsiteDetail from './WebsiteDetail.vue'
import MagazineSectionDetail from './MagazineSectionDetail.vue'
import CodeDetail from './CodeDetail.vue'
import DesignSystemDetail from './DesignSystemDetail.vue'
import { useCodeContext } from '@/composables/useCodeContext'
const { isCodeMode, activeFile: activeCodeFile } = useCodeContext()
@@ -105,5 +111,7 @@ const {
panelMagazineSections,
closeMagazineSectionDetail,
navigateMagazineSection,
selectedDesignSystemItem,
closeDesignSystemItem,
} = useContentPanel()
</script>
@@ -103,10 +103,11 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { computed } from 'vue'
import type { Film } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { fetchTmdbPoster } from '@/composables/useImageFallback'
import { useBannerFallback } from '@/composables/useBannerFallback'
import { fetchFilmImage } from '@/composables/useImageFallback'
const props = defineProps<{ film: Film }>()
defineEmits<{ back: [] }>()
@@ -115,43 +116,12 @@ const { isDark } = useTheme()
const isExternal = computed(() => props.film.id.startsWith('ext-'))
// Fallback order: backdrop (mock) → TMDB API → poster (mock) → gradient
const bannerTry = ref<'backdrop' | 'tmdb' | 'poster' | 'done'>('backdrop')
const tmdbBannerUrl = ref<string | null>(null)
const bannerSrc = computed(() => {
if (bannerTry.value === 'done') return null
if (bannerTry.value === 'backdrop' && props.film.backdropUrl) return props.film.backdropUrl
if (bannerTry.value === 'tmdb' && tmdbBannerUrl.value) return tmdbBannerUrl.value
if (bannerTry.value === 'poster' && props.film.posterUrl) return props.film.posterUrl
if (bannerTry.value === 'backdrop' && !props.film.backdropUrl) return props.film.posterUrl || null
return null
const { bannerSrc, fallbackGradient, onBannerError } = useBannerFallback({
primaryUrls: () => [props.film.backdropUrl, props.film.posterUrl],
apiFetch: () => fetchFilmImage(props.film.title, props.film.year),
title: () => props.film.title,
})
const fallbackGradient = computed(() => {
const hue = [...props.film.title].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
return `linear-gradient(135deg, hsl(${hue}, 25%, 12%) 0%, hsl(${(hue + 40) % 360}, 20%, 8%) 100%)`
})
async function onBannerError() {
if (bannerTry.value === 'backdrop') {
const { backdropUrl, posterUrl } = await fetchTmdbPoster(props.film.title, props.film.year)
const url = backdropUrl ?? posterUrl
if (url) {
tmdbBannerUrl.value = url
bannerTry.value = 'tmdb'
return
}
bannerTry.value = props.film.posterUrl ? 'poster' : 'done'
return
}
if (bannerTry.value === 'tmdb') {
bannerTry.value = props.film.posterUrl ? 'poster' : 'done'
return
}
bannerTry.value = 'done'
}
function sourceIcon(type: string): string {
const icons: Record<string, string> = {
plex: '🟧',
@@ -54,11 +54,17 @@
<div class="poster-card flex-1 min-h-0">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]">
<img
:src="film.posterUrl"
v-if="coverSrc(film)"
:src="coverSrc(film)!"
:alt="film.title"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="(e) => handleImgError(e, film.title, film.year)"
@error="onCoverError(film)"
/>
<div
v-else
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(film)})` }"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
@@ -96,10 +102,10 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, reactive, onMounted, watch } from 'vue'
import type { Film } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { handleImgError } from '@/composables/useImageFallback'
import { handleImgError, fetchFilmImage, generatePosterFallback } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
films: Film[]
@@ -113,6 +119,35 @@ defineEmits<{ selectFilm: [film: Film] }>()
const { isDark } = useTheme()
const search = ref('')
const activeGenre = ref<string | null>(null)
const failedCovers = ref<Set<string>>(new Set())
const fetchedCovers = reactive<Map<string, string>>(new Map())
function coverSrc(film: Film): string | null {
if (failedCovers.value.has(film.id)) return null
const url = film.posterUrl || fetchedCovers.get(film.id)
return url || null
}
function fallbackFor(film: Film): string {
return generatePosterFallback(film.title, film.year)
}
function onCoverError(film: Film) {
failedCovers.value.add(film.id)
failedCovers.value = new Set(failedCovers.value)
}
function fetchCoversFor(films: Film[]) {
for (const film of films) {
if (film.posterUrl || fetchedCovers.has(film.id)) continue
fetchFilmImage(film.title, film.year).then((result) => {
if (result.posterUrl) fetchedCovers.set(film.id, result.posterUrl)
})
}
}
onMounted(() => fetchCoversFor(props.films))
watch(() => props.films, (films) => fetchCoversFor(films), { immediate: false })
const topGenres = computed(() => {
const counts = new Map<string, number>()
@@ -0,0 +1,105 @@
<template>
<div class="flex-1 overflow-y-auto p-4">
<!-- Poster grid: films, TV, books -->
<div v-if="variant === 'poster'" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
<div
v-for="i in count"
:key="i"
class="aspect-[2/3] rounded-xl animate-pulse"
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
/>
</div>
<!-- Square grid: songs, podcasts, images -->
<div v-else-if="variant === 'square'" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
<div v-for="i in count" :key="i" class="space-y-2">
<div
class="aspect-square rounded-xl animate-pulse"
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
/>
<div
class="h-3 rounded animate-pulse w-3/4"
:class="isDark ? 'bg-white/8' : 'bg-black/5'"
/>
<div
class="h-2.5 rounded animate-pulse w-1/2"
:class="isDark ? 'bg-white/5' : 'bg-black/3'"
/>
</div>
</div>
<!-- List: news, websites -->
<div v-else-if="variant === 'list'" class="space-y-3">
<div
v-for="i in count"
:key="i"
class="flex gap-3 p-3 rounded-xl animate-pulse"
:class="isDark ? 'bg-white/[0.04]' : 'bg-black/[0.03]'"
>
<div
class="w-20 h-14 rounded-lg shrink-0"
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
/>
<div class="flex-1 space-y-2 py-1">
<div
class="h-3 rounded w-4/5"
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
/>
<div
class="h-2.5 rounded w-3/5"
:class="isDark ? 'bg-white/6' : 'bg-black/4'"
/>
</div>
</div>
</div>
<!-- Magazine: tile-style skeleton -->
<div v-else-if="variant === 'magazine'" class="space-y-0">
<!-- Hero skeleton -->
<div
class="h-44 animate-pulse mb-px"
:class="isDark ? 'bg-white/[0.04]' : 'bg-black/[0.03]'"
/>
<!-- Tile grid skeleton -->
<div class="grid grid-cols-2 gap-px"
:class="isDark ? 'bg-white/12' : 'bg-black/10'">
<div
v-for="i in count"
:key="i"
class="p-4 animate-pulse"
:class="[
isDark ? 'bg-[#0a0a0a]' : 'bg-[#faf9f6]',
i <= 1 ? 'col-span-2' : ''
]"
>
<div
class="h-2.5 rounded w-1/3 mb-2"
:class="isDark ? 'bg-white/8' : 'bg-black/5'"
/>
<div
class="h-4 rounded w-4/5 mb-2"
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
/>
<div
class="h-2.5 rounded w-full"
:class="isDark ? 'bg-white/6' : 'bg-black/4'"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
withDefaults(
defineProps<{
variant?: 'poster' | 'square' | 'list' | 'magazine'
count?: number
}>(),
{ variant: 'poster', count: 8 }
)
const { isDark } = useTheme()
</script>
@@ -179,11 +179,19 @@ const bannerLabels = ['Perspectives', 'Worth Noting', 'Key Signals', 'Analysis']
function cleanText(text: string): string {
return text
.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '')
.replace(/\[([^\]]*)\]\([^)]+\)/g, '$1') // [text](url) → text
.replace(/https?:\/\/\S+/g, '') // bare URLs
.replace(/\uFE0F/g, '') // variation selectors
.replace(/(?:^|(?<=\s))[\p{Emoji_Presentation}\p{Extended_Pictographic}]+\s*/gu, '') // standalone emojis
.replace(/---+/g, '') // horizontal rules
.replace(/^#+\s*/gm, '')
.replace(/\*\*/g, '')
.replace(/[-•]\s*/g, '')
.replace(/\*([^*\n]+)\*/g, '$1') // *italic* → italic
.replace(/\|/g, ', ') // pipes → comma-space
.replace(/,\s*,+/g, ',') // collapse multiple commas
.replace(/^\s*[-•]\s+/gm, '') // bullets at line start only
.replace(/\n+/g, ' ')
.replace(/(^|\s),\s*/g, '$1') // trim stray leading commas
.trim()
}
@@ -59,11 +59,7 @@
<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)'} }` : ''">
:class="isDark ? 'text-white/75' : 'text-black/65'">
{{ paragraph }}
</p>
</div>
@@ -147,11 +143,18 @@ 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, '')
.replace(/\[([^\]]*)\]\([^)]+\)/g, '$1') // [text](url) → text
.replace(/https?:\/\/\S+/g, '') // bare URLs
.replace(/\uFE0F/g, '') // variation selectors
.replace(/\*\*/g, '') // bold markers
.replace(/\*([^*\n]+)\*/g, '$1') // *italic* → italic
.replace(/(?:^|(?<=\s))[\p{Emoji_Presentation}\p{Extended_Pictographic}]+\s*/gu, '') // standalone emojis
.replace(/---+/g, '') // horizontal rules
.replace(/^#+\s*/gm, '') // heading markers
.replace(/\|/g, ', ') // pipes → comma-space
.replace(/,\s*,+/g, ',') // collapse multiple commas
.split(/\n{2,}|\n\s*[-•]\s+/)
.map(p => p.replace(/^[-•]\s*/, '').trim())
.map(p => p.replace(/^\s*[-•]\s+/, '').replace(/(^|\n)\s*,\s*/g, '$1').trim())
.filter(p => p.length > 0)
})
</script>
@@ -0,0 +1,278 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 space-y-3" :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 items-center justify-between gap-2">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
Nostr Feed
</h3>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[10px] font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredNotes.length }} notes
</span>
<slot name="header-actions" />
</div>
</div>
<input
v-model="search"
type="text"
placeholder="Search notes, npubs..."
class="w-full px-3 py-2 rounded-lg text-xs outline-none transition-colors"
:class="isDark
? 'bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10'
: 'bg-black/3 text-gray-800 placeholder:text-gray-400 focus:bg-black/5'"
/>
<div class="flex gap-1.5">
<button
v-for="kind in noteKinds"
:key="kind.id"
class="text-[10px] px-2 py-1 rounded-md transition-all duration-150"
:class="activeKind === kind.id
? '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="activeKind = activeKind === kind.id ? null : kind.id"
>
{{ kind.label }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-2">
<button
v-for="note in filteredNotes"
:key="note.id"
class="w-full 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="$emit('selectNote', note)"
>
<div class="flex items-start gap-2.5">
<!-- Avatar -->
<div class="w-8 h-8 rounded-full shrink-0 flex items-center justify-center text-[10px] font-bold"
:class="isDark ? 'bg-purple-500/20 text-purple-400' : 'bg-purple-100 text-purple-600'">
{{ note.authorName?.charAt(0)?.toUpperCase() ?? '?' }}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-xs font-semibold truncate"
:class="isDark ? 'text-white/80' : 'text-gray-800'">
{{ note.authorName ?? 'anon' }}
</span>
<span v-if="note.nip05" class="text-[9px] truncate"
:class="isDark ? 'text-purple-400/60' : 'text-purple-500/60'">
{{ note.nip05 }}
</span>
<span class="text-[9px] ml-auto shrink-0"
:class="isDark ? 'text-white/20' : 'text-gray-300'">
{{ formatTime(note.created_at) }}
</span>
</div>
<p class="text-[11px] mt-1 leading-relaxed line-clamp-3"
:class="isDark ? 'text-white/60' : 'text-gray-600'">
{{ note.content }}
</p>
<div class="flex items-center gap-3 mt-2">
<span v-if="note.kind !== 1" class="text-[9px] px-1.5 py-0.5 rounded"
:class="isDark ? 'bg-white/5 text-white/30' : 'bg-black/5 text-gray-400'">
kind:{{ note.kind }}
</span>
<span v-if="note.replies" class="text-[9px]"
:class="isDark ? 'text-white/25' : 'text-gray-400'">
{{ note.replies }} replies
</span>
<span v-if="note.zaps" class="text-[9px] text-amber-500/70">
{{ note.zaps }} sats
</span>
</div>
</div>
</div>
</button>
<!-- Relay status -->
<div class="mt-4 pt-4" :style="isDark
? 'border-top: 1px solid rgba(255, 255, 255, 0.05)'
: 'border-top: 1px solid rgba(0, 0, 0, 0.05)'"
>
<p class="text-[10px] font-medium mb-2"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
Relays
</p>
<div class="space-y-1">
<div
v-for="relay in relays"
:key="relay.url"
class="flex items-center gap-2 text-[10px] px-2 py-1 rounded-lg"
:class="isDark ? 'bg-white/[0.02]' : 'bg-black/[0.02]'"
>
<span class="w-1.5 h-1.5 rounded-full shrink-0"
:class="relay.connected ? 'bg-emerald-500' : 'bg-red-400/60'" />
<span class="truncate font-mono"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
{{ relay.url }}
</span>
</div>
</div>
</div>
<div v-if="filteredNotes.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No notes match your search
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
export interface NostrNote {
id: string
pubkey: string
authorName?: string
nip05?: string
kind: number
content: string
created_at: number
replies?: number
zaps?: number
tags?: string[][]
}
interface Relay {
url: string
connected: boolean
}
defineEmits<{ selectNote: [note: NostrNote] }>()
const { isDark } = useTheme()
const search = ref('')
const activeKind = ref<number | null>(null)
const noteKinds = [
{ id: 1, label: 'Notes' },
{ id: 30023, label: 'Articles' },
{ id: 9735, label: 'Zaps' },
{ id: 6, label: 'Reposts' },
]
const relays: Relay[] = [
{ url: 'wss://relay.damus.io', connected: true },
{ url: 'wss://relay.nostr.band', connected: true },
{ url: 'wss://nos.lol', connected: true },
{ url: 'wss://relay.snort.social', connected: false },
]
// Mock notes for demo — will be replaced with real Nostr websocket data
const mockNotes: NostrNote[] = [
{
id: 'note1abc',
pubkey: 'npub1abc',
authorName: 'fiatjaf',
nip05: 'fiatjaf@fiatjaf.com',
kind: 1,
content: 'nostr is the simplest open protocol that is able to create a censorship-resistant global "social" network once and for all.',
created_at: Date.now() / 1000 - 3600,
replies: 42,
zaps: 21000,
},
{
id: 'note2def',
pubkey: 'npub2def',
authorName: 'jb55',
nip05: 'jb55@jb55.com',
kind: 1,
content: 'Just shipped a new update to Damus. Lots of performance improvements and better zap UX. Let me know what you think!',
created_at: Date.now() / 1000 - 7200,
replies: 18,
zaps: 5000,
},
{
id: 'note3ghi',
pubkey: 'npub3ghi',
authorName: 'ODELL',
nip05: 'odell@citadeldispatch.com',
kind: 1,
content: 'Bitcoin and nostr are complementary protocols. One provides sound money, the other provides sound communication. Together they form the foundation of a free and open internet.',
created_at: Date.now() / 1000 - 14400,
replies: 31,
zaps: 15000,
},
{
id: 'note4jkl',
pubkey: 'npub4jkl',
authorName: 'Gigi',
nip05: 'gigi@dergigi.com',
kind: 30023,
content: 'New long-form article: "Why Nostr Matters" — exploring the implications of a truly censorship-resistant communication layer and what it means for the future of online discourse.',
created_at: Date.now() / 1000 - 28800,
replies: 12,
zaps: 50000,
},
{
id: 'note5mno',
pubkey: 'npub5mno',
authorName: 'Karnage',
kind: 1,
content: 'Building on nostr feels like the early web. Permissionless innovation, interoperable clients, and a community that actually cares about freedom. This is what I signed up for.',
created_at: Date.now() / 1000 - 36000,
replies: 8,
zaps: 3000,
},
{
id: 'note6pqr',
pubkey: 'npub6pqr',
authorName: 'Pablo',
nip05: 'pablo@fountain.fm',
kind: 1,
content: 'Podcasting 2.0 + Nostr = the future of content discovery. Imagine your podcast app pulling recommendations from your nostr social graph. We are building this.',
created_at: Date.now() / 1000 - 43200,
replies: 25,
zaps: 8000,
},
{
id: 'note7stu',
pubkey: 'npub7stu',
authorName: 'Calle',
nip05: 'calle@cashu.space',
kind: 1,
content: 'Cashu + Nostr ecash tokens make micropayments frictionless. Zap someone a few sats with zero fees and instant settlement. The Lightning Network as a settlement layer.',
created_at: Date.now() / 1000 - 50400,
replies: 15,
zaps: 12000,
},
]
function formatTime(ts: number): string {
const diff = Math.floor(Date.now() / 1000 - ts)
if (diff < 60) return 'now'
if (diff < 3600) return `${Math.floor(diff / 60)}m`
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
return `${Math.floor(diff / 86400)}d`
}
const filteredNotes = computed(() => {
let result = mockNotes
if (activeKind.value !== null) {
result = result.filter(n => n.kind === activeKind.value)
}
if (search.value) {
const q = search.value.toLowerCase()
result = result.filter(n =>
n.content.toLowerCase().includes(q) ||
(n.authorName ?? '').toLowerCase().includes(q) ||
(n.nip05 ?? '').toLowerCase().includes(q)
)
}
return result
})
</script>
@@ -1,50 +1,112 @@
<template>
<div class="flex flex-col h-full">
<!-- Header -->
<div class="shrink-0 px-4 py-3 flex items-center justify-between"
<!-- Header with breadcrumb -->
<div class="shrink-0 px-4 py-3 flex items-center justify-between gap-2"
: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">
<div class="flex items-center gap-1 min-w-0 flex-1">
<!-- Breadcrumb: "Projects" root (clickable when deeper) -->
<button
v-if="activeProject"
class="text-[10px] px-2.5 py-1 rounded-lg font-medium transition-colors"
v-if="viewState !== 'projects'"
class="text-[10px] shrink-0 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'"
? 'text-white/40 hover:text-white/70 hover:underline'
: 'text-gray-400 hover:text-gray-700 hover:underline'"
@click="backToProjects"
>
All Projects
Projects
</button>
<span v-if="viewState !== 'projects'" class="text-[10px] shrink-0"
:class="isDark ? 'text-white/20' : 'text-gray-300'">/</span>
<!-- Current location (not clickable) -->
<span class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ currentTitle }}
</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<!-- Subtitle info -->
<p v-if="viewState === 'projects'" class="text-[10px]"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ projectList.length }} repos
</p>
<p v-else-if="viewState === 'filetree'" class="text-[10px]"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ activeProject?.language }}
</p>
<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">
<!-- Project list -->
<div v-if="viewState === 'projects'" class="flex-1 overflow-y-auto custom-scrollbar p-3">
<!-- Search + New Project -->
<div class="mb-3 flex gap-2">
<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="flex-1 min-w-0 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'"
/>
<button
class="shrink-0 px-3 py-2 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5"
:class="isDark
? 'bg-accent/20 text-accent hover:bg-accent/30'
: 'bg-accent/10 text-accent hover:bg-accent/20'"
@click="showNewProjectDialog"
>
<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="M12 4v16m8-8H4" />
</svg>
New
</button>
</div>
<!-- New project inline dialog -->
<div v-if="isCreatingProject" class="mb-3 p-3 rounded-xl"
:class="isDark ? 'bg-white/[0.05] border border-white/10' : 'bg-black/[0.03] border border-black/8'">
<p class="text-xs font-medium mb-2"
:class="isDark ? 'text-white/70' : 'text-gray-700'">
New Project
</p>
<input
ref="newProjectInputRef"
v-model="newProjectName"
type="text"
placeholder="Project name..."
class="w-full px-3 py-2 rounded-lg text-xs bg-transparent outline-none mb-2"
: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'"
@keydown.enter="confirmCreateProject"
@keydown.escape="cancelCreateProject"
/>
<div class="flex justify-end gap-2">
<button
class="text-[10px] px-2.5 py-1 rounded-lg transition-colors"
:class="isDark ? 'text-white/40 hover:text-white/70' : 'text-gray-500 hover:text-gray-800'"
@click="cancelCreateProject"
>
Cancel
</button>
<button
class="text-[10px] px-2.5 py-1 rounded-lg font-medium transition-colors"
:class="isDark ? 'bg-accent/20 text-accent hover:bg-accent/30' : 'bg-accent/10 text-accent hover:bg-accent/20'"
:disabled="!newProjectName.trim()"
@click="confirmCreateProject"
>
Create
</button>
</div>
</div>
<!-- Project grid -->
<div class="grid grid-cols-2 gap-2">
<button
v-for="project in filteredProjects"
@@ -55,12 +117,10 @@
: '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" />
<path 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"
@@ -75,8 +135,8 @@
</div>
</div>
<!-- File tree (when project is selected) -->
<div v-else class="flex-1 overflow-y-auto custom-scrollbar p-2">
<!-- File tree -->
<div v-else-if="viewState === 'filetree'" class="flex-1 overflow-y-auto custom-scrollbar p-2">
<FileTreeNode
v-for="entry in fileTree"
:key="entry.path"
@@ -91,15 +151,21 @@
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, nextTick } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useCodeContext, type ProjectInfo } from '@/composables/useCodeContext'
import FileTreeNode from './FileTreeNode.vue'
defineProps<{
isWideDesktop?: boolean
isMobile?: boolean
}>()
const { isDark } = useTheme()
const {
projectList,
@@ -108,10 +174,26 @@ const {
activeFile,
selectProject: doSelectProject,
openFile,
exitCodeMode,
createProject,
clearActiveFile,
} = useCodeContext()
const search = ref('')
const isCreatingProject = ref(false)
const newProjectName = ref('')
const newProjectInputRef = ref<HTMLInputElement | null>(null)
// View state: projects | filetree (file content always opens in Context panel)
type CodeViewState = 'projects' | 'filetree'
const viewState = computed<CodeViewState>(() => {
if (!activeProject.value) return 'projects'
return 'filetree'
})
const currentTitle = computed(() => {
if (viewState.value === 'projects') return 'Projects'
return activeProject.value?.name ?? 'Projects'
})
const filteredProjects = computed(() => {
const q = search.value.toLowerCase()
@@ -126,15 +208,34 @@ function selectProject(project: ProjectInfo) {
}
function backToProjects() {
// Clear active project to go back to project list
const { activeProject: ap, fileTree: ft, activeFile: af, activeFileContent: afc } = useCodeContext()
const { activeProject: ap, fileTree: ft } = useCodeContext()
ap.value = null
ft.value = []
af.value = null
afc.value = ''
clearActiveFile()
}
function handleFileSelect(filePath: string) {
openFile(filePath)
}
// New project dialog
function showNewProjectDialog() {
isCreatingProject.value = true
newProjectName.value = ''
nextTick(() => newProjectInputRef.value?.focus())
}
function cancelCreateProject() {
isCreatingProject.value = false
newProjectName.value = ''
}
function confirmCreateProject() {
const name = newProjectName.value.trim()
if (!name) return
createProject(name)
isCreatingProject.value = false
newProjectName.value = ''
}
</script>
@@ -51,10 +51,10 @@
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('selectSong', song)"
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="cover-card flex-1 min-h-0 relative flex items-center justify-center">
<div class="aspect-square relative w-full overflow-hidden rounded-[10px]">
<button
class="absolute inset-0 flex items-center justify-center z-10 backdrop-blur-sm bg-black/30 opacity-0 group-hover:opacity-100 transition-all duration-200"
class="absolute inset-0 bottom-10 flex items-center justify-center z-10 backdrop-blur-sm bg-black/30 opacity-0 group-hover:opacity-100 transition-all duration-200"
aria-label="Play"
@click.stop="onPlayClick(song)"
>
@@ -97,10 +97,6 @@
</div>
</div>
</div>
<p class="text-xs font-semibold mt-2 truncate px-0.5"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ song.title }}
</p>
</button>
</div>
@@ -3,16 +3,16 @@
<div class="relative w-full overflow-hidden">
<div class="w-full aspect-[16/7] flex items-center justify-center overflow-hidden bg-black/20">
<img
v-if="coverSrc"
:src="coverSrc"
v-if="bannerSrc"
:src="bannerSrc"
:alt="series.title"
class="w-full h-full object-cover object-center block"
@error="coverFailed = true"
@error="onBannerError"
/>
<div
v-else
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackCover})` }"
class="w-full h-full"
:style="{ background: fallbackGradient }"
/>
</div>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
@@ -124,27 +124,23 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { computed } from 'vue'
import type { TVSeries } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateTVSeriesFallback, fetchTmdbTVPoster } from '@/composables/useImageFallback'
import { useBannerFallback } from '@/composables/useBannerFallback'
import { fetchTVImage } from '@/composables/useImageFallback'
const props = defineProps<{ series: TVSeries }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const coverFailed = ref(false)
const fetchedCover = ref<string | null>(null)
const coverSrc = computed(() => {
if (coverFailed.value) return null
return props.series.posterUrl || props.series.backdropUrl || fetchedCover.value
const { bannerSrc, fallbackGradient, onBannerError } = useBannerFallback({
primaryUrls: () => [props.series.posterUrl, props.series.backdropUrl],
apiFetch: () => fetchTVImage(props.series.title, props.series.year),
title: () => props.series.title,
})
const fallbackCover = computed(() =>
generateTVSeriesFallback(props.series.title, props.series.year)
)
const yearDisplay = computed(() => {
if (!props.series.year) return ''
if (props.series.endYear && props.series.endYear !== props.series.year) {
@@ -180,11 +176,4 @@ function sourceIcon(type: string): string {
return icons[type] ?? '📺'
}
onMounted(() => {
if (props.series.posterUrl || props.series.backdropUrl) return
fetchTmdbTVPoster(props.series.title, props.series.year).then((result) => {
if (result.backdropUrl) fetchedCover.value = result.backdropUrl
else if (result.posterUrl) fetchedCover.value = result.posterUrl
})
})
</script>
@@ -116,7 +116,7 @@
import { ref, computed, reactive, onMounted, watch } from 'vue'
import type { TVSeries } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateTVSeriesFallback, fetchTmdbTVPoster } from '@/composables/useImageFallback'
import { generateTVSeriesFallback, fetchTVImage } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
series: TVSeries[]
@@ -157,7 +157,7 @@ function yearDisplay(s: TVSeries): string {
function fetchCoversFor(list: TVSeries[]) {
for (const s of list) {
if (s.posterUrl || fetchedCovers.has(s.id)) continue
fetchTmdbTVPoster(s.title, s.year).then((result) => {
fetchTVImage(s.title, s.year).then((result) => {
if (result.posterUrl) fetchedCovers.set(s.id, result.posterUrl)
})
}