Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
|
||||
API Keys
|
||||
</h3>
|
||||
|
||||
<!-- Configured providers -->
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="provider in providers"
|
||||
:key="provider.id"
|
||||
class="flex items-center gap-3 p-3 rounded-xl"
|
||||
:class="isDark
|
||||
? 'bg-white/[0.03] border border-white/5'
|
||||
: 'bg-black/[0.02] border border-black/5'"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-xs font-medium"
|
||||
:class="isDark ? 'text-white/80' : 'text-gray-800'"
|
||||
>
|
||||
{{ provider.name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs font-mono mt-0.5"
|
||||
:class="isDark ? 'text-white/30' : 'text-gray-400'"
|
||||
>
|
||||
{{ provider.masked ?? 'Not configured' }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="provider.hasKey"
|
||||
class="text-xs px-2 py-1 rounded-md transition-colors"
|
||||
:class="isDark
|
||||
? 'text-red-400/60 hover:text-red-400 hover:bg-red-500/10'
|
||||
: 'text-red-500/60 hover:text-red-600 hover:bg-red-50'"
|
||||
@click="removeKey(provider.id)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add new key -->
|
||||
<div class="space-y-2">
|
||||
<select
|
||||
v-model="selectedProvider"
|
||||
class="w-full px-3 py-2 rounded-lg text-xs outline-none"
|
||||
:class="isDark
|
||||
? 'bg-white/5 text-white/80 border border-white/10'
|
||||
: 'bg-gray-50 text-gray-800 border border-gray-200'"
|
||||
>
|
||||
<option value="">Select provider...</option>
|
||||
<option value="claude">Claude (Anthropic)</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
</select>
|
||||
|
||||
<div v-if="selectedProvider" class="flex gap-2">
|
||||
<input
|
||||
v-model="newKey"
|
||||
type="password"
|
||||
placeholder="Paste API key..."
|
||||
class="flex-1 px-3 py-2 rounded-lg text-xs outline-none"
|
||||
:class="isDark
|
||||
? 'bg-white/5 text-white/80 placeholder:text-white/25 border border-white/10'
|
||||
: 'bg-gray-50 text-gray-800 placeholder:text-gray-400 border border-gray-200'"
|
||||
style="font-size: 16px"
|
||||
@keydown.enter="saveKey"
|
||||
/>
|
||||
<button
|
||||
:disabled="!newKey.trim()"
|
||||
class="px-3 py-2 rounded-lg text-xs font-medium transition-colors"
|
||||
:class="newKey.trim()
|
||||
? 'bg-accent text-white hover:bg-accent/90'
|
||||
: isDark
|
||||
? 'bg-white/5 text-white/20 cursor-not-allowed'
|
||||
: 'bg-gray-100 text-gray-300 cursor-not-allowed'"
|
||||
@click="saveKey"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="statusMessage"
|
||||
class="text-xs"
|
||||
:class="statusError ? 'text-red-400' : isDark ? 'text-green-400' : 'text-green-600'"
|
||||
>
|
||||
{{ statusMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { storeApiKey, getApiKey, deleteApiKey, listProviders, maskApiKey } from '@/utils/key-vault'
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
interface ProviderInfo {
|
||||
id: string
|
||||
name: string
|
||||
hasKey: boolean
|
||||
masked: string | null
|
||||
}
|
||||
|
||||
const providers = ref<ProviderInfo[]>([])
|
||||
const selectedProvider = ref('')
|
||||
const newKey = ref('')
|
||||
const statusMessage = ref('')
|
||||
const statusError = ref(false)
|
||||
|
||||
const PROVIDER_NAMES: Record<string, string> = {
|
||||
claude: 'Claude (Anthropic)',
|
||||
openrouter: 'OpenRouter',
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
const configured = await listProviders()
|
||||
const allProviders = ['claude', 'openrouter']
|
||||
|
||||
providers.value = await Promise.all(
|
||||
allProviders.map(async (id) => {
|
||||
const key = await getApiKey(id)
|
||||
return {
|
||||
id,
|
||||
name: PROVIDER_NAMES[id] ?? id,
|
||||
hasKey: !!key,
|
||||
masked: key ? maskApiKey(key) : null,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function saveKey() {
|
||||
if (!selectedProvider.value || !newKey.value.trim()) return
|
||||
try {
|
||||
await storeApiKey(selectedProvider.value, newKey.value.trim())
|
||||
statusMessage.value = `${PROVIDER_NAMES[selectedProvider.value] ?? selectedProvider.value} key saved`
|
||||
statusError.value = false
|
||||
newKey.value = ''
|
||||
selectedProvider.value = ''
|
||||
await loadProviders()
|
||||
} catch (err) {
|
||||
statusMessage.value = err instanceof Error ? err.message : 'Failed to store key — encryption required'
|
||||
statusError.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function removeKey(provider: string) {
|
||||
await deleteApiKey(provider)
|
||||
statusMessage.value = `${PROVIDER_NAMES[provider] ?? provider} key removed`
|
||||
statusError.value = false
|
||||
await loadProviders()
|
||||
}
|
||||
|
||||
onMounted(loadProviders)
|
||||
</script>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
|
||||
Nostr Identity
|
||||
</h3>
|
||||
|
||||
<!-- Logged in state -->
|
||||
<div
|
||||
v-if="isLoggedIn"
|
||||
class="flex items-center gap-3 p-3 rounded-xl"
|
||||
:class="isDark
|
||||
? 'bg-white/[0.03] border border-white/5'
|
||||
: 'bg-black/[0.02] border border-black/5'"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-purple-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-xs font-medium"
|
||||
:class="isDark ? 'text-white/80' : 'text-gray-800'"
|
||||
>
|
||||
Connected
|
||||
</div>
|
||||
<button
|
||||
class="text-xs font-mono mt-0.5 hover:underline"
|
||||
:class="isDark ? 'text-purple-400/60' : 'text-purple-600/60'"
|
||||
@click="copyNpub"
|
||||
>
|
||||
{{ copied ? 'Copied!' : truncatedNpub }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="text-xs px-2 py-1 rounded-md transition-colors"
|
||||
:class="isDark
|
||||
? 'text-red-400/60 hover:text-red-400 hover:bg-red-500/10'
|
||||
: 'text-red-500/60 hover:text-red-600 hover:bg-red-50'"
|
||||
@click="logout"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Logged out state -->
|
||||
<template v-else>
|
||||
<button
|
||||
v-if="isAvailable"
|
||||
:disabled="isLoading"
|
||||
class="w-full px-4 py-2.5 rounded-xl text-sm font-medium transition-all active:scale-[0.98]"
|
||||
:class="isLoading
|
||||
? isDark
|
||||
? 'bg-purple-500/10 text-purple-400/40 cursor-wait'
|
||||
: 'bg-purple-50 text-purple-300 cursor-wait'
|
||||
: 'bg-purple-500/10 text-purple-400 hover:bg-purple-500/20'"
|
||||
@click="login"
|
||||
>
|
||||
{{ isLoading ? 'Connecting...' : 'Login with Nostr' }}
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="p-3 rounded-xl text-xs"
|
||||
:class="isDark
|
||||
? 'bg-white/[0.03] border border-white/5 text-white/40'
|
||||
: 'bg-black/[0.02] border border-black/5 text-gray-500'"
|
||||
>
|
||||
No Nostr extension detected. Install
|
||||
<a
|
||||
href="https://github.com/nicolgit/nos2x"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-purple-400 hover:underline"
|
||||
>nos2x</a>,
|
||||
<a
|
||||
href="https://getalby.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-purple-400 hover:underline"
|
||||
>Alby</a>, or another NIP-07 browser extension.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error -->
|
||||
<p
|
||||
v-if="error"
|
||||
class="text-xs text-red-400"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useNostrIdentity } from '@/composables/useNostrIdentity'
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const {
|
||||
isAvailable,
|
||||
isLoggedIn,
|
||||
isLoading,
|
||||
error,
|
||||
npub,
|
||||
truncatedNpub,
|
||||
login,
|
||||
logout,
|
||||
} = useNostrIdentity()
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
async function copyNpub() {
|
||||
if (!npub.value) return
|
||||
await navigator.clipboard.writeText(npub.value)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="p-4 border-b border-white/[0.08]">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-bold text-white/90">Plugins</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="store.hasUpdates" class="text-xs px-1.5 py-0.5 rounded-full bg-accent/20 text-accent/80">
|
||||
Updates
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1.5 flex-wrap">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="text-xs px-2 py-1 rounded-md transition-all duration-150"
|
||||
:class="activeTab === tab.id
|
||||
? 'nav-tab-active'
|
||||
: 'text-white/40 hover:text-white/70 hover:bg-white/5'"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-3">
|
||||
<!-- Discover -->
|
||||
<template v-if="activeTab === 'discover'">
|
||||
<div v-if="store.isLoadingRegistry" class="flex items-center justify-center py-12">
|
||||
<p class="text-xs text-white/30">Loading registry...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.registryError && store.registryPlugins.length === 0" class="py-8 text-center">
|
||||
<p class="text-xs text-red-400/60">{{ store.registryError }}</p>
|
||||
<button class="mt-2 text-xs text-accent/60 hover:text-accent/80" @click="store.fetchRegistry()">Retry</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="plugin in store.registryPlugins"
|
||||
:key="plugin.id"
|
||||
class="rounded-xl bg-white/[0.03] border border-white/5 p-3 space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-semibold text-white/80">{{ plugin.name }}</p>
|
||||
<p class="text-xs text-white/30">{{ plugin.author }} · v{{ plugin.version }}</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="!store.isInstalled(plugin.id)"
|
||||
class="text-xs px-3 py-1.5 rounded-lg bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors shrink-0"
|
||||
@click="showPermissionsDialog(plugin)"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
<span v-else class="text-xs text-emerald-400/60 shrink-0">Installed</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/50">{{ plugin.description }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-white/5 text-white/30">{{ plugin.type }}</span>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<span v-for="i in 5" :key="i" class="text-xs" :class="i <= plugin.rating ? 'text-accent/60' : 'text-white/10'">★</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Installed -->
|
||||
<template v-else-if="activeTab === 'installed'">
|
||||
<div v-if="store.installedPlugins.length === 0" class="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<svg class="w-8 h-8 text-white/10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<p class="text-xs text-white/30">No plugins installed</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="plugin in store.installedPlugins"
|
||||
:key="plugin.id"
|
||||
class="rounded-xl bg-white/[0.03] border border-white/5 p-3 space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-semibold text-white/80">{{ plugin.name }}</p>
|
||||
<p class="text-xs text-white/30">v{{ plugin.version }} · {{ plugin.author }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
v-if="store.updatesAvailable.has(plugin.id)"
|
||||
class="text-xs px-2 py-1 rounded bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
|
||||
@click="store.updatePlugin(plugin.id)"
|
||||
>
|
||||
Update to v{{ store.updatesAvailable.get(plugin.id) }}
|
||||
</button>
|
||||
<button
|
||||
class="text-xs min-w-[44px] min-h-[44px] flex items-center justify-center rounded text-white/30 hover:text-white/60 hover:bg-white/5 transition-colors"
|
||||
@click="editingPlugin = editingPlugin === plugin.id ? null : plugin.id"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="text-xs px-2 py-1 rounded text-red-400/50 hover:text-red-400/80 hover:bg-red-400/10 transition-colors"
|
||||
@click="store.uninstallPlugin(plugin.id)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Permissions -->
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="perm in plugin.permissions"
|
||||
:key="perm"
|
||||
class="text-xs px-1.5 py-0.5 rounded"
|
||||
:class="plugin.grantedPermissions.includes(perm)
|
||||
? 'bg-emerald-400/15 text-emerald-400/60'
|
||||
: 'bg-red-400/15 text-red-400/60'"
|
||||
>
|
||||
{{ permissionLabel(perm) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Settings panel (inline) -->
|
||||
<PluginSettingsForm
|
||||
v-if="editingPlugin === plugin.id"
|
||||
:plugin-id="plugin.id"
|
||||
:settings="plugin.settings"
|
||||
:permissions="plugin.permissions"
|
||||
:granted-permissions="plugin.grantedPermissions"
|
||||
@update-settings="(s) => store.updatePluginSettings(plugin.id, s)"
|
||||
@update-permissions="(p) => store.updatePluginPermissions(plugin.id, p)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Update all -->
|
||||
<button
|
||||
v-if="store.hasUpdates"
|
||||
class="w-full py-2.5 rounded-lg text-xs font-medium bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors mt-2"
|
||||
@click="store.updateAllPlugins()"
|
||||
>
|
||||
Update all plugins
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Import -->
|
||||
<template v-else-if="activeTab === 'import'">
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs text-white/40">
|
||||
Paste a GitHub raw URL or IPFS CID to the plugin's manifest (aiui-plugin.json).
|
||||
</p>
|
||||
<input
|
||||
v-model="importUrl"
|
||||
type="text"
|
||||
placeholder="https://raw.githubusercontent.com/.../aiui-plugin.json"
|
||||
class="w-full px-3 py-2.5 rounded-lg text-base bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors font-mono"
|
||||
/>
|
||||
<button
|
||||
class="w-full py-2.5 rounded-lg text-xs font-medium bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
|
||||
:disabled="!importUrl.trim() || isImporting"
|
||||
@click="importPlugin"
|
||||
>
|
||||
{{ isImporting ? 'Fetching manifest...' : 'Import Plugin' }}
|
||||
</button>
|
||||
<p v-if="importError" class="text-xs text-red-400/60">{{ importError }}</p>
|
||||
|
||||
<!-- Imported plugin preview -->
|
||||
<div v-if="importedManifest" class="rounded-xl bg-white/[0.03] border border-white/5 p-3 space-y-2">
|
||||
<p class="text-xs font-semibold text-white/80">{{ importedManifest.name }}</p>
|
||||
<p class="text-xs text-white/50">{{ importedManifest.description }}</p>
|
||||
<p class="text-xs text-white/30">{{ importedManifest.author }} · v{{ importedManifest.version }}</p>
|
||||
<button
|
||||
class="w-full py-2 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
|
||||
@click="showPermissionsDialog(importedManifest)"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Dev Mode -->
|
||||
<template v-else-if="activeTab === 'dev'">
|
||||
<div v-if="!isDevMode" class="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<p class="text-xs text-white/30">Dev mode not enabled</p>
|
||||
<p class="text-xs text-white/20">Set VITE_PLUGIN_DEV=true to enable</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="rounded-xl bg-white/[0.03] border border-white/5 p-3 space-y-2">
|
||||
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Plugin Dev Mode</p>
|
||||
<p class="text-xs text-white/40">Hot-reload from src/plugins/dev/</p>
|
||||
|
||||
<div v-if="devErrors.length > 0" class="space-y-1 mt-2">
|
||||
<p class="text-xs text-red-400/60 uppercase tracking-wider font-bold">Errors</p>
|
||||
<div
|
||||
v-for="(err, i) in devErrors"
|
||||
:key="i"
|
||||
class="text-xs text-red-400/50 font-mono bg-red-400/5 rounded p-2"
|
||||
>
|
||||
{{ err }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="devTimings.length > 0" class="space-y-1 mt-2">
|
||||
<p class="text-xs text-white/30 uppercase tracking-wider font-bold">Init Timing</p>
|
||||
<div
|
||||
v-for="t in devTimings"
|
||||
:key="t.id"
|
||||
class="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span class="text-white/50">{{ t.id }}</span>
|
||||
<span class="text-white/30 tabular-nums">{{ t.ms }}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Permissions dialog -->
|
||||
<Teleport to="body">
|
||||
<div v-if="pendingInstall" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<div class="w-full max-w-sm mx-4 rounded-2xl bg-[#0a0a0a] border border-white/10 p-5 space-y-4">
|
||||
<h4 class="text-sm font-bold text-white/90">Plugin Permissions</h4>
|
||||
<p class="text-xs text-white/40">
|
||||
"{{ pendingInstall.name }}" requests the following permissions:
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
v-for="perm in pendingInstall.permissions"
|
||||
:key="perm"
|
||||
class="flex items-center gap-2 p-2 rounded-lg bg-white/[0.03] border border-white/5 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="pendingPermissions.includes(perm)"
|
||||
class="rounded accent-[#F7931A]"
|
||||
@change="togglePermission(perm)"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-xs text-white/70">{{ permissionLabel(perm) }}</p>
|
||||
<p class="text-xs text-white/30">{{ permissionDescription(perm) }}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 py-2 rounded-lg text-xs text-white/40 hover:text-white/70 hover:bg-white/5 transition-colors"
|
||||
@click="pendingInstall = null"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-2 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
|
||||
@click="confirmInstall"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { usePluginMarketplaceStore, type RegistryPlugin, type PluginPermission } from '@/stores/pluginMarketplace'
|
||||
import PluginSettingsForm from './PluginSettingsForm.vue'
|
||||
|
||||
type Tab = 'discover' | 'installed' | 'import' | 'dev'
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'discover', label: 'Discover' },
|
||||
{ id: 'installed', label: 'Installed' },
|
||||
{ id: 'import', label: 'Import' },
|
||||
{ id: 'dev', label: 'Dev' },
|
||||
]
|
||||
|
||||
const activeTab = ref<Tab>('discover')
|
||||
const store = usePluginMarketplaceStore()
|
||||
const editingPlugin = ref<string | null>(null)
|
||||
|
||||
// Import
|
||||
const importUrl = ref('')
|
||||
const isImporting = ref(false)
|
||||
const importError = ref('')
|
||||
const importedManifest = ref<RegistryPlugin | null>(null)
|
||||
|
||||
// Permissions dialog
|
||||
const pendingInstall = ref<RegistryPlugin | null>(null)
|
||||
const pendingPermissions = ref<PluginPermission[]>([])
|
||||
|
||||
// Dev mode
|
||||
const isDevMode = !!import.meta.env.VITE_PLUGIN_DEV
|
||||
const devErrors = ref<string[]>([])
|
||||
const devTimings = ref<{ id: string; ms: number }[]>([])
|
||||
|
||||
function showPermissionsDialog(plugin: RegistryPlugin) {
|
||||
pendingInstall.value = plugin
|
||||
pendingPermissions.value = [...plugin.permissions]
|
||||
}
|
||||
|
||||
function togglePermission(perm: PluginPermission) {
|
||||
const idx = pendingPermissions.value.indexOf(perm)
|
||||
if (idx >= 0) pendingPermissions.value.splice(idx, 1)
|
||||
else pendingPermissions.value.push(perm)
|
||||
}
|
||||
|
||||
function confirmInstall() {
|
||||
if (!pendingInstall.value) return
|
||||
store.installPlugin(pendingInstall.value, pendingPermissions.value)
|
||||
pendingInstall.value = null
|
||||
importedManifest.value = null
|
||||
}
|
||||
|
||||
async function importPlugin() {
|
||||
isImporting.value = true
|
||||
importError.value = ''
|
||||
importedManifest.value = null
|
||||
|
||||
const manifest = await store.importFromUrl(importUrl.value.trim())
|
||||
if (manifest) {
|
||||
importedManifest.value = manifest
|
||||
} else {
|
||||
importError.value = 'Invalid manifest or failed to fetch'
|
||||
}
|
||||
isImporting.value = false
|
||||
}
|
||||
|
||||
function permissionLabel(perm: PluginPermission): string {
|
||||
const labels: Record<PluginPermission, string> = {
|
||||
'chat-messages': 'Chat Messages',
|
||||
'network': 'Network Access',
|
||||
'favorites': 'Favorites',
|
||||
'storage': 'Local Storage',
|
||||
'nostr': 'Nostr Identity',
|
||||
'wallet': 'Wallet',
|
||||
}
|
||||
return labels[perm] ?? perm
|
||||
}
|
||||
|
||||
function permissionDescription(perm: PluginPermission): string {
|
||||
const descs: Record<PluginPermission, string> = {
|
||||
'chat-messages': 'Read and inject content into chat messages',
|
||||
'network': 'Make network requests to external APIs',
|
||||
'favorites': 'Read and modify your favorites list',
|
||||
'storage': 'Store data in local storage',
|
||||
'nostr': 'Access your Nostr identity for signing',
|
||||
'wallet': 'Interact with your connected wallet',
|
||||
}
|
||||
return descs[perm] ?? ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchRegistry().then(() => store.checkForUpdates())
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="mt-2 pt-2 border-t border-white/[0.05] space-y-3">
|
||||
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Settings</p>
|
||||
|
||||
<!-- Generic key-value settings editor -->
|
||||
<div class="space-y-2">
|
||||
<div v-for="(value, key) in localSettings" :key="key" class="flex items-center gap-2">
|
||||
<span class="text-xs text-white/40 min-w-[60px]">{{ key }}</span>
|
||||
<input
|
||||
:value="String(value ?? '')"
|
||||
class="flex-1 px-2 py-1.5 rounded-md text-base bg-white/5 text-white/70 outline-none focus:bg-white/10 transition-colors"
|
||||
@input="updateSetting(key as string, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Add new setting -->
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newSettingKey"
|
||||
type="text"
|
||||
placeholder="Key"
|
||||
class="flex-1 px-2 py-1.5 rounded-md text-base bg-white/5 text-white/70 placeholder:text-white/20 outline-none focus:bg-white/10 transition-colors"
|
||||
/>
|
||||
<input
|
||||
v-model="newSettingValue"
|
||||
type="text"
|
||||
placeholder="Value"
|
||||
class="flex-1 px-2 py-1.5 rounded-md text-base bg-white/5 text-white/70 placeholder:text-white/20 outline-none focus:bg-white/10 transition-colors"
|
||||
/>
|
||||
<button
|
||||
class="text-xs px-2 py-1.5 rounded-md bg-white/5 text-white/40 hover:text-white/70 hover:bg-white/10 transition-colors disabled:opacity-30"
|
||||
:disabled="!newSettingKey.trim()"
|
||||
@click="addSetting"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Permissions toggles -->
|
||||
<div v-if="permissions.length > 0">
|
||||
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold mb-2">Permissions</p>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
v-for="perm in permissions"
|
||||
:key="perm"
|
||||
class="flex items-center gap-2 text-xs cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="localGranted.includes(perm)"
|
||||
class="rounded accent-[#F7931A]"
|
||||
@change="togglePermission(perm)"
|
||||
/>
|
||||
<span class="text-white/50">{{ perm }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="w-full py-2 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
|
||||
@click="save"
|
||||
>
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { PluginPermission } from '@/stores/pluginMarketplace'
|
||||
|
||||
const props = defineProps<{
|
||||
pluginId: string
|
||||
settings: Record<string, unknown>
|
||||
permissions: PluginPermission[]
|
||||
grantedPermissions: PluginPermission[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateSettings: [settings: Record<string, unknown>]
|
||||
updatePermissions: [permissions: PluginPermission[]]
|
||||
}>()
|
||||
|
||||
const localSettings = ref<Record<string, unknown>>({ ...props.settings })
|
||||
const localGranted = ref<PluginPermission[]>([...props.grantedPermissions])
|
||||
const newSettingKey = ref('')
|
||||
const newSettingValue = ref('')
|
||||
|
||||
function updateSetting(key: string, value: string) {
|
||||
localSettings.value[key] = value
|
||||
}
|
||||
|
||||
function addSetting() {
|
||||
if (!newSettingKey.value.trim()) return
|
||||
localSettings.value[newSettingKey.value.trim()] = newSettingValue.value
|
||||
newSettingKey.value = ''
|
||||
newSettingValue.value = ''
|
||||
}
|
||||
|
||||
function togglePermission(perm: PluginPermission) {
|
||||
const idx = localGranted.value.indexOf(perm)
|
||||
if (idx >= 0) localGranted.value.splice(idx, 1)
|
||||
else localGranted.value.push(perm)
|
||||
}
|
||||
|
||||
function save() {
|
||||
emit('updateSettings', { ...localSettings.value })
|
||||
emit('updatePermissions', [...localGranted.value])
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,597 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" @click.self="$emit('close')">
|
||||
<div class="w-full max-w-lg mx-4 max-h-[80vh] rounded-2xl bg-[#0a0a0a] border border-white/10 flex flex-col overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-white/[0.08] flex items-center justify-between shrink-0">
|
||||
<h2 class="text-sm font-bold text-white/90">Settings</h2>
|
||||
<button
|
||||
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/5 transition-colors"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1.5 px-4 pt-3 pb-1 shrink-0 overflow-x-auto scrollbar-hide">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="text-xs px-3 min-h-[44px] flex items-center justify-center rounded-md whitespace-nowrap transition-all duration-150"
|
||||
:class="activeTab === tab.id
|
||||
? 'nav-tab-active'
|
||||
: 'text-white/40 hover:text-white/70 hover:bg-white/5'"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-6 space-y-4">
|
||||
<!-- Appearance -->
|
||||
<template v-if="activeTab === 'appearance'">
|
||||
<!-- M15.1 Accent colour -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Accent Colour</p>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button
|
||||
v-for="color in accentPresets"
|
||||
:key="color.value"
|
||||
class="w-8 h-8 rounded-lg border-2 transition-all"
|
||||
:style="{ backgroundColor: color.value }"
|
||||
:class="store.accentColor === color.value ? 'border-white/60 scale-110' : 'border-white/10'"
|
||||
:title="color.label"
|
||||
@click="store.accentColor = color.value"
|
||||
/>
|
||||
<label class="w-8 h-8 rounded-lg border-2 border-white/10 flex items-center justify-center cursor-pointer overflow-hidden relative">
|
||||
<span class="text-xs text-white/30">+</span>
|
||||
<input
|
||||
type="color"
|
||||
:value="store.accentColor"
|
||||
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||
@input="store.accentColor = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- M15.2 Glass intensity -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Glass Intensity</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="opt in glassOptions"
|
||||
:key="opt.value"
|
||||
class="flex-1 py-2 rounded-lg text-xs transition-all"
|
||||
:class="store.glassIntensity === opt.value
|
||||
? 'bg-accent/15 text-accent/80'
|
||||
: 'bg-white/5 text-white/40 hover:text-white/60 hover:bg-white/10'"
|
||||
@click="store.glassIntensity = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- M15.3 Font size -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Font Size</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="opt in fontOptions"
|
||||
:key="opt.value"
|
||||
class="flex-1 py-2 rounded-lg text-xs transition-all"
|
||||
:class="store.fontSize === opt.value
|
||||
? 'bg-accent/15 text-accent/80'
|
||||
: 'bg-white/5 text-white/40 hover:text-white/60 hover:bg-white/10'"
|
||||
@click="store.fontSize = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Content -->
|
||||
<template v-else-if="activeTab === 'content'">
|
||||
<!-- M15.4 Content type visibility -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Content Types</p>
|
||||
<p class="text-xs text-white/30">Hidden types still extract but don't show in the panel</p>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
v-for="ct in contentTypes"
|
||||
:key="ct.tab"
|
||||
class="flex items-center gap-2 p-2 rounded-lg bg-white/[0.03] border border-white/5 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="store.isTabVisible(ct.tab)"
|
||||
class="rounded accent-[#F7931A]"
|
||||
@change="store.toggleTabVisibility(ct.tab)"
|
||||
/>
|
||||
<span class="text-xs text-white/70">{{ ct.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Shortcuts -->
|
||||
<template v-else-if="activeTab === 'shortcuts'">
|
||||
<!-- M15.5 Keyboard shortcuts -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Keyboard Shortcuts</p>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="(binding, action) in store.settings.shortcuts"
|
||||
:key="action"
|
||||
class="flex items-center justify-between p-2.5 rounded-lg bg-white/[0.03] border border-white/5"
|
||||
>
|
||||
<span class="text-xs text-white/60">{{ formatAction(action as string) }}</span>
|
||||
<button
|
||||
class="text-xs px-2 py-1 rounded bg-white/5 text-white/40 hover:text-white/70 font-mono transition-colors"
|
||||
:class="recordingAction === action ? 'bg-accent/15 text-accent/80' : ''"
|
||||
@click="startRecording(action as string)"
|
||||
>
|
||||
{{ recordingAction === action ? 'Press key...' : binding }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Notifications -->
|
||||
<template v-else-if="activeTab === 'notifications'">
|
||||
<!-- M15.6 Push notifications -->
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Notifications</p>
|
||||
<label class="flex items-center gap-3 p-3 rounded-lg bg-white/[0.03] border border-white/5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="store.notificationsEnabled"
|
||||
class="rounded accent-[#F7931A]"
|
||||
@change="toggleNotifications"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-xs text-white/70">Generation complete</p>
|
||||
<p class="text-xs text-white/30">Notify when AI finishes while tab is backgrounded</p>
|
||||
</div>
|
||||
</label>
|
||||
<p v-if="notificationStatus" class="text-xs" :class="notificationOk ? 'text-emerald-400/60' : 'text-yellow-400/60'">
|
||||
{{ notificationStatus }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Storage -->
|
||||
<template v-else-if="activeTab === 'storage'">
|
||||
<!-- M15.7 Auto-archive -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Auto-Archive</p>
|
||||
<p class="text-xs text-white/30">Archive conversations older than:</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="opt in archiveOptions"
|
||||
:key="opt.value"
|
||||
class="flex-1 py-2 rounded-lg text-xs transition-all"
|
||||
:class="store.autoArchiveDays === opt.value
|
||||
? 'bg-accent/15 text-accent/80'
|
||||
: 'bg-white/5 text-white/40 hover:text-white/60 hover:bg-white/10'"
|
||||
@click="store.autoArchiveDays = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- M15.8 Full data export -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Data Export</p>
|
||||
<button
|
||||
class="w-full py-2.5 rounded-lg text-xs bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors"
|
||||
@click="exportData"
|
||||
>
|
||||
{{ isExporting ? 'Exporting...' : 'Export All Data' }}
|
||||
</button>
|
||||
<p class="text-xs text-white/20">Exports conversations, favorites, settings, tags, and collections as JSON</p>
|
||||
</div>
|
||||
|
||||
<!-- M15.9 Data wipe -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-red-400/60 uppercase tracking-wider font-bold">Danger Zone</p>
|
||||
<button
|
||||
v-if="!confirmWipe"
|
||||
class="w-full py-2.5 rounded-lg text-xs bg-red-400/10 text-red-400/60 hover:bg-red-400/20 hover:text-red-400/80 transition-colors"
|
||||
@click="confirmWipe = true"
|
||||
>
|
||||
Wipe All Data
|
||||
</button>
|
||||
<div v-else class="space-y-2 p-3 rounded-lg bg-red-400/5 border border-red-400/20">
|
||||
<p class="text-xs text-red-400/80 font-semibold">Are you sure?</p>
|
||||
<p class="text-xs text-red-400/50">This will clear all conversations, favorites, settings, and cached data.</p>
|
||||
<label class="flex items-center gap-2 text-xs text-red-400/60 cursor-pointer">
|
||||
<input v-model="wipeApiKeys" type="checkbox" class="rounded" />
|
||||
Also clear API key vault
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 py-2 rounded-lg text-xs text-white/40 hover:text-white/70 hover:bg-white/5 transition-colors"
|
||||
@click="confirmWipe = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-2 rounded-lg text-xs bg-red-400/20 text-red-400/80 hover:bg-red-400/30 transition-colors"
|
||||
@click="wipeData"
|
||||
>
|
||||
Confirm Wipe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Chat defaults -->
|
||||
<template v-else-if="activeTab === 'chat'">
|
||||
<!-- Claude API Key Management -->
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Claude API Authentication</p>
|
||||
|
||||
<!-- Status indicator -->
|
||||
<div class="flex items-center gap-2 p-3 rounded-xl bg-white/[0.03] border border-white/5">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="store.settings.useOwnApiKey && store.claudeApiKey
|
||||
? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]'
|
||||
: 'bg-amber-400 shadow-[0_0_6px_rgba(251,191,36,0.4)]'"
|
||||
/>
|
||||
<span class="text-xs text-white/60">
|
||||
{{ store.settings.useOwnApiKey && store.claudeApiKey
|
||||
? 'Using your API key'
|
||||
: 'Using server authentication (OAuth)' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Toggle -->
|
||||
<label class="flex items-center gap-3 p-2.5 rounded-lg bg-white/[0.03] border border-white/5 cursor-pointer">
|
||||
<input
|
||||
v-model="store.settings.useOwnApiKey"
|
||||
type="checkbox"
|
||||
class="rounded accent-[#F7931A]"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-xs text-white/70">Use my own API key</span>
|
||||
<p class="text-xs text-white/25 mt-0.5">Override server OAuth with your personal Claude API key</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- API Key input (shown when toggle is on) -->
|
||||
<div v-if="store.settings.useOwnApiKey" class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
:value="apiKeyDisplay"
|
||||
:type="showApiKey ? 'text' : 'password'"
|
||||
placeholder="sk-ant-api03-..."
|
||||
class="flex-1 px-3 py-2 rounded-lg text-xs bg-white/5 text-white/70 placeholder:text-white/20 outline-none focus:bg-white/10 transition-colors font-mono"
|
||||
style="font-size: 16px"
|
||||
@input="onApiKeyInput"
|
||||
@focus="onApiKeyFocus"
|
||||
@blur="onApiKeyBlur"
|
||||
/>
|
||||
<button
|
||||
class="px-2 py-2 rounded-lg text-white/30 hover:text-white/60 hover:bg-white/5 transition-colors"
|
||||
:title="showApiKey ? 'Hide' : 'Show'"
|
||||
@click="showApiKey = !showApiKey"
|
||||
>
|
||||
<svg v-if="!showApiKey" 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 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.542-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.542 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="store.claudeApiKey"
|
||||
class="text-xs px-2 py-1 rounded-md text-red-400/50 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
@click="store.setClaudeApiKey(''); apiKeyStatus = 'Key removed'"
|
||||
>
|
||||
Remove key
|
||||
</button>
|
||||
<p v-if="apiKeyStatus" class="text-xs" :class="apiKeyStatusOk ? 'text-emerald-400/70' : 'text-amber-400/70'">
|
||||
{{ apiKeyStatus }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Help text -->
|
||||
<div class="p-3 rounded-xl bg-white/[0.02] border border-white/5 space-y-1.5">
|
||||
<p class="text-xs text-white/40 font-medium">How to get a Claude API key</p>
|
||||
<ol class="text-xs text-white/25 space-y-1 list-decimal pl-4">
|
||||
<li>Go to console.anthropic.com and create an account</li>
|
||||
<li>Navigate to API Keys in your dashboard</li>
|
||||
<li>Create a new key (starts with sk-ant-api03-)</li>
|
||||
<li>Paste it above and enable "Use my own API key"</li>
|
||||
</ol>
|
||||
<p class="text-xs text-white/20 mt-2">Your key is stored encrypted on this device (AES-256-GCM) once you set an AIUI passphrase; without one it is held in memory for this session only — never written to disk unencrypted. Without a key, the server's OAuth authentication is used.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default conversation settings -->
|
||||
<div class="space-y-3 mt-6 pt-4 border-t border-white/5">
|
||||
<p class="text-xs text-accent/60 uppercase tracking-wider font-bold">Default Conversation Settings</p>
|
||||
<p class="text-xs text-white/30">Applied to all new conversations</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block">
|
||||
<span class="text-xs text-white/40">Default Model</span>
|
||||
<input
|
||||
v-model="store.settings.defaultModel"
|
||||
type="text"
|
||||
placeholder="e.g. claude-sonnet-4-6"
|
||||
class="w-full mt-1 px-3 py-2 rounded-lg text-base bg-white/5 text-white/70 placeholder:text-white/20 outline-none focus:bg-white/10 transition-colors font-mono"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-3 p-2.5 rounded-lg bg-white/[0.03] border border-white/5 cursor-pointer">
|
||||
<input
|
||||
v-model="store.settings.defaultWebSearch"
|
||||
type="checkbox"
|
||||
class="rounded accent-[#F7931A]"
|
||||
/>
|
||||
<span class="text-xs text-white/60">Enable web search by default</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-3 p-2.5 rounded-lg bg-white/[0.03] border border-white/5 cursor-pointer">
|
||||
<input
|
||||
v-model="store.settings.defaultShowTokens"
|
||||
type="checkbox"
|
||||
class="rounded accent-[#F7931A]"
|
||||
/>
|
||||
<span class="text-xs text-white/60">Show token counts by default</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import type { ContentTab } from '@/composables/contentFiltering'
|
||||
|
||||
defineEmits<{ close: [] }>()
|
||||
|
||||
type Tab = 'appearance' | 'content' | 'shortcuts' | 'notifications' | 'storage' | 'chat'
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'appearance', label: 'Appearance' },
|
||||
{ id: 'content', label: 'Content' },
|
||||
{ id: 'shortcuts', label: 'Shortcuts' },
|
||||
{ id: 'notifications', label: 'Notifications' },
|
||||
{ id: 'storage', label: 'Storage' },
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
]
|
||||
|
||||
const activeTab = ref<Tab>('appearance')
|
||||
const store = useSettingsStore()
|
||||
|
||||
// Populate the key from the encrypted vault when a passphrase session is
|
||||
// already active (standalone). Never touches localStorage (S2).
|
||||
onMounted(() => { store.initClaudeKey() })
|
||||
|
||||
// M15.1 Accent presets
|
||||
const accentPresets = [
|
||||
{ value: '#F7931A', label: 'Bitcoin Orange' },
|
||||
{ value: '#8B5CF6', label: 'Purple' },
|
||||
{ value: '#3B82F6', label: 'Blue' },
|
||||
{ value: '#10B981', label: 'Emerald' },
|
||||
{ value: '#EF4444', label: 'Red' },
|
||||
{ value: '#EC4899', label: 'Pink' },
|
||||
{ value: '#F59E0B', label: 'Amber' },
|
||||
{ value: '#06B6D4', label: 'Cyan' },
|
||||
]
|
||||
|
||||
// M15.2 Glass options
|
||||
const glassOptions: { value: 'subtle' | 'default' | 'strong'; label: string }[] = [
|
||||
{ value: 'subtle', label: 'Subtle' },
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'strong', label: 'Strong' },
|
||||
]
|
||||
|
||||
// M15.3 Font options
|
||||
const fontOptions: { value: 'compact' | 'default' | 'large'; label: string }[] = [
|
||||
{ value: 'compact', label: 'Compact (13px)' },
|
||||
{ value: 'default', label: 'Default (15px)' },
|
||||
{ value: 'large', label: 'Large (17px)' },
|
||||
]
|
||||
|
||||
// M15.4 Content types
|
||||
const contentTypes: { tab: ContentTab; label: string }[] = [
|
||||
{ tab: 'film', label: 'Films' },
|
||||
{ tab: 'song', label: 'Music' },
|
||||
{ tab: 'podcast', label: 'Podcasts' },
|
||||
{ tab: 'book', label: 'Books' },
|
||||
{ tab: 'tvshow', label: 'TV Series' },
|
||||
{ tab: 'image', label: 'Images' },
|
||||
{ tab: 'place', label: 'Places' },
|
||||
{ tab: 'news', label: 'News' },
|
||||
{ tab: 'websites', label: 'Websites' },
|
||||
{ tab: 'magazine', label: 'Brief' },
|
||||
{ tab: 'nostr', label: 'Nostr' },
|
||||
]
|
||||
|
||||
// M15.5 Shortcuts
|
||||
const recordingAction = ref<string | null>(null)
|
||||
|
||||
function formatAction(action: string): string {
|
||||
return action.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||||
}
|
||||
|
||||
function startRecording(action: string) {
|
||||
recordingAction.value = action
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
const parts: string[] = []
|
||||
if (e.metaKey || e.ctrlKey) parts.push('Cmd')
|
||||
if (e.shiftKey) parts.push('Shift')
|
||||
if (e.altKey) parts.push('Alt')
|
||||
if (e.key !== 'Meta' && e.key !== 'Control' && e.key !== 'Shift' && e.key !== 'Alt') {
|
||||
parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key)
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
store.setShortcut(action, parts.join('+'))
|
||||
}
|
||||
recordingAction.value = null
|
||||
document.removeEventListener('keydown', handler)
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
}
|
||||
|
||||
// M15.6 Notifications
|
||||
const notificationStatus = ref('')
|
||||
const notificationOk = ref(false)
|
||||
|
||||
async function toggleNotifications() {
|
||||
if (!store.notificationsEnabled) {
|
||||
// Enable
|
||||
if (!('Notification' in window)) {
|
||||
notificationStatus.value = 'Notifications not supported in this browser'
|
||||
notificationOk.value = false
|
||||
return
|
||||
}
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission === 'granted') {
|
||||
store.notificationsEnabled = true
|
||||
notificationStatus.value = 'Notifications enabled'
|
||||
notificationOk.value = true
|
||||
} else {
|
||||
notificationStatus.value = 'Permission denied'
|
||||
notificationOk.value = false
|
||||
}
|
||||
} else {
|
||||
store.notificationsEnabled = false
|
||||
notificationStatus.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// M15.7 Archive options
|
||||
const archiveOptions = [
|
||||
{ value: 0, label: 'Never' },
|
||||
{ value: 7, label: '7 days' },
|
||||
{ value: 30, label: '30 days' },
|
||||
{ value: 90, label: '90 days' },
|
||||
]
|
||||
|
||||
// M15.8 Export
|
||||
const isExporting = ref(false)
|
||||
|
||||
async function exportData() {
|
||||
isExporting.value = true
|
||||
try {
|
||||
const data: Record<string, unknown> = {}
|
||||
|
||||
// Gather all localStorage items
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key?.startsWith('aiui-')) {
|
||||
try { data[key] = JSON.parse(localStorage.getItem(key) ?? '') }
|
||||
catch { data[key] = localStorage.getItem(key) }
|
||||
}
|
||||
}
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `aiui-export-${new Date().toISOString().slice(0, 10)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} finally {
|
||||
isExporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// API key management
|
||||
const showApiKey = ref(false)
|
||||
const apiKeyEditing = ref(false)
|
||||
const apiKeyStatus = ref('')
|
||||
const apiKeyStatusOk = ref(true)
|
||||
|
||||
const apiKeyDisplay = computed(() => {
|
||||
if (apiKeyEditing.value) return store.claudeApiKey
|
||||
if (!store.claudeApiKey) return ''
|
||||
if (showApiKey.value) return store.claudeApiKey
|
||||
const k = store.claudeApiKey
|
||||
return k.length > 8 ? k.slice(0, 10) + '...' + k.slice(-4) : '****'
|
||||
})
|
||||
|
||||
async function onApiKeyInput(e: Event) {
|
||||
const val = (e.target as HTMLInputElement).value
|
||||
const held = await store.setClaudeApiKey(val)
|
||||
if (val && val.startsWith('sk-ant-api')) {
|
||||
apiKeyStatus.value = held === 'encrypted'
|
||||
? 'Key saved — stored encrypted'
|
||||
: 'Key saved for this session only — set a passphrase to store it encrypted'
|
||||
apiKeyStatusOk.value = true
|
||||
} else if (val && !val.startsWith('sk-ant-')) {
|
||||
apiKeyStatus.value = 'Key should start with sk-ant-api03-'
|
||||
apiKeyStatusOk.value = false
|
||||
} else {
|
||||
apiKeyStatus.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function onApiKeyFocus() {
|
||||
apiKeyEditing.value = true
|
||||
}
|
||||
|
||||
function onApiKeyBlur() {
|
||||
apiKeyEditing.value = false
|
||||
}
|
||||
|
||||
// M15.9 Data wipe
|
||||
const confirmWipe = ref(false)
|
||||
const wipeApiKeys = ref(false)
|
||||
|
||||
async function wipeData() {
|
||||
// Clear localStorage (except API keys if unchecked)
|
||||
const keysToKeep: string[] = []
|
||||
if (!wipeApiKeys.value) {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key?.includes('api-key') || key?.includes('vault')) {
|
||||
keysToKeep.push(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const preserved = new Map<string, string>()
|
||||
for (const key of keysToKeep) {
|
||||
preserved.set(key, localStorage.getItem(key) ?? '')
|
||||
}
|
||||
|
||||
localStorage.clear()
|
||||
|
||||
for (const [key, value] of preserved) {
|
||||
localStorage.setItem(key, value)
|
||||
}
|
||||
|
||||
// Clear IDB
|
||||
const dbs = await indexedDB.databases()
|
||||
for (const db of dbs) {
|
||||
if (db.name) indexedDB.deleteDatabase(db.name)
|
||||
}
|
||||
|
||||
// Clear SW cache
|
||||
if ('caches' in window) {
|
||||
const names = await caches.keys()
|
||||
for (const name of names) {
|
||||
await caches.delete(name)
|
||||
}
|
||||
}
|
||||
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user