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:
co-authored by
Claude Opus 4.6
parent
5d4367eaff
commit
84ccdc7048
@@ -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">·</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)">×</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"
|
||||
|
||||
Reference in New Issue
Block a user