archy/neode-ui/src/views/AppSession.vue

675 lines
24 KiB
Vue
Raw Normal View History

2026-03-15 00:40:55 +00:00
<template>
<div class="app-session-root">
<Teleport to="body" :disabled="displayMode === 'panel'">
<div
:class="backdropClasses"
@click.self="goBack"
>
<div
ref="sessionRef"
:class="panelClasses"
@click.stop
>
<!-- Header bar -->
<div class="sticky top-0 z-10 flex items-center gap-3 border-b border-white/10 px-4 py-3 bg-black/60 backdrop-blur-md md:bg-transparent md:backdrop-blur-none">
<!-- Back / Forward navigation -->
<div class="flex items-center gap-0.5">
<button class="app-session-btn" aria-label="Back" title="Go back" @click="iframeGoBack">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button class="app-session-btn" aria-label="Forward" title="Go forward" @click="iframeGoForward">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<span class="flex-1 truncate text-sm font-medium text-white/90">{{ appTitle }}</span>
<button class="app-session-btn" aria-label="Refresh" :disabled="isRefreshing" @click="refresh">
<svg class="w-5 h-5 transition-transform duration-300" :class="{ 'animate-spin': isRefreshing }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<!-- Display mode selector -->
<div class="relative" ref="modeMenuRef">
<button
class="app-session-btn"
aria-label="Display mode"
title="Display mode"
@click="showModeMenu = !showModeMenu"
>
<!-- Panel icon -->
<svg v-if="displayMode === 'panel'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v18m12-18H3a1 1 0 00-1 1v16a1 1 0 001 1h18a1 1 0 001-1V4a1 1 0 00-1-1z" />
</svg>
<!-- Overlay icon -->
<svg v-else-if="displayMode === 'overlay'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5z" />
</svg>
<!-- Fullscreen icon -->
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
</svg>
</button>
<!-- Dropdown -->
<Transition name="menu-fade">
<div v-if="showModeMenu" class="absolute right-0 top-full mt-1 w-48 bg-black/90 border border-white/10 rounded-lg backdrop-blur-xl shadow-2xl overflow-hidden z-50">
<button
class="mode-option"
:class="{ 'mode-option-active': displayMode === 'panel' }"
@click="setMode('panel')"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v18m12-18H3a1 1 0 00-1 1v16a1 1 0 001 1h18a1 1 0 001-1V4a1 1 0 00-1-1z" />
</svg>
<span>Right panel</span>
</button>
<button
class="mode-option"
:class="{ 'mode-option-active': displayMode === 'overlay' }"
@click="setMode('overlay')"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5z" />
</svg>
<span>Over whole app</span>
</button>
<button
class="mode-option"
:class="{ 'mode-option-active': displayMode === 'fullscreen' }"
@click="setMode('fullscreen')"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
</svg>
<span>Open fullscreen</span>
</button>
</div>
</Transition>
</div>
<button class="app-session-btn" aria-label="Open in new tab" title="Open in new tab" @click="openNewTab">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
<button class="app-session-btn" aria-label="Close" @click="closeSession">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
</div>
<!-- App frame -->
<div class="relative flex-1 min-h-0 bg-black/40 overflow-hidden">
<Transition name="content-fade">
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
<svg class="animate-spin h-8 w-8 text-blue-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
</Transition>
<iframe
v-if="appUrl && !iframeBlocked"
ref="iframeRef"
:key="refreshKey"
:src="appUrl"
class="absolute inset-0 w-full h-full border-0 iframe-scrollbar-hide"
title="App content"
@load="onLoad"
@error="onError"
/>
<!-- Iframe blocked fallback -->
<Transition name="content-fade">
<div v-if="iframeBlocked" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-white mb-2">This site blocks embedded viewing</h3>
<p class="text-white/50 text-sm mb-6">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</p>
<button
@click="openNewTabAndBack"
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open in new tab
</button>
</div>
</div>
</Transition>
<div v-if="!appUrl" class="absolute inset-0 flex items-center justify-center">
<div class="text-center px-8">
<h3 class="text-lg font-semibold text-white mb-2">App not configured</h3>
<p class="text-white/50 text-sm">No URL found for {{ appId }}</p>
</div>
</div>
</div>
</div>
<NostrIdentityPicker
:show="showIdentityPicker"
:app-name="appTitle"
@select="onIdentitySelected"
@cancel="showIdentityPicker = false"
/>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
const route = useRoute()
const router = useRouter()
const sessionRef = ref<HTMLElement | null>(null)
const iframeRef = ref<HTMLIFrameElement | null>(null)
const modeMenuRef = ref<HTMLElement | null>(null)
const loading = ref(true)
const isRefreshing = ref(false)
const iframeBlocked = ref(false)
const refreshKey = ref(0)
const showIdentityPicker = ref(false)
const showModeMenu = ref(false)
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
/** Sites known to block iframes — skip the timeout and go straight to fallback */
const IFRAME_BLOCKED_APPS = new Set<string>([])
// Display mode — persisted in localStorage
const displayMode = ref<DisplayMode>(
(localStorage.getItem(DISPLAY_MODE_KEY) as DisplayMode) || 'panel'
)
function setMode(mode: DisplayMode) {
// Exit fullscreen first if switching away
if (displayMode.value === 'fullscreen' && document.fullscreenElement) {
document.exitFullscreen().catch(() => {})
}
displayMode.value = mode
localStorage.setItem(DISPLAY_MODE_KEY, mode)
showModeMenu.value = false
// Enter fullscreen if selected
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
sessionRef.value.requestFullscreen().catch(() => {})
}
}
// Reactive classes based on display mode
const backdropClasses = computed(() => {
if (displayMode.value === 'overlay' || displayMode.value === 'fullscreen') {
return 'app-session-backdrop-overlay'
}
return 'app-session-backdrop-panel'
})
const panelClasses = computed(() => {
const base = 'app-session-panel glass-card'
if (displayMode.value === 'overlay') return `${base} app-session-overlay`
if (displayMode.value === 'fullscreen') return `${base} app-session-fullscreen`
return `${base} app-session-inpanel`
})
const appId = computed(() => route.params.appId as string)
const APP_URLS: Record<string, string> = {
// Container apps — use nginx proxy paths (strips X-Frame-Options)
'bitcoin-knots': '/app/bitcoin-ui/',
'electrs': '/app/electrs/',
'btcpay-server': '/app/btcpay/',
'lnd': '/app/lnd/',
'mempool': '/app/mempool/',
'homeassistant': '/app/homeassistant/',
'grafana': '/app/grafana/',
'searxng': '/app/searxng/',
'ollama': '/app/ollama/',
'onlyoffice': '/app/onlyoffice/',
'penpot': '/app/penpot/',
'nextcloud': '/app/nextcloud/',
'vaultwarden': '/app/vaultwarden/',
'jellyfin': '/app/jellyfin/',
'photoprism': '/app/photoprism/',
'immich': '/app/immich/',
'filebrowser': '/app/filebrowser/',
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
'portainer': '/app/portainer/',
'uptime-kuma': '/app/uptime-kuma/',
'tailscale': '/app/tailscale/',
'fedimint': '/app/fedimint/',
'nostr-rs-relay': '/app/nostr-rs-relay/',
'indeedhub': '/app/indeedhub/',
'dwn': '/app/dwn/',
'endurain': '/app/endurain/',
'botfights': 'https://botfights.net',
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
'call-the-operator': 'https://cta.tx1138.com',
// 'arch-presentation': hidden until X-Frame-Options fixed on present.l484.com
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
'nostrudel': 'https://nostrudel.ninja',
}
const APP_TITLES: Record<string, string> = {
'bitcoin-knots': 'Bitcoin', 'btcpay-server': 'BTCPay Server', 'indeedhub': 'Indeehub',
'botfights': 'BotFights', '484-kitchen': '484 Kitchen', 'arch-presentation': 'Presentation',
'homeassistant': 'Home Assistant', 'uptime-kuma': 'Uptime Kuma',
'nginx-proxy-manager': 'Nginx Proxy Manager', 'nostr-rs-relay': 'Nostr Relay',
'call-the-operator': 'Call The Operator', 'syntropy-institute': 'Syntropy Institute',
't-zero': 'T-Zero', 'nostrudel': 'noStrudel',
}
const appTitle = computed(() => APP_TITLES[appId.value] || appId.value.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
const appUrl = computed(() => {
const url = APP_URLS[appId.value]
if (!url) return ''
// Proxy paths — same origin
if (url.startsWith('/')) return `${window.location.origin}${url}`
// External HTTPS sites — direct
if (url.startsWith('https://')) return url
// Fallback: localhost port URLs (shouldn't reach here normally)
return url.replace('localhost', window.location.hostname)
})
// --- Identity ---
function isIdentityAwareApp(id: string): boolean {
return id === 'indeedhub' || id === 'nostrudel'
}
const IDENTITY_KEY = 'archipelago_app_identity_'
interface SelectedIdentity {
id: string; name: string; did: string; pubkey: string
nostr_pubkey?: string; nostr_npub?: string
}
function getStoredIdentity(): SelectedIdentity | null {
try {
const stored = localStorage.getItem(IDENTITY_KEY + appId.value)
return stored ? JSON.parse(stored) as SelectedIdentity : null
} catch { return null }
}
function storeIdentity(identity: SelectedIdentity) {
try { localStorage.setItem(IDENTITY_KEY + appId.value, JSON.stringify(identity)) } catch {}
}
function onIdentitySelected(identity: SelectedIdentity) {
showIdentityPicker.value = false
storeIdentity(identity)
sendIdentity(identity)
}
async function sendIdentity(identity: SelectedIdentity) {
try {
const challenge = `archipelago-identity:${Date.now()}`
const sigRes = await rpcClient.call<{ signature: string }>({ method: 'identity.sign', params: { id: identity.id, message: challenge } })
iframeRef.value?.contentWindow?.postMessage({
type: 'archipelago:identity', did: identity.did, name: identity.name,
pubkey: identity.pubkey, nostr_pubkey: identity.nostr_pubkey || null,
nostr_npub: identity.nostr_npub || null, challenge, signature: sigRes.signature
}, '*')
} catch {}
}
// --- Lifecycle ---
function onLoad() {
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
loading.value = false
isRefreshing.value = false
// Check if iframe actually loaded content (same-origin only)
setTimeout(() => {
try {
const doc = iframeRef.value?.contentDocument
if (doc) {
const body = doc.body
if (!body || (body.children.length === 0 && body.innerText.trim() === '')) {
iframeBlocked.value = true
}
}
} catch {
// Cross-origin — can't check, assume OK
}
}, 1000)
if (isIdentityAwareApp(appId.value)) {
const stored = getStoredIdentity()
if (stored) sendIdentity(stored)
else showIdentityPicker.value = true
}
}
function onError() {
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
loading.value = false
isRefreshing.value = false
iframeBlocked.value = true
}
function refresh() {
isRefreshing.value = true
loading.value = true
iframeBlocked.value = false
refreshKey.value++
startLoadTimeout()
}
function startLoadTimeout() {
if (loadTimeoutId) clearTimeout(loadTimeoutId)
loadTimeoutId = setTimeout(() => {
if (loading.value) {
loading.value = false
iframeBlocked.value = true
}
}, 12000)
}
function openNewTabAndBack() {
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
goBack()
}
function openNewTab() {
if (appUrl.value) window.open(appUrl.value, '_blank', 'noopener,noreferrer')
}
function iframeGoBack() {
try { iframeRef.value?.contentWindow?.history.back() } catch {}
}
function iframeGoForward() {
try { iframeRef.value?.contentWindow?.history.forward() } catch {}
}
function goBack() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
router.back()
}
function closeSession() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
router.push({ name: 'apps' })
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
else closeSession()
e.preventDefault()
}
}
// Close dropdown on outside click
function onClickOutside(e: MouseEvent) {
if (showModeMenu.value && modeMenuRef.value && !modeMenuRef.value.contains(e.target as Node)) {
showModeMenu.value = false
}
}
function onFullscreenChange() {
if (!document.fullscreenElement && displayMode.value === 'fullscreen') {
// User exited fullscreen via browser UI — switch to overlay
displayMode.value = 'overlay'
localStorage.setItem(DISPLAY_MODE_KEY, 'overlay')
}
}
// Enter fullscreen on mount if mode is fullscreen
watch(displayMode, (mode) => {
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
sessionRef.value.requestFullscreen().catch(() => {})
}
})
// --- NIP-07 ---
function onMessage(e: MessageEvent) {
if (e.data?.type === 'nostr-request') handleNostrRequest(e)
if (e.data?.type === 'archipelago:identity:request') {
const stored = getStoredIdentity()
if (stored) sendIdentity(stored)
else showIdentityPicker.value = true
}
}
async function handleNostrRequest(event: MessageEvent) {
const { id, method, params } = event.data
const source = event.source as Window | null
if (!source) return
const identityId = getStoredIdentity()?.id || null
try {
let result: unknown
if (method === 'getPublicKey') {
if (identityId) {
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'identity.get', params: { id: identityId } })
result = res.nostr_pubkey
} else {
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' })
result = res.nostr_pubkey
}
} else if (method === 'signEvent') {
if (identityId) {
result = await rpcClient.call<unknown>({ method: 'identity.nostr-sign', params: { id: identityId, event: params.event } })
} else {
result = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
}
} else if (method === 'getRelays') { result = {} }
else if (method === 'nip04.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
else if (method === 'nip04.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip04', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
else if (method === 'nip44.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
else if (method === 'nip44.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
else { throw new Error(`Unsupported NIP-07 method: ${method}`) }
source.postMessage({ type: 'nostr-response', id, result }, '*')
} catch (err) {
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, '*')
}
}
onMounted(() => {
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('message', onMessage)
document.addEventListener('click', onClickOutside)
document.addEventListener('fullscreenchange', onFullscreenChange)
// Known blocked apps — show fallback immediately
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
loading.value = false
iframeBlocked.value = true
} else {
startLoadTimeout()
}
// Enter fullscreen if that's the stored mode
if (displayMode.value === 'fullscreen') {
requestAnimationFrame(() => {
sessionRef.value?.requestFullscreen().catch(() => {})
})
}
})
onBeforeUnmount(() => {
if (loadTimeoutId) clearTimeout(loadTimeoutId)
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('message', onMessage)
document.removeEventListener('click', onClickOutside)
document.removeEventListener('fullscreenchange', onFullscreenChange)
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
})
</script>
<style scoped>
.app-session-root {
width: 100%;
height: 100%;
}
/* Panel mode — edge-to-edge dark overlay with centered glass panel */
.app-session-backdrop-panel {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
padding: 0;
}
.app-session-inpanel {
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
height: 100%;
border-radius: 0;
}
@media (min-width: 768px) {
.app-session-backdrop-panel {
padding: 1.5rem;
}
.app-session-inpanel {
border-radius: 1rem;
max-width: calc(100% - 1rem);
max-height: calc(100vh - 6rem);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
}
/* Overlay mode — covers entire viewport including sidebar */
.app-session-backdrop-overlay {
position: fixed;
inset: 0;
z-index: 2400;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(12px);
}
@media (min-width: 768px) {
.app-session-backdrop-overlay {
padding: 2.5rem;
}
}
.app-session-overlay {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
height: 100%;
border-radius: 0;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
}
@media (min-width: 768px) {
.app-session-overlay {
max-width: calc(100vw - 5rem);
max-height: calc(100vh - 5rem);
border-radius: 1rem;
}
}
/* Fullscreen mode */
.app-session-fullscreen {
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
height: 100%;
border-radius: 0 !important;
max-width: none !important;
max-height: none !important;
}
/* Shared */
.app-session-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
color: rgba(255, 255, 255, 0.7);
transition: all 0.15s ease;
flex-shrink: 0;
}
.app-session-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: white;
}
.app-session-btn:disabled {
opacity: 0.5;
}
/* Mode dropdown */
.mode-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 14px;
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
transition: all 0.15s ease;
text-align: left;
}
.mode-option:hover {
background: rgba(255, 255, 255, 0.08);
color: white;
}
.mode-option-active {
color: #fb923c;
background: rgba(251, 146, 60, 0.08);
}
.menu-fade-enter-active,
.menu-fade-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.menu-fade-enter-from,
.menu-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.content-fade-enter-active,
.content-fade-leave-active {
transition: opacity 0.2s ease;
}
.content-fade-enter-from,
.content-fade-leave-to {
opacity: 0;
}
</style>