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
+1 -1
View File
@@ -82,7 +82,7 @@ define(['./workbox-cf23aef7'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.ndi22oikhk8"
"revision": "0.3epgdnb2r7k"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
@@ -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)
})
}
@@ -0,0 +1,967 @@
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
import type { WebSearchResult } from '@aiui/core/types/message'
import type { MagazineSection } from './contentFiltering'
import { mockFilms } from '@/mocks/films'
import { mockSongs } from '@/mocks/songs'
import { mockPodcasts } from '@/mocks/podcasts'
import { isNewsLikeResponse, isMusicQuery, isTVQuery, isBookQuery, isBookLikeResponse, isImageQuery, isPlaceQuery, isPlaceLikeResponse } from './contentFiltering'
// ─── Tag regexes ──────────────────────────────────────────────────
export const FILM_TAG_RE = /\[\[film:(f?\d+)\]\]/gi
export const FILM_EXT_RE = /\[\[film_ext:([^|]+)\|(\d{4})\|([^\]]+)\]\]/gi
export const SONG_TAG_RE = /\[\[song:(s?\d+)\]\]/gi
export const SONG_EXT_RE = /\[\[song_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
export const PODCAST_TAG_RE = /\[\[podcast:(p?\d+)\]\]/gi
export const PODCAST_EXT_RE = /\[\[podcast_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
export const BOOK_TAG_RE = /\[\[book:(b?\d+)\]\]/gi
export const BOOK_EXT_RE = /\[\[book_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
export const TV_EXT_RE = /\[\[tv_ext:([^|]+)\|([^|]*)(?:\|(\d{4}))?\]\]/gi
export const PLACE_EXT_RE = /\[\[place_ext:([^|]+)\|([^|]*)(?:\|([^|]*))?(?:\|([^|]*))?(?:\|([^|]*))?(?:\|([^|]*))?\]\]/gi
export const MARKDOWN_LINK_RE = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g
const SAFE_URL_SCHEME = /^https?:\/\//i
// ─── Utility helpers ──────────────────────────────────────────────
function extractUrlFromText(text: string): string | undefined {
const mdLink = /\[([^\]]*)\]\((https?:\/\/[^)]+)\)/.exec(text)
const raw = mdLink ? mdLink[2] : (/(https?:\/\/[^\s)\]\"'<>]+)/.exec(text)?.[1])
if (!raw?.trim()) return undefined
try {
const u = new URL(raw.trim())
if (!/^https?:$/i.test(u.protocol)) return undefined
return u.href
} catch {
return undefined
}
}
function extractAuthorFromText(text: string): string | undefined {
const patterns = [
/(?:analyst|according to)\s+\*{0,2}([A-Z][^*\n]+?)\*{0,2}(?:\s+(?:is|calls?|says?|cited)|\.|,)/,
/\bby\s+\*{0,2}([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)+)\*{0,2}/,
/(?:source|—)\s*:?\s*\*{0,2}([A-Z][^*\n]+?)\*{0,2}(?:\s|$|\.|,)/,
/\*\*([A-Z][^*]+)\*\*(?:\s+(?:is|calls?|says?|cited|predicts?))/,
]
for (const re of patterns) {
const m = re.exec(text)
if (m) {
const name = m[1].trim().slice(0, 60)
if (name.length > 3 && name.length < 50) return name
}
}
return undefined
}
function extractFirstImageFromText(text: string): string | undefined {
const mdImg = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/.exec(text)
const raw = mdImg ? mdImg[1] : (/(https?:\/\/[^\s)\]\"'<>]+\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s)\]]*)?)/i.exec(text)?.[1])
if (!raw?.trim()) return undefined
try {
const u = new URL(raw.trim())
if (!/^https?:$/i.test(u.protocol)) return undefined
return u.href
} catch {
return undefined
}
}
function extractDescriptionForTag(text: string, matchIndex: number, matchLength: number): string {
const prevNewline = text.lastIndexOf('\n', matchIndex - 1)
const lineStart = prevNewline === -1 ? 0 : prevNewline + 1
const nextNewline = text.indexOf('\n', matchIndex + matchLength)
const lineEnd = nextNewline === -1 ? text.length : nextNewline
let line = text.slice(lineStart, lineEnd)
line = line.replace(text.slice(matchIndex, matchIndex + matchLength), '')
line = line.replace(/\[\[(?:film|song|podcast)(?:_ext)?:[^\]]*\]\]/g, '')
line = line.replace(/^\s*[-*•]\s*/, '').replace(/^\s*\d+\.\s*/, '')
line = line.replace(/\*\*[^*]+\*\*\s*[-–—:]\s*/, '').replace(/\*\*[^*]+\*\*\s*/, '')
line = line.replace(/\*\*/g, '').replace(/\*/g, '')
line = line.replace(/\(\d{4}\)\s*/g, '')
line = line.replace(/^[\s\-–—:,]+/, '').replace(/[\s\-–—:,]+$/, '')
const result = line.trim().slice(0, 300)
return result.length >= 10 ? result : ''
}
export function normUrl(u: string): string {
return u.toLowerCase().trim().replace(/\/$/, '')
}
// ─── Magazine extraction ──────────────────────────────────────────
const MAGAZINE_CONTENT_MAX = 2000
/** Clean markdown artifacts from magazine text */
function cleanMagazineText(text: string): string {
return text
.replace(/\[([^\]]*)\]\([^)]+\)/g, '$1')
.replace(/https?:\/\/\S+/g, '')
.replace(/\uFE0F/g, '')
.replace(/(?:^|(?<=\s))[\p{Emoji_Presentation}\p{Extended_Pictographic}]+\s*/gu, '') // standalone emojis only
.replace(/\*\*/g, '')
.replace(/\*([^*\n]+)\*/g, '$1') // *italic* → italic
.replace(/---+/g, '')
.replace(/^#+\s*/gm, '')
.replace(/\|/g, ', ') // pipes → comma-space
.replace(/,\s*,+/g, ',') // collapse multiple commas
.replace(/(^|\n)\s*,\s*/g, '$1') // trim leading commas per line
.replace(/\s*,\s*($|\n)/g, '$1') // trim trailing commas per line
}
function addSection(
sections: MagazineSection[],
title: string,
content: string,
seen: Set<string>,
group?: string,
): void {
let t = cleanMagazineText(title).replace(/\s+/g, ' ').trim().slice(0, 150)
let c = cleanMagazineText(content).replace(/\n{3,}/g, '\n\n').trim().slice(0, MAGAZINE_CONTENT_MAX)
if (t.length < 2 || c.length < 15) return
const tLower = t.toLowerCase()
const cLower = c.toLowerCase()
if (tLower.length >= 10 && cLower.startsWith(tLower.slice(0, Math.min(tLower.length, 40)))) {
c = c.slice(t.length).replace(/^[\s.,:;—–\-]+/, '').trim()
if (c.length < 15) return
}
const key = `${t.slice(0, 50)}`
if (seen.has(key)) return
seen.add(key)
const imgMatch = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/.exec(content)?.[1]
const imageUrl = imgMatch ? (() => {
try {
const u = new URL(imgMatch.trim())
return /^https?:$/i.test(u.protocol) ? u.href : undefined
} catch { return undefined }
})() : undefined
sections.push({
title: t,
content: c,
url: extractUrlFromText(content),
author: extractAuthorFromText(content),
imageUrl,
group,
})
}
function cleanMagazineContent(raw: string): string {
return raw
.replace(/\[\[(?:podcast|film|song|book|tvshow|film_ext|song_ext):[^\]]*\]\]/g, '')
.replace(/^\s*\n---\s*\n?/g, '')
.replace(/\n---\s*$/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export function extractMagazineSections(text: string): MagazineSection[] {
const sections: MagazineSection[] = []
const seen = new Set<string>()
let cleanedText = text
.replace(/\n+(?:Sources|References|Links):?\s*\n[\s\S]*$/i, '')
.replace(/\n+For deeper[^:]*:[\s\S]*$/im, '')
const headingPositions: { title: string; start: number; contentStart: number }[] = []
const headingLineRe = /^#{2,3}\s+(.+)$/gm
let m: RegExpExecArray | null
while ((m = headingLineRe.exec(cleanedText)) !== null) {
const rawTitle = m[1].trim()
const title = rawTitle
.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\uFE0F?\s*/gu, '')
.replace(/^[#*_\s-]+/, '')
.trim()
if (title.length > 1) {
headingPositions.push({ title, start: m.index, contentStart: m.index + m[0].length })
}
}
for (let i = 0; i < headingPositions.length; i++) {
const hp = headingPositions[i]
const nextStart = i + 1 < headingPositions.length ? headingPositions[i + 1].start : cleanedText.length
const rawContent = cleanMagazineContent(cleanedText.slice(hp.contentStart, nextStart))
if (rawContent.length < 20) continue
const bullets = rawContent
.split(/\n\s*[-•]\s+/)
.map(s => s.replace(/^[-•]\s*/, '').trim())
.filter(s => s.length > 10)
if (bullets.length >= 2) {
for (const bullet of bullets) {
const cleaned = cleanMagazineContent(bullet)
if (cleaned.length < 15) continue
const boldMatch = /^\*\*([^*]+)\*\*\s*[:\u2014\u2013]\s*(.+)/s.exec(cleaned)
if (boldMatch) {
addSection(sections, boldMatch[1].trim(), boldMatch[2].trim(), seen, hp.title)
} else {
const sentenceMatch = /^([^.!?]{10,80}[.!?])/.exec(cleaned)
const bulletTitle = sentenceMatch ? sentenceMatch[1] : cleaned.slice(0, 60)
addSection(sections, bulletTitle, cleaned, seen, hp.title)
}
}
} else {
addSection(sections, hp.title, rawContent, seen)
}
}
const campRe = /\*\*([^*]+(?:camp| side| view)[^*]*)\*\*[🟠🔴🟢]?\s*\n([\s\S]+?)(?=\n\*\*[^*]+(?:camp| side)[^*]*\*\*|\n#{2,3}\s|\n\nThis is|\n\nFor deeper|$)/gim
while ((m = campRe.exec(cleanedText)) !== null) {
const title = m[1].trim()
const content = cleanMagazineContent(m[2])
if (content.length > 15) addSection(sections, title, content, seen)
}
const firstHeadingIdx = headingPositions.length > 0 ? headingPositions[0].start : -1
const introEnd = firstHeadingIdx > 0 ? firstHeadingIdx : cleanedText.indexOf('\n---\n')
if (introEnd > 0) {
const intro = cleanedText.slice(0, introEnd).trim()
.replace(/^[#*_\s-]+/gm, '').trim()
if (intro.length > 30 && !seen.has('Summary')) {
addSection(sections, 'Summary', intro, seen)
}
}
const closingMatch = /(This is being called[^.]+\.[^"]*"[^"]+"[^.]*\.)/i.exec(cleanedText)
if (closingMatch && !seen.has('Key')) {
addSection(sections, 'Key takeaway', closingMatch[1].trim(), seen)
}
const order = ['Summary', 'Key takeaway']
sections.sort((a, b) => {
const ai = order.indexOf(a.title)
const bi = order.indexOf(b.title)
if (ai >= 0 && bi >= 0) return ai - bi
if (ai >= 0) return -1
if (bi >= 0) return 1
return 0
})
return sections
}
export function extractMagazineHeroImage(text: string): string | undefined {
return extractFirstImageFromText(text)
}
// ─── Links extraction ─────────────────────────────────────────────
export function extractBoldDomainLinks(text: string): WebSearchResult[] {
const results: WebSearchResult[] = []
const seen = new Set<string>()
const re = /\*\*([^*]+)\*\*\s*\(([a-zA-Z0-9][-a-zA-Z0-9.]*\.[a-zA-Z]{2,})\)/g
let match: RegExpExecArray | null
while ((match = re.exec(text)) !== null) {
const title = match[1].trim().slice(0, 500)
const domain = match[2].trim()
if (title.length < 2) continue
const url = /^https?:\/\//i.test(domain) ? domain : `https://${domain}`
const norm = normUrl(url)
if (seen.has(norm)) continue
seen.add(norm)
results.push({ title, url, content: undefined })
}
return results
}
export function extractMarkdownLinks(text: string): WebSearchResult[] {
const results: WebSearchResult[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
const re = new RegExp(MARKDOWN_LINK_RE.source, 'g')
while ((match = re.exec(text)) !== null) {
const title = match[1].trim().slice(0, 500)
const rawUrl = match[2].trim()
if (title.length < 2 || rawUrl.length < 10 || !SAFE_URL_SCHEME.test(rawUrl)) continue
try {
new URL(rawUrl)
} catch {
continue
}
const norm = rawUrl.toLowerCase().replace(/\/$/, '')
if (seen.has(norm)) continue
seen.add(norm)
results.push({ title, url: rawUrl, content: undefined })
}
return results
}
export function mergeNewsResults(web: WebSearchResult[], fromText: WebSearchResult[]): WebSearchResult[] {
const byUrl = new Map<string, WebSearchResult>()
for (const r of web) {
byUrl.set(normUrl(r.url), r)
}
for (const r of fromText) {
const k = normUrl(r.url)
if (!byUrl.has(k)) byUrl.set(k, r)
}
return [...byUrl.values()]
}
// ─── Film extraction ──────────────────────────────────────────────
function normalizeFilmId(raw: string): string {
return raw.startsWith('f') ? raw : `f${raw}`
}
export function extractFilmIds(text: string): string[] {
const ids: string[] = []
let match: RegExpExecArray | null
const re = new RegExp(FILM_TAG_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const id = normalizeFilmId(match[1])
if (!ids.includes(id)) ids.push(id)
}
return ids
}
function resolveFilms(ids: string[]): Film[] {
return ids
.map((id) => mockFilms.find((f) => f.id === id))
.filter((f): f is Film => !!f)
}
function extractExternalFilms(text: string): Film[] {
const films: Film[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
const re = new RegExp(FILM_EXT_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const title = match[1].trim()
const year = parseInt(match[2], 10)
const director = match[3].trim()
const key = `${title.toLowerCase()}|${year}`
if (seen.has(key)) continue
seen.add(key)
films.push({
id: `ext-${key.replace(/\W/g, '-')}`,
title,
year,
posterUrl: '',
synopsis: extractDescriptionForTag(text, match.index, match[0].length),
genres: [],
rating: 0,
runtime: 0,
director,
cast: [],
sources: [],
})
}
return films
}
export function extractAllFilms(text: string): Film[] {
const libraryFilms = resolveFilms(extractFilmIds(text))
const externalFilms = extractExternalFilms(text)
return [...libraryFilms, ...externalFilms]
}
// ─── Song extraction ──────────────────────────────────────────────
function normalizeSongId(raw: string): string {
return raw.startsWith('s') ? raw : `s${raw}`
}
export function extractSongIds(text: string): string[] {
const ids: string[] = []
let match: RegExpExecArray | null
const re = new RegExp(SONG_TAG_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const id = normalizeSongId(match[1])
if (!ids.includes(id)) ids.push(id)
}
return ids
}
function resolveSongs(ids: string[]): Song[] {
return ids
.map((id) => mockSongs.find((s) => s.id === id))
.filter((s): s is Song => !!s)
}
/** Reject obvious non-song phrases (news bullets, factual descriptions, etc.) */
function looksLikeSong(title: string, artist: string): boolean {
const t = title.toLowerCase()
const a = artist.toLowerCase()
const bad = [
'latest news', 'protocol updates', 'community debates', 'real-time information',
'training cutoff', 'bip discussion', 'beyond my training', 'what people are saying',
'want me to go', 'search for what', 'look up things', 'direct answer',
'for deeper coverage', 'for instant', 'check these sources',
'bip 110', 'bip discussions', 'web search',
'developer mailing list', 'mailing list reactions', 'technical opinions',
'community sentiment', 'twitter', 'reddit', 'github', 'stackexchange',
'bitcoin bips', 'bitcoin mailing', 'canonical source', 'formal dev',
'what i\'d suggest', 'for bip', 'sources to',
'series', 'season', 'episode', 'animated', 'anime', 'netflix', 'amazon',
'streaming', 'hbo', 'hulu', 'disney', 'showtime', 'cancelled', 'renewed',
'viewership', 'rotten tomatoes', 'imdb',
'published', 'author', 'edition', 'chapter', 'novel', 'nonfiction',
'product', 'brand', 'company', 'startup', 'pricing',
'meaning', 'definition', 'synonym', 'refers to', 'describes',
'example', 'similar to', 'also known as', 'originates from',
'a word', 'a term', 'conveys', 'evokes', 'suggests',
]
for (const phrase of bad) {
if (t.includes(phrase) || a.includes(phrase)) return false
}
if (t.length > 55 || a.length > 40) return false
if (/\b(the act of|a type of|when something|which means|referring to|something that)\b/i.test(a)) return false
if (/^(this|these|it|that|here|there|when|where|what|how|why|if|but|and|or|the |a |an )\b/i.test(t)) return false
return true
}
function extractExternalSongs(text: string): Song[] {
const songs: Song[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
const re = new RegExp(SONG_EXT_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const title = match[1].trim()
const artist = match[2].trim()
if (!looksLikeSong(title, artist)) continue
const year = match[3] ? parseInt(match[3], 10) : undefined
const key = `${title.toLowerCase()}|${artist.toLowerCase()}`
if (seen.has(key)) continue
seen.add(key)
songs.push({
id: `ext-${key.replace(/\W/g, '-')}`,
title,
artist,
year,
coverUrl: undefined,
sources: [],
})
}
return songs
}
function extractSongsFromLibraryMatch(text: string): Song[] {
const lower = text.toLowerCase()
const found: { song: Song; pos: number }[] = []
const seen = new Set<string>()
for (const song of mockSongs) {
const key = song.id
if (seen.has(key)) continue
const title = song.title.toLowerCase()
const artist = song.artist.toLowerCase()
if (!lower.includes(title)) continue
const artistRe = new RegExp('\\b' + artist.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i')
if (!artistRe.test(lower)) continue
const titlePos = lower.indexOf(title)
const artistMatch = lower.match(artistRe)
const artistPos = artistMatch?.index ?? -1
if (artistPos < 0) continue
const dist = Math.abs(titlePos - artistPos)
if (dist > 120) continue
seen.add(key)
found.push({ song, pos: Math.min(titlePos, artistPos) })
}
return found.sort((a, b) => a.pos - b.pos).map((f) => f.song)
}
function extractSongsFromPatterns(text: string): Song[] {
const songs: { title: string; artist: string; pos: number }[] = []
const seen = new Set<string>()
const patterns: { re: RegExp; titleIdx: number; artistIdx: number }[] = [
{ re: /"([^"]{2,80})"\s+by\s+([A-Za-z0-9][^,\n\.]{1,50}?)(?:\s*[,\n\.]|$)/gi, titleIdx: 1, artistIdx: 2 },
{ re: /\*\*([^*]{2,80})\*\*\s+by\s+([A-Za-z0-9][^,\n\.]{1,50}?)(?:\s*[,\n\.]|$)/g, titleIdx: 1, artistIdx: 2 },
{ re: /(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)?([^\n\-–—]{2,60}?)\s*[-–—]\s*([A-Za-z0-9][^,\n]{1,50}?)(?:\s*[,\n\.]|$)/gm, titleIdx: 1, artistIdx: 2 },
{ re: /([A-Za-z0-9][^\-–—\n]{2,60}?)\s+[-–—]\s+([A-Za-z0-9][^,\n]{1,50}?)(?=\s*[,\n\.]|$)/g, titleIdx: 1, artistIdx: 2 },
]
for (const { re, titleIdx, artistIdx } of patterns) {
let m: RegExpExecArray | null
const rx = new RegExp(re.source, re.flags)
while ((m = rx.exec(text)) !== null) {
const title = m[titleIdx].trim()
const artist = m[artistIdx].trim()
if (title.length < 2 || artist.length < 2) continue
if (!looksLikeSong(title, artist)) continue
if (/^\d{4}$/.test(title) || /^\d{4}$/.test(artist)) continue
if (/\[\[(film|song)(_ext)?:/.test(title) || /\[\[(film|song)(_ext)?:/.test(artist)) continue
if (/\*\*\[\[/.test(title) || title.includes(']]**')) continue
const key = `${title.toLowerCase()}|${artist.toLowerCase()}`
if (seen.has(key)) continue
seen.add(key)
songs.push({ title, artist, pos: m.index })
}
}
return songs
.sort((a, b) => a.pos - b.pos)
.map(({ title, artist }) => ({
id: `ext-${`${title}|${artist}`.toLowerCase().replace(/\W/g, '-')}`,
title,
artist,
coverUrl: undefined,
sources: [],
}))
}
export function extractAllSongs(text: string, userQuery = ''): Song[] {
const librarySongs = resolveSongs(extractSongIds(text))
const externalSongs = extractExternalSongs(text)
if (librarySongs.length > 0 || externalSongs.length > 0) {
return [...librarySongs, ...externalSongs]
}
if (extractFilmIds(text).length > 0 || /\[\[film_ext:/.test(text)) return []
if (extractPodcastIds(text).length > 0 || /\[\[podcast_ext:/.test(text)) return []
if (/\[\[tv_ext:/.test(text) || /\[\[book_ext:/.test(text)) return []
if (isNewsLikeResponse(text)) return []
const q = userQuery.toLowerCase()
if (q && !isMusicQuery(q)) return []
const libMatches = extractSongsFromLibraryMatch(text)
const patternMatches = extractSongsFromPatterns(text)
const libKeys = new Set(libMatches.map((s) => `${s.title.toLowerCase()}|${s.artist.toLowerCase()}`))
const fromPatterns = patternMatches.filter(
(p) => !libKeys.has(`${p.title.toLowerCase()}|${p.artist.toLowerCase()}`)
)
return [...libMatches, ...fromPatterns]
}
// ─── Podcast extraction ───────────────────────────────────────────
function normalizePodcastId(raw: string): string {
return raw.startsWith('p') ? raw : `p${raw}`
}
export function extractPodcastIds(text: string): string[] {
const ids: string[] = []
let match: RegExpExecArray | null
const re = new RegExp(PODCAST_TAG_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const id = normalizePodcastId(match[1])
if (!ids.includes(id)) ids.push(id)
}
return ids
}
function resolvePodcasts(ids: string[]): Podcast[] {
return ids
.map((id) => mockPodcasts.find((p) => p.id === id))
.filter((p): p is Podcast => !!p)
}
function looksLikePodcast(title: string, host: string): boolean {
const t = title.toLowerCase()
const h = host.toLowerCase()
const bad = [
'bitcoin mailing list', 'mailing list', 'developer mailing list', 'gnusha.org',
'canonical source', 'formal dev', 'github', 'stackexchange', 'reddit', 'twitter',
'latest news', 'protocol updates', 'web search', 'training cutoff',
'documentation', 'bip discussion', 'bip 110', 'bitcoin bips',
]
for (const phrase of bad) {
if (t.includes(phrase) || h.includes(phrase)) return false
}
if (t.length > 80 || h.length > 50) return false
return true
}
function extractExternalPodcasts(text: string): Podcast[] {
const podcasts: Podcast[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
const re = new RegExp(PODCAST_EXT_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const title = match[1].trim()
const host = match[2].trim()
if (!looksLikePodcast(title, host)) continue
const year = match[3] ? parseInt(match[3], 10) : undefined
const key = `${title.toLowerCase()}|${host.toLowerCase()}`
if (seen.has(key)) continue
seen.add(key)
podcasts.push({
id: `ext-${key.replace(/\W/g, '-')}`,
title,
host,
year,
coverUrl: undefined,
sources: [],
})
}
return podcasts
}
export function extractAllPodcasts(text: string): Podcast[] {
const libraryPodcasts = resolvePodcasts(extractPodcastIds(text))
const externalPodcasts = extractExternalPodcasts(text)
return [...libraryPodcasts, ...externalPodcasts]
}
// ─── Book extraction ──────────────────────────────────────────────
function extractExternalBooks(text: string): Book[] {
const books: Book[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
const re = new RegExp(BOOK_EXT_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const title = match[1].trim()
const author = match[2].trim()
const year = match[3] ? parseInt(match[3], 10) : undefined
const key = `${title.toLowerCase()}|${author.toLowerCase()}`
if (seen.has(key)) continue
seen.add(key)
books.push({
id: `ext-${key.replace(/\W/g, '-')}`,
title,
author,
year,
coverUrl: undefined,
description: extractDescriptionForTag(text, match.index, match[0].length),
genres: [],
sources: [],
})
}
return books
}
function extractBooksFromPatterns(text: string): Book[] {
const books: { title: string; author: string; year?: number; desc: string; pos: number }[] = []
const seen = new Set<string>()
const patterns: { re: RegExp; titleIdx: number; authorIdx: number }[] = [
{ re: /["""]([^"""]{2,80})["""]\s+by\s+([A-Z][^,\n\.]{1,50}?)(?:\s*[,()\n\.]|$)/gi, titleIdx: 1, authorIdx: 2 },
{ re: /\*\*([^*]{2,80})\*\*\s+(?:by|—|)\s+\*?([A-Z][^*\n]{1,50}?)\*?(?:\s*[,()*\n]|$)/g, titleIdx: 1, authorIdx: 2 },
{ re: /(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n\-–—]{2,80}?)\*{0,2}\s+(?:by|—|)\s+\*?([A-Z][^*\n]{1,50}?)\*?(?:\s*[,()*\n]|$)/gm, titleIdx: 1, authorIdx: 2 },
]
for (const { re, titleIdx, authorIdx } of patterns) {
let m: RegExpExecArray | null
const rx = new RegExp(re.source, re.flags)
while ((m = rx.exec(text)) !== null) {
const title = m[titleIdx].trim().replace(/^\*\*|\*\*$/g, '').replace(/^\[|\]$/g, '')
const author = m[authorIdx].trim().replace(/^\*\*|\*\*$/g, '')
if (title.length < 2 || author.length < 2) continue
if (/\[\[(film|song|podcast|book)(_ext)?:/.test(title)) continue
if (/^\d{4}$/.test(title) || /^\d{4}$/.test(author)) continue
const afterMatch = text.substring(m.index + m[0].length, m.index + m[0].length + 200)
if (/^\s*\]\s*\(https?:/.test(afterMatch) || /\]\(https?:\/\//.test(m[0])) continue
const key = `${title.toLowerCase()}|${author.toLowerCase()}`
if (seen.has(key)) continue
seen.add(key)
const desc = extractDescriptionForTag(text, m.index, m[0].length)
books.push({ title, author, desc, pos: m.index })
}
}
return books
.sort((a, b) => a.pos - b.pos)
.map(({ title, author, desc }) => ({
id: `ext-${`${title}|${author}`.toLowerCase().replace(/\W/g, '-')}`,
title,
author,
description: desc,
coverUrl: undefined,
genres: [],
sources: [],
}))
}
export function extractAllBooks(text: string, userQuery: string): Book[] {
const externalBooks = extractExternalBooks(text)
if (externalBooks.length > 0) return externalBooks
if (!isBookQuery(userQuery) && !isBookLikeResponse(text)) return []
if (!isBookQuery(userQuery)) {
if (extractFilmIds(text).length > 0 || /\[\[film_ext:/.test(text)) return []
if (extractSongIds(text).length > 0 || /\[\[song_ext:/.test(text)) return []
}
if (isNewsLikeResponse(text)) return []
const cleanText = text.replace(/\n---\n\s*(?:Sources|References|Links):?\s*\n[\s\S]*$/i, '')
return extractBooksFromPatterns(cleanText)
}
// ─── TV Series extraction ─────────────────────────────────────────
function extractExternalTVSeries(text: string): TVSeries[] {
const series: TVSeries[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
const re = new RegExp(TV_EXT_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const title = match[1].trim()
const creator = match[2].trim()
const year = match[3] ? parseInt(match[3], 10) : undefined
const key = title.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
series.push({
id: `ext-${key.replace(/\W/g, '-')}`,
title,
creator: creator || undefined,
year,
synopsis: extractDescriptionForTag(text, match.index, match[0].length),
genres: [],
sources: [],
})
}
return series
}
function isTVLikeResponse(text: string): boolean {
return /\b(season|episodes?|showrunner|streaming|renewed|cancelled|premiere|network|HBO|Netflix|AMC|FX|Apple TV|Disney\+)\b/i.test(text) &&
(text.match(/\bseason\b/gi)?.length ?? 0) >= 2
}
function extractTVSeriesFromPatterns(text: string): TVSeries[] {
const series: { title: string; desc: string; pos: number }[] = []
const seen = new Set<string>()
const patterns: RegExp[] = [
/"([^"]{2,60})"\s*[-–—]\s*(?:a |an )?(?:series|show|tv)/gi,
/\*\*([^*]{2,60})\*\*\s*[-–—:]\s*(?:a |an )?(?:\w+ )?(?:series|show|drama|comedy|thriller|animated)/gi,
/(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n]{2,60}?)\*{0,2}\s*\((\d{4})(?:[-]\d{0,4})?(?:,\s*\d+ seasons?)?\)/gm,
]
for (const re of patterns) {
let m: RegExpExecArray | null
const rx = new RegExp(re.source, re.flags)
while ((m = rx.exec(text)) !== null) {
const title = m[1].trim().replace(/^\*\*|\*\*$/g, '')
if (title.length < 2) continue
if (/\[\[(film|song|podcast|book|tv)(_ext)?:/.test(title)) continue
const key = title.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
const desc = extractDescriptionForTag(text, m.index, m[0].length)
series.push({ title, desc, pos: m.index })
}
}
return series
.sort((a, b) => a.pos - b.pos)
.map(({ title, desc }) => ({
id: `ext-${title.toLowerCase().replace(/\W/g, '-')}`,
title,
synopsis: desc,
genres: [],
sources: [],
}))
}
function convertFilmExtToTVSeries(text: string): TVSeries[] {
const series: TVSeries[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
const re = new RegExp(FILM_EXT_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const title = match[1].trim()
const year = parseInt(match[2], 10)
const creator = match[3].trim()
const key = title.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
series.push({
id: `ext-${key.replace(/\W/g, '-')}`,
title,
year,
synopsis: extractDescriptionForTag(text, match.index, match[0].length),
creator,
genres: [],
sources: [],
})
}
return series
}
export function extractAllTVSeries(text: string, userQuery: string): TVSeries[] {
const external = extractExternalTVSeries(text)
if (external.length > 0) return external
if (!isTVQuery(userQuery) && !isTVLikeResponse(text)) return []
if (isNewsLikeResponse(text)) return []
if (isTVQuery(userQuery) && /\[\[film_ext:/.test(text)) {
return convertFilmExtToTVSeries(text)
}
if (extractFilmIds(text).length > 0) return []
return extractTVSeriesFromPatterns(text)
}
// ─── Image extraction ─────────────────────────────────────────────
function extractImages(text: string): ImageItem[] {
const images: ImageItem[] = []
const seen = new Set<string>()
const mdImgRe = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/gi
let m: RegExpExecArray | null
while ((m = mdImgRe.exec(text)) !== null) {
const alt = m[1].trim()
const url = m[2].trim()
if (seen.has(url)) continue
seen.add(url)
const domain = new URL(url).hostname.replace(/^www\./, '')
images.push({
id: `img-${seen.size}`,
url,
alt: alt || undefined,
title: alt || undefined,
source: domain,
})
}
const urlRe = /(https?:\/\/[^\s)"'>]+\.(?:jpg|jpeg|png|gif|webp|svg|avif|bmp|tiff)(?:\?[^\s)"'>]*)?)/gi
while ((m = urlRe.exec(text)) !== null) {
const url = m[1].trim()
if (seen.has(url)) continue
seen.add(url)
const domain = new URL(url).hostname.replace(/^www\./, '')
images.push({
id: `img-${seen.size}`,
url,
source: domain,
})
}
return images
}
export function extractAllImages(text: string, userQuery: string): ImageItem[] {
const images = extractImages(text)
if (images.length === 0) return []
if (isImageQuery(userQuery) || images.length >= 2) return images
return []
}
// ─── Place extraction ─────────────────────────────────────────────
function extractExternalPlaces(text: string): Place[] {
const places: Place[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
const re = new RegExp(PLACE_EXT_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const name = match[1].trim()
const cuisine = match[2]?.trim() || undefined
const city = match[3]?.trim() || undefined
const rating = match[4] ? parseFloat(match[4]) : undefined
const priceLevel = match[5] ? parseInt(match[5], 10) : undefined
const address = match[6]?.trim() || undefined
const key = name.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
places.push({
id: `ext-place-${key.replace(/\W/g, '-')}`,
name,
cuisine,
city,
rating: rating && !isNaN(rating) ? rating : undefined,
priceLevel: priceLevel && priceLevel >= 1 && priceLevel <= 4 ? priceLevel : undefined,
address,
description: extractDescriptionForTag(text, match.index, match[0].length),
sources: [],
})
}
return places
}
function extractPlacesFromPatterns(text: string): Place[] {
const places: { name: string; cuisine?: string; city?: string; rating?: number; priceLevel?: number; desc: string; pos: number }[] = []
const seen = new Set<string>()
const patterns: RegExp[] = [
/\*\*([^*]{2,60})\*\*\s*[-–—:]\s*(?:a |an )?(?:(\w[\w\s]{1,30}?)\s+)?(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill|eatery|spot|joint)/gi,
/(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n]{2,60}?)\*{0,2}\s*[-–—(]\s*(?:(\w[\w\s&]{1,30}?)\s+)?(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill|cuisine|food|dining)/gim,
]
for (const re of patterns) {
let m: RegExpExecArray | null
const rx = new RegExp(re.source, re.flags)
while ((m = rx.exec(text)) !== null) {
const name = m[1].trim().replace(/^\*\*|\*\*$/g, '').replace(/^\[|\]$/g, '')
if (name.length < 2) continue
if (/\[\[(film|song|podcast|book|tv|place)(_ext)?:/.test(name)) continue
const key = name.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
const cuisine = m[2]?.trim()
const desc = extractDescriptionForTag(text, m.index, m[0].length)
const nearby = text.slice(m.index, m.index + 300)
const ratingMatch = /(\d\.?\d?)\s*(?:\/\s*5|stars?|★)/i.exec(nearby)
const rating = ratingMatch ? parseFloat(ratingMatch[1]) : undefined
const priceMatch = /(\${1,4})\b/.exec(nearby)
const priceLevel = priceMatch ? priceMatch[1].length : undefined
places.push({ name, cuisine, desc, rating, priceLevel, pos: m.index })
}
}
return places
.sort((a, b) => a.pos - b.pos)
.map(({ name, cuisine, desc, rating, priceLevel }) => ({
id: `ext-place-${name.toLowerCase().replace(/\W/g, '-')}`,
name,
cuisine,
rating,
priceLevel,
description: desc,
sources: [],
}))
}
export function extractAllPlaces(text: string, userQuery: string): Place[] {
const external = extractExternalPlaces(text)
if (external.length > 0) return external
if (!isPlaceQuery(userQuery) && !isPlaceLikeResponse(text)) return []
if (isNewsLikeResponse(text)) return []
return extractPlacesFromPatterns(text)
}
// ─── Tag stripping ────────────────────────────────────────────────
export function stripFilmTags(text: string): string {
return text
.replace(FILM_TAG_RE, '')
.replace(FILM_EXT_RE, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export function stripSongTags(text: string): string {
return text
.replace(SONG_TAG_RE, '')
.replace(SONG_EXT_RE, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export function stripPodcastTags(text: string): string {
return text
.replace(PODCAST_TAG_RE, '')
.replace(PODCAST_EXT_RE, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export function stripBookTags(text: string): string {
return text
.replace(BOOK_TAG_RE, '')
.replace(BOOK_EXT_RE, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export function stripTVTags(text: string): string {
return text
.replace(TV_EXT_RE, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export function stripPlaceTags(text: string): string {
return text
.replace(PLACE_EXT_RE, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export function stripContentTags(text: string): string {
return stripFilmTags(stripSongTags(stripPodcastTags(stripBookTags(stripTVTags(stripPlaceTags(text))))))
}
export function stripMarkdownLinks(text: string): string {
return text
.replace(/^[\s]*[-*]\s*\[[^\]]+\]\(https?:\/\/[^)\s]+\)\s*$/gm, '')
.replace(/\s*\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, (_, title) => ` ${title}`)
.replace(/\n{3,}/g, '\n\n')
.trim()
}
@@ -0,0 +1,144 @@
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'place' | 'news' | 'websites' | 'magazine' | 'code' | 'design-system' | 'nostr'
export interface MagazineSection {
title: string
content: string
imageUrl?: string
author?: string
url?: string
group?: string
}
// ─── Query classifiers ───────────────────────────────────────────
export function isNewsQuery(q: string): boolean {
const lower = q.toLowerCase().trim()
if (!lower) return false
return /\b(news|latest|recent|current|what'?s happening|updates? about)\b/.test(lower) ||
/what'?s the latest|latest \w+ news/.test(lower) ||
/what are people saying|what'?s the word|what do people think/.test(lower)
}
export function isNewsLikeResponse(text: string): boolean {
const lower = text.toLowerCase()
return /for instant .* news|check these sources|for (the )?latest (bitcoin )?news|direct sources/i.test(lower) ||
/(have )?access to (live )?web search|want me to (go back and )?search/i.test(lower)
}
export function isMusicQuery(q: string): boolean {
if (!q) return false
return /\b(song|songs|music|track|tracks|playlist|album|albums|listen|listening|sing|singing|singer|band|bands|artist|artists|rapper|rap|hip hop|r&b|rock|jazz|classical|edm|electronic|pop music|concert|vinyl|soundtrack|anthem|beat|beats|melody|melodies|tune|tunes|lyric|lyrics|acoustic|remix|dj)\b/i.test(q) ||
/recommend.*(song|music|track|listen)/i.test(q) ||
/play\s+(me\s+)?(some|a)\b/i.test(q)
}
export function isWebsitesQuery(q: string): boolean {
const lower = q.toLowerCase().trim()
return /\b(website|websites|where to check|best places? to check|places? to (look|check|find)|check online|resources?|sources? to (check|read|visit))\b/.test(lower) ||
/where (can i|should i) (check|look|find)/.test(lower)
}
export function isWebsitesLikeResponse(text: string): boolean {
const lower = text.toLowerCase()
return /best places? to check|check online yourself|places? to check online|websites? to (visit|check|read)/i.test(lower)
}
export function isBookQuery(q: string): boolean {
return /\b(book|books|read|reading|novel|novels|author|nonfiction|non-fiction|recommend.*read|must.read|literature)\b/i.test(q)
}
export function isBookLikeResponse(text: string): boolean {
return /\b(novel|author|pages?|ISBN|published|bestsell|literary|fiction|nonfiction|book)\b/i.test(text) &&
(text.match(/\bby\s+[A-Z]/g)?.length ?? 0) >= 2
}
export function isTVQuery(q: string): boolean {
return /\b(tv show|tv series|series|television|streaming|binge|watch|recommend.*show|best show|season)\b/i.test(q)
}
export function isImageQuery(q: string): boolean {
return /\b(image|images|photo|photos|picture|pictures|screenshot|screenshots|gallery|artwork|illustration|visual|infographic|diagram|chart)\b/i.test(q)
}
export function isPlaceQuery(q: string): boolean {
return /\b(restaurant|restaurants|place|places|food|eat|eating|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse|where to eat|good food|best food|where should i eat|recommend.*eat|recommend.*restaurant|recommend.*place)\b/i.test(q)
}
export function isPlaceLikeResponse(text: string): boolean {
return /\b(restaurant|cuisine|menu|reserv|dining|address|open|hours|price range|\$\$|\$\$\$|michelin|yelp|rating)\b/i.test(text) &&
(text.match(/\b(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill)\b/gi)?.length ?? 0) >= 2
}
// ─── Tab filtering ────────────────────────────────────────────────
export function extractQueryContext(q: string): string {
const stop = /\b(what|is|are|the|a|an|latest|recent|current|news|about|for|how|why|when|where|can|could|should|would|tell|me|please|best|good)\b/gi
const cleaned = q.replace(stop, ' ').replace(/\s+/g, ' ').trim().slice(0, 60)
return cleaned || ''
}
export function preferredFirstTab(userQuery: string): ContentTab | null {
const q = userQuery.toLowerCase().trim()
if (/\b(film|movie|movies)\b/.test(q)) return 'film'
if (/\b(song|music|track|album|band|artist|listen)\b/.test(q)) return 'song'
if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast'
if (/\b(book|books|read|reading|novel|author|nonfiction|non-fiction)\b/.test(q)) return 'book'
if (/\b(tv show|tv series|series|television|streaming|binge|watch)\b/.test(q)) return 'tvshow'
if (/\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/.test(q)) return 'image'
if (/\b(restaurant|restaurants|place|places|food|eat|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse)\b/.test(q)) return 'place'
if (isNewsQuery(q)) return 'news'
if (isWebsitesQuery(q)) return 'websites'
return null
}
export function filterTabsByContext(
userQuery: string,
hasFilms: boolean,
hasSongs: boolean,
hasPodcasts: boolean,
hasBooks: boolean,
hasTVSeries: boolean,
hasImages: boolean,
hasPlaces: boolean,
hasNews: boolean,
hasWebsites: boolean,
hasMagazine: boolean,
): ContentTab[] {
const q = userQuery.toLowerCase().trim()
const preferred = preferredFirstTab(userQuery)
if (isNewsQuery(q)) {
const tabs: ContentTab[] = []
if (hasMagazine) tabs.push('magazine')
if (hasNews) tabs.push('news')
if (hasWebsites) tabs.push('websites')
if (hasPodcasts) tabs.push('podcast')
return tabs
}
if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasPlaces && !hasNews && !hasWebsites) {
return ['magazine']
}
if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasPlaces && !hasNews && !hasMagazine) {
return ['websites']
}
const all: ContentTab[] = []
if (hasFilms) all.push('film')
if (hasBooks) all.push('book')
if (hasTVSeries) all.push('tvshow')
if (hasImages) all.push('image')
if (hasPlaces) all.push('place')
if (hasSongs) all.push('song')
if (hasPodcasts) all.push('podcast')
if (hasMagazine) all.push('magazine')
if (hasNews) all.push('news')
if (hasWebsites) all.push('websites')
if (preferred && all.includes(preferred)) {
const rest = all.filter((t) => t !== preferred)
return [preferred, ...rest]
}
return all
}
+50 -20
View File
@@ -114,6 +114,7 @@ interface ChatMessage {
async function streamMock(
messages: ChatMessage[],
onToken: (text: string) => void,
signal?: AbortSignal,
): Promise<void> {
const lastUser = messages.filter((m) => m.role === 'user').pop()
const text = lastUser
@@ -121,6 +122,7 @@ async function streamMock(
: 'Hello! I am AIUI running in mock mode.'
for (const char of text) {
if (signal?.aborted) return
onToken(char)
await new Promise((r) => setTimeout(r, 12))
}
@@ -132,6 +134,7 @@ async function streamClaude(
onError: (err: string) => void,
systemPrompt: string,
webSearch: boolean,
signal?: AbortSignal,
): Promise<void> {
const res = await fetch(CLAUDE_PATH, {
method: 'POST',
@@ -143,6 +146,7 @@ async function streamClaude(
stream: true,
webSearch,
}),
signal,
})
if (!res.ok) {
@@ -158,7 +162,7 @@ async function streamClaude(
} else if (parsed.type === 'error') {
onError(parsed.error?.message ?? 'Claude stream error')
}
}, onError)
}, onError, signal)
}
async function streamOpenRouter(
@@ -166,6 +170,7 @@ async function streamOpenRouter(
onToken: (text: string) => void,
onError: (err: string) => void,
systemPrompt: string,
signal?: AbortSignal,
): Promise<void> {
const orMessages = [
{ role: 'system' as const, content: systemPrompt },
@@ -184,6 +189,7 @@ async function streamOpenRouter(
messages: orMessages,
stream: true,
}),
signal,
})
if (!res.ok) {
@@ -197,13 +203,14 @@ async function streamOpenRouter(
const parsed = JSON.parse(data)
const delta = parsed.choices?.[0]?.delta?.content
if (delta) onToken(delta)
}, onError)
}, onError, signal)
}
async function readSSE(
res: Response,
onData: (data: string) => void,
onError: (err: string) => void,
signal?: AbortSignal,
): Promise<void> {
const reader = res.body?.getReader()
if (!reader) {
@@ -214,25 +221,33 @@ async function readSSE(
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
try {
while (true) {
if (signal?.aborted) {
reader.cancel()
return
}
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || !trimmed.startsWith('data: ')) continue
const payload = trimmed.slice(6)
if (payload === '[DONE]') return
try {
onData(payload)
} catch {
// skip malformed chunks
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || !trimmed.startsWith('data: ')) continue
const payload = trimmed.slice(6)
if (payload === '[DONE]') return
try {
onData(payload)
} catch {
// skip malformed chunks
}
}
}
} finally {
reader.cancel().catch(() => {})
}
}
@@ -247,11 +262,23 @@ function formatWebSearchContext(results: { title: string; url: string; content?:
- You MAY add [[podcast_ext:...]] or [[film_ext:...]] tags for "to learn more" recommendations after your answer.\n\n${lines.join('\n')}`
}
let currentAbort: AbortController | null = null
export function useAI() {
const chatStore = useChatStore()
function stopGeneration() {
if (currentAbort) {
currentAbort.abort()
currentAbort = null
}
chatStore.isStreaming = false
}
async function sendMessage(userText: string) {
const provider = activeProvider.value
currentAbort = new AbortController()
const signal = currentAbort.signal
let convId = chatStore.activeConversationId
if (!convId) {
@@ -294,23 +321,26 @@ export function useAI() {
try {
if (provider === 'claude') {
await streamClaude(history, onToken, onError, systemPrompt, chatStore.webSearchEnabled)
await streamClaude(history, onToken, onError, systemPrompt, chatStore.webSearchEnabled, signal)
} else if (provider === 'openrouter') {
await streamOpenRouter(history, onToken, onError, systemPrompt)
await streamOpenRouter(history, onToken, onError, systemPrompt, signal)
} else {
await streamMock(history, onToken)
await streamMock(history, onToken, signal)
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
const msg = err instanceof Error ? err.message : String(err)
console.error(`[AIUI] Connection error:`, err)
chatStore.appendToLastMessage(cid, `\n\n⚠ Connection error: ${msg}`)
} finally {
currentAbort = null
chatStore.isStreaming = false
}
}
return {
sendMessage,
stopGeneration,
activeProvider,
activeModel,
availableProviders,
@@ -0,0 +1,103 @@
import { ref, computed, type ComputedRef } from 'vue'
export interface BannerFallbackOptions {
/** Primary image URL candidates, tried in order */
primaryUrls: () => (string | undefined | null)[]
/** Async fetch to try when all primary URLs fail */
apiFetch: () => Promise<{ posterUrl: string | null; backdropUrl: string | null }>
/** Title for gradient generation */
title: () => string
/** Optional seed override for gradient hue */
gradientSeed?: () => string
}
export interface BannerFallbackReturn {
bannerSrc: ComputedRef<string | null>
fallbackGradient: ComputedRef<string>
onBannerError: () => void
}
export function useBannerFallback(options: BannerFallbackOptions): BannerFallbackReturn {
const primaryIndex = ref(0)
const stage = ref<'primary' | 'api' | 'done'>('primary')
const apiUrl = ref<string | null>(null)
let apiFetching = false
const bannerSrc = computed<string | null>(() => {
if (stage.value === 'done') return null
if (stage.value === 'primary') {
const urls = options.primaryUrls()
// Find first non-null URL starting from primaryIndex
for (let i = primaryIndex.value; i < urls.length; i++) {
if (urls[i]) return urls[i]!
}
// No primary URLs available — skip to API immediately
return null
}
if (stage.value === 'api' && apiUrl.value) return apiUrl.value
return null
})
const fallbackGradient = computed(() => {
const seed = options.gradientSeed ? options.gradientSeed() : options.title()
const hue = [...seed].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 (stage.value === 'primary') {
const urls = options.primaryUrls()
// Advance to next primary URL
let nextIdx = primaryIndex.value + 1
while (nextIdx < urls.length && !urls[nextIdx]) nextIdx++
if (nextIdx < urls.length) {
primaryIndex.value = nextIdx
return
}
// All primaries exhausted — try API
if (!apiFetching) {
apiFetching = true
try {
const result = await options.apiFetch()
const url = result.backdropUrl ?? result.posterUrl
if (url) {
apiUrl.value = url
stage.value = 'api'
return
}
} catch { /* ignore */ }
}
stage.value = 'done'
return
}
if (stage.value === 'api') {
stage.value = 'done'
}
}
// If no primary URLs at all, trigger API fetch on first render
const urls = options.primaryUrls()
const hasAnyPrimary = urls.some(u => !!u)
if (!hasAnyPrimary && !apiFetching) {
apiFetching = true
options.apiFetch().then(result => {
const url = result.backdropUrl ?? result.posterUrl
if (url) {
apiUrl.value = url
stage.value = 'api'
} else {
stage.value = 'done'
}
}).catch(() => {
stage.value = 'done'
})
}
return { bannerSrc, fallbackGradient, onBannerError }
}
@@ -183,6 +183,43 @@ export function useCodeContext() {
return `// ${name}\n`
}
async function createProject(name: string): Promise<void> {
const safeName = name.trim().replace(/[^a-zA-Z0-9_\-. ]/g, '')
if (!safeName) return
const projectPath = `${PROJECTS_ROOT}/${safeName}`
try {
const res = await fetch('/api/fs/mkdir', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: projectPath }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
console.warn('[AIUI code] Failed to create directory:', data.error ?? res.status)
}
} catch (err) {
console.warn('[AIUI code] Could not create directory:', err)
}
const newProject: ProjectInfo = {
name: safeName,
path: projectPath,
isGit: false,
language: 'Unknown',
}
projectList.value = [newProject, ...projectList.value]
selectProject(newProject)
}
function clearActiveFile(): void {
activeFile.value = null
activeFileContent.value = ''
activeFileLanguage.value = 'plaintext'
}
return {
// State
codeMode,
@@ -202,5 +239,7 @@ export function useCodeContext() {
openFile,
loadProjects,
detectLanguage,
createProject,
clearActiveFile,
}
}
File diff suppressed because it is too large Load Diff
@@ -207,6 +207,15 @@ export async function handleImgError(
}
}
if (img.dataset.fallback !== 'wiki') {
const wiki = await fetchWikipediaImage(title, 'film')
if (wiki) {
img.dataset.fallback = 'wiki'
img.src = wiki
return
}
}
img.dataset.fallback = 'done'
img.src = generatePosterFallback(title, year)
}
@@ -364,3 +373,132 @@ export async function fetchMusicCover(
return null
}
}
// ---------------------------------------------------------------------------
// Wikipedia image source (free, no key)
// ---------------------------------------------------------------------------
const wikiImageCache = new Map<string, string | null>()
/** Fetch an image from Wikipedia REST API. Free, no API key needed. */
export async function fetchWikipediaImage(
title: string,
disambiguator?: string,
): Promise<string | null> {
const key = `${title.toLowerCase().trim()}|${(disambiguator ?? '').toLowerCase()}`
if (wikiImageCache.has(key)) return wikiImageCache.get(key) ?? null
const tryTitle = async (t: string): Promise<string | null> => {
try {
const encoded = encodeURIComponent(t.trim().replace(/\s+/g, '_'))
const res = await fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encoded}`)
if (!res.ok) return null
const data = (await res.json()) as {
thumbnail?: { source?: string }
originalimage?: { source?: string }
}
return data.originalimage?.source ?? data.thumbnail?.source ?? null
} catch {
return null
}
}
// Try exact title first
let url = await tryTitle(title)
// Try with disambiguator suffix if no result
if (!url && disambiguator) {
url = await tryTitle(`${title} (${disambiguator})`)
}
wikiImageCache.set(key, url)
return url
}
// ---------------------------------------------------------------------------
// Google Books image source (free, no key)
// ---------------------------------------------------------------------------
/** Fetch book cover from Google Books API. Free, no API key needed. */
export async function fetchGoogleBooksImage(
title: string,
author?: string,
): Promise<string | null> {
const key = bookCacheKey(title, author ?? '')
const gKey = `gbooks:${key}`
if (wikiImageCache.has(gKey)) return wikiImageCache.get(gKey) ?? null
try {
const q = author ? `intitle:${title}+inauthor:${author}` : `intitle:${title}`
const res = await fetch(
`https://www.googleapis.com/books/v1/volumes?q=${encodeURIComponent(q)}&maxResults=1`,
)
if (!res.ok) return null
const data = (await res.json()) as {
items?: { volumeInfo?: { imageLinks?: { thumbnail?: string; smallThumbnail?: string } } }[]
}
const links = data.items?.[0]?.volumeInfo?.imageLinks
let url = links?.thumbnail ?? links?.smallThumbnail ?? null
// Google Books returns http URLs and small sizes — upgrade
if (url) {
url = url.replace(/^http:/, 'https:').replace(/&edge=curl/g, '')
// Request larger zoom
if (!url.includes('zoom=')) url += '&zoom=2'
}
wikiImageCache.set(gKey, url)
return url
} catch {
return null
}
}
// ---------------------------------------------------------------------------
// Chained fetchers — try multiple sources in order
// ---------------------------------------------------------------------------
/** Film image: TMDB → Wikipedia */
export async function fetchFilmImage(
title: string,
year?: number,
): Promise<{ posterUrl: string | null; backdropUrl: string | null }> {
// Try TMDB first
const tmdb = await fetchTmdbPoster(title, year)
if (tmdb.posterUrl || tmdb.backdropUrl) return tmdb
// Fall back to Wikipedia
const wiki = await fetchWikipediaImage(title, 'film')
if (wiki) return { posterUrl: wiki, backdropUrl: null }
return { posterUrl: null, backdropUrl: null }
}
/** TV series image: TMDB → Wikipedia */
export async function fetchTVImage(
title: string,
year?: number,
): Promise<{ posterUrl: string | null; backdropUrl: string | null }> {
const tmdb = await fetchTmdbTVPoster(title, year)
if (tmdb.posterUrl || tmdb.backdropUrl) return tmdb
const wiki = await fetchWikipediaImage(title, 'TV series')
if (wiki) return { posterUrl: wiki, backdropUrl: null }
return { posterUrl: null, backdropUrl: null }
}
/** Book image: Open Library → Google Books → Wikipedia */
export async function fetchBookImage(
title: string,
author?: string,
): Promise<string | null> {
// Try Open Library first
const ol = await fetchBookCover(title, author ?? '')
if (ol) return ol
// Try Google Books
const gb = await fetchGoogleBooksImage(title, author)
if (gb) return gb
// Try Wikipedia
const wiki = await fetchWikipediaImage(title, 'novel')
return wiki
}
+32 -9
View File
@@ -26,12 +26,11 @@
<!-- Grid/list panel (on wide desktop: always show grid; on regular: show when no detail) -->
<div
v-if="panelOpen && hasGridContent && (isWideDesktop || !hasDetailOpen)"
class="flex-1 min-w-0 flex flex-col"
class="flex-1 min-w-0 flex flex-col relative"
>
<CloseButton @click="closePanel" />
<div
v-if="availableTabs.length > 1"
class="shrink-0 flex items-center justify-between gap-2 px-4 py-2"
class="shrink-0 flex items-center gap-2 px-4 pr-12 py-3"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
@@ -54,6 +53,8 @@
</div>
<ContentGridView
:active-tab="activeTab"
:is-wide-desktop="isWideDesktop"
:is-mobile="isMobile"
:panel-films="panelFilms"
:panel-books="panelBooks"
:panelTVSeries="panelTVSeries"
@@ -164,10 +165,18 @@
<div v-show="mobileTab === 'content'" class="flex-1 min-h-0 flex flex-col p-2 pb-0">
<div class="flex-1 min-h-0 path-glass-card flex flex-col rounded-2xl overflow-hidden">
<template v-if="panelOpen">
<div
v-if="availableTabs.length > 1"
class="shrink-0 flex items-center gap-2 px-3 pt-2 pb-1 overflow-x-auto scrollbar-hide"
>
<div class="shrink-0 flex items-center gap-2 px-3 pt-2 pb-1 overflow-x-auto scrollbar-hide">
<button
class="p-1.5 rounded-lg transition-colors shrink-0"
:class="isDark
? 'text-white/50 hover:text-white/80 hover:bg-white/5'
: 'text-gray-400 hover:text-gray-700 hover:bg-black/5'"
@click="mobileTab = 'chat'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
v-for="tab in availableTabs"
:key="tab"
@@ -184,6 +193,8 @@
</div>
<ContentGridView
:active-tab="activeTab"
:is-wide-desktop="isWideDesktop"
:is-mobile="isMobile"
:panel-films="panelFilms"
:panel-books="panelBooks"
:panelTVSeries="panelTVSeries"
@@ -349,7 +360,7 @@ import { usePlayer } from '@/composables/usePlayer'
import { useCodeContext } from '@/composables/useCodeContext'
const chatStore = useChatStore()
const { activeFile: codeActiveFile, isCodeMode } = useCodeContext()
const { activeFile: codeActiveFile, isCodeMode, clearActiveFile: clearCodeFile } = useCodeContext()
const { hasTrack } = usePlayer()
const { isDark } = useTheme()
@@ -384,6 +395,7 @@ const {
selectedArticle,
selectedWebsite,
selectedMagazineSection,
selectedDesignSystemItem,
closeFilmDetail,
closeBookDetail,
closeTVSeriesDetail,
@@ -417,19 +429,27 @@ function onResize() { windowWidth.value = window.innerWidth }
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))
// Auto-switch to content tab on mobile when panel opens or detail is selected
// Auto-switch to content tab on mobile when panel opens or content changes
watch(panelOpen, (open) => {
if (open && isMobile.value) mobileTab.value = 'content'
})
watch(panelTitle, () => {
if (panelOpen.value && isMobile.value && mobileTab.value === 'chat') mobileTab.value = 'content'
})
watch(activeTab, () => {
if (panelOpen.value && isMobile.value && mobileTab.value === 'chat') mobileTab.value = 'content'
})
const hasDetailOpen = computed(() =>
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value ||
selectedImage.value || selectedPlace.value || selectedSong.value || selectedPodcast.value || selectedArticle.value || selectedWebsite.value || selectedMagazineSection.value ||
selectedDesignSystemItem.value ||
(isCodeMode.value && codeActiveFile.value))
)
watch(hasDetailOpen, (open) => {
if (open && isMobile.value) mobileTab.value = 'context'
if (!open && isMobile.value && mobileTab.value === 'context') mobileTab.value = 'content'
})
const hasGridContent = computed(() =>
@@ -447,6 +467,7 @@ function closeAllDetails() {
closeArticleDetail()
closeWebsiteDetail()
closeMagazineSectionDetail()
clearCodeFile()
}
// Context navigation flat list of all detail-openable items
@@ -532,6 +553,8 @@ const TAB_LABELS: Record<ContentTab, string> = {
websites: 'Websites',
magazine: 'Brief',
code: 'Code',
'design-system': 'Design',
nostr: 'Nostr',
}
function tabLabel(tab: ContentTab): string {
+206
View File
@@ -0,0 +1,206 @@
import type { Plugin } from 'vite'
import type { Connect } from 'vite'
import { readdirSync, statSync, readFileSync, existsSync, mkdirSync } from 'fs'
import { join, resolve, relative } from 'path'
const PROJECTS_ROOT = '/Users/dorian/Projects'
const IGNORED = new Set([
'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.output',
'.cache', '.turbo', '.vercel', '.netlify', '__pycache__', 'target',
'.DS_Store', 'coverage', '.vite', '.angular',
])
const MAX_FILE_SIZE = 1_000_000 // 1MB
const MAX_TREE_ENTRIES = 500
const MAX_DEPTH = 4
/** Validate path is within PROJECTS_ROOT to prevent directory traversal */
function isPathSafe(p: string): boolean {
const resolved = resolve(p)
return resolved.startsWith(PROJECTS_ROOT)
}
function parseUrl(req: Connect.IncomingMessage): URL {
return new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`)
}
/** GET /api/fs/list — list projects */
function handleList(_req: Connect.IncomingMessage, res: any) {
try {
const entries = readdirSync(PROJECTS_ROOT, { withFileTypes: true })
const projects = entries
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
.map(e => {
const fullPath = join(PROJECTS_ROOT, e.name)
const isGit = existsSync(join(fullPath, '.git'))
let language = 'Unknown'
// Detect project language from config files
try {
const files = readdirSync(fullPath).map(f => f)
if (files.includes('package.json')) language = 'TypeScript/JavaScript'
else if (files.includes('Cargo.toml')) language = 'Rust'
else if (files.includes('go.mod')) language = 'Go'
else if (files.includes('requirements.txt') || files.includes('pyproject.toml')) language = 'Python'
else if (files.includes('pom.xml') || files.includes('build.gradle')) language = 'Java'
else if (files.includes('Package.swift')) language = 'Swift'
else if (files.includes('Gemfile')) language = 'Ruby'
else if (files.includes('composer.json')) language = 'PHP'
else if (files.some(f => f.endsWith('.csproj') || f.endsWith('.sln'))) language = 'C#'
} catch { /* ignore read errors */ }
return { name: e.name, path: fullPath, isGit, language }
})
.sort((a, b) => a.name.localeCompare(b.name))
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ projects }))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: String(err) }))
}
}
/** GET /api/fs/tree — recursive file tree */
function handleTree(req: Connect.IncomingMessage, res: any) {
const url = parseUrl(req)
const dirPath = url.searchParams.get('path')
if (!dirPath || !isPathSafe(dirPath)) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
return
}
let entryCount = 0
function walk(dir: string, depth: number): any[] {
if (depth > MAX_DEPTH || entryCount >= MAX_TREE_ENTRIES) return []
try {
const entries = readdirSync(dir, { withFileTypes: true })
.filter(e => !IGNORED.has(e.name) && !e.name.startsWith('.'))
.sort((a, b) => {
// Directories first, then alphabetical
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1
return a.name.localeCompare(b.name)
})
const result: any[] = []
for (const entry of entries) {
if (entryCount >= MAX_TREE_ENTRIES) break
entryCount++
const fullPath = join(dir, entry.name)
const relPath = relative(dirPath, fullPath)
if (entry.isDirectory()) {
result.push({
name: entry.name,
path: relPath,
isDirectory: true,
children: walk(fullPath, depth + 1),
})
} else {
result.push({
name: entry.name,
path: relPath,
isDirectory: false,
})
}
}
return result
} catch {
return []
}
}
try {
const files = walk(dirPath, 0)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ files }))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: String(err) }))
}
}
/** GET /api/fs/read — read file content */
function handleRead(req: Connect.IncomingMessage, res: any) {
const url = parseUrl(req)
const filePath = url.searchParams.get('path')
if (!filePath || !isPathSafe(filePath)) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
return
}
try {
const stat = statSync(filePath)
if (stat.isDirectory()) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Path is a directory' }))
return
}
if (stat.size > MAX_FILE_SIZE) {
res.writeHead(413, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'File too large', size: stat.size }))
return
}
const content = readFileSync(filePath, 'utf-8')
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ content, size: stat.size }))
} catch (err: any) {
const code = err?.code === 'ENOENT' ? 404 : 500
res.writeHead(code, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: String(err) }))
}
}
/** POST /api/fs/mkdir — create a directory */
function handleMkdir(req: Connect.IncomingMessage, res: any) {
let body = ''
req.on('data', (chunk: Buffer) => { body += chunk.toString() })
req.on('end', () => {
try {
const { path: dirPath } = JSON.parse(body)
if (!dirPath || !isPathSafe(dirPath)) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
return
}
if (existsSync(dirPath)) {
res.writeHead(409, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Directory already exists' }))
return
}
mkdirSync(dirPath, { recursive: true })
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ ok: true, path: dirPath }))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: String(err) }))
}
})
}
export function fsPlugin(): Plugin {
return {
name: 'aiui-fs',
configureServer(server) {
server.middlewares.use('/api/fs/list', (req, res, next) => {
if (req.method !== 'GET') return next()
handleList(req, res)
})
server.middlewares.use('/api/fs/tree', (req, res, next) => {
if (req.method !== 'GET') return next()
handleTree(req, res)
})
server.middlewares.use('/api/fs/read', (req, res, next) => {
if (req.method !== 'GET') return next()
handleRead(req, res)
})
server.middlewares.use('/api/fs/mkdir', (req, res, next) => {
if (req.method !== 'POST') return next()
handleMkdir(req, res)
})
},
}
}
+2
View File
@@ -8,6 +8,7 @@ import { devChatsPlugin } from './vite-dev-chats'
import { musicSearchPlugin } from './vite-music-search'
import { webSearchPlugin } from './vite-web-search'
import { rssPlugin } from './vite-rss'
import { fsPlugin } from './vite-fs'
export default defineConfig({
plugins: [
@@ -18,6 +19,7 @@ export default defineConfig({
musicSearchPlugin(),
webSearchPlugin(),
rssPlugin(),
fsPlugin(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'icon.svg', 'apple-touch-icon-180x180.png'],