Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,181 @@
<template>
<div class="app-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<div class="relative w-full overflow-hidden">
<div
class="w-full aspect-[16/7] flex items-center justify-center"
:style="{ background: appGradient }"
>
<span class="text-5xl font-bold text-white/20">{{ app.name.charAt(0) }}</span>
</div>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
<button
class="absolute top-3 left-3 min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
@click="$emit('back')"
>
<svg class="w-4 h-4 text-white/90" 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="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ app.name }}</h2>
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-1 text-xs text-white/60">
<span
class="px-1.5 py-0.5 rounded text-xs font-medium bg-white/15"
>{{ categoryLabel }}</span>
<span
v-for="p in app.platforms"
:key="p"
class="text-xs"
>{{ platformLabel(p) }}</span>
</div>
</div>
</div>
<div class="p-4 space-y-5">
<p class="text-sm leading-relaxed"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ app.longDescription }}
</p>
<div v-if="app.howTo?.length">
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Getting Started</h4>
<ol class="space-y-2">
<li
v-for="(step, i) in app.howTo"
:key="i"
class="flex gap-2.5 text-xs"
>
<span
class="w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold shrink-0 mt-0.5"
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'"
>{{ i + 1 }}</span>
<span :class="isDark ? 'text-white/70' : 'text-gray-600'">{{ step }}</span>
</li>
</ol>
</div>
<div>
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Open</h4>
<a
:href="app.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<div
class="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold"
:style="{ background: appGradient }"
>
<span class="text-white/90">{{ app.name.charAt(0) }}</span>
</div>
<div>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ app.url.replace(/^https?:\/\//, '') }}</p>
<p class="text-xs"
:class="isDark ? 'text-white/30' : 'text-gray-400'">Official website</p>
</div>
</div>
<svg class="w-4 h-4" :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>
</a>
</div>
<div v-if="relatedApps.length > 0">
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Related Apps</h4>
<div class="space-y-2">
<button
v-for="related in relatedApps"
:key="related.id"
class="w-full text-left flex items-center gap-2.5 p-2.5 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
@click="$emit('selectApp', related)"
>
<div
class="w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold shrink-0"
:style="{ background: relatedGradient(related.id) }"
>
<span class="text-white/90">{{ related.name.charAt(0) }}</span>
</div>
<div class="min-w-0">
<p class="text-xs font-medium truncate"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ related.name }}</p>
<p class="text-xs truncate"
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ related.description }}</p>
</div>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { APP_DATABASE, type AppEntry } from '@/data/apps'
import { useTheme } from '@/composables/useTheme'
const props = defineProps<{ app: AppEntry }>()
defineEmits<{ back: []; selectApp: [app: AppEntry] }>()
const { isDark } = useTheme()
const categoryLabels: Record<string, string> = {
'nostr-client': 'Nostr Client',
'lightning-wallet': 'Lightning Wallet',
'bitcoin-wallet': 'Bitcoin Wallet',
privacy: 'Privacy',
node: 'Node Software',
'dev-tool': 'Dev Tool',
relay: 'Relay',
}
const categoryLabel = computed(() => categoryLabels[props.app.category] ?? props.app.category)
function platformLabel(p: string): string {
const labels: Record<string, string> = {
ios: 'iOS',
android: 'Android',
web: 'Web',
desktop: 'Desktop',
cli: 'CLI',
nodeos: 'Node',
}
return labels[p] ?? p
}
function hashToHue(id: string): number {
let hash = 0
for (let i = 0; i < id.length; i++) hash = id.charCodeAt(i) + ((hash << 5) - hash)
return Math.abs(hash % 360)
}
const appGradient = computed(() => {
const hue = hashToHue(props.app.id)
return `linear-gradient(135deg, hsl(${hue}, 60%, 35%), hsl(${(hue + 40) % 360}, 50%, 25%))`
})
function relatedGradient(id: string): string {
const hue = hashToHue(id)
return `linear-gradient(135deg, hsl(${hue}, 60%, 35%), hsl(${(hue + 40) % 360}, 50%, 25%))`
}
const relatedApps = computed(() => {
if (!props.app.relatedApps?.length) return []
return props.app.relatedApps
.map(id => APP_DATABASE.find(a => a.id === id))
.filter((a): a is AppEntry => !!a)
})
</script>
@@ -0,0 +1,167 @@
<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'">
{{ title }}
</h3>
<span class="text-xs font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredApps.length }} apps
</span>
</div>
<input
v-model="search"
type="text"
placeholder="Search apps..."
class="w-full px-3 py-2 rounded-lg text-base 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 flex-wrap gap-1.5">
<button
v-for="cat in categories"
:key="cat.value"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeCategory === cat.value
? '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="activeCategory = activeCategory === cat.value ? null : cat.value"
>
{{ cat.label }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="space-y-2">
<button
v-for="app in filteredApps"
:key="app.id"
class="w-full text-left p-3 rounded-xl transition-all duration-200 flex items-start gap-3"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
:aria-label="app.name"
@click="$emit('selectApp', app)"
>
<div
class="w-10 h-10 rounded-xl flex items-center justify-center text-lg font-bold shrink-0"
:style="{ background: appGradient(app.id) }"
>
<span class="text-white/90">{{ app.name.charAt(0) }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="text-xs font-semibold truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ app.name }}
</p>
<span
class="text-xs px-1.5 py-0.5 rounded font-medium shrink-0"
:class="isDark ? 'bg-white/10 text-white/50' : 'bg-black/5 text-gray-500'"
>
{{ categoryLabel(app.category) }}
</span>
</div>
<p class="text-xs mt-0.5 line-clamp-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ app.description }}
</p>
<div class="flex gap-1 mt-1.5">
<span
v-for="p in app.platforms"
:key="p"
class="text-xs px-1 py-0.5 rounded"
:class="isDark ? 'bg-white/5 text-white/30' : 'bg-black/3 text-gray-400'"
>
{{ platformLabel(p) }}
</span>
</div>
</div>
</button>
</div>
<div v-if="filteredApps.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No apps match your search
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { AppEntry } from '@/data/apps'
import { useTheme } from '@/composables/useTheme'
const props = withDefaults(defineProps<{
apps: AppEntry[]
title?: string
}>(), {
title: 'Recommended Apps',
})
defineEmits<{ selectApp: [app: AppEntry] }>()
const { isDark } = useTheme()
const search = ref('')
const activeCategory = ref<string | null>(null)
const categories = [
{ value: 'nostr-client', label: 'Nostr' },
{ value: 'lightning-wallet', label: 'Lightning' },
{ value: 'bitcoin-wallet', label: 'Bitcoin' },
{ value: 'privacy', label: 'Privacy' },
{ value: 'node', label: 'Nodes' },
{ value: 'dev-tool', label: 'Dev' },
]
function categoryLabel(cat: string): string {
return categories.find(c => c.value === cat)?.label ?? cat
}
function platformLabel(p: string): string {
const labels: Record<string, string> = {
ios: 'iOS',
android: 'Android',
web: 'Web',
desktop: 'Desktop',
cli: 'CLI',
nodeos: 'Node',
}
return labels[p] ?? p
}
function appGradient(id: string): string {
let hash = 0
for (let i = 0; i < id.length; i++) hash = id.charCodeAt(i) + ((hash << 5) - hash)
const hue = Math.abs(hash % 360)
return `linear-gradient(135deg, hsl(${hue}, 60%, 35%), hsl(${(hue + 40) % 360}, 50%, 25%))`
}
const filteredApps = computed(() => {
let result = props.apps
if (search.value) {
const q = search.value.toLowerCase()
result = result.filter(
a => a.name.toLowerCase().includes(q) ||
a.description.toLowerCase().includes(q) ||
a.keywords.some(k => k.toLowerCase().includes(q))
)
}
if (activeCategory.value) {
result = result.filter(a => a.category === activeCategory.value)
}
return result
})
</script>
@@ -0,0 +1,137 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 space-y-3" style="border-bottom: 1px solid rgba(255, 255, 255, 0.08)">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-bold text-white/90">
Node Apps
</h3>
<span class="text-xs font-mono text-white/30">
{{ filteredApps.length }} apps
</span>
</div>
<input
v-model="search"
type="text"
placeholder="Search node apps..."
class="w-full px-3 py-2 rounded-lg text-base outline-none transition-colors bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10"
/>
<div class="flex flex-wrap gap-1.5">
<button
v-for="cat in categories"
:key="cat.value"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeCategory === cat.value
? 'nav-tab-active'
: 'text-white/40 hover:text-white/70 hover:bg-white/5'"
@click="activeCategory = activeCategory === cat.value ? null : cat.value"
>
{{ cat.label }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-2 gap-2">
<button
v-for="app in filteredApps"
:key="app.id"
class="text-left p-3 rounded-xl transition-all duration-200 glass-card"
:class="app.liveStatus === 'running' ? 'hover:bg-white/10 cursor-pointer' : 'opacity-70'"
@click="handleAppClick(app)"
>
<div class="flex items-center gap-2 mb-1.5">
<span class="text-lg leading-none">{{ app.icon }}</span>
<span class="text-xs font-semibold text-white/90 truncate">{{ app.name }}</span>
</div>
<p class="text-xs text-white/50 line-clamp-2 leading-relaxed">
{{ app.description }}
</p>
<div class="mt-2 flex items-center gap-1.5">
<span
class="w-1.5 h-1.5 rounded-full"
:class="statusDotClass(app.liveStatus)"
/>
<span class="text-xs" :class="statusTextClass(app.liveStatus)">
{{ statusLabel(app.liveStatus) }}
</span>
</div>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ARCHY_APPS, type ArchyAppMeta } from '@/data/archy-apps'
import { useArchy } from '@/composables/useArchy'
interface MergedApp extends ArchyAppMeta {
liveStatus: 'running' | 'stopped' | 'not-installed'
}
const { isEmbedded, installedApps, requestAction } = useArchy()
const search = ref('')
const activeCategory = ref<string | null>(null)
const categories = [
{ label: 'Bitcoin', value: 'bitcoin' },
{ label: 'Lightning', value: 'lightning' },
{ label: 'Storage', value: 'storage' },
{ label: 'Social', value: 'social' },
{ label: 'Tools', value: 'tools' },
{ label: 'AI', value: 'ai' },
]
const mergedApps = computed<MergedApp[]>(() => {
return ARCHY_APPS.map((app) => {
const live = installedApps.value.find((a) => a.id === app.id)
let liveStatus: MergedApp['liveStatus'] = 'not-installed'
if (live) {
liveStatus = live.state === 'running' ? 'running' : 'stopped'
}
return { ...app, liveStatus }
})
})
const filteredApps = computed(() => {
let apps = mergedApps.value
if (activeCategory.value) {
apps = apps.filter((a) => a.category === activeCategory.value)
}
if (search.value.trim()) {
const q = search.value.toLowerCase()
apps = apps.filter((a) =>
a.name.toLowerCase().includes(q) || a.description.toLowerCase().includes(q),
)
}
return apps
})
function statusDotClass(status: MergedApp['liveStatus']) {
if (status === 'running') return 'bg-green-400'
if (status === 'stopped') return 'bg-yellow-400'
return 'bg-white/20'
}
function statusTextClass(status: MergedApp['liveStatus']) {
if (status === 'running') return 'text-green-400/80'
if (status === 'stopped') return 'text-yellow-400/70'
return 'text-white/30'
}
function statusLabel(status: MergedApp['liveStatus']) {
if (status === 'running') return 'Running'
if (status === 'stopped') return 'Stopped'
return isEmbedded.value ? 'Not installed' : 'Available'
}
function handleAppClick(app: MergedApp) {
if (app.liveStatus === 'running' && isEmbedded.value) {
requestAction('open-app', { appId: app.id })
}
}
</script>
@@ -0,0 +1,93 @@
<template>
<div class="article-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<div class="relative w-full overflow-hidden aspect-[16/7] shrink-0">
<img
v-if="article.imgSrc && isSafeImgSrc(article.imgSrc)"
:src="article.imgSrc"
:alt="article.title"
class="absolute inset-0 w-full h-full object-cover object-center block"
/>
<div
v-else
class="absolute inset-0"
:style="{ background: fallbackGradient }"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
<button
class="absolute top-3 left-3 p-2 rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10 text-white/80"
@click="$emit('back')"
>
<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>
<div class="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ article.title }}</h2>
<p v-if="articleDomain" class="text-xs text-white/60 mt-1">{{ articleDomain }}</p>
</div>
</div>
<div class="p-4 space-y-4">
<article
v-if="article.content"
class="text-white/90 [&_p]:mb-3 [&_ul]:list-disc [&_ol]:list-decimal [&_li]:ml-4 [&_a]:underline [&_a]:underline-offset-2 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:italic"
>
<div v-html="sanitizedContent" />
</article>
<div v-else class="py-4">
<p class="text-sm text-white/50">
Full article content is not available. Open the link below to read on the source site.
</p>
</div>
<a
v-if="article.url"
:href="article.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 p-3 rounded-xl transition-colors bg-white/10 hover:bg-white/15 text-white/90"
>
<svg class="w-4 h-4 shrink-0" 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>
Read full article
</a>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { WebSearchResult } from '@aiui/core/types/message'
import { isSafeImgSrc, sanitizeHtml, escapeHtml } from '@/utils/html'
const props = defineProps<{ article: WebSearchResult }>()
defineEmits<{ back: [] }>()
const articleDomain = computed(() => {
const url = props.article?.url
if (!url || typeof url !== 'string') return ''
try {
const u = new URL(url)
if (!/^https?:$/i.test(u.protocol)) return ''
return u.hostname.replace(/^www\./, '')
} catch {
return ''
}
})
const fallbackGradient = computed(() => {
const hue = [...props.article.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%)`
})
const sanitizedContent = computed(() => {
const c = props.article.content
if (!c) return ''
if (/<[a-z][\s\S]*>/i.test(c)) return sanitizeHtml(c)
return `<p class="whitespace-pre-wrap">${escapeHtml(c)}</p>`
})
</script>
@@ -0,0 +1,199 @@
<template>
<Teleport to="body">
<Transition name="app-launcher">
<div
v-if="store.isOpen"
class="fixed inset-0 z-[2400] flex items-center justify-center p-6 md:p-10"
@click.self="store.close()"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-md" />
<div
class="article-overlay-panel relative z-10 flex flex-col overflow-hidden rounded-2xl shadow-2xl path-glass-card"
:class="panelClasses"
>
<div class="flex items-center gap-3 px-4 py-3 shrink-0"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
<button
v-if="!store.content"
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors transition-transform duration-300 disabled:opacity-70 disabled:cursor-not-allowed"
:class="isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-black/5 text-gray-600'"
aria-label="Refresh page"
title="Refresh"
:disabled="isRefreshing"
@click="refreshIframe"
>
<svg
class="w-5 h-5"
:class="{ 'animate-spin': isRefreshing }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<span class="flex-1 truncate text-sm font-medium min-w-0"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ store.title || 'Article' }}
</span>
<a
v-if="store.url"
:href="store.url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors shrink-0"
:class="isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-black/5 text-gray-600'"
aria-label="Open in new tab"
title="Open in new tab"
@click.stop
>
<svg class="w-5 h-5" 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>
</a>
<button
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors shrink-0"
:class="isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-black/5 text-gray-600'"
aria-label="Close"
@click="store.close()"
>
<svg class="w-5 h-5" 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 class="relative flex-1 min-h-0 bg-black/20 overflow-hidden">
<!-- When we have RSS/article content, render it; otherwise load URL in iframe -->
<div
v-if="store.content"
class="absolute inset-0 overflow-y-auto p-4 md:p-6 text-sm leading-relaxed"
:class="isDark ? 'text-white/90' : 'text-gray-900'"
>
<article
class="[&_p]:mb-3 [&_ul]:list-disc [&_ol]:list-decimal [&_li]:ml-4 [&_a]:underline [&_a]:underline-offset-2 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:italic"
>
<img
v-if="store.imgSrc && isSafeImgSrc(store.imgSrc)"
:src="store.imgSrc"
:alt="store.title"
class="w-full rounded-lg object-cover max-h-48 mb-4"
/>
<div v-html="sanitizedContent" />
</article>
<a
:href="store.url ?? undefined"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 mt-4 text-sm"
:class="isDark ? 'text-white/70 hover:text-white' : 'text-gray-500 hover:text-gray-800'"
>
Read full article
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
<iframe
v-else-if="store.url"
ref="iframeRef"
:key="iframeRefreshKey"
:src="store.url"
class="absolute inset-0 w-full h-full border-0"
style="-ms-overflow-style: none; scrollbar-width: none;"
title="Article content"
@load="onIframeLoad"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useArticleOverlayStore } from '@/stores/articleOverlay'
import { useTheme } from '@/composables/useTheme'
import { isSafeImgSrc, sanitizeHtml, escapeHtml } from '@/utils/html'
const store = useArticleOverlayStore()
const { isDark } = useTheme()
const sanitizedContent = computed(() => {
const c = store.content
if (!c) return ''
if (/<[a-z][\s\S]*>/i.test(c)) return sanitizeHtml(c)
return `<p class="whitespace-pre-wrap">${escapeHtml(c)}</p>`
})
const iframeRef = ref<HTMLIFrameElement | null>(null)
const iframeRefreshKey = ref(0)
const isRefreshing = ref(false)
function refreshIframe() {
isRefreshing.value = true
iframeRefreshKey.value++
}
function onIframeLoad() {
isRefreshing.value = false
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && store.isOpen) {
store.close()
e.preventDefault()
e.stopPropagation()
}
}
watch(
() => store.isOpen,
(open) => {
if (!open) isRefreshing.value = false
}
)
onMounted(() => {
window.addEventListener('keydown', onKeyDown, true)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeyDown, true)
})
const panelClasses = [
'w-full max-w-[calc(100vw-3rem)] h-[80vh] max-h-[calc(100vh-5rem)]',
'md:max-w-[calc(100vw-5rem)]',
]
</script>
<style scoped>
iframe::-webkit-scrollbar {
display: none;
}
.app-launcher-enter-active,
.app-launcher-leave-active {
transition: opacity 0.25s ease;
}
.app-launcher-enter-active .article-overlay-panel,
.app-launcher-leave-active .article-overlay-panel {
transition: transform 0.25s ease, opacity 0.25s ease;
}
.app-launcher-enter-from,
.app-launcher-leave-to {
opacity: 0;
}
.app-launcher-enter-from .article-overlay-panel,
.app-launcher-leave-to .article-overlay-panel {
transform: scale(0.96);
opacity: 0;
}
</style>
@@ -0,0 +1,71 @@
<template>
<button
class="flex items-start gap-3 w-full text-left p-2.5 rounded-xl transition-all duration-150"
:class="isDark
? 'hover:bg-white/5'
: 'hover:bg-black/3'"
@click="$emit('select', book)"
>
<div class="w-12 h-auto shrink-0 rounded-md overflow-hidden shadow-md">
<div class="aspect-[2/3] relative">
<img
v-if="coverSrc"
:src="coverSrc"
:alt="book.title"
class="w-full h-full object-cover"
loading="lazy"
@error="coverFailed = true"
/>
<div
v-else
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackCover})` }"
/>
</div>
</div>
<div class="flex-1 min-w-0 py-0.5">
<p class="text-sm font-medium leading-snug line-clamp-2"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ book.title }}
</p>
<p class="text-xs mt-0.5 truncate"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ book.author }}<span v-if="book.year"> · {{ book.year }}</span>
</p>
<p v-if="book.description" class="text-xs mt-1 line-clamp-2 leading-relaxed"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
{{ book.description }}
</p>
</div>
</button>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { Book } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateBookCoverFallback, fetchBookImage } from '@/composables/useImageFallback'
const props = defineProps<{ book: Book }>()
defineEmits<{ select: [book: Book] }>()
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 fallbackCover = computed(() =>
generateBookCoverFallback(props.book.title, props.book.author)
)
onMounted(() => {
if (props.book.coverUrl) return
fetchBookImage(props.book.title, props.book.author).then((url) => {
if (url) fetchedCover.value = url
})
})
</script>
@@ -0,0 +1,158 @@
<template>
<div class="book-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<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="bannerSrc"
:src="bannerSrc"
:alt="book.title"
class="w-full h-full object-cover object-center block"
@error="onBannerError"
/>
<div
v-else
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" />
<button
class="absolute top-3 left-3 min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
@click="$emit('back')"
>
<svg class="w-4 h-4 text-white/90" 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="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ book.title }}</h2>
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-1 text-xs text-white/60">
<span>{{ book.author }}</span>
<span v-if="book.year">{{ book.year }}</span>
<span v-if="book.pages">{{ book.pages }} pages</span>
<span v-if="book.rating" class="text-amber-400">★ {{ book.rating.toFixed(1) }}</span>
</div>
</div>
</div>
<div class="p-4 space-y-4">
<p v-if="book.description" class="text-sm leading-relaxed"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ book.description }}
</p>
<div v-if="book.genres?.length" class="flex flex-wrap gap-1.5">
<span
v-for="genre in book.genres"
:key="genre"
class="text-xs px-2 py-1 rounded-md font-medium"
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'"
>
{{ genre }}
</span>
</div>
<div>
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Read on</h4>
<div class="space-y-2">
<a
v-for="src in (book.sources ?? [])"
:key="src.url"
:href="src.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<span class="text-sm">{{ sourceIcon(src.type) }}</span>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ src.name }}</p>
</div>
<svg class="w-4 h-4" :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>
</a>
<a
v-for="link in readLinks"
:key="link.url"
:href="link.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<span class="text-sm">{{ link.icon }}</span>
<div>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ link.name }}</p>
<p v-if="link.desc" class="text-xs"
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ link.desc }}</p>
</div>
</div>
<svg class="w-4 h-4" :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>
</a>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Book } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useBannerFallback } from '@/composables/useBannerFallback'
import { fetchBookImage } from '@/composables/useImageFallback'
const props = defineProps<{ book: Book }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
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 q = computed(() =>
`${props.book.title} ${props.book.author}`.trim().replace(/\s+/g, '+'),
)
const readLinks = computed(() => [
{ name: 'Open Library', url: `https://openlibrary.org/search?q=${q.value}`, icon: '📖', desc: 'Free, open catalog' },
{ name: 'Internet Archive', url: `https://archive.org/search?query=${q.value}`, icon: '🏛️', desc: 'Borrow & read free' },
{ name: 'Project Gutenberg', url: `https://www.gutenberg.org/ebooks/search/?query=${q.value}`, icon: '📜', desc: 'Public domain' },
{ name: 'Standard Ebooks', url: `https://standardebooks.org/ebooks?query=${q.value}`, icon: '📕', desc: 'Beautifully formatted' },
])
function sourceIcon(type: string): string {
const icons: Record<string, string> = {
openlibrary: '📖',
gutenberg: '📜',
archive: '🏛️',
goodreads: '📚',
libgen: '🔓',
local: '💾',
}
return icons[type] ?? '📚'
}
</script>
@@ -0,0 +1,163 @@
<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'">
{{ title }}
</h3>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredBooks.length }} books
</span>
<slot name="header-actions" />
</div>
</div>
<input
v-model="search"
type="text"
placeholder="Search books..."
class="w-full px-3 py-2 rounded-lg text-base 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 v-if="topGenres.length > 0" class="flex flex-wrap gap-1.5">
<button
v-for="genre in topGenres"
:key="genre"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeGenre === genre
? '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="activeGenre = activeGenre === genre ? null : genre"
>
{{ genre }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<button
v-for="book in filteredBooks"
:key="book.id"
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"
:aria-label="`${book.title} by ${book.author}`"
@click="$emit('selectBook', book)"
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(book) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="isLoading(book)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(book)"
:src="coverSrc(book)!"
:alt="`${book.title} by ${book.author}`"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onError(book)"
/>
<img
v-else-if="!isLoading(book)"
:src="fallbackSrc(book)"
:alt="book.title"
class="w-full h-full object-cover"
/>
<div v-if="coverSrc(book)" class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
<div class="absolute bottom-0 left-0 right-0 p-2">
<p class="text-xs font-semibold text-white/90 leading-tight truncate">
{{ book.title }}
</p>
<p class="text-xs text-white/40 truncate mt-0.5">{{ book.author }}</p>
</div>
<div v-if="book.rating" class="absolute top-1.5 left-1.5">
<span class="text-xs px-1.5 py-0.5 rounded bg-black/60 text-amber-400 backdrop-blur-sm font-medium">
★ {{ book.rating.toFixed(1) }}
</span>
</div>
<div v-if="book.year" class="absolute top-1.5 right-1.5">
<span class="text-xs px-1 py-0.5 rounded bg-black/60 text-white/70 backdrop-blur-sm">
{{ book.year }}
</span>
</div>
</div>
</div>
</button>
</div>
<div v-if="filteredBooks.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No books match your search
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, toRef } from 'vue'
import type { Book } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useContentImages } from '@/composables/useContentImages'
import { generateBookCoverFallback, fetchBookImage } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
books: Book[]
title?: string
}>(), {
title: 'Recommended Books',
})
defineEmits<{ selectBook: [book: Book] }>()
const { isDark } = useTheme()
const search = ref('')
const activeGenre = ref<string | null>(null)
const { coverSrc, fallbackSrc, onError, isLoading } = useContentImages({
items: toRef(props, 'books'),
id: (b) => b.id,
existingUrl: (b) => b.coverUrl,
fetch: (b) => fetchBookImage(b.title, b.author),
fallback: (b) => generateBookCoverFallback(b.title, b.author),
})
const topGenres = computed(() => {
const counts = new Map<string, number>()
for (const b of props.books) {
for (const g of b.genres ?? []) {
counts.set(g, (counts.get(g) ?? 0) + 1)
}
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([g]) => g)
})
const filteredBooks = computed(() => {
let result = props.books
if (search.value) {
const q = search.value.toLowerCase()
result = result.filter(
(b) =>
b.title.toLowerCase().includes(q) ||
b.author.toLowerCase().includes(q) ||
(b.genres ?? []).some((g) => g.toLowerCase().includes(q))
)
}
if (activeGenre.value) {
result = result.filter((b) => (b.genres ?? []).includes(activeGenre.value!))
}
return result
})
</script>
@@ -0,0 +1,22 @@
<template>
<button
class="absolute top-3 right-3 z-10 p-2 rounded-lg path-glass-icon transition-colors"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
title="Close"
aria-label="Close"
@click="$emit('click')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
const { isDark } = useTheme()
defineEmits<{ click: [] }>()
</script>
@@ -0,0 +1,104 @@
<template>
<div class="code-detail h-full flex flex-col overflow-hidden"
:class="isDark ? 'bg-[#1a1a2e]' : 'bg-[#fafafa]'">
<!-- Header with file name + back button -->
<div class="shrink-0 flex items-center gap-2 px-3 py-2"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
<button
class="absolute top-3 left-3 p-2 rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
@click="$emit('back')"
>
<svg class="w-4 h-4" :class="isDark ? 'text-white/70' : 'text-gray-600'" 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="flex-1 min-w-0 pl-8">
<div class="flex items-center gap-2">
<!-- Language badge -->
<span class="shrink-0 text-xs px-1.5 py-0.5 rounded font-mono"
:class="isDark ? 'bg-white/10 text-white/50' : 'bg-black/5 text-gray-500'">
{{ language }}
</span>
<p class="text-xs font-mono truncate"
:class="isDark ? 'text-white/70' : 'text-gray-700'">
{{ fileName }}
</p>
</div>
<p v-if="projectName" class="text-xs font-mono mt-0.5 truncate"
:class="isDark ? 'text-white/25' : 'text-gray-400'">
{{ projectName }} / {{ filePath }}
</p>
</div>
</div>
<!-- Code content -->
<div class="flex-1 min-h-0 overflow-auto custom-scrollbar">
<div v-if="content" class="font-mono text-xs leading-relaxed">
<table class="w-full border-collapse">
<tbody>
<tr v-for="(line, i) in lines" :key="i"
class="hover:bg-white/[0.03]">
<td class="select-none text-right pr-4 pl-4 py-0 align-top w-1"
:class="isDark ? 'text-white/15' : 'text-gray-300'"
style="min-width: 3rem;">
{{ i + 1 }}
</td>
<td class="pr-4 py-0 whitespace-pre"
:class="isDark ? 'text-white/75' : 'text-gray-700'">{{ line }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty state -->
<div v-else class="flex items-center justify-center h-full">
<div class="text-center space-y-3 px-6">
<div class="w-16 h-16 rounded-2xl flex items-center justify-center mx-auto"
:class="isDark ? 'bg-white/5' : 'bg-black/5'">
<svg class="w-7 h-7" :class="isDark ? 'text-white/20' : 'text-gray-300'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</div>
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
Select a file to view its contents.
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useCodeContext } from '@/composables/useCodeContext'
defineEmits<{
back: []
}>()
const { isDark } = useTheme()
const { activeFile, activeFileContent, activeFileLanguage, activeProject } = useCodeContext()
const content = computed(() => activeFileContent.value)
const language = computed(() => activeFileLanguage.value)
const filePath = computed(() => activeFile.value ?? '')
const fileName = computed(() => filePath.value.split('/').pop() ?? '')
const projectName = computed(() => activeProject.value?.name ?? '')
const lines = computed(() => {
if (!content.value) return []
return content.value.split('\n')
})
</script>
<style scoped>
.code-detail table {
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,163 @@
<template>
<div class="flex-1 min-h-0 flex flex-col">
<FilmGrid
v-if="activeTab === 'film'"
:films="panelFilms"
:title="panelTitle"
@select-film="openFilmDetail"
/>
<BookGrid
v-else-if="activeTab === 'book'"
:books="panelBooks"
:title="panelTitle"
@select-book="openBookDetail"
/>
<TVSeriesGrid
v-else-if="activeTab === 'tvshow'"
:series="panelTVSeries"
:title="panelTitle"
@select-series="openTVSeriesDetail"
/>
<ImageGrid
v-else-if="activeTab === 'image'"
:images="panelImages"
:title="panelTitle"
@select-image="openImageDetail"
/>
<PlaceGrid
v-else-if="activeTab === 'place'"
:places="panelPlaces"
:title="panelTitle"
@select-place="openPlaceDetail"
/>
<SongGrid
v-else-if="activeTab === 'song'"
:songs="panelSongs"
:title="panelTitle"
@select-song="openSongDetail"
/>
<MagazineGrid
v-else-if="activeTab === 'magazine'"
:sections="panelMagazineSections"
:hero-image-url="panelMagazineHeroImage"
:title="panelTitle"
:query="panelQuery"
/>
<NewsGrid
v-else-if="activeTab === 'news'"
:articles="panelWebResults"
:title="panelTitle"
:query="panelQuery"
/>
<NewsGrid
v-else-if="activeTab === 'websites'"
:articles="panelWebsites"
:title="panelTitle"
variant="websites"
/>
<PodcastGrid
v-else-if="activeTab === 'podcast'"
:podcasts="panelPodcasts"
:title="panelTitle"
@select-podcast="openPodcastDetail"
/>
<RecipeGrid
v-else-if="activeTab === 'recipe'"
:recipes="panelRecipes"
:title="panelTitle"
@select-recipe="openRecipeDetail"
/>
<AppsGrid
v-else-if="activeTab === 'app'"
:apps="panelApps"
:title="panelTitle"
@select-app="openAppDetail"
/>
<ProjectGrid
v-else-if="activeTab === 'code'"
:is-wide-desktop="isWideDesktop"
:is-mobile="isMobile"
/>
<DesignSystemGrid
v-else-if="activeTab === 'design-system'"
/>
<NostrGrid
v-else-if="activeTab === 'nostr'"
/>
<MagazineGrid
v-else-if="activeTab === 'prompt'"
:sections="promptSections"
:hero-image-url="null"
title="Prompt"
:query="panelQuery"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
import type { WebSearchResult } from '@aiui/core/types/message'
import type { ContentTab, MagazineSection } from '@/composables/useContentPanel'
import type { RecipeData, AppEntry } from '@/composables/contentExtraction'
import { useContentPanel } from '@/composables/useContentPanel'
import { extractMagazineSections, stripContentTags } from '@/composables/contentExtraction'
import FilmGrid from './FilmGrid.vue'
import BookGrid from './BookGrid.vue'
import TVSeriesGrid from './TVSeriesGrid.vue'
import ImageGrid from './ImageGrid.vue'
import PlaceGrid from './PlaceGrid.vue'
import SongGrid from './SongGrid.vue'
import PodcastGrid from './PodcastGrid.vue'
import MagazineGrid from './MagazineGrid.vue'
import NewsGrid from './NewsGrid.vue'
import RecipeGrid from './RecipeGrid.vue'
import AppsGrid from './AppsGrid.vue'
import ProjectGrid from './ProjectGrid.vue'
import DesignSystemGrid from './DesignSystemGrid.vue'
import NostrGrid from './NostrGrid.vue'
const props = defineProps<{
activeTab: ContentTab
isWideDesktop?: boolean
isMobile?: boolean
panelFilms: Film[]
panelBooks: Book[]
panelTVSeries: TVSeries[]
panelImages: ImageItem[]
panelPlaces: Place[]
panelSongs: Song[]
panelPodcasts: Podcast[]
panelWebResults: WebSearchResult[]
panelWebsites: WebSearchResult[]
panelMagazineSections: MagazineSection[]
panelMagazineHeroImage: string | null
panelRecipes: RecipeData[]
panelApps: AppEntry[]
panelTitle: string
panelQuery: string
panelResponseText?: string
}>()
const promptSections = computed<MagazineSection[]>(() => {
const text = props.panelResponseText ?? ''
if (!text) return [{ title: props.panelQuery || 'Prompt', content: '' }]
// Use magazine extraction to format the response beautifully
const sections = extractMagazineSections(text)
if (sections.length > 0) return sections
// Fallback: single section with cleaned response
return [{ title: props.panelQuery || 'Response', content: stripContentTags(text) }]
})
const {
openFilmDetail,
openBookDetail,
openTVSeriesDetail,
openImageDetail,
openPlaceDetail,
openSongDetail,
openPodcastDetail,
openRecipeDetail,
openAppDetail,
} = useContentPanel()
</script>
@@ -0,0 +1,411 @@
<template>
<Transition name="panel">
<aside
v-if="panelOpen"
class="path-glass-card overflow-hidden flex flex-col"
:class="isMobile
? 'fixed inset-0 z-30'
: 'w-80 xl:w-96 shrink-0'"
>
<!-- Mobile header -->
<div
v-if="isMobile"
class="p-3 flex items-center justify-between shrink-0"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ panelTitle }}
</h3>
<button
class="touch-target rounded-lg transition-colors"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5'"
@click="closePanel"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Tab bar (when multiple tabs available) -->
<div
v-if="displayTabs.length > 1 && !hasDetailOpen"
class="flex items-center gap-2 px-3 pt-3 pb-1 shrink-0 overflow-x-auto scrollbar-hide"
>
<button
v-for="tab in displayTabs"
:key="tab"
class="text-xs px-2.5 min-h-[44px] flex items-center justify-center rounded-lg font-medium whitespace-nowrap transition-all duration-150"
:class="activeTab === tab
? 'nav-tab-active'
: isDark
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
@click="tab === 'favorites' || tab === 'discover' ? (activeTab = tab) : setActiveTab(tab)"
>
{{ tabLabel(tab) }}
</button>
</div>
<!-- Content area -->
<div class="flex-1 min-h-0">
<ErrorBoundary title="Content failed to load">
<!-- Detail views (override tab content) -->
<component
:is="filmRenderer?.panelPlay"
v-if="selectedFilm && filmRenderer?.panelPlay"
:key="selectedFilm.id"
:film="selectedFilm"
@back="closeFilmDetail"
/>
<BookDetail
v-else-if="selectedBook"
:key="selectedBook.id"
:book="selectedBook"
@back="closeBookDetail"
/>
<TVSeriesDetail
v-else-if="selectedTVSeries"
:key="selectedTVSeries.id"
:series="selectedTVSeries"
@back="closeTVSeriesDetail"
/>
<component
:is="songRenderer?.panelPlay"
v-else-if="selectedSong && songRenderer?.panelPlay"
:key="selectedSong.id"
:song="selectedSong"
@back="closeSongDetail"
/>
<PodcastDetail
v-else-if="selectedPodcast"
:key="selectedPodcast.id"
:podcast="selectedPodcast"
@back="closePodcastDetail"
/>
<ImageDetail
v-else-if="selectedImage"
:key="selectedImage.url"
:image="selectedImage"
@back="closeImageDetail"
/>
<PlaceDetail
v-else-if="selectedPlace"
:key="selectedPlace.id"
:place="selectedPlace"
@back="closePlaceDetail"
/>
<RecipeDetail
v-else-if="selectedRecipe"
:key="selectedRecipe.title"
:recipe="selectedRecipe"
@back="closeRecipeDetail"
/>
<AppDetail
v-else-if="selectedApp"
:key="selectedApp.id"
:app="selectedApp"
@back="closeAppDetail"
@select-app="openAppDetail"
/>
<ArticleDetail
v-else-if="selectedArticle"
:key="selectedArticle.url"
:article="selectedArticle"
@back="closeArticleDetail"
/>
<ArticleReader
v-else-if="longFormArticle"
:content="longFormArticle.content"
:title="longFormArticle.title"
@back="closeLongFormArticle"
/>
<PdfViewer
v-else-if="pdfUrl"
:url="pdfUrl.url"
:title="pdfUrl.title"
@back="closePdfViewer"
/>
<MapRenderer
v-else-if="mapPlaces.length > 0"
:places="mapPlaces"
@back="closeMapView"
/>
<!-- Grid views by active tab -->
<component
:is="filmRenderer?.panelPreview"
v-else-if="activeTab === 'film' && filmRenderer?.panelPreview"
:films="panelFilms"
:title="panelTitle"
@select-film="openFilmDetail"
/>
<BookGrid
v-else-if="activeTab === 'book'"
:books="panelBooks"
:title="panelTitle"
@select-book="openBookDetail"
/>
<TVSeriesGrid
v-else-if="activeTab === 'tvshow'"
:series="panelTVSeries"
:title="panelTitle"
@select-series="openTVSeriesDetail"
/>
<component
:is="songRenderer?.panelPreview"
v-else-if="activeTab === 'song' && songRenderer?.panelPreview"
:songs="panelSongs"
:title="panelTitle"
@select-song="openSongDetail"
/>
<PodcastGrid
v-else-if="activeTab === 'podcast'"
:podcasts="panelPodcasts"
:title="panelTitle"
@select-podcast="openPodcastDetail"
/>
<ImageGrid
v-else-if="activeTab === 'image'"
:images="panelImages"
:title="panelTitle"
@select-image="openImageDetail"
/>
<PlaceGrid
v-else-if="activeTab === 'place'"
:places="panelPlaces"
:title="panelTitle"
@select-place="openPlaceDetail"
/>
<RecipeGrid
v-else-if="activeTab === 'recipe'"
:recipes="panelRecipes"
:title="panelTitle"
@select-recipe="openRecipeDetail"
/>
<NewsGrid
v-else-if="activeTab === 'news'"
:articles="panelWebResults"
:title="panelTitle"
:query="panelQuery"
variant="news"
/>
<NewsGrid
v-else-if="activeTab === 'websites'"
:articles="panelWebsites"
:title="panelTitle"
:query="panelQuery"
variant="websites"
/>
<MagazineGrid
v-else-if="activeTab === 'magazine'"
:sections="panelMagazineSections"
:title="panelTitle"
:query="panelQuery"
:hero-image="panelMagazineHeroImage ?? undefined"
/>
<ArchyAppsGrid
v-else-if="activeTab === 'app' && isArchyEmbedded"
/>
<AppsGrid
v-else-if="activeTab === 'app'"
:apps="panelApps"
:title="panelTitle"
@select-app="openAppDetail"
/>
<ProjectGrid
v-else-if="activeTab === 'code'"
/>
<NostrGrid
v-else-if="activeTab === 'nostr'"
/>
<FavoritesGrid
v-else-if="activeTab === 'favorites'"
/>
<DiscoverPanel
v-else-if="activeTab === 'discover'"
/>
</ErrorBoundary>
</div>
</aside>
</Transition>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel, type ContentTab } from '@/composables/useContentPanel'
import { getRendererForContentType } from '@aiui/core'
import BookGrid from './BookGrid.vue'
import BookDetail from './BookDetail.vue'
import TVSeriesGrid from './TVSeriesGrid.vue'
import TVSeriesDetail from './TVSeriesDetail.vue'
import ImageGrid from './ImageGrid.vue'
import ImageDetail from './ImageDetail.vue'
import PlaceGrid from './PlaceGrid.vue'
import PlaceDetail from './PlaceDetail.vue'
import RecipeGrid from './RecipeGrid.vue'
import RecipeDetail from './RecipeDetail.vue'
import PodcastGrid from './PodcastGrid.vue'
import PodcastDetail from './PodcastDetail.vue'
import NewsGrid from './NewsGrid.vue'
import ArticleDetail from './ArticleDetail.vue'
import ArticleReader from '@/components/renderers/ArticleReader.vue'
const PdfViewer = defineAsyncComponent({
loader: () => import('@/components/renderers/PdfViewer.vue'),
loadingComponent: { template: '<div class="flex items-center justify-center h-32"><span class="text-sm text-white/50">Loading PDF viewer...</span></div>' },
})
const MapRenderer = defineAsyncComponent({
loader: () => import('@/components/renderers/MapRenderer.vue'),
loadingComponent: { template: '<div class="flex items-center justify-center h-32"><span class="text-sm text-white/50">Loading map...</span></div>' },
})
import MagazineGrid from './MagazineGrid.vue'
import AppsGrid from './AppsGrid.vue'
import ArchyAppsGrid from './ArchyAppsGrid.vue'
import AppDetail from './AppDetail.vue'
import ProjectGrid from './ProjectGrid.vue'
import NostrGrid from './NostrGrid.vue'
import FavoritesGrid from './FavoritesGrid.vue'
import DiscoverPanel from './DiscoverPanel.vue'
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import { useFavoritesStore } from '@/stores/favorites'
import { useArchy } from '@/composables/useArchy'
// Film and song renderers loaded from plugin registry
const filmRenderer = computed(() => getRendererForContentType('film'))
const songRenderer = computed(() => getRendererForContentType('song'))
const { isDark } = useTheme()
const favoritesStore = useFavoritesStore()
const { isEmbedded: isArchyEmbedded } = useArchy()
const {
panelOpen,
panelFilms,
panelBooks,
panelTVSeries,
panelSongs,
panelPodcasts,
panelWebResults,
panelWebsites,
panelImages,
panelPlaces,
panelRecipes,
panelApps,
panelMagazineSections,
panelMagazineHeroImage,
panelTitle,
panelQuery,
activeTab,
availableTabs,
selectedFilm,
selectedBook,
selectedTVSeries,
selectedSong,
selectedPodcast,
selectedImage,
selectedPlace,
selectedArticle,
selectedRecipe,
selectedApp,
selectedDesignSystemItem,
setActiveTab,
openFilmDetail,
closeFilmDetail,
openBookDetail,
closeBookDetail,
openTVSeriesDetail,
closeTVSeriesDetail,
openSongDetail,
closeSongDetail,
openPodcastDetail,
closePodcastDetail,
openImageDetail,
closeImageDetail,
openPlaceDetail,
closePlaceDetail,
openRecipeDetail,
closeRecipeDetail,
openAppDetail,
closeAppDetail,
closeArticleDetail,
longFormArticle,
closeLongFormArticle,
pdfUrl,
closePdfViewer,
mapPlaces,
closeMapView,
closePanel,
} = useContentPanel()
const hasDetailOpen = computed(() =>
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedImage.value || selectedPlace.value || selectedRecipe.value || selectedArticle.value || selectedApp.value || selectedDesignSystemItem.value || longFormArticle.value || pdfUrl.value || mapPlaces.value.length > 0)
)
const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value < 1024)
const displayTabs = computed(() => {
const tabs = [...availableTabs.value]
if (favoritesStore.items.length > 0 && !tabs.includes('favorites')) {
tabs.push('favorites')
}
if (!tabs.includes('discover')) {
tabs.push('discover')
}
return tabs
})
function onResize() {
windowWidth.value = window.innerWidth
}
const TAB_LABELS: Record<ContentTab, string> = {
film: 'Films',
book: 'Books',
tvshow: 'TV',
image: 'Images',
place: 'Places',
recipe: 'Recipes',
song: 'Music',
podcast: 'Podcasts',
news: 'News',
websites: 'Web',
magazine: 'Brief',
code: 'Code',
'design-system': 'Design',
app: 'Apps',
nostr: 'Nostr',
favorites: 'Favorites',
discover: 'Discover',
prompt: 'Prompt',
}
function tabLabel(tab: ContentTab): string {
return TAB_LABELS[tab] ?? tab
}
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))
</script>
<style scoped>
.panel-enter-active {
transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.panel-leave-active {
transition: all 0.2s ease-in;
}
.panel-enter-from {
opacity: 0;
transform: translateX(20px);
}
.panel-leave-to {
opacity: 0;
transform: translateX(20px);
}
</style>
@@ -0,0 +1,145 @@
<template>
<div class="relative flex-1 flex flex-col min-h-0 overflow-hidden">
<div
v-if="['film','song','podcast','book','tvshow','image','news','websites','magazine'].includes(contextType)"
class="flex-1 flex flex-col min-h-0"
>
<div
class="p-4 shrink-0 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)'"
>
<p class="text-sm font-medium"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ contextLabel }}
</p>
<!-- gap-3 + mr-1 on the label: "Surfacing…" tracks at 0.2em, so its
last glyph's letter-spacing sits flush against the close button
and read as overlapping it. -->
<div class="flex items-center gap-3 shrink-0">
<p class="text-xs font-mono uppercase tracking-[0.2em] mr-1"
:class="isDark ? 'text-white/25' : 'text-gray-400'">
Surfacing…
</p>
<slot name="header-actions" />
</div>
</div>
<LoadingContentGrid :variant="skeletonVariant" :count="skeletonCount" />
</div>
<div
v-else
class="relative flex-1 flex items-center justify-center min-h-0"
>
<div
class="absolute inset-0 opacity-[0.04] pointer-events-none"
:class="isDark ? 'bg-white' : 'bg-black'"
style="background-image: url(&quot;data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E&quot;)"
/>
<div class="relative flex flex-col items-center gap-8">
<div class="flex items-center gap-2">
<div
v-for="(_, i) in 5"
:key="i"
class="w-12 h-16 rounded-lg overflow-hidden relative animate-cell-pulse"
:class="isDark
? 'bg-white/8 border border-white/15 shadow-xl shadow-accent/5'
: 'bg-black/6 border border-black/8 shadow-xl shadow-accent/10'"
:style="{ animationDelay: `${i * 100}ms` }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden">
<div
class="absolute inset-0 w-1/2 animate-shimmer-sweep"
:class="isDark
? 'bg-gradient-to-r from-transparent via-accent/25 to-transparent'
: 'bg-gradient-to-r from-transparent via-accent/35 to-transparent'"
/>
</div>
</div>
</div>
<p class="text-sm font-medium"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ contextLabel }}
</p>
<div class="w-40 h-px rounded-full overflow-hidden"
:class="isDark ? 'bg-white/8' : 'bg-black/8'">
<div class="h-full bg-accent/90 rounded-full animate-progress-sweep" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import LoadingContentGrid from './LoadingContentGrid.vue'
const props = withDefaults(
defineProps<{
contextType?: 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'news' | 'websites' | 'magazine' | 'generic'
}>(),
{ contextType: 'film' }
)
const { isDark } = useTheme()
const contextLabel = computed(() => {
if (props.contextType === 'film') return 'Film recommendations'
if (props.contextType === 'song') return 'Song recommendations'
if (props.contextType === 'podcast') return 'Podcast recommendations'
if (props.contextType === 'book') return 'Book recommendations'
if (props.contextType === 'tvshow') return 'TV Series recommendations'
if (props.contextType === 'image') return 'Images'
if (props.contextType === 'news') return 'Articles'
if (props.contextType === 'websites') return 'Websites'
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>
.animate-cell-pulse {
animation: cell-pulse 1.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
.animate-shimmer-sweep {
animation: shimmer-sweep 2.2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
.animate-progress-sweep {
animation: progress-sweep 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes cell-pulse {
0%, 100% { opacity: 0.6; transform: scale(0.96); }
50% { opacity: 1; transform: scale(1.02); }
}
@keyframes shimmer-sweep {
0% { transform: translateX(-100%); }
60% { transform: translateX(200%); }
100% { transform: translateX(200%); }
}
@keyframes progress-sweep {
0% { width: 0; margin-left: 0; }
45% { width: 60%; margin-left: 20%; }
90% { width: 0; margin-left: 100%; }
100% { width: 0; margin-left: 0; }
}
</style>
@@ -0,0 +1,438 @@
<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="min-w-[44px] min-h-[44px] 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-xs"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
{{ categoryLabel }}
</p>
</div>
<button
class="text-xs 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-xs 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-xs 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-3">
<button class="glass-button text-sm">Action</button>
<button class="glass-button text-sm opacity-50 cursor-not-allowed">Disabled</button>
</div>
<div v-else-if="item.id === 'atom-glass-btn-sm'" class="flex gap-3">
<button class="glass-button glass-button-sm text-xs">Small</button>
<button class="glass-button glass-button-sm text-xs opacity-50 cursor-not-allowed">Disabled</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-xs 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-xs 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-xs 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-xs px-2.5 py-1 rounded-md font-medium bg-accent/20 text-accent">Films</button>
<button class="text-xs 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-xs 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-base 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-xs py-0.5"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
Scrollable content line {{ n }}
</p>
</div>
<p class="text-xs 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-xs" :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-xs 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-xs 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-xs"
: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-xs"
: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-xs px-2 py-0.5 rounded font-medium bg-accent/20 text-accent">Films</span>
<span class="text-xs px-2 py-0.5 rounded font-medium"
:class="isDark ? 'text-white/40' : 'text-gray-400'">Songs</span>
<span class="text-xs 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-xs font-bold text-white/90">Title</p>
<p class="text-xs 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-xs 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-xs 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-xs 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-xs 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-xs font-semibold" :class="isDark ? 'text-white/80' : 'text-gray-800'">fiatjaf</span>
<span class="text-xs" :class="isDark ? 'text-white/25' : 'text-gray-300'">2h</span>
</div>
<p class="text-xs 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-xs" :class="isDark ? 'text-white/25' : 'text-gray-300'">3 replies</span>
<span class="text-xs 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-xs 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-xs 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-xs 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-xs 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,192 @@
<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-xs"
: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-xs 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-all duration-150 group relative"
:class="[
codeMode && isDesignTokenSelected(item.id)
? 'ring-2 ring-accent/50 bg-accent/10 cursor-pointer'
: isDark
? 'bg-white/[0.03] hover:bg-white/[0.07] cursor-pointer'
: 'bg-black/[0.02] hover:bg-black/[0.05] cursor-pointer',
]"
@click="selectItem(item)"
>
<!-- Selection toggle (top-right) — only this area toggles context selection -->
<div
v-if="codeMode"
class="absolute top-2 right-2 min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center z-10 cursor-pointer transition-colors"
:class="isDesignTokenSelected(item.id)
? 'bg-accent'
: isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-black/10 hover:bg-black/20'"
@click.stop="toggleDesignToken(item.id)"
>
<svg v-if="isDesignTokenSelected(item.id)" class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</div>
<!-- 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-xs 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'
import { useCodeContext } from '@/composables/useCodeContext'
const { isDark } = useTheme()
const { openDesignSystemItem } = useContentPanel()
const { codeMode, toggleDesignToken, isDesignTokenSelected } = useCodeContext()
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-xs /* 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-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-xs 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-xs\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-xs 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-xs 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-xs text-white/60">\n Note content...</p>\n <span class="text-xs\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>
@@ -0,0 +1,117 @@
<template>
<FilmDetail
v-if="selectedFilm"
:film="selectedFilm"
@back="closeFilmDetail"
/>
<SongDetail
v-else-if="selectedSong"
:song="selectedSong"
@back="closeSongDetail"
/>
<PodcastDetail
v-else-if="selectedPodcast"
:podcast="selectedPodcast"
@back="closePodcastDetail"
/>
<BookDetail
v-else-if="selectedBook"
:book="selectedBook"
@back="closeBookDetail"
/>
<TVSeriesDetail
v-else-if="selectedTVSeries"
:series="selectedTVSeries"
@back="closeTVSeriesDetail"
/>
<ImageDetail
v-else-if="selectedImage"
:image="selectedImage"
@back="closeImageDetail"
/>
<PlaceDetail
v-else-if="selectedPlace"
:place="selectedPlace"
@back="closePlaceDetail"
/>
<ArticleDetail
v-else-if="selectedArticle"
:article="selectedArticle"
@back="closeArticleDetail"
/>
<WebsiteDetail
v-else-if="selectedWebsite"
:website="selectedWebsite"
@back="closeWebsiteDetail"
/>
<MagazineSectionDetail
v-else-if="selectedMagazineSection"
:section="selectedMagazineSection"
:current-index="magazineSectionIndex"
:total-sections="panelMagazineSections.length"
@back="closeMagazineSectionDetail"
@navigate="navigateMagazineSection"
/>
<CodeDetail
v-else-if="isCodeMode && activeCodeFile"
@back="closeCodeFile"
/>
<DesignSystemDetail
v-else-if="selectedDesignSystemItem"
:item="selectedDesignSystemItem"
@back="closeDesignSystemItem"
/>
</template>
<script setup lang="ts">
import { useContentPanel } from '@/composables/useContentPanel'
import FilmDetail from './FilmDetail.vue'
import BookDetail from './BookDetail.vue'
import TVSeriesDetail from './TVSeriesDetail.vue'
import SongDetail from './SongDetail.vue'
import PodcastDetail from './PodcastDetail.vue'
import ImageDetail from './ImageDetail.vue'
import PlaceDetail from './PlaceDetail.vue'
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()
function closeCodeFile() {
const { activeFile, activeFileContent } = useCodeContext()
activeFile.value = null
activeFileContent.value = ''
}
const {
selectedFilm,
selectedBook,
selectedTVSeries,
selectedImage,
selectedPlace,
selectedSong,
selectedPodcast,
selectedArticle,
closeFilmDetail,
closeBookDetail,
closeTVSeriesDetail,
closeImageDetail,
closePlaceDetail,
closeSongDetail,
closePodcastDetail,
closeArticleDetail,
selectedWebsite,
closeWebsiteDetail,
selectedMagazineSection,
magazineSectionIndex,
panelMagazineSections,
closeMagazineSectionDetail,
navigateMagazineSection,
selectedDesignSystemItem,
closeDesignSystemItem,
} = useContentPanel()
</script>
@@ -0,0 +1,421 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 border-b border-white/[0.08]">
<h3 class="text-sm font-bold text-white/90 mb-3">Discover</h3>
<!-- Sub-tabs -->
<div class="flex gap-1.5 flex-wrap">
<button
v-for="tab in subTabs"
:key="tab.id"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeSubTab === tab.id
? 'nav-tab-active'
: 'text-white/40 hover:text-white/70 hover:bg-white/5'"
@click="activeSubTab = tab.id"
>
{{ tab.label }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-2">
<!-- For You -->
<template v-if="activeSubTab === 'foryou'">
<div v-if="forYouItems.length === 0" class="flex flex-col items-center justify-center py-12 gap-2">
<svg class="w-8 h-8 text-white/10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
<p class="text-xs text-white/30">Add favorites to get personalized suggestions</p>
</div>
<div
v-for="item in forYouItems.slice(0, 30)"
:key="item.id"
class="flex items-center gap-3 p-3 rounded-xl bg-white/[0.03] hover:bg-white/[0.07] border border-white/5 transition-all duration-150 cursor-pointer"
>
<span class="text-xs w-6 h-6 rounded flex items-center justify-center shrink-0" :class="typeStyle(item.type)">
{{ typeIcon(item.type) }}
</span>
<div class="flex-1 min-w-0">
<div class="text-xs font-semibold truncate text-white/80">{{ item.title }}</div>
<div v-if="item.subtitle" class="text-xs truncate text-white/40">{{ item.subtitle }}</div>
</div>
<span class="text-xs text-white/20 shrink-0">{{ item.type }}</span>
</div>
</template>
<!-- Recent -->
<template v-else-if="activeSubTab === 'recent'">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-white/30">{{ viewHistory.length }} items</span>
<button
v-if="viewHistory.length > 0"
class="text-xs text-red-400/50 hover:text-red-400/80 transition-colors"
@click="clearHistory"
>
Clear
</button>
</div>
<div v-if="viewHistory.length === 0" class="flex flex-col items-center justify-center py-12 gap-2">
<svg class="w-8 h-8 text-white/10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-xs text-white/30">No recently viewed items</p>
</div>
<div
v-for="entry in viewHistory"
:key="entry.id + entry.viewedAt"
class="flex items-center gap-3 p-3 rounded-xl bg-white/[0.03] hover:bg-white/[0.07] border border-white/5 transition-all duration-150 cursor-pointer"
>
<span class="text-xs w-6 h-6 rounded flex items-center justify-center shrink-0" :class="typeStyle(entry.type)">
{{ typeIcon(entry.type) }}
</span>
<div class="flex-1 min-w-0">
<div class="text-xs font-semibold truncate text-white/80">{{ entry.title }}</div>
<div v-if="entry.subtitle" class="text-xs truncate text-white/40">{{ entry.subtitle }}</div>
</div>
<span class="text-xs text-white/20 shrink-0">{{ timeAgo(entry.viewedAt) }}</span>
</div>
</template>
<!-- Trending -->
<template v-else-if="activeSubTab === 'trending'">
<div v-if="trending.length === 0" class="flex flex-col items-center justify-center py-12 gap-2">
<svg class="w-8 h-8 text-white/10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
<p class="text-xs text-white/30">No trending items yet</p>
</div>
<div
v-for="item in trending"
:key="item.id"
class="flex items-center gap-3 p-3 rounded-xl bg-white/[0.03] hover:bg-white/[0.07] border border-white/5 transition-all duration-150"
>
<span class="text-xs w-6 h-6 rounded flex items-center justify-center shrink-0" :class="typeStyle(item.type)">
{{ typeIcon(item.type) }}
</span>
<div class="flex-1 min-w-0">
<div class="text-xs font-semibold truncate text-white/80">{{ item.title }}</div>
</div>
<span class="text-xs px-1.5 py-0.5 rounded bg-accent/15 text-accent/80 shrink-0">
{{ item.count }}x
</span>
</div>
</template>
<!-- Collections -->
<template v-else-if="activeSubTab === 'collections'">
<!-- Create new -->
<div class="flex gap-2 mb-3">
<input
v-model="newCollectionName"
type="text"
placeholder="New collection name..."
class="flex-1 px-3 py-2 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors"
@keydown.enter="createNewCollection"
/>
<button
class="px-2.5 py-2 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
:disabled="!newCollectionName.trim()"
@click="createNewCollection"
>
Create
</button>
</div>
<div v-if="collections.length === 0" class="flex flex-col items-center justify-center py-12 gap-2">
<svg class="w-8 h-8 text-white/10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path 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" />
</svg>
<p class="text-xs text-white/30">No collections yet</p>
</div>
<div
v-for="col in collections"
:key="col.id"
class="rounded-xl bg-white/[0.03] border border-white/5 p-3 space-y-2"
>
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<p class="text-xs font-semibold text-white/80 truncate">{{ col.name }}</p>
<p v-if="col.description" class="text-xs text-white/30 truncate">{{ col.description }}</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<span class="text-xs text-white/25">{{ col.items.length }} items</span>
<button
class="text-xs px-1.5 py-0.5 rounded text-red-400/50 hover:text-red-400/80 hover:bg-red-400/10 transition-colors"
@click="deleteCollection(col.id)"
>
Delete
</button>
</div>
</div>
<!-- Mosaic thumbnails -->
<div v-if="col.items.length > 0" class="grid grid-cols-4 gap-1">
<div
v-for="item in col.items.slice(0, 4)"
:key="item.id"
class="aspect-square rounded bg-white/5 flex items-center justify-center"
>
<span class="text-xs font-bold" :class="typeStyle(item.type)">{{ typeIcon(item.type) }}</span>
</div>
</div>
<!-- Items list -->
<div v-for="item in col.items" :key="item.id" class="flex items-center gap-2 text-xs">
<span :class="typeStyle(item.type)" class="w-4 h-4 rounded flex items-center justify-center text-[7px] shrink-0">{{ typeIcon(item.type) }}</span>
<span class="text-white/60 truncate flex-1">{{ item.title }}</span>
<button
class="text-red-400/40 hover:text-red-400/70 transition-colors text-xs shrink-0"
@click="removeFromCollection(col.id, item.id)"
>
x
</button>
</div>
</div>
</template>
<!-- Tags -->
<template v-else-if="activeSubTab === 'tags'">
<div v-if="tagCloud.length === 0" class="flex flex-col items-center justify-center py-12 gap-2">
<svg class="w-8 h-8 text-white/10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<p class="text-xs text-white/30">No tags yet</p>
<p class="text-xs text-white/20">Tag items from content cards to organize them</p>
</div>
<!-- Tag cloud -->
<div v-if="tagCloud.length > 0" class="flex flex-wrap gap-1.5 mb-4">
<button
v-for="tc in tagCloud"
:key="tc.tag"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeTagFilter === tc.tag
? 'nav-tab-active'
: 'text-white/40 hover:text-white/70 bg-white/5 hover:bg-white/10'"
@click="activeTagFilter = activeTagFilter === tc.tag ? null : tc.tag"
>
{{ tc.tag }} <span class="text-white/20 ml-0.5">{{ tc.count }}</span>
</button>
</div>
<!-- Filtered items by tag -->
<div v-if="activeTagFilter" class="space-y-2">
<p class="text-xs text-white/30">Items tagged "{{ activeTagFilter }}"</p>
<div
v-for="itemId in getItemsByTag(activeTagFilter)"
:key="itemId"
class="flex items-center gap-2 p-2.5 rounded-xl bg-white/[0.03] border border-white/5"
>
<span class="text-xs text-white/60 font-mono truncate">{{ itemId }}</span>
<button
class="text-xs text-red-400/50 hover:text-red-400/80 transition-colors shrink-0"
@click="removeTag(itemId, activeTagFilter!)"
>
untag
</button>
</div>
</div>
</template>
<!-- Smart Playlists -->
<template v-else-if="activeSubTab === 'playlists'">
<div v-if="!hasAnySongs" class="flex flex-col items-center justify-center py-12 gap-2">
<svg class="w-8 h-8 text-white/10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
</svg>
<p class="text-xs text-white/30">No music data yet</p>
</div>
<template v-else>
<!-- Recently played songs -->
<div v-if="recentSongs.length > 0" class="mb-4">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold mb-2">Recently Played</p>
<div
v-for="entry in recentSongs.slice(0, 10)"
:key="entry.id"
class="flex items-center gap-2 p-2 rounded-lg bg-white/[0.03] hover:bg-white/[0.07] border border-white/5 mb-1 transition-colors"
>
<span class="text-xs w-5 h-5 rounded flex items-center justify-center shrink-0 bg-green-500/20 text-green-400">S</span>
<div class="flex-1 min-w-0">
<p class="text-xs text-white/70 truncate">{{ entry.title }}</p>
<p v-if="entry.subtitle" class="text-xs text-white/30 truncate">{{ entry.subtitle }}</p>
</div>
<span class="text-xs text-white/20 shrink-0">{{ timeAgo(entry.viewedAt) }}</span>
</div>
</div>
<!-- Most played songs -->
<div v-if="mostPlayedSongs.length > 0" class="mb-4">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold mb-2">Most Played</p>
<div
v-for="item in mostPlayedSongs.slice(0, 10)"
:key="item.id"
class="flex items-center gap-2 p-2 rounded-lg bg-white/[0.03] hover:bg-white/[0.07] border border-white/5 mb-1 transition-colors"
>
<span class="text-xs w-5 h-5 rounded flex items-center justify-center shrink-0 bg-green-500/20 text-green-400">S</span>
<div class="flex-1 min-w-0">
<p class="text-xs text-white/70 truncate">{{ item.title }}</p>
</div>
<span class="text-xs px-1.5 py-0.5 rounded bg-green-400/15 text-green-400/80 shrink-0">{{ item.count }}x</span>
</div>
</div>
<!-- By genre -->
<div v-if="songsByGenre.length > 0" class="mb-4">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold mb-2">By Genre</p>
<div class="flex flex-wrap gap-1.5">
<button
v-for="genre in songsByGenre"
:key="genre.genre"
class="text-xs px-2 py-1 rounded-md bg-white/5 text-white/50 hover:text-white/70 hover:bg-white/10 transition-colors"
>
{{ genre.genre }} <span class="text-white/20">{{ genre.count }}</span>
</button>
</div>
</div>
<!-- By decade -->
<div v-if="songsByDecade.length > 0">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold mb-2">By Decade</p>
<div class="flex flex-wrap gap-1.5">
<button
v-for="dec in songsByDecade"
:key="dec.decade"
class="text-xs px-2 py-1 rounded-md bg-white/5 text-white/50 hover:text-white/70 hover:bg-white/10 transition-colors"
>
{{ dec.decade }}s <span class="text-white/20">{{ dec.count }}</span>
</button>
</div>
</div>
</template>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useForYouFeed, useContentTags, useViewHistory, useTrending } from '@/composables/useContentDiscovery'
import { useContentCollections } from '@/composables/useContentCollections'
import { useFavoritesStore, type FavoriteType } from '@/stores/favorites'
type SubTab = 'foryou' | 'recent' | 'trending' | 'collections' | 'tags' | 'playlists'
const subTabs: { id: SubTab; label: string }[] = [
{ id: 'foryou', label: 'For You' },
{ id: 'recent', label: 'Recent' },
{ id: 'trending', label: 'Trending' },
{ id: 'collections', label: 'Collections' },
{ id: 'tags', label: 'Tags' },
{ id: 'playlists', label: 'Playlists' },
]
const activeSubTab = ref<SubTab>('foryou')
// M13.1 — For You
const { forYouItems } = useForYouFeed()
// M13.2 — Tags
const { tagCloud, getItemsByTag, removeTag } = useContentTags()
const activeTagFilter = ref<string | null>(null)
// M13.5 — Recent
const { viewHistory, clearHistory } = useViewHistory()
// M13.6 — Collections
const { collections, createCollection, deleteCollection, removeFromCollection } = useContentCollections()
const newCollectionName = ref('')
function createNewCollection() {
const name = newCollectionName.value.trim()
if (!name) return
createCollection(name)
newCollectionName.value = ''
}
// M13.7 — Trending
const { trending } = useTrending()
// M13.3 — Smart Playlists
const favoritesStore = useFavoritesStore()
const recentSongs = computed(() =>
viewHistory.value.filter(h => h.type === 'song')
)
const mostPlayedSongs = computed(() =>
trending.value.filter(t => t.type === 'song')
)
const hasAnySongs = computed(() =>
recentSongs.value.length > 0 || mostPlayedSongs.value.length > 0 || favoritesStore.getFavoritesByType('song').length > 0
)
const songsByGenre = computed(() => {
const songFavs = favoritesStore.getFavoritesByType('song')
const counts = new Map<string, number>()
for (const fav of songFavs) {
const data = fav.data as { genres?: string[] } | undefined
for (const g of data?.genres ?? []) {
counts.set(g, (counts.get(g) ?? 0) + 1)
}
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.map(([genre, count]) => ({ genre, count }))
})
const songsByDecade = computed(() => {
const songFavs = favoritesStore.getFavoritesByType('song')
const counts = new Map<number, number>()
for (const fav of songFavs) {
const data = fav.data as { year?: number } | undefined
if (data?.year) {
const decade = Math.floor(data.year / 10) * 10
counts.set(decade, (counts.get(decade) ?? 0) + 1)
}
}
return [...counts.entries()]
.sort((a, b) => a[0] - b[0])
.map(([decade, count]) => ({ decade, count }))
})
// Helpers
function typeIcon(type: string): string {
const icons: Record<string, string> = {
film: 'F', song: 'S', podcast: 'P', book: 'B', tv: 'T', place: 'L', article: 'A',
}
return icons[type] ?? '?'
}
function typeStyle(type: string): string {
const colors: Record<string, string> = {
film: 'bg-blue-500/20 text-blue-400',
song: 'bg-green-500/20 text-green-400',
podcast: 'bg-orange-500/20 text-orange-400',
book: 'bg-yellow-500/20 text-yellow-400',
tv: 'bg-indigo-500/20 text-indigo-400',
place: 'bg-red-500/20 text-red-400',
article: 'bg-cyan-500/20 text-cyan-400',
}
return colors[type] ?? 'bg-white/10 text-white/40'
}
function timeAgo(ts: number): string {
const diff = Date.now() - ts
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
</script>
@@ -0,0 +1,162 @@
<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'">
Favorites
</h3>
<span class="text-xs font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredItems.length }} saved
</span>
</div>
<div class="flex gap-1.5 flex-wrap">
<button
v-for="filter in typeFilters"
:key="filter.id"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeType === filter.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="activeType = activeType === filter.id ? null : filter.id"
>
{{ filter.label }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-2">
<div
v-if="filteredItems.length === 0"
class="flex flex-col items-center justify-center py-12 gap-2"
>
<svg
class="w-8 h-8"
:class="isDark ? 'text-white/10' : 'text-gray-200'"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No favorites yet
</p>
</div>
<div
v-for="item in filteredItems"
:key="item.id"
class="flex items-center gap-3 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'"
>
<span
class="text-xs w-6 h-6 rounded flex items-center justify-center shrink-0"
:class="typeStyle(item.type)"
>
{{ typeIcon(item.type) }}
</span>
<div class="flex-1 min-w-0">
<div
class="text-xs font-semibold truncate"
:class="isDark ? 'text-white/80' : 'text-gray-800'"
>
{{ item.title }}
</div>
<div
v-if="item.subtitle"
class="text-xs truncate"
:class="isDark ? 'text-white/40' : 'text-gray-500'"
>
{{ item.subtitle }}
</div>
</div>
<button
class="shrink-0 text-accent/60 hover:text-accent transition-colors p-1"
aria-label="Remove from favorites"
@click="store.removeFavorite(item.id)"
>
<svg class="w-3.5 h-3.5" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useFavoritesStore, type FavoriteType } from '@/stores/favorites'
const { isDark } = useTheme()
const store = useFavoritesStore()
const activeType = ref<FavoriteType | null>(null)
const typeFilters: { id: FavoriteType; label: string }[] = [
{ id: 'film', label: 'Films' },
{ id: 'song', label: 'Songs' },
{ id: 'podcast', label: 'Podcasts' },
{ id: 'book', label: 'Books' },
{ id: 'tv', label: 'TV' },
{ id: 'place', label: 'Places' },
]
const filteredItems = computed(() => {
if (activeType.value) {
return store.getFavoritesByType(activeType.value)
}
return store.sortedItems
})
function typeIcon(type: string): string {
const icons: Record<string, string> = {
film: 'F', song: 'S', podcast: 'P', book: 'B', tv: 'T', place: 'L', article: 'A',
}
return icons[type] ?? '?'
}
function typeStyle(type: string): string {
if (isDark.value) {
const colors: Record<string, string> = {
film: 'bg-blue-500/20 text-blue-400',
song: 'bg-green-500/20 text-green-400',
podcast: 'bg-orange-500/20 text-orange-400',
book: 'bg-yellow-500/20 text-yellow-400',
tv: 'bg-indigo-500/20 text-indigo-400',
place: 'bg-red-500/20 text-red-400',
article: 'bg-cyan-500/20 text-cyan-400',
}
return colors[type] ?? 'bg-white/10 text-white/40'
}
const colors: Record<string, string> = {
film: 'bg-blue-50 text-blue-600',
song: 'bg-green-50 text-green-600',
podcast: 'bg-orange-50 text-orange-600',
book: 'bg-yellow-50 text-yellow-600',
tv: 'bg-indigo-50 text-indigo-600',
place: 'bg-red-50 text-red-600',
article: 'bg-cyan-50 text-cyan-600',
}
return colors[type] ?? 'bg-gray-50 text-gray-600'
}
</script>
@@ -0,0 +1,132 @@
<template>
<div class="group/node">
<div
class="w-full flex items-center gap-1.5 py-1 px-2 rounded-lg text-xs transition-colors cursor-pointer"
:class="[
isActive
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
: isDark ? 'text-white/60 hover:bg-white/[0.04] hover:text-white/80' : 'text-gray-600 hover:bg-black/[0.03] hover:text-gray-800',
]"
:style="{ paddingLeft: `${depth * 12 + 8}px` }"
@click="handleClick"
>
<!-- Expand/collapse for directories -->
<svg
v-if="entry.isDirectory"
class="w-3 h-3 shrink-0 transition-transform duration-150"
:class="expanded ? 'rotate-90' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<!-- File/folder icon -->
<svg class="w-3.5 h-3.5 shrink-0"
:class="entry.isDirectory
? 'text-accent/70'
: isDark ? 'text-white/30' : 'text-gray-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="entry.isDirectory" 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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="truncate flex-1">{{ entry.name }}</span>
<!-- Context selector checkbox (files: far right, visible on hover or when selected) -->
<button
v-if="!entry.isDirectory"
class="shrink-0 w-4 h-4 rounded-full border flex items-center justify-center transition-all ml-auto"
:class="[
isSelected
? 'bg-accent border-accent text-white'
: isDark
? 'border-white/20 opacity-0 group-hover/node:opacity-100 hover:border-white/40'
: 'border-black/15 opacity-0 group-hover/node:opacity-100 hover:border-black/30',
]"
aria-label="Toggle file for chat context"
@click.stop="handleToggleContext"
>
<svg v-if="isSelected" class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</button>
<!-- Context selector for directories (top-right, visible on hover or when selected) -->
<button
v-if="entry.isDirectory"
class="shrink-0 w-4 h-4 rounded-full border flex items-center justify-center transition-all ml-auto"
:class="[
isDirSelected
? 'bg-accent border-accent text-white'
: isDark
? 'border-white/20 opacity-0 group-hover/node:opacity-100 hover:border-white/40'
: 'border-black/15 opacity-0 group-hover/node:opacity-100 hover:border-black/30',
]"
aria-label="Add folder to chat context"
@click.stop="handleToggleDirContext"
>
<svg v-if="isDirSelected" class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
<!-- Children (when expanded) -->
<div v-if="entry.isDirectory && expanded && entry.children">
<FileTreeNode
v-for="child in entry.children"
:key="child.path"
:entry="child"
:active-file="activeFile"
:depth="depth + 1"
@select="$emit('select', $event)"
@toggle-context="$emit('toggle-context', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useCodeContext, type FileEntry } from '@/composables/useCodeContext'
const props = defineProps<{
entry: FileEntry
activeFile: string | null
depth: number
}>()
const { isFileSelected } = useCodeContext()
const emit = defineEmits<{
select: [path: string]
'toggle-context': [path: string]
}>()
const { isDark } = useTheme()
const expanded = ref(props.depth < 1) // Auto-expand first level
const isActive = computed(() => !props.entry.isDirectory && props.activeFile === props.entry.path)
const isSelected = computed(() => !props.entry.isDirectory && isFileSelected(props.entry.path))
const isDirSelected = computed(() => props.entry.isDirectory && isFileSelected(props.entry.path))
function handleClick() {
if (props.entry.isDirectory) {
expanded.value = !expanded.value
} else {
// Click opens file in code viewer
emit('select', props.entry.path)
}
}
function handleToggleContext() {
emit('toggle-context', props.entry.path)
}
function handleToggleDirContext() {
emit('toggle-context', props.entry.path)
}
</script>
@@ -0,0 +1,88 @@
<template>
<button
class="flex gap-3 p-2 rounded-xl transition-all duration-200 text-left w-full group overflow-hidden"
:class="isDark
? 'hover:bg-white/5 active:bg-white/10'
: 'hover:bg-black/[0.03] active:bg-black/5'"
@click="$emit('select', film)"
>
<div class="poster-card-sm shrink-0 w-12 aspect-[2/3] rounded-lg overflow-hidden">
<img
v-if="film.posterUrl"
:src="film.posterUrl"
:alt="film.title"
class="w-full h-full object-cover rounded-[6px] transition-transform duration-300 group-hover:scale-105"
loading="lazy"
@error="(e) => handleImgError(e, film.title, film.year)"
/>
<div
v-else
class="w-full h-full rounded-[6px]"
:class="isDark ? 'bg-white/10' : 'bg-black/5'"
/>
</div>
<div class="min-w-0 flex-1 py-0.5">
<p class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">{{ film.title }}</p>
<p class="text-xs mt-0.5"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
{{ film.year }}<template v-if="film.director"> · {{ film.director }}</template>
</p>
<p v-if="isExternal && film.synopsis"
class="text-xs mt-0.5 line-clamp-2"
:class="isDark ? 'text-white/35' : 'text-gray-400'">
{{ film.synopsis }}
</p>
<div class="flex items-center gap-1.5 mt-1.5">
<span v-if="film.rating > 0"
class="text-xs font-semibold px-1.5 py-0.5 rounded"
:class="ratingClass">
★ {{ film.rating }}
</span>
<span v-if="isExternal"
class="text-xs px-1.5 py-0.5 rounded font-medium"
:class="isDark ? 'bg-info/15 text-info/70' : 'bg-info/10 text-blue-600'">
not in library
</span>
<span
v-for="src in film.sources.slice(0, 3)"
:key="src.type"
class="text-xs px-1.5 py-0.5 rounded font-medium"
:class="isDark ? 'bg-white/8 text-white/50' : 'bg-black/5 text-gray-500'"
>
{{ src.type }}
</span>
<FavoriteButton
class="ml-auto"
:favorited="favoritesStore.isFavorited(film.id)"
@toggle="favoritesStore.toggleFavorite({ id: film.id, type: 'film', title: film.title, subtitle: `${film.year} · ${film.director}`, data: film })"
/>
</div>
</div>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Film } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { handleImgError } from '@/composables/useImageFallback'
import FavoriteButton from '@/components/ui/FavoriteButton.vue'
import { useFavoritesStore } from '@/stores/favorites'
const props = defineProps<{ film: Film }>()
defineEmits<{ select: [film: Film] }>()
const { isDark } = useTheme()
const favoritesStore = useFavoritesStore()
const isExternal = computed(() => props.film.id.startsWith('ext-'))
const ratingClass = computed(() => {
const r = props.film.rating
if (r >= 8.5) return isDark.value ? 'bg-success/20 text-success' : 'bg-success/10 text-green-700'
if (r >= 7.5) return isDark.value ? 'bg-accent/20 text-accent' : 'bg-accent/10 text-amber-700'
return isDark.value ? 'bg-white/10 text-white/50' : 'bg-black/5 text-gray-500'
})
</script>
@@ -0,0 +1,166 @@
<template>
<div class="film-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<div class="relative w-full overflow-hidden aspect-[16/7] shrink-0">
<img
v-if="bannerSrc"
:src="bannerSrc"
:alt="film.title"
class="absolute inset-0 w-full h-full object-cover object-center block"
@error="onBannerError"
/>
<div
v-else
class="absolute inset-0"
:style="{ background: fallbackGradient }"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
<button
class="absolute top-3 left-3 min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10 text-white/80"
@click="$emit('back')"
>
<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-if="playableSource"
class="absolute inset-0 flex items-center justify-center z-[5] group/play"
aria-label="Watch film"
@click="openVideo"
>
<span class="w-20 h-20 rounded-full flex items-center justify-center path-glass-icon group-hover/play:scale-110 transition-transform">
<svg class="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</span>
</button>
<div class="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ film.title }}</h2>
<div class="flex items-center gap-2 mt-1 text-xs text-white/60">
<!-- honest rendering: zero/empty metadata is hidden, not "★ 0 · 0m" -->
<span v-if="film.rating > 0" class="text-accent font-bold">★ {{ film.rating }}</span>
<span v-if="film.year > 0">{{ film.year }}</span>
<span v-if="film.runtime > 0">{{ film.runtime }}m</span>
<span v-if="film.director">{{ film.director }}</span>
</div>
</div>
</div>
<div class="p-4 space-y-4">
<div v-if="film.genres.length" class="flex flex-wrap gap-1.5">
<span
v-for="genre in film.genres"
:key="genre"
class="text-xs px-2 py-1 rounded-md font-medium"
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'"
>
{{ genre }}
</span>
</div>
<div v-if="film.synopsis">
<h4 v-if="isExternal"
class="text-xs uppercase tracking-[0.2em] font-semibold mb-1.5"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
Why watch
</h4>
<p class="text-sm leading-relaxed"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ film.synopsis }}
</p>
</div>
<div v-if="film.cast.length">
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Cast</h4>
<p class="text-sm" :class="isDark ? 'text-white/70' : 'text-gray-700'">
{{ film.cast.join(', ') }}
</p>
</div>
<div v-if="film.sources.length">
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Watch on</h4>
<div class="space-y-2">
<a
v-for="src in film.sources"
:key="src.url"
:href="src.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<span class="text-sm">{{ sourceIcon(src.type) }}</span>
<div>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ src.name }}</p>
<p class="text-xs"
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ src.quality }}</p>
</div>
</div>
<svg class="w-4 h-4" :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>
</a>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Film } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useBannerFallback } from '@/composables/useBannerFallback'
import { fetchFilmImage } from '@/composables/useImageFallback'
import { useVideoPlayerStore } from '@/stores/videoPlayer'
const props = defineProps<{ film: Film }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const videoStore = useVideoPlayerStore()
const isExternal = computed(() => props.film.id.startsWith('ext-'))
// Node files first: own/peer/IndeeHub sources are same-origin `/content/<id>`
// streams the node serves with Range support (media-src 'self' allows them).
// YouTube is the free-films fallback, not the default — a real library file
// must never route out to YouTube when its bytes are on the operator's node.
const NODE_SOURCE_TYPES = new Set(['nextcloud', 'plex', 'indeehub'])
const playableSource = computed(() =>
props.film.sources.find(
s => NODE_SOURCE_TYPES.has(s.type) && (s.url.startsWith('/') || s.url.startsWith(window.location.origin)),
) ?? props.film.sources.find(s => s.type === 'youtube' || s.url.includes('youtube.com'))
)
function openVideo() {
if (!playableSource.value) return
videoStore.open(playableSource.value.url, props.film.title, props.film.posterUrl || props.film.backdropUrl)
}
const { bannerSrc, fallbackGradient, onBannerError } = useBannerFallback({
primaryUrls: () => [props.film.backdropUrl, props.film.posterUrl],
apiFetch: () => fetchFilmImage(props.film.title, props.film.year),
title: () => props.film.title,
})
function sourceIcon(type: string): string {
const icons: Record<string, string> = {
plex: '🟧',
nextcloud: '☁️',
youtube: '▶️',
'free-web': '🌐',
}
return icons[type] ?? '📺'
}
</script>
@@ -0,0 +1,165 @@
<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'">
{{ title }}
</h3>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredFilms.length }} films
</span>
<slot name="header-actions" />
</div>
</div>
<input
v-model="search"
type="text"
placeholder="Search films..."
class="w-full px-3 py-2 rounded-lg text-base 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 flex-wrap gap-1.5">
<button
v-for="genre in topGenres"
:key="genre"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeGenre === genre
? '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="activeGenre = activeGenre === genre ? null : genre"
>
{{ genre }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<button
v-for="film in filteredFilms"
:key="film.id"
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"
:aria-label="`${film.title} (${film.year})`"
@click="$emit('selectFilm', film)"
>
<div class="poster-card flex-1 min-h-0">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(film) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="isLoading(film)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(film)"
:src="coverSrc(film)!"
:alt="`${film.title} (${film.year}) directed by ${film.director}`"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onError(film)"
/>
<img
v-else-if="!isLoading(film)"
:src="fallbackSrc(film)"
:alt="film.title"
class="w-full h-full object-cover"
/>
<div v-if="coverSrc(film)" class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
<div class="absolute bottom-0 left-0 right-0 p-2">
<p class="text-xs font-semibold text-white/90 leading-tight truncate">
{{ film.title }}
</p>
<div class="flex items-center gap-1 mt-0.5">
<!-- hide zero-value metadata: "★ 0" tells the operator nothing -->
<span v-if="film.rating > 0" class="text-xs text-accent font-bold">★ {{ film.rating }}</span>
<span v-if="film.year > 0" class="text-xs text-white/40">{{ film.year }}</span>
</div>
</div>
<div class="absolute top-1.5 right-1.5 flex gap-0.5">
<span
v-for="src in film.sources.slice(0, 2)"
:key="src.type"
class="text-xs px-1 py-0.5 rounded bg-black/60 text-white/70 backdrop-blur-sm"
>
{{ src.type }}
</span>
</div>
</div>
</div>
</button>
</div>
<div v-if="filteredFilms.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No films match your search
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, toRef } from 'vue'
import type { Film } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useContentImages } from '@/composables/useContentImages'
import { fetchFilmImage, generatePosterFallback } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
films: Film[]
title?: string
}>(), {
title: 'Recommended Films',
})
defineEmits<{ selectFilm: [film: Film] }>()
const { isDark } = useTheme()
const search = ref('')
const activeGenre = ref<string | null>(null)
const { coverSrc, fallbackSrc, onError, isLoading } = useContentImages({
items: toRef(props, 'films'),
id: (f) => f.id,
existingUrl: (f) => f.posterUrl || f.backdropUrl,
fetch: (f) => fetchFilmImage(f.title, f.year).then((r) => r.posterUrl),
fallback: (f) => generatePosterFallback(f.title, f.year),
})
const topGenres = computed(() => {
const counts = new Map<string, number>()
for (const f of props.films) {
for (const g of f.genres) {
counts.set(g, (counts.get(g) ?? 0) + 1)
}
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([g]) => g)
})
const filteredFilms = computed(() => {
let result = props.films
if (search.value) {
const q = search.value.toLowerCase()
result = result.filter(
(f) =>
f.title.toLowerCase().includes(q) ||
f.director.toLowerCase().includes(q) ||
f.cast.some((c) => c.toLowerCase().includes(q))
)
}
if (activeGenre.value) {
result = result.filter((f) => f.genres.includes(activeGenre.value!))
}
return result
})
</script>
@@ -0,0 +1,58 @@
<template>
<button
class="flex items-start gap-3 w-full text-left p-2.5 rounded-xl transition-all duration-150"
:class="isDark
? 'hover:bg-white/5'
: 'hover:bg-black/3'"
@click="$emit('select', image)"
>
<div class="w-16 shrink-0 rounded-lg overflow-hidden">
<div class="aspect-[4/3] relative bg-black/10">
<img
v-if="!imgFailed"
:src="image.url"
:alt="image.alt || image.title || 'Image'"
class="w-full h-full object-cover"
loading="lazy"
@error="imgFailed = true"
/>
<div
v-else
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackImg})` }"
/>
</div>
</div>
<div class="flex-1 min-w-0 py-0.5">
<p v-if="image.title" class="text-sm font-medium leading-snug line-clamp-1"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ image.title }}
</p>
<p v-if="image.source" class="text-xs mt-0.5 truncate"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ image.source }}
</p>
<p v-if="image.description" class="text-xs mt-1 line-clamp-2 leading-relaxed"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
{{ image.description }}
</p>
</div>
</button>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ImageItem } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateImageFallback } from '@/composables/useImageFallback'
const props = defineProps<{ image: ImageItem }>()
defineEmits<{ select: [image: ImageItem] }>()
const { isDark } = useTheme()
const imgFailed = ref(false)
const fallbackImg = computed(() =>
generateImageFallback(props.image.title || props.image.alt || 'Image')
)
</script>
@@ -0,0 +1,87 @@
<template>
<div class="image-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<div class="relative w-full overflow-hidden bg-black/20">
<img
v-if="!imgFailed"
:src="image.url"
:alt="image.alt || image.title || 'Image'"
class="w-full block max-h-[60vh] object-contain bg-black/40"
@error="imgFailed = true"
/>
<div
v-else
class="w-full aspect-video flex items-center justify-center"
:class="isDark ? 'bg-white/5' : 'bg-black/5'"
>
<svg class="w-12 h-12" :class="isDark ? 'text-white/15' : 'text-gray-300'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<button
class="absolute top-3 left-3 min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
@click="$emit('back')"
>
<svg class="w-4 h-4 text-white/90" 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>
<div class="p-4 space-y-3">
<h2 v-if="image.title" class="text-base font-bold"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ image.title }}
</h2>
<p v-if="image.description" class="text-sm leading-relaxed"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ image.description }}
</p>
<div v-if="image.attribution" class="text-xs"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ image.attribution }}
</div>
<div v-if="image.source" class="text-xs"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
Source: {{ image.source }}
</div>
<div v-if="image.width && image.height" class="text-xs"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ image.width }} &times; {{ image.height }}
</div>
<div class="pt-2">
<a
:href="image.url"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-2 px-4 min-h-[44px] rounded-xl text-xs font-medium transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10 text-white/80'
: 'bg-black/3 hover:bg-black/5 text-gray-800'"
>
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open original
</a>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { ImageItem } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
defineProps<{ image: ImageItem }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const imgFailed = ref(false)
</script>
@@ -0,0 +1,85 @@
<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'">
{{ title }}
</h3>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ images.length }} images
</span>
<slot name="header-actions" />
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="columns-2 sm:columns-3 gap-3 space-y-3">
<button
v-for="img in images"
:key="img.id"
class="group w-full break-inside-avoid text-left rounded-xl overflow-hidden transition-all duration-200 hover:brightness-110 relative"
:class="isDark ? 'bg-white/5' : 'bg-black/3'"
:aria-label="img.alt || img.title || 'Image'"
@click="$emit('selectImage', img)"
>
<img
v-if="!failedIds.has(img.id)"
:src="img.url"
:alt="img.alt || img.title || 'Image'"
class="w-full block transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy"
@error="onError(img)"
/>
<div
v-else
class="w-full aspect-[4/3] flex items-center justify-center"
:class="isDark ? 'bg-white/5' : 'bg-black/5'"
>
<svg class="w-8 h-8" :class="isDark ? 'text-white/15' : 'text-gray-300'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div v-if="img.title || img.source"
class="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent">
<p v-if="img.title" class="text-xs font-medium text-white/90 truncate">{{ img.title }}</p>
<p v-if="img.source" class="text-xs text-white/50 truncate">{{ img.source }}</p>
</div>
</button>
</div>
<div v-if="images.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No images found
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { ImageItem } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
withDefaults(defineProps<{
images: ImageItem[]
title?: string
}>(), {
title: 'Images',
})
defineEmits<{ selectImage: [image: ImageItem] }>()
const { isDark } = useTheme()
const failedIds = ref<Set<string>>(new Set())
function onError(img: ImageItem) {
failedIds.value.add(img.id)
failedIds.value = new Set(failedIds.value)
}
</script>
@@ -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>
@@ -0,0 +1,12 @@
<template>
<div
class="aspect-[2/3] rounded-xl animate-pulse"
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
/>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
const { isDark } = useTheme()
</script>
@@ -0,0 +1,21 @@
<template>
<div class="flex-1 overflow-y-auto p-4">
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
<LoadingFilmCard
v-for="i in count"
:key="i"
/>
</div>
</div>
</template>
<script setup lang="ts">
import LoadingFilmCard from './LoadingFilmCard.vue'
withDefaults(
defineProps<{
count?: number
}>(),
{ count: 12 }
)
</script>
@@ -0,0 +1,330 @@
<template>
<div class="magazine h-full flex flex-col"
:class="isDark ? 'magazine-dark' : 'magazine-light'">
<!-- Masthead -->
<header class="shrink-0 px-5 py-4 flex items-center justify-between border-b"
:class="isDark ? 'border-white/10' : 'border-black/10'">
<h1 class="font-serif text-xl font-bold tracking-tight"
:class="isDark ? 'text-white' : 'text-black'">
AI Brief
</h1>
<div class="shrink-0">
<slot name="header-actions" />
</div>
</header>
<div class="flex-1 overflow-y-auto custom-scrollbar">
<!-- Query context hero -->
<div v-if="headlineText" class="relative overflow-hidden"
:style="{ minHeight: '180px' }">
<!-- Background image or gradient -->
<div class="absolute inset-0">
<img v-if="heroImageUrl"
:src="heroImageUrl"
alt=""
class="w-full h-full object-cover"
style="filter: saturate(0.3) contrast(1.1);" />
<div v-else class="w-full h-full"
:class="isDark
? 'bg-gradient-to-br from-white/[0.04] via-white/[0.02] to-transparent'
: 'bg-gradient-to-br from-black/[0.06] via-black/[0.03] to-transparent'" />
</div>
<!-- Dark overlay -->
<div class="absolute inset-0"
:class="isDark
? 'bg-gradient-to-t from-[#0a0a0a] via-[#0a0a0a]/80 to-[#0a0a0a]/60'
: 'bg-gradient-to-t from-[#faf9f6] via-[#faf9f6]/85 to-[#faf9f6]/65'" />
<!-- Content -->
<div class="relative z-10 flex flex-col justify-end h-full px-5 pb-5 pt-12"
style="min-height: 180px;">
<p class="text-xs uppercase tracking-[0.3em] font-medium mb-2"
:class="isDark ? 'text-white/40' : 'text-black/40'">
In response to
</p>
<p class="font-serif text-2xl italic leading-tight"
:class="isDark ? 'text-white/70' : 'text-black/60'">
{{ headlineText }}
</p>
</div>
</div>
<!-- Tile grid -->
<div class="px-3 pt-2 pb-8">
<div class="grid grid-cols-2 gap-px"
:class="isDark ? 'bg-white/12' : 'bg-black/10'">
<template v-for="(tile, i) in tiles" :key="i">
<!-- Banner tile: full width with icon -->
<div v-if="tile.type === 'banner'"
class="col-span-2 flex flex-col items-center justify-center py-8 px-5"
:class="isDark ? 'bg-[#0a0a0a]' : 'bg-[#faf9f6]'">
<svg class="w-5 h-5 mb-2.5" :class="isDark ? 'text-white/20' : 'text-black/15'"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path v-if="tile.icon === 'compass'" stroke-linecap="round" stroke-linejoin="round"
d="M12 2a10 10 0 100 20 10 10 0 000-20zm0 0v2m0 16v2m10-10h-2M4 12H2m15.07-5.07l-1.41 1.41M8.34 15.66l-1.41 1.41m0-11.14l1.41 1.41m7.32 7.32l1.41 1.41" />
<path v-else-if="tile.icon === 'bookmark'" stroke-linecap="round" stroke-linejoin="round"
d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
<path v-else-if="tile.icon === 'lightning'" stroke-linecap="round" stroke-linejoin="round"
d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
<path v-else stroke-linecap="round" stroke-linejoin="round"
d="M4 6h16M4 12h16M4 18h7" />
</svg>
<p class="text-xs uppercase tracking-[0.3em] font-semibold text-center"
:class="isDark ? 'text-white/30' : 'text-black/30'">
{{ tile.label }}
</p>
</div>
<!-- Wide tile: full width, for lead/summary -->
<button v-else-if="tile.type === 'wide'"
class="col-span-2 text-left px-5 py-5 transition-colors cursor-pointer"
:class="isDark
? 'bg-[#0a0a0a] hover:bg-white/[0.03]'
: 'bg-[#faf9f6] hover:bg-black/[0.02]'"
@click="tile.section && openTile(tile.section)">
<p v-if="tile.label"
class="text-xs uppercase tracking-[0.3em] font-semibold mb-2"
:class="isDark ? 'text-white/30' : 'text-black/35'">
{{ tile.label }}
</p>
<h2 class="font-serif text-lg font-bold leading-snug mb-2"
:class="isDark ? 'text-white/95' : 'text-black/90'">
{{ tile.title }}
</h2>
<p v-if="tile.author"
class="text-xs mb-2"
:class="isDark ? 'text-white/40' : 'text-black/40'">
By {{ tile.author }}
</p>
<p class="font-serif text-sm leading-relaxed"
:class="isDark ? 'text-white/70' : 'text-black/60'">
{{ tile.text }}
</p>
</button>
<!-- Standard tile: half width -->
<button v-else
class="text-left px-4 py-4 transition-colors flex flex-col cursor-pointer"
:class="[
isDark
? 'bg-[#0a0a0a] hover:bg-white/[0.03]'
: 'bg-[#faf9f6] hover:bg-black/[0.02]',
tile.type === 'dark'
? isDark ? 'bg-white/[0.04]' : 'bg-black/[0.04]'
: ''
]"
@click="tile.section && openTile(tile.section)">
<p v-if="tile.label"
class="text-xs uppercase tracking-[0.25em] font-semibold mb-1.5"
:class="isDark ? 'text-white/25' : 'text-black/30'">
{{ tile.label }}
</p>
<h3 v-if="tile.title"
class="font-serif text-sm font-bold leading-snug mb-1"
:class="isDark ? 'text-white/90' : 'text-black/85'">
{{ tile.title }}
</h3>
<p class="font-serif text-xs leading-relaxed flex-1"
:class="[
isDark ? 'text-white/55' : 'text-black/50',
!tile.title ? 'italic' : ''
]">
{{ tile.text }}
</p>
</button>
</template>
</div>
</div>
<!-- Empty state -->
<div v-if="sections.length === 0" class="flex items-center justify-center py-16 px-4">
<p class="text-sm" :class="isDark ? 'text-white/40' : 'text-gray-400'">
No sections to display
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel, type MagazineSection } from '@/composables/useContentPanel'
interface Tile {
type: 'wide' | 'half' | 'dark' | 'banner'
title: string
text: string
label?: string
author?: string
icon?: string
section?: MagazineSection
}
const props = withDefaults(defineProps<{
sections: MagazineSection[]
heroImageUrl?: string | null
title?: string
query?: string
}>(), {
heroImageUrl: null,
title: 'Brief',
query: '',
})
const { isDark } = useTheme()
const { openWebsiteDetail, openMagazineSectionDetail } = useContentPanel()
const bannerIcons = ['compass', 'bookmark', 'lightning', 'lines'] as const
const bannerLabels = ['Perspectives', 'Worth Noting', 'Key Signals', 'Analysis']
function cleanText(text: string): string {
return text
.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(/\*([^*\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()
}
function truncate(text: string, max: number): string {
const clean = cleanText(text)
if (clean.length <= max) return clean
return clean.slice(0, max).replace(/\s+\S*$/, '') + '\u2009...'
}
/** Break a section's content into individual points (split on bullets/newlines) */
function splitIntoBullets(content: string): string[] {
return content
.split(/\n\s*[-•]\s*|\n{2,}/)
.map(s => s.replace(/^[-•]\s*/, '').replace(/\*\*/g, '').replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').trim())
.filter(s => s.length > 10)
}
const tiles = computed<Tile[]>(() => {
const result: Tile[] = []
const secs = props.sections
if (!secs.length) return result
// Seeded pseudo-random based on query for consistent layout
let seed = 0
for (const c of (props.query || 'brief')) seed = ((seed << 5) - seed + c.charCodeAt(0)) | 0
const rand = () => { seed = (seed * 16807 + 0) % 2147483647; return (seed & 0x7fffffff) / 2147483647 }
let lastGroup = ''
let bannerIdx = 0
let pairToggle = false // track half-tile pairing
secs.forEach((section, i) => {
if (i === 0 && !section.group) {
// Lead section: always wide
result.push({
type: 'wide',
title: section.title,
text: truncate(section.content, 200),
label: 'The Lead',
author: section.author,
section,
})
return
}
// Insert a banner when entering a new heading group
const group = section.group || ''
if (group && group !== lastGroup) {
// Pad any unpaired half tile before the banner
if (pairToggle) {
result.push({ type: 'dark', title: '', text: '' })
pairToggle = false
}
result.push({
type: 'banner',
title: '',
text: '',
icon: bannerIcons[bannerIdx % bannerIcons.length],
label: group,
})
bannerIdx++
lastGroup = group
}
// Sections within a group get alternating half/dark tiles
if (group) {
const variant = pairToggle ? 'dark' : 'half'
// If title is basically the same as content start, skip the title and just show content
const contentClean = cleanText(section.content)
const titleClean = cleanText(section.title)
const titleIsContent = contentClean.toLowerCase().startsWith(titleClean.toLowerCase().slice(0, 30))
result.push({
type: variant,
title: titleIsContent ? '' : section.title,
text: truncate(section.content, titleIsContent ? 160 : 100),
section,
})
pairToggle = !pairToggle
} else {
// Non-grouped sections: use wide layout
// Pad any unpaired half tile
if (pairToggle) {
result.push({ type: 'dark', title: '', text: '' })
pairToggle = false
}
result.push({
type: 'wide',
title: section.title,
text: truncate(section.content, 180),
author: section.author,
section,
})
}
})
// Pad final unpaired half tile
if (pairToggle) {
result.push({ type: 'dark', title: '', text: '' })
}
return result
})
/** Extract bold title from a bullet point like "**Title** - rest" */
function extractBulletTitle(text: string): string {
const m = /^\*\*([^*]+)\*\*/.exec(text)
return m ? m[1].trim() : ''
}
function cleanBulletTitle(text: string): string {
return text.replace(/^\*\*[^*]+\*\*\s*[-–—:]\s*/, '').trim()
}
function openTile(section: MagazineSection) {
const idx = props.sections.indexOf(section)
openMagazineSectionDetail(section, idx >= 0 ? idx : 0)
}
const headlineText = computed(() => {
const q = (props.query ?? '').trim()
if (!q) return props.title
return q.length > 100 ? q.slice(0, 97) + '...' : q
})
</script>
<style scoped>
.magazine {
font-family: Georgia, 'Times New Roman', Times, serif;
}
.magazine-light {
background-color: #faf9f6;
}
.magazine-dark {
background-color: #0a0a0a;
}
</style>
@@ -0,0 +1,160 @@
<template>
<div class="magazine-section-detail h-full flex flex-col overflow-hidden"
:class="isDark ? 'bg-[#0a0a0a]' : 'bg-[#faf9f6]'"
style="font-family: Georgia, 'Times New Roman', Times, serif;">
<!-- Header with back + nav counter -->
<div class="shrink-0 flex items-center justify-between px-4 py-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="absolute top-3 left-3 min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
@click="$emit('back')"
>
<svg class="w-4 h-4" :class="isDark ? 'text-white/70' : 'text-gray-600'" 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="flex-1 text-center pl-8">
<span class="text-xs uppercase tracking-[0.3em] font-semibold"
:class="isDark ? 'text-white/30' : 'text-black/30'">
AI Brief
</span>
</div>
<span class="text-xs font-mono tabular-nums shrink-0"
:class="isDark ? 'text-white/25' : 'text-black/25'">
{{ currentIndex + 1 }}/{{ totalSections }}
</span>
</div>
<!-- Content area -->
<div class="flex-1 min-h-0 overflow-y-auto custom-scrollbar flex flex-col">
<div class="px-6 py-8 md:px-8 md:py-10 max-w-lg mx-auto my-auto">
<!-- Group label -->
<p v-if="section.group"
class="text-xs uppercase tracking-[0.3em] font-semibold mb-4"
:class="isDark ? 'text-white/25' : 'text-black/30'">
{{ section.group }}
</p>
<!-- Title -->
<h2 class="text-2xl md:text-3xl font-bold leading-tight mb-4"
:class="isDark ? 'text-white/95' : 'text-black/90'">
{{ section.title }}
</h2>
<!-- Author -->
<p v-if="section.author"
class="text-xs mb-6"
:class="isDark ? 'text-white/40' : 'text-black/40'">
By {{ section.author }}
</p>
<!-- Decorative rule -->
<div class="w-12 h-px mb-6"
:class="isDark ? 'bg-white/15' : 'bg-black/15'" />
<!-- Content as quote-style paragraphs -->
<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'">
{{ paragraph }}
</p>
</div>
<!-- Source link -->
<a v-if="section.url"
:href="section.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 mt-6 min-h-[44px] text-xs transition-colors"
:class="isDark ? 'text-white/40 hover:text-white/70' : 'text-black/40 hover:text-black/70'">
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Source
</a>
</div>
</div>
<!-- Navigation footer -->
<div class="shrink-0 flex items-center justify-between px-4 py-3"
:style="isDark
? 'border-top: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-top: 1px solid rgba(0, 0, 0, 0.06)'">
<button
class="flex items-center gap-1.5 px-3 min-h-[44px] rounded-lg text-xs transition-colors"
:class="isDark
? 'text-white/50 hover:text-white/80 hover:bg-white/5'
: 'text-black/40 hover:text-black/70 hover:bg-black/5'"
@click="$emit('navigate', 'prev')"
>
<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>
Prev
</button>
<!-- Dot indicators -->
<div class="flex items-center gap-1">
<div v-for="n in totalSections" :key="n"
class="w-1.5 h-1.5 rounded-full transition-all duration-200"
:class="n - 1 === currentIndex
? isDark ? 'bg-white/70 scale-125' : 'bg-black/60 scale-125'
: isDark ? 'bg-white/15' : 'bg-black/15'" />
</div>
<button
class="flex items-center gap-1.5 px-3 min-h-[44px] rounded-lg text-xs transition-colors"
:class="isDark
? 'text-white/50 hover:text-white/80 hover:bg-white/5'
: 'text-black/40 hover:text-black/70 hover:bg-black/5'"
@click="$emit('navigate', 'next')"
>
Next
<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 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { MagazineSection } from '@/composables/useContentPanel'
import { useTheme } from '@/composables/useTheme'
const props = defineProps<{
section: MagazineSection
currentIndex: number
totalSections: number
}>()
defineEmits<{
back: []
navigate: [direction: 'prev' | 'next']
}>()
const { isDark } = useTheme()
const paragraphs = computed(() => {
const text = props.section.content
return text
.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*[-•]\s+/, '').replace(/(^|\n)\s*,\s*/g, '$1').trim())
.filter(p => p.length > 0)
})
</script>
@@ -0,0 +1,69 @@
<template>
<button
class="flex gap-3 p-2 rounded-xl transition-all duration-200 text-left w-full group overflow-hidden"
:class="isDark
? 'hover:bg-white/5 active:bg-white/10'
: 'hover:bg-black/[0.03] active:bg-black/5'"
@click="$emit('select-article', article)"
>
<div class="cover-card-sm shrink-0 w-12 h-12 rounded-lg overflow-hidden">
<img
v-if="imgSrc"
:src="imgSrc"
:alt="article.title"
class="w-full h-full object-cover rounded-[6px] transition-transform duration-300 group-hover:scale-105"
loading="lazy"
@error="imgFailed = true"
/>
<div
v-else
class="w-full h-full rounded-[6px] bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackImg})` }"
/>
</div>
<div class="min-w-0 flex-1 py-0.5">
<p class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">{{ article.title }}</p>
<p v-if="article.content"
class="text-xs mt-0.5 line-clamp-2"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
{{ article.content }}
</p>
<p class="text-xs mt-1 truncate"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ formatDomain(article.url) }}
</p>
</div>
<svg class="w-4 h-4 shrink-0 self-center opacity-50"
:class="isDark ? 'text-white/50' : '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>
</button>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { WebSearchResult } from '@aiui/core/types/message'
import { useTheme } from '@/composables/useTheme'
import { isSafeUrl, formatDomain } from '@/utils/html'
import { generateNewsFallback } from '@/composables/useImageFallback'
const props = defineProps<{ article: WebSearchResult }>()
defineEmits<{ 'select-article': [article: WebSearchResult] }>()
const { isDark } = useTheme()
const imgFailed = ref(false)
const imgSrc = computed(() => {
if (imgFailed.value) return null
const u = props.article.imgSrc
return isSafeUrl(u) ? u : null
})
const fallbackImg = computed(() =>
generateNewsFallback(props.article.title, formatDomain(props.article.url))
)
</script>
@@ -0,0 +1,183 @@
<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'">
{{ title }}
</h3>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredArticles.length }} {{ variant === 'websites' ? 'websites' : 'articles' }}
</span>
<slot name="header-actions" />
</div>
</div>
<input
v-model="search"
type="text"
:placeholder="variant === 'websites' ? 'Search websites...' : 'Search articles...'"
class="w-full px-3 py-2 rounded-lg text-base 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>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<button
v-for="(article, i) in filteredArticles"
:key="i"
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"
:aria-label="article.title"
@click="openArticle(article)"
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-[4/3] flex flex-col w-full overflow-hidden rounded-[10px]">
<!-- Top: image or icon area (edge-to-edge with title bar below) -->
<div class="flex-1 min-h-0 relative">
<img
v-if="isSafeImgUrl(article.imgSrc) && !failedImgs.has(article.url)"
:src="article.imgSrc"
:alt="article.title"
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onImgError(article.url)"
/>
<div
v-else
class="absolute inset-0 bg-cover bg-center"
:style="{ backgroundImage: `url(${newsFallback(article)})` }"
/>
<div v-if="isSafeImgUrl(article.imgSrc) && !failedImgs.has(article.url)" class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent pointer-events-none" />
</div>
<div
class="shrink-0 p-2 backdrop-blur-md rounded-b-[10px]"
:class="[
isDark ? 'bg-black shadow-[inset_0_1px_0_rgba(255,255,255,0.12)]' : 'bg-white shadow-[inset_0_1px_0_rgba(0,0,0,0.06)]'
]"
>
<p class="text-xs font-semibold leading-tight line-clamp-2"
:class="isDark ? 'text-white/95' : 'text-gray-900'">
{{ article.title }}
</p>
<p v-if="article.content"
class="text-xs line-clamp-1 mt-0.5"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ article.content }}
</p>
<p class="text-xs truncate mt-0.5"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ formatDomain(article.url) }}
</p>
</div>
</div>
</div>
</button>
</div>
<div v-if="filteredArticles.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ variant === 'websites' ? 'No websites match your search' : 'No articles match your search' }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { WebSearchResult } from '@aiui/core/types/message'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel } from '@/composables/useContentPanel'
import { generateNewsFallback, generateWebsiteFallback } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
articles: WebSearchResult[]
title?: string
/** User query for contextual sorting (most relevant first) */
query?: string
/** 'news' = icon in upper area; 'websites' = icon in glassmorphic circle centered */
variant?: 'news' | 'websites'
}>(), {
title: 'News & Articles',
query: '',
variant: 'news',
})
const { isDark } = useTheme()
const { openArticleDetail, openWebsiteDetail } = useContentPanel()
const search = ref('')
const failedImgs = ref<Set<string>>(new Set())
function isSafeImgUrl(u: string | undefined): u is string {
if (!u || typeof u !== 'string') return false
return /^https?:\/\//i.test(u.trim())
}
function onImgError(url: string) {
failedImgs.value = new Set([...failedImgs.value, url])
}
function formatDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '')
} catch {
return url
}
}
function newsFallback(article: WebSearchResult): string {
if (props.variant === 'websites') {
return generateWebsiteFallback(article.title, formatDomain(article.url))
}
return generateNewsFallback(article.title, formatDomain(article.url))
}
function openArticle(article: WebSearchResult) {
if (props.variant === 'websites') {
openWebsiteDetail(article)
} else {
openArticleDetail(article)
}
}
function relevanceScore(article: WebSearchResult, query: string): number {
if (!query.trim()) return 0
const q = query.toLowerCase()
const terms = q.split(/\s+/).filter((t) => t.length > 1)
if (terms.length === 0) return 0
const title = article.title.toLowerCase()
const content = (article.content ?? '').toLowerCase()
const url = article.url.toLowerCase()
let score = 0
for (const term of terms) {
if (title.includes(term)) score += 3
if (content.includes(term)) score += 2
if (url.includes(term)) score += 1
}
return score
}
const filteredArticles = computed(() => {
let list = props.articles
if (search.value.trim()) {
const q = search.value.toLowerCase()
list = list.filter(
(a) =>
a.title.toLowerCase().includes(q) ||
(a.content ?? '').toLowerCase().includes(q) ||
a.url.toLowerCase().includes(q),
)
}
if (props.query.trim()) {
return [...list].sort((a, b) => relevanceScore(b, props.query) - relevanceScore(a, props.query))
}
return list
})
</script>
@@ -0,0 +1,170 @@
<template>
<div class="h-full flex flex-col">
<!-- Article detail view -->
<template v-if="selectedArticle">
<div class="flex items-center gap-2 px-4 py-3 border-b border-white/[0.08]">
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg text-white/60 hover:text-white/80 hover:bg-white/10 transition-colors"
@click="selectedArticle = null"
>
<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>
<span class="text-xs text-white/40 truncate">{{ articleTitle }}</span>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar">
<ArticleReader
:content="selectedArticle.content"
:title="articleTitle"
/>
</div>
</template>
<!-- Article list -->
<template v-else>
<div class="p-4 border-b border-white/[0.08]">
<h3 class="text-sm font-bold text-white/90 mb-2">Long-Form Articles</h3>
<p class="text-xs text-white/30">NIP-23 kind:30023 articles from your network</p>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-2">
<div v-if="isLoading" class="flex items-center justify-center py-12">
<p class="text-xs text-white/30">Loading articles...</p>
</div>
<button
v-for="article in articles"
:key="article.id"
class="w-full text-left p-3 rounded-xl transition-all duration-150 bg-white/[0.03] hover:bg-white/[0.07] border border-white/5"
@click="selectedArticle = article"
>
<div class="space-y-1">
<h4 class="text-xs font-semibold text-white/80 line-clamp-2">
{{ getArticleTitle(article) }}
</h4>
<p class="text-xs text-white/40 line-clamp-2">
{{ getArticleSummary(article) }}
</p>
<div class="flex items-center gap-2">
<span class="text-xs text-white/25 font-mono">{{ truncate(article.pubkey) }}</span>
<span class="text-xs text-white/20">{{ formatDate(article.created_at) }}</span>
<span v-if="getArticleImage(article)" class="text-xs text-accent/40 ml-auto">has image</span>
</div>
</div>
</button>
<div v-if="!isLoading && articles.length === 0" class="flex items-center justify-center py-12">
<p class="text-xs text-white/30">No articles found</p>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import ArticleReader from '@/components/renderers/ArticleReader.vue'
import type { NostrEvent, NostrNote } from '@/composables/useNostr'
const articles = ref<NostrNote[]>([])
const isLoading = ref(true)
const selectedArticle = ref<NostrNote | null>(null)
const articleTitle = computed(() => {
if (!selectedArticle.value) return ''
return getArticleTitle(selectedArticle.value)
})
function truncate(hex: string): string {
if (hex.length <= 16) return hex
return hex.slice(0, 8) + '...' + hex.slice(-4)
}
function formatDate(ts: number): string {
return new Date(ts * 1000).toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' })
}
function getArticleTitle(note: NostrNote): string {
const titleTag = note.tags.find(t => t[0] === 'title')
if (titleTag?.[1]) return titleTag[1]
// Fallback: first line or first 60 chars
const firstLine = note.content.split('\n')[0]
return firstLine.replace(/^#+ /, '').slice(0, 60) || 'Untitled'
}
function getArticleSummary(note: NostrNote): string {
const summaryTag = note.tags.find(t => t[0] === 'summary')
if (summaryTag?.[1]) return summaryTag[1]
return note.content.slice(0, 120).replace(/[#*_]/g, '')
}
function getArticleImage(note: NostrNote): string | null {
const imageTag = note.tags.find(t => t[0] === 'image')
return imageTag?.[1] ?? null
}
async function loadArticles() {
isLoading.value = true
articles.value = []
const relayUrl = 'wss://relay.nostr.band'
const subId = 'articles-' + Math.random().toString(36).slice(2, 8)
try {
const ws = new WebSocket(relayUrl)
const results: NostrNote[] = []
const timer = setTimeout(() => {
ws.close()
articles.value = results
isLoading.value = false
}, 10000)
ws.onopen = () => {
ws.send(JSON.stringify([
'REQ', subId,
{ kinds: [30023], limit: 30 },
]))
}
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data)
if (Array.isArray(data) && data[0] === 'EVENT' && data[1] === subId && data[2]) {
const evt = data[2] as NostrEvent
if (!results.find(r => r.id === evt.id)) {
results.push({
id: evt.id,
pubkey: evt.pubkey,
authorName: truncate(evt.pubkey),
kind: evt.kind,
content: evt.content,
created_at: evt.created_at,
tags: evt.tags ?? [],
})
}
}
if (Array.isArray(data) && data[0] === 'EOSE') {
clearTimeout(timer)
ws.close()
results.sort((a, b) => b.created_at - a.created_at)
articles.value = results
isLoading.value = false
}
} catch { /* skip */ }
}
ws.onerror = () => {
clearTimeout(timer)
isLoading.value = false
}
} catch {
isLoading.value = false
}
}
onMounted(() => {
loadArticles()
})
</script>
@@ -0,0 +1,209 @@
<template>
<div class="h-full flex flex-col">
<!-- Thread view -->
<template v-if="activeThread">
<div class="flex items-center gap-2 px-4 py-3 border-b border-white/[0.08]">
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg text-white/60 hover:text-white/80 hover:bg-white/10 transition-colors"
@click="clearActiveContact"
>
<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>
<div class="flex-1 min-w-0">
<p class="text-xs font-semibold text-white/80 truncate">{{ activeThread.contactName }}</p>
<p class="text-xs text-white/30 font-mono truncate">{{ activeThread.contactPubkey }}</p>
</div>
</div>
<div ref="messagesRef" class="flex-1 overflow-y-auto custom-scrollbar px-4 py-3 space-y-2">
<div
v-for="msg in activeThread.messages"
:key="msg.id"
class="flex"
:class="msg.fromPubkey === pubkey ? 'justify-end' : 'justify-start'"
>
<div
class="max-w-[80%] rounded-xl px-3 py-2"
:class="msg.fromPubkey === pubkey
? 'bg-accent/15 text-white/80'
: 'bg-white/5 text-white/70'"
>
<p class="text-xs leading-relaxed break-words">{{ msg.content }}</p>
<p class="text-xs mt-1 text-white/25 tabular-nums">{{ formatTime(msg.created_at) }}</p>
</div>
</div>
</div>
<!-- Message input -->
<div class="px-4 py-3 border-t border-white/[0.08]">
<div class="flex gap-2">
<input
v-model="messageInput"
type="text"
placeholder="Type a message..."
class="flex-1 px-3 py-2 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors"
@keydown.enter="sendMessage"
/>
<button
class="px-3 py-2 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
:disabled="!messageInput.trim() || isSending"
@click="sendMessage"
>
Send
</button>
</div>
</div>
</template>
<!-- Contact list / inbox -->
<template v-else>
<div class="p-4 border-b border-white/[0.08]">
<div class="flex items-center justify-between gap-2 mb-3">
<h3 class="text-sm font-bold text-white/90">Messages</h3>
<button
class="text-xs px-2.5 py-1 rounded bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
@click="showNewDM = !showNewDM"
>
{{ showNewDM ? 'Cancel' : 'New' }}
</button>
</div>
<!-- New DM input -->
<div v-if="showNewDM" class="space-y-2 mb-3">
<input
v-model="newContactPubkey"
type="text"
placeholder="Recipient hex pubkey or npub..."
class="w-full px-3 py-2 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors font-mono"
/>
<button
class="text-xs px-2.5 py-1 rounded bg-white/5 text-white/60 hover:bg-white/10 transition-colors disabled:opacity-30"
:disabled="!newContactPubkey.trim()"
@click="startNewDM"
>
Start conversation
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-1">
<div
v-if="!isLoggedIn"
class="flex items-center justify-center py-12"
>
<p class="text-xs text-white/30">Sign in with Nostr to use DMs</p>
</div>
<div
v-else-if="isLoading"
class="flex items-center justify-center py-12"
>
<p class="text-xs text-white/30">Loading messages...</p>
</div>
<div
v-else-if="threads.length === 0"
class="flex items-center justify-center py-12"
>
<p class="text-xs text-white/30">No messages yet</p>
</div>
<button
v-for="thread in threads"
:key="thread.contactPubkey"
class="w-full text-left p-3 rounded-xl transition-all duration-150 bg-white/[0.03] hover:bg-white/[0.07] border border-white/5"
@click="selectContact(thread.contactPubkey)"
>
<div class="flex items-start gap-2.5">
<div class="w-8 h-8 rounded-full shrink-0 flex items-center justify-center text-xs font-bold bg-accent/20 text-accent">
{{ thread.contactName.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 text-white/80">
{{ thread.contactName }}
</span>
<span v-if="thread.lastMessage" class="text-xs ml-auto shrink-0 text-white/20">
{{ formatTime(thread.lastMessage.created_at) }}
</span>
</div>
<p v-if="thread.lastMessage" class="text-xs mt-1 text-white/40 truncate">
{{ thread.lastMessage.content }}
</p>
</div>
</div>
</button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, watch } from 'vue'
import { useNostrDMs } from '@/composables/useNostrDMs'
import { useNostrIdentity } from '@/composables/useNostrIdentity'
import { decodeNpub } from '@/utils/bech32'
const { threads, activeThread, activeContact, isLoading, loadDMs, sendDM, selectContact, clearActiveContact } = useNostrDMs()
const { pubkey, isLoggedIn } = useNostrIdentity()
const messageInput = ref('')
const isSending = ref(false)
const messagesRef = ref<HTMLElement | null>(null)
const showNewDM = ref(false)
const newContactPubkey = ref('')
function formatTime(ts: number): string {
const d = new Date(ts * 1000)
const now = new Date()
const diffDays = Math.floor((now.getTime() - d.getTime()) / 86400000)
if (diffDays === 0) return d.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' })
if (diffDays < 7) return d.toLocaleDateString('en', { weekday: 'short' })
return d.toLocaleDateString('en', { month: 'short', day: 'numeric' })
}
async function sendMessage() {
if (!messageInput.value.trim() || isSending.value || !activeContact.value) return
isSending.value = true
const success = await sendDM(activeContact.value, messageInput.value.trim())
if (success) {
messageInput.value = ''
await nextTick()
scrollToBottom()
}
isSending.value = false
}
function scrollToBottom() {
if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
}
function startNewDM() {
let hex = newContactPubkey.value.trim()
if (hex.startsWith('npub')) {
try {
hex = decodeNpub(hex)
} catch {
return
}
}
if (hex.length === 64) {
selectContact(hex)
showNewDM.value = false
newContactPubkey.value = ''
}
}
watch(activeContact, async () => {
await nextTick()
scrollToBottom()
})
onMounted(() => {
loadDMs()
})
</script>
@@ -0,0 +1,412 @@
<template>
<div class="h-full flex flex-col">
<!-- Sub-tab switcher -->
<div class="flex gap-2 px-4 pt-3 pb-1">
<button
v-for="tab in subTabs"
:key="tab.id"
class="text-xs px-2.5 min-h-[44px] rounded-md transition-all duration-150 flex items-center justify-center"
:class="activeSubTab === tab.id
? 'nav-tab-active'
: 'text-white/40 hover:text-white/70 hover:bg-white/5'"
@click="activeSubTab = tab.id"
>
{{ tab.label }}
</button>
</div>
<!-- DMs sub-tab -->
<NostrDMs v-if="activeSubTab === 'dms'" />
<!-- Relays sub-tab -->
<NostrRelayManager v-else-if="activeSubTab === 'relays'" />
<!-- Profile sub-tab -->
<NostrProfileEditor v-else-if="activeSubTab === 'profile'" />
<!-- Lists sub-tab -->
<NostrLists v-else-if="activeSubTab === 'lists'" />
<!-- Articles sub-tab -->
<NostrArticles v-else-if="activeSubTab === 'articles'" />
<!-- Thread view -->
<NostrThread
v-else-if="activeSubTab === 'feed' && selectedNoteId"
:note-id="selectedNoteId"
@back="selectedNoteId = null"
/>
<!-- Feed sub-tab -->
<template v-else>
<div class="p-4 space-y-3 border-b border-white/[0.08]">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-bold text-white/90">
Nostr Feed
</h3>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs font-mono text-white/30">
{{ filteredNotes.length }} notes
</span>
<slot name="header-actions" />
</div>
</div>
<!-- Compose toggle -->
<button
v-if="isLoggedIn"
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/40 bg-white/5 hover:bg-white/10 transition-colors"
@click="showCompose = !showCompose"
>
{{ showCompose ? 'Cancel' : 'Write a note...' }}
</button>
<!-- Compose panel -->
<div v-if="showCompose && isLoggedIn" class="rounded-lg bg-white/5 border border-white/10 p-3 space-y-2">
<textarea
ref="composeRef"
v-model="composeText"
class="w-full bg-transparent text-base text-white/80 placeholder:text-white/25 outline-none resize-none min-h-[80px]"
placeholder="What's on your mind?"
@keydown.meta.enter="publishNote"
@keydown.ctrl.enter="publishNote"
/>
<div class="flex items-center justify-between gap-2">
<span
class="text-xs tabular-nums"
:class="composeText.length > 280 ? 'text-accent/80' : 'text-white/25'"
>
{{ composeText.length }}
</span>
<button
class="text-xs px-3 py-1.5 rounded-lg bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
:disabled="!composeText.trim() || isPublishing"
@click="publishNote"
>
{{ isPublishing ? 'Publishing...' : 'Publish' }}
</button>
</div>
<!-- Publish results -->
<div v-if="publishResults.length > 0" class="space-y-1">
<div
v-for="result in publishResults"
:key="result.url"
class="flex items-center gap-2 text-xs px-2 py-1 rounded bg-white/[0.02]"
>
<span
class="w-1.5 h-1.5 rounded-full shrink-0"
:class="result.success ? 'bg-emerald-500' : 'bg-red-400/60'"
/>
<span class="truncate font-mono text-white/40">{{ result.url }}</span>
<span class="ml-auto shrink-0" :class="result.success ? 'text-emerald-400/60' : 'text-red-400/60'">
{{ result.message }}
</span>
</div>
</div>
</div>
<div class="flex gap-2">
<input
v-model="search"
type="text"
placeholder="Search notes, npubs..."
class="flex-1 px-3 py-2 rounded-lg text-base outline-none transition-colors bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10"
@keydown.enter="triggerNostrSearch"
/>
<button
v-if="search.trim()"
class="px-2.5 py-2 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30 shrink-0"
:disabled="isSearching"
@click="triggerNostrSearch"
>
{{ isSearching ? '...' : 'NIP-50' }}
</button>
</div>
<div class="flex gap-2">
<button
v-for="kind in noteKinds"
:key="kind.id"
class="text-xs px-2.5 min-h-[44px] rounded-md transition-all duration-150 flex items-center justify-center"
:class="activeKind === kind.id
? 'nav-tab-active'
: 'text-white/40 hover:text-white/70 hover:bg-white/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">
<!-- Loading state -->
<div
v-if="!isConnected && notes.length === 0"
class="flex flex-col items-center justify-center py-12 gap-3"
>
<svg class="w-5 h-5 animate-spin text-white/30" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<p class="text-xs text-white/30">Connecting to relays...</p>
</div>
<button
v-for="note in filteredNotes"
:key="note.id"
class="w-full text-left p-3 rounded-xl transition-all duration-150 bg-white/[0.03] hover:bg-white/[0.07] border border-white/5"
@click="selectedNoteId = note.id"
>
<div class="flex items-start gap-2.5">
<div class="w-8 h-8 rounded-full shrink-0 overflow-hidden">
<img
v-if="note.authorPicture && !failedAvatars.has(note.pubkey)"
:src="note.authorPicture"
:alt="note.authorName ?? 'profile'"
class="w-full h-full object-cover"
loading="lazy"
@error="failedAvatars.add(note.pubkey)"
/>
<div
v-else
class="w-full h-full flex items-center justify-center text-xs font-bold bg-purple-500/20 text-purple-400"
>
{{ note.authorName?.charAt(0)?.toUpperCase() ?? '?' }}
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-xs font-semibold truncate text-white/80">
{{ note.authorName ?? 'anon' }}
</span>
<span v-if="note.nip05" class="text-xs truncate text-purple-400/60 flex items-center gap-0.5">
<svg
v-if="nip05Status[note.id] === true"
class="w-2.5 h-2.5 text-emerald-400 shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
:title="note.nip05"
>
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
{{ note.nip05 }}
</span>
<span class="text-xs ml-auto shrink-0 text-white/20">
{{ formatTime(note.created_at) }}
</span>
</div>
<p class="text-xs mt-1 leading-relaxed line-clamp-3 text-white/60">
{{ note.content }}
</p>
<div class="flex items-center gap-3 mt-2">
<button
class="text-xs px-3 py-2 min-h-[44px] min-w-[44px] rounded bg-white/5 text-accent/60 hover:text-accent hover:bg-accent/10 transition-colors flex items-center justify-center"
@click.stop="openZap(note)"
>
Zap
</button>
<span
v-if="note.kind !== 1"
class="text-xs px-1.5 py-0.5 rounded bg-white/5 text-white/30"
>
kind:{{ note.kind }}
</span>
</div>
</div>
</div>
</button>
<!-- Relay status -->
<div class="mt-4 pt-4 border-t border-white/5">
<p class="text-xs font-medium mb-2 text-white/30">Relays</p>
<div class="space-y-1">
<div
v-for="relay in relayStates"
:key="relay.url"
class="flex items-center gap-2 text-xs px-2 py-1 rounded-lg bg-white/[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 text-white/40">{{ relay.url }}</span>
</div>
</div>
</div>
<div
v-if="isConnected && filteredNotes.length === 0"
class="flex items-center justify-center py-12"
>
<p class="text-sm text-white/30">No notes match your search</p>
</div>
</div>
</template>
<!-- Zap dialog -->
<ZapDialog
:is-open="zapOpen"
:target-name="zapTargetName"
:lightning-address="zapLightningAddress"
@close="zapOpen = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted, nextTick, watch } from 'vue'
import NostrDMs from './NostrDMs.vue'
import NostrRelayManager from './NostrRelayManager.vue'
import NostrProfileEditor from './NostrProfileEditor.vue'
import ZapDialog from './ZapDialog.vue'
import NostrThread from './NostrThread.vue'
import NostrLists from './NostrLists.vue'
import NostrArticles from './NostrArticles.vue'
import { useNip05Verification } from '@/composables/useNip05Verification'
import { useNostr, type NostrNote, type PublishResult } from '@/composables/useNostr'
import { useNostrIdentity } from '@/composables/useNostrIdentity'
const { events: notes, isConnected, relayStates, connect, publishEvent, searchResults, isSearching, searchNostr } = useNostr()
const { isLoggedIn, signEvent } = useNostrIdentity()
const { verifyNip05 } = useNip05Verification()
const activeSubTab = ref<'feed' | 'dms' | 'relays' | 'profile' | 'lists' | 'articles'>('feed')
const subTabs = [
{ id: 'feed' as const, label: 'Feed' },
{ id: 'articles' as const, label: 'Articles' },
{ id: 'dms' as const, label: 'Messages' },
{ id: 'lists' as const, label: 'Lists' },
{ id: 'relays' as const, label: 'Relays' },
{ id: 'profile' as const, label: 'Profile' },
]
const selectedNoteId = ref<string | null>(null)
const failedAvatars = reactive(new Set<string>())
const search = ref('')
const activeKind = ref<number | null>(null)
const showCompose = ref(false)
const composeText = ref('')
const composeRef = ref<HTMLTextAreaElement | null>(null)
const isPublishing = ref(false)
const publishResults = ref<PublishResult[]>([])
const zapOpen = ref(false)
const zapTargetName = ref('')
const zapLightningAddress = ref<string | undefined>(undefined)
function openZap(note: NostrNote) {
zapTargetName.value = note.authorName ?? note.pubkey.slice(0, 12)
// In a full implementation, we'd fetch the profile to get their Lightning address
zapLightningAddress.value = undefined
zapOpen.value = true
}
const nip05Status = reactive<Record<string, boolean | null>>({})
const noteKinds = [
{ id: 1, label: 'Notes' },
{ id: 30023, label: 'Articles' },
{ id: 9735, label: 'Zaps' },
{ id: 6, label: 'Reposts' },
]
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 usingNostrSearch = ref(false)
const filteredNotes = computed(() => {
// If NIP-50 search active, show those results
if (usingNostrSearch.value && searchResults.value.length > 0) {
let result = searchResults.value
if (activeKind.value !== null) {
result = result.filter(n => n.kind === activeKind.value)
}
return result
}
let result = notes.value
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
})
function triggerNostrSearch() {
if (!search.value.trim()) return
usingNostrSearch.value = true
searchNostr(search.value.trim(), activeKind.value ? [activeKind.value] : undefined)
}
// Clear NIP-50 mode when search is cleared
watch(search, (val) => {
if (!val.trim()) usingNostrSearch.value = false
})
// Verify NIP-05 for notes that have it
watch(filteredNotes, (visibleNotes) => {
for (const note of visibleNotes) {
if (note.nip05 && nip05Status[note.id] === undefined) {
nip05Status[note.id] = null
verifyNip05(note.nip05, note.pubkey).then(result => {
nip05Status[note.id] = result
})
}
}
}, { immediate: true })
async function publishNote() {
if (!composeText.value.trim() || isPublishing.value) return
isPublishing.value = true
publishResults.value = []
const unsigned = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: composeText.value.trim(),
}
const signed = await signEvent(unsigned)
if (!signed) {
isPublishing.value = false
return
}
const results = await publishEvent(signed)
publishResults.value = results
isPublishing.value = false
if (results.some(r => r.success)) {
composeText.value = ''
setTimeout(() => {
publishResults.value = []
showCompose.value = false
}, 3000)
}
}
onMounted(async () => {
connect()
if (showCompose.value) {
await nextTick()
composeRef.value?.focus()
}
})
</script>
@@ -0,0 +1,268 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 border-b border-white/[0.08]">
<h3 class="text-sm font-bold text-white/90 mb-3">Nostr Lists</h3>
<!-- List type selector -->
<div class="flex gap-1.5 flex-wrap">
<button
v-for="lt in listTypes"
:key="lt.kind"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeListKind === lt.kind
? 'nav-tab-active'
: 'text-white/40 hover:text-white/70 hover:bg-white/5'"
@click="activeListKind = lt.kind; loadList(lt.kind)"
>
{{ lt.label }}
</button>
</div>
</div>
<div v-if="!isLoggedIn" class="flex-1 flex items-center justify-center">
<p class="text-xs text-white/30">Sign in with Nostr to manage lists</p>
</div>
<div v-else class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-2">
<!-- Loading -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<p class="text-xs text-white/30">Loading list...</p>
</div>
<!-- Add item -->
<div class="flex gap-2 mb-3">
<input
v-model="newItemValue"
type="text"
:placeholder="activeListKind === 3 ? 'Add npub or hex pubkey...' : 'Add item (hex id or npub)...'"
class="flex-1 px-3 py-2 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors font-mono"
@keydown.enter="addItem"
/>
<button
class="px-2.5 py-2 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
:disabled="!newItemValue.trim()"
@click="addItem"
>
Add
</button>
</div>
<!-- List items -->
<div
v-for="item in listItems"
:key="item.value"
class="flex items-center gap-2 p-2.5 rounded-xl bg-white/[0.03] border border-white/5"
>
<div class="w-6 h-6 rounded-full shrink-0 flex items-center justify-center text-xs font-bold bg-purple-500/20 text-purple-400">
{{ item.tag === 'p' ? 'P' : item.tag === 'e' ? 'E' : item.tag === 't' ? '#' : '?' }}
</div>
<div class="flex-1 min-w-0">
<p class="text-xs text-white/60 font-mono truncate">{{ item.displayValue }}</p>
<p v-if="item.petname" class="text-xs text-white/30">{{ item.petname }}</p>
</div>
<button
class="text-xs px-2 py-1 rounded bg-white/5 text-red-400/50 hover:text-red-400/80 hover:bg-red-400/10 transition-colors shrink-0"
@click="removeItem(item)"
>
Remove
</button>
</div>
<div v-if="!isLoading && listItems.length === 0" class="flex items-center justify-center py-12">
<p class="text-xs text-white/30">List is empty</p>
</div>
<!-- Publish button -->
<button
v-if="isDirty"
class="w-full py-2.5 rounded-lg text-xs font-medium bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30 mt-4"
:disabled="isPublishingList"
@click="publishList"
>
{{ isPublishingList ? 'Publishing...' : 'Publish updated list' }}
</button>
<p v-if="publishStatus" class="text-xs text-center" :class="publishOk ? 'text-emerald-400/60' : 'text-red-400/60'">
{{ publishStatus }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useNostrIdentity } from '@/composables/useNostrIdentity'
import { useNostr, type NostrEvent } from '@/composables/useNostr'
import { decodeNpub, encodeNpub } from '@/utils/bech32'
const { isLoggedIn, signEvent, pubkey } = useNostrIdentity()
const { publishEvent } = useNostr()
interface ListItem {
tag: string
value: string
displayValue: string
relay?: string
petname?: string
}
const listTypes = [
{ kind: 3, label: 'Follows' },
{ kind: 10000, label: 'Mute' },
{ kind: 10001, label: 'Pin' },
{ kind: 10003, label: 'Bookmarks' },
]
const activeListKind = ref(3)
const listItems = ref<ListItem[]>([])
const isLoading = ref(false)
const isDirty = ref(false)
const newItemValue = ref('')
const isPublishingList = ref(false)
const publishStatus = ref('')
const publishOk = ref(false)
function truncate(hex: string): string {
if (hex.length <= 16) return hex
return hex.slice(0, 8) + '...' + hex.slice(-8)
}
function tagsToItems(tags: string[][]): ListItem[] {
return tags
.filter(t => t[0] === 'p' || t[0] === 'e' || t[0] === 't')
.map(t => {
let displayValue = truncate(t[1])
if (t[0] === 'p') {
try { displayValue = encodeNpub(t[1]) } catch { /* keep hex */ }
}
return {
tag: t[0],
value: t[1],
displayValue,
relay: t[2] || undefined,
petname: t[3] || undefined,
}
})
}
async function loadList(kind: number) {
if (!pubkey.value) return
isLoading.value = true
isDirty.value = false
listItems.value = []
try {
const ws = new WebSocket('wss://relay.nostr.band')
const subId = 'list-' + Math.random().toString(36).slice(2, 8)
const timer = setTimeout(() => {
ws.close()
isLoading.value = false
}, 8000)
ws.onopen = () => {
ws.send(JSON.stringify([
'REQ', subId,
{ kinds: [kind], authors: [pubkey.value], limit: 1 },
]))
}
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data)
if (Array.isArray(data) && data[0] === 'EVENT' && data[1] === subId && data[2]) {
const evt = data[2] as NostrEvent
listItems.value = tagsToItems(evt.tags)
}
if (Array.isArray(data) && data[0] === 'EOSE') {
clearTimeout(timer)
ws.close()
isLoading.value = false
}
} catch { /* skip */ }
}
ws.onerror = () => {
clearTimeout(timer)
isLoading.value = false
}
} catch {
isLoading.value = false
}
}
function addItem() {
let val = newItemValue.value.trim()
if (!val) return
let hex = val
const tag = activeListKind.value === 3 || activeListKind.value === 10000 ? 'p' : 'e'
if (val.startsWith('npub')) {
try { hex = decodeNpub(val) } catch { return }
}
if (listItems.value.find(i => i.value === hex)) return
let displayValue = truncate(hex)
if (tag === 'p') {
try { displayValue = encodeNpub(hex) } catch { /* keep hex */ }
}
listItems.value.push({ tag, value: hex, displayValue })
isDirty.value = true
newItemValue.value = ''
}
function removeItem(item: ListItem) {
listItems.value = listItems.value.filter(i => i.value !== item.value)
isDirty.value = true
}
async function publishList() {
if (!pubkey.value) return
isPublishingList.value = true
publishStatus.value = ''
const tags = listItems.value.map(i => {
const t = [i.tag, i.value]
if (i.relay) t.push(i.relay)
if (i.petname) t.push(i.petname)
return t
})
const unsigned = {
kind: activeListKind.value,
created_at: Math.floor(Date.now() / 1000),
tags,
content: '',
}
const signed = await signEvent(unsigned)
if (!signed) {
isPublishingList.value = false
publishStatus.value = 'Signing failed'
publishOk.value = false
return
}
const results = await publishEvent(signed)
const successes = results.filter(r => r.success).length
isPublishingList.value = false
isDirty.value = false
if (successes > 0) {
publishStatus.value = `Published to ${successes}/${results.length} relays`
publishOk.value = true
} else {
publishStatus.value = 'Failed to publish'
publishOk.value = false
}
}
onMounted(() => {
loadList(activeListKind.value)
})
</script>
@@ -0,0 +1,221 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 border-b border-white/[0.08]">
<h3 class="text-sm font-bold text-white/90">Nostr Profile</h3>
</div>
<div v-if="!isLoggedIn" class="flex-1 flex items-center justify-center">
<p class="text-xs text-white/30">Sign in with Nostr to edit your profile</p>
</div>
<div v-else class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-4">
<!-- Profile card preview -->
<div class="rounded-xl overflow-hidden border border-white/5">
<div
class="h-24 bg-cover bg-center"
:style="profile.banner ? { backgroundImage: `url(${profile.banner})` } : {}"
:class="!profile.banner ? 'bg-gradient-to-r from-accent/20 to-purple-500/20' : ''"
/>
<div class="px-4 pb-4 -mt-8">
<div
class="w-16 h-16 rounded-full border-2 border-black bg-cover bg-center flex items-center justify-center"
:style="profile.picture ? { backgroundImage: `url(${profile.picture})` } : {}"
:class="!profile.picture ? 'bg-accent/20' : ''"
>
<span v-if="!profile.picture" class="text-lg font-bold text-accent">
{{ (profile.display_name || profile.name || '?').charAt(0).toUpperCase() }}
</span>
</div>
<p class="text-sm font-bold text-white/90 mt-2">{{ profile.display_name || profile.name || 'Anonymous' }}</p>
<p v-if="profile.nip05" class="text-xs text-purple-400/60">{{ profile.nip05 }}</p>
<p v-if="profile.about" class="text-xs text-white/50 mt-1 line-clamp-2">{{ profile.about }}</p>
</div>
</div>
<!-- Edit form -->
<div class="space-y-3">
<div>
<label class="text-xs text-white/30 block mb-1">Display Name</label>
<input
v-model="profile.display_name"
type="text"
class="w-full px-3 py-2 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors"
placeholder="Your display name"
/>
</div>
<div>
<label class="text-xs text-white/30 block mb-1">Username</label>
<input
v-model="profile.name"
type="text"
class="w-full px-3 py-2 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors"
placeholder="username"
/>
</div>
<div>
<label class="text-xs text-white/30 block mb-1">Bio</label>
<textarea
v-model="profile.about"
class="w-full px-3 py-2 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors resize-none min-h-[60px]"
placeholder="Tell the world about yourself"
/>
</div>
<div>
<label class="text-xs text-white/30 block mb-1">Avatar URL</label>
<input
v-model="profile.picture"
type="url"
class="w-full px-3 py-2 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors font-mono"
placeholder="https://example.com/avatar.jpg"
/>
</div>
<div>
<label class="text-xs text-white/30 block mb-1">Banner URL</label>
<input
v-model="profile.banner"
type="url"
class="w-full px-3 py-2 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors font-mono"
placeholder="https://example.com/banner.jpg"
/>
</div>
<div>
<label class="text-xs text-white/30 block mb-1">Website</label>
<input
v-model="profile.website"
type="url"
class="w-full px-3 py-2 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors font-mono"
placeholder="https://example.com"
/>
</div>
<div>
<label class="text-xs text-white/30 block mb-1">NIP-05 Address</label>
<input
v-model="profile.nip05"
type="text"
class="w-full px-3 py-2 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors font-mono"
placeholder="you@example.com"
/>
</div>
<div>
<label class="text-xs text-white/30 block mb-1">Lightning Address</label>
<input
v-model="profile.lud16"
type="text"
class="w-full px-3 py-2 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors font-mono"
placeholder="you@getalby.com"
/>
</div>
<!-- Publish button -->
<button
class="w-full min-h-[44px] rounded-lg text-sm font-medium bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
:disabled="isPublishing"
@click="publishProfile"
>
{{ isPublishing ? 'Publishing...' : 'Publish Profile (kind:0)' }}
</button>
<!-- Status -->
<div v-if="publishStatus" class="text-xs text-center" :class="publishSuccess ? 'text-emerald-400/60' : 'text-red-400/60'">
{{ publishStatus }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useNostrIdentity } from '@/composables/useNostrIdentity'
import { useNostr } from '@/composables/useNostr'
const { isLoggedIn, signEvent, pubkey } = useNostrIdentity()
const { publishEvent, fetchNote } = useNostr()
interface ProfileData {
name: string
display_name: string
about: string
picture: string
banner: string
website: string
nip05: string
lud16: string
}
const profile = reactive<ProfileData>({
name: '',
display_name: '',
about: '',
picture: '',
banner: '',
website: '',
nip05: '',
lud16: '',
})
const isPublishing = ref(false)
const publishStatus = ref('')
const publishSuccess = ref(false)
async function loadExistingProfile() {
if (!pubkey.value) return
const note = await fetchNote(pubkey.value, 5000)
if (note && note.kind === 0) {
try {
const data = JSON.parse(note.content) as Partial<ProfileData>
Object.assign(profile, data)
} catch { /* invalid JSON */ }
}
}
async function publishProfile() {
if (!isLoggedIn.value) return
isPublishing.value = true
publishStatus.value = ''
const content: Record<string, string> = {}
for (const [key, val] of Object.entries(profile)) {
if (val) content[key] = val
}
const unsigned = {
kind: 0,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: JSON.stringify(content),
}
const signed = await signEvent(unsigned)
if (!signed) {
isPublishing.value = false
publishStatus.value = 'Signing failed'
publishSuccess.value = false
return
}
const results = await publishEvent(signed)
const successes = results.filter(r => r.success).length
isPublishing.value = false
if (successes > 0) {
publishStatus.value = `Published to ${successes}/${results.length} relays`
publishSuccess.value = true
} else {
publishStatus.value = 'Failed to publish to any relay'
publishSuccess.value = false
}
}
onMounted(() => {
loadExistingProfile()
})
</script>
@@ -0,0 +1,168 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 border-b border-white/[0.08]">
<h3 class="text-sm font-bold text-white/90 mb-3">Relay Management</h3>
<!-- Add relay -->
<div class="flex gap-2">
<input
v-model="newRelayUrl"
type="text"
placeholder="wss://relay.example.com"
class="flex-1 px-3 py-2 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors font-mono"
@keydown.enter="addNewRelay"
/>
<button
class="px-4 min-h-[44px] rounded-lg text-sm bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
:disabled="!newRelayUrl.trim()"
@click="addNewRelay"
>
Add
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-2">
<div
v-for="relay in relayStates"
:key="relay.url"
class="rounded-xl bg-white/[0.03] border border-white/5 p-3 space-y-2"
>
<!-- Relay URL + status -->
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full shrink-0"
:class="relay.connected ? 'bg-emerald-500' : 'bg-red-400/60'"
/>
<span class="text-xs font-mono text-white/70 truncate flex-1">{{ relay.url }}</span>
<span
v-if="relay.latencyMs !== null"
class="text-xs tabular-nums shrink-0"
:class="relay.latencyMs < 200 ? 'text-emerald-400/60' : relay.latencyMs < 500 ? 'text-yellow-400/60' : 'text-red-400/60'"
>
{{ relay.latencyMs }}ms
</span>
<span
class="text-xs shrink-0"
:class="relay.connected ? 'text-emerald-400/60' : 'text-red-400/60'"
>
{{ relay.connected ? 'Connected' : 'Disconnected' }}
</span>
</div>
<!-- Controls -->
<div class="flex items-center gap-2 flex-wrap">
<button
class="text-sm px-3 min-h-[44px] rounded-lg transition-colors"
:class="relay.read
? 'bg-accent/15 text-accent/80'
: 'bg-white/5 text-white/30 hover:text-white/50'"
@click="toggleRelayRead(relay.url)"
>
Read
</button>
<button
class="text-sm px-3 min-h-[44px] rounded-lg transition-colors"
:class="relay.write
? 'bg-accent/15 text-accent/80'
: 'bg-white/5 text-white/30 hover:text-white/50'"
@click="toggleRelayWrite(relay.url)"
>
Write
</button>
<div class="flex-1" />
<button
class="text-sm px-3 min-h-[44px] rounded-lg bg-white/5 text-white/30 hover:text-white/50 transition-colors"
:disabled="testingRelay === relay.url"
@click="testConnection(relay.url)"
>
{{ testingRelay === relay.url ? 'Testing...' : 'Test' }}
</button>
<button
class="text-sm px-3 min-h-[44px] rounded-lg bg-white/5 text-red-400/50 hover:text-red-400/80 hover:bg-red-400/10 transition-colors"
@click="removeRelay(relay.url)"
>
Remove
</button>
</div>
<!-- Test result -->
<p
v-if="testResults[relay.url] !== undefined"
class="text-xs"
:class="testResults[relay.url] !== null ? 'text-emerald-400/60' : 'text-red-400/60'"
>
{{ testResults[relay.url] !== null ? `Reachable (${testResults[relay.url]}ms)` : 'Unreachable' }}
</p>
</div>
<!-- Import NIP-65 -->
<div v-if="isLoggedIn" class="mt-4 pt-4 border-t border-white/5">
<button
class="w-full text-left px-3 min-h-[44px] rounded-lg text-sm bg-white/5 text-white/40 hover:text-white/60 hover:bg-white/10 transition-colors"
:disabled="isImporting"
@click="importFromNIP65"
>
{{ isImporting ? 'Importing...' : 'Import relays from NIP-65 (kind:10002)' }}
</button>
<p v-if="importMessage" class="text-xs mt-1 text-white/30">{{ importMessage }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useNostr } from '@/composables/useNostr'
import { useNostrIdentity } from '@/composables/useNostrIdentity'
const { relayStates, addRelay, removeRelay, toggleRelayRead, toggleRelayWrite, testRelay, importNIP65Relays, fetchNote } = useNostr()
const { isLoggedIn, pubkey } = useNostrIdentity()
const newRelayUrl = ref('')
const testingRelay = ref<string | null>(null)
const testResults = reactive<Record<string, number | null>>({})
const isImporting = ref(false)
const importMessage = ref('')
function addNewRelay() {
let url = newRelayUrl.value.trim()
if (!url) return
if (!url.startsWith('wss://') && !url.startsWith('ws://')) {
url = 'wss://' + url
}
addRelay(url)
newRelayUrl.value = ''
}
async function testConnection(url: string) {
testingRelay.value = url
const latency = await testRelay(url)
testResults[url] = latency
testingRelay.value = null
}
async function importFromNIP65() {
if (!pubkey.value) return
isImporting.value = true
importMessage.value = 'Fetching relay list...'
// Fetch kind:10002 for our pubkey from connected relays
const note = await fetchNote(pubkey.value, 5000)
if (note) {
importNIP65Relays({
id: note.id,
pubkey: note.pubkey,
kind: 10002,
content: note.content,
created_at: note.created_at,
tags: note.tags,
sig: '',
})
importMessage.value = 'Imported relays from NIP-65'
} else {
importMessage.value = 'No NIP-65 relay list found'
}
isImporting.value = false
}
</script>
@@ -0,0 +1,263 @@
<template>
<div class="h-full flex flex-col">
<div class="flex items-center gap-2 px-4 py-3 border-b border-white/[0.08]">
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg text-white/60 hover:text-white/80 hover:bg-white/10 transition-colors"
@click="$emit('back')"
>
<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>
<h3 class="text-sm font-bold text-white/90">Thread</h3>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-2">
<!-- Loading -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<p class="text-xs text-white/30">Loading thread...</p>
</div>
<!-- Root note -->
<div v-if="rootNote" class="rounded-xl bg-white/[0.05] border border-white/10 p-3">
<div class="flex items-center gap-1.5 mb-1">
<div class="w-6 h-6 rounded-full shrink-0 flex items-center justify-center text-xs font-bold bg-purple-500/20 text-purple-400">
{{ rootNote.authorName?.charAt(0)?.toUpperCase() ?? '?' }}
</div>
<span class="text-xs font-semibold text-white/80">{{ rootNote.authorName ?? 'anon' }}</span>
<span class="text-xs ml-auto text-white/20">{{ formatTime(rootNote.created_at) }}</span>
</div>
<p class="text-xs text-white/70 leading-relaxed whitespace-pre-wrap">{{ rootNote.content }}</p>
</div>
<!-- Replies -->
<div v-if="threadTree.length > 0" class="space-y-1">
<p class="text-xs text-white/30 font-medium mt-3 mb-1">{{ threadTree.length }} replies</p>
<ThreadNode
v-for="node in threadTree"
:key="node.note.id"
:node="node"
:depth="0"
@reply="startReply"
/>
</div>
<div v-if="!isLoading && !rootNote" class="flex items-center justify-center py-12">
<p class="text-xs text-white/30">Thread not found</p>
</div>
</div>
<!-- Reply input -->
<div v-if="rootNote && isLoggedIn" class="px-4 py-3 border-t border-white/[0.08]">
<p v-if="replyTo" class="text-xs text-white/30 mb-1">
Replying to {{ replyTo.authorName ?? 'anon' }}
<button class="text-accent/60 ml-1" @click="replyTo = null">cancel</button>
</p>
<div class="flex gap-2">
<input
v-model="replyText"
type="text"
placeholder="Reply..."
class="flex-1 px-3 py-2 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors"
@keydown.enter="sendReply"
/>
<button
class="px-3 py-2 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
:disabled="!replyText.trim()"
@click="sendReply"
>
Reply
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, defineAsyncComponent } from 'vue'
import { useNostr, type NostrNote, type NostrEvent } from '@/composables/useNostr'
import { useNostrIdentity } from '@/composables/useNostrIdentity'
const ThreadNode = defineAsyncComponent(() => import('./ThreadNode.vue'))
const props = defineProps<{
noteId: string
}>()
defineEmits<{ back: [] }>()
const { fetchNote, publishEvent } = useNostr()
const { isLoggedIn, signEvent, pubkey } = useNostrIdentity()
interface ThreadTreeNode {
note: NostrNote
children: ThreadTreeNode[]
}
const rootNote = ref<NostrNote | null>(null)
const threadTree = ref<ThreadTreeNode[]>([])
const isLoading = ref(true)
const replyTo = ref<NostrNote | null>(null)
const replyText = ref('')
function formatTime(ts: number): string {
const d = new Date(ts * 1000)
return d.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' })
+ ' ' + d.toLocaleDateString('en', { month: 'short', day: 'numeric' })
}
function truncatePubkey(pk: string): string {
if (pk.length <= 12) return pk
return pk.slice(0, 8) + '...' + pk.slice(-4)
}
async function loadThread() {
isLoading.value = true
// Fetch root note
const root = await fetchNote(props.noteId)
if (!root) {
isLoading.value = false
return
}
rootNote.value = root
// Fetch replies (kind:1 with #e tag referencing this note)
const replies = await fetchReplies(props.noteId)
threadTree.value = buildTree(replies, props.noteId)
isLoading.value = false
}
async function fetchReplies(rootId: string): Promise<NostrNote[]> {
return new Promise((resolve) => {
const results: NostrNote[] = []
const subId = 'thread-' + Math.random().toString(36).slice(2, 8)
let resolved = false
const timer = setTimeout(() => {
if (!resolved) { resolved = true; resolve(results) }
}, 8000)
// Connect to first available NIP-50 relay for broader search
const relayUrl = 'wss://relay.nostr.band'
try {
const ws = new WebSocket(relayUrl)
ws.onopen = () => {
ws.send(JSON.stringify([
'REQ', subId,
{ kinds: [1], '#e': [rootId], limit: 100 },
]))
}
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data)
if (Array.isArray(data) && data[0] === 'EVENT' && data[1] === subId && data[2]) {
const evt = data[2] as NostrEvent
if (!results.find(r => r.id === evt.id)) {
results.push({
id: evt.id,
pubkey: evt.pubkey,
authorName: truncatePubkey(evt.pubkey),
kind: evt.kind,
content: evt.content,
created_at: evt.created_at,
tags: evt.tags ?? [],
})
}
}
if (Array.isArray(data) && data[0] === 'EOSE' && data[1] === subId) {
clearTimeout(timer)
ws.close()
if (!resolved) { resolved = true; resolve(results) }
}
} catch { /* skip */ }
}
ws.onerror = () => {
clearTimeout(timer)
if (!resolved) { resolved = true; resolve(results) }
}
ws.onclose = () => {
if (!resolved) { resolved = true; resolve(results) }
}
} catch {
clearTimeout(timer)
resolve(results)
}
})
}
function buildTree(replies: NostrNote[], rootId: string, maxDepth = 5): ThreadTreeNode[] {
const childrenMap = new Map<string, NostrNote[]>()
for (const reply of replies) {
// Find the parent — last 'e' tag with 'reply' marker, or last 'e' tag
let parentId = rootId
const eTags = reply.tags.filter(t => t[0] === 'e')
if (eTags.length > 0) {
const replyTag = eTags.find(t => t[3] === 'reply')
parentId = replyTag ? replyTag[1] : eTags[eTags.length - 1][1]
}
const siblings = childrenMap.get(parentId) ?? []
siblings.push(reply)
childrenMap.set(parentId, siblings)
}
function build(parentId: string, depth: number): ThreadTreeNode[] {
const children = childrenMap.get(parentId) ?? []
children.sort((a, b) => a.created_at - b.created_at)
return children.map(note => ({
note,
children: depth < maxDepth ? build(note.id, depth + 1) : [],
}))
}
return build(rootId, 0)
}
function startReply(note: NostrNote) {
replyTo.value = note
}
async function sendReply() {
if (!replyText.value.trim() || !pubkey.value) return
const target = replyTo.value ?? rootNote.value
if (!target) return
const tags: string[][] = [
['e', props.noteId, '', 'root'],
]
if (target.id !== props.noteId) {
tags.push(['e', target.id, '', 'reply'])
}
tags.push(['p', target.pubkey])
const unsigned = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags,
content: replyText.value.trim(),
}
const signed = await signEvent(unsigned)
if (!signed) return
await publishEvent(signed)
replyText.value = ''
replyTo.value = null
// Reload thread
await loadThread()
}
onMounted(() => {
loadThread()
})
</script>
@@ -0,0 +1,83 @@
<template>
<button
class="flex gap-3 p-2 rounded-xl transition-all duration-200 text-left w-full group overflow-hidden"
:class="isDark
? 'hover:bg-white/5 active:bg-white/10'
: 'hover:bg-black/[0.03] active:bg-black/5'"
@click="$emit('select', place)"
>
<div class="shrink-0 w-12 h-12 rounded-lg overflow-hidden">
<img
v-if="photoSrc"
:src="photoSrc"
:alt="place.name"
class="w-full h-full object-cover rounded-[6px] transition-transform duration-300 group-hover:scale-105"
loading="lazy"
@error="photoFailed = true"
/>
<div
v-else
class="w-full h-full rounded-[6px] bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackPhoto})` }"
/>
</div>
<div class="min-w-0 flex-1 py-0.5">
<p class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">{{ place.name }}</p>
<p class="text-xs mt-0.5 truncate"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
{{ place.cuisine || place.category }}<template v-if="place.city"> · {{ place.city }}</template>
</p>
<div class="flex items-center gap-1.5 mt-1.5">
<span v-if="place.rating && place.rating > 0"
class="text-xs font-semibold px-1.5 py-0.5 rounded"
:class="ratingClass">
★ {{ place.rating.toFixed(1) }}
</span>
<span v-if="place.priceLevel"
class="text-xs px-1.5 py-0.5 rounded font-medium"
:class="isDark ? 'bg-white/8 text-white/50' : 'bg-black/5 text-gray-500'">
{{ '$'.repeat(place.priceLevel) }}
</span>
</div>
</div>
</button>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { Place } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePlaceFallback, fetchPlaceImage } from '@/composables/useImageFallback'
const props = defineProps<{ place: Place }>()
defineEmits<{ select: [place: Place] }>()
const { isDark } = useTheme()
const photoFailed = ref(false)
const fetchedPhoto = ref<string | null>(null)
const photoSrc = computed(() => {
if (photoFailed.value) return null
return props.place.photoUrl || fetchedPhoto.value || null
})
onMounted(() => {
if (props.place.photoUrl) return
fetchPlaceImage(props.place.name, props.place.city).then((url) => {
if (url) fetchedPhoto.value = url
})
})
const fallbackPhoto = computed(() =>
generatePlaceFallback(props.place.name, props.place.cuisine || props.place.category)
)
const ratingClass = computed(() => {
const r = props.place.rating ?? 0
if (r >= 4.5) return isDark.value ? 'bg-success/20 text-success' : 'bg-success/10 text-green-700'
if (r >= 4.0) return isDark.value ? 'bg-accent/20 text-accent' : 'bg-accent/10 text-amber-700'
return isDark.value ? 'bg-white/10 text-white/50' : 'bg-black/5 text-gray-500'
})
</script>
@@ -0,0 +1,206 @@
<template>
<div class="place-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<div class="relative w-full overflow-hidden">
<div class="w-full aspect-[16/9] flex items-center justify-center overflow-hidden bg-black/20">
<img
v-if="place.photoUrl || fetchedPhoto"
:src="(place.photoUrl || fetchedPhoto)!"
:alt="place.name"
class="w-full h-full object-cover object-center block"
/>
<img
v-else
:src="fallbackCover"
:alt="place.name"
class="w-full h-full object-cover"
/>
</div>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
<button
class="absolute top-3 left-3 min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
@click="$emit('back')"
>
<svg class="w-4 h-4 text-white/90" 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="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ place.name }}</h2>
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-1 text-xs text-white/60">
<span v-if="place.cuisine || place.category">{{ place.cuisine || place.category }}</span>
<span v-if="place.city">{{ place.city }}</span>
<span v-if="place.rating" class="text-amber-400">★ {{ place.rating.toFixed(1) }}</span>
<span v-if="place.priceLevel" class="text-white/50">{{ '$'.repeat(place.priceLevel) }}</span>
</div>
</div>
</div>
<div class="p-4 space-y-4">
<p v-if="place.description" class="text-sm leading-relaxed"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ place.description }}
</p>
<div v-if="place.address" class="flex items-start gap-2.5">
<svg class="w-4 h-4 shrink-0 mt-0.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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span class="text-xs" :class="isDark ? 'text-white/60' : 'text-gray-600'">
{{ place.address }}
</span>
</div>
<div v-if="place.phone" class="flex items-center gap-2.5">
<svg class="w-4 h-4 shrink-0" :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="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
<span class="text-xs" :class="isDark ? 'text-white/60' : 'text-gray-600'">
{{ place.phone }}
</span>
</div>
<div v-if="place.hours" class="flex items-start gap-2.5">
<svg class="w-4 h-4 shrink-0 mt-0.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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-xs" :class="isDark ? 'text-white/60' : 'text-gray-600'">
{{ place.hours }}
</span>
</div>
<div v-if="place.website" class="flex items-center gap-2.5">
<svg class="w-4 h-4 shrink-0" :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="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
<a :href="place.website" target="_blank" rel="noopener"
class="text-xs underline underline-offset-2"
:class="isDark ? 'text-white/60 hover:text-white/80' : 'text-gray-600 hover:text-gray-800'">
{{ websiteDomain }}
</a>
</div>
<div>
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Find on</h4>
<div class="space-y-2">
<a
v-for="src in (place.sources ?? [])"
:key="src.url"
:href="src.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<span class="text-sm">{{ sourceIcon(src.type) }}</span>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ src.name }}</p>
</div>
<svg class="w-4 h-4" :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>
</a>
<a
v-for="link in mapLinks"
:key="link.url"
:href="link.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<span class="text-sm">{{ link.icon }}</span>
<div>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ link.name }}</p>
<p v-if="link.desc" class="text-xs"
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ link.desc }}</p>
</div>
</div>
<svg class="w-4 h-4" :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>
</a>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { Place } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePlaceFallback, fetchPlaceImage } from '@/composables/useImageFallback'
const props = defineProps<{ place: Place }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const fetchedPhoto = ref<string | null>(null)
const fallbackCover = computed(() =>
generatePlaceFallback(props.place.name, props.place.cuisine || props.place.category)
)
onMounted(() => {
if (props.place.photoUrl) return
fetchPlaceImage(props.place.name, props.place.city).then((url) => {
if (url) fetchedPhoto.value = url
})
})
const websiteDomain = computed(() => {
if (!props.place.website) return ''
try {
return new URL(props.place.website).hostname.replace(/^www\./, '')
} catch {
return props.place.website
}
})
const q = computed(() =>
`${props.place.name} ${props.place.city ?? ''}`.trim().replace(/\s+/g, '+'),
)
const mapLinks = computed(() => {
if ((props.place.sources ?? []).length > 0) return []
const links = [
{ name: 'OpenStreetMap', url: `https://www.openstreetmap.org/search?query=${q.value}`, icon: '🗺️', desc: 'Open source maps' },
{ name: 'Google Maps', url: `https://www.google.com/maps/search/${q.value}`, icon: '📍', desc: 'Directions & reviews' },
]
if (props.place.lat && props.place.lng) {
links.unshift({
name: 'OpenStreetMap',
url: `https://www.openstreetmap.org/?mlat=${props.place.lat}&mlon=${props.place.lng}#map=17/${props.place.lat}/${props.place.lng}`,
icon: '🗺️',
desc: 'Open source maps',
})
links.splice(2) // Remove the search-based OSM link
}
return links
})
function sourceIcon(type: string): string {
const icons: Record<string, string> = {
gmaps: '📍',
osm: '🗺️',
yelp: '⭐',
tripadvisor: '🦉',
foursquare: '📌',
local: '💾',
}
return icons[type] ?? '📍'
}
</script>
@@ -0,0 +1,151 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-base font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ title || 'Places' }}
</h3>
<div class="shrink-0 flex items-center gap-2">
<span class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredPlaces.length }} places
</span>
<slot name="header-actions" />
</div>
</div>
<input
v-model="search"
type="text"
:placeholder="`Search places...`"
class="w-full text-base px-3 py-2 rounded-lg outline-none transition-colors"
:class="isDark
? 'bg-white/5 text-white/80 placeholder-white/25 focus:bg-white/8'
: 'bg-black/5 text-gray-800 placeholder-gray-400 focus:bg-black/8'"
/>
<div v-if="topCategories.length > 1" class="flex flex-wrap gap-1.5">
<button
v-for="cat in topCategories"
:key="cat"
class="text-xs px-2 py-1 rounded-md font-medium transition-all duration-150"
:class="activeCategory === cat
? 'nav-tab-active'
: isDark
? 'bg-white/5 text-white/40 hover:text-white/70'
: 'bg-black/5 text-gray-500 hover:text-gray-800'"
@click="activeCategory = activeCategory === cat ? null : cat"
>
{{ cat }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pb-16">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<button
v-for="place in filteredPlaces"
:key="place.id"
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"
:aria-label="place.name"
@click="$emit('selectPlace', place)"
>
<div class="aspect-[4/3] relative w-full overflow-hidden rounded-t-[10px]">
<div v-if="isLoading(place)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(place)"
:src="coverSrc(place)!"
:alt="place.name"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onError(place)"
/>
<img
v-else-if="!isLoading(place)"
:src="fallbackSrc(place)"
:alt="place.name"
class="w-full h-full object-cover"
/>
<div v-if="coverSrc(place)" class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
<div class="absolute bottom-0 left-0 right-0 p-2">
<p class="text-xs font-semibold text-white/90 truncate">{{ place.name }}</p>
<p class="text-xs text-white/40 truncate mt-0.5">{{ place.cuisine || place.category }}</p>
</div>
<div v-if="place.rating" class="absolute top-1.5 right-1.5">
<span class="text-xs px-1.5 py-0.5 rounded bg-black/60 text-amber-400 backdrop-blur-sm font-semibold">
★ {{ place.rating.toFixed(1) }}
</span>
</div>
<div v-if="place.priceLevel" class="absolute top-1.5 left-1.5">
<span class="text-xs px-1 py-0.5 rounded bg-black/60 text-white/70 backdrop-blur-sm">
{{ '$'.repeat(place.priceLevel) }}
</span>
</div>
</div>
</button>
</div>
<div v-if="filteredPlaces.length === 0" class="flex items-center justify-center py-12">
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No places match your search
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, toRef } from 'vue'
import type { Place } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useContentImages } from '@/composables/useContentImages'
import { generatePlaceFallback, fetchPlaceImage } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
places: Place[]
title?: string
}>(), {
title: 'Places',
})
defineEmits<{ selectPlace: [place: Place] }>()
const { isDark } = useTheme()
const search = ref('')
const activeCategory = ref<string | null>(null)
const { coverSrc, fallbackSrc, onError, isLoading } = useContentImages({
items: toRef(props, 'places'),
id: (p) => p.id,
existingUrl: (p) => p.photoUrl,
fetch: (p) => fetchPlaceImage(p.name, p.city),
fallback: (p) => generatePlaceFallback(p.name, p.cuisine || p.category),
})
const topCategories = computed(() => {
const counts = new Map<string, number>()
for (const p of props.places) {
const cat = p.cuisine || p.category
if (cat) counts.set(cat, (counts.get(cat) ?? 0) + 1)
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([g]) => g)
})
const filteredPlaces = computed(() => {
let result = props.places
if (search.value) {
const q = search.value.toLowerCase()
result = result.filter(p =>
p.name.toLowerCase().includes(q) ||
(p.cuisine?.toLowerCase().includes(q)) ||
(p.category?.toLowerCase().includes(q)) ||
(p.city?.toLowerCase().includes(q)) ||
(p.address?.toLowerCase().includes(q))
)
}
if (activeCategory.value) {
result = result.filter(p =>
p.cuisine === activeCategory.value || p.category === activeCategory.value
)
}
return result
})
</script>
@@ -0,0 +1,81 @@
<template>
<button
class="flex gap-3 p-2 rounded-xl transition-all duration-200 text-left w-full group overflow-hidden"
:class="isDark
? 'hover:bg-white/5 active:bg-white/10'
: 'hover:bg-black/[0.03] active:bg-black/5'"
@click="$emit('select', podcast)"
>
<div class="cover-card-sm shrink-0 w-12 h-12 rounded-lg overflow-hidden">
<img
v-if="coverSrc"
:src="coverSrc"
:alt="podcast.title"
class="w-full h-full object-cover rounded-[6px] transition-transform duration-300 group-hover:scale-105"
loading="lazy"
@error="coverFailed = true"
/>
<div
v-else
class="w-full h-full rounded-[6px] bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackCover})` }"
/>
</div>
<div class="min-w-0 flex-1 py-0.5">
<p class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">{{ podcast.title }}</p>
<p class="text-xs mt-0.5"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
{{ podcast.host || 'Podcast' }}<template v-if="podcast.year"> · {{ podcast.year }}</template>
</p>
<div class="flex items-center gap-1.5 mt-1.5">
<span v-if="isExternal"
class="text-xs px-1.5 py-0.5 rounded font-medium"
:class="isDark ? 'bg-info/15 text-info/70' : 'bg-info/10 text-blue-600'">
not in library
</span>
<span
v-for="src in podcast.sources.slice(0, 3)"
:key="src.type"
class="text-xs px-1.5 py-0.5 rounded font-medium"
:class="isDark ? 'bg-white/8 text-white/50' : 'bg-black/5 text-gray-500'"
>
{{ src.type }}
</span>
</div>
</div>
</button>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { Podcast } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePodcastCoverFallback, fetchPodcastCover } from '@/composables/useImageFallback'
const props = defineProps<{ podcast: Podcast }>()
defineEmits<{ select: [podcast: Podcast] }>()
const { isDark } = useTheme()
const coverFailed = ref(false)
const fetchedCover = ref<string | null>(null)
const coverSrc = computed(() => {
if (coverFailed.value) return null
return props.podcast.coverUrl || fetchedCover.value || null
})
onMounted(() => {
if (props.podcast.coverUrl) return
fetchPodcastCover(props.podcast.title, props.podcast.host).then((url) => {
if (url) fetchedCover.value = url
})
})
const fallbackCover = computed(() =>
generatePodcastCoverFallback(props.podcast.title, props.podcast.host)
)
const isExternal = computed(() => props.podcast.id.startsWith('ext-'))
</script>
@@ -0,0 +1,172 @@
<template>
<div class="podcast-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<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"
:alt="podcast.title"
class="w-full h-full object-cover object-center block"
@error="coverFailed = true"
/>
<div
v-else
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackCover})` }"
/>
</div>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
<button
class="absolute top-3 left-3 min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
@click="$emit('back')"
>
<svg class="w-4 h-4 text-white/90" 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="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ podcast.title }}</h2>
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-1 text-xs text-white/60">
<span v-if="podcast.host">{{ podcast.host }}</span>
<span v-if="podcast.year">{{ podcast.year }}</span>
<span v-if="podcast.episodeCount">{{ podcast.episodeCount }} episodes</span>
</div>
</div>
</div>
<div class="p-4 space-y-4">
<p v-if="podcast.description" class="text-sm leading-relaxed"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ podcast.description }}
</p>
<div v-if="podcast.genres?.length" class="flex flex-wrap gap-1.5">
<span
v-for="genre in podcast.genres"
:key="genre"
class="text-xs px-2 py-1 rounded-md font-medium"
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'"
>
{{ genre }}
</span>
</div>
<div>
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Listen on</h4>
<div class="space-y-2">
<a
v-for="src in podcast.sources"
:key="src.url"
:href="src.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<span class="text-sm">{{ sourceIcon(src.type) }}</span>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ src.name }}</p>
</div>
<svg class="w-4 h-4" :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>
</a>
<template v-if="extListenLinks.length">
<a
v-for="link in extListenLinks"
:key="link.url"
:href="link.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<span class="text-sm">{{ link.icon }}</span>
<div>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ link.name }}</p>
<p v-if="link.desc" class="text-xs"
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ link.desc }}</p>
</div>
</div>
<svg class="w-4 h-4" :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>
</a>
</template>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { Podcast } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePodcastCoverFallback, fetchPodcastCover } from '@/composables/useImageFallback'
const props = defineProps<{ podcast: Podcast }>()
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.podcast.coverUrl || fetchedCover.value || null
})
onMounted(() => {
if (props.podcast.coverUrl) return
fetchPodcastCover(props.podcast.title, props.podcast.host).then((url) => {
if (url) fetchedCover.value = url
})
})
const fallbackCover = computed(() =>
generatePodcastCoverFallback(props.podcast.title, props.podcast.host)
)
const q = computed(() =>
`${props.podcast.title} ${props.podcast.host ?? ''}`.trim().replace(/\s+/g, '+'),
)
const extListenLinks = computed(() => {
if (props.podcast.sources.length > 0) return []
return [
{ name: 'Fountain', url: `https://fountain.fm/search?q=${q.value}`, icon: '⚡', desc: 'Podcasting 2.0, Lightning' },
{ name: 'Podcast Index', url: `https://podcastindex.org/search?q=${q.value}`, icon: '📻', desc: 'Open podcast directory' },
{ name: 'YouTube', url: `https://youtube.com/results?search_query=${q.value}`, icon: '▶️', desc: 'Video podcasts' },
{ name: 'Rumble', url: `https://rumble.com/search/video?q=${q.value}`, icon: '📺', desc: 'Video & podcasts' },
{ name: 'Odysee', url: `https://odysee.com/$/search?q=${q.value}`, icon: '🔗', desc: 'Decentralized' },
]
})
function sourceIcon(type: string): string {
const icons: Record<string, string> = {
fountain: '⚡',
rumble: '📺',
youtube: '▶️',
podcastindex: '📻',
castopod: '🦣',
odysee: '🔗',
podverse: '🎧',
ipfs: '🌐',
rss: '📡',
}
return icons[type] ?? '🎙️'
}
</script>
@@ -0,0 +1,161 @@
<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'">
{{ title }}
</h3>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredPodcasts.length }} podcasts
</span>
<slot name="header-actions" />
</div>
</div>
<input
v-model="search"
type="text"
placeholder="Search podcasts..."
class="w-full px-3 py-2 rounded-lg text-base 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 v-if="topGenres.length > 0" class="flex flex-wrap gap-1.5">
<button
v-for="genre in topGenres"
:key="genre"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeGenre === genre
? '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="activeGenre = activeGenre === genre ? null : genre"
>
{{ genre }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<button
v-for="podcast in filteredPodcasts"
:key="podcast.id"
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"
:aria-label="podcast.title"
@click="$emit('selectPodcast', podcast)"
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-square relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(podcast) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="isLoading(podcast)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(podcast)"
:src="coverSrc(podcast)!"
:alt="podcast.title"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onError(podcast)"
/>
<img
v-else-if="!isLoading(podcast)"
:src="fallbackSrc(podcast)"
:alt="podcast.title"
class="w-full h-full object-cover"
/>
<div v-if="coverSrc(podcast)" class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
<div class="absolute bottom-0 left-0 right-0 p-2">
<p class="text-xs font-semibold text-white/90 leading-tight truncate">
{{ podcast.title }}
</p>
<p class="text-xs text-white/40 truncate mt-0.5">{{ podcast.host || 'Podcast' }}</p>
</div>
<div class="absolute top-1.5 right-1.5 flex gap-0.5 flex-wrap justify-end max-w-[60%]">
<span
v-for="src in podcast.sources.slice(0, 2)"
:key="src.type"
class="text-xs px-1 py-0.5 rounded bg-black/60 text-white/70 backdrop-blur-sm"
>
{{ src.type }}
</span>
</div>
</div>
</div>
</button>
</div>
<div v-if="filteredPodcasts.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No podcasts match your search
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, toRef } from 'vue'
import type { Podcast } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useContentImages } from '@/composables/useContentImages'
import { generatePodcastCoverFallback, fetchPodcastCover } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
podcasts: Podcast[]
title?: string
}>(), {
title: 'Recommended Podcasts',
})
defineEmits<{ selectPodcast: [podcast: Podcast] }>()
const { isDark } = useTheme()
const search = ref('')
const activeGenre = ref<string | null>(null)
const { coverSrc, fallbackSrc, onError, isLoading } = useContentImages({
items: toRef(props, 'podcasts'),
id: (p) => p.id,
existingUrl: (p) => p.coverUrl,
fetch: (p) => fetchPodcastCover(p.title, p.host),
fallback: (p) => generatePodcastCoverFallback(p.title, p.host),
})
const topGenres = computed(() => {
const counts = new Map<string, number>()
for (const p of props.podcasts) {
for (const g of p.genres ?? []) {
counts.set(g, (counts.get(g) ?? 0) + 1)
}
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([g]) => g)
})
const filteredPodcasts = computed(() => {
let result = props.podcasts
if (search.value) {
const q = search.value.toLowerCase()
result = result.filter(
(p) =>
p.title.toLowerCase().includes(q) ||
(p.host ?? '').toLowerCase().includes(q) ||
(p.genres ?? []).some((g) => g.toLowerCase().includes(q))
)
}
if (activeGenre.value) {
result = result.filter((p) => (p.genres ?? []).includes(activeGenre.value!))
}
return result
})
</script>
@@ -0,0 +1,255 @@
<template>
<div class="flex flex-col h-full">
<!-- 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 class="flex items-center gap-1 min-w-0 flex-1">
<!-- Breadcrumb: "Projects" root (clickable when deeper) -->
<button
v-if="viewState !== 'projects'"
class="text-xs shrink-0 transition-colors"
:class="isDark
? 'text-white/40 hover:text-white/70 hover:underline'
: 'text-gray-400 hover:text-gray-700 hover:underline'"
@click="backToProjects"
>
Projects
</button>
<span v-if="viewState !== 'projects'" class="text-xs 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-xs"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ projectList.length }} repos
</p>
<p v-else-if="viewState === 'filetree'" class="text-xs"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ activeProject?.language }}
</p>
<slot name="header-actions" />
</div>
</div>
<!-- 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="flex-1 min-w-0 px-3 py-2 rounded-lg text-base 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-base 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-xs 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-xs 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"
:key="project.path"
class="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="selectProject(project)"
>
<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 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"
:class="isDark ? 'text-white/80' : 'text-gray-800'">
{{ project.name }}
</p>
<p class="text-xs mt-0.5 truncate"
:class="isDark ? 'text-white/25' : 'text-gray-400'">
{{ project.language }}
</p>
</button>
</div>
</div>
<!-- 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"
:entry="entry"
:active-file="activeFile"
:depth="0"
@select="handleFileOpen"
@toggle-context="handleToggleContext"
/>
<div v-if="fileTree.length === 0" class="flex items-center justify-center py-12">
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
Loading file tree...
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted } 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,
activeProject,
fileTree,
activeFile,
codeMode,
selectedFiles,
selectProject: doSelectProject,
openFile,
createProject,
clearActiveFile,
toggleFileSelection,
isFileSelected,
loadProjects,
} = useCodeContext()
onMounted(() => {
if (projectList.value.length === 0) loadProjects()
})
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()
if (!q) return projectList.value
return projectList.value.filter(p =>
p.name.toLowerCase().includes(q) || (p.language ?? '').toLowerCase().includes(q)
)
})
function selectProject(project: ProjectInfo) {
doSelectProject(project)
}
function backToProjects() {
const { activeProject: ap, fileTree: ft } = useCodeContext()
ap.value = null
ft.value = []
clearActiveFile()
}
function handleFileOpen(filePath: string) {
openFile(filePath)
}
function handleToggleContext(filePath: string) {
toggleFileSelection(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>
@@ -0,0 +1,172 @@
<template>
<div class="h-full flex flex-col">
<!-- Header -->
<div class="shrink-0 px-4 py-3 flex items-center gap-2"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
<button
class="touch-target rounded-lg transition-colors shrink-0"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5'"
aria-label="Back to recipes"
@click="$emit('back')"
>
<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>
<h3 class="text-sm font-bold truncate" :class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ recipe.title }}
</h3>
</div>
<!-- Cover banner -->
<div class="shrink-0 h-28 relative overflow-hidden">
<img
:src="generateRecipeFallback(recipe.title, recipe.time)"
:alt="recipe.title"
class="w-full h-full object-cover"
/>
</div>
<!-- Recipe content (reuses RecipeCard logic inline) -->
<div class="flex-1 overflow-y-auto custom-scrollbar">
<!-- Meta -->
<div class="px-4 py-3 flex gap-3 flex-wrap"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.05)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.04)'">
<span v-if="recipe.time" class="text-xs flex items-center gap-1"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
<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 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ recipe.time }}
</span>
<span v-if="recipe.servings" class="text-xs flex items-center gap-1"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
<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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{{ scaledServings }} servings
</span>
<span v-if="recipe.calories" class="text-xs"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ recipe.calories }} cal
</span>
</div>
<!-- Scale slider -->
<div class="px-4 py-3 flex items-center gap-3"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.05)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.04)'">
<label class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">Scale</label>
<input
v-model.number="scaleFactor"
type="range"
min="0.5"
max="4"
step="0.5"
class="flex-1 h-1 accent-accent rounded-full appearance-none cursor-pointer"
:class="isDark ? 'bg-white/10' : 'bg-black/10'"
/>
<span class="text-xs tabular-nums w-8 text-right"
:class="isDark ? 'text-white/50' : 'text-gray-500'">{{ scaleFactor }}x</span>
</div>
<!-- Ingredients -->
<div class="px-4 py-4"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.05)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.04)'">
<h4 class="text-xs uppercase tracking-wider mb-3 font-medium"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
Ingredients
</h4>
<ul class="space-y-2">
<li
v-for="(ing, i) in scaledIngredients"
:key="i"
class="flex items-start gap-2.5 text-sm cursor-pointer select-none transition-colors"
:class="checkedIngredients.has(i)
? isDark ? 'line-through text-white/25' : 'line-through text-gray-300'
: isDark ? 'text-white/70' : 'text-gray-700'"
@click="toggleIngredient(i)"
>
<span class="shrink-0 mt-0.5 w-5 h-5 rounded border flex items-center justify-center transition-colors"
:class="checkedIngredients.has(i)
? 'border-accent/50 bg-accent/20'
: isDark ? 'border-white/20' : 'border-gray-300'">
<svg v-if="checkedIngredients.has(i)" class="w-3 h-3 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</span>
{{ ing }}
</li>
</ul>
</div>
<!-- Steps -->
<div class="px-4 py-4 pb-16">
<h4 class="text-xs uppercase tracking-wider mb-3 font-medium"
:class="isDark ? 'text-white/40' : 'text-gray-400'">
Steps
</h4>
<ol class="space-y-3">
<li
v-for="(step, i) in recipe.steps"
:key="i"
class="flex gap-3 text-sm"
:class="isDark ? 'text-white/70' : 'text-gray-700'"
>
<span class="shrink-0 w-6 h-6 rounded-full bg-accent/15 text-accent text-xs flex items-center justify-center font-semibold">
{{ i + 1 }}
</span>
<span class="leading-relaxed pt-0.5">{{ step }}</span>
</li>
</ol>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import type { RecipeData } from '@/composables/contentExtraction'
import { useTheme } from '@/composables/useTheme'
import { generateRecipeFallback } from '@/composables/useImageFallback'
const props = defineProps<{ recipe: RecipeData }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const scaleFactor = ref(1)
const checkedIngredients = reactive(new Set<number>())
const scaledServings = computed(() => {
const base = parseInt(props.recipe.servings || '0', 10)
if (!base) return props.recipe.servings
return Math.round(base * scaleFactor.value)
})
const scaledIngredients = computed(() => {
return props.recipe.ingredients.map((ing) =>
ing.replace(/(\d+\.?\d*)/g, (match) => {
const num = parseFloat(match)
const scaled = num * scaleFactor.value
return scaled % 1 === 0 ? String(scaled) : scaled.toFixed(1)
})
)
})
function toggleIngredient(idx: number) {
if (checkedIngredients.has(idx)) {
checkedIngredients.delete(idx)
} else {
checkedIngredients.add(idx)
}
}
</script>
@@ -0,0 +1,125 @@
<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'">
{{ title }}
</h3>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredRecipes.length }} recipes
</span>
<slot name="header-actions" />
</div>
</div>
<input
v-if="recipes.length > 3"
v-model="search"
type="text"
placeholder="Search recipes..."
class="w-full px-3 py-2 rounded-lg text-base 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>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
v-for="(recipe, i) in filteredRecipes"
:key="i"
class="group flex flex-col items-stretch text-left w-full rounded-2xl overflow-hidden transition-all duration-200 hover:brightness-105"
:class="isDark
? 'bg-white/[0.04] border border-white/8 hover:bg-white/[0.07]'
: 'bg-black/[0.02] border border-black/5 hover:bg-black/[0.05]'"
:aria-label="recipe.title"
@click="$emit('selectRecipe', recipe)"
>
<!-- Cover image area -->
<div class="aspect-[3/1] relative w-full overflow-hidden">
<img
:src="generateRecipeFallback(recipe.title, recipe.time)"
:alt="recipe.title"
class="w-full h-full object-cover"
/>
</div>
<!-- Card body -->
<div class="p-3">
<p class="text-sm font-semibold leading-tight line-clamp-2"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ recipe.title }}
</p>
<!-- Meta row -->
<div class="flex items-center gap-3 mt-2 flex-wrap">
<span v-if="recipe.time" class="flex items-center gap-1 text-xs"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ recipe.time }}
</span>
<span v-if="recipe.servings" class="flex items-center gap-1 text-xs"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{{ recipe.servings }}
</span>
<span v-if="recipe.calories" class="text-xs"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
{{ recipe.calories }} cal
</span>
</div>
<!-- Ingredient preview -->
<p v-if="recipe.ingredients.length > 0" class="text-xs mt-2 line-clamp-1"
:class="isDark ? 'text-white/25' : 'text-gray-400'">
{{ recipe.ingredients.slice(0, 4).join(' \u00B7 ') }}
</p>
</div>
</button>
</div>
<div v-if="filteredRecipes.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No recipes match your search
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { RecipeData } from '@/composables/contentExtraction'
import { useTheme } from '@/composables/useTheme'
import { generateRecipeFallback } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
recipes: RecipeData[]
title?: string
}>(), {
title: 'Recipes',
})
defineEmits<{ selectRecipe: [recipe: RecipeData] }>()
const { isDark } = useTheme()
const search = ref('')
const filteredRecipes = computed(() => {
if (!search.value.trim()) return props.recipes
const q = search.value.toLowerCase()
return props.recipes.filter(r =>
r.title.toLowerCase().includes(q) ||
r.ingredients.some(ing => ing.toLowerCase().includes(q))
)
})
</script>
@@ -0,0 +1,128 @@
<template>
<div v-if="showCompose" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div class="w-full max-w-md mx-4 rounded-2xl bg-[#0a0a0a] border border-white/10 p-5 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-bold text-white/90">Share to Nostr</h3>
<button
class="p-2 min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/5 transition-colors"
@click="close"
>
<svg class="w-5 h-5" 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>
<textarea
v-model="noteContent"
class="w-full h-32 px-3 py-2 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors resize-none"
placeholder="Add a note about this content..."
/>
<div class="rounded-lg bg-white/[0.03] border border-white/5 p-3">
<p class="text-xs text-white/25 mb-1">Preview</p>
<p class="text-xs text-white/60 whitespace-pre-wrap">{{ previewText }}</p>
</div>
<div class="flex gap-2">
<button
class="flex-1 min-h-[44px] rounded-lg text-sm font-medium text-white/40 hover:text-white/70 hover:bg-white/5 transition-colors"
@click="close"
>
Cancel
</button>
<button
class="flex-1 min-h-[44px] rounded-lg text-sm font-medium bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
:disabled="isPublishing || !isLoggedIn"
@click="publish"
>
{{ isPublishing ? 'Publishing...' : 'Publish' }}
</button>
</div>
<p v-if="!isLoggedIn" class="text-xs text-yellow-400/60 text-center">
Sign in with Nostr to share
</p>
<p v-if="publishResult" class="text-xs text-center" :class="publishOk ? 'text-emerald-400/60' : 'text-red-400/60'">
{{ publishResult }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useNostrIdentity } from '@/composables/useNostrIdentity'
import { useNostr } from '@/composables/useNostr'
const props = defineProps<{
title: string
type: string
description?: string
url?: string
}>()
const emit = defineEmits<{ close: [] }>()
const { isLoggedIn, signEvent } = useNostrIdentity()
const { publishEvent } = useNostr()
const showCompose = ref(true)
const noteContent = ref('')
const isPublishing = ref(false)
const publishResult = ref('')
const publishOk = ref(false)
const previewText = computed(() => {
const parts: string[] = []
if (noteContent.value.trim()) {
parts.push(noteContent.value.trim())
parts.push('')
}
parts.push(`${props.type.charAt(0).toUpperCase() + props.type.slice(1)}: ${props.title}`)
if (props.description) parts.push(props.description)
if (props.url) parts.push(props.url)
return parts.join('\n')
})
function close() {
showCompose.value = false
emit('close')
}
async function publish() {
isPublishing.value = true
publishResult.value = ''
const content = previewText.value
const unsigned = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [] as string[][],
content,
}
const signed = await signEvent(unsigned)
if (!signed) {
isPublishing.value = false
publishResult.value = 'Signing failed'
publishOk.value = false
return
}
const results = await publishEvent(signed)
const successes = results.filter(r => r.success).length
isPublishing.value = false
if (successes > 0) {
publishResult.value = `Published to ${successes}/${results.length} relays`
publishOk.value = true
setTimeout(close, 1500)
} else {
publishResult.value = 'Failed to publish'
publishOk.value = false
}
}
</script>
@@ -0,0 +1,38 @@
<template>
<div v-if="isLoading || suggestions.length > 0" class="mt-4 pt-4 border-t border-white/[0.08]">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold mb-2">More Like This</p>
<div v-if="isLoading" class="py-3">
<p class="text-xs text-white/30">Finding similar content...</p>
</div>
<div v-else class="space-y-1.5">
<div
v-for="(item, i) in suggestions"
:key="i"
class="p-2.5 rounded-lg bg-white/[0.03] border border-white/5"
>
<p class="text-xs font-semibold text-white/80">{{ item.title }}</p>
<p class="text-xs text-white/40 mt-0.5">{{ item.reason }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useSimilarContent } from '@/composables/useSimilarContent'
import type { FavoriteType } from '@/stores/favorites'
const props = defineProps<{
itemId: string
title: string
type: FavoriteType
}>()
const { suggestions, isLoading, fetchSimilar } = useSimilarContent()
onMounted(() => {
fetchSimilar(props.itemId, props.title, props.type)
})
</script>
@@ -0,0 +1,89 @@
<template>
<button
class="flex gap-3 p-2 rounded-xl transition-all duration-200 text-left w-full group overflow-hidden"
:class="isDark
? 'hover:bg-white/5 active:bg-white/10'
: 'hover:bg-black/[0.03] active:bg-black/5'"
@click="$emit('select', song)"
>
<div class="cover-card-sm shrink-0 w-12 h-12 rounded-lg overflow-hidden">
<img
v-if="coverSrc"
:src="coverSrc"
:alt="song.title"
class="w-full h-full object-cover rounded-[6px] transition-transform duration-300 group-hover:scale-105"
loading="lazy"
@error="coverFailed = true"
/>
<div
v-else
class="w-full h-full rounded-[6px] bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackCover})` }"
/>
</div>
<div class="min-w-0 flex-1 py-0.5">
<p class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">{{ song.title }}</p>
<p class="text-xs mt-0.5"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
{{ song.artist }}<template v-if="song.year"> · {{ song.year }}</template>
</p>
<div class="flex items-center gap-1.5 mt-1.5">
<span v-if="isExternal"
class="text-xs px-1.5 py-0.5 rounded font-medium"
:class="isDark ? 'bg-info/15 text-info/70' : 'bg-info/10 text-blue-600'">
not in library
</span>
<span
v-for="src in (song.sources ?? []).slice(0, 3)"
:key="src.type"
class="text-xs px-1.5 py-0.5 rounded font-medium"
:class="isDark ? 'bg-white/8 text-white/50' : 'bg-black/5 text-gray-500'"
>
{{ src.type }}
</span>
<FavoriteButton
class="ml-auto"
:favorited="favoritesStore.isFavorited(song.id)"
@toggle="favoritesStore.toggleFavorite({ id: song.id, type: 'song', title: song.title, subtitle: song.artist, data: song })"
/>
</div>
</div>
</button>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { Song } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateSongCoverFallback, fetchMusicCover } from '@/composables/useImageFallback'
import FavoriteButton from '@/components/ui/FavoriteButton.vue'
import { useFavoritesStore } from '@/stores/favorites'
const props = defineProps<{ song: Song }>()
defineEmits<{ select: [song: Song] }>()
const { isDark } = useTheme()
const favoritesStore = useFavoritesStore()
const coverFailed = ref(false)
const fetchedCover = ref<string | null>(null)
const coverSrc = computed(() => {
if (coverFailed.value) return null
return props.song.coverUrl || fetchedCover.value
})
const fallbackCover = computed(() =>
generateSongCoverFallback(props.song.title, props.song.artist)
)
const isExternal = computed(() => props.song.id.startsWith('ext-'))
onMounted(() => {
if (props.song.coverUrl) return
fetchMusicCover(props.song.title, props.song.artist, props.song.album).then((url) => {
if (url) fetchedCover.value = url
})
})
</script>
@@ -0,0 +1,208 @@
<template>
<div class="song-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<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"
:alt="song.title"
class="w-full h-full object-cover object-center block"
@error="coverFailed = true"
/>
<div
v-else
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackCover})` }"
/>
</div>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
<button
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-20 h-20 rounded-full flex items-center justify-center path-glass-icon hover:scale-105 active:scale-95 transition-all duration-200 z-10"
title="Play"
@click="onPlay"
>
<svg class="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</button>
<button
class="absolute top-3 left-3 min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
@click="$emit('back')"
>
<svg class="w-4 h-4 text-white/90" 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="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ song.title }}</h2>
<div class="flex items-center gap-2 mt-1 text-xs text-white/60">
<span>{{ song.artist }}</span>
<span v-if="song.album">{{ song.album }}</span>
<span v-if="song.year">{{ song.year }}</span>
<span v-if="song.duration">{{ formatDuration(song.duration) }}</span>
</div>
</div>
</div>
<div class="p-4 space-y-4">
<div v-if="song.genres?.length" class="flex flex-wrap gap-1.5">
<span
v-for="genre in song.genres"
:key="genre"
class="text-xs px-2 py-1 rounded-md font-medium"
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'"
>
{{ genre }}
</span>
</div>
<div v-if="wavlakeChartUrl" class="rounded-xl overflow-hidden border"
:class="isDark ? 'border-white/10' : 'border-black/8'">
<h4 class="text-xs font-semibold mb-2 px-3 pt-3"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Discover on Wavlake</h4>
<iframe
:src="wavlakeChartUrl"
class="w-full h-[380px] border-0"
loading="lazy"
title="Wavlake chart"
/>
</div>
<div>
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Listen on</h4>
<div class="space-y-2">
<a
v-for="link in listenLinks"
:key="link.url"
:href="link.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<span class="text-sm">{{ link.icon }}</span>
<div>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ link.name }}</p>
<p v-if="link.desc" class="text-xs"
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ link.desc }}</p>
</div>
</div>
<svg class="w-4 h-4" :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>
</a>
<a
v-for="src in song.sources ?? []"
:key="src.url"
:href="src.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<span class="text-sm">{{ sourceIcon(src.type) }}</span>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ src.name }}</p>
</div>
<svg class="w-4 h-4" :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>
</a>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { Song } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateSongCoverFallback, fetchMusicCover } from '@/composables/useImageFallback'
import { usePlayer } from '@/composables/usePlayer'
import { mapToWavlakeGenre } from '@/utils/wavlakeGenres'
const props = defineProps<{ song: Song }>()
const { play } = usePlayer()
function onPlay() {
play(props.song)
}
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const coverFailed = ref(false)
const fetchedCover = ref<string | null>(null)
const q = computed(() =>
`${props.song.title} ${props.song.artist}`.trim().replace(/\s+/g, '+'),
)
const listenLinks = computed(() => {
const links: { name: string; url: string; icon: string; desc?: string }[] = [
{ name: 'Wavlake', url: `https://wavlake.com/search?q=${q.value}`, icon: '⚡', desc: 'Lightning-powered music' },
]
return links
})
const wavlakeGenre = computed(() =>
mapToWavlakeGenre(props.song.genres ?? []),
)
const wavlakeChartUrl = computed(() => {
const genre = wavlakeGenre.value
if (!genre) return null
return `https://embed.wavlake.com/chart?days=21&limit=10&genre=${encodeURIComponent(genre)}`
})
const coverSrc = computed(() => {
if (coverFailed.value) return null
return props.song.coverUrl || fetchedCover.value
})
const fallbackCover = computed(() =>
generateSongCoverFallback(props.song.title, props.song.artist)
)
function formatDuration(sec: number): string {
const m = Math.floor(sec / 60)
const s = sec % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
function sourceIcon(type: string): string {
const icons: Record<string, string> = {
spotify: '🟢',
youtube: '▶️',
'apple-music': '🍎',
bandcamp: '📦',
soundcloud: '☁️',
wavlake: '⚡',
internet_archive: '🏛️',
jamendo: '🎵',
odysee: '🔗',
funkwhale: '🐋',
}
return icons[type] ?? '🎵'
}
onMounted(() => {
if (props.song.coverUrl) return
fetchMusicCover(props.song.title, props.song.artist, props.song.album).then((url) => {
if (url) fetchedCover.value = url
})
})
</script>
@@ -0,0 +1,175 @@
<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'">
{{ title }}
</h3>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredSongs.length }} songs
</span>
<slot name="header-actions" />
</div>
</div>
<input
v-model="search"
type="text"
placeholder="Search songs..."
class="w-full px-3 py-2 rounded-lg text-base 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 flex-wrap gap-1.5">
<button
v-for="genre in topGenres"
:key="genre"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeGenre === genre
? '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="activeGenre = activeGenre === genre ? null : genre"
>
{{ genre }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<button
v-for="song in filteredSongs"
:key="song.id"
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"
:aria-label="`${song.title} by ${song.artist}`"
@click="emit('selectSong', song)"
>
<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]" :class="!coverSrc(song) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="isLoading(song)" class="absolute inset-0 animate-shimmer" />
<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"
aria-label="Play"
@click.stop="play(song); emit('selectSong', song)"
>
<span class="w-16 h-16 rounded-full flex items-center justify-center path-glass-icon">
<svg class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</span>
</button>
<img
v-if="coverSrc(song)"
:src="coverSrc(song)!"
:alt="`${song.title} by ${song.artist}`"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onError(song)"
/>
<img
v-else-if="!isLoading(song)"
:src="fallbackSrc(song)"
:alt="song.title"
class="w-full h-full object-cover"
/>
<div v-if="coverSrc(song)" class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
<div class="absolute bottom-0 left-0 right-0 p-2">
<p class="text-xs font-semibold text-white/90 leading-tight truncate">
{{ song.title }}
</p>
<!-- filename-derived songs have no artist — don't spend a blank line on it -->
<p v-if="song.artist" class="text-xs text-white/40 truncate mt-0.5">{{ song.artist }}</p>
</div>
<div class="absolute top-1.5 right-1.5 flex gap-0.5 flex-wrap justify-end max-w-[60%]">
<span
v-for="src in (song.sources ?? []).slice(0, 2)"
:key="src.type"
class="text-xs px-1 py-0.5 rounded bg-black/60 text-white/70 backdrop-blur-sm"
>
{{ src.type }}
</span>
</div>
</div>
</div>
</button>
</div>
<div v-if="filteredSongs.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No songs match your search
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, toRef } from 'vue'
import type { Song } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { usePlayer } from '@/composables/usePlayer'
import { useContentImages } from '@/composables/useContentImages'
import { generateSongCoverFallback, fetchMusicCover } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
songs: Song[]
title?: string
}>(), {
title: 'Recommended Songs',
})
const emit = defineEmits<{ selectSong: [song: Song] }>()
const { isDark } = useTheme()
const { play } = usePlayer()
const search = ref('')
const activeGenre = ref<string | null>(null)
const { coverSrc, fallbackSrc, onError, isLoading } = useContentImages({
items: toRef(props, 'songs'),
id: (s) => s.id,
existingUrl: (s) => s.coverUrl,
fetch: (s) => fetchMusicCover(s.title, s.artist, s.album),
fallback: (s) => generateSongCoverFallback(s.title, s.artist),
})
const topGenres = computed(() => {
const counts = new Map<string, number>()
for (const s of props.songs) {
for (const g of s.genres ?? []) {
counts.set(g, (counts.get(g) ?? 0) + 1)
}
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([g]) => g)
})
const filteredSongs = computed(() => {
let result = props.songs
if (search.value) {
const q = search.value.toLowerCase()
result = result.filter(
(s) =>
s.title.toLowerCase().includes(q) ||
s.artist.toLowerCase().includes(q) ||
(s.album ?? '').toLowerCase().includes(q)
)
}
if (activeGenre.value) {
result = result.filter((s) => (s.genres ?? []).includes(activeGenre.value!))
}
return result
})
</script>
@@ -0,0 +1,107 @@
<template>
<button
class="flex gap-3 p-2 rounded-xl transition-all duration-200 text-left w-full group overflow-hidden"
:class="isDark
? 'hover:bg-white/5 active:bg-white/10'
: 'hover:bg-black/[0.03] active:bg-black/5'"
@click="$emit('select', series)"
>
<div class="poster-card-sm shrink-0 w-12 aspect-[2/3] rounded-lg overflow-hidden">
<img
v-if="posterSrc"
:src="posterSrc"
:alt="series.title"
class="w-full h-full object-cover rounded-[6px] transition-transform duration-300 group-hover:scale-105"
loading="lazy"
@error="posterFailed = true"
/>
<div
v-else
class="w-full h-full rounded-[6px] bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackPoster})` }"
/>
</div>
<div class="min-w-0 flex-1 py-0.5">
<p class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">{{ series.title }}</p>
<p class="text-xs mt-0.5"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
{{ yearDisplay }}<template v-if="series.network"> · {{ series.network }}</template>
</p>
<p v-if="series.synopsis"
class="text-xs mt-0.5 line-clamp-2"
:class="isDark ? 'text-white/35' : 'text-gray-400'">
{{ series.synopsis }}
</p>
<div class="flex items-center gap-1.5 mt-1.5">
<span v-if="series.rating && series.rating > 0"
class="text-xs font-semibold px-1.5 py-0.5 rounded"
:class="ratingClass">
★ {{ series.rating.toFixed(1) }}
</span>
<span v-if="series.seasons"
class="text-xs px-1.5 py-0.5 rounded font-medium"
:class="isDark ? 'bg-white/8 text-white/50' : 'bg-black/5 text-gray-500'">
{{ series.seasons }}S
</span>
<span v-if="series.status === 'ongoing'"
class="text-xs px-1.5 py-0.5 rounded font-medium"
:class="isDark ? 'bg-success/15 text-success/70' : 'bg-green-50 text-green-600'">
ongoing
</span>
<span v-else-if="series.status === 'ended'"
class="text-xs px-1.5 py-0.5 rounded font-medium"
:class="isDark ? 'bg-white/8 text-white/40' : 'bg-black/5 text-gray-400'">
ended
</span>
</div>
</div>
</button>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { TVSeries } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateTVSeriesFallback, fetchTVImage } from '@/composables/useImageFallback'
const props = defineProps<{ series: TVSeries }>()
defineEmits<{ select: [series: TVSeries] }>()
const { isDark } = useTheme()
const posterFailed = ref(false)
const fetchedPoster = ref<string | null>(null)
const posterSrc = computed(() => {
if (posterFailed.value) return null
return props.series.posterUrl || fetchedPoster.value
})
const fallbackPoster = 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) {
return `${props.series.year}–${props.series.endYear}`
}
if (props.series.status === 'ongoing') return `${props.series.year}–`
return String(props.series.year)
})
const ratingClass = computed(() => {
const r = props.series.rating ?? 0
if (r >= 8.5) return isDark.value ? 'bg-success/20 text-success' : 'bg-success/10 text-green-700'
if (r >= 7.5) return isDark.value ? 'bg-accent/20 text-accent' : 'bg-accent/10 text-amber-700'
return isDark.value ? 'bg-white/10 text-white/50' : 'bg-black/5 text-gray-500'
})
onMounted(() => {
if (props.series.posterUrl) return
fetchTVImage(props.series.title, props.series.year).then((result) => {
if (result.posterUrl) fetchedPoster.value = result.posterUrl
})
})
</script>
@@ -0,0 +1,179 @@
<template>
<div class="tv-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<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="bannerSrc"
:src="bannerSrc"
:alt="series.title"
class="w-full h-full object-cover object-center block"
@error="onBannerError"
/>
<div
v-else
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" />
<button
class="absolute top-3 left-3 min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
@click="$emit('back')"
>
<svg class="w-4 h-4 text-white/90" 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="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ series.title }}</h2>
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-1 text-xs text-white/60">
<span v-if="yearDisplay">{{ yearDisplay }}</span>
<span v-if="series.seasons">{{ series.seasons }} seasons</span>
<span v-if="series.episodes">{{ series.episodes }} episodes</span>
<span v-if="series.network">{{ series.network }}</span>
<span v-if="series.rating" class="text-amber-400">★ {{ series.rating.toFixed(1) }}</span>
<span v-if="series.status === 'ongoing'" class="text-emerald-400">ongoing</span>
<span v-else-if="series.status === 'ended'" class="text-white/40">ended</span>
</div>
</div>
</div>
<div class="p-4 space-y-4">
<p v-if="series.synopsis" class="text-sm leading-relaxed"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ series.synopsis }}
</p>
<div v-if="series.creator" class="text-xs"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
Created by <span class="font-medium" :class="isDark ? 'text-white/70' : 'text-gray-700'">{{ series.creator }}</span>
</div>
<div v-if="series.cast?.length" class="text-xs"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
Starring: {{ series.cast.slice(0, 5).join(', ') }}
</div>
<div v-if="series.genres?.length" class="flex flex-wrap gap-1.5">
<span
v-for="genre in series.genres"
:key="genre"
class="text-xs px-2 py-1 rounded-md font-medium"
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'"
>
{{ genre }}
</span>
</div>
<div>
<h4 class="text-xs font-semibold mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">Watch on</h4>
<div class="space-y-2">
<a
v-for="src in (series.sources ?? [])"
:key="src.url"
:href="src.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<span class="text-sm">{{ sourceIcon(src.type) }}</span>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ src.name }}</p>
</div>
<svg class="w-4 h-4" :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>
</a>
<a
v-for="link in watchLinks"
:key="link.url"
:href="link.url"
target="_blank"
rel="noopener"
class="flex items-center justify-between p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/5 hover:bg-white/10'
: 'bg-black/3 hover:bg-black/5'"
>
<div class="flex items-center gap-2.5">
<span class="text-sm">{{ link.icon }}</span>
<div>
<p class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'">{{ link.name }}</p>
<p v-if="link.desc" class="text-xs"
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ link.desc }}</p>
</div>
</div>
<svg class="w-4 h-4" :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>
</a>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { TVSeries } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useBannerFallback } from '@/composables/useBannerFallback'
import { fetchTVImage } from '@/composables/useImageFallback'
const props = defineProps<{ series: TVSeries }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
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 yearDisplay = computed(() => {
if (!props.series.year) return ''
if (props.series.endYear && props.series.endYear !== props.series.year) {
return `${props.series.year}–${props.series.endYear}`
}
if (props.series.status === 'ongoing') return `${props.series.year}–`
return String(props.series.year)
})
const q = computed(() =>
props.series.title.trim().replace(/\s+/g, '+'),
)
const watchLinks = computed(() => {
if ((props.series.sources ?? []).length > 0) return []
return [
{ name: 'Internet Archive', url: `https://archive.org/search?query=${q.value}`, icon: '🏛️', desc: 'Free, open archive' },
{ name: 'YouTube', url: `https://youtube.com/results?search_query=${q.value}+full+series`, icon: '▶️', desc: 'Free episodes' },
{ name: 'Odysee', url: `https://odysee.com/$/search?q=${q.value}`, icon: '🔗', desc: 'Decentralized' },
{ name: 'Tubi', url: `https://tubitv.com/search/${q.value}`, icon: '📺', desc: 'Free streaming' },
]
})
function sourceIcon(type: string): string {
const icons: Record<string, string> = {
plex: '🟠',
nextcloud: '☁️',
youtube: '▶️',
netflix: '🔴',
'free-web': '🌐',
local: '💾',
}
return icons[type] ?? '📺'
}
</script>
@@ -0,0 +1,183 @@
<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'">
{{ title }}
</h3>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredSeries.length }} series
</span>
<slot name="header-actions" />
</div>
</div>
<input
v-model="search"
type="text"
placeholder="Search TV series..."
class="w-full px-3 py-2 rounded-lg text-base 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 v-if="topGenres.length > 0" class="flex flex-wrap gap-1.5">
<button
v-for="genre in topGenres"
:key="genre"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeGenre === genre
? '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="activeGenre = activeGenre === genre ? null : genre"
>
{{ genre }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<button
v-for="s in filteredSeries"
:key="s.id"
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"
:aria-label="s.title"
@click="$emit('selectSeries', s)"
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(s) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="isLoading(s)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(s)"
:src="coverSrc(s)!"
:alt="`${s.title} — TV Series`"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onError(s)"
/>
<img
v-else-if="!isLoading(s)"
:src="fallbackSrc(s)"
:alt="s.title"
class="w-full h-full object-cover"
/>
<div v-if="coverSrc(s)" class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
<div class="absolute bottom-0 left-0 right-0 p-2">
<p class="text-xs font-semibold text-white/90 leading-tight truncate">
{{ s.title }}
</p>
<p class="text-xs text-white/40 truncate mt-0.5">
{{ yearDisplay(s) }}<template v-if="s.seasons"> · {{ s.seasons }}S</template>
</p>
</div>
<div v-if="s.rating" class="absolute top-1.5 left-1.5">
<span class="text-xs px-1.5 py-0.5 rounded bg-black/60 text-amber-400 backdrop-blur-sm font-medium">
★ {{ s.rating.toFixed(1) }}
</span>
</div>
<div v-if="s.status === 'ongoing'" class="absolute top-1.5 right-1.5">
<span class="text-xs px-1 py-0.5 rounded bg-emerald-500/80 text-white backdrop-blur-sm">
ongoing
</span>
</div>
<div class="absolute top-1.5 right-1.5 flex gap-0.5 flex-wrap justify-end max-w-[60%]">
<span
v-for="src in (s.sources ?? []).slice(0, 2)"
:key="src.type"
class="text-xs px-1 py-0.5 rounded bg-black/60 text-white/70 backdrop-blur-sm"
>
{{ src.type }}
</span>
</div>
</div>
</div>
</button>
</div>
<div v-if="filteredSeries.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No TV series match your search
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, toRef } from 'vue'
import type { TVSeries } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useContentImages } from '@/composables/useContentImages'
import { generateTVSeriesFallback, fetchTVImage } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
series: TVSeries[]
title?: string
}>(), {
title: 'Recommended TV Series',
})
defineEmits<{ selectSeries: [series: TVSeries] }>()
const { isDark } = useTheme()
const search = ref('')
const activeGenre = ref<string | null>(null)
const { coverSrc, fallbackSrc, onError, isLoading } = useContentImages({
items: toRef(props, 'series'),
id: (s) => s.id,
existingUrl: (s) => s.posterUrl || s.backdropUrl,
fetch: (s) => fetchTVImage(s.title, s.year).then((r) => r.posterUrl),
fallback: (s) => generateTVSeriesFallback(s.title, s.year),
})
function yearDisplay(s: TVSeries): string {
if (!s.year) return ''
if (s.endYear && s.endYear !== s.year) return `${s.year}–${s.endYear}`
if (s.status === 'ongoing') return `${s.year}–`
return String(s.year)
}
const topGenres = computed(() => {
const counts = new Map<string, number>()
for (const s of props.series) {
for (const g of s.genres ?? []) {
counts.set(g, (counts.get(g) ?? 0) + 1)
}
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([g]) => g)
})
const filteredSeries = computed(() => {
let result = props.series
if (search.value) {
const q = search.value.toLowerCase()
result = result.filter(
(s) =>
s.title.toLowerCase().includes(q) ||
(s.creator ?? '').toLowerCase().includes(q) ||
(s.network ?? '').toLowerCase().includes(q) ||
(s.genres ?? []).some((g) => g.toLowerCase().includes(q))
)
}
if (activeGenre.value) {
result = result.filter((s) => (s.genres ?? []).includes(activeGenre.value!))
}
return result
})
</script>
@@ -0,0 +1,50 @@
<template>
<div :style="{ paddingLeft: `${Math.min(depth, 4) * 16}px` }">
<div class="rounded-lg bg-white/[0.03] border border-white/5 p-2.5 mb-1">
<div class="flex items-center gap-1.5 mb-1">
<div class="w-5 h-5 rounded-full shrink-0 flex items-center justify-center text-xs font-bold bg-purple-500/20 text-purple-400">
{{ node.note.authorName?.charAt(0)?.toUpperCase() ?? '?' }}
</div>
<span class="text-xs font-semibold text-white/70">{{ node.note.authorName ?? 'anon' }}</span>
<span class="text-xs ml-auto text-white/20">{{ formatTime(node.note.created_at) }}</span>
</div>
<p class="text-xs text-white/60 leading-relaxed whitespace-pre-wrap">{{ node.note.content }}</p>
<button
class="text-xs text-white/25 hover:text-accent/60 mt-1 transition-colors"
@click="$emit('reply', node.note)"
>
Reply
</button>
</div>
<!-- Children (recursive) -->
<ThreadNode
v-for="child in node.children"
:key="child.note.id"
:node="child"
:depth="depth + 1"
@reply="(note: NostrNote) => $emit('reply', note)"
/>
</div>
</template>
<script setup lang="ts">
import type { NostrNote } from '@/composables/useNostr'
interface ThreadTreeNode {
note: NostrNote
children: ThreadTreeNode[]
}
defineProps<{
node: ThreadTreeNode
depth: number
}>()
defineEmits<{ reply: [note: NostrNote] }>()
function formatTime(ts: number): string {
const d = new Date(ts * 1000)
return d.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' })
}
</script>
@@ -0,0 +1,76 @@
<template>
<div class="website-detail h-full flex flex-col overflow-hidden">
<div class="shrink-0 flex items-center gap-2 px-3 py-2.5"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
<button
class="absolute top-3 left-3 p-2 rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10"
@click="$emit('back')"
>
<svg class="w-4 h-4 text-white/90" 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="flex-1 min-w-0 pl-8">
<p class="text-sm font-medium truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ website.title || 'Website' }}
</p>
<p v-if="domain" class="text-xs truncate"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ domain }}
</p>
</div>
<a
:href="website.url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center w-8 h-8 rounded-lg transition-colors shrink-0"
:class="isDark ? 'hover:bg-white/10 text-white/50' : 'hover:bg-black/5 text-gray-400'"
aria-label="Open in new tab"
title="Open in new tab"
@click.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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
<div class="flex-1 min-h-0 relative bg-black/20">
<iframe
:key="website.url"
:src="website.url"
class="absolute inset-0 w-full h-full border-0"
style="-ms-overflow-style: none; scrollbar-width: none;"
title="Website content"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { WebSearchResult } from '@aiui/core/types/message'
import { useTheme } from '@/composables/useTheme'
const props = defineProps<{ website: WebSearchResult }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const domain = computed(() => {
if (!props.website.url) return ''
try {
return new URL(props.website.url).hostname.replace(/^www\./, '')
} catch {
return ''
}
})
</script>
<style scoped>
iframe::-webkit-scrollbar {
display: none;
}
</style>
@@ -0,0 +1,257 @@
<template>
<div
v-if="isOpen"
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-label="Send zap"
class="fixed inset-0 z-50 flex items-center justify-center"
@click.self="close"
@keydown.escape="close"
@keydown.tab="trapFocus"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" @click="close" />
<div class="relative glass-card w-[320px] max-w-[90vw] p-5 space-y-4 animate-scale-in">
<div class="flex items-center justify-between">
<h3 class="text-sm font-bold text-white/90">Zap</h3>
<button
ref="closeButtonRef"
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded text-white/40 hover:text-white/70 transition-colors"
aria-label="Close"
@click="close"
>
<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>
<!-- Target info -->
<p class="text-xs text-white/40 truncate font-mono">{{ targetName }}</p>
<!-- Amount presets -->
<div class="flex gap-1.5 flex-wrap">
<button
v-for="preset in amountPresets"
:key="preset"
class="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
:class="amount === preset
? 'bg-accent/20 text-accent border border-accent/30'
: 'bg-white/5 text-white/50 hover:bg-white/10'"
@click="amount = preset"
>
{{ formatSats(preset) }}
</button>
</div>
<!-- Custom amount -->
<div>
<label class="text-xs text-white/30 block mb-1">Amount (sats)</label>
<input
v-model.number="amount"
type="number"
min="1"
class="w-full px-3 py-2 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors tabular-nums"
placeholder="21"
/>
</div>
<!-- Optional message -->
<div>
<label class="text-xs text-white/30 block mb-1">Message (optional)</label>
<input
v-model="message"
type="text"
class="w-full px-3 py-2 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors"
placeholder="Great post!"
/>
</div>
<!-- Zap button -->
<button
class="w-full py-2.5 rounded-lg text-xs font-medium bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
:disabled="!amount || amount < 1 || isZapping"
@click="sendZap"
>
{{ isZapping ? 'Generating invoice...' : `Zap ${formatSats(amount)} sats` }}
</button>
<!-- Invoice / QR -->
<div v-if="invoice" class="space-y-2">
<p class="text-xs text-white/30 text-center">Scan or tap to pay</p>
<!-- QR placeholder (canvas) -->
<div class="flex justify-center">
<canvas ref="qrCanvas" class="rounded-lg" width="200" height="200" />
</div>
<!-- Invoice string -->
<div class="flex gap-1">
<input
:value="invoice"
readonly
class="flex-1 px-2 py-1.5 rounded text-base bg-white/5 text-white/40 font-mono truncate outline-none"
/>
<button
class="px-2 py-1.5 rounded text-xs bg-white/5 text-white/40 hover:text-white/60 transition-colors"
@click="copyInvoice"
>
{{ copied ? 'Copied' : 'Copy' }}
</button>
</div>
<!-- Open in wallet -->
<a
:href="'lightning:' + invoice"
class="block w-full py-2 rounded-lg text-xs text-center bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
>
Open in wallet
</a>
</div>
<!-- Error -->
<p v-if="error" class="text-xs text-red-400/60 text-center">{{ error }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
const props = defineProps<{
isOpen: boolean
targetName: string
lightningAddress?: string
}>()
const emit = defineEmits<{ close: [] }>()
const dialogRef = ref<HTMLElement | null>(null)
const closeButtonRef = ref<HTMLElement | null>(null)
const amountPresets = [21, 100, 500, 1000, 5000, 10000]
const amount = ref(21)
const message = ref('')
const invoice = ref('')
const isZapping = ref(false)
const error = ref('')
const copied = ref(false)
const qrCanvas = ref<HTMLCanvasElement | null>(null)
function formatSats(sats: number): string {
if (sats >= 1000) return `${(sats / 1000).toFixed(sats % 1000 === 0 ? 0 : 1)}k`
return String(sats)
}
function close() {
emit('close')
invoice.value = ''
error.value = ''
message.value = ''
}
async function sendZap() {
if (!props.lightningAddress || !amount.value) return
isZapping.value = true
error.value = ''
invoice.value = ''
try {
// Resolve LNURL from Lightning address
const [name, domain] = props.lightningAddress.split('@')
if (!name || !domain) throw new Error('Invalid Lightning address')
const lnurlRes = await fetch(`https://${domain}/.well-known/lnurlp/${name}`)
if (!lnurlRes.ok) throw new Error('Failed to fetch LNURL')
const lnurlData = await lnurlRes.json()
if (lnurlData.status === 'ERROR') throw new Error(lnurlData.reason || 'LNURL error')
const msats = amount.value * 1000
if (msats < (lnurlData.minSendable ?? 0)) throw new Error(`Minimum: ${Math.ceil((lnurlData.minSendable ?? 0) / 1000)} sats`)
if (msats > (lnurlData.maxSendable ?? Infinity)) throw new Error(`Maximum: ${Math.floor((lnurlData.maxSendable ?? 0) / 1000)} sats`)
// Request invoice
let callbackUrl = lnurlData.callback
const sep = callbackUrl.includes('?') ? '&' : '?'
callbackUrl += `${sep}amount=${msats}`
if (message.value) callbackUrl += `&comment=${encodeURIComponent(message.value)}`
const invoiceRes = await fetch(callbackUrl)
if (!invoiceRes.ok) throw new Error('Failed to get invoice')
const invoiceData = await invoiceRes.json()
if (invoiceData.status === 'ERROR') throw new Error(invoiceData.reason || 'Invoice error')
invoice.value = invoiceData.pr
// Draw QR
await nextTick()
drawQR(invoiceData.pr)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Zap failed'
} finally {
isZapping.value = false
}
}
function drawQR(data: string) {
const canvas = qrCanvas.value
if (!canvas) return
// Simple QR placeholder — draw the invoice text in a styled box
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.fillStyle = '#1a1a1a'
ctx.fillRect(0, 0, 200, 200)
ctx.fillStyle = '#F7931A'
ctx.font = '10px monospace'
ctx.textAlign = 'center'
// Wrap text
const lines: string[] = []
for (let i = 0; i < data.length; i += 30) {
lines.push(data.slice(i, i + 30))
}
const startY = Math.max(10, 100 - (lines.length * 6))
lines.forEach((line, i) => {
ctx.fillText(line, 100, startY + i * 12)
})
}
function copyInvoice() {
navigator.clipboard.writeText(invoice.value)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
function trapFocus(e: KeyboardEvent) {
const dialog = dialogRef.value
if (!dialog) return
const focusable = dialog.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
watch(() => props.isOpen, async (open) => {
if (open) {
await nextTick()
closeButtonRef.value?.focus()
} else {
invoice.value = ''
error.value = ''
}
})
</script>