feat(app): content detection overhaul, apps tab, chat UX, web search

- Overhaul content detection: expand all query/response classifiers with
  broader AI response patterns (news, music, books, TV, places, websites)
- Add Nostr detection (isNostrQuery, isNostrLikeResponse) and App detection
  (isAppQuery, isAppLikeResponse) classifiers
- Add bare domain extraction from AI text (e.g. "check out damus.io")
- Add Apps tab with curated database of ~30 Nostr + Bitcoin ecosystem apps
  (clients, wallets, privacy tools, node software, dev tools)
- Create AppsGrid + AppDetail components with search, filtering, how-to
- Wire app extraction and Nostr detection into useContentPanel
- Add PromptIndex badges for Apps and Nostr tabs
- Chat UX: dedicated history button, settings modal (memory + advanced),
  fix collapsed chat v-if/v-else chain bug
- Web search: add Brave Search API as primary backend, expand SearXNG pool
- iOS HIG: comprehensive mobile UX rules in cursor rules + CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 05:03:03 +00:00
co-authored by Claude Opus 4.6
parent 5d4367eaff
commit 84ccdc7048
36 changed files with 2295 additions and 321 deletions
+31 -62
View File
@@ -9,7 +9,7 @@
class="w-8 h-8 rounded-xl path-glass-icon flex items-center justify-center shrink-0 transition-colors cursor-pointer text-[#fafafa] hover:text-white"
:title="`AI model: ${modelDisplayName}`"
aria-label="Select AI model"
@click="showModelPicker = !showModelPicker; showChatList = false"
@click="showModelPicker = !showModelPicker"
>
<span class="text-base"></span>
</button>
@@ -28,12 +28,26 @@
</svg>
</button>
<button
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors"
:class="chatStore.showHistory
? 'text-accent'
: 'text-white/70 hover:text-white'"
:title="chatStore.showHistory ? 'Back to chat' : 'Chat history'"
aria-label="Toggle chat history"
@click="chatStore.toggleHistory()"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<button
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors"
:class="chatStore.chatCollapsed
? 'text-accent'
: 'text-white/70 hover:text-white'"
:title="chatStore.chatCollapsed ? 'Expand chat' : 'Collapse to prompts'"
:title="chatStore.chatCollapsed ? 'Expand chat' : 'Prompt index'"
aria-label="Toggle prompt index"
@click="chatStore.toggleChatCollapse()"
>
@@ -59,6 +73,18 @@
</svg>
</button>
<button
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors text-white/70 hover:text-white"
aria-label="Settings"
title="Settings"
@click="$emit('openSettings')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<button
ref="menuTriggerRef"
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors text-white/70 hover:text-white"
@@ -93,44 +119,14 @@
</div>
</div>
<button
ref="chatListTriggerRef"
class="w-full text-left pt-3 pb-1 min-w-0"
:class="conversationList.length > 0 ? 'cursor-pointer' : ''"
@click="conversationList.length > 0 && (showChatList = !showChatList)"
>
<div class="w-full text-left pt-3 pb-1 min-w-0">
<h2 class="text-sm font-semibold truncate text-white/96">{{ title }}</h2>
<div class="flex items-center gap-1.5 mt-0.5">
<p class="text-[10px] truncate font-mono text-white/40">{{ conversationId }}</p>
<span class="text-[10px] text-white/20">·</span>
<span class="text-[10px] truncate text-white/50">{{ modelDisplayName }}</span>
</div>
</button>
<Teleport to="body">
<div v-if="showChatList && conversationList.length > 0" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showChatList = false" />
<Transition name="picker">
<div
v-if="showChatList && conversationList.length > 0"
class="fixed z-[9999] path-glass-card p-3 max-h-48 overflow-y-auto animate-fade-up-fast shadow-2xl min-w-[200px]"
:style="chatListDropdownStyle"
@click.stop
>
<p class="text-[10px] font-semibold uppercase tracking-wider mb-2 px-1 text-white/40">Saved chats</p>
<button
v-for="c in conversationList"
:key="c.id"
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all"
:class="c.id === activeConversationId
? 'nav-tab-active'
: 'text-white/60 hover:text-white hover:bg-white/10'"
@click="selectChat(c.id)"
>
{{ c.title || 'Untitled' }}
</button>
</div>
</Transition>
</Teleport>
</div>
<Teleport to="body">
<div v-if="showModelPicker" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showModelPicker = false" />
@@ -278,6 +274,7 @@ defineEmits<{
switchSide: []
newChat: []
close: []
openSettings: []
}>()
const { activeProvider, activeModel, availableProviders, setProvider, setModel } = useAI()
@@ -301,30 +298,13 @@ function getModelCaps(modelId: string) {
const chatStore = useChatStore()
const webSearchEnabled = computed(() => chatStore.webSearchEnabled)
const showModelPicker = ref(false)
const showChatList = ref(false)
const showMenu = ref(false)
const menuTriggerRef = ref<HTMLElement | null>(null)
const menuDropdownStyle = ref<Record<string, string>>({})
const headerRef = ref<HTMLElement | null>(null)
const chatListTriggerRef = ref<HTMLElement | null>(null)
const modelPickerTriggerRef = ref<HTMLElement | null>(null)
const chatListDropdownStyle = ref<Record<string, string>>({})
const modelPickerDropdownStyle = ref<Record<string, string>>({})
function updateChatListPosition() {
nextTick(() => {
const el = chatListTriggerRef.value
if (el) {
const r = el.getBoundingClientRect()
chatListDropdownStyle.value = {
top: `${r.bottom + 4}px`,
left: `${r.left}px`,
width: `${Math.max(r.width, 200)}px`,
}
}
})
}
function updateModelPickerPosition() {
nextTick(() => {
const el = modelPickerTriggerRef.value
@@ -353,21 +333,10 @@ function updateMenuPosition() {
})
}
watch(showChatList, (v) => { if (v) updateChatListPosition() })
watch(showModelPicker, (v) => { if (v) updateModelPickerPosition() })
watch(showMenu, (v) => { if (v) updateMenuPosition() })
const conversationList = computed(() => chatStore.conversationList)
const activeConversationId = computed(() => chatStore.activeConversationId)
function selectChat(id: string) {
chatStore.setActiveConversation(id)
showChatList.value = false
}
watch(() => chatStore.conversationList.length, () => {
showChatList.value = false
})
const modelDisplayName = computed(() => {
for (const p of availableProviders.value) {
@@ -0,0 +1,70 @@
<template>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hide p-3 space-y-1">
<button
class="w-full text-left px-3 py-2.5 rounded-xl transition-all duration-150 hover:bg-white/5 flex items-center gap-2 text-white/70 mb-2"
@click="$emit('newChat')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span class="text-sm font-medium">New Chat</span>
</button>
<div v-if="conversations.length === 0" class="flex items-center justify-center h-32">
<p class="text-xs text-white/30">No conversations yet</p>
</div>
<button
v-for="conv in conversations"
:key="conv.id"
class="w-full text-left px-3 py-2.5 rounded-xl transition-all duration-150"
:class="conv.id === activeId ? 'nav-tab-active' : 'hover:bg-white/5'"
@click="selectConversation(conv.id)"
>
<p class="text-sm leading-snug truncate" :class="conv.id === activeId ? 'text-white' : 'text-white/90'">
{{ conv.title || 'Untitled' }}
</p>
<div class="flex items-center gap-1.5 mt-1">
<span class="text-[10px] text-white/30">
{{ formatTime(conv.updatedAt) }}
</span>
<span class="text-[10px] text-white/20">&middot;</span>
<span class="text-[10px] text-white/30">
{{ conv.messages.length }} msg{{ conv.messages.length !== 1 ? 's' : '' }}
</span>
</div>
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useChatStore } from '@/stores/chat'
const emit = defineEmits<{
select: [id: string]
newChat: []
}>()
const chatStore = useChatStore()
const conversations = computed(() => chatStore.conversationList)
const activeId = computed(() => chatStore.activeConversationId)
function selectConversation(id: string) {
emit('select', id)
}
function formatTime(ts: number): string {
const now = Date.now()
const diff = now - ts
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'Just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
return new Date(ts).toLocaleDateString([], { month: 'short', day: 'numeric' })
}
</script>
@@ -8,6 +8,7 @@
@switch-side="$emit('switchSide')"
@new-chat="handleNewChat"
@close="$emit('close')"
@open-settings="showSettings = true"
/>
<BranchSwitcher />
@@ -21,15 +22,25 @@
<ContextBar :messages="messages" :active-model="activeModel" />
<PersonaSelector />
<MemoryPanel />
<AdvancedSettings />
<SettingsModal v-model:open="showSettings" />
<!-- History: full conversation list -->
<ChatHistory
v-if="showHistory"
@select="handleHistorySelect"
@new-chat="handleNewChat"
/>
<!-- Collapsed: prompt index -->
<PromptIndex
v-if="chatCollapsed"
:messages="messages"
@select="handlePromptSelect"
/>
<template v-else-if="chatCollapsed">
<PromptIndex
:messages="messages"
@select="handlePromptSelect"
/>
<div v-if="isStreaming" class="px-4 pb-3">
<StreamingDots />
</div>
</template>
<!-- Expanded: full message list (virtualized) -->
<div
@@ -117,13 +128,13 @@ import ChatMessage from './ChatMessage.vue'
import ChatInput from './ChatInput.vue'
import StreamingDots from './StreamingDots.vue'
import PromptIndex from './PromptIndex.vue'
import ChatHistory from './ChatHistory.vue'
import BranchSwitcher from './BranchSwitcher.vue'
import ChatSearch from './ChatSearch.vue'
import ContextBar from './ContextBar.vue'
import ComparisonView from './ComparisonView.vue'
import PersonaSelector from './PersonaSelector.vue'
import MemoryPanel from './MemoryPanel.vue'
import AdvancedSettings from './AdvancedSettings.vue'
import SettingsModal from './SettingsModal.vue'
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import type { Message, ImageAttachment } from '@aiui/core/types/message'
@@ -155,6 +166,7 @@ const personaStore = usePersonaStore()
const comparison = useComparisonMode()
const messageListRef = ref<HTMLElement | null>(null)
const chatSearchRef = ref<InstanceType<typeof ChatSearch> | null>(null)
const showSettings = ref(false)
// Scroll position memory per conversation
const scrollPositions = new Map<string, number>()
@@ -206,6 +218,7 @@ const virtualizer = useVirtualizer(computed(() => ({
})))
const isStreaming = computed(() => chatStore.isStreaming)
const chatCollapsed = computed(() => chatStore.chatCollapsed)
const showHistory = computed(() => chatStore.showHistory)
const title = computed(
() => chatStore.activeConversation?.title ?? 'New Chat'
@@ -231,6 +244,12 @@ function getTriggeringQuery(msgs: typeof messages.value, idx: number): string {
function handleNewChat() {
chatStore.createConversation('New Chat', personaStore.defaultPersona?.id)
chatStore.showHistory = false
}
function handleHistorySelect(id: string) {
chatStore.setActiveConversation(id)
chatStore.showHistory = false
}
function handleStop() {
@@ -370,6 +389,13 @@ function scrollToBottom() {
})
}
// Scroll to bottom when expanding from collapsed
watch(chatCollapsed, (collapsed, wasCollapsed) => {
if (!collapsed && wasCollapsed) {
scrollToBottom()
}
})
// Save/restore scroll position on conversation switch
watch(
() => chatStore.activeConversationId,
@@ -1,7 +1,7 @@
<template>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hide p-3 space-y-1">
<div v-if="promptPairs.length === 0" class="flex items-center justify-center h-full">
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
<p class="text-xs text-white/30">
No prompts yet
</p>
</div>
@@ -13,28 +13,21 @@
:class="[
activeIndex === i
? 'path-glass-bubble-user'
: isDark
? 'hover:bg-white/5'
: 'hover:bg-black/3'
: 'hover:bg-white/5'
]"
@click="selectPrompt(pair, i)"
>
<p class="text-sm leading-snug truncate"
:class="isDark ? 'text-white/90' : 'text-gray-800'">
<p class="text-sm leading-snug truncate text-white/90">
{{ pair.userMsg.content }}
</p>
<div class="flex items-center gap-1.5 mt-1">
<span class="text-[10px]"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
<div class="flex items-center gap-1.5 mt-1 flex-wrap">
<span class="text-[10px] text-white/30">
{{ formatTime(pair.userMsg.timestamp) }}
</span>
<span
v-for="badge in pair.badges"
:key="badge"
class="text-[9px] px-1.5 py-0.5 rounded-md"
:class="isDark
? 'bg-white/8 text-white/40'
: 'bg-black/5 text-gray-500'"
class="text-[9px] px-1.5 py-0.5 rounded-md bg-white/8 text-white/40"
>
{{ badge }}
</span>
@@ -46,7 +39,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Message } from '@aiui/core/types/message'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel } from '@/composables/useContentPanel'
interface PromptPair {
@@ -63,7 +55,6 @@ const emit = defineEmits<{
select: [userMsg: Message, assistantMsg: Message | null]
}>()
const { isDark } = useTheme()
const { getContextualInlineContent } = useContentPanel()
const activeIndex = ref<number | null>(null)
@@ -96,6 +87,8 @@ const promptPairs = computed<PromptPair[]>(() => {
if (content.magazineSections.length > 0) badges.push('Magazine')
if ((content.newsLinks?.length ?? 0) > 0) badges.push('News')
if ((content.websitesLinks?.length ?? 0) > 0) badges.push('Web')
if ((content.apps?.length ?? 0) > 0) badges.push('Apps')
if (content.hasNostr) badges.push('Nostr')
}
pairs.push({ userMsg, assistantMsg, badges })
@@ -0,0 +1,322 @@
<template>
<Teleport to="body">
<Transition name="settings-modal">
<div
v-if="open"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
@click.self="$emit('update:open', false)"
/>
<div class="glass-card relative w-full max-w-md p-5 space-y-5 animate-scale-in max-h-[85vh] overflow-y-auto scrollbar-hide">
<!-- Header -->
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-white/96">Settings</h2>
<button
class="w-7 h-7 flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/10 transition-all"
aria-label="Close settings"
@click="$emit('update:open', false)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Memory Section -->
<div class="space-y-2">
<h3 class="text-[11px] font-semibold uppercase tracking-wider text-white/40">
Memory ({{ memoryStore.items.length }}/20)
</h3>
<div class="space-y-1.5">
<div
v-for="item in memoryStore.items"
:key="item.id"
class="group flex items-start gap-2 rounded-lg bg-white/5 border border-white/5 px-2.5 py-1.5"
>
<div v-if="editingId === item.id" class="flex-1 flex gap-1.5">
<input
v-model="editText"
type="text"
class="flex-1 bg-transparent text-xs text-white/80 outline-none"
@keydown.enter="saveEdit(item.id)"
@keydown.escape="cancelEdit"
/>
<button
class="text-[10px] text-accent/70 hover:text-accent"
@click="saveEdit(item.id)"
>
Save
</button>
</div>
<template v-else>
<p class="flex-1 text-xs text-white/60 leading-relaxed">{{ item.text }}</p>
<div class="shrink-0 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
class="w-5 h-5 flex items-center justify-center rounded text-white/30 hover:text-white/60 hover:bg-white/10 transition-all"
title="Edit"
@click="startEdit(item)"
>
<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /></svg>
</button>
<button
class="w-5 h-5 flex items-center justify-center rounded text-white/30 hover:text-red-400/80 hover:bg-white/10 transition-all"
title="Delete"
@click="memoryStore.deleteItem(item.id)"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</template>
</div>
<div v-if="!memoryStore.isFull" class="flex gap-1.5">
<input
v-model="newMemoryText"
type="text"
class="flex-1 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
placeholder="Add a memory..."
@keydown.enter="addMemory"
/>
<button
class="px-2.5 py-1.5 rounded-lg text-[11px] bg-accent/15 text-accent/80 hover:bg-accent/25 transition-all"
:disabled="!newMemoryText.trim()"
@click="addMemory"
>
Add
</button>
</div>
<p v-else class="text-[10px] text-white/25">
Maximum 20 memories reached
</p>
</div>
</div>
<!-- Divider -->
<div class="border-t border-white/5" />
<!-- Advanced Section -->
<div class="space-y-3">
<h3 class="text-[11px] font-semibold uppercase tracking-wider text-white/40">
Advanced
</h3>
<template v-if="conv">
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-[11px] text-white/40">Temperature</label>
<span class="text-[11px] text-white/50 tabular-nums">{{ temperature.toFixed(2) }}</span>
</div>
<input
v-model.number="temperature"
type="range"
min="0"
max="1"
step="0.05"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-[11px] text-white/40">Max Tokens</label>
<span class="text-[11px] text-white/50 tabular-nums">{{ maxTokens }}</span>
</div>
<input
v-model.number="maxTokens"
type="range"
min="256"
max="8192"
step="256"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-[11px] text-white/40">Top P</label>
<span class="text-[11px] text-white/50 tabular-nums">{{ topP.toFixed(2) }}</span>
</div>
<input
v-model.number="topP"
type="range"
min="0"
max="1"
step="0.05"
class="w-full h-1 accent-[#F7931A] bg-white/10 rounded-full appearance-none cursor-pointer"
@input="persistParams"
/>
</div>
<div class="space-y-1">
<label class="text-[11px] text-white/40">Stop Sequences</label>
<div v-if="stopSequences.length > 0" class="flex gap-1 flex-wrap mb-1">
<span
v-for="(seq, i) in stopSequences"
:key="i"
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-white/5 border border-white/10 text-[10px] text-white/50"
>
{{ seq }}
<button class="text-white/30 hover:text-white/60" @click="removeStopSequence(i)">&times;</button>
</span>
</div>
<input
v-model="newStopSeq"
type="text"
class="w-full bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
placeholder="Add stop sequence (Enter to add)"
@keydown.enter="addStopSequence"
/>
</div>
<button
class="text-[11px] text-white/30 hover:text-white/50 transition-colors"
@click="resetDefaults"
>
Reset to defaults
</button>
</template>
<p v-else class="text-xs text-white/30">Start a conversation to configure parameters</p>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useMemoryStore, type MemoryItem } from '@/stores/memory'
import { useChatStore } from '@/stores/chat'
defineProps<{
open: boolean
}>()
defineEmits<{
'update:open': [value: boolean]
}>()
// --- Memory ---
const memoryStore = useMemoryStore()
const newMemoryText = ref('')
const editingId = ref<string | null>(null)
const editText = ref('')
function addMemory() {
if (!newMemoryText.value.trim()) return
memoryStore.addItem(newMemoryText.value)
newMemoryText.value = ''
}
function startEdit(item: MemoryItem) {
editingId.value = item.id
editText.value = item.text
}
function saveEdit(id: string) {
if (editText.value.trim()) {
memoryStore.updateItem(id, editText.value)
}
editingId.value = null
editText.value = ''
}
function cancelEdit() {
editingId.value = null
editText.value = ''
}
// --- Advanced ---
const chatStore = useChatStore()
const conv = computed(() => chatStore.activeConversation)
const newStopSeq = ref('')
const temperature = ref(1.0)
const maxTokens = ref(4096)
const topP = ref(1.0)
const stopSequences = ref<string[]>([])
watch(
() => chatStore.activeConversationId,
() => loadFromConv(),
{ immediate: true }
)
function loadFromConv() {
const c = conv.value
temperature.value = c?.temperature ?? 1.0
maxTokens.value = c?.maxTokens ?? 4096
topP.value = c?.topP ?? 1.0
stopSequences.value = c?.stopSequences ? [...c.stopSequences] : []
}
function persistParams() {
const c = conv.value
if (!c) return
c.temperature = temperature.value
c.maxTokens = maxTokens.value
c.topP = topP.value
c.updatedAt = Date.now()
}
function addStopSequence() {
const seq = newStopSeq.value.trim()
if (!seq) return
stopSequences.value.push(seq)
newStopSeq.value = ''
const c = conv.value
if (c) {
c.stopSequences = [...stopSequences.value]
c.updatedAt = Date.now()
}
}
function removeStopSequence(index: number) {
stopSequences.value.splice(index, 1)
const c = conv.value
if (c) {
c.stopSequences = [...stopSequences.value]
c.updatedAt = Date.now()
}
}
function resetDefaults() {
temperature.value = 1.0
maxTokens.value = 4096
topP.value = 1.0
stopSequences.value = []
newStopSeq.value = ''
const c = conv.value
if (c) {
c.temperature = undefined
c.maxTokens = undefined
c.topP = undefined
c.stopSequences = undefined
c.updatedAt = Date.now()
}
}
</script>
<style scoped>
.settings-modal-enter-active {
transition: opacity 0.2s ease-out;
}
.settings-modal-enter-active .glass-card {
transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
}
.settings-modal-leave-active {
transition: opacity 0.15s ease-in;
}
.settings-modal-enter-from,
.settings-modal-leave-to {
opacity: 0;
}
</style>
@@ -5,16 +5,10 @@
<span
v-for="i in 3"
:key="i"
class="w-1.5 h-1.5 rounded-full animate-pulse-glow"
:class="isDark ? 'bg-white/40' : 'bg-gray-400'"
class="w-1.5 h-1.5 rounded-full bg-white/40 animate-pulse-glow"
:style="{ animationDelay: `${i * 200}ms` }"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
const { isDark } = useTheme()
</script>
@@ -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 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="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-[10px] font-medium bg-white/15"
>{{ categoryLabel }}</span>
<span
v-for="p in app.platforms"
:key="p"
class="text-[10px]"
>{{ 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-[10px] 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-[10px]"
: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-[10px] 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,166 @@
<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-[10px] 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-xs outline-none transition-colors"
:class="isDark
? 'bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10'
: 'bg-black/3 text-gray-800 placeholder:text-gray-400 focus:bg-black/5'"
/>
<div class="flex flex-wrap gap-1.5">
<button
v-for="cat in categories"
:key="cat.value"
class="text-[10px] 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'"
@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-[9px] 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-[11px] 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-[8px] 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>
@@ -82,6 +82,12 @@
:podcast="selectedPodcast"
@back="closePodcastDetail"
/>
<AppDetail
v-else-if="selectedApp"
:app="selectedApp"
@back="closeAppDetail"
@select-app="openAppDetail"
/>
<ArticleDetail
v-else-if="selectedArticle"
:article="selectedArticle"
@@ -159,6 +165,12 @@
:query="panelQuery"
:hero-image="panelMagazineHeroImage ?? undefined"
/>
<AppsGrid
v-else-if="activeTab === 'app'"
:apps="panelApps"
:title="panelTitle"
@select-app="openAppDetail"
/>
<ProjectGrid
v-else-if="activeTab === 'code'"
/>
@@ -194,6 +206,8 @@ import ArticleReader from '@/components/renderers/ArticleReader.vue'
import PdfViewer from '@/components/renderers/PdfViewer.vue'
import MapRenderer from '@/components/renderers/MapRenderer.vue'
import MagazineGrid from './MagazineGrid.vue'
import AppsGrid from './AppsGrid.vue'
import AppDetail from './AppDetail.vue'
import ProjectGrid from './ProjectGrid.vue'
import NostrGrid from './NostrGrid.vue'
import FavoritesGrid from './FavoritesGrid.vue'
@@ -217,6 +231,7 @@ const {
panelPodcasts,
panelWebResults,
panelWebsites,
panelApps,
panelMagazineSections,
panelMagazineHeroImage,
panelTitle,
@@ -229,6 +244,7 @@ const {
selectedSong,
selectedPodcast,
selectedArticle,
selectedApp,
selectedDesignSystemItem,
setActiveTab,
openFilmDetail,
@@ -241,6 +257,8 @@ const {
closeSongDetail,
openPodcastDetail,
closePodcastDetail,
openAppDetail,
closeAppDetail,
closeArticleDetail,
longFormArticle,
closeLongFormArticle,
@@ -252,7 +270,7 @@ const {
} = useContentPanel()
const hasDetailOpen = computed(() =>
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedArticle.value || selectedDesignSystemItem.value || longFormArticle.value || pdfUrl.value || mapPlaces.value.length > 0)
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedArticle.value || selectedApp.value || selectedDesignSystemItem.value || longFormArticle.value || pdfUrl.value || mapPlaces.value.length > 0)
)
const windowWidth = ref(window.innerWidth)
@@ -286,6 +304,7 @@ const TAB_LABELS: Record<ContentTab, string> = {
magazine: 'Brief',
code: 'Code',
'design-system': 'Design',
app: 'Apps',
nostr: 'Nostr',
favorites: 'Favorites',
discover: 'Discover',
@@ -90,14 +90,13 @@
<!-- Component preview (rendered as styled blocks) -->
<div v-else class="space-y-2">
<!-- Glass button preview -->
<div v-if="item.id === 'atom-glass-btn'" class="flex gap-2">
<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-2">
<button class="glass-button-sm text-xs">Small</button>
</div>
<div v-else-if="item.id === 'atom-gradient-btn'" class="flex gap-2">
<button class="gradient-button text-sm">Primary Action</button>
<div 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"
@@ -129,8 +129,7 @@ const items: DesignSystemItem[] = [
// Atoms
{ id: 'atom-glass-btn', name: 'Glass Button', category: 'atoms', description: '48px height, glass morphism background', code: '<button class="glass-button">\n Action\n</button>\n\n/* glass-button:\n height: 48px\n background: rgba(0,0,0,0.6)\n backdrop-filter: blur(18px)\n border-radius: 12px\n border: 1px solid rgba(255,255,255,0.12)\n*/', usedIn: 'ChatInput send, modal actions, primary controls' },
{ id: 'atom-glass-btn-sm', name: 'Glass Button Small', category: 'atoms', description: 'Compact glass button variant', code: '<button class="glass-button-sm">\n Small\n</button>\n\n/* Compact variant of glass-button */', usedIn: 'ChatInput send/stop buttons, inline actions' },
{ id: 'atom-gradient-btn', name: 'Gradient Button', category: 'atoms', description: 'Primary CTA with accent gradient', code: '<button class="gradient-button">\n Primary Action\n</button>\n\n/* gradient-button:\n background: linear-gradient(135deg, #F7931A, #e8850f)\n height: 48px\n border-radius: 12px\n font-weight: 600\n*/', usedIn: 'Primary CTAs, onboarding, confirmation dialogs' },
{ id: 'atom-icon-btn', name: 'Icon Button', category: 'atoms', description: 'Path glass icon, 32-36px square', code: '<button class="w-9 h-9 rounded-xl path-glass-icon\n flex items-center justify-center">\n <svg class="w-4 h-4" ...>\n</button>\n\n/* path-glass-icon:\n background: transparent\n transition: colors\n hover: bg-white/10\n*/', usedIn: 'ChatHeader toolbar, detail back buttons, close buttons' },
{ id: 'atom-icon-btn', name: 'Icon Button', category: 'atoms', description: 'Path glass icon, 32-36px square', code: '<button class="w-9 h-9 rounded-xl path-glass-icon\n flex items-center justify-center">\n <svg class="w-4 h-4" ...>\n</button>\n\n/* path-glass-icon:\n background: transparent\n transition: colors\n hover: bg-white/10\n*/', usedIn: 'ChatHeader toolbar, detail back buttons, close buttons' },
{ id: 'atom-badge', name: 'Genre Badge', category: 'atoms', description: 'Tiny pill badge for tags/genres', code: '<span class="text-[10px] px-2 py-1 rounded-md\n font-medium bg-white/10 text-white/60">\n Science Fiction\n</span>', usedIn: 'FilmGrid, SongGrid, BookGrid, TVSeriesGrid genre filters' },
{ id: 'atom-nav-tab', name: 'Nav Tab', category: 'atoms', description: 'Content panel tab with active state', code: '<button class="nav-tab-active">\n Films\n</button>\n\n/* Active: accent underline\n Inactive: text-white/50 hover:text-white\n Transition: 200ms */', usedIn: 'ContentPanel tab bar, mobile content tab filters' },
{ id: 'atom-input', name: 'Text Input', category: 'atoms', description: 'Search/filter input field', code: '<input\n class="w-full px-3 py-2 rounded-lg text-xs\n outline-none transition-colors\n bg-white/5 text-white/80\n placeholder:text-white/25\n focus:bg-white/10"\n placeholder="Search..."\n/>', usedIn: 'All grid search bars, ProjectGrid new project' },
@@ -68,7 +68,7 @@
Skip
</button>
<button
class="gradient-button flex-1 h-10 rounded-xl text-sm font-medium text-white transition-opacity"
class="glass-button flex-1 h-10 rounded-xl text-sm font-medium text-white transition-opacity"
:disabled="!canSubmit"
:class="{ 'opacity-40 cursor-not-allowed': !canSubmit }"
@click="handleSubmit"