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,58 @@
<template>
<div class="h-full flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-white/5 shrink-0">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white/80 truncate">{{ file.name }}</p>
<p class="text-xs text-white/30 truncate mt-0.5">{{ file.path }}</p>
</div>
<div class="flex items-center gap-2 shrink-0 ml-3">
<span class="text-xs text-white/25 font-mono">{{ formatSize(file.size) }}</span>
<button
class="min-w-[32px] min-h-[32px] flex items-center justify-center rounded-md text-white/40 hover:text-white/70 hover:bg-white/10 transition-colors"
aria-label="Close preview"
@click="$emit('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>
</div>
<!-- Content -->
<div class="flex-1 overflow-auto">
<table class="text-xs font-mono leading-relaxed w-full">
<tbody>
<tr v-for="(line, i) in lines" :key="i" class="hover:bg-white/3">
<td class="text-white/20 text-right pr-4 pl-4 py-0 select-none align-top whitespace-nowrap sticky left-0 bg-[#0a0a0a]">{{ i + 1 }}</td>
<td class="text-white/70 pr-4 py-0 whitespace-pre">{{ line }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
file: {
name: string
path: string
content: string
size: number
}
}>()
defineEmits<{ close: [] }>()
const lines = computed(() => props.file.content.split('\n'))
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
</script>
@@ -0,0 +1,124 @@
<template>
<div class="space-y-0.5">
<div v-for="item in items" :key="item.path">
<button
class="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-left transition-colors min-h-[32px]"
:class="item.isDirectory
? 'hover:bg-white/5 text-white/70 hover:text-white/80'
: 'hover:bg-white/8 text-white/60 hover:text-white/80'"
@click="handleClick(item)"
>
<!-- Expand/collapse chevron for directories -->
<svg
v-if="item.isDirectory"
class="w-3 h-3 text-white/30 shrink-0 transition-transform duration-150"
:class="{ 'rotate-90': expanded.has(item.path) }"
fill="currentColor"
viewBox="0 0 20 20"
>
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
</svg>
<span v-else class="w-3 shrink-0" />
<!-- File/folder icon -->
<svg
class="w-4 h-4 shrink-0"
:class="iconColor(item)"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
:d="iconPath(item)"
/>
</svg>
<!-- Name -->
<span class="text-sm truncate">{{ item.name }}</span>
</button>
<!-- Children (recursive) -->
<div
v-if="item.isDirectory && item.children?.length && expanded.has(item.path)"
class="pl-4 ml-[18px] border-l border-white/5"
>
<FileTree
:items="item.children"
@select-file="$emit('selectFile', $event)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface FileEntry {
name: string
path: string
isDirectory: boolean
children?: FileEntry[]
}
defineProps<{ items: FileEntry[] }>()
const expanded = ref<Set<string>>(new Set())
const emit = defineEmits<{ selectFile: [entry: FileEntry] }>()
function handleClick(item: FileEntry) {
if (item.isDirectory) {
const next = new Set(expanded.value)
if (next.has(item.path)) {
next.delete(item.path)
} else {
next.add(item.path)
}
expanded.value = next
} else {
emit('selectFile', item)
}
}
const CODE_EXTS = new Set([
'ts', 'tsx', 'js', 'jsx', 'vue', 'svelte', 'py', 'rs', 'go', 'java',
'c', 'cpp', 'h', 'hpp', 'rb', 'php', 'swift', 'kt', 'cs', 'css',
'scss', 'less', 'html', 'xml', 'yaml', 'yml', 'toml', 'json', 'sh',
'bash', 'zsh', 'sql', 'md', 'mdx',
])
const IMAGE_EXTS = new Set([
'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif',
])
function fileExt(name: string): string {
return name.split('.').pop()?.toLowerCase() ?? ''
}
function iconColor(item: FileEntry): string {
if (item.isDirectory) return 'text-yellow-500/70'
const ext = fileExt(item.name)
if (CODE_EXTS.has(ext)) return 'text-blue-400/70'
if (IMAGE_EXTS.has(ext)) return 'text-green-400/70'
return 'text-white/40'
}
const FOLDER_PATH = 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z'
const FOLDER_OPEN_PATH = 'M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z'
const CODE_PATH = 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4'
const IMAGE_PATH = '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'
const DOC_PATH = 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z'
function iconPath(item: FileEntry): string {
if (item.isDirectory) {
return expanded.value.has(item.path) ? FOLDER_OPEN_PATH : FOLDER_PATH
}
const ext = fileExt(item.name)
if (CODE_EXTS.has(ext)) return CODE_PATH
if (IMAGE_EXTS.has(ext)) return IMAGE_PATH
return DOC_PATH
}
</script>
@@ -0,0 +1,179 @@
<template>
<div class="px-3 md:px-4 pb-1">
<button
class="flex items-center gap-1.5 text-xs text-white/40 hover:text-white/60 transition-colors"
@click="isExpanded = !isExpanded"
>
<svg
class="w-3 h-3 transition-transform duration-200"
:class="isExpanded ? 'rotate-90' : ''"
fill="currentColor"
viewBox="0 0 20 20"
>
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
Advanced
</button>
<div v-if="isExpanded && conv" class="mt-2 space-y-3 animate-fade-up-fast">
<!-- Temperature -->
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs text-white/40">Temperature</label>
<span class="text-xs text-white/50 tabular-nums">{{ temperature.toFixed(2) }}</span>
</div>
<input
v-model.number="temperature"
type="range"
min="0"
max="1"
step="0.05"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<!-- Max Tokens -->
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs text-white/40">Max Tokens</label>
<span class="text-xs text-white/50 tabular-nums">{{ maxTokens }}</span>
</div>
<input
v-model.number="maxTokens"
type="range"
min="256"
max="8192"
step="256"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<!-- Top P -->
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs text-white/40">Top P</label>
<span class="text-xs text-white/50 tabular-nums">{{ topP.toFixed(2) }}</span>
</div>
<input
v-model.number="topP"
type="range"
min="0"
max="1"
step="0.05"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<!-- Stop Sequences (M9.10) -->
<div class="space-y-1">
<label class="text-xs text-white/40">Stop Sequences</label>
<div v-if="stopSequences.length > 0" class="flex gap-1 flex-wrap mb-1">
<span
v-for="(seq, i) in stopSequences"
:key="i"
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-white/5 border border-white/10 text-xs text-white/50"
>
{{ seq }}
<button class="text-white/30 hover:text-white/60" @click="removeStopSequence(i)">×</button>
</span>
</div>
<input
v-model="newStopSeq"
type="text"
class="w-full bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-base text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
placeholder="Add stop sequence (Enter to add)"
@keydown.enter="addStopSequence"
/>
</div>
<!-- Reset -->
<button
class="text-xs text-white/30 hover:text-white/50 transition-colors"
@click="resetDefaults"
>
Reset to defaults
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
const chatStore = useChatStore()
const isExpanded = ref(false)
const newStopSeq = ref('')
const conv = computed(() => chatStore.activeConversation)
const temperature = ref(1.0)
const maxTokens = ref(4096)
const topP = ref(1.0)
const stopSequences = ref<string[]>([])
// Sync from conversation on switch
watch(
() => chatStore.activeConversationId,
() => loadFromConv(),
{ immediate: true }
)
function loadFromConv() {
const c = conv.value
temperature.value = c?.temperature ?? 1.0
maxTokens.value = c?.maxTokens ?? 4096
topP.value = c?.topP ?? 1.0
stopSequences.value = c?.stopSequences ? [...c.stopSequences] : []
}
function persistParams() {
const c = conv.value
if (!c) return
c.temperature = temperature.value
c.maxTokens = maxTokens.value
c.topP = topP.value
c.updatedAt = Date.now()
}
function addStopSequence() {
const seq = newStopSeq.value.trim()
if (!seq) return
stopSequences.value.push(seq)
newStopSeq.value = ''
const c = conv.value
if (c) {
c.stopSequences = [...stopSequences.value]
c.updatedAt = Date.now()
}
}
function removeStopSequence(index: number) {
stopSequences.value.splice(index, 1)
const c = conv.value
if (c) {
c.stopSequences = [...stopSequences.value]
c.updatedAt = Date.now()
}
}
function resetDefaults() {
temperature.value = 1.0
maxTokens.value = 4096
topP.value = 1.0
stopSequences.value = []
newStopSeq.value = ''
const c = conv.value
if (c) {
c.temperature = undefined
c.maxTokens = undefined
c.topP = undefined
c.stopSequences = undefined
c.updatedAt = Date.now()
}
}
</script>
@@ -0,0 +1,55 @@
<template>
<div
v-if="branches.length > 1"
class="flex items-center justify-center px-4 py-1.5 animate-fade-up-fast"
>
<div class="glass-button-sm min-h-[44px] md:min-h-0 md:!h-7 flex items-center gap-1.5 px-2 text-xs">
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md hover:bg-white/10 transition-colors text-white/60 hover:text-white/90 disabled:opacity-30 disabled:cursor-default"
:disabled="currentIndex <= 0"
aria-label="Previous branch"
@click="switchToBranch(currentIndex - 1)"
>
<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="M15 19l-7-7 7-7" />
</svg>
</button>
<span class="text-white/70 select-none whitespace-nowrap">
Branch {{ currentIndex + 1 }} of {{ branches.length }}
</span>
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md hover:bg-white/10 transition-colors text-white/60 hover:text-white/90 disabled:opacity-30 disabled:cursor-default"
:disabled="currentIndex >= branches.length - 1"
aria-label="Next branch"
@click="switchToBranch(currentIndex + 1)"
>
<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="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useChatStore } from '@/stores/chat'
const chatStore = useChatStore()
const branches = computed(() => {
if (!chatStore.activeConversationId) return []
return chatStore.getSiblingBranches(chatStore.activeConversationId)
})
const currentIndex = computed(() => {
return branches.value.findIndex(b => b.isCurrent)
})
function switchToBranch(index: number) {
const branch = branches.value[index]
if (branch) {
chatStore.setActiveConversation(branch.id)
}
}
</script>
@@ -0,0 +1,116 @@
<template>
<div
v-if="parsed"
class="rounded-xl p-3 space-y-2 my-2"
:class="isDark
? 'bg-white/[0.03] border border-[#F7931A]/20'
: 'bg-black/[0.02] border border-[#F7931A]/20'"
>
<!-- Header -->
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-[#F7931A]/10 flex items-center justify-center">
<svg class="w-3.5 h-3.5 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="10" />
<text x="12" y="16" text-anchor="middle" fill="white" font-size="12" font-weight="bold">C</text>
</svg>
</div>
<span
class="text-xs font-semibold"
:class="isDark ? 'text-white/80' : 'text-gray-800'"
>
Cashu Token
</span>
<span class="ml-auto text-sm font-bold text-[#F7931A]">
{{ formattedAmount }}
</span>
</div>
<!-- Mint info -->
<div
class="text-xs font-mono"
:class="isDark ? 'text-white/30' : 'text-gray-400'"
>
Mint: {{ displayMint }}
</div>
<!-- Memo -->
<div
v-if="parsed.memo"
class="text-xs"
:class="isDark ? 'text-white/50' : 'text-gray-500'"
>
{{ parsed.memo }}
</div>
<!-- Actions -->
<div class="flex gap-2">
<button
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
:class="isDark
? 'bg-white/5 text-white/60 hover:bg-white/10'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="copyToken"
>
{{ copied ? 'Copied!' : 'Copy Token' }}
</button>
<button
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-[#F7931A]/10 text-[#F7931A] hover:bg-[#F7931A]/20"
@click="openInWallet"
>
Open in Wallet
</button>
</div>
</div>
<!-- Fallback for unparseable tokens -->
<div
v-else
class="rounded-lg p-2 my-1 text-xs font-mono break-all"
:class="isDark ? 'bg-white/5 text-white/40' : 'bg-gray-50 text-gray-500'"
>
{{ truncatedRaw }}
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { parseCashuToken, formatMintUrl, formatCashuAmount } from '@/utils/cashu'
const { isDark } = useTheme()
const props = defineProps<{
token: string
}>()
const copied = ref(false)
const parsed = computed(() => parseCashuToken(props.token))
const formattedAmount = computed(() => {
if (!parsed.value) return ''
return formatCashuAmount(parsed.value.amount, parsed.value.unit)
})
const displayMint = computed(() => {
if (!parsed.value) return ''
return formatMintUrl(parsed.value.mint)
})
const truncatedRaw = computed(() => {
const t = props.token
if (t.length <= 40) return t
return t.slice(0, 20) + '...' + t.slice(-16)
})
async function copyToken() {
await navigator.clipboard.writeText(props.token)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
function openInWallet() {
// Use web+cashu: URI scheme for wallet deep-linking
window.open(`web+cashu:${props.token}`, '_blank')
}
</script>
@@ -0,0 +1,431 @@
<template>
<div
ref="headerRef"
class="flex flex-col gap-0 p-3 relative z-[60] shrink-0"
>
<div class="flex items-center justify-between gap-2">
<button
ref="modelPickerTriggerRef"
class="touch-target rounded-xl path-glass-icon shrink-0 transition-colors cursor-pointer text-[#fafafa] hover:text-white"
:title="`AI model: ${modelDisplayName}`"
aria-label="Select AI model"
@click="showModelPicker = !showModelPicker"
>
<span class="text-base"></span>
</button>
<div class="flex items-center gap-2 shrink-0">
<button
class="touch-target rounded-xl path-glass-icon transition-colors"
:class="chatStore.showHistory
? 'text-accent'
: 'text-white/70 hover:text-white'"
:title="chatStore.showHistory ? 'Back to chat' : 'Chat history'"
aria-label="Toggle chat history"
@click="chatStore.toggleHistory()"
>
<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 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<button
class="touch-target rounded-xl path-glass-icon transition-colors"
:class="chatStore.chatCollapsed
? 'text-accent'
: 'text-white/70 hover:text-white'"
:title="chatStore.chatCollapsed ? 'Expand chat' : 'Prompt index'"
aria-label="Toggle prompt index"
@click="chatStore.toggleChatCollapse()"
>
<svg v-if="!chatStore.chatCollapsed" 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="M4 6h16M4 12h10M4 18h16" />
</svg>
<svg v-else 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="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</button>
<button
class="touch-target rounded-xl path-glass-icon transition-colors"
:class="comparison.isComparing.value
? 'text-accent'
: 'text-white/70 hover:text-white'"
:title="comparison.isComparing.value ? 'Comparison mode on' : 'Compare models'"
aria-label="Toggle model comparison"
@click="comparison.toggleComparison()"
>
<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 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
</svg>
</button>
<button
class="touch-target rounded-xl path-glass-icon transition-colors text-white/70 hover:text-white"
aria-label="Settings"
title="Settings"
@click="$emit('openSettings')"
>
<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.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<button
ref="menuTriggerRef"
class="touch-target rounded-xl path-glass-icon transition-colors text-white/70 hover:text-white"
aria-label="Conversation menu"
@click="showMenu = !showMenu"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
<button
class="touch-target rounded-xl path-glass-icon transition-colors text-white/70 hover:text-white"
aria-label="New conversation"
@click="$emit('newChat')"
>
<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
v-if="showClose"
class="touch-target rounded-xl path-glass-icon transition-colors text-white/70 hover:text-white"
aria-label="Close"
@click="$emit('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>
</div>
<div class="w-full text-left pt-3 pb-1 min-w-0">
<h2 class="text-sm font-semibold truncate text-white/96">{{ title }}</h2>
<div class="flex items-center gap-1.5 mt-0.5">
<p class="text-xs truncate font-mono text-white/40">{{ conversationId }}</p>
<span class="text-xs text-white/20">·</span>
<span class="text-xs truncate text-white/50">{{ modelDisplayName }}</span>
</div>
</div>
<Teleport to="body">
<div v-if="showModelPicker" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showModelPicker = false" />
<Transition name="picker">
<div
v-if="showModelPicker"
class="fixed z-[9999] path-glass-card header-overlay-panel p-3 space-y-3 animate-fade-up-fast shadow-2xl min-w-[220px]"
:style="modelPickerDropdownStyle"
@click.stop
>
<div v-for="provider in availableProviders" :key="provider.id">
<p class="text-xs font-semibold uppercase tracking-wider mb-1.5 px-1 text-white/40">
{{ provider.name }}
</p>
<div class="space-y-0.5">
<button
v-for="model in provider.models"
:key="model.id"
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all duration-200 flex items-center gap-2"
:class="model.id === activeModel && provider.id === activeProvider
? 'nav-tab-active'
: 'text-white/60 hover:text-white hover:bg-white/10'"
@click="selectModel(provider.id, model.id)"
>
<span class="flex-1">{{ model.name }}</span>
<span class="flex gap-0.5 shrink-0">
<span
v-if="getModelCaps(model.id).vision"
class="text-xs opacity-60"
title="Supports vision input"
>👁</span>
<span
v-if="getModelCaps(model.id).tools"
class="text-xs opacity-60"
title="Supports tool use"
>🔧</span>
<span
v-if="getModelCaps(model.id).longContext"
class="text-xs opacity-60"
title="Long context window"
>📄</span>
</span>
</button>
</div>
</div>
<!-- Design System -->
<div class="border-t mt-2 pt-2 border-white/10">
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all duration-200 flex items-center gap-2 text-white/60 hover:text-white hover:bg-white/10"
@click="openDesignSystem"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
Design System
</button>
</div>
</div>
</Transition>
</Teleport>
<!-- Conversation menu (export, delete) -->
<Teleport to="body">
<div v-if="showMenu" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showMenu = false" />
<Transition name="picker">
<div
v-if="showMenu"
class="fixed z-[9999] path-glass-card header-overlay-panel p-2 animate-fade-up-fast shadow-2xl min-w-[160px]"
:style="menuDropdownStyle"
@click.stop
>
<p class="text-xs font-semibold uppercase tracking-wider mb-1.5 px-2 text-white/40">Export</p>
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/60 hover:text-white hover:bg-white/10 transition-all"
@click="handleExport('markdown')"
>
Markdown (.md)
</button>
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/60 hover:text-white hover:bg-white/10 transition-all"
@click="handleExport('json')"
>
JSON
</button>
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/60 hover:text-white hover:bg-white/10 transition-all"
@click="handleExport('text')"
>
Plain text (.txt)
</button>
<div class="border-t border-white/10 mt-1 pt-1">
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-white/60 hover:text-white hover:bg-white/10 transition-all"
@click="triggerImport"
>
Import conversations
</button>
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs text-red-400/80 hover:text-red-400 hover:bg-white/10 transition-all"
@click="handleDelete"
>
Delete conversation
</button>
</div>
</div>
</Transition>
</Teleport>
<input
ref="importInputRef"
type="file"
accept=".json"
class="hidden"
@change="handleImportFile"
/>
<!-- Import status -->
<div
v-if="importStatus"
class="absolute top-full left-3 right-3 mt-1 z-[100] glass px-3 py-2 rounded-lg text-xs animate-fade-up-fast"
:class="importStatus.startsWith('Error') ? 'text-red-400' : 'text-accent'"
>
{{ importStatus }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useAI } from '@/composables/useAI'
import { useContentPanel } from '@/composables/useContentPanel'
import { downloadConversation, type ExportFormat } from '@/utils/conversation-export'
import { parseImportFile } from '@/utils/conversation-import'
import { useComparisonMode } from '@/composables/useComparisonMode'
defineProps<{
title: string
conversationId: string
side: 'left' | 'right'
showClose?: boolean
}>()
defineEmits<{
switchSide: []
newChat: []
close: []
openSettings: []
}>()
const { activeProvider, activeModel, availableProviders, setProvider, setModel } = useAI()
const comparison = useComparisonMode()
// Model capabilities map
const MODEL_CAPS: Record<string, { vision: boolean; tools: boolean; longContext: boolean }> = {
'claude-haiku-4.5': { vision: true, tools: true, longContext: true },
'claude-sonnet-4': { vision: true, tools: true, longContext: true },
'claude-opus-4': { vision: true, tools: true, longContext: true },
'meta-llama/llama-4-maverick': { vision: true, tools: true, longContext: true },
'qwen/qwen3-235b-a22b-thinking-2507': { vision: false, tools: false, longContext: true },
'mistralai/mistral-small-3.1-24b-instruct:free': { vision: true, tools: true, longContext: false },
'google/gemma-3-27b-it:free': { vision: true, tools: false, longContext: false },
'echo': { vision: false, tools: false, longContext: false },
}
function getModelCaps(modelId: string) {
return MODEL_CAPS[modelId] ?? { vision: false, tools: false, longContext: false }
}
const chatStore = useChatStore()
const showModelPicker = ref(false)
const showMenu = ref(false)
const menuTriggerRef = ref<HTMLElement | null>(null)
const menuDropdownStyle = ref<Record<string, string>>({})
const headerRef = ref<HTMLElement | null>(null)
const modelPickerTriggerRef = ref<HTMLElement | null>(null)
const modelPickerDropdownStyle = ref<Record<string, string>>({})
function updateModelPickerPosition() {
nextTick(() => {
const el = modelPickerTriggerRef.value
if (el) {
const r = el.getBoundingClientRect()
modelPickerDropdownStyle.value = {
top: `${r.bottom + 4}px`,
right: 'auto',
left: `${r.left}px`,
width: `${Math.max(r.width + 24, 220)}px`,
}
}
})
}
function updateMenuPosition() {
nextTick(() => {
const el = menuTriggerRef.value
if (el) {
const r = el.getBoundingClientRect()
menuDropdownStyle.value = {
top: `${r.bottom + 4}px`,
right: `${window.innerWidth - r.right}px`,
}
}
})
}
watch(showModelPicker, (v) => { if (v) updateModelPickerPosition() })
watch(showMenu, (v) => { if (v) updateMenuPosition() })
const conversationList = computed(() => chatStore.conversationList)
const modelDisplayName = computed(() => {
for (const p of availableProviders.value) {
const m = p.models.find((mm) => mm.id === activeModel.value)
if (m) return m.name
}
return activeModel.value
})
function selectModel(providerId: string, modelId: string) {
setProvider(providerId as 'claude' | 'openrouter' | 'mock')
setModel(modelId)
showModelPicker.value = false
}
const { enterDesignSystemMode } = useContentPanel()
function openDesignSystem() {
enterDesignSystemMode()
showModelPicker.value = false
}
async function handleExport(format: ExportFormat) {
const conv = chatStore.activeConversation
if (!conv) return
await downloadConversation(conv, format)
showMenu.value = false
}
function handleDelete() {
const id = chatStore.activeConversationId
if (!id) return
chatStore.deleteConversation(id)
showMenu.value = false
}
const importInputRef = ref<HTMLInputElement | null>(null)
const importStatus = ref<string | null>(null)
function triggerImport() {
showMenu.value = false
importInputRef.value?.click()
}
async function handleImportFile(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
const text = await file.text()
const result = parseImportFile(text)
if (result.error || result.conversations.length === 0) {
importStatus.value = `Error: ${result.error ?? 'No conversations found'}`
} else {
for (const conv of result.conversations) {
chatStore.conversations.set(conv.id, conv)
}
const count = result.conversations.length
importStatus.value = `Imported ${count} conversation${count > 1 ? 's' : ''} (${result.format})`
// Switch to first imported conversation
chatStore.setActiveConversation(result.conversations[0].id)
}
} catch {
importStatus.value = 'Error: Failed to read file'
}
// Clear file input for re-use
input.value = ''
// Auto-hide status after 3 seconds
setTimeout(() => { importStatus.value = null }, 3000)
}
</script>
<style scoped>
/* The two overlays this header opens — the model picker and the conversation
* menu — float over live chat content, so the shared `path-glass-card`
* translucency (rgba(0,0,0,0.65)) leaves their text hard to read against a
* busy backdrop. Operator-reported 2026-08-04.
*
* Deliberately scoped to this component rather than changing `.path-glass-card`
* itself: that class is also used by BookDetail, ArticleDetail, TVSeriesDetail,
* WebsiteDetail, ContentPanel, ChatWindow and every ChatPage panel, none of
* which have this problem. Only these two get the extra opacity.
*
* Scoped styles reach these nodes even though they are inside <Teleport to="body">
* (Vue stamps the scope attribute on the element itself), and this rule is
* unlayered while `.path-glass-card` lives in `@layer components` — unlayered
* wins over layered regardless of specificity, so no !important is needed.
*/
.header-overlay-panel {
background: rgba(0, 0, 0, 0.88);
}
.picker-enter-active {
transition: all 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
.picker-leave-active {
transition: all 0.15s ease-in;
}
.picker-enter-from,
.picker-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>
@@ -0,0 +1,70 @@
<template>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hide p-3 space-y-1">
<button
class="w-full text-left px-3 py-2.5 min-h-[44px] rounded-xl transition-all duration-150 hover:bg-white/5 flex items-center gap-2 text-white/70 mb-2"
@click="$emit('newChat')"
>
<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>
<span class="text-sm font-medium">New Chat</span>
</button>
<div v-if="conversations.length === 0" class="flex items-center justify-center h-32">
<p class="text-xs text-white/30">No conversations yet</p>
</div>
<button
v-for="conv in conversations"
:key="conv.id"
class="w-full text-left px-3 py-2.5 min-h-[44px] rounded-xl transition-all duration-150"
:class="conv.id === activeId ? 'nav-tab-active' : 'hover:bg-white/5'"
@click="selectConversation(conv.id)"
>
<p class="text-sm leading-snug truncate" :class="conv.id === activeId ? 'text-white' : 'text-white/90'">
{{ conv.title || 'Untitled' }}
</p>
<div class="flex items-center gap-1.5 mt-1">
<span class="text-xs text-white/30">
{{ formatTime(conv.updatedAt) }}
</span>
<span class="text-xs text-white/20">&middot;</span>
<span class="text-xs text-white/30">
{{ conv.messages.length }} msg{{ conv.messages.length !== 1 ? 's' : '' }}
</span>
</div>
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useChatStore } from '@/stores/chat'
const emit = defineEmits<{
select: [id: string]
newChat: []
}>()
const chatStore = useChatStore()
const conversations = computed(() => chatStore.conversationList)
const activeId = computed(() => chatStore.activeConversationId)
function selectConversation(id: string) {
emit('select', id)
}
function formatTime(ts: number): string {
const now = Date.now()
const diff = 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)
if (days < 7) return `${days}d ago`
return new Date(ts).toLocaleDateString([], { month: 'short', day: 'numeric' })
}
</script>
@@ -0,0 +1,447 @@
<template>
<div
class="p-3 md:p-4 relative"
@dragover.prevent="onDragOver"
@dragleave="onDragLeave"
@drop.prevent="onDrop"
>
<SearchResults
v-if="isSearchMode"
:results="searchResults"
:is-searching="isSearching"
@select="handleSearchSelect"
/>
<!-- Reply-to quote -->
<div
v-if="replyTo"
class="mb-2 flex items-start gap-2 rounded-xl bg-white/5 border border-white/10 px-3 py-2 animate-fade-up-fast"
>
<div class="flex-1 min-w-0">
<p class="text-xs text-accent/70 font-medium mb-0.5">Replying to</p>
<p class="text-xs text-white/50 truncate">{{ replyTo.excerpt }}</p>
</div>
<button
class="shrink-0 touch-target rounded-md hover:bg-white/10 transition-colors text-white/40 hover:text-white/70"
aria-label="Cancel reply"
@click="$emit('clearReply')"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Image thumbnails -->
<div v-if="images.length > 0" class="mb-2 flex gap-2 flex-wrap animate-fade-up-fast">
<div
v-for="(img, i) in images"
:key="i"
class="relative group w-16 h-16 rounded-lg overflow-hidden border border-white/10 bg-white/5"
>
<img
:src="`data:${img.mediaType};base64,${img.data}`"
:alt="`Attached image ${i + 1}`"
class="w-full h-full object-cover"
/>
<button
class="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Remove image"
@click="removeImage(i)"
>
<svg class="w-4 h-4 text-white/80" 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>
<span v-if="images.length >= MAX_IMAGES" class="self-center text-xs text-white/30">
Max {{ MAX_IMAGES }} images
</span>
</div>
<!-- Prompt palette -->
<PromptPalette
ref="paletteRef"
:query="paletteQuery"
:is-open="isPaletteMode"
@select="handlePaletteSelect"
@close="closePalette"
/>
<!-- Drag overlay -->
<div
v-if="isDragging"
class="absolute inset-0 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-accent/50 bg-accent/5 backdrop-blur-sm pointer-events-none"
>
<p class="text-sm text-accent/80 font-medium">Drop image here</p>
</div>
<div
class="rounded-2xl px-4 py-3 flex items-center gap-2 transition-all duration-300"
:class="[
isCodeMode
? 'bg-accent/15 border border-accent/25 backdrop-blur-xl'
: 'path-glass-bubble',
focused ? (isCodeMode ? 'border-accent/40' : 'border-white/30') : '',
]"
>
<!-- Image attach button -->
<button
v-if="!streaming && images.length < MAX_IMAGES"
class="shrink-0 min-w-[44px] min-h-[44px] -my-2 flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/10 transition-all"
aria-label="Attach image"
@click="openFilePicker"
>
<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="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>
</button>
<textarea
ref="textareaRef"
v-model="text"
rows="1"
:placeholder="placeholder"
class="flex-1 resize-none bg-transparent text-base outline-none min-h-[24px] max-h-[120px] text-white/90 placeholder:text-white/25"
@keydown="handleKeydown"
@input="autoResize"
@paste="onPaste"
@focus="onInputFocus"
@blur="focused = false"
/>
<button
v-if="streaming"
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200 hover:opacity-80 active:scale-95"
aria-label="Stop generation"
@click="$emit('stop')"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
</button>
<template v-else>
<!-- Extract/contextualize button shown after paste -->
<button
v-if="hasPasted && canSend"
:disabled="!canSend"
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200 hover:opacity-80 active:scale-95 text-accent/80"
aria-label="Extract content"
title="Contextualize — extract media without sending to AI"
@click="extract"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</button>
<!-- Send button -->
<button
:disabled="!canSend"
class="shrink-0 rounded-xl px-3 transition-all duration-200"
:class="[
isCodeMode ? 'bg-accent/80 text-white' : 'path-glass-button path-glass-button-sm',
canSend
? 'hover:opacity-80 active:scale-95'
: 'opacity-30 cursor-not-allowed',
]"
:style="isCodeMode ? 'height: 32px' : ''"
aria-label="Send message"
@click="send"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
</button>
</template>
</div>
<!-- Hidden file input -->
<input
ref="fileInputRef"
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
multiple
class="hidden"
@change="onFileSelect"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
import { archyBridge } from '../../services/archyBridge'
import { useFederatedSearch, type SearchResult } from '@/composables/useFederatedSearch'
import SearchResults from '@/components/ui/SearchResults.vue'
import PromptPalette from './PromptPalette.vue'
import type { ImageAttachment } from '@aiui/core/types/message'
const MAX_IMAGES = 4
const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const props = withDefaults(
defineProps<{
disabled?: boolean
streaming?: boolean
placeholder?: string
activeTab?: string
replyTo?: { messageId: string; excerpt: string } | null
}>(),
{
disabled: false,
streaming: false,
placeholder: 'Message AIUI...',
activeTab: '',
replyTo: null,
}
)
const isCodeMode = computed(() => props.activeTab === 'code')
const emit = defineEmits<{
send: [text: string, images: ImageAttachment[]]
extract: [text: string]
stop: []
clearReply: []
}>()
const text = ref('')
const focused = ref(false)
const hasPasted = ref(false)
const isDragging = ref(false)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const images = ref<ImageAttachment[]>([])
const paletteRef = ref<InstanceType<typeof PromptPalette> | null>(null)
// Archy's ⌘K search can hand us the text the operator typed there
// ("Talk to AIUI about it"). We prefill and focus rather than auto-sending:
// the operator gets to see and amend the question before it costs a model
// call. Anything already half-typed here wins — their own draft is not
// clobbered by a background handoff.
let releasePrefill: (() => void) | null = null
onMounted(() => {
releasePrefill = archyBridge.onPrefill((incoming: string) => {
if (text.value.trim()) return
text.value = incoming
nextTick(() => {
autoResize()
textareaRef.value?.focus()
// Caret to the end so they can keep typing straight away.
const el = textareaRef.value
if (el) el.setSelectionRange(el.value.length, el.value.length)
})
})
})
onBeforeUnmount(() => {
releasePrefill?.()
releasePrefill = null
})
// Prompt palette: opens when text starts with "/" and has no space yet
const isPaletteMode = computed(() => {
const t = text.value.trimStart()
return t === '/' || (t.startsWith('/') && !t.includes(' '))
})
const paletteQuery = computed(() => {
if (!isPaletteMode.value) return ''
return text.value.trimStart().slice(1) // strip leading /
})
function handlePaletteSelect(templateText: string) {
// Slash commands — send immediately (except /search which needs query input)
if (templateText.startsWith('/') && !templateText.includes('{{')) {
text.value = templateText
if (templateText.trimEnd() === '/search') {
// /search needs a query — set text and let user type
nextTick(() => {
autoResize()
textareaRef.value?.focus()
})
return
}
nextTick(() => send())
return
}
text.value = templateText
nextTick(() => {
autoResize()
textareaRef.value?.focus()
})
}
function closePalette() {
// Add a space to exit palette mode
if (text.value.trimStart() === '/') {
text.value = ''
}
}
function onInputFocus() {
focused.value = true
nextTick(() => {
textareaRef.value?.scrollIntoView({ block: 'end', behavior: 'smooth' })
})
}
function handleKeydown(e: KeyboardEvent) {
if (isPaletteMode.value && !paletteRef.value?.hasSelectedTemplate) {
if (e.key === 'ArrowUp') {
e.preventDefault()
paletteRef.value?.navigateUp()
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
paletteRef.value?.navigateDown()
return
}
if (e.key === 'Enter') {
e.preventDefault()
paletteRef.value?.selectHighlighted()
return
}
if (e.key === 'Escape') {
e.preventDefault()
closePalette()
return
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}
const canSend = computed(() => (text.value.trim().length > 0 || images.value.length > 0) && !props.disabled)
function send() {
if (!canSend.value) return
emit('send', text.value.trim(), [...images.value])
text.value = ''
images.value = []
hasPasted.value = false
nextTick(autoResize)
}
function extract() {
if (!canSend.value) return
emit('extract', text.value.trim())
text.value = ''
hasPasted.value = false
nextTick(autoResize)
}
function onPaste(e: ClipboardEvent) {
const items = e.clipboardData?.items
if (!items) {
hasPasted.value = true
return
}
let hasImage = false
for (const item of items) {
if (ACCEPTED_TYPES.includes(item.type)) {
hasImage = true
const file = item.getAsFile()
if (file) addImageFile(file)
}
}
if (!hasImage) {
hasPasted.value = true
}
}
function onDragOver(e: DragEvent) {
if (e.dataTransfer?.types.includes('Files')) {
isDragging.value = true
}
}
function onDragLeave() {
isDragging.value = false
}
function onDrop(e: DragEvent) {
isDragging.value = false
const files = e.dataTransfer?.files
if (!files) return
for (const file of files) {
if (ACCEPTED_TYPES.includes(file.type)) {
addImageFile(file)
}
}
}
function openFilePicker() {
fileInputRef.value?.click()
}
function onFileSelect(e: Event) {
const input = e.target as HTMLInputElement
const files = input.files
if (!files) return
for (const file of files) {
if (ACCEPTED_TYPES.includes(file.type)) {
addImageFile(file)
}
}
// Reset input so same file can be re-selected
input.value = ''
}
function addImageFile(file: File) {
if (images.value.length >= MAX_IMAGES) return
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
// Strip the data:...;base64, prefix
const base64 = result.split(',')[1]
if (base64) {
images.value.push({ data: base64, mediaType: file.type })
}
}
reader.readAsDataURL(file)
}
function removeImage(index: number) {
images.value.splice(index, 1)
}
function autoResize() {
const el = textareaRef.value
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
// Federated search via /search command
const { results: searchResults, isSearching, search: doSearch, clear: clearSearch } = useFederatedSearch()
const isSearchMode = computed(() => text.value.trimStart().startsWith('/search '))
watch(text, (val) => {
if (isSearchMode.value) {
const searchQuery = val.trimStart().replace(/^\/search\s+/, '')
doSearch(searchQuery)
} else {
clearSearch()
}
})
function handleSearchSelect(result: SearchResult) {
// Insert content reference tag based on type
const tags: Record<string, string> = {
film: `[[film:${result.id}]]`,
song: `[[song:${result.id}]]`,
podcast: `[[podcast:${result.id}]]`,
}
text.value = tags[result.type] ?? result.title
clearSearch()
nextTick(() => textareaRef.value?.focus())
}
</script>
@@ -0,0 +1,759 @@
<template>
<div
class="group/msg flex animate-fade-up-fast"
:class="isUser ? 'justify-end' : 'justify-start'"
:style="{ animationDelay: `${index * 30}ms` }"
@contextmenu.prevent="openContextMenu"
>
<div class="relative max-w-[85%] md:max-w-[75%]">
<!-- Action buttons (hover on desktop, always visible on touch) -->
<div
v-if="!isEditing"
class="absolute -top-4 opacity-0 group-hover/msg:opacity-100 transition-opacity duration-200 z-10"
:class="isUser ? 'right-1' : 'left-1'"
>
<div class="flex items-center gap-2 px-1 py-0.5 rounded-lg bg-black/60 backdrop-blur-md border border-white/10 shadow-lg">
<button
v-if="isUser"
class="touch-target rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-colors"
title="Edit message"
aria-label="Edit message"
@click.stop="startEditing"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /></svg>
</button>
<button
v-if="!isUser"
class="touch-target rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-colors"
title="Regenerate response"
aria-label="Regenerate response"
@click.stop="$emit('regenerate')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" /></svg>
</button>
<button
class="touch-target rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-colors"
title="Reply"
aria-label="Reply"
@click.stop="$emit('reply', message.id, message.content)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.707 3.293a1 1 0 010 1.414L5.414 7H11a7 7 0 017 7v2a1 1 0 11-2 0v-2a5 5 0 00-5-5H5.414l2.293 2.293a1 1 0 11-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>
</button>
<button
v-if="!isUser"
class="touch-target rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-colors"
title="Branch from here"
aria-label="Branch from here"
@click.stop="$emit('branch', message.id)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5z" clip-rule="evenodd" /></svg>
</button>
<div v-if="!isUser" class="w-px h-4 bg-white/10 mx-0.5" />
<button
v-if="!isUser"
class="touch-target rounded-md transition-colors"
:class="message.feedback === 'up' ? 'text-green-400' : 'text-white/50 hover:text-green-400/80 hover:bg-white/10'"
title="Good response"
aria-label="Good response"
@click.stop="toggleFeedback('up')"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20"><path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.56 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z" /></svg>
</button>
<button
v-if="!isUser"
class="touch-target rounded-md transition-colors"
:class="message.feedback === 'down' ? 'text-red-400' : 'text-white/50 hover:text-red-400/80 hover:bg-white/10'"
title="Poor response"
aria-label="Poor response"
@click.stop="toggleFeedback('down')"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20"><path d="M18 9.5a1.5 1.5 0 11-3 0v-6a1.5 1.5 0 013 0v6zM14 9.667v-5.43a2 2 0 00-1.106-1.79l-.05-.025A4 4 0 0011.057 2H5.64a2 2 0 00-1.962 1.608l-1.2 6A2 2 0 004.44 12H8v4a2 2 0 002 2 1 1 0 001-1v-.667a4 4 0 01.8-2.4l1.4-1.866a4 4 0 00.8-2.4z" /></svg>
</button>
</div>
</div>
<div
class="rounded-2xl px-4 py-3 transition-all duration-300"
:class="[bubbleClasses, { 'cursor-pointer': hasContext }]"
@click="handleBubbleClick"
>
<!-- Edit mode -->
<div v-if="isEditing" class="space-y-2" @click.stop>
<textarea
ref="editTextareaRef"
v-model="editContent"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white/90 resize-none focus:outline-none focus:border-accent/50"
rows="3"
@keydown.enter.exact="submitEdit"
@keydown.escape="cancelEdit"
/>
<div class="flex gap-2 justify-end">
<button
class="glass-button-sm min-h-[44px] md:min-h-0 md:!h-7 text-xs text-white/60 hover:text-white/90 px-3"
@click="cancelEdit"
>Cancel</button>
<button
class="glass-button-sm min-h-[44px] md:min-h-0 md:!h-7 text-xs bg-accent/20 text-accent hover:bg-accent/30 px-3"
@click="submitEdit"
>Save & Resend</button>
</div>
</div>
<!-- Normal display -->
<template v-else>
<!-- Attached images -->
<div v-if="message.images && message.images.length > 0" class="flex gap-2 flex-wrap mb-2">
<img
v-for="(img, i) in message.images"
:key="i"
:src="`data:${img.mediaType};base64,${img.data}`"
:alt="`Attached image ${i + 1}`"
class="rounded-lg max-w-[200px] max-h-[200px] object-cover border border-white/10"
/>
</div>
<div
v-if="!isUser"
class="chat-markdown text-sm leading-relaxed break-words text-white/90"
v-html="renderedMarkdown"
/>
<p
v-else-if="message.content"
class="text-sm leading-relaxed whitespace-pre-wrap break-words text-white/90"
>{{ displayText }}</p>
</template>
<div v-if="inlineFilms.length > 0" class="mt-3 space-y-1" @click.stop>
<FilmCard
v-for="film in inlineFilms"
:key="film.id"
:film="film"
@select="handleFilmSelect"
/>
</div>
<div v-if="inlineBooks.length > 0" class="mt-3 space-y-1" @click.stop>
<BookCard
v-for="book in inlineBooks"
:key="book.id"
:book="book"
@select="handleBookSelect"
/>
</div>
<div v-if="inlineTVSeries.length > 0" class="mt-3 space-y-1" @click.stop>
<TVSeriesCard
v-for="series in inlineTVSeries"
:key="series.id"
:series="series"
@select="handleTVSeriesSelect"
/>
</div>
<div v-if="inlineSongs.length > 0" class="mt-3 space-y-1" @click.stop>
<SongCard
v-for="song in inlineSongs"
:key="song.id"
:song="song"
@select="handleSongSelect"
/>
</div>
<div v-if="inlinePodcasts.length > 0" class="mt-3 space-y-1" @click.stop>
<PodcastCard
v-for="podcast in inlinePodcasts"
:key="podcast.id"
:podcast="podcast"
@select="handlePodcastSelect"
/>
</div>
<div v-if="inlinePlaces.length > 0" class="mt-3 space-y-1" @click.stop>
<PlaceCard
v-for="place in inlinePlaces"
:key="place.id"
:place="place"
@select="handlePlaceSelect"
/>
</div>
<div v-if="nostrUris.length > 0" class="mt-3 space-y-1.5" @click.stop>
<NostrEmbed
v-for="uri in nostrUris"
:key="uri"
:uri="uri"
/>
</div>
<div v-if="cashuTokens.length > 0" class="mt-3 space-y-1.5" @click.stop>
<CashuToken
v-for="token in cashuTokens"
:key="token"
:token="token"
/>
</div>
<div v-if="inlineRecipes.length > 0" class="mt-3 space-y-2" @click.stop>
<RecipeCard
v-for="(recipe, i) in inlineRecipes"
:key="`recipe-${i}`"
:recipe="recipe"
/>
</div>
<!-- Timeline view for 3+ events -->
<div v-if="inlineEvents.length >= 3" class="mt-3" @click.stop>
<TimelineRenderer :events="inlineEvents" />
</div>
<!-- Individual event cards for 1-2 events -->
<div v-else-if="inlineEvents.length > 0" class="mt-3 space-y-2" @click.stop>
<EventCard
v-for="(event, i) in inlineEvents"
:key="`event-${i}`"
:event="event"
/>
</div>
<div v-if="inlineTables.length > 0" class="mt-3 space-y-2" @click.stop>
<InteractiveTable
v-for="(table, i) in inlineTables"
:key="`table-${i}`"
:headers="table.headers"
:rows="table.rows"
/>
</div>
<div v-if="runnableCodeBlocks.length > 0" class="mt-3 space-y-2" @click.stop>
<CodeRunner
v-for="(block, i) in runnableCodeBlocks"
:key="`code-${i}`"
:code="block.code"
:language="block.language"
/>
</div>
<div v-if="bitcoinAddresses.length > 0" class="mt-3 space-y-2" @click.stop>
<BitcoinAddressCard
v-for="(addr, i) in bitcoinAddresses"
:key="`btc-${i}`"
:address="addr.address"
/>
</div>
<div v-if="bolt11Invoices.length > 0" class="mt-3 space-y-2" @click.stop>
<Bolt11InvoiceCard
v-for="(inv, i) in bolt11Invoices"
:key="`bolt11-${i}`"
:invoice="inv.invoice"
/>
</div>
<div v-if="bolt12Offers.length > 0" class="mt-3 space-y-2" @click.stop>
<Bolt12OfferCard
v-for="(offer, i) in bolt12Offers"
:key="`bolt12-${i}`"
:offer="offer.offer"
/>
</div>
<div v-if="detectedTxIds.length > 0" class="mt-3 space-y-2" @click.stop>
<MempoolTxCard
v-for="(tx, i) in detectedTxIds"
:key="`tx-${i}`"
:txid="tx.txid"
/>
</div>
<div v-if="inlineNewsLinks.length > 0" class="mt-3 space-y-1" @click.stop>
<NewsCard
v-for="(link, i) in inlineNewsLinks"
:key="i"
:article="link"
@select-article="handleArticleSelect"
/>
</div>
<div v-if="inlineWebsitesLinks.length > 0" class="mt-3 space-y-1" @click.stop>
<NewsCard
v-for="(link, i) in inlineWebsitesLinks"
:key="`web-${i}`"
:article="link"
@select-article="handleWebsiteSelect"
/>
</div>
<div v-if="!isEditing" class="flex items-center gap-2 mt-1.5">
<span class="text-xs select-none text-white/30">{{ formattedTime }}</span>
<span v-if="message.editedAt" class="text-xs text-white/25 select-none">(edited)</span>
<span v-if="showTokenCount" class="text-xs text-white/20 select-none" :title="`~${estimatedTokens} tokens`">{{ tokenLabel }}</span>
<span v-if="message.feedback" class="text-xs select-none">{{ message.feedback === 'up' ? '👍' : '👎' }}</span>
<button
v-if="inlineFilms.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineFilms.length }} films
</button>
<button
v-else-if="inlineBooks.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineBooks.length }} books
</button>
<button
v-else-if="inlineTVSeries.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineTVSeries.length }} series
</button>
<button
v-else-if="inlineImages.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineImages.length }} images
</button>
<button
v-else-if="inlinePlaces.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlinePlaces.length }} places
</button>
<button
v-if="inlinePlaces.length > 0 && inlinePlaces.some(p => p.lat != null)"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openMapView(inlinePlaces)"
>
View on map
</button>
<button
v-else-if="inlineSongs.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineSongs.length }} songs
</button>
<button
v-else-if="inlinePodcasts.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlinePodcasts.length }} podcasts
</button>
<button
v-else-if="inlineNewsLinks.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineNewsLinks.length }} articles
</button>
<button
v-else-if="inlineWebsitesLinks.length > 1"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineWebsitesLinks.length }} websites
</button>
<button
v-else-if="inlineMagazineSections.length > 0"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View brief
</button>
<button
v-if="isLongForm"
class="text-xs text-accent/70 hover:text-accent transition-colors"
@click.stop="openLongFormArticle(message.content)"
>
Read as article
</button>
</div>
</div>
</div>
<!-- Context menu -->
<ContextMenu ref="contextMenuRef">
<ContextMenuItem v-if="isUser" @click="startEditingFromMenu">Edit</ContextMenuItem>
<ContextMenuItem v-if="!isUser" @click="emitRegenerate">Regenerate</ContextMenuItem>
<ContextMenuItem @click="emitReply">Reply</ContextMenuItem>
<ContextMenuItem v-if="!isUser" @click="emitBranch">Branch from here</ContextMenuItem>
<ContextMenuItem @click="copyContent">Copy</ContextMenuItem>
</ContextMenu>
</div>
</template>
<script setup lang="ts">
import { computed, ref, nextTick, watch } from 'vue'
import MarkdownIt from 'markdown-it'
import { hasMath, renderMathInHtml } from '@/composables/useMathRenderer'
import { hasMermaid, renderMermaidBlocks } from '@/composables/useMermaidRenderer'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ContextMenuItem from '@/components/ui/ContextMenuItem.vue'
import type { Message, WebSearchResult } from '@aiui/core/types/message'
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
import { useContentPanel, type MagazineSection } from '@/composables/useContentPanel'
import { useCodeContext } from '@/composables/useCodeContext'
import FilmCard from '@/components/content/FilmCard.vue'
import BookCard from '@/components/content/BookCard.vue'
import TVSeriesCard from '@/components/content/TVSeriesCard.vue'
import SongCard from '@/components/content/SongCard.vue'
import PodcastCard from '@/components/content/PodcastCard.vue'
import PlaceCard from '@/components/content/PlaceCard.vue'
import NewsCard from '@/components/content/NewsCard.vue'
import NostrEmbed from '@/components/chat/NostrEmbed.vue'
import CashuToken from '@/components/chat/CashuToken.vue'
import { extractCashuTokens } from '@/utils/cashu'
import { extractRecipes, extractEvents } from '@/composables/contentExtraction'
import RecipeCard from '@/components/renderers/RecipeCard.vue'
import EventCard from '@/components/renderers/EventCard.vue'
import InteractiveTable from '@/components/renderers/InteractiveTable.vue'
import TimelineRenderer from '@/components/renderers/TimelineRenderer.vue'
import CodeRunner from '@/components/renderers/CodeRunner.vue'
import BitcoinAddressCard from '@/components/renderers/BitcoinAddressCard.vue'
import Bolt11InvoiceCard from '@/components/renderers/Bolt11InvoiceCard.vue'
import Bolt12OfferCard from '@/components/renderers/Bolt12OfferCard.vue'
import MempoolTxCard from '@/components/renderers/MempoolTxCard.vue'
import { detectBitcoinAddresses, detectBolt11Invoices, detectBolt12Offers, detectTxIds } from '@/composables/useBitcoinDetector'
import { extractTables } from '@/composables/useTableExtractor'
import { extractRunnableCodeBlocks } from '@/composables/useCodeBlockExtractor'
const props = withDefaults(
defineProps<{
message: Message
index: number
triggeringQuery?: string
}>(),
{ triggeringQuery: '' }
)
const emit = defineEmits<{
edit: [messageId: string, newContent: string]
regenerate: []
branch: [messageId: string]
reply: [messageId: string, content: string]
feedback: [messageId: string, value: 'up' | 'down' | undefined]
}>()
function toggleFeedback(value: 'up' | 'down') {
const newValue = props.message.feedback === value ? undefined : value
emit('feedback', props.message.id, newValue)
}
// Editing state
const isEditing = ref(false)
const editContent = ref('')
const editTextareaRef = ref<HTMLTextAreaElement | null>(null)
function startEditing() {
editContent.value = props.message.content
isEditing.value = true
nextTick(() => {
editTextareaRef.value?.focus()
// Auto-size textarea
if (editTextareaRef.value) {
editTextareaRef.value.style.height = 'auto'
editTextareaRef.value.style.height = editTextareaRef.value.scrollHeight + 'px'
}
})
}
function cancelEdit() {
isEditing.value = false
editContent.value = ''
}
function submitEdit() {
const trimmed = editContent.value.trim()
if (!trimmed) return
emit('edit', props.message.id, trimmed)
isEditing.value = false
editContent.value = ''
}
const { getContextualInlineContent, stripContentTags, stripMarkdownLinks, updatePanelFromText, panelOpen, availableTabs, setActiveTab, openFilmDetail, openBookDetail, openTVSeriesDetail, openImageDetail, openPlaceDetail, openSongDetail, openPodcastDetail, openArticleDetail, openWebsiteDetail, openLongFormArticle, openMapView, closeFilmDetail, closeBookDetail, closeTVSeriesDetail, closeImageDetail, closePlaceDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
const codeContext = useCodeContext()
const isUser = computed(() => props.message.role === 'user')
const inlineContent = computed(() => {
if (isUser.value) return { films: [] as Film[], books: [] as Book[], tvSeries: [] as TVSeries[], images: [] as ImageItem[], places: [] as Place[], songs: [] as Song[], podcasts: [] as Podcast[], newsLinks: [] as WebSearchResult[], websitesLinks: [] as WebSearchResult[], magazineSections: [] as MagazineSection[] }
return getContextualInlineContent(props.message.content, props.triggeringQuery, props.message.webResults ?? [])
})
const bubbleClasses = computed(() =>
isUser.value
? 'path-glass-bubble-user rounded-br-md rounded-2xl'
: 'path-glass-bubble rounded-bl-md rounded-2xl'
)
const inlineFilms = computed(() => inlineContent.value.films)
const inlineBooks = computed(() => inlineContent.value.books ?? [])
const inlineTVSeries = computed(() => inlineContent.value.tvSeries ?? [])
const inlineImages = computed(() => inlineContent.value.images ?? [])
const inlinePlaces = computed(() => inlineContent.value.places ?? [])
const inlineSongs = computed(() => inlineContent.value.songs)
const inlinePodcasts = computed(() => inlineContent.value.podcasts)
const inlineNewsLinks = computed(() => inlineContent.value.newsLinks ?? [])
const inlineWebsitesLinks = computed(() => inlineContent.value.websitesLinks ?? [])
const inlineMagazineSections = computed(() => inlineContent.value.magazineSections ?? [])
const NOSTR_URI_RE = /nostr:(note1[a-z0-9]{58}|npub1[a-z0-9]{58}|nevent1[a-z0-9]+|nprofile1[a-z0-9]+)/g
const nostrUris = computed(() => {
if (isUser.value) return []
const matches = props.message.content.match(NOSTR_URI_RE)
return matches ? [...new Set(matches)] : []
})
const cashuTokens = computed(() => {
if (isUser.value) return []
return extractCashuTokens(props.message.content)
})
const inlineRecipes = computed(() => {
if (isUser.value) return []
return extractRecipes(props.message.content)
})
const inlineTables = computed(() => {
if (isUser.value) return []
return extractTables(props.message.content)
})
const inlineEvents = computed(() => {
if (isUser.value) return []
return extractEvents(props.message.content)
})
const runnableCodeBlocks = computed(() => {
if (isUser.value) return []
return extractRunnableCodeBlocks(props.message.content)
})
const bitcoinAddresses = computed(() => {
if (isUser.value) return []
return detectBitcoinAddresses(props.message.content)
})
const bolt11Invoices = computed(() => {
if (isUser.value) return []
return detectBolt11Invoices(props.message.content)
})
const bolt12Offers = computed(() => {
if (isUser.value) return []
return detectBolt12Offers(props.message.content)
})
const detectedTxIds = computed(() => {
if (isUser.value) return []
return detectTxIds(props.message.content)
})
const isCodeResponse = computed(() =>
!isUser.value && props.triggeringQuery.trim().toLowerCase() === '/code'
)
const hasContext = computed(() => !isUser.value && (
isCodeResponse.value ||
inlineFilms.value.length > 0 ||
inlineBooks.value.length > 0 ||
inlineTVSeries.value.length > 0 ||
inlineImages.value.length > 0 ||
inlineSongs.value.length > 0 ||
inlinePodcasts.value.length > 0 ||
inlineNewsLinks.value.length > 0 ||
inlineWebsitesLinks.value.length > 0 ||
inlineMagazineSections.value.length > 0
))
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
})
// Open links in new tab
const defaultRender = md.renderer.rules.link_open || function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
tokens[idx].attrSet('target', '_blank')
tokens[idx].attrSet('rel', 'noopener noreferrer')
return defaultRender(tokens, idx, options, env, self)
}
const displayText = computed(() => {
if (isUser.value) return props.message.content
let text = stripContentTags(props.message.content)
if (inlineNewsLinks.value.length > 0 || inlineWebsitesLinks.value.length > 0) text = stripMarkdownLinks(text)
if (nostrUris.value.length > 0) text = text.replace(NOSTR_URI_RE, '').replace(/\n{3,}/g, '\n\n').trim()
if (cashuTokens.value.length > 0) {
for (const token of cashuTokens.value) {
text = text.replace(token, '')
}
text = text.replace(/\n{3,}/g, '\n\n').trim()
}
return text
})
const baseMarkdown = computed(() => md.render(displayText.value))
const renderedMarkdown = ref('')
// Render math/mermaid after markdown, batch on content change
watch(baseMarkdown, async (html) => {
let result = html
renderedMarkdown.value = html // Show immediately
if (hasMath(displayText.value)) {
result = await renderMathInHtml(result)
}
if (hasMermaid(displayText.value)) {
result = await renderMermaidBlocks(result)
}
renderedMarkdown.value = result
}, { immediate: true })
const formattedTime = computed(() => {
const d = new Date(props.message.timestamp)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
})
// Long-form article detection (>800 words + has headings)
const isLongForm = computed(() => {
if (isUser.value) return false
const text = props.message.content
const words = text.split(/\s+/).length
const hasHeadings = /^#{2,3}\s+.+$/m.test(text)
return words > 800 && hasHeadings
})
// Token estimate (~4 chars per token)
const estimatedTokens = computed(() => Math.ceil(props.message.content.length / 4))
const showTokenCount = computed(() => props.message.content.length > 20)
const tokenLabel = computed(() => {
const t = estimatedTokens.value
if (t >= 1000) return `${(t / 1000).toFixed(1)}k tok`
return `${t} tok`
})
function openPanelWithContext() {
updatePanelFromText(props.message.content, props.triggeringQuery, props.message.webResults ?? [])
}
function handleFilmSelect(film: Film) {
openPanelWithContext()
closeSongDetail()
closePodcastDetail()
openFilmDetail(film)
}
function handleBookSelect(book: Book) {
openPanelWithContext()
closeFilmDetail()
closeTVSeriesDetail()
closeSongDetail()
closePodcastDetail()
openBookDetail(book)
}
function handleTVSeriesSelect(series: TVSeries) {
openPanelWithContext()
closeFilmDetail()
closeBookDetail()
closeSongDetail()
closePodcastDetail()
openTVSeriesDetail(series)
}
function handleSongSelect(song: Song) {
openPanelWithContext()
closeFilmDetail()
closeBookDetail()
closePodcastDetail()
openSongDetail(song)
}
function handlePlaceSelect(place: Place) {
openPanelWithContext()
closeFilmDetail()
closeBookDetail()
closePlaceDetail()
openPlaceDetail(place)
}
function handlePodcastSelect(podcast: Podcast) {
openPanelWithContext()
closeFilmDetail()
closeSongDetail()
openPodcastDetail(podcast)
}
function openPanel() {
closeFilmDetail()
closeBookDetail()
closeTVSeriesDetail()
closeSongDetail()
closePodcastDetail()
openPanelWithContext()
}
function handleArticleSelect(article: WebSearchResult) {
openPanelWithContext()
openArticleDetail(article)
}
function handleWebsiteSelect(article: WebSearchResult) {
openPanelWithContext()
openWebsiteDetail(article)
}
function activateCodeMode() {
codeContext.enterCodeMode()
panelOpen.value = true
if (!availableTabs.value.includes('code')) {
availableTabs.value = [...availableTabs.value, 'code']
}
setActiveTab('code')
}
function handleBubbleClick() {
if (isCodeResponse.value) {
activateCodeMode()
return
}
if (hasContext.value) openPanel()
}
// Context menu
const contextMenuRef = ref<InstanceType<typeof ContextMenu> | null>(null)
function openContextMenu(e: MouseEvent) {
contextMenuRef.value?.open(e.clientX, e.clientY)
}
function startEditingFromMenu() {
contextMenuRef.value?.close()
startEditing()
}
function emitRegenerate() {
contextMenuRef.value?.close()
emit('regenerate')
}
function emitReply() {
contextMenuRef.value?.close()
emit('reply', props.message.id, props.message.content)
}
function emitBranch() {
contextMenuRef.value?.close()
emit('branch', props.message.id)
}
function copyContent() {
contextMenuRef.value?.close()
navigator.clipboard.writeText(props.message.content).catch(() => {})
}
</script>
@@ -0,0 +1,137 @@
<template>
<div
v-if="isOpen"
class="glass px-3 py-2 mx-3 mb-1 rounded-xl flex items-center gap-2 animate-fade-up-fast"
>
<svg class="w-4 h-4 text-white/40 shrink-0" 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>
<input
ref="inputRef"
v-model="query"
type="text"
placeholder="Search messages..."
class="flex-1 bg-transparent text-base text-white/90 placeholder:text-white/25 outline-none min-w-0"
@keydown.enter.exact="nextMatch"
@keydown.shift.enter="prevMatch"
@keydown.escape="close"
@keydown.up.prevent="prevMatch"
@keydown.down.prevent="nextMatch"
/>
<span v-if="query" class="text-xs text-white/40 whitespace-nowrap select-none">
{{ matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : 'No results' }}
</span>
<button
class="touch-target rounded-md hover:bg-white/10 transition-colors text-white/50 hover:text-white/80 disabled:opacity-30"
:disabled="matchCount === 0"
aria-label="Previous match"
@click="prevMatch"
>
<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="M5 15l7-7 7 7" />
</svg>
</button>
<button
class="touch-target rounded-md hover:bg-white/10 transition-colors text-white/50 hover:text-white/80 disabled:opacity-30"
:disabled="matchCount === 0"
aria-label="Next match"
@click="nextMatch"
>
<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="M19 9l-7 7-7-7" />
</svg>
</button>
<button
class="touch-target rounded-md hover:bg-white/10 transition-colors text-white/50 hover:text-white/80"
aria-label="Close search"
@click="close"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import type { Message } from '@aiui/core/types/message'
const props = defineProps<{
messages: Message[]
}>()
const emit = defineEmits<{
scrollToMessage: [index: number]
}>()
const isOpen = ref(false)
const query = ref('')
const currentMatchIndex = ref(0)
const inputRef = ref<HTMLInputElement | null>(null)
const matchingIndices = computed(() => {
if (!query.value.trim()) return []
const q = query.value.toLowerCase()
const indices: number[] = []
for (let i = 0; i < props.messages.length; i++) {
if (props.messages[i].content.toLowerCase().includes(q)) {
indices.push(i)
}
}
return indices
})
const matchCount = computed(() => matchingIndices.value.length)
watch(query, () => {
currentMatchIndex.value = 0
if (matchingIndices.value.length > 0) {
emit('scrollToMessage', matchingIndices.value[0])
}
})
function nextMatch() {
if (matchCount.value === 0) return
currentMatchIndex.value = (currentMatchIndex.value + 1) % matchCount.value
emit('scrollToMessage', matchingIndices.value[currentMatchIndex.value])
}
function prevMatch() {
if (matchCount.value === 0) return
currentMatchIndex.value = (currentMatchIndex.value - 1 + matchCount.value) % matchCount.value
emit('scrollToMessage', matchingIndices.value[currentMatchIndex.value])
}
function open() {
isOpen.value = true
nextTick(() => inputRef.value?.focus())
}
function close() {
isOpen.value = false
query.value = ''
currentMatchIndex.value = 0
}
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
e.preventDefault()
if (isOpen.value) {
inputRef.value?.focus()
} else {
open()
}
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
defineExpose({ open, close, isOpen, matchingIndices, currentMatchIndex })
</script>
@@ -0,0 +1,540 @@
<template>
<div class="flex flex-col h-full rounded-2xl overflow-visible transition-all duration-300">
<ChatHeader
:title="title"
:conversation-id="displayId"
:side="side"
:show-close="showClose"
@switch-side="$emit('switchSide')"
@new-chat="handleNewChat"
@close="$emit('close')"
@open-settings="showSettings = true"
/>
<BranchSwitcher />
<ChatSearch
ref="chatSearchRef"
:messages="messages"
@scroll-to-message="scrollToMessageIndex"
/>
<ContextBar :messages="messages" :active-model="activeModel" />
<PersonaSelector />
<SettingsModal v-model:open="showSettings" />
<!-- History: full conversation list -->
<ChatHistory
v-if="showHistory"
@select="handleHistorySelect"
@new-chat="handleNewChat"
/>
<!-- Collapsed: prompt index -->
<template v-else-if="chatCollapsed">
<PromptIndex
:messages="messages"
@select="handlePromptSelect"
/>
<div v-if="isStreaming" class="px-4 pb-3">
<StreamingDots />
</div>
</template>
<!-- Expanded: full message list (virtualized) -->
<div
v-else
ref="messageListRef"
class="relative z-0 flex-1 min-h-0 overflow-y-auto scrollbar-hide"
>
<div v-if="messages.length === 0" class="flex items-center justify-center h-full p-4">
<div class="text-center space-y-4 animate-fade-up">
<div class="empty-state-icon w-16 h-16 rounded-2xl path-glass-icon flex items-center justify-center mx-auto overflow-hidden">
<span class="text-2xl text-[#fafafa]"></span>
</div>
<p class="text-sm text-white/30">
Start a conversation
</p>
<router-link
to="/guide"
class="inline-flex items-center gap-1.5 text-xs text-white/25 hover:text-white/50 transition-colors"
>
<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="1.5" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
AIUI Guide
</router-link>
</div>
</div>
<div
v-else
:style="{ height: `${virtualizer.getTotalSize()}px`, width: '100%', position: 'relative' }"
>
<div
v-for="virtualRow in virtualizer.getVirtualItems()"
:key="messages[virtualRow.index].id"
:ref="(el) => el && virtualizer.measureElement(el as Element)"
:data-index="virtualRow.index"
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}"
:class="['px-4 py-2.5', virtualRow.index === 0 ? 'pt-6' : '']"
>
<ErrorBoundary title="Message failed to render">
<ChatMessage
:message="messages[virtualRow.index]"
:index="virtualRow.index"
:triggering-query="getTriggeringQuery(messages, virtualRow.index)"
@edit="handleEdit"
@regenerate="handleRegenerate"
@branch="handleBranch"
@reply="handleReply"
@feedback="handleFeedback"
/>
</ErrorBoundary>
</div>
</div>
<div v-if="isStreaming && lastMessageEmpty" class="px-4 pb-4">
<StreamingDots />
</div>
</div>
<!-- Comparison mode split view -->
<ComparisonView
v-if="comparison.isComparing.value && (comparison.response1.value || comparison.response2.value)"
class="flex-1 min-h-0"
/>
<!-- Code mode project indicator -->
<div
v-if="codeContext.isCodeMode.value && codeContext.activeProject.value"
class="px-3 pb-1 flex items-center gap-1.5"
>
<span class="text-xs px-2 py-0.5 rounded-md bg-accent/15 text-accent font-medium truncate max-w-[200px]">
{{ codeContext.activeProject.value.name }}
</span>
<button
class="text-white/30 hover:text-white/60 transition-colors p-0.5"
@click="codeContext.exitCodeMode()"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<ChatInput
:disabled="isStreaming || comparison.isAnyStreaming.value"
:streaming="isStreaming || comparison.isAnyStreaming.value"
:placeholder="activeTab === 'code' ? 'Code...' : isStreaming ? 'Waiting for response...' : 'Message AIUI...'"
:active-tab="activeTab"
:reply-to="replyTo"
@send="handleSend"
@extract="handleExtract"
@stop="handleStop"
@clear-reply="clearReply"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useChatStore } from '@/stores/chat'
import { DEMO_CONTENT_ENABLED } from '@/utils/demoContent'
import { useAI, streamWithModel } from '@/composables/useAI'
import { useContentPanel } from '@/composables/useContentPanel'
import { useComparisonMode } from '@/composables/useComparisonMode'
import ChatHeader from './ChatHeader.vue'
import ChatMessage from './ChatMessage.vue'
import ChatInput from './ChatInput.vue'
import StreamingDots from './StreamingDots.vue'
import PromptIndex from './PromptIndex.vue'
import ChatHistory from './ChatHistory.vue'
import BranchSwitcher from './BranchSwitcher.vue'
import ChatSearch from './ChatSearch.vue'
import ContextBar from './ContextBar.vue'
import ComparisonView from './ComparisonView.vue'
import PersonaSelector from './PersonaSelector.vue'
import SettingsModal from './SettingsModal.vue'
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import type { Message, ImageAttachment } from '@aiui/core/types/message'
withDefaults(
defineProps<{
variant?: 'standalone' | 'modal'
side?: 'left' | 'right'
showClose?: boolean
}>(),
{
variant: 'standalone',
side: 'right',
showClose: false,
}
)
defineEmits<{
switchSide: []
close: []
}>()
const chatStore = useChatStore()
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel, needsApiKey } = useAI()
const { updatePanelFromText, panelOpen, panelFilms, panelTitle, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel()
import { useCodeContext } from '@/composables/useCodeContext'
import { useVisualViewport } from '@/composables/useVisualViewport'
const codeContext = useCodeContext()
import { usePersonaStore } from '@/stores/personas'
const personaStore = usePersonaStore()
const comparison = useComparisonMode()
const messageListRef = ref<HTMLElement | null>(null)
const chatSearchRef = ref<InstanceType<typeof ChatSearch> | null>(null)
const showSettings = ref(false)
// A send/regenerate/edit failure that looks like a missing or invalid API
// key (see useAI.ts's looksLikeMissingApiKey) opens Settings automatically
// instead of leaving the user stuck on a silent/dead error with no obvious
// next step. One-shot: reset immediately after acting so a later retry that
// fails the same way can re-trigger it (the user may have closed Settings
// without fixing anything).
watch(needsApiKey, (needs) => {
if (needs) {
showSettings.value = true
needsApiKey.value = false
}
})
// Scroll position memory per conversation
const scrollPositions = new Map<string, number>()
function saveScrollPosition() {
const el = messageListRef.value
const id = chatStore.activeConversationId
if (el && id) {
scrollPositions.set(id, el.scrollTop)
}
}
function restoreScrollPosition(convId: string) {
const saved = scrollPositions.get(convId)
if (saved !== undefined) {
nextTick(() => {
const el = messageListRef.value
if (el) el.scrollTop = saved
})
}
}
function scrollToMessageIndex(index: number) {
virtualizer.value.scrollToIndex(index, { align: 'center' })
}
// Reply-to threading state
const replyTo = ref<{ messageId: string; excerpt: string } | null>(null)
function handleReply(messageId: string, content: string) {
const excerpt = content.slice(0, 100) + (content.length > 100 ? '...' : '')
replyTo.value = { messageId, excerpt }
}
function clearReply() {
replyTo.value = null
}
const messages = computed(() => chatStore.messages)
const virtualizer = useVirtualizer(computed(() => ({
count: messages.value.length,
getScrollElement: () => messageListRef.value,
estimateSize: (index: number) => {
// User messages are typically shorter
return messages.value[index]?.role === 'user' ? 60 : 200
},
overscan: 5,
})))
const isStreaming = computed(() => chatStore.isStreaming)
const chatCollapsed = computed(() => chatStore.chatCollapsed)
const showHistory = computed(() => chatStore.showHistory)
const title = computed(
() => chatStore.activeConversation?.title ?? 'New Chat'
)
const displayId = computed(
() => chatStore.activeConversationId?.slice(0, 8) ?? '—'
)
const lastMessageEmpty = computed(() => {
const msgs = messages.value
if (msgs.length === 0) return true
return msgs[msgs.length - 1].content === ''
})
function getTriggeringQuery(msgs: typeof messages.value, idx: number): string {
if (msgs[idx]?.role !== 'assistant') return ''
for (let i = idx - 1; i >= 0; i--) {
if (msgs[i]?.role === 'user') return msgs[i].content ?? ''
}
return ''
}
function handleNewChat() {
chatStore.createConversation('New Chat', personaStore.defaultPersona?.id)
chatStore.showHistory = false
}
function handleHistorySelect(id: string) {
chatStore.setActiveConversation(id)
chatStore.showHistory = false
}
function handleStop() {
stopGeneration()
comparison.stopComparison()
}
async function handleEdit(messageId: string, newContent: string) {
await editAndResend(messageId, newContent)
}
async function handleRegenerate() {
await regenerateLastResponse()
}
function handleBranch(messageId: string) {
const convId = chatStore.activeConversationId
if (!convId) return
chatStore.branchFromMessage(convId, messageId)
}
function handleFeedback(messageId: string, value: 'up' | 'down' | undefined) {
const convId = chatStore.activeConversationId
if (!convId) return
chatStore.setMessageFeedback(convId, messageId, value)
}
function handleExtract(text: string) {
// Run content extraction without sending to AI
const convId = chatStore.activeConversationId ?? chatStore.createConversation()
chatStore.addMessage(convId, { role: 'user', content: `[Extract] ${text.slice(0, 80)}${text.length > 80 ? '…' : ''}` })
chatStore.addMessage(convId, { role: 'assistant', content: text })
updatePanelFromText(text, '', [])
}
async function handleSend(text: string, images: ImageAttachment[] = []) {
// Command handling
const trimmed = text.trim().toLowerCase()
if (trimmed === '/code') {
codeContext.enterCodeMode()
// Open the content panel with code tab
panelOpen.value = true
if (!availableTabs.value.includes('code')) {
availableTabs.value = [...availableTabs.value, 'code']
}
setActiveTab('code')
// Add system message to chat
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, {
role: 'user',
content: '/code',
})
chatStore.addMessage(convId, {
role: 'assistant',
content: 'Code mode activated. Select a project from the content panel to start coding.',
})
}
return
}
if (trimmed === '/seed') {
// Demo-site/dev only (S7): the seed showcase is fabricated example
// content and must not ship in the node build.
if (!DEMO_CONTENT_ENABLED) {
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, {
role: 'assistant',
content: 'The `/seed` showcase is only available in demo builds.',
})
}
return
}
await chatStore.loadSeedChats()
// loadSeedChats already switches to the seed conversation
// Collapse chat so user sees PromptIndex for quick picking
chatStore.chatCollapsed = true
chatStore.showHistory = false
return
}
if (trimmed === '/nostr') {
panelOpen.value = true
if (!availableTabs.value.includes('nostr')) {
availableTabs.value = [...availableTabs.value, 'nostr']
}
setActiveTab('nostr')
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, { role: 'user', content: '/nostr' })
chatStore.addMessage(convId, { role: 'assistant', content: 'Nostr feed opened. Browse notes, articles, and zaps from the network.' })
}
return
}
if (trimmed === '/design') {
enterDesignSystemMode()
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, { role: 'user', content: '/design' })
chatStore.addMessage(convId, { role: 'assistant', content: 'Design system viewer opened. Browse colors, typography, spacing, and components.' })
}
return
}
if (trimmed === '/freefilms') {
const { freeFilms } = await import('@/data/freeFilms')
panelFilms.value = freeFilms
panelTitle.value = 'Free Documentary Films'
panelOpen.value = true
availableTabs.value = ['film', 'prompt']
setActiveTab('film')
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, { role: 'user', content: '/freefilms' })
chatStore.addMessage(convId, { role: 'assistant', content: `Browse ${freeFilms.length} free documentary films from InDeeHub. Click any film to see details, and hit play to watch.` })
}
return
}
if (trimmed === '/code exit' || trimmed === '/exit') {
if (codeContext.isCodeMode.value) {
codeContext.exitCodeMode()
availableTabs.value = availableTabs.value.filter(t => t !== 'code')
const convId2 = chatStore.activeConversationId
if (convId2) {
chatStore.addMessage(convId2, {
role: 'assistant',
content: 'Code mode deactivated.',
})
}
return
}
}
// Prepend quote if replying to a message
let finalText = text
if (replyTo.value) {
const quoteLine = replyTo.value.excerpt.split('\n').map(l => `> ${l}`).join('\n')
finalText = `${quoteLine}\n\n${text}`
clearReply()
}
// Comparison mode: stream to both models simultaneously
if (comparison.isComparing.value) {
const convId = chatStore.activeConversationId ?? chatStore.createConversation()
chatStore.addMessage(convId, { role: 'user', content: finalText, images: images.length > 0 ? images : undefined })
const history = chatStore.messages.map(m => ({ role: m.role, content: m.content }))
await comparison.streamBothModels(streamWithModel, history)
return
}
await sendMessage(finalText, images.length > 0 ? images : undefined)
}
function handlePromptSelect(_userMsg: Message, assistantMsg: Message | null) {
const userText = _userMsg.content?.trim().toLowerCase() ?? ''
if (userText === '/code') {
codeContext.enterCodeMode()
panelOpen.value = true
if (!availableTabs.value.includes('code')) {
availableTabs.value = [...availableTabs.value, 'code']
}
setActiveTab('code')
return
}
if (assistantMsg?.content) {
updatePanelFromText(assistantMsg.content, _userMsg.content, assistantMsg.webResults ?? [])
}
}
function scrollToBottom() {
// Container scroll ONLY — deliberately not virtualizer.scrollToIndex.
//
// Doing both is what produced "Failed to scroll to index N after 10
// attempts" in the console on every send. scrollToIndex runs a retry loop
// that nudges scrollTop toward the measured offset of the target row and
// re-checks, up to 10 times, because dynamically-measured rows move the
// target as they settle. Assigning scrollTop ourselves on the next tick
// overwrote each of those nudges, so the loop never saw itself converge and
// always exhausted its attempts — the warning was the two of us fighting
// over the same scrollTop, not a real failure.
//
// For "go to the end" the virtualizer's index-settling machinery buys
// nothing: scrollHeight is already the bottom, and the virtualizer renders
// whatever window that offset implies. It also keeps working while a
// response streams and the last row keeps growing, which is exactly the
// case the old fallback was added for. scrollToMessageIndex still uses
// scrollToIndex, which is the right tool for jumping to an arbitrary row.
nextTick(() => {
const el = messageListRef.value
if (el) el.scrollTop = el.scrollHeight
})
}
// Scroll to bottom when expanding from collapsed
watch(chatCollapsed, (collapsed, wasCollapsed) => {
if (!collapsed && wasCollapsed) {
scrollToBottom()
}
})
// Scroll to bottom when mobile keyboard opens so latest messages + input stay visible
const { isKeyboardOpen } = useVisualViewport()
watch(isKeyboardOpen, (open) => {
if (open) scrollToBottom()
})
// Save/restore scroll position on conversation switch
watch(
() => chatStore.activeConversationId,
(newId, oldId) => {
if (oldId) saveScrollPosition()
if (newId) restoreScrollPosition(newId)
}
)
watch(
() => messages.value.length,
() => scrollToBottom()
)
watch(
() => {
const msgs = messages.value
const last = msgs[msgs.length - 1]
return last ? { content: last.content, webResults: last.webResults } : null
},
(val) => {
scrollToBottom()
if (val?.content) {
const msgs = messages.value
const lastMsg = msgs[msgs.length - 1]
const lastUser = [...msgs].reverse().find((m) => m.role === 'user')
// Skip panel updates for command messages (e.g. /code, /exit)
const userText = lastUser?.content?.trim() ?? ''
if (!userText.startsWith('/')) {
updatePanelFromText(val.content, userText, lastMsg?.webResults ?? [])
}
}
},
{ deep: true, immediate: true }
)
</script>
@@ -0,0 +1,79 @@
<template>
<div class="flex flex-col h-full">
<!-- Mobile tabs -->
<div class="flex md:hidden gap-2 px-3 pt-2">
<button
v-for="(tab, i) in tabs"
:key="i"
class="flex-1 text-sm min-h-[44px] rounded-lg transition-all"
:class="activeTab === i
? 'bg-accent/20 text-accent'
: 'text-white/50 hover:text-white/70'"
@click="activeTab = i"
>
{{ tab }}
</button>
</div>
<!-- Split panes -->
<div class="flex-1 min-h-0 flex gap-0.5 p-2">
<!-- Model 1 -->
<div
class="flex-1 min-w-0 flex flex-col glass rounded-xl overflow-hidden"
:class="{ 'hidden md:flex': activeTab !== 0 }"
>
<div class="px-3 py-2 flex items-center justify-between border-b border-white/5">
<span class="text-xs text-accent font-medium truncate">{{ model1Label }}</span>
<span v-if="isStreaming1" class="text-xs text-white/30 animate-pulse">streaming...</span>
</div>
<div class="flex-1 overflow-y-auto p-3">
<div
v-if="response1"
class="chat-markdown text-sm leading-relaxed break-words text-white/90"
v-html="rendered1"
/>
<div v-else-if="error1" class="text-sm text-red-400/80">{{ error1 }}</div>
<div v-else class="text-sm text-white/25 italic">Waiting for response...</div>
</div>
</div>
<!-- Model 2 -->
<div
class="flex-1 min-w-0 flex flex-col glass rounded-xl overflow-hidden"
:class="{ 'hidden md:flex': activeTab !== 1 }"
>
<div class="px-3 py-2 flex items-center justify-between border-b border-white/5">
<span class="text-xs text-accent font-medium truncate">{{ model2Label }}</span>
<span v-if="isStreaming2" class="text-xs text-white/30 animate-pulse">streaming...</span>
</div>
<div class="flex-1 overflow-y-auto p-3">
<div
v-if="response2"
class="chat-markdown text-sm leading-relaxed break-words text-white/90"
v-html="rendered2"
/>
<div v-else-if="error2" class="text-sm text-red-400/80">{{ error2 }}</div>
<div v-else class="text-sm text-white/25 italic">Waiting for response...</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import MarkdownIt from 'markdown-it'
import { useComparisonMode } from '@/composables/useComparisonMode'
const { model1, model2, response1, response2, isStreaming1, isStreaming2, error1, error2 } = useComparisonMode()
const activeTab = ref(0)
const tabs = computed(() => [model1Label.value, model2Label.value])
const model1Label = computed(() => `${model1.value.provider}/${model1.value.model}`.split('/').pop() ?? 'Model 1')
const model2Label = computed(() => `${model2.value.provider}/${model2.value.model}`.split('/').pop() ?? 'Model 2')
const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
const rendered1 = computed(() => md.render(response1.value))
const rendered2 = computed(() => md.render(response2.value))
</script>
@@ -0,0 +1,74 @@
<template>
<div
v-if="messages.length > 0"
class="h-1 mx-3 rounded-full bg-white/5 overflow-hidden shrink-0 group cursor-help relative"
:title="tooltipText"
>
<div
class="h-full rounded-full transition-all duration-500"
:class="percentage > 80 ? 'bg-red-500' : 'bg-accent'"
:style="{ width: `${Math.min(percentage, 100)}%` }"
/>
<!-- Tooltip on hover -->
<div
class="absolute -top-8 left-1/2 -translate-x-1/2 hidden group-hover:flex items-center px-2 py-1 rounded-md bg-black/80 text-xs text-white/80 whitespace-nowrap pointer-events-none z-10"
>
{{ tooltipText }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Message } from '@aiui/core/types/message'
const props = defineProps<{
messages: Message[]
contextWindow?: number
activeModel?: string
}>()
// Default context window sizes per model (tokens)
const maxTokens = computed(() => props.contextWindow ?? 200000)
// Estimate: ~4 chars per token
const estimatedTokens = computed(() => {
let chars = 0
for (const msg of props.messages) {
chars += msg.content.length
}
return Math.ceil(chars / 4)
})
const percentage = computed(() => {
if (maxTokens.value === 0) return 0
return (estimatedTokens.value / maxTokens.value) * 100
})
// Model pricing per million tokens (input/output)
const MODEL_PRICING: Record<string, { input: number; output: number }> = {
'claude-haiku-4.5': { input: 0.80, output: 4.00 },
'claude-sonnet-4': { input: 3.00, output: 15.00 },
'claude-opus-4': { input: 15.00, output: 75.00 },
}
const estimatedCost = computed(() => {
const pricing = MODEL_PRICING[props.activeModel ?? '']
if (!pricing) return null
// Rough split: 70% input, 30% output
const inputTokens = estimatedTokens.value * 0.7
const outputTokens = estimatedTokens.value * 0.3
const cost = (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000
return cost
})
const tooltipText = computed(() => {
const est = estimatedTokens.value.toLocaleString()
const max = maxTokens.value.toLocaleString()
let text = `~${est} / ${max} tokens used`
if (estimatedCost.value !== null) {
text += ` · ~$${estimatedCost.value.toFixed(4)}`
}
return text
})
</script>
@@ -0,0 +1,122 @@
<template>
<div class="px-3 md:px-4 pb-1">
<!-- Collapsed toggle -->
<button
class="flex items-center gap-1.5 text-xs text-white/40 hover:text-white/60 transition-colors"
@click="isExpanded = !isExpanded"
>
<svg
class="w-3 h-3 transition-transform duration-200"
:class="isExpanded ? 'rotate-90' : ''"
fill="currentColor"
viewBox="0 0 20 20"
>
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
Memory ({{ memoryStore.items.length }}/20)
</button>
<!-- Expanded panel -->
<div v-if="isExpanded" class="mt-2 space-y-1.5 animate-fade-up-fast">
<div
v-for="item in memoryStore.items"
:key="item.id"
class="group flex items-start gap-2 rounded-lg bg-white/5 border border-white/5 px-2.5 py-1.5"
>
<div v-if="editingId === item.id" class="flex-1 flex gap-1.5">
<input
v-model="editText"
type="text"
class="flex-1 bg-transparent text-base text-white/80 outline-none"
@keydown.enter="saveEdit(item.id)"
@keydown.escape="cancelEdit"
/>
<button
class="text-xs text-accent/70 hover:text-accent"
@click="saveEdit(item.id)"
>
Save
</button>
</div>
<template v-else>
<p class="flex-1 text-xs text-white/60 leading-relaxed">{{ item.text }}</p>
<div class="shrink-0 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded text-white/30 hover:text-white/60 hover:bg-white/10 transition-all"
title="Edit"
@click="startEdit(item)"
>
<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /></svg>
</button>
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded text-white/30 hover:text-red-400/80 hover:bg-white/10 transition-all"
title="Delete"
@click="memoryStore.deleteItem(item.id)"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</template>
</div>
<!-- Add new -->
<div v-if="!memoryStore.isFull" class="flex gap-1.5">
<input
v-model="newText"
type="text"
class="flex-1 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-base text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
placeholder="Add a memory..."
@keydown.enter="addMemory"
/>
<button
class="px-2.5 py-1.5 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-all"
:disabled="!newText.trim()"
@click="addMemory"
>
Add
</button>
</div>
<p v-else class="text-xs text-white/25">
Maximum 20 memories reached
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useMemoryStore, type MemoryItem } from '@/stores/memory'
const memoryStore = useMemoryStore()
const isExpanded = ref(false)
const newText = ref('')
const editingId = ref<string | null>(null)
const editText = ref('')
function addMemory() {
if (!newText.value.trim()) return
memoryStore.addItem(newText.value)
newText.value = ''
}
function startEdit(item: MemoryItem) {
editingId.value = item.id
editText.value = item.text
}
function saveEdit(id: string) {
if (editText.value.trim()) {
memoryStore.updateItem(id, editText.value)
}
editingId.value = null
editText.value = ''
}
function cancelEdit() {
editingId.value = null
editText.value = ''
}
</script>
@@ -0,0 +1,173 @@
<template>
<div
class="rounded-xl p-3 transition-all duration-150"
:class="isDark
? 'bg-purple-500/10 border border-purple-500/20'
: 'bg-purple-50 border border-purple-200'"
>
<!-- Loading -->
<div v-if="loading" class="flex items-center gap-2">
<div
class="w-6 h-6 rounded-full animate-pulse"
:class="isDark ? 'bg-purple-500/20' : 'bg-purple-200'"
/>
<div class="flex-1 space-y-1">
<div
class="h-3 w-24 rounded animate-pulse"
:class="isDark ? 'bg-white/10' : 'bg-gray-200'"
/>
<div
class="h-2 w-40 rounded animate-pulse"
:class="isDark ? 'bg-white/5' : 'bg-gray-100'"
/>
</div>
</div>
<!-- Error -->
<div v-else-if="error" class="flex items-center gap-2">
<span class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ isProfile ? 'Profile' : 'Note' }} not found
</span>
<span
class="text-xs font-mono truncate"
:class="isDark ? 'text-purple-400/40' : 'text-purple-400'"
>
{{ truncatedId }}
</span>
</div>
<!-- Note content -->
<div v-else-if="note">
<div class="flex items-center gap-2 mb-1.5">
<div
class="w-6 h-6 rounded-full shrink-0 flex items-center justify-center text-xs font-bold"
:class="isDark ? 'bg-purple-500/20 text-purple-400' : 'bg-purple-100 text-purple-600'"
>
{{ note.authorName?.charAt(0)?.toUpperCase() ?? '?' }}
</div>
<span
class="text-xs font-semibold truncate"
:class="isDark ? 'text-white/70' : 'text-gray-700'"
>
{{ note.authorName ?? 'anon' }}
</span>
<span
class="text-xs ml-auto shrink-0"
:class="isDark ? 'text-white/20' : 'text-gray-300'"
>
{{ formatTime(note.created_at) }}
</span>
</div>
<p
class="text-xs leading-relaxed line-clamp-4"
:class="isDark ? 'text-white/60' : 'text-gray-600'"
>
{{ note.content }}
</p>
<div class="flex items-center gap-2 mt-1.5">
<span
class="text-xs font-mono"
:class="isDark ? 'text-purple-400/40' : 'text-purple-400/60'"
>
nostr
</span>
</div>
</div>
<!-- Profile card (npub) -->
<div v-else-if="isProfile">
<div class="flex items-center gap-2">
<div
class="w-8 h-8 rounded-full shrink-0 flex items-center justify-center text-xs font-bold"
:class="isDark ? 'bg-purple-500/20 text-purple-400' : 'bg-purple-100 text-purple-600'"
>
{{ truncatedId.charAt(0).toUpperCase() }}
</div>
<div>
<span
class="text-xs font-mono block"
:class="isDark ? 'text-white/60' : 'text-gray-600'"
>
{{ truncatedId }}
</span>
<span
class="text-xs"
:class="isDark ? 'text-purple-400/40' : 'text-purple-400/60'"
>
nostr profile
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useNostr, type NostrNote } from '@/composables/useNostr'
import { decodeNIP19 } from '@/utils/bech32'
const props = defineProps<{
uri: string
}>()
const { isDark } = useTheme()
const { connect, fetchNote: fetchFromRelay } = useNostr()
const note = ref<NostrNote | null>(null)
const loading = ref(false)
const error = ref(false)
const decoded = computed(() => {
const raw = props.uri.replace(/^nostr:/, '')
return decodeNIP19(raw)
})
const isProfile = computed(() => decoded.value?.type === 'npub' || decoded.value?.type === 'nprofile')
const truncatedId = computed(() => {
const hex = decoded.value?.hex ?? ''
if (hex.length <= 12) return hex
return hex.slice(0, 8) + '...' + hex.slice(-4)
})
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`
}
onMounted(async () => {
if (!decoded.value) {
error.value = true
return
}
// For profiles, just show the card with no fetch needed
if (isProfile.value) return
// For notes/events, fetch from relay
const hexId = decoded.value.hex
if (!hexId) {
error.value = true
return
}
loading.value = true
connect()
// Small delay to allow relay connections to establish
await new Promise(r => setTimeout(r, 500))
const result = await fetchFromRelay(hexId)
loading.value = false
if (result) {
note.value = result
} else {
error.value = true
}
})
</script>
@@ -0,0 +1,248 @@
<template>
<div v-if="personaStore.personas.length > 0" class="px-3 md:px-4">
<div class="flex items-center gap-1.5 flex-wrap">
<button
v-for="p in personaStore.sortedPersonas"
:key="p.id"
class="px-2.5 py-1 rounded-full text-xs font-medium transition-all duration-200 border"
:class="isActive(p.id)
? 'bg-accent/20 text-accent border-accent/30'
: 'bg-white/5 text-white/50 border-white/10 hover:text-white/70 hover:bg-white/10'"
@click="selectPersona(p.id)"
>
<span
v-if="p.accentColor"
class="inline-block w-2 h-2 rounded-full mr-1"
:style="{ backgroundColor: p.accentColor }"
/>
{{ p.name }}
</button>
<button
class="px-2 py-1 rounded-full text-xs text-white/30 hover:text-white/60 border border-transparent hover:border-white/10 transition-all duration-200"
@click="showEditor = true"
>
+ New
</button>
<button
v-if="activePersonaId"
class="px-2 py-1 rounded-full text-xs text-white/30 hover:text-white/60 transition-all"
title="Clear persona"
@click="clearPersona"
>
</button>
</div>
<!-- Persona editor modal -->
<Teleport to="body">
<div
v-if="showEditor"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@click.self="closeEditor"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div class="relative glass-card w-full max-w-md p-5 space-y-4 animate-scale-in">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-white/90">
{{ editingPersona ? 'Edit Persona' : 'New Persona' }}
</h3>
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/10 transition-all"
@click="closeEditor"
>
<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 class="space-y-3">
<div>
<label class="block text-xs text-white/40 mb-1">Name</label>
<input
v-model="formName"
type="text"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-base text-white/90 focus:outline-none focus:border-accent/50"
placeholder="e.g. Film Critic"
/>
</div>
<div>
<label class="block text-xs text-white/40 mb-1">System Prompt</label>
<textarea
v-model="formPrompt"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-base text-white/90 resize-none focus:outline-none focus:border-accent/50"
rows="5"
placeholder="You are a film critic who..."
/>
</div>
<div class="flex gap-3">
<div class="flex-1">
<label class="block text-xs text-white/40 mb-1">Model Preference</label>
<select
v-model="formModel"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-base text-white/90 focus:outline-none focus:border-accent/50"
>
<option value="">Default</option>
<option value="claude-haiku-4.5">Claude 4.5 Haiku</option>
<option value="claude-sonnet-4">Claude Sonnet 4</option>
<option value="claude-opus-4">Claude Opus 4</option>
</select>
</div>
<div class="w-20">
<label class="block text-xs text-white/40 mb-1">Colour</label>
<input
v-model="formColor"
type="color"
class="w-full h-9 rounded-lg bg-white/5 border border-white/10 cursor-pointer"
/>
</div>
</div>
<label class="flex items-center gap-2 text-xs text-white/60">
<input
v-model="formDefault"
type="checkbox"
class="accent-[#F7931A]"
/>
Set as default for new conversations
</label>
</div>
<div class="flex items-center gap-2 pt-1">
<button
v-if="editingPersona"
class="px-3 py-1.5 rounded-lg text-xs text-red-400/80 hover:text-red-400 hover:bg-red-400/10 transition-all"
@click="handleDelete"
>
Delete
</button>
<div class="flex-1" />
<button
class="px-3 py-1.5 rounded-lg text-xs text-white/50 hover:text-white/70 hover:bg-white/10 transition-all"
@click="closeEditor"
>
Cancel
</button>
<button
class="px-4 py-1.5 rounded-lg text-xs bg-accent/20 text-accent hover:bg-accent/30 transition-all"
:disabled="!formName.trim()"
@click="handleSave"
>
{{ editingPersona ? 'Save' : 'Create' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { usePersonaStore, type Persona } from '@/stores/personas'
import { useChatStore } from '@/stores/chat'
const personaStore = usePersonaStore()
const chatStore = useChatStore()
const showEditor = ref(false)
const editingPersona = ref<Persona | null>(null)
// Form state
const formName = ref('')
const formPrompt = ref('')
const formModel = ref('')
const formColor = ref('#F7931A')
const formDefault = ref(false)
const activePersonaId = computed(() => chatStore.activeConversation?.personaId ?? null)
function isActive(id: string): boolean {
return activePersonaId.value === id
}
function selectPersona(id: string) {
const conv = chatStore.activeConversation
if (!conv) return
if (conv.personaId === id) {
// Double-click to edit
openEditorFor(personaStore.getPersona(id))
return
}
conv.personaId = id
conv.updatedAt = Date.now()
}
function clearPersona() {
const conv = chatStore.activeConversation
if (!conv) return
conv.personaId = undefined
conv.updatedAt = Date.now()
}
function openEditorFor(persona?: Persona) {
if (persona) {
editingPersona.value = persona
formName.value = persona.name
formPrompt.value = persona.systemPrompt
formModel.value = persona.modelPreference ?? ''
formColor.value = persona.accentColor ?? '#F7931A'
formDefault.value = persona.isDefault ?? false
} else {
editingPersona.value = null
formName.value = ''
formPrompt.value = ''
formModel.value = ''
formColor.value = '#F7931A'
formDefault.value = false
}
showEditor.value = true
}
function closeEditor() {
showEditor.value = false
editingPersona.value = null
}
function handleSave() {
if (!formName.value.trim()) return
const data = {
name: formName.value.trim(),
systemPrompt: formPrompt.value.trim(),
modelPreference: formModel.value || undefined,
accentColor: formColor.value,
isDefault: formDefault.value,
}
if (editingPersona.value) {
personaStore.updatePersona(editingPersona.value.id, data)
} else {
const created = personaStore.addPersona(data)
// Auto-select the new persona
const conv = chatStore.activeConversation
if (conv) {
conv.personaId = created.id
conv.updatedAt = Date.now()
}
}
closeEditor()
}
function handleDelete() {
if (!editingPersona.value) return
const id = editingPersona.value.id
// Clear from any active conversation
const conv = chatStore.activeConversation
if (conv?.personaId === id) {
conv.personaId = undefined
}
personaStore.deletePersona(id)
closeEditor()
}
</script>
@@ -0,0 +1,109 @@
<template>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hide p-3 space-y-1">
<div v-if="promptPairs.length === 0" class="flex items-center justify-center h-full">
<p class="text-xs text-white/30">
No prompts yet
</p>
</div>
<button
v-for="(pair, i) in promptPairs"
:key="pair.userMsg.id"
class="w-full text-left px-3 py-2.5 rounded-xl transition-all duration-150 group"
:class="[
activeIndex === i
? 'path-glass-bubble-user'
: 'hover:bg-white/5'
]"
@click="selectPrompt(pair, i)"
>
<p class="text-sm leading-snug truncate text-white/90">
{{ pair.userMsg.content }}
</p>
<div class="flex items-center gap-1.5 mt-1 flex-wrap">
<span class="text-xs text-white/30">
{{ formatTime(pair.userMsg.timestamp) }}
</span>
<span
v-for="badge in pair.badges"
:key="badge"
class="text-xs px-1.5 py-0.5 rounded-md bg-white/8 text-white/40"
>
{{ badge }}
</span>
</div>
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Message } from '@aiui/core/types/message'
import { useContentPanel } from '@/composables/useContentPanel'
interface PromptPair {
userMsg: Message
assistantMsg: Message | null
badges: string[]
}
const props = defineProps<{
messages: Message[]
}>()
const emit = defineEmits<{
select: [userMsg: Message, assistantMsg: Message | null]
}>()
const { getContextualInlineContent } = useContentPanel()
const activeIndex = ref<number | null>(null)
const promptPairs = computed<PromptPair[]>(() => {
const pairs: PromptPair[] = []
const msgs = props.messages
for (let i = 0; i < msgs.length; i++) {
if (msgs[i].role !== 'user') continue
const userMsg = msgs[i]
const assistantMsg = (i + 1 < msgs.length && msgs[i + 1].role === 'assistant')
? msgs[i + 1]
: null
const badges: string[] = []
if (assistantMsg && assistantMsg.content) {
const content = getContextualInlineContent(
assistantMsg.content,
userMsg.content,
assistantMsg.webResults ?? [],
)
if (content.films.length > 0) badges.push('Films')
if ((content.books?.length ?? 0) > 0) badges.push('Books')
if ((content.tvSeries?.length ?? 0) > 0) badges.push('TV')
if ((content.images?.length ?? 0) > 0) badges.push('Images')
if ((content.places?.length ?? 0) > 0) badges.push('Places')
if (content.songs.length > 0) badges.push('Music')
if (content.podcasts.length > 0) badges.push('Podcasts')
if (content.magazineSections.length > 0) badges.push('Magazine')
if ((content.newsLinks?.length ?? 0) > 0) badges.push('News')
if ((content.websitesLinks?.length ?? 0) > 0) badges.push('Web')
if ((content.codeBlocks?.length ?? 0) > 0) badges.push('Code')
if ((content.apps?.length ?? 0) > 0) badges.push('Apps')
if (content.hasNostr) badges.push('Nostr')
}
pairs.push({ userMsg, assistantMsg, badges })
}
return pairs
})
function selectPrompt(pair: PromptPair, index: number) {
activeIndex.value = index
emit('select', pair.userMsg, pair.assistantMsg)
}
function formatTime(ts: number): string {
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
</script>
@@ -0,0 +1,218 @@
<template>
<div v-if="isOpen" class="absolute bottom-full left-2 right-2 mb-1 z-50">
<!-- Variable fill form -->
<div
v-if="selectedTemplate && variables.length > 0"
class="rounded-2xl bg-[#1a1a1a] border border-white/10 shadow-2xl p-4 space-y-3 animate-scale-in"
>
<div class="flex items-center justify-between">
<h4 class="text-xs font-semibold text-white/80">{{ selectedTemplate.title }}</h4>
<button
class="text-xs text-white/40 hover:text-white/60 transition-colors"
@click="cancelTemplate"
>
Cancel
</button>
</div>
<div v-for="v in variables" :key="v" class="space-y-1">
<label class="text-xs text-white/40">{{ v }}</label>
<input
v-model="variableValues[v]"
type="text"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-base text-white/90 focus:outline-none focus:border-accent/50"
:placeholder="v"
@keydown.enter="applyTemplate"
/>
</div>
<button
class="w-full py-1.5 rounded-lg text-xs bg-accent/20 text-accent hover:bg-accent/30 transition-all"
@click="applyTemplate"
>
Insert
</button>
</div>
<!-- Command + template list -->
<div
v-else
class="rounded-2xl bg-[#1a1a1a] border border-white/10 shadow-2xl max-h-72 overflow-y-auto animate-scale-in"
>
<!-- Commands section -->
<div v-if="filteredCommands.length > 0">
<div class="p-2 border-b border-white/5">
<p class="text-xs text-white/30 px-2">Commands</p>
</div>
<div
v-for="(cmd, i) in filteredCommands"
:key="cmd.id"
class="px-3 py-2 cursor-pointer transition-colors"
:class="i === highlightIndex ? 'bg-white/10' : 'hover:bg-white/5'"
@click="selectCommand(cmd)"
@mouseenter="highlightIndex = i"
>
<div class="flex items-center gap-2">
<span class="text-xs font-mono text-accent/70">{{ cmd.slash }}</span>
<p class="text-sm text-white/80">{{ cmd.title }}</p>
</div>
<p v-if="cmd.preview" class="text-xs text-white/40 mt-0.5 truncate">{{ cmd.preview }}</p>
</div>
</div>
<!-- Templates section -->
<div v-if="filteredUserTemplates.length > 0">
<div class="p-2 border-b border-white/5">
<p class="text-xs text-white/30 px-2">Templates</p>
</div>
<div
v-for="(t, i) in filteredUserTemplates"
:key="t.id"
class="px-3 py-2 cursor-pointer transition-colors"
:class="(filteredCommands.length + i) === highlightIndex ? 'bg-white/10' : 'hover:bg-white/5'"
@click="selectTemplate(t)"
@mouseenter="highlightIndex = filteredCommands.length + i"
>
<p class="text-sm text-white/80">{{ t.title }}</p>
<p v-if="t.preview" class="text-xs text-white/40 mt-0.5 truncate">{{ t.preview }}</p>
</div>
</div>
<div v-if="filteredCommands.length === 0 && filteredUserTemplates.length === 0" class="px-3 py-4 text-center">
<p class="text-xs text-white/30">No matching commands</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { usePromptTemplateStore, extractVariables, type PromptTemplate } from '@/stores/promptTemplates'
import { DEMO_CONTENT_ENABLED } from '@/utils/demoContent'
interface PaletteCommand {
id: string
slash: string
title: string
preview: string
}
const BUILT_IN_COMMANDS: PaletteCommand[] = [
{ id: 'cmd-code', slash: '/code', title: 'Code', preview: 'Open project browser and code editor' },
{ id: 'cmd-nostr', slash: '/nostr', title: 'Nostr', preview: 'Browse the Nostr network feed' },
{ id: 'cmd-design', slash: '/design', title: 'Design System', preview: 'Open the design system viewer' },
{ id: 'cmd-search', slash: '/search ', title: 'Search', preview: 'Search your content library' },
// /seed loads fabricated showcase content — demo-site/dev builds only (S7)
...(DEMO_CONTENT_ENABLED
? [{ id: 'cmd-seed', slash: '/seed', title: 'Seed', preview: 'Load seed conversations for all content types' }]
: []),
{ id: 'cmd-freefilms', slash: '/freefilms', title: 'Free Films', preview: 'Browse free documentary films from InDeeHub' },
]
const props = defineProps<{
query: string
isOpen: boolean
}>()
const emit = defineEmits<{
select: [text: string]
close: []
}>()
const templateStore = usePromptTemplateStore()
const highlightIndex = ref(0)
const selectedTemplate = ref<PromptTemplate | null>(null)
const variableValues = ref<Record<string, string>>({})
const filteredCommands = computed(() => {
const q = props.query.toLowerCase()
if (!q) return BUILT_IN_COMMANDS
return BUILT_IN_COMMANDS.filter(c =>
c.slash.toLowerCase().includes('/' + q) ||
c.title.toLowerCase().includes(q) ||
c.preview.toLowerCase().includes(q)
)
})
const filteredUserTemplates = computed(() => {
const q = props.query.toLowerCase()
if (!q) return templateStore.sortedTemplates
return templateStore.sortedTemplates.filter(t =>
t.title.toLowerCase().includes(q) || (t.preview ?? '').toLowerCase().includes(q)
)
})
const allItemsCount = computed(() => filteredCommands.value.length + filteredUserTemplates.value.length)
const variables = computed(() => {
if (!selectedTemplate.value) return []
return extractVariables(selectedTemplate.value.content)
})
watch(() => props.query, () => {
highlightIndex.value = 0
selectedTemplate.value = null
})
watch(() => props.isOpen, (open) => {
if (!open) {
selectedTemplate.value = null
variableValues.value = {}
highlightIndex.value = 0
}
})
function selectCommand(cmd: PaletteCommand) {
// Commands like /code, /nostr send the slash text directly
emit('select', cmd.slash)
}
function selectTemplate(t: PromptTemplate) {
const vars = extractVariables(t.content)
if (vars.length === 0) {
emit('select', t.content)
return
}
selectedTemplate.value = t
variableValues.value = Object.fromEntries(vars.map(v => [v, '']))
}
function applyTemplate() {
if (!selectedTemplate.value) return
let result = selectedTemplate.value.content
for (const [key, val] of Object.entries(variableValues.value)) {
result = result.replaceAll(`{{${key}}}`, val || key)
}
emit('select', result)
selectedTemplate.value = null
variableValues.value = {}
}
function cancelTemplate() {
selectedTemplate.value = null
variableValues.value = {}
}
function navigateUp() {
if (highlightIndex.value > 0) highlightIndex.value--
}
function navigateDown() {
if (highlightIndex.value < allItemsCount.value - 1) highlightIndex.value++
}
function selectHighlighted() {
const idx = highlightIndex.value
if (idx < filteredCommands.value.length) {
selectCommand(filteredCommands.value[idx])
} else {
const t = filteredUserTemplates.value[idx - filteredCommands.value.length]
if (t) selectTemplate(t)
}
}
defineExpose({
navigateUp,
navigateDown,
selectHighlighted,
hasSelectedTemplate: computed(() => !!selectedTemplate.value),
})
</script>
@@ -0,0 +1,357 @@
<template>
<Teleport to="body">
<Transition name="settings-modal">
<div
v-if="open"
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-label="Settings"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape="$emit('update:open', false)"
@keydown.tab="trapFocus"
>
<div
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
@click.self="$emit('update:open', false)"
/>
<div class="glass-card relative w-full max-w-md p-5 space-y-5 animate-scale-in max-h-[85vh] overflow-y-auto scrollbar-hide">
<!-- Header -->
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-white/96">Settings</h2>
<button
ref="closeButtonRef"
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/10 transition-all"
aria-label="Close settings"
@click="$emit('update:open', false)"
>
<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>
<!-- Memory Section -->
<div class="space-y-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-white/50">
Memory ({{ memoryStore.items.length }}/20)
</h3>
<div class="space-y-1.5">
<div
v-for="item in memoryStore.items"
:key="item.id"
class="group flex items-start gap-2 rounded-lg bg-white/5 border border-white/5 px-2.5 py-1.5"
>
<div v-if="editingId === item.id" class="flex-1 flex gap-1.5">
<input
v-model="editText"
type="text"
class="flex-1 bg-transparent text-base text-white/80 outline-none"
@keydown.enter="saveEdit(item.id)"
@keydown.escape="cancelEdit"
/>
<button
class="text-xs text-accent/70 hover:text-accent"
@click="saveEdit(item.id)"
>
Save
</button>
</div>
<template v-else>
<p class="flex-1 text-xs text-white/60 leading-relaxed">{{ item.text }}</p>
<div class="shrink-0 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded text-white/30 hover:text-white/60 hover:bg-white/10 transition-all"
title="Edit"
@click="startEdit(item)"
>
<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /></svg>
</button>
<button
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded text-white/30 hover:text-red-400/80 hover:bg-white/10 transition-all"
title="Delete"
@click="memoryStore.deleteItem(item.id)"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</template>
</div>
<div v-if="!memoryStore.isFull" class="flex gap-1.5">
<input
v-model="newMemoryText"
type="text"
class="flex-1 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-base text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
placeholder="Add a memory..."
@keydown.enter="addMemory"
/>
<button
class="px-2.5 py-1.5 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-all"
:disabled="!newMemoryText.trim()"
@click="addMemory"
>
Add
</button>
</div>
<p v-else class="text-xs text-white/25">
Maximum 20 memories reached
</p>
</div>
</div>
<!-- Divider -->
<div class="border-t border-white/5" />
<!-- Advanced Section -->
<div class="space-y-3">
<h3 class="text-xs font-semibold uppercase tracking-wider text-white/50">
Advanced
</h3>
<template v-if="conv">
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs text-white/50">Temperature</label>
<span class="text-xs text-white/50 tabular-nums">{{ temperature.toFixed(2) }}</span>
</div>
<input
v-model.number="temperature"
type="range"
min="0"
max="1"
step="0.05"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs text-white/50">Max Tokens</label>
<span class="text-xs text-white/50 tabular-nums">{{ maxTokens }}</span>
</div>
<input
v-model.number="maxTokens"
type="range"
min="256"
max="8192"
step="256"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs text-white/50">Top P</label>
<span class="text-xs text-white/50 tabular-nums">{{ topP.toFixed(2) }}</span>
</div>
<input
v-model.number="topP"
type="range"
min="0"
max="1"
step="0.05"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-white/50">Stop Sequences</label>
<div v-if="stopSequences.length > 0" class="flex gap-1 flex-wrap mb-1">
<span
v-for="(seq, i) in stopSequences"
:key="i"
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-white/5 border border-white/10 text-xs text-white/50"
>
{{ seq }}
<button class="text-white/30 hover:text-white/60" @click="removeStopSequence(i)">&times;</button>
</span>
</div>
<input
v-model="newStopSeq"
type="text"
class="w-full bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-base text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
placeholder="Add stop sequence (Enter to add)"
@keydown.enter="addStopSequence"
/>
</div>
<button
class="text-xs text-white/30 hover:text-white/50 transition-colors"
@click="resetDefaults"
>
Reset to defaults
</button>
</template>
<p v-else class="text-xs text-white/30">Start a conversation to configure parameters</p>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useMemoryStore, type MemoryItem } from '@/stores/memory'
import { useChatStore } from '@/stores/chat'
const props = defineProps<{
open: boolean
}>()
defineEmits<{
'update:open': [value: boolean]
}>()
const dialogRef = ref<HTMLElement | null>(null)
const closeButtonRef = ref<HTMLElement | null>(null)
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.open, async (isOpen) => {
if (isOpen) {
await nextTick()
closeButtonRef.value?.focus()
}
})
// --- Memory ---
const memoryStore = useMemoryStore()
const newMemoryText = ref('')
const editingId = ref<string | null>(null)
const editText = ref('')
function addMemory() {
if (!newMemoryText.value.trim()) return
memoryStore.addItem(newMemoryText.value)
newMemoryText.value = ''
}
function startEdit(item: MemoryItem) {
editingId.value = item.id
editText.value = item.text
}
function saveEdit(id: string) {
if (editText.value.trim()) {
memoryStore.updateItem(id, editText.value)
}
editingId.value = null
editText.value = ''
}
function cancelEdit() {
editingId.value = null
editText.value = ''
}
// --- Advanced ---
const chatStore = useChatStore()
const conv = computed(() => chatStore.activeConversation)
const newStopSeq = ref('')
const temperature = ref(1.0)
const maxTokens = ref(4096)
const topP = ref(1.0)
const stopSequences = ref<string[]>([])
watch(
() => chatStore.activeConversationId,
() => loadFromConv(),
{ immediate: true }
)
function loadFromConv() {
const c = conv.value
temperature.value = c?.temperature ?? 1.0
maxTokens.value = c?.maxTokens ?? 4096
topP.value = c?.topP ?? 1.0
stopSequences.value = c?.stopSequences ? [...c.stopSequences] : []
}
function persistParams() {
const c = conv.value
if (!c) return
c.temperature = temperature.value
c.maxTokens = maxTokens.value
c.topP = topP.value
c.updatedAt = Date.now()
}
function addStopSequence() {
const seq = newStopSeq.value.trim()
if (!seq) return
stopSequences.value.push(seq)
newStopSeq.value = ''
const c = conv.value
if (c) {
c.stopSequences = [...stopSequences.value]
c.updatedAt = Date.now()
}
}
function removeStopSequence(index: number) {
stopSequences.value.splice(index, 1)
const c = conv.value
if (c) {
c.stopSequences = [...stopSequences.value]
c.updatedAt = Date.now()
}
}
function resetDefaults() {
temperature.value = 1.0
maxTokens.value = 4096
topP.value = 1.0
stopSequences.value = []
newStopSeq.value = ''
const c = conv.value
if (c) {
c.temperature = undefined
c.maxTokens = undefined
c.topP = undefined
c.stopSequences = undefined
c.updatedAt = Date.now()
}
}
</script>
<style scoped>
.settings-modal-enter-active {
transition: opacity 0.2s ease-out;
}
.settings-modal-enter-active .glass-card {
transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
}
.settings-modal-leave-active {
transition: opacity 0.15s ease-in;
}
.settings-modal-enter-from,
.settings-modal-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,14 @@
<template>
<div class="flex justify-start animate-fade-up-fast">
<div class="path-glass-card rounded-2xl rounded-bl-md px-4 py-3">
<div class="flex items-center gap-1.5">
<span
v-for="i in 3"
:key="i"
class="w-1.5 h-1.5 rounded-full bg-white/40 animate-pulse-glow"
:style="{ animationDelay: `${i * 200}ms` }"
/>
</div>
</div>
</div>
</template>
@@ -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>
@@ -0,0 +1,280 @@
<template>
<Transition name="player-slide">
<div
v-if="hasTrack"
:class="[
props.variant === 'fixed'
? 'fixed bottom-0 left-0 right-0 z-[999]'
: 'w-full shrink-0',
'path-glass-card !rounded-none'
]"
>
<!-- Plyr container: YouTube requires min 200x200px. Kept off-screen but sized. -->
<div
ref="plyrContainerRef"
class="absolute left-[-9999px] w-[320px] h-[180px] overflow-hidden"
aria-hidden="true"
/>
<!-- Compact layout: mini-player (mobile) -->
<div v-if="props.compact" class="flex flex-col">
<!-- Scrubber bar (full width, thin) -->
<div
class="w-full h-1 cursor-pointer bg-white/10"
@click="onScrubberClick"
>
<div
class="h-full bg-accent transition-all duration-150"
:style="{ width: `${progress}%` }"
/>
</div>
<!-- Cover + info + controls -->
<div class="flex items-center gap-3 px-3 py-2">
<div class="w-10 h-10 rounded-lg overflow-hidden shrink-0 flex items-center justify-center path-glass-icon">
<img
v-if="coverUrl"
:src="coverUrl"
:alt="currentSong!.title"
class="w-full h-full object-cover"
/>
<span v-else class="text-base">🎵</span>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold truncate text-white/90">{{ cleanTitle(currentSong!.title) }}</p>
<p class="text-xs truncate text-white/50">{{ currentSong!.artist }}</p>
</div>
<button
class="w-11 h-11 rounded-full flex items-center justify-center shrink-0 active:scale-95"
:class="hasPrevious ? 'text-white/70' : 'text-white/20'"
:disabled="!hasPrevious"
aria-label="Previous track"
@click="playPrevious"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h2v12H6V6zm3.5 6l8.5 6V6l-8.5 6z" />
</svg>
</button>
<button
class="w-12 h-12 rounded-full flex items-center justify-center glass-button shrink-0 active:scale-95"
aria-label="Play or pause"
@click="toggle"
>
<svg v-if="isLoading" class="w-6 h-6 animate-spin text-white/90" 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>
<svg v-else-if="isPlaying" class="w-6 h-6 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
<svg v-else class="w-6 h-6 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</button>
<button
class="w-11 h-11 rounded-full flex items-center justify-center shrink-0 active:scale-95"
:class="hasNext ? 'text-white/70' : 'text-white/20'"
:disabled="!hasNext"
aria-label="Next track"
@click="playNext"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" />
</svg>
</button>
<button
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 active:scale-95 text-white/40"
aria-label="Close player"
@click="clear"
>
<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>
<p v-if="error" class="text-xs text-red-400/60 px-3 pb-1 truncate">{{ error }}</p>
</div>
<!-- Desktop layout: full controls with scrubber -->
<div v-else class="flex items-center gap-4 px-4 py-3">
<div class="flex items-center gap-3 min-w-0 flex-1 max-w-[280px]">
<div class="w-12 h-12 rounded-lg overflow-hidden shrink-0 flex items-center justify-center path-glass-icon">
<img
v-if="coverUrl"
:src="coverUrl"
:alt="currentSong!.title"
class="w-full h-full object-cover"
/>
<span v-else class="text-lg">🎵</span>
</div>
<div class="min-w-0">
<p class="text-sm font-semibold truncate text-white/90">{{ cleanTitle(currentSong!.title) }}</p>
<p class="text-xs truncate text-white/50">{{ currentSong!.artist }}</p>
</div>
</div>
<div class="flex flex-col gap-1 flex-1 max-w-xl mx-4">
<div class="flex items-center gap-2">
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center shrink-0 transition-all hover:scale-105 active:scale-95"
:class="hasPrevious ? 'text-white/70 hover:text-white/90' : 'text-white/20'"
:disabled="!hasPrevious"
aria-label="Previous track"
@click="playPrevious"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h2v12H6V6zm3.5 6l8.5 6V6l-8.5 6z" />
</svg>
</button>
<button
class="w-12 h-12 rounded-full flex items-center justify-center glass-button shrink-0 transition-all hover:scale-105 active:scale-95"
aria-label="Play or pause"
@click="toggle"
>
<svg v-if="isLoading" class="w-6 h-6 animate-spin text-white/90" 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>
<svg v-else-if="isPlaying" class="w-6 h-6 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
<svg v-else class="w-6 h-6 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</button>
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center shrink-0 transition-all hover:scale-105 active:scale-95"
:class="hasNext ? 'text-white/70 hover:text-white/90' : 'text-white/20'"
:disabled="!hasNext"
aria-label="Next track"
@click="playNext"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" />
</svg>
</button>
<span class="text-xs font-mono tabular-nums text-white/40">
{{ formatTime(currentTime) }}
</span>
<div
class="flex-1 h-1.5 rounded-full cursor-pointer group bg-white/15"
@click="onScrubberClick"
>
<div
class="h-full rounded-full transition-all duration-150 group-hover:h-2 bg-accent"
:style="{ width: `${progress}%` }"
/>
</div>
<span class="text-xs font-mono tabular-nums text-white/40">
{{ formatTime(duration) }}
</span>
</div>
<p v-if="error" class="text-xs text-red-400/60 truncate">{{ error }}</p>
</div>
<span
v-if="queue.length > 1"
class="text-xs font-mono tabular-nums shrink-0 text-white/30"
>{{ queue.length }} songs</span>
<button
class="touch-target rounded-xl path-glass-button path-glass-button-sm shrink-0 transition-all hover:scale-105"
aria-label="Close player"
@click="clear"
title="Close player"
>
<svg class="w-4 h-4 text-white/70" 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>
</Transition>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { usePlayer } from '@/composables/usePlayer'
import { fetchMusicCover } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
variant?: 'fixed' | 'inline'
compact?: boolean
}>(), { variant: 'fixed', compact: false })
const {
currentSong,
hasTrack,
isPlaying,
isLoading,
error,
currentTime,
duration,
progress,
queue,
hasNext,
hasPrevious,
toggle,
seek,
clear,
playNext,
playPrevious,
setContainer,
} = usePlayer()
const plyrContainerRef = ref<HTMLDivElement | null>(null)
const fetchedCover = ref<string | null>(null)
const coverUrl = computed(() => {
const song = currentSong.value
if (!song) return null
return song.coverUrl || fetchedCover.value
})
function cleanTitle(raw: string): string {
return raw.replace(/^(?:song|film|podcast|book|tv)_ext:/i, '').replace(/\|.*$/, '')
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
function onScrubberClick(e: MouseEvent) {
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
const percent = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100))
seek(percent)
}
onMounted(async () => {
await nextTick()
setContainer(plyrContainerRef.value)
})
watch(
() => plyrContainerRef.value,
(el) => setContainer(el),
{ flush: 'post' },
)
watch(currentSong, (song) => {
fetchedCover.value = null
if (song && !song.coverUrl) {
fetchMusicCover(song.title, song.artist).then((url) => {
if (url) fetchedCover.value = url
})
}
}, { immediate: true })
</script>
<style scoped>
.player-slide-enter-active,
.player-slide-leave-active {
transition: transform 0.25s ease, opacity 0.2s ease;
}
.player-slide-enter-from,
.player-slide-leave-to {
transform: translateY(100%);
opacity: 0;
}
</style>
@@ -0,0 +1,516 @@
<template>
<Teleport to="body">
<Transition name="video-player">
<div
v-if="store.isOpen"
ref="containerRef"
class="fixed inset-0 z-[2500] flex flex-col bg-black"
:class="controlsVisible ? '' : 'cursor-none'"
@mousemove="showControls"
@touchstart.passive="showControls"
@keydown="onKeydown"
@click="onContainerClick"
tabindex="0"
>
<!-- Video area -->
<div class="flex-1 min-h-0 relative flex items-center justify-center">
<div
ref="playerRef"
class="w-full h-full"
/>
<!-- Big center play/pause indicator (flashes on toggle) -->
<Transition name="center-icon">
<div
v-if="showCenterIcon"
class="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div class="w-20 h-20 rounded-full flex items-center justify-center bg-black/40 backdrop-blur-sm">
<svg v-if="isPlaying" class="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
<svg v-else class="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</div>
</div>
</Transition>
<!-- Loading spinner -->
<div v-if="isBuffering" class="absolute inset-0 flex items-center justify-center pointer-events-none">
<svg class="w-12 h-12 animate-spin text-white/60" 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>
</div>
</div>
<!-- Controls overlay bottom bar -->
<div
class="absolute bottom-0 left-0 right-0 transition-all duration-300 z-20"
:class="controlsVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'"
>
<!-- Gradient fade above controls -->
<div class="h-24 bg-gradient-to-t from-black/80 to-transparent pointer-events-none" />
<div class="path-glass-card !rounded-none px-4 py-3 space-y-2">
<!-- Scrubber -->
<div
ref="scrubberRef"
class="group w-full h-1.5 rounded-full cursor-pointer bg-white/15 transition-all hover:h-2.5"
@click="onScrubberClick"
@mousedown="onScrubberDragStart"
>
<div
class="h-full bg-accent rounded-full transition-[width] duration-75 relative"
:style="{ width: `${progress}%` }"
>
<div class="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-white shadow-md opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
<!-- Controls row -->
<div class="flex items-center gap-3">
<!-- Title -->
<div class="min-w-0 flex-1 max-w-[280px]">
<p class="text-sm font-semibold truncate text-white/90">{{ store.title }}</p>
<p class="text-xs truncate text-white/40">Free Documentary</p>
</div>
<!-- Center controls -->
<div class="flex items-center gap-2 flex-1 justify-center">
<!-- Rewind 10s -->
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center text-white/70 hover:text-white/90 transition-all hover:scale-105 active:scale-95"
aria-label="Rewind 10 seconds"
@click.stop="seek(-10)"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z" />
</svg>
</button>
<!-- Play/Pause -->
<button
class="w-12 h-12 rounded-full flex items-center justify-center glass-button shrink-0 transition-all hover:scale-105 active:scale-95"
aria-label="Play or pause"
@click.stop="toggle"
>
<svg v-if="isPlaying" class="w-6 h-6 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
<svg v-else class="w-6 h-6 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</button>
<!-- Forward 10s -->
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center text-white/70 hover:text-white/90 transition-all hover:scale-105 active:scale-95"
aria-label="Forward 10 seconds"
@click.stop="seek(10)"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z" />
</svg>
</button>
<!-- Time -->
<span class="text-xs font-mono tabular-nums text-white/40 ml-1">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span>
</div>
<!-- Right controls -->
<div class="flex items-center gap-1 shrink-0">
<!-- Fullscreen -->
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center text-white/60 hover:text-white/90 transition-colors"
aria-label="Toggle fullscreen"
@click.stop="toggleFullscreen"
>
<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="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
</button>
<!-- Close -->
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center text-white/60 hover:text-white/90 transition-colors"
aria-label="Close video player"
@click.stop="closePlayer"
>
<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>
</div>
</div>
<!-- Top bar close button (visible with controls) -->
<div
class="absolute top-0 left-0 right-0 transition-all duration-300 z-20"
:class="controlsVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'"
>
<div class="h-16 bg-gradient-to-b from-black/60 to-transparent flex items-start justify-end px-4 pt-3">
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center text-white/70 hover:text-white/90 hover:bg-white/10 transition-colors"
aria-label="Close"
@click.stop="closePlayer"
>
<svg class="w-6 h-6" 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>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useVideoPlayerStore } from '@/stores/videoPlayer'
const store = useVideoPlayerStore()
const containerRef = ref<HTMLElement | null>(null)
const playerRef = ref<HTMLElement | null>(null)
const scrubberRef = ref<HTMLElement | null>(null)
const isPlaying = ref(false)
const isBuffering = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const progress = ref(0)
const controlsVisible = ref(true)
const showCenterIcon = ref(false)
let hideTimer: ReturnType<typeof setTimeout> | null = null
let rafId: number | null = null
let ytPlayer: any = null
let centerIconTimer: ReturnType<typeof setTimeout> | null = null
// YouTube IFrame API
let ytApiReady = false
const ytApiCallbacks: (() => void)[] = []
function loadYouTubeApi(): Promise<void> {
if (ytApiReady) return Promise.resolve()
return new Promise((resolve) => {
if ((window as any).YT?.Player) {
ytApiReady = true
resolve()
return
}
ytApiCallbacks.push(resolve)
if (!document.getElementById('yt-iframe-api')) {
const tag = document.createElement('script')
tag.id = 'yt-iframe-api'
tag.src = 'https://www.youtube.com/iframe_api'
document.head.appendChild(tag)
;(window as any).onYouTubeIframeAPIReady = () => {
ytApiReady = true
ytApiCallbacks.forEach(cb => cb())
ytApiCallbacks.length = 0
}
}
})
}
function extractYouTubeId(url: string): string | null {
// Handle embed URLs: youtube.com/embed/VIDEO_ID
const embedMatch = url.match(/\/embed\/([a-zA-Z0-9_-]{11})/)
if (embedMatch) return embedMatch[1]
// Handle watch URLs: youtube.com/watch?v=VIDEO_ID
const watchMatch = url.match(/[?&]v=([a-zA-Z0-9_-]{11})/)
if (watchMatch) return watchMatch[1]
// Handle youtu.be/VIDEO_ID
const shortMatch = url.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/)
if (shortMatch) return shortMatch[1]
return null
}
async function initPlayer(url: string) {
const videoId = extractYouTubeId(url)
if (!videoId || !playerRef.value) return
isBuffering.value = true
await loadYouTubeApi()
const YT = (window as any).YT
// Create a div target inside playerRef
const target = document.createElement('div')
target.id = 'yt-video-player'
playerRef.value.innerHTML = ''
playerRef.value.appendChild(target)
ytPlayer = new YT.Player('yt-video-player', {
videoId,
width: '100%',
height: '100%',
playerVars: {
autoplay: 1,
controls: 0,
modestbranding: 1,
rel: 0,
showinfo: 0,
iv_load_policy: 3,
fs: 0,
playsinline: 1,
},
events: {
onReady: () => {
isBuffering.value = false
isPlaying.value = true
startPolling()
showControls()
},
onStateChange: (e: any) => {
const state = e.data
// -1: unstarted, 0: ended, 1: playing, 2: paused, 3: buffering, 5: cued
isPlaying.value = state === 1
isBuffering.value = state === 3
if (state === 0) {
// Video ended
isPlaying.value = false
controlsVisible.value = true
}
},
},
})
}
function startPolling() {
if (rafId) cancelAnimationFrame(rafId)
function poll() {
if (ytPlayer?.getCurrentTime && ytPlayer?.getDuration) {
currentTime.value = ytPlayer.getCurrentTime() ?? 0
duration.value = ytPlayer.getDuration() ?? 0
progress.value = duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0
}
rafId = requestAnimationFrame(poll)
}
poll()
}
function stopPolling() {
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
}
function destroyPlayer() {
stopPolling()
if (ytPlayer?.destroy) {
try { ytPlayer.destroy() } catch {}
}
ytPlayer = null
if (playerRef.value) playerRef.value.innerHTML = ''
isPlaying.value = false
isBuffering.value = false
currentTime.value = 0
duration.value = 0
progress.value = 0
}
// Controls
function toggle() {
if (!ytPlayer) return
if (isPlaying.value) {
ytPlayer.pauseVideo()
} else {
ytPlayer.playVideo()
}
flashCenterIcon()
}
function seek(seconds: number) {
if (!ytPlayer?.seekTo) return
const target = Math.max(0, Math.min(duration.value, currentTime.value + seconds))
ytPlayer.seekTo(target, true)
showControls()
}
function seekToPercent(percent: number) {
if (!ytPlayer?.seekTo || duration.value <= 0) return
const target = (percent / 100) * duration.value
ytPlayer.seekTo(target, true)
}
function onScrubberClick(e: MouseEvent) {
if (!scrubberRef.value) return
const rect = scrubberRef.value.getBoundingClientRect()
const percent = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100))
seekToPercent(percent)
}
let isDragging = false
function onScrubberDragStart(e: MouseEvent) {
isDragging = true
onScrubberClick(e)
const onMove = (ev: MouseEvent) => { if (isDragging) onScrubberClick(ev) }
const onUp = () => {
isDragging = false
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
function toggleFullscreen() {
if (!containerRef.value) return
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
containerRef.value.requestFullscreen()
}
}
function closePlayer() {
destroyPlayer()
store.close()
}
function flashCenterIcon() {
showCenterIcon.value = true
if (centerIconTimer) clearTimeout(centerIconTimer)
centerIconTimer = setTimeout(() => {
showCenterIcon.value = false
}, 600)
}
function onContainerClick(e: MouseEvent) {
// Only toggle on direct click on video area (not controls)
const target = e.target as HTMLElement
if (target === containerRef.value || target.closest('.flex-1.min-h-0')) {
toggle()
}
}
// Auto-hide controls
function showControls() {
controlsVisible.value = true
resetHideTimer()
}
function resetHideTimer() {
if (hideTimer) clearTimeout(hideTimer)
hideTimer = setTimeout(() => {
if (isPlaying.value) controlsVisible.value = false
}, 3000)
}
// Keyboard shortcuts
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
closePlayer()
return
}
if (e.key === ' ' || e.key === 'k') {
e.preventDefault()
toggle()
showControls()
return
}
if (e.key === 'ArrowLeft') {
e.preventDefault()
seek(-10)
return
}
if (e.key === 'ArrowRight') {
e.preventDefault()
seek(10)
return
}
if (e.key === 'f') {
e.preventDefault()
toggleFullscreen()
return
}
}
function formatTime(seconds: number): string {
const s = Math.floor(seconds)
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = s % 60
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
return `${m}:${String(sec).padStart(2, '0')}`
}
// Global escape handler (captures even when focus is elsewhere)
function onGlobalKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && store.isOpen) {
e.preventDefault()
e.stopPropagation()
closePlayer()
}
}
// Watch store open/close
watch(() => store.isOpen, async (open) => {
if (open) {
await nextTick()
containerRef.value?.focus()
initPlayer(store.videoUrl)
showControls()
} else {
destroyPlayer()
}
})
onMounted(() => {
window.addEventListener('keydown', onGlobalKeydown, true)
})
onBeforeUnmount(() => {
destroyPlayer()
window.removeEventListener('keydown', onGlobalKeydown, true)
if (hideTimer) clearTimeout(hideTimer)
if (centerIconTimer) clearTimeout(centerIconTimer)
})
</script>
<style scoped>
.video-player-enter-active,
.video-player-leave-active {
transition: opacity 0.25s ease;
}
.video-player-enter-from,
.video-player-leave-to {
opacity: 0;
}
.center-icon-enter-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.center-icon-leave-active {
transition: opacity 0.4s ease, transform 0.4s ease;
}
.center-icon-enter-from {
opacity: 0;
transform: scale(0.8);
}
.center-icon-leave-to {
opacity: 0;
transform: scale(1.3);
}
/* Make YouTube iframe fill container */
:deep(iframe) {
width: 100% !important;
height: 100% !important;
border: 0;
pointer-events: none;
}
</style>
@@ -0,0 +1,259 @@
<template>
<div class="article-reader h-full flex">
<!-- TOC Sidebar (desktop only) -->
<aside
v-if="headings.length > 1"
class="hidden lg:flex flex-col w-56 shrink-0 border-r border-white/5 overflow-y-auto scrollbar-hide py-4 px-3"
>
<p class="text-xs uppercase tracking-wider text-white/30 mb-2 px-2">Contents</p>
<button
v-for="(h, i) in headings"
:key="i"
class="text-left text-xs leading-relaxed py-1 px-2 rounded transition-colors truncate"
:class="[
activeHeadingIdx === i ? 'text-accent bg-accent/10' : 'text-white/50 hover:text-white/70 hover:bg-white/5',
h.level === 3 ? 'pl-5' : ''
]"
@click="scrollToHeading(h.id)"
>
{{ h.text }}
</button>
</aside>
<!-- Main content -->
<div ref="contentRef" class="flex-1 overflow-y-auto scrollbar-hide">
<!-- Header bar -->
<div class="sticky top-0 z-10 flex items-center gap-2 px-4 py-2 bg-black/60 backdrop-blur-md border-b border-white/5">
<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"
title="Back"
@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>
<span class="flex-1 text-xs text-white/40 truncate">{{ readingTime }} min read</span>
<!-- TOC toggle (mobile) -->
<button
v-if="headings.length > 1"
class="lg:hidden 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"
title="Table of contents"
@click="showMobileToc = !showMobileToc"
>
<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="M4 6h16M4 12h16M4 18h7" />
</svg>
</button>
<!-- Font size -->
<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"
title="Decrease font size"
:disabled="fontSizeIdx <= 0"
@click="fontSizeIdx = Math.max(0, fontSizeIdx - 1)"
>
<span class="text-xs font-bold">A-</span>
</button>
<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"
title="Increase font size"
:disabled="fontSizeIdx >= fontSizes.length - 1"
@click="fontSizeIdx = Math.min(fontSizes.length - 1, fontSizeIdx + 1)"
>
<span class="text-xs font-bold">A+</span>
</button>
<!-- Print -->
<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"
title="Print"
@click="printArticle"
>
<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="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
</button>
</div>
<!-- Mobile TOC dropdown -->
<div
v-if="showMobileToc && headings.length > 1"
class="lg:hidden bg-black/40 backdrop-blur-md border-b border-white/5 px-4 py-2 space-y-0.5 animate-fade-up-fast"
>
<button
v-for="(h, i) in headings"
:key="i"
class="block w-full text-left text-xs py-1 px-2 rounded transition-colors truncate"
:class="[
activeHeadingIdx === i ? 'text-accent bg-accent/10' : 'text-white/50 hover:text-white/70',
h.level === 3 ? 'pl-5' : ''
]"
@click="scrollToHeading(h.id); showMobileToc = false"
>
{{ h.text }}
</button>
</div>
<!-- Article body -->
<article
ref="articleRef"
class="article-body px-4 md:px-8 py-6 max-w-prose mx-auto leading-relaxed text-white/90"
:style="{ fontSize: fontSizes[fontSizeIdx] + 'px' }"
>
<h1 v-if="title" class="text-xl font-bold text-white/96 mb-4">{{ title }}</h1>
<div
class="article-content [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-white/96 [&_h2]:mt-8 [&_h2]:mb-3 [&_h3]:text-base [&_h3]:font-medium [&_h3]:text-white/90 [&_h3]:mt-6 [&_h3]:mb-2 [&_p]:mb-4 [&_ul]:list-disc [&_ul]:ml-5 [&_ul]:mb-4 [&_ol]:list-decimal [&_ol]:ml-5 [&_ol]:mb-4 [&_li]:mb-1 [&_a]:text-accent [&_a]:underline [&_a]:underline-offset-2 [&_blockquote]:border-l-2 [&_blockquote]:border-accent/30 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-white/70 [&_blockquote]:my-4 [&_code]:bg-white/10 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-[0.9em] [&_pre]:bg-white/5 [&_pre]:rounded-lg [&_pre]:p-4 [&_pre]:overflow-x-auto [&_pre]:my-4 [&_img]:rounded-lg [&_img]:max-w-full [&_img]:my-4 [&_hr]:border-white/10 [&_hr]:my-6"
v-html="renderedHtml"
/>
</article>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import MarkdownIt from 'markdown-it'
const props = defineProps<{
content: string
title?: string
}>()
defineEmits<{ back: [] }>()
// Font sizes
const fontSizes = [13, 15, 17, 19, 21]
const savedIdx = localStorage.getItem('aiui-article-font-size')
const fontSizeIdx = ref(savedIdx ? parseInt(savedIdx, 10) : 1)
watch(fontSizeIdx, (v) => {
localStorage.setItem('aiui-article-font-size', String(v))
})
const showMobileToc = ref(false)
// markdown-it instance
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
})
// Add IDs to headings for TOC anchoring
md.renderer.rules.heading_open = (tokens, idx, options, _env, self) => {
const token = tokens[idx]
const level = parseInt(token.tag.slice(1), 10)
if (level === 2 || level === 3) {
const nextToken = tokens[idx + 1]
const text = nextToken?.children?.reduce((acc, t) => acc + (t.content || ''), '') || ''
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/(^-|-$)/g, '')
token.attrSet('id', id)
}
return self.renderToken(tokens, idx, options)
}
// Open links in new tab
const defaultLinkOpen = md.renderer.rules.link_open || function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
tokens[idx].attrSet('target', '_blank')
tokens[idx].attrSet('rel', 'noopener noreferrer')
return defaultLinkOpen(tokens, idx, options, env, self)
}
const renderedHtml = computed(() => md.render(props.content))
// Extract headings for TOC
interface Heading {
text: string
id: string
level: number
}
const headings = computed<Heading[]>(() => {
const result: Heading[] = []
const re = /^(#{2,3})\s+(.+)$/gm
let m: RegExpExecArray | null
while ((m = re.exec(props.content)) !== null) {
const text = m[2].trim()
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/(^-|-$)/g, '')
result.push({ text, id, level: m[1].length })
}
return result
})
// Reading time (~200 words/min)
const readingTime = computed(() => {
const words = props.content.split(/\s+/).length
return Math.max(1, Math.ceil(words / 200))
})
// Active heading tracking via Intersection Observer
const contentRef = ref<HTMLElement | null>(null)
const articleRef = ref<HTMLElement | null>(null)
const activeHeadingIdx = ref(0)
let observer: IntersectionObserver | null = null
function setupObserver() {
if (!contentRef.value) return
observer?.disconnect()
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const id = (entry.target as HTMLElement).id
const idx = headings.value.findIndex((h) => h.id === id)
if (idx >= 0) activeHeadingIdx.value = idx
}
}
},
{ root: contentRef.value, rootMargin: '-20% 0px -60% 0px', threshold: 0 }
)
const headingEls = articleRef.value?.querySelectorAll('h2[id], h3[id]')
headingEls?.forEach((el) => observer!.observe(el))
}
onMounted(() => {
setTimeout(setupObserver, 100)
})
watch(() => props.content, () => {
setTimeout(setupObserver, 100)
})
onBeforeUnmount(() => {
observer?.disconnect()
})
function scrollToHeading(id: string) {
const el = articleRef.value?.querySelector(`#${CSS.escape(id)}`)
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function printArticle() {
const printWindow = window.open('', '_blank')
if (!printWindow) return
printWindow.document.write(`<!DOCTYPE html>
<html><head><title>${props.title || 'Article'}</title>
<style>
body { font-family: Georgia, serif; max-width: 700px; margin: 2em auto; padding: 0 1em; line-height: 1.7; color: #222; }
h1 { font-size: 1.8em; margin-bottom: 0.5em; }
h2 { font-size: 1.4em; margin-top: 1.5em; }
h3 { font-size: 1.2em; margin-top: 1.2em; }
code { background: #f0f0f0; padding: 2px 5px; border-radius: 3px; }
pre { background: #f5f5f5; padding: 1em; overflow-x: auto; border-radius: 5px; }
blockquote { border-left: 3px solid #ccc; padding-left: 1em; color: #555; }
img { max-width: 100%; }
a { color: #0066cc; }
</style></head><body>
${props.title ? `<h1>${props.title}</h1>` : ''}
${renderedHtml.value}
</body></html>`)
printWindow.document.close()
printWindow.print()
}
</script>
@@ -0,0 +1,111 @@
<template>
<div class="audio-waveform rounded-xl bg-white/5 border border-white/10 overflow-hidden">
<div class="px-4 py-3 flex items-center gap-3">
<!-- Play/Pause -->
<button
class="w-10 h-10 flex items-center justify-center rounded-full bg-accent/15 text-accent hover:bg-accent/25 transition-colors shrink-0"
:disabled="loading"
@click="togglePlay"
>
<svg v-if="!isPlaying" class="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
</svg>
<svg v-else class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M5.75 3a.75.75 0 00-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75V3.75A.75.75 0 007.25 3h-1.5zM12.75 3a.75.75 0 00-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75V3.75a.75.75 0 00-.75-.75h-1.5z" />
</svg>
</button>
<!-- Waveform container -->
<div class="flex-1 min-w-0">
<div ref="waveformRef" class="waveform-container" />
<div class="flex justify-between mt-1">
<span class="text-xs text-white/30 tabular-nums">{{ formatTime(currentTime) }}</span>
<span class="text-xs text-white/30 tabular-nums">{{ formatTime(duration) }}</span>
</div>
</div>
</div>
<div v-if="title" class="px-4 pb-3 -mt-1">
<p class="text-xs text-white/60 truncate">{{ title }}</p>
</div>
<div v-if="loading" class="px-4 pb-3">
<p class="text-xs text-white/30">Loading audio...</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, shallowRef } from 'vue'
const props = defineProps<{
url: string
title?: string
}>()
const waveformRef = ref<HTMLElement | null>(null)
const isPlaying = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const loading = ref(true)
type WaveSurferInstance = import('wavesurfer.js').default
const ws = shallowRef<WaveSurferInstance | null>(null)
async function initWaveSurfer() {
if (!waveformRef.value) return
const WaveSurfer = (await import('wavesurfer.js')).default
const instance = WaveSurfer.create({
container: waveformRef.value,
waveColor: '#F7931A44',
progressColor: '#F7931A',
cursorColor: '#F7931Acc',
barWidth: 2,
barGap: 1,
barRadius: 2,
height: 48,
normalize: true,
url: props.url,
backend: 'WebAudio',
})
instance.on('ready', () => {
loading.value = false
duration.value = instance.getDuration()
})
instance.on('timeupdate', (time: number) => {
currentTime.value = time
})
instance.on('play', () => { isPlaying.value = true })
instance.on('pause', () => { isPlaying.value = false })
instance.on('finish', () => { isPlaying.value = false })
instance.on('error', () => {
loading.value = false
})
ws.value = instance
}
function togglePlay() {
ws.value?.playPause()
}
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
onMounted(() => {
initWaveSurfer()
})
onBeforeUnmount(() => {
ws.value?.destroy()
})
</script>
@@ -0,0 +1,95 @@
<template>
<div class="rounded-xl bg-white/5 border border-white/10 p-3 space-y-2">
<div class="flex items-center gap-2">
<span class="text-xs text-accent/60 uppercase tracking-wider font-bold">Bitcoin Address</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-white/5 text-white/30">{{ addressType }}</span>
</div>
<!-- Address display -->
<p class="text-xs font-mono text-white/70 break-all leading-relaxed select-all">{{ address }}</p>
<!-- QR code -->
<div class="flex justify-center py-2">
<canvas ref="qrCanvas" class="rounded-lg" width="160" height="160" />
</div>
<!-- Actions -->
<div class="flex gap-2">
<button
class="flex-1 py-2 rounded-lg text-xs bg-white/5 text-white/50 hover:text-white/70 hover:bg-white/10 transition-colors"
@click="copyAddress"
>
{{ copied ? 'Copied!' : 'Copy Address' }}
</button>
<a
:href="mempoolLink"
target="_blank"
rel="noopener noreferrer"
class="flex-1 py-2 rounded-lg text-xs text-center bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
>
View on Mempool
</a>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
const props = defineProps<{
address: string
}>()
const qrCanvas = ref<HTMLCanvasElement | null>(null)
const copied = ref(false)
const addressType = computed(() => {
const addr = props.address
if (addr.startsWith('bc1q')) return 'SegWit (bech32)'
if (addr.startsWith('bc1p')) return 'Taproot (bech32m)'
if (addr.startsWith('1')) return 'Legacy (P2PKH)'
if (addr.startsWith('3')) return 'Nested SegWit (P2SH)'
if (addr.startsWith('tb1') || addr.startsWith('2') || addr.startsWith('m') || addr.startsWith('n')) return 'Testnet'
return 'Unknown'
})
const mempoolLink = computed(() => {
return `https://mempool.space/address/${props.address}`
})
function copyAddress() {
navigator.clipboard.writeText(props.address)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
function drawQR() {
const canvas = qrCanvas.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
// Simple QR placeholder — draw bitcoin URI in styled grid
ctx.fillStyle = '#1a1a1a'
ctx.fillRect(0, 0, 160, 160)
ctx.fillStyle = '#F7931A'
ctx.font = '8px monospace'
ctx.textAlign = 'center'
const uri = `bitcoin:${props.address}`
const lines: string[] = []
for (let i = 0; i < uri.length; i += 24) {
lines.push(uri.slice(i, i + 24))
}
const startY = Math.max(10, 80 - (lines.length * 5))
lines.forEach((line, i) => {
ctx.fillText(line, 80, startY + i * 10)
})
}
onMounted(() => {
drawQR()
})
</script>
@@ -0,0 +1,130 @@
<template>
<div class="rounded-xl bg-white/5 border border-white/10 p-3 space-y-2">
<div class="flex items-center gap-2">
<span class="text-xs text-accent/60 uppercase tracking-wider font-bold">Lightning Invoice</span>
<span
v-if="isExpired"
class="text-xs px-1.5 py-0.5 rounded bg-red-400/15 text-red-400/80"
>
Expired
</span>
</div>
<!-- Amount -->
<div v-if="decodedAmount" class="flex items-center gap-2">
<span class="text-lg font-bold text-accent tabular-nums">{{ formatSats(decodedAmount) }}</span>
<span class="text-xs text-white/30">sats</span>
</div>
<!-- Description -->
<p v-if="decodedDescription" class="text-xs text-white/50">{{ decodedDescription }}</p>
<!-- Expiry countdown -->
<div v-if="expiryText" class="flex items-center gap-1.5">
<span
class="text-xs tabular-nums"
:class="isExpired ? 'text-red-400/60' : isExpiringSoon ? 'text-red-400/60' : 'text-white/30'"
>
{{ expiryText }}
</span>
</div>
<!-- Invoice string -->
<p class="text-xs font-mono text-white/30 break-all line-clamp-2 select-all">{{ invoice }}</p>
<div class="flex gap-2">
<button
class="flex-1 py-2 rounded-lg text-xs bg-white/5 text-white/50 hover:text-white/70 hover:bg-white/10 transition-colors"
@click="copyInvoice"
>
{{ copied ? 'Copied!' : 'Copy' }}
</button>
<a
:href="'lightning:' + invoice"
class="flex-1 py-2 rounded-lg text-xs text-center bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
>
Pay with wallet
</a>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps<{
invoice: string
}>()
const copied = ref(false)
const now = ref(Math.floor(Date.now() / 1000))
let timer: ReturnType<typeof setInterval> | null = null
// Basic BOLT11 decoding (amount from hrp, no full decode)
const decodedAmount = computed(() => {
const lower = props.invoice.toLowerCase()
// lnbc<amount><multiplier>1...
const match = lower.match(/^lnbc(\d+)([munp]?)1/)
if (!match) return null
const num = parseInt(match[1])
const mult = match[2]
switch (mult) {
case 'm': return num * 100000 // milli-btc to sats
case 'u': return num * 100 // micro-btc to sats
case 'n': return Math.round(num * 0.1) // nano-btc to sats
case 'p': return Math.round(num * 0.0001) // pico-btc to sats
default: return num * 100000000 // btc to sats
}
})
const decodedDescription = computed<string | null>(() => {
// Description is in tagged fields — basic extraction not possible without full decode
return null
})
const expiryTimestamp = computed<number | null>(() => {
// Default BOLT11 expiry is 3600s — we can't decode exact timestamp without full decode
return null
})
const isExpired = computed(() => {
if (!expiryTimestamp.value) return false
return now.value > expiryTimestamp.value
})
const isExpiringSoon = computed(() => {
if (!expiryTimestamp.value) return false
return expiryTimestamp.value - now.value < 300 // < 5 min
})
const expiryText = computed(() => {
if (!expiryTimestamp.value) return null
const diff = expiryTimestamp.value - now.value
if (diff <= 0) return 'Expired'
const min = Math.floor(diff / 60)
if (min < 60) return `Expires in ${min}m`
return `Expires in ${Math.floor(min / 60)}h ${min % 60}m`
})
function formatSats(sats: number): string {
return sats.toLocaleString()
}
function copyInvoice() {
navigator.clipboard.writeText(props.invoice)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
onMounted(() => {
timer = setInterval(() => {
now.value = Math.floor(Date.now() / 1000)
}, 1000)
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
</script>
@@ -0,0 +1,72 @@
<template>
<div class="rounded-xl bg-white/5 border border-white/10 p-3 space-y-2">
<div class="flex items-center gap-2">
<span class="text-xs text-accent/60 uppercase tracking-wider font-bold">BOLT12 Offer</span>
</div>
<p class="text-xs font-mono text-white/50 break-all line-clamp-2 select-all">{{ offer }}</p>
<!-- QR -->
<div class="flex justify-center py-2">
<canvas ref="qrCanvas" class="rounded-lg" width="140" height="140" />
</div>
<div class="flex gap-2">
<button
class="flex-1 py-2 rounded-lg text-xs bg-white/5 text-white/50 hover:text-white/70 hover:bg-white/10 transition-colors"
@click="copyOffer"
>
{{ copied ? 'Copied!' : 'Copy Offer' }}
</button>
<a
:href="'lightning:' + offer"
class="flex-1 py-2 rounded-lg text-xs text-center bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
>
Pay with wallet
</a>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const props = defineProps<{
offer: string
}>()
const qrCanvas = ref<HTMLCanvasElement | null>(null)
const copied = ref(false)
function copyOffer() {
navigator.clipboard.writeText(props.offer)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
function drawQR() {
const canvas = qrCanvas.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.fillStyle = '#1a1a1a'
ctx.fillRect(0, 0, 140, 140)
ctx.fillStyle = '#F7931A'
ctx.font = '7px monospace'
ctx.textAlign = 'center'
const lines: string[] = []
for (let i = 0; i < props.offer.length; i += 22) {
lines.push(props.offer.slice(i, i + 22))
}
const startY = Math.max(8, 70 - (lines.length * 4))
lines.slice(0, 14).forEach((line, i) => {
ctx.fillText(line, 70, startY + i * 9)
})
}
onMounted(() => {
drawQR()
})
</script>
@@ -0,0 +1,127 @@
<template>
<div class="code-runner rounded-xl bg-white/5 border border-white/10 overflow-hidden">
<!-- Toolbar -->
<div class="flex items-center gap-2 px-3 py-2 border-b border-white/5">
<span class="text-xs text-white/30 uppercase tracking-wider">{{ language }}</span>
<div class="flex-1" />
<button
class="text-xs px-2.5 py-1 rounded bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
@click="runCode"
>
Run
</button>
<button
v-if="consoleOutput.length > 0"
class="text-xs px-2 py-1 rounded bg-white/5 text-white/40 hover:text-white/60 hover:bg-white/10 transition-colors"
@click="clearOutput"
>
Clear
</button>
</div>
<!-- Code display -->
<pre class="px-3 py-2 text-xs text-white/70 overflow-x-auto max-h-48 bg-black/20"><code>{{ code }}</code></pre>
<!-- Output iframe (hidden, used for execution) -->
<iframe
v-if="showIframe"
ref="iframeRef"
:srcdoc="srcdoc"
sandbox="allow-scripts"
class="w-full border-t border-white/5"
:class="isHtml ? 'h-48' : 'h-0 invisible'"
title="Code output"
/>
<!-- Console output -->
<div
v-if="consoleOutput.length > 0"
class="border-t border-white/5 bg-black/30 px-3 py-2 max-h-32 overflow-y-auto"
>
<p class="text-xs text-white/20 uppercase tracking-wider mb-1">Console</p>
<div
v-for="(entry, i) in consoleOutput"
:key="i"
class="text-xs font-mono leading-relaxed"
:class="entry.type === 'error' ? 'text-red-400/80' : 'text-white/60'"
>
{{ entry.text }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps<{
code: string
language: string
}>()
interface ConsoleEntry {
type: 'log' | 'error'
text: string
}
const consoleOutput = ref<ConsoleEntry[]>([])
const showIframe = ref(false)
const iframeRef = ref<HTMLIFrameElement | null>(null)
const isHtml = computed(() => props.language === 'html')
const srcdoc = computed(() => buildSrcdoc(props.language, props.code))
function buildSrcdoc(lang: string, code: string): string {
// Build script tags via concatenation to avoid parser issues
const sOpen = String.fromCharCode(60) + 'script>'
const sClose = String.fromCharCode(60) + '/script>'
const capture = sOpen
+ 'const _post=(t,a)=>parent.postMessage({type:"code-runner-console",level:t,text:a.map(x=>typeof x==="object"?JSON.stringify(x):String(x)).join(" ")},"*");'
+ 'console.log=(...a)=>_post("log",a);'
+ 'console.error=(...a)=>_post("error",a);'
+ 'window.onerror=(m)=>_post("error",[m]);'
+ sClose
if (lang === 'html') {
return '<!DOCTYPE html><html><head><style>body{background:#0a0a0a;color:#e0e0e0;font-family:system-ui;margin:8px}</style>' + capture + '</head><body>' + code + '</body></html>'
}
if (lang === 'javascript' || lang === 'js') {
return '<!DOCTYPE html><html><head>' + capture + '</head><body>' + sOpen + code + sClose + '</body></html>'
}
if (lang === 'css') {
return '<!DOCTYPE html><html><head><style>' + code + '</style></head><body style="background:#0a0a0a"><div style="padding:16px;color:#e0e0e0;font-family:system-ui">CSS Preview</div></body></html>'
}
return ''
}
function runCode() {
consoleOutput.value = []
showIframe.value = false
// Force re-mount iframe
requestAnimationFrame(() => {
showIframe.value = true
})
}
function clearOutput() {
consoleOutput.value = []
}
function handleMessage(e: MessageEvent) {
if (e.data?.type === 'code-runner-console') {
consoleOutput.value.push({
type: e.data.level === 'error' ? 'error' : 'log',
text: e.data.text,
})
}
}
onMounted(() => {
window.addEventListener('message', handleMessage)
})
onBeforeUnmount(() => {
window.removeEventListener('message', handleMessage)
})
</script>
@@ -0,0 +1,142 @@
<template>
<div class="event-card rounded-xl bg-white/5 border border-white/10 px-4 py-3">
<div class="flex items-start gap-3">
<!-- Date chip -->
<div class="shrink-0 w-14 text-center rounded-lg bg-accent/10 border border-accent/20 py-1.5">
<p class="text-xs text-accent/70 uppercase">{{ monthLabel }}</p>
<p class="text-lg font-bold text-accent leading-tight">{{ dayLabel }}</p>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-white/90 truncate">{{ event.title }}</h3>
<p v-if="event.location" class="text-xs text-white/50 mt-0.5 truncate">{{ event.location }}</p>
<p v-if="event.description" class="text-xs text-white/40 mt-1 line-clamp-2">{{ event.description }}</p>
<!-- Countdown -->
<p v-if="countdownText" class="text-xs mt-1.5" :class="isPast ? 'text-white/30' : 'text-accent/70'">
{{ countdownText }}
</p>
<!-- Actions -->
<div class="flex gap-2 mt-2">
<button
class="text-xs px-2 py-1 rounded bg-white/5 border border-white/10 text-white/60 hover:text-white/80 hover:bg-white/10 transition-colors"
title="Download ICS file"
@click.stop="downloadIcs"
>
Add to Calendar
</button>
<a
:href="googleCalendarUrl"
target="_blank"
rel="noopener noreferrer"
class="text-xs px-2 py-1 rounded bg-white/5 border border-white/10 text-white/60 hover:text-white/80 hover:bg-white/10 transition-colors"
@click.stop
>
Google Calendar
</a>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import type { EventData } from '@/composables/contentExtraction'
const props = defineProps<{ event: EventData }>()
const now = ref(Date.now())
let timer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
timer = setInterval(() => { now.value = Date.now() }, 1000)
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
const eventDate = computed(() => {
if (!props.event.date) return null
const d = new Date(props.event.date)
return isNaN(d.getTime()) ? null : d
})
const monthLabel = computed(() => {
if (!eventDate.value) return '?'
return eventDate.value.toLocaleString('en', { month: 'short' }).toUpperCase()
})
const dayLabel = computed(() => {
if (!eventDate.value) return '?'
return eventDate.value.getDate()
})
const isPast = computed(() => {
if (!eventDate.value) return false
return eventDate.value.getTime() < now.value
})
const countdownText = computed(() => {
if (!eventDate.value) return ''
const diff = eventDate.value.getTime() - now.value
if (diff < 0) return 'Event has passed'
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
if (days > 0) return `${days}d ${hours}h remaining`
if (hours > 0) return `${hours}h ${minutes}m remaining`
return `${minutes}m remaining`
})
function formatIcsDate(d: Date): string {
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
}
function downloadIcs() {
const d = eventDate.value
if (!d) return
const end = new Date(d.getTime() + 60 * 60 * 1000) // 1 hour default
const ics = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'BEGIN:VEVENT',
`DTSTART:${formatIcsDate(d)}`,
`DTEND:${formatIcsDate(end)}`,
`SUMMARY:${props.event.title}`,
props.event.location ? `LOCATION:${props.event.location}` : '',
props.event.description ? `DESCRIPTION:${props.event.description}` : '',
props.event.url ? `URL:${props.event.url}` : '',
'END:VEVENT',
'END:VCALENDAR',
].filter(Boolean).join('\r\n')
const blob = new Blob([ics], { type: 'text/calendar' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${props.event.title.replace(/[^a-zA-Z0-9]/g, '_')}.ics`
a.click()
URL.revokeObjectURL(url)
}
const googleCalendarUrl = computed(() => {
const d = eventDate.value
if (!d) return '#'
const start = formatIcsDate(d).replace('Z', '')
const end = formatIcsDate(new Date(d.getTime() + 60 * 60 * 1000)).replace('Z', '')
const params = new URLSearchParams({
action: 'TEMPLATE',
text: props.event.title,
dates: `${start}/${end}`,
})
if (props.event.location) params.set('location', props.event.location)
if (props.event.description) params.set('details', props.event.description)
return `https://calendar.google.com/calendar/render?${params.toString()}`
})
</script>
@@ -0,0 +1,54 @@
<template>
<div class="rounded-xl bg-white/5 border border-white/10 p-3 space-y-2">
<div class="flex items-center gap-2">
<span class="text-xs text-accent/60 uppercase tracking-wider font-bold">Fedimint Ecash</span>
</div>
<p class="text-xs font-mono text-white/50 break-all line-clamp-2 select-all">{{ token }}</p>
<div class="flex items-center gap-3">
<span class="text-sm font-bold text-accent tabular-nums">{{ formattedAmount }}</span>
</div>
<div class="flex gap-2">
<button
class="flex-1 py-2 rounded-lg text-xs bg-white/5 text-white/50 hover:text-white/70 hover:bg-white/10 transition-colors"
@click="copyToken"
>
{{ copied ? 'Copied!' : 'Copy Token' }}
</button>
<a
:href="fediLink"
class="flex-1 py-2 rounded-lg text-xs text-center bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
>
Receive in Fedi
</a>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{
token: string
}>()
const copied = ref(false)
const formattedAmount = computed(() => {
// Fedimint ecash tokens are base64-encoded — we can't decode amount without
// the Fedimint library, so show a generic label
return 'ecash token'
})
const fediLink = computed(() => {
return `fedi://receive?token=${encodeURIComponent(props.token)}`
})
function copyToken() {
navigator.clipboard.writeText(props.token)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
</script>
@@ -0,0 +1,134 @@
<template>
<div class="interactive-table my-4 rounded-lg bg-white/5 border border-white/5 overflow-hidden">
<!-- Toolbar -->
<div class="flex items-center gap-2 px-3 py-2 border-b border-white/5">
<input
v-model="filterQuery"
type="text"
class="flex-1 bg-white/5 border border-white/10 rounded px-2 py-1 text-base text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
placeholder="Filter rows..."
/>
<button
class="text-xs px-2 py-1 rounded bg-white/5 border border-white/10 text-white/50 hover:text-white/80 hover:bg-white/10 transition-colors"
title="Export CSV"
@click="exportCsv"
>
CSV
</button>
</div>
<!-- Table -->
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr class="border-b border-white/10">
<th
v-for="(header, col) in headers"
:key="col"
class="px-3 py-2 text-left text-white/50 font-medium cursor-pointer hover:text-white/70 select-none whitespace-nowrap"
@click="toggleSort(col)"
>
<span class="inline-flex items-center gap-1">
{{ header }}
<span v-if="sortCol === col" class="text-accent">
{{ sortDir === 'asc' ? '' : '' }}
</span>
</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, i) in displayRows"
:key="i"
class="border-b border-white/5 hover:bg-white/5 transition-colors"
>
<td
v-for="(cell, j) in row"
:key="j"
class="px-3 py-1.5 text-white/70 whitespace-nowrap"
>
{{ cell }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="filteredRows.length === 0" class="px-3 py-4 text-center text-xs text-white/30">
No matching rows
</div>
<div v-if="filteredRows.length > 0" class="px-3 py-1.5 text-xs text-white/25 border-t border-white/5">
{{ filteredRows.length }} row{{ filteredRows.length === 1 ? '' : 's' }}
<span v-if="filterQuery"> (filtered from {{ rows.length }})</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{
headers: string[]
rows: string[][]
}>()
const filterQuery = ref('')
const sortCol = ref(-1)
const sortDir = ref<'asc' | 'desc'>('asc')
function toggleSort(col: number) {
if (sortCol.value === col) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
} else {
sortCol.value = col
sortDir.value = 'asc'
}
}
const filteredRows = computed(() => {
const q = filterQuery.value.toLowerCase()
if (!q) return props.rows
return props.rows.filter((row) =>
row.some((cell) => cell.toLowerCase().includes(q))
)
})
const sortedRows = computed(() => {
if (sortCol.value < 0) return filteredRows.value
const col = sortCol.value
const dir = sortDir.value === 'asc' ? 1 : -1
return [...filteredRows.value].sort((a, b) => {
const aVal = a[col] || ''
const bVal = b[col] || ''
// Try numeric sort
const aNum = parseFloat(aVal)
const bNum = parseFloat(bVal)
if (!isNaN(aNum) && !isNaN(bNum)) return (aNum - bNum) * dir
return aVal.localeCompare(bVal) * dir
})
})
const displayRows = computed(() => sortedRows.value)
function exportCsv() {
const header = props.headers.map(escapeCsvCell).join(',')
const body = props.rows.map((row) => row.map(escapeCsvCell).join(',')).join('\n')
const csv = header + '\n' + body
const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'table.csv'
a.click()
URL.revokeObjectURL(url)
}
function escapeCsvCell(cell: string): string {
if (cell.includes(',') || cell.includes('"') || cell.includes('\n')) {
return `"${cell.replace(/"/g, '""')}"`
}
return cell
}
</script>
@@ -0,0 +1,139 @@
<template>
<div class="map-renderer h-full flex flex-col">
<!-- Toolbar -->
<div class="flex items-center gap-2 px-4 py-2 bg-black/60 backdrop-blur-md border-b border-white/5 shrink-0">
<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"
title="Back"
@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>
<span class="flex-1 text-xs text-white/40">{{ places.length }} place{{ places.length === 1 ? '' : 's' }}</span>
</div>
<!-- Map + place list -->
<div class="flex-1 flex overflow-hidden">
<!-- Map container -->
<div ref="mapContainer" class="flex-1 min-h-0" />
<!-- Place list sidebar (desktop only) -->
<aside
v-if="places.length > 1"
class="hidden md:flex flex-col w-56 border-l border-white/5 overflow-y-auto scrollbar-hide"
>
<button
v-for="(place, i) in places"
:key="place.id"
class="text-left px-3 py-2 border-b border-white/5 transition-colors hover:bg-white/5"
:class="selectedIdx === i ? 'bg-accent/10' : ''"
@click="selectPlace(i)"
>
<p class="text-xs text-white/80 truncate">{{ place.name }}</p>
<p v-if="place.address || place.city" class="text-xs text-white/40 truncate">
{{ place.address || place.city }}
</p>
</button>
</aside>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, shallowRef } from 'vue'
import type { Place } from '@aiui/core/types/content'
const props = defineProps<{
places: Place[]
}>()
defineEmits<{ back: [] }>()
const mapContainer = ref<HTMLElement | null>(null)
const selectedIdx = ref(-1)
type LeafletMap = import('leaflet').Map
const mapInstance = shallowRef<LeafletMap | null>(null)
async function initMap() {
if (!mapContainer.value || props.places.length === 0) return
const L = await import('leaflet')
// Import leaflet CSS
await import('leaflet/dist/leaflet.css')
// Calculate bounds from places with coordinates
const withCoords = props.places.filter((p) => p.lat != null && p.lng != null)
if (withCoords.length === 0) return
const map = L.map(mapContainer.value, {
zoomControl: true,
attributionControl: true,
})
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19,
}).addTo(map)
// Custom orange marker icon
const orangeIcon = L.divIcon({
html: `<div style="background:#F7931A;width:12px;height:12px;border-radius:50%;border:2px solid rgba(255,255,255,0.8);box-shadow:0 1px 4px rgba(0,0,0,0.4)"></div>`,
className: '',
iconSize: [12, 12],
iconAnchor: [6, 6],
})
const markers: import('leaflet').Marker[] = []
const bounds = L.latLngBounds([])
for (const place of withCoords) {
const latlng = L.latLng(place.lat!, place.lng!)
bounds.extend(latlng)
const marker = L.marker(latlng, { icon: orangeIcon }).addTo(map)
// Popup
let popupHtml = `<div style="font-family:system-ui;font-size:12px"><strong>${place.name}</strong>`
if (place.address) popupHtml += `<br><span style="color:#888">${place.address}</span>`
if (place.rating) popupHtml += `<br>Rating: ${place.rating}/5`
popupHtml += '</div>'
marker.bindPopup(popupHtml)
markers.push(marker)
}
if (withCoords.length === 1) {
map.setView([withCoords[0].lat!, withCoords[0].lng!], 14)
} else {
map.fitBounds(bounds, { padding: [40, 40] })
}
mapInstance.value = map
// Invalidate size after animation
setTimeout(() => map.invalidateSize(), 200)
}
function selectPlace(idx: number) {
selectedIdx.value = idx
const place = props.places[idx]
if (place.lat != null && place.lng != null && mapInstance.value) {
mapInstance.value.setView([place.lat, place.lng], 16, { animate: true })
}
}
onMounted(() => {
initMap()
})
watch(() => props.places, () => {
mapInstance.value?.remove()
mapInstance.value = null
initMap()
})
onBeforeUnmount(() => {
mapInstance.value?.remove()
})
</script>
@@ -0,0 +1,114 @@
<template>
<div class="rounded-xl bg-white/5 border border-white/10 p-3 space-y-2">
<div class="flex items-center gap-2">
<span class="text-xs text-accent/60 uppercase tracking-wider font-bold">Transaction</span>
<span
v-if="txData"
class="text-xs px-1.5 py-0.5 rounded"
:class="txData.confirmed ? 'bg-emerald-400/15 text-emerald-400/80' : 'bg-yellow-400/15 text-yellow-400/80'"
>
{{ txData.confirmed ? `${txData.confirmations} confirmations` : 'Unconfirmed' }}
</span>
</div>
<!-- TXID -->
<p class="text-xs font-mono text-white/40 break-all select-all">{{ txid }}</p>
<!-- TX details -->
<div v-if="txData" class="grid grid-cols-2 gap-2 text-xs">
<div>
<p class="text-white/25">Fee</p>
<p class="text-white/60 tabular-nums">{{ txData.fee.toLocaleString() }} sats</p>
</div>
<div>
<p class="text-white/25">Fee Rate</p>
<p class="text-white/60 tabular-nums">{{ txData.feeRate }} sat/vB</p>
</div>
<div>
<p class="text-white/25">Size</p>
<p class="text-white/60 tabular-nums">{{ txData.size }} vB</p>
</div>
<div>
<p class="text-white/25">Block</p>
<p class="text-white/60 tabular-nums">{{ txData.blockHeight ?? 'Pending' }}</p>
</div>
</div>
<div v-else-if="isLoading" class="py-2">
<p class="text-xs text-white/30">Loading transaction...</p>
</div>
<div v-else-if="error" class="py-2">
<p class="text-xs text-red-400/60">{{ error }}</p>
</div>
<a
:href="`https://mempool.space/tx/${txid}`"
target="_blank"
rel="noopener noreferrer"
class="block w-full py-2 rounded-lg text-xs text-center bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
>
View on Mempool.space
</a>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const props = defineProps<{
txid: string
}>()
interface TxInfo {
fee: number
feeRate: number
size: number
confirmed: boolean
confirmations: number
blockHeight: number | null
}
const txData = ref<TxInfo | null>(null)
const isLoading = ref(true)
const error = ref('')
async function fetchTx() {
isLoading.value = true
error.value = ''
try {
const res = await fetch(`https://mempool.space/api/tx/${props.txid}`)
if (!res.ok) throw new Error('Transaction not found')
const data = await res.json()
const confirmed = !!data.status?.confirmed
let confirmations = 0
if (confirmed && data.status?.block_height) {
const tipRes = await fetch('https://mempool.space/api/blocks/tip/height')
if (tipRes.ok) {
const tipHeight = parseInt(await tipRes.text())
confirmations = tipHeight - data.status.block_height + 1
}
}
txData.value = {
fee: data.fee ?? 0,
feeRate: data.weight ? Math.round((data.fee / data.weight) * 4) : 0,
size: data.weight ? Math.round(data.weight / 4) : data.size ?? 0,
confirmed,
confirmations,
blockHeight: data.status?.block_height ?? null,
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load transaction'
} finally {
isLoading.value = false
}
}
onMounted(() => {
fetchTx()
})
</script>
@@ -0,0 +1,174 @@
<template>
<div class="pdf-viewer h-full flex flex-col">
<!-- Toolbar -->
<div class="flex items-center gap-2 px-4 py-2 bg-black/60 backdrop-blur-md border-b border-white/5 shrink-0">
<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"
title="Back"
@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>
<span class="text-xs text-white/40 truncate flex-1">{{ title || 'PDF' }}</span>
<!-- Page nav -->
<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 disabled:opacity-30"
:disabled="currentPage <= 1"
title="Previous page"
@click="goToPage(currentPage - 1)"
>
<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/50 tabular-nums min-w-[60px] text-center">{{ currentPage }} / {{ totalPages }}</span>
<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 disabled:opacity-30"
:disabled="currentPage >= totalPages"
title="Next page"
@click="goToPage(currentPage + 1)"
>
<svg class="w-4 h-4 rotate-180" 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>
<!-- Zoom -->
<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 disabled:opacity-30"
:disabled="scale <= 0.5"
title="Zoom out"
@click="scale = Math.max(0.5, scale - 0.25)"
>
<span class="text-xs font-bold"></span>
</button>
<span class="text-xs text-white/40 tabular-nums w-10 text-center">{{ Math.round(scale * 100) }}%</span>
<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 disabled:opacity-30"
:disabled="scale >= 2"
title="Zoom in"
@click="scale = Math.min(2, scale + 0.25)"
>
<span class="text-xs font-bold">+</span>
</button>
</div>
<!-- PDF canvas container -->
<div ref="containerRef" class="flex-1 overflow-auto bg-black/20 p-4 flex justify-center">
<div v-if="loading" class="flex items-center justify-center h-full">
<div class="text-sm text-white/50">Loading PDF...</div>
</div>
<div v-else-if="error" class="flex items-center justify-center h-full">
<div class="text-sm text-red-400/70">{{ error }}</div>
</div>
<canvas
v-show="!loading && !error"
ref="canvasRef"
class="shadow-lg rounded"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, shallowRef } from 'vue'
const props = defineProps<{
url: string
title?: string
}>()
defineEmits<{ back: [] }>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const containerRef = ref<HTMLElement | null>(null)
const currentPage = ref(1)
const totalPages = ref(0)
const scale = ref(1)
const loading = ref(true)
const error = ref('')
// Lazy-load pdfjs-dist
type PDFDocumentProxy = import('pdfjs-dist').PDFDocumentProxy
const pdfDoc = shallowRef<PDFDocumentProxy | null>(null)
async function loadPdf() {
loading.value = true
error.value = ''
try {
const pdfjsLib = await import('pdfjs-dist')
// Set worker source
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString()
const doc = await pdfjsLib.getDocument(props.url).promise
pdfDoc.value = doc
totalPages.value = doc.numPages
currentPage.value = 1
await renderPage()
} catch (e) {
error.value = `Failed to load PDF: ${e instanceof Error ? e.message : 'Unknown error'}`
} finally {
loading.value = false
}
}
async function renderPage() {
const doc = pdfDoc.value
const canvas = canvasRef.value
if (!doc || !canvas) return
try {
const page = await doc.getPage(currentPage.value)
const viewport = page.getViewport({ scale: scale.value })
canvas.height = viewport.height
canvas.width = viewport.width
const ctx = canvas.getContext('2d')
if (!ctx) return
await page.render({
canvasContext: ctx,
canvas: canvas,
viewport,
}).promise
} catch (e) {
error.value = `Failed to render page: ${e instanceof Error ? e.message : 'Unknown error'}`
}
}
function goToPage(page: number) {
if (page < 1 || page > totalPages.value) return
currentPage.value = page
}
watch(currentPage, () => renderPage())
watch(scale, () => renderPage())
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
goToPage(currentPage.value - 1)
e.preventDefault()
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
goToPage(currentPage.value + 1)
e.preventDefault()
}
}
onMounted(() => {
loadPdf()
window.addEventListener('keydown', handleKeyDown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyDown)
pdfDoc.value?.destroy()
})
</script>
@@ -0,0 +1,111 @@
<template>
<div class="recipe-card rounded-xl bg-white/5 border border-white/10 overflow-hidden">
<!-- Header -->
<div class="px-4 py-3 border-b border-white/5">
<h3 class="text-sm font-semibold text-white/90">{{ recipe.title }}</h3>
<div class="flex gap-3 mt-1.5">
<span v-if="recipe.time" class="text-xs text-white/40 flex items-center gap-1">
<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="text-xs text-white/40 flex items-center gap-1">
<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>
{{ scaledServings }} servings
</span>
<span v-if="recipe.calories" class="text-xs text-white/40">{{ recipe.calories }} cal</span>
</div>
</div>
<!-- Scale slider -->
<div class="px-4 py-2 border-b border-white/5 flex items-center gap-3">
<label class="text-xs text-white/30">Scale</label>
<input
v-model.number="scaleFactor"
type="range"
min="0.5"
max="4"
step="0.5"
class="flex-1 h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
/>
<span class="text-xs text-white/50 tabular-nums w-8 text-right">{{ scaleFactor }}×</span>
</div>
<!-- Ingredients -->
<div class="px-4 py-3 border-b border-white/5">
<h4 class="text-xs text-white/40 uppercase tracking-wider mb-2">Ingredients</h4>
<ul class="space-y-1">
<li
v-for="(ing, i) in scaledIngredients"
:key="i"
class="flex items-start gap-2 text-xs cursor-pointer select-none"
:class="checkedIngredients.has(i) ? 'line-through text-white/30' : 'text-white/70'"
@click="toggleIngredient(i)"
>
<span class="shrink-0 mt-0.5 w-4 h-4 rounded border flex items-center justify-center transition-colors"
:class="checkedIngredients.has(i) ? 'border-accent/50 bg-accent/20' : 'border-white/20'">
<svg v-if="checkedIngredients.has(i)" class="w-2.5 h-2.5 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-3">
<h4 class="text-xs text-white/40 uppercase tracking-wider mb-2">Steps</h4>
<ol class="space-y-2">
<li
v-for="(step, i) in recipe.steps"
:key="i"
class="flex gap-2 text-xs text-white/70"
>
<span class="shrink-0 w-5 h-5 rounded-full bg-accent/15 text-accent text-xs flex items-center justify-center font-medium">{{ i + 1 }}</span>
<span class="leading-relaxed">{{ step }}</span>
</li>
</ol>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
export interface Recipe {
title: string
servings?: string
time?: string
calories?: string
ingredients: string[]
steps: string[]
}
const props = defineProps<{ recipe: Recipe }>()
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)
})
// Scale numeric quantities in ingredients
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,53 @@
<template>
<div class="timeline-renderer my-4">
<div class="relative">
<!-- Vertical line -->
<div class="absolute left-4 md:left-1/2 top-0 bottom-0 w-px bg-white/10 md:-translate-x-px" />
<!-- Timeline entries -->
<div
v-for="(event, i) in events"
:key="i"
class="relative flex items-start gap-4 mb-6 animate-fade-up-fast"
:class="i % 2 === 0 ? 'md:flex-row' : 'md:flex-row-reverse'"
:style="{ animationDelay: `${i * 80}ms` }"
>
<!-- Dot -->
<div
class="absolute left-4 md:left-1/2 w-3 h-3 rounded-full bg-accent border-2 border-black z-10 md:-translate-x-1.5"
:style="{ top: '6px' }"
/>
<!-- Date (mobile: inline, desktop: left/right side) -->
<div class="hidden md:block w-[calc(50%-2rem)] text-right shrink-0" :class="i % 2 === 0 ? '' : 'order-last text-left'">
<p class="text-xs text-white/40 tabular-nums pt-1">{{ formatDate(event.date) }}</p>
</div>
<!-- Card -->
<div class="ml-10 md:ml-0 md:w-[calc(50%-2rem)] shrink-0">
<div class="rounded-lg bg-white/5 border border-white/10 px-3 py-2">
<p class="text-xs text-white/30 md:hidden tabular-nums">{{ formatDate(event.date) }}</p>
<h4 class="text-xs font-medium text-white/90">{{ event.title }}</h4>
<p v-if="event.location" class="text-xs text-white/40 mt-0.5">{{ event.location }}</p>
<p v-if="event.description" class="text-xs text-white/50 mt-1 line-clamp-2">{{ event.description }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { EventData } from '@/composables/contentExtraction'
defineProps<{
events: EventData[]
}>()
function formatDate(dateStr: string): string {
if (!dateStr) return ''
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
return d.toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' })
}
</script>
@@ -0,0 +1,102 @@
<template>
<div class="video-player rounded-xl bg-white/5 border border-white/10 overflow-hidden">
<!-- YouTube embed -->
<div v-if="youtubeId" class="relative w-full" style="aspect-ratio: 16/9">
<iframe
:src="`https://www.youtube-nocookie.com/embed/${youtubeId}`"
class="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
title="Video"
/>
</div>
<!-- Native video -->
<div v-else class="relative w-full" style="aspect-ratio: 16/9">
<video
ref="videoRef"
class="absolute inset-0 w-full h-full bg-black"
controls
playsinline
preload="metadata"
:poster="poster"
>
<source v-if="!isHls" :src="url" />
</video>
</div>
<!-- Title bar -->
<div v-if="title" class="px-3 py-2 border-t border-white/5">
<p class="text-xs text-white/60 truncate">{{ title }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, shallowRef, watch } from 'vue'
const props = defineProps<{
url: string
title?: string
poster?: string
}>()
const videoRef = ref<HTMLVideoElement | null>(null)
// YouTube detection
const youtubeId = computed(() => {
const ytRe = /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/
const m = props.url.match(ytRe)
return m ? m[1] : null
})
// HLS detection
const isHls = computed(() =>
/\.m3u8(\?|$)/i.test(props.url) || /\.hls(\?|$)/i.test(props.url)
)
type HlsInstance = import('hls.js').default
const hls = shallowRef<HlsInstance | null>(null)
const hlsError = ref('')
async function initHls() {
if (!isHls.value || !videoRef.value || youtubeId.value) return
try {
const HlsModule = await import('hls.js')
const Hls = HlsModule.default
if (!Hls.isSupported()) {
// Try native HLS support (Safari)
if (videoRef.value.canPlayType('application/vnd.apple.mpegurl')) {
videoRef.value.src = props.url
}
return
}
const instance = new Hls()
instance.loadSource(props.url)
instance.attachMedia(videoRef.value)
hls.value = instance
} catch (e) {
hlsError.value = `Failed to initialize video: ${e instanceof Error ? e.message : 'Unknown error'}`
}
}
onMounted(() => {
if (isHls.value) {
initHls()
}
})
watch(() => props.url, () => {
hls.value?.destroy()
hls.value = null
if (isHls.value) initHls()
})
onBeforeUnmount(() => {
hls.value?.destroy()
})
</script>
@@ -0,0 +1,160 @@
<template>
<div class="space-y-4">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
API Keys
</h3>
<!-- Configured providers -->
<div class="space-y-2">
<div
v-for="provider in providers"
:key="provider.id"
class="flex items-center gap-3 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-1 min-w-0">
<div
class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'"
>
{{ provider.name }}
</div>
<div
class="text-xs font-mono mt-0.5"
:class="isDark ? 'text-white/30' : 'text-gray-400'"
>
{{ provider.masked ?? 'Not configured' }}
</div>
</div>
<button
v-if="provider.hasKey"
class="text-xs px-2 py-1 rounded-md transition-colors"
:class="isDark
? 'text-red-400/60 hover:text-red-400 hover:bg-red-500/10'
: 'text-red-500/60 hover:text-red-600 hover:bg-red-50'"
@click="removeKey(provider.id)"
>
Remove
</button>
</div>
</div>
<!-- Add new key -->
<div class="space-y-2">
<select
v-model="selectedProvider"
class="w-full px-3 py-2 rounded-lg text-xs outline-none"
:class="isDark
? 'bg-white/5 text-white/80 border border-white/10'
: 'bg-gray-50 text-gray-800 border border-gray-200'"
>
<option value="">Select provider...</option>
<option value="claude">Claude (Anthropic)</option>
<option value="openrouter">OpenRouter</option>
</select>
<div v-if="selectedProvider" class="flex gap-2">
<input
v-model="newKey"
type="password"
placeholder="Paste API key..."
class="flex-1 px-3 py-2 rounded-lg text-xs outline-none"
:class="isDark
? 'bg-white/5 text-white/80 placeholder:text-white/25 border border-white/10'
: 'bg-gray-50 text-gray-800 placeholder:text-gray-400 border border-gray-200'"
style="font-size: 16px"
@keydown.enter="saveKey"
/>
<button
:disabled="!newKey.trim()"
class="px-3 py-2 rounded-lg text-xs font-medium transition-colors"
:class="newKey.trim()
? 'bg-accent text-white hover:bg-accent/90'
: isDark
? 'bg-white/5 text-white/20 cursor-not-allowed'
: 'bg-gray-100 text-gray-300 cursor-not-allowed'"
@click="saveKey"
>
Save
</button>
</div>
<p
v-if="statusMessage"
class="text-xs"
:class="statusError ? 'text-red-400' : isDark ? 'text-green-400' : 'text-green-600'"
>
{{ statusMessage }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { storeApiKey, getApiKey, deleteApiKey, listProviders, maskApiKey } from '@/utils/key-vault'
const { isDark } = useTheme()
interface ProviderInfo {
id: string
name: string
hasKey: boolean
masked: string | null
}
const providers = ref<ProviderInfo[]>([])
const selectedProvider = ref('')
const newKey = ref('')
const statusMessage = ref('')
const statusError = ref(false)
const PROVIDER_NAMES: Record<string, string> = {
claude: 'Claude (Anthropic)',
openrouter: 'OpenRouter',
}
async function loadProviders() {
const configured = await listProviders()
const allProviders = ['claude', 'openrouter']
providers.value = await Promise.all(
allProviders.map(async (id) => {
const key = await getApiKey(id)
return {
id,
name: PROVIDER_NAMES[id] ?? id,
hasKey: !!key,
masked: key ? maskApiKey(key) : null,
}
})
)
}
async function saveKey() {
if (!selectedProvider.value || !newKey.value.trim()) return
try {
await storeApiKey(selectedProvider.value, newKey.value.trim())
statusMessage.value = `${PROVIDER_NAMES[selectedProvider.value] ?? selectedProvider.value} key saved`
statusError.value = false
newKey.value = ''
selectedProvider.value = ''
await loadProviders()
} catch (err) {
statusMessage.value = err instanceof Error ? err.message : 'Failed to store key — encryption required'
statusError.value = true
}
}
async function removeKey(provider: string) {
await deleteApiKey(provider)
statusMessage.value = `${PROVIDER_NAMES[provider] ?? provider} key removed`
statusError.value = false
await loadProviders()
}
onMounted(loadProviders)
</script>
@@ -0,0 +1,120 @@
<template>
<div class="space-y-3">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
Nostr Identity
</h3>
<!-- Logged in state -->
<div
v-if="isLoggedIn"
class="flex items-center gap-3 p-3 rounded-xl"
:class="isDark
? 'bg-white/[0.03] border border-white/5'
: 'bg-black/[0.02] border border-black/5'"
>
<div class="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center">
<svg class="w-4 h-4 text-purple-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<div
class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'"
>
Connected
</div>
<button
class="text-xs font-mono mt-0.5 hover:underline"
:class="isDark ? 'text-purple-400/60' : 'text-purple-600/60'"
@click="copyNpub"
>
{{ copied ? 'Copied!' : truncatedNpub }}
</button>
</div>
<button
class="text-xs px-2 py-1 rounded-md transition-colors"
:class="isDark
? 'text-red-400/60 hover:text-red-400 hover:bg-red-500/10'
: 'text-red-500/60 hover:text-red-600 hover:bg-red-50'"
@click="logout"
>
Logout
</button>
</div>
<!-- Logged out state -->
<template v-else>
<button
v-if="isAvailable"
:disabled="isLoading"
class="w-full px-4 py-2.5 rounded-xl text-sm font-medium transition-all active:scale-[0.98]"
:class="isLoading
? isDark
? 'bg-purple-500/10 text-purple-400/40 cursor-wait'
: 'bg-purple-50 text-purple-300 cursor-wait'
: 'bg-purple-500/10 text-purple-400 hover:bg-purple-500/20'"
@click="login"
>
{{ isLoading ? 'Connecting...' : 'Login with Nostr' }}
</button>
<div
v-else
class="p-3 rounded-xl text-xs"
:class="isDark
? 'bg-white/[0.03] border border-white/5 text-white/40'
: 'bg-black/[0.02] border border-black/5 text-gray-500'"
>
No Nostr extension detected. Install
<a
href="https://github.com/nicolgit/nos2x"
target="_blank"
rel="noopener noreferrer"
class="text-purple-400 hover:underline"
>nos2x</a>,
<a
href="https://getalby.com"
target="_blank"
rel="noopener noreferrer"
class="text-purple-400 hover:underline"
>Alby</a>, or another NIP-07 browser extension.
</div>
</template>
<!-- Error -->
<p
v-if="error"
class="text-xs text-red-400"
>
{{ error }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useNostrIdentity } from '@/composables/useNostrIdentity'
const { isDark } = useTheme()
const {
isAvailable,
isLoggedIn,
isLoading,
error,
npub,
truncatedNpub,
login,
logout,
} = useNostrIdentity()
const copied = ref(false)
async function copyNpub() {
if (!npub.value) return
await navigator.clipboard.writeText(npub.value)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
</script>
@@ -0,0 +1,363 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 border-b border-white/[0.08]">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-bold text-white/90">Plugins</h3>
<div class="flex items-center gap-2">
<span v-if="store.hasUpdates" class="text-xs px-1.5 py-0.5 rounded-full bg-accent/20 text-accent/80">
Updates
</span>
</div>
</div>
<div class="flex gap-1.5 flex-wrap">
<button
v-for="tab in tabs"
:key="tab.id"
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
:class="activeTab === tab.id
? 'nav-tab-active'
: 'text-white/40 hover:text-white/70 hover:bg-white/5'"
@click="activeTab = tab.id"
>
{{ tab.label }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-3">
<!-- Discover -->
<template v-if="activeTab === 'discover'">
<div v-if="store.isLoadingRegistry" class="flex items-center justify-center py-12">
<p class="text-xs text-white/30">Loading registry...</p>
</div>
<div v-else-if="store.registryError && store.registryPlugins.length === 0" class="py-8 text-center">
<p class="text-xs text-red-400/60">{{ store.registryError }}</p>
<button class="mt-2 text-xs text-accent/60 hover:text-accent/80" @click="store.fetchRegistry()">Retry</button>
</div>
<div
v-for="plugin in store.registryPlugins"
:key="plugin.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">{{ plugin.name }}</p>
<p class="text-xs text-white/30">{{ plugin.author }} · v{{ plugin.version }}</p>
</div>
<button
v-if="!store.isInstalled(plugin.id)"
class="text-xs px-3 py-1.5 rounded-lg bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors shrink-0"
@click="showPermissionsDialog(plugin)"
>
Install
</button>
<span v-else class="text-xs text-emerald-400/60 shrink-0">Installed</span>
</div>
<p class="text-xs text-white/50">{{ plugin.description }}</p>
<div class="flex items-center gap-2">
<span class="text-xs px-1.5 py-0.5 rounded bg-white/5 text-white/30">{{ plugin.type }}</span>
<div class="flex items-center gap-0.5">
<span v-for="i in 5" :key="i" class="text-xs" :class="i <= plugin.rating ? 'text-accent/60' : 'text-white/10'"></span>
</div>
</div>
</div>
</template>
<!-- Installed -->
<template v-else-if="activeTab === 'installed'">
<div v-if="store.installedPlugins.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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p class="text-xs text-white/30">No plugins installed</p>
</div>
<div
v-for="plugin in store.installedPlugins"
:key="plugin.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">{{ plugin.name }}</p>
<p class="text-xs text-white/30">v{{ plugin.version }} · {{ plugin.author }}</p>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<button
v-if="store.updatesAvailable.has(plugin.id)"
class="text-xs px-2 py-1 rounded bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
@click="store.updatePlugin(plugin.id)"
>
Update to v{{ store.updatesAvailable.get(plugin.id) }}
</button>
<button
class="text-xs min-w-[44px] min-h-[44px] flex items-center justify-center rounded text-white/30 hover:text-white/60 hover:bg-white/5 transition-colors"
@click="editingPlugin = editingPlugin === plugin.id ? null : plugin.id"
>
<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.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<button
class="text-xs px-2 py-1 rounded text-red-400/50 hover:text-red-400/80 hover:bg-red-400/10 transition-colors"
@click="store.uninstallPlugin(plugin.id)"
>
Remove
</button>
</div>
</div>
<!-- Permissions -->
<div class="flex flex-wrap gap-1">
<span
v-for="perm in plugin.permissions"
:key="perm"
class="text-xs px-1.5 py-0.5 rounded"
:class="plugin.grantedPermissions.includes(perm)
? 'bg-emerald-400/15 text-emerald-400/60'
: 'bg-red-400/15 text-red-400/60'"
>
{{ permissionLabel(perm) }}
</span>
</div>
<!-- Settings panel (inline) -->
<PluginSettingsForm
v-if="editingPlugin === plugin.id"
:plugin-id="plugin.id"
:settings="plugin.settings"
:permissions="plugin.permissions"
:granted-permissions="plugin.grantedPermissions"
@update-settings="(s) => store.updatePluginSettings(plugin.id, s)"
@update-permissions="(p) => store.updatePluginPermissions(plugin.id, p)"
/>
</div>
<!-- Update all -->
<button
v-if="store.hasUpdates"
class="w-full py-2.5 rounded-lg text-xs font-medium bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors mt-2"
@click="store.updateAllPlugins()"
>
Update all plugins
</button>
</template>
<!-- Import -->
<template v-else-if="activeTab === 'import'">
<div class="space-y-3">
<p class="text-xs text-white/40">
Paste a GitHub raw URL or IPFS CID to the plugin's manifest (aiui-plugin.json).
</p>
<input
v-model="importUrl"
type="text"
placeholder="https://raw.githubusercontent.com/.../aiui-plugin.json"
class="w-full px-3 py-2.5 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="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="!importUrl.trim() || isImporting"
@click="importPlugin"
>
{{ isImporting ? 'Fetching manifest...' : 'Import Plugin' }}
</button>
<p v-if="importError" class="text-xs text-red-400/60">{{ importError }}</p>
<!-- Imported plugin preview -->
<div v-if="importedManifest" class="rounded-xl bg-white/[0.03] border border-white/5 p-3 space-y-2">
<p class="text-xs font-semibold text-white/80">{{ importedManifest.name }}</p>
<p class="text-xs text-white/50">{{ importedManifest.description }}</p>
<p class="text-xs text-white/30">{{ importedManifest.author }} · v{{ importedManifest.version }}</p>
<button
class="w-full py-2 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
@click="showPermissionsDialog(importedManifest)"
>
Install
</button>
</div>
</div>
</template>
<!-- Dev Mode -->
<template v-else-if="activeTab === 'dev'">
<div v-if="!isDevMode" class="flex flex-col items-center justify-center py-12 gap-2">
<p class="text-xs text-white/30">Dev mode not enabled</p>
<p class="text-xs text-white/20">Set VITE_PLUGIN_DEV=true to enable</p>
</div>
<template v-else>
<div class="rounded-xl bg-white/[0.03] border border-white/5 p-3 space-y-2">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Plugin Dev Mode</p>
<p class="text-xs text-white/40">Hot-reload from src/plugins/dev/</p>
<div v-if="devErrors.length > 0" class="space-y-1 mt-2">
<p class="text-xs text-red-400/60 uppercase tracking-wider font-bold">Errors</p>
<div
v-for="(err, i) in devErrors"
:key="i"
class="text-xs text-red-400/50 font-mono bg-red-400/5 rounded p-2"
>
{{ err }}
</div>
</div>
<div v-if="devTimings.length > 0" class="space-y-1 mt-2">
<p class="text-xs text-white/30 uppercase tracking-wider font-bold">Init Timing</p>
<div
v-for="t in devTimings"
:key="t.id"
class="flex items-center justify-between text-xs"
>
<span class="text-white/50">{{ t.id }}</span>
<span class="text-white/30 tabular-nums">{{ t.ms }}ms</span>
</div>
</div>
</div>
</template>
</template>
</div>
<!-- Permissions dialog -->
<Teleport to="body">
<div v-if="pendingInstall" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div class="w-full max-w-sm mx-4 rounded-2xl bg-[#0a0a0a] border border-white/10 p-5 space-y-4">
<h4 class="text-sm font-bold text-white/90">Plugin Permissions</h4>
<p class="text-xs text-white/40">
"{{ pendingInstall.name }}" requests the following permissions:
</p>
<div class="space-y-2">
<label
v-for="perm in pendingInstall.permissions"
:key="perm"
class="flex items-center gap-2 p-2 rounded-lg bg-white/[0.03] border border-white/5 cursor-pointer"
>
<input
type="checkbox"
:checked="pendingPermissions.includes(perm)"
class="rounded accent-[#F7931A]"
@change="togglePermission(perm)"
/>
<div>
<p class="text-xs text-white/70">{{ permissionLabel(perm) }}</p>
<p class="text-xs text-white/30">{{ permissionDescription(perm) }}</p>
</div>
</label>
</div>
<div class="flex gap-2">
<button
class="flex-1 py-2 rounded-lg text-xs text-white/40 hover:text-white/70 hover:bg-white/5 transition-colors"
@click="pendingInstall = null"
>
Cancel
</button>
<button
class="flex-1 py-2 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
@click="confirmInstall"
>
Install
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { usePluginMarketplaceStore, type RegistryPlugin, type PluginPermission } from '@/stores/pluginMarketplace'
import PluginSettingsForm from './PluginSettingsForm.vue'
type Tab = 'discover' | 'installed' | 'import' | 'dev'
const tabs: { id: Tab; label: string }[] = [
{ id: 'discover', label: 'Discover' },
{ id: 'installed', label: 'Installed' },
{ id: 'import', label: 'Import' },
{ id: 'dev', label: 'Dev' },
]
const activeTab = ref<Tab>('discover')
const store = usePluginMarketplaceStore()
const editingPlugin = ref<string | null>(null)
// Import
const importUrl = ref('')
const isImporting = ref(false)
const importError = ref('')
const importedManifest = ref<RegistryPlugin | null>(null)
// Permissions dialog
const pendingInstall = ref<RegistryPlugin | null>(null)
const pendingPermissions = ref<PluginPermission[]>([])
// Dev mode
const isDevMode = !!import.meta.env.VITE_PLUGIN_DEV
const devErrors = ref<string[]>([])
const devTimings = ref<{ id: string; ms: number }[]>([])
function showPermissionsDialog(plugin: RegistryPlugin) {
pendingInstall.value = plugin
pendingPermissions.value = [...plugin.permissions]
}
function togglePermission(perm: PluginPermission) {
const idx = pendingPermissions.value.indexOf(perm)
if (idx >= 0) pendingPermissions.value.splice(idx, 1)
else pendingPermissions.value.push(perm)
}
function confirmInstall() {
if (!pendingInstall.value) return
store.installPlugin(pendingInstall.value, pendingPermissions.value)
pendingInstall.value = null
importedManifest.value = null
}
async function importPlugin() {
isImporting.value = true
importError.value = ''
importedManifest.value = null
const manifest = await store.importFromUrl(importUrl.value.trim())
if (manifest) {
importedManifest.value = manifest
} else {
importError.value = 'Invalid manifest or failed to fetch'
}
isImporting.value = false
}
function permissionLabel(perm: PluginPermission): string {
const labels: Record<PluginPermission, string> = {
'chat-messages': 'Chat Messages',
'network': 'Network Access',
'favorites': 'Favorites',
'storage': 'Local Storage',
'nostr': 'Nostr Identity',
'wallet': 'Wallet',
}
return labels[perm] ?? perm
}
function permissionDescription(perm: PluginPermission): string {
const descs: Record<PluginPermission, string> = {
'chat-messages': 'Read and inject content into chat messages',
'network': 'Make network requests to external APIs',
'favorites': 'Read and modify your favorites list',
'storage': 'Store data in local storage',
'nostr': 'Access your Nostr identity for signing',
'wallet': 'Interact with your connected wallet',
}
return descs[perm] ?? ''
}
onMounted(() => {
store.fetchRegistry().then(() => store.checkForUpdates())
})
</script>
@@ -0,0 +1,111 @@
<template>
<div class="mt-2 pt-2 border-t border-white/[0.05] space-y-3">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Settings</p>
<!-- Generic key-value settings editor -->
<div class="space-y-2">
<div v-for="(value, key) in localSettings" :key="key" class="flex items-center gap-2">
<span class="text-xs text-white/40 min-w-[60px]">{{ key }}</span>
<input
:value="String(value ?? '')"
class="flex-1 px-2 py-1.5 rounded-md text-base bg-white/5 text-white/70 outline-none focus:bg-white/10 transition-colors"
@input="updateSetting(key as string, ($event.target as HTMLInputElement).value)"
/>
</div>
<!-- Add new setting -->
<div class="flex gap-2">
<input
v-model="newSettingKey"
type="text"
placeholder="Key"
class="flex-1 px-2 py-1.5 rounded-md text-base bg-white/5 text-white/70 placeholder:text-white/20 outline-none focus:bg-white/10 transition-colors"
/>
<input
v-model="newSettingValue"
type="text"
placeholder="Value"
class="flex-1 px-2 py-1.5 rounded-md text-base bg-white/5 text-white/70 placeholder:text-white/20 outline-none focus:bg-white/10 transition-colors"
/>
<button
class="text-xs px-2 py-1.5 rounded-md bg-white/5 text-white/40 hover:text-white/70 hover:bg-white/10 transition-colors disabled:opacity-30"
:disabled="!newSettingKey.trim()"
@click="addSetting"
>
Add
</button>
</div>
</div>
<!-- Permissions toggles -->
<div v-if="permissions.length > 0">
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold mb-2">Permissions</p>
<div class="space-y-1">
<label
v-for="perm in permissions"
:key="perm"
class="flex items-center gap-2 text-xs cursor-pointer"
>
<input
type="checkbox"
:checked="localGranted.includes(perm)"
class="rounded accent-[#F7931A]"
@change="togglePermission(perm)"
/>
<span class="text-white/50">{{ perm }}</span>
</label>
</div>
</div>
<button
class="w-full py-2 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
@click="save"
>
Save Settings
</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { PluginPermission } from '@/stores/pluginMarketplace'
const props = defineProps<{
pluginId: string
settings: Record<string, unknown>
permissions: PluginPermission[]
grantedPermissions: PluginPermission[]
}>()
const emit = defineEmits<{
updateSettings: [settings: Record<string, unknown>]
updatePermissions: [permissions: PluginPermission[]]
}>()
const localSettings = ref<Record<string, unknown>>({ ...props.settings })
const localGranted = ref<PluginPermission[]>([...props.grantedPermissions])
const newSettingKey = ref('')
const newSettingValue = ref('')
function updateSetting(key: string, value: string) {
localSettings.value[key] = value
}
function addSetting() {
if (!newSettingKey.value.trim()) return
localSettings.value[newSettingKey.value.trim()] = newSettingValue.value
newSettingKey.value = ''
newSettingValue.value = ''
}
function togglePermission(perm: PluginPermission) {
const idx = localGranted.value.indexOf(perm)
if (idx >= 0) localGranted.value.splice(idx, 1)
else localGranted.value.push(perm)
}
function save() {
emit('updateSettings', { ...localSettings.value })
emit('updatePermissions', [...localGranted.value])
}
</script>

Some files were not shown because too many files have changed in this diff Show More