Archipelago — open-source initial import
This commit is contained in:
@@ -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">·</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)">×</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("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")"
|
||||
/>
|
||||
<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 }} × {{ 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: '© 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
Reference in New Issue
Block a user