Archipelago — open-source initial import
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user