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:
co-authored by
Claude Opus 4.6
parent
e8fc54cade
commit
00bdc055ba
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user