bullshit
This commit is contained in:
@@ -6,8 +6,8 @@
|
||||
class="fixed inset-0 z-[3100] flex items-center justify-center p-4"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||
<!-- Backdrop — near-black -->
|
||||
<div class="absolute inset-0 bg-black/90 backdrop-blur-xl"></div>
|
||||
|
||||
<!-- Main panel -->
|
||||
<div
|
||||
@@ -15,97 +15,80 @@
|
||||
@click.stop
|
||||
class="relative z-10 w-full max-w-lg"
|
||||
>
|
||||
<!-- Header with animated key icon -->
|
||||
<!-- Header: screensaver-style glass disc + radial viz ring -->
|
||||
<div class="relative mb-6 flex flex-col items-center">
|
||||
<div class="key-glow-ring">
|
||||
<svg viewBox="0 0 120 120" class="w-20 h-20" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Outer ring -->
|
||||
<circle cx="60" cy="60" r="54" fill="none" stroke="rgba(251,146,60,0.2)" stroke-width="1" />
|
||||
<circle cx="60" cy="60" r="54" fill="none" stroke="#fb923c" stroke-width="1.5"
|
||||
stroke-dasharray="8 6" class="ring-spin" />
|
||||
<!-- Inner ring -->
|
||||
<circle cx="60" cy="60" r="38" fill="none" stroke="rgba(251,146,60,0.1)" stroke-width="1" />
|
||||
<circle cx="60" cy="60" r="38" fill="none" stroke="#fb923c" stroke-width="1"
|
||||
stroke-dasharray="4 8" class="ring-spin-reverse" />
|
||||
<!-- Key icon -->
|
||||
<g transform="translate(60,60)" class="key-breathe">
|
||||
<circle cx="0" cy="-8" r="10" fill="none" stroke="#fb923c" stroke-width="2" />
|
||||
<circle cx="0" cy="-8" r="4" fill="#fb923c" opacity="0.4" />
|
||||
<line x1="0" y1="2" x2="0" y2="22" stroke="#fb923c" stroke-width="2" />
|
||||
<line x1="0" y1="14" x2="6" y2="14" stroke="#fb923c" stroke-width="2" />
|
||||
<line x1="0" y1="19" x2="4" y2="19" stroke="#fb923c" stroke-width="2" />
|
||||
</g>
|
||||
<!-- Network dots -->
|
||||
<circle cx="16" cy="28" r="2" fill="#fb923c" opacity="0.5" class="node-pulse" style="--pulse-delay: 0s" />
|
||||
<circle cx="104" cy="32" r="2" fill="#fb923c" opacity="0.5" class="node-pulse" style="--pulse-delay: 0.5s" />
|
||||
<circle cx="20" cy="92" r="2" fill="#fb923c" opacity="0.5" class="node-pulse" style="--pulse-delay: 1s" />
|
||||
<circle cx="100" cy="88" r="2" fill="#fb923c" opacity="0.5" class="node-pulse" style="--pulse-delay: 1.5s" />
|
||||
<!-- Connection lines -->
|
||||
<line x1="16" y1="28" x2="40" y2="48" stroke="#fb923c" stroke-width="0.5" opacity="0.2" />
|
||||
<line x1="104" y1="32" x2="80" y2="48" stroke="#fb923c" stroke-width="0.5" opacity="0.2" />
|
||||
<line x1="20" y1="92" x2="40" y2="72" stroke="#fb923c" stroke-width="0.5" opacity="0.2" />
|
||||
<line x1="100" y1="88" x2="80" y2="72" stroke="#fb923c" stroke-width="0.5" opacity="0.2" />
|
||||
</svg>
|
||||
<div class="nostr-hero">
|
||||
<!-- Radial viz segments — exact screensaver pattern, 48 bars, #FAFAFA -->
|
||||
<div class="nostr-viz-ring">
|
||||
<div
|
||||
v-for="(_, i) in 48"
|
||||
:key="i"
|
||||
class="nostr-viz-segment"
|
||||
:style="{ '--seg-i': i, '--seg-deg': `${(i / 48) * 360}deg` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Glass disc — exact logo-gradient-border from screensaver -->
|
||||
<div class="nostr-glass-border">
|
||||
<div class="nostr-glass-inner">
|
||||
<svg viewBox="0 0 122.88 88.39" width="42" height="30" xmlns="http://www.w3.org/2000/svg" class="nostr-cinema-svg">
|
||||
<path fill="#FAFAFA" fill-rule="evenodd" clip-rule="evenodd" d="M87.51,21.16c5.26,1.45,10.79,1.84,16.58,1.18c1.42-0.16,2.81-0.35,4.16-0.53c6.46-0.84,11.86-1.32,13.78,3.52 c3.39,8.55-4.28,27.07-8.32,34.56c-8.32,15.43-24.9,32.69-44.08,27.57c-2.99-0.8-5.68-2.1-8.08-3.86 c6.3-3.51,11.28-8.9,15.13-15.24l-0.01,0.02c4.77,0.26,9.73,2.78,14.27,5.44c0.33-5.99-5.46-9.97-10.62-12.45 c4.14-9.29,6.33-19.72,7.01-29.03C87.53,29.46,87.64,25.53,87.51,21.16L87.51,21.16z M2.61,6.51c1.56-1.48,3.92-1.87,6.6-1.7 c5.03,0.31,10.23,1.86,15.11,3.18c10.61,2.86,20.99,1.93,31.1-2.74c1.36-0.63,2.69-1.28,3.98-1.9C65.56,0.37,70.8-1.9,74.31,2.3 c6.21,7.42,4.68,28.44,3.13,37.25c-3.2,18.15-14.03,40.87-34.88,42.1c-11.06,0.65-20.49-5.57-28.61-17.32 c-5.17-8-8.9-16.22-11.18-24.67C1.13,33.5-2.46,11.34,2.61,6.51L2.61,6.51z M12.94,34.3c-1.91-0.5-3.01-1.12-3.38-1.85 c-1.47-2.92,10.66-10.29,19.22-3.52C40.95,38.4,17.26,35.58,12.94,34.3L12.94,34.3z M32.63,62.79c-3.23-2.31-4.96-5.16-5.9-9.02 c10.67,5.4,20.66,5.01,29.96-2.42c-0.37,3.29-1.44,6.24-3.28,8.83C47.98,67.83,40.04,68.08,32.63,62.79L32.63,62.79z M67.07,30.06 c1.79-0.84,2.76-1.65,2.99-2.44c0.92-3.14-12.35-8.19-19.54,0.03C40.27,39.18,63.06,32.1,67.07,30.06L67.07,30.06z M90.82,42.07 c5.04-4.04,11.94-3.22,16.74,0.73c1.22,1.01,4.57,3.95,2.64,5.56c-0.53,0.44-1.41,0.69-2.63,0.75c-2.98,0.34-7.32-0.28-10.78-1.71 C94.07,46.3,92.01,44.83,90.82,42.07L90.82,42.07z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-4 text-lg font-semibold text-white">Select Identity</h2>
|
||||
<p class="mt-1 text-xs text-white/50">Nostr authentication protocol</p>
|
||||
<h2 class="mt-5 text-lg font-semibold text-white">Select Identity</h2>
|
||||
<p class="mt-1 text-white/25 tracking-widest uppercase" style="font-size: 10px;">Nostr authentication protocol</p>
|
||||
</div>
|
||||
|
||||
<!-- Identity list -->
|
||||
<div class="glass-card p-4 space-y-3 max-h-[50vh] overflow-y-auto">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<svg class="animate-spin h-6 w-6 text-orange-400" viewBox="0 0 24 24" fill="none">
|
||||
<svg class="animate-spin h-6 w-6 text-white/40" 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>
|
||||
<span class="ml-3 text-white/60 text-sm">Loading identities...</span>
|
||||
</div>
|
||||
|
||||
<!-- No identities -->
|
||||
<div v-else-if="identities.length === 0" class="text-center py-8">
|
||||
<p class="text-white/50 text-sm">No identities found.</p>
|
||||
<p class="text-white/30 text-xs mt-1">Create one in Settings → Credentials</p>
|
||||
</div>
|
||||
|
||||
<!-- Identity cards -->
|
||||
<button
|
||||
v-for="identity in identities"
|
||||
:key="identity.id"
|
||||
type="button"
|
||||
class="w-full text-left p-3 rounded-lg border transition-all duration-200"
|
||||
:class="selectedId === identity.id
|
||||
? 'bg-orange-500/10 border-orange-500/30'
|
||||
: 'bg-white/5 border-white/10 hover:bg-white/8 hover:border-white/15'"
|
||||
? 'bg-white/8 border-white/25'
|
||||
: 'bg-white/3 border-white/8 hover:bg-white/6 hover:border-white/15'"
|
||||
@click="selectedId = identity.id"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Avatar -->
|
||||
<div
|
||||
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 border"
|
||||
:class="avatarClasses(identity.purpose)"
|
||||
>
|
||||
<span class="text-sm font-bold">{{ identity.name.charAt(0).toUpperCase() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-white font-semibold text-sm truncate">{{ identity.name }}</span>
|
||||
<span v-if="identity.is_default" class="text-[10px] px-1.5 py-0.5 rounded bg-orange-500/20 text-orange-400">default</span>
|
||||
<span v-if="identity.is_default" class="text-[10px] px-1.5 py-0.5 rounded bg-white/10 text-white/60">default</span>
|
||||
</div>
|
||||
<div class="mt-0.5">
|
||||
<span v-if="identity.nostr_npub" class="text-white/40 text-xs font-mono truncate">{{ truncateNpub(identity.nostr_npub) }}</span>
|
||||
<span v-if="identity.nostr_npub" class="text-white/35 text-xs font-mono truncate">{{ truncateNpub(identity.nostr_npub) }}</span>
|
||||
<span v-else class="text-red-400/60 text-xs">No Nostr key</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Radio indicator -->
|
||||
<div class="shrink-0">
|
||||
<div v-if="selectedId === identity.id" class="w-5 h-5 rounded-full bg-orange-500/30 border border-orange-400 flex items-center justify-center">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-orange-400"></div>
|
||||
<div v-if="selectedId === identity.id" class="w-5 h-5 rounded-full bg-white/20 border border-white/50 flex items-center justify-center">
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-white/80"></div>
|
||||
</div>
|
||||
<div v-else class="w-5 h-5 rounded-full border border-white/20"></div>
|
||||
<div v-else class="w-5 h-5 rounded-full border border-white/15"></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -113,7 +96,7 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button @click="$emit('cancel')" class="glass-button flex-1 py-3 rounded-lg text-sm font-medium text-white/80">
|
||||
<button @click="$emit('cancel')" class="glass-button flex-1 py-3 rounded-lg text-sm font-medium text-white/70">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@@ -121,18 +104,15 @@
|
||||
:disabled="!selectedId || !hasNostrKey"
|
||||
class="flex-1 py-3 rounded-lg text-sm font-semibold transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
:class="selectedId && hasNostrKey
|
||||
? 'bg-orange-500/20 border border-orange-500/40 text-orange-400 hover:bg-orange-500/30'
|
||||
: 'bg-white/5 border border-white/10 text-white/40'"
|
||||
? 'bg-white/10 border border-white/25 text-white hover:bg-white/15'
|
||||
: 'bg-white/3 border border-white/8 text-white/40'"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1.5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
Authenticate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-center text-[10px] text-white/25 tracking-wider">
|
||||
NIP-07 · SECP256K1 · Signed locally
|
||||
<p class="mt-3 text-center text-[10px] text-white/20 tracking-widest">
|
||||
NIP-07 · SECP256K1 · Signed locally
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,9 +159,7 @@ const hasNostrKey = computed(() => {
|
||||
})
|
||||
|
||||
watch(() => props.show, async (open) => {
|
||||
if (open) {
|
||||
await loadIdentities()
|
||||
}
|
||||
if (open) await loadIdentities()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@@ -195,9 +173,7 @@ async function loadIdentities() {
|
||||
identities.value = res.identities || []
|
||||
const defaultId = identities.value.find(i => i.is_default && i.nostr_pubkey)
|
||||
|| identities.value.find(i => i.nostr_pubkey)
|
||||
if (defaultId) {
|
||||
selectedId.value = defaultId.id
|
||||
}
|
||||
if (defaultId) selectedId.value = defaultId.id
|
||||
} catch {
|
||||
identities.value = []
|
||||
} finally {
|
||||
@@ -207,9 +183,7 @@ async function loadIdentities() {
|
||||
|
||||
function confirm() {
|
||||
const selected = identities.value.find(i => i.id === selectedId.value)
|
||||
if (selected) {
|
||||
emit('select', selected)
|
||||
}
|
||||
if (selected) emit('select', selected)
|
||||
}
|
||||
|
||||
function truncateNpub(npub: string): string {
|
||||
@@ -221,92 +195,122 @@ function avatarClasses(purpose: string): string {
|
||||
switch (purpose) {
|
||||
case 'business': return 'bg-blue-500/15 text-blue-400 border-blue-500/25'
|
||||
case 'anonymous': return 'bg-purple-500/15 text-purple-400 border-purple-500/25'
|
||||
default: return 'bg-orange-500/15 text-orange-400 border-orange-500/25'
|
||||
default: return 'bg-white/10 text-white/80 border-white/20'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Glow ring around key icon */
|
||||
.key-glow-ring {
|
||||
/* ── Hero container ── */
|
||||
.nostr-hero {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
width: 148px;
|
||||
height: 148px;
|
||||
}
|
||||
.key-glow-ring::before {
|
||||
content: '';
|
||||
|
||||
/* ── Radial viz ring — exact screensaver pattern, #FAFAFA ── */
|
||||
.nostr-viz-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(251, 146, 60, 0.12) 0%, transparent 70%);
|
||||
animation: glow-pulse 3s ease-in-out infinite;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% { opacity: 0.4; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.15); }
|
||||
.nostr-viz-segment {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 2.5px;
|
||||
height: 14px;
|
||||
margin-left: -1.25px;
|
||||
margin-top: -7px;
|
||||
background: linear-gradient(to bottom, rgba(250, 250, 250, 0.4), rgba(250, 250, 250, 0.06));
|
||||
border-radius: 1.5px;
|
||||
transform-origin: center center;
|
||||
transform: rotate(var(--seg-deg)) translateY(-60px);
|
||||
animation: seg-pulse 14s ease-in-out infinite;
|
||||
animation-delay: calc(var(--seg-i) * 0.02s);
|
||||
}
|
||||
|
||||
/* Rotating rings */
|
||||
.ring-spin {
|
||||
animation: ring-rotate 20s linear infinite;
|
||||
transform-origin: 60px 60px;
|
||||
}
|
||||
.ring-spin-reverse {
|
||||
animation: ring-rotate 15s linear infinite reverse;
|
||||
transform-origin: 60px 60px;
|
||||
/* Exact screensaver keyframes — 5 normal pulses then 1 strong expression, 14s total */
|
||||
@keyframes seg-pulse {
|
||||
0% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
|
||||
7.1% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
|
||||
14.3% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
|
||||
21.4% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
|
||||
28.6% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
|
||||
35.7% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
|
||||
42.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
|
||||
50% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
|
||||
57.1% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
|
||||
64.3% { opacity: 0.7; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1); }
|
||||
71.4% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
|
||||
78.6% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1.5); }
|
||||
85.7% { opacity: 1; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(1.5); }
|
||||
92.9% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
|
||||
100% { opacity: 0.15; transform: rotate(var(--seg-deg)) translateY(-60px) scaleY(0.4); }
|
||||
}
|
||||
|
||||
@keyframes ring-rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
/* ── Glass disc — exact screensaver logo-gradient-border ── */
|
||||
.nostr-glass-border {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
border-radius: 9999px;
|
||||
padding: 3px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.6) 0%, rgba(0, 0, 0, 0.8) 100%);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
filter: drop-shadow(0 0 24px rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
/* Key breathing animation */
|
||||
.key-breathe {
|
||||
animation: breathe 4s ease-in-out infinite;
|
||||
transform-origin: 0 6px;
|
||||
.nostr-glass-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 9999px;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% { opacity: 0.8; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.05); }
|
||||
/* ── Cinema icon — breathing glow ── */
|
||||
.nostr-cinema-svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 0 12px rgba(250, 250, 250, 0.12));
|
||||
animation: cinema-breathe 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Network node pulse */
|
||||
.node-pulse {
|
||||
animation: node-blink 3s ease-in-out infinite;
|
||||
animation-delay: var(--pulse-delay, 0s);
|
||||
@keyframes cinema-breathe {
|
||||
0%, 100% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
filter: drop-shadow(0 0 8px rgba(250, 250, 250, 0.08));
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.08);
|
||||
filter: drop-shadow(0 0 20px rgba(250, 250, 250, 0.22));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes node-blink {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.8; r: 3; }
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
/* ── Modal transitions ── */
|
||||
.identity-picker-enter-active,
|
||||
.identity-picker-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.identity-picker-enter-active > .relative {
|
||||
transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease;
|
||||
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease;
|
||||
}
|
||||
.identity-picker-leave-active > .relative {
|
||||
transition: transform 0.25s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.identity-picker-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
.identity-picker-enter-from > .relative {
|
||||
transform: translateY(20px) scale(0.96);
|
||||
opacity: 0;
|
||||
}
|
||||
.identity-picker-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.identity-picker-leave-to > .relative {
|
||||
transform: translateY(10px) scale(0.98);
|
||||
opacity: 0;
|
||||
}
|
||||
.identity-picker-enter-from { opacity: 0; }
|
||||
.identity-picker-enter-from > .relative { transform: translateY(24px) scale(0.94); opacity: 0; }
|
||||
.identity-picker-leave-to { opacity: 0; }
|
||||
.identity-picker-leave-to > .relative { transform: translateY(10px) scale(0.98); opacity: 0; }
|
||||
</style>
|
||||
|
||||
@@ -169,6 +169,11 @@ const router = createRouter({
|
||||
name: 'chat',
|
||||
component: () => import('../views/Chat.vue'),
|
||||
},
|
||||
{
|
||||
path: 'app-session/:appId',
|
||||
name: 'app-session',
|
||||
component: () => import('../views/AppSession.vue'),
|
||||
},
|
||||
// Containers removed: My Apps serves the same purpose. Redirect old links.
|
||||
{
|
||||
path: 'containers',
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import router from '@/router'
|
||||
|
||||
/** Hostnames of external sites that block iframes via X-Frame-Options or CSP.
|
||||
* These always open in a new tab. Other external sites load directly in the iframe. */
|
||||
const IFRAME_BLOCKED_HOSTS: string[] = [
|
||||
'484.kitchen',
|
||||
'botfights.net',
|
||||
'present.l484.com',
|
||||
]
|
||||
/** Legacy: these used to open in new tabs. Now all apps go through AppSession. */
|
||||
const IFRAME_BLOCKED_HOSTS: string[] = []
|
||||
|
||||
/** External site proxy paths — disabled. External URLs load directly in the iframe
|
||||
* via their standard https:// URL. The /ext/ subpath approach broke SPAs. */
|
||||
@@ -66,7 +64,7 @@ const PORT_TO_PROXY: Record<string, string> = {
|
||||
'8176': '/app/fedimint-gateway/',
|
||||
'3100': '/app/dwn/',
|
||||
'18081': '/app/nostr-rs-relay/',
|
||||
'8190': '/app/indeedhub/',
|
||||
'7777': '/app/indeedhub/',
|
||||
}
|
||||
|
||||
/** Rewrite to same-origin proxy ONLY when needed for HTTPS mixed-content.
|
||||
@@ -131,7 +129,19 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const showConsent = ref(false)
|
||||
let previousActiveElement: HTMLElement | null = null
|
||||
|
||||
/** Open app in full-page session view (preferred — no iframe subpath issues) */
|
||||
function openSession(appId: string) {
|
||||
router.push({ name: 'app-session', params: { appId } })
|
||||
}
|
||||
|
||||
/** Legacy: open app in iframe overlay (kept for backward compat) */
|
||||
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
|
||||
// Route to full-page session if we can resolve an app ID from the URL
|
||||
const resolvedId = resolveAppIdFromUrl(payload.url)
|
||||
if (resolvedId) {
|
||||
openSession(resolvedId)
|
||||
return
|
||||
}
|
||||
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
|
||||
window.open(payload.url, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
@@ -143,6 +153,31 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
/** Resolve an app ID from a URL (port or known external) */
|
||||
function resolveAppIdFromUrl(urlStr: string): string | null {
|
||||
try {
|
||||
const u = new URL(urlStr)
|
||||
// Check port-based apps
|
||||
for (const [port, proxyPath] of Object.entries(PORT_TO_PROXY)) {
|
||||
if (u.port === port) {
|
||||
return proxyPath.replace('/app/', '').replace(/\/$/, '')
|
||||
}
|
||||
}
|
||||
// Check external URLs
|
||||
const EXTERNAL_APP_HOSTS: Record<string, string> = {
|
||||
'botfights.net': 'botfights',
|
||||
'nwnn.l484.com': 'nwnn',
|
||||
'484.kitchen': '484-kitchen',
|
||||
'cta.tx1138.com': 'call-the-operator',
|
||||
'present.l484.com': 'arch-presentation',
|
||||
'syntropy.institute': 'syntropy-institute',
|
||||
'teeminuszero.net': 't-zero',
|
||||
'nostrudel.ninja': 'nostrudel',
|
||||
}
|
||||
return EXTERNAL_APP_HOSTS[u.hostname] || null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function close() {
|
||||
const toRestore = previousActiveElement
|
||||
previousActiveElement = null
|
||||
@@ -289,6 +324,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
url,
|
||||
title,
|
||||
open,
|
||||
openSession,
|
||||
close,
|
||||
showConsent,
|
||||
consentRequest,
|
||||
|
||||
@@ -500,8 +500,6 @@ const isWebOnly = computed(() => appId.value in WEB_ONLY_APP_URLS)
|
||||
/** Map route/marketplace app IDs to backend package keys (container names). */
|
||||
const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
|
||||
mempool: 'mempool-web',
|
||||
'mempool-electrs': 'mempool-electrs',
|
||||
electrs: 'mempool-electrs',
|
||||
btcpay: 'btcpay-server',
|
||||
'btcpay-server': 'btcpay-server',
|
||||
fedimint: 'fedimint',
|
||||
@@ -714,124 +712,7 @@ function goBack() {
|
||||
|
||||
function launchApp() {
|
||||
if (!pkg.value) return
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
const id = appId.value
|
||||
|
||||
// Web-only apps — use their external URL directly
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (webOnlyUrl) {
|
||||
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title })
|
||||
return
|
||||
}
|
||||
|
||||
// Special handling for apps with Docker containers
|
||||
const appUrls: Record<string, { dev: string, prod: string }> = {
|
||||
'lorabell': {
|
||||
dev: 'http://192.168.1.166',
|
||||
prod: 'http://192.168.1.166'
|
||||
},
|
||||
'atob': {
|
||||
dev: 'http://localhost:8102',
|
||||
prod: 'https://app.atobitcoin.io'
|
||||
},
|
||||
'k484': {
|
||||
dev: 'http://localhost:8103',
|
||||
prod: 'http://localhost:8103' // Self-hosted splash screen
|
||||
},
|
||||
'indeedhub': {
|
||||
dev: 'http://localhost:8190',
|
||||
prod: 'http://localhost:8190'
|
||||
},
|
||||
// Dummy apps - replace with real URLs when packaged
|
||||
'bitcoin': {
|
||||
dev: 'http://localhost:8332',
|
||||
prod: 'http://localhost:8332'
|
||||
},
|
||||
'btcpay-server': {
|
||||
dev: 'http://localhost:23000',
|
||||
prod: 'http://localhost:23000'
|
||||
},
|
||||
'homeassistant': {
|
||||
dev: 'http://localhost:8123',
|
||||
prod: 'http://localhost:8123'
|
||||
},
|
||||
'grafana': {
|
||||
dev: 'http://localhost:3000',
|
||||
prod: 'http://localhost:3000'
|
||||
},
|
||||
'endurain': {
|
||||
dev: 'http://localhost:8080',
|
||||
prod: 'http://localhost:8080'
|
||||
},
|
||||
'fedimint': {
|
||||
dev: 'http://localhost:8175',
|
||||
prod: 'http://192.168.1.228:8175'
|
||||
},
|
||||
'fedimint-gateway': {
|
||||
dev: 'http://localhost:8176',
|
||||
prod: 'http://192.168.1.228:8176'
|
||||
},
|
||||
'morphos-server': {
|
||||
dev: 'http://localhost:8081',
|
||||
prod: 'http://localhost:8081'
|
||||
},
|
||||
'lightning-stack': {
|
||||
dev: 'http://localhost:9735',
|
||||
prod: 'http://localhost:9735'
|
||||
},
|
||||
'mempool': {
|
||||
dev: 'http://localhost:4080',
|
||||
prod: 'http://localhost:4080'
|
||||
},
|
||||
'ollama': {
|
||||
dev: 'http://localhost:11434',
|
||||
prod: 'http://localhost:11434'
|
||||
},
|
||||
'searxng': {
|
||||
dev: 'http://localhost:8888',
|
||||
prod: 'http://localhost:8888'
|
||||
},
|
||||
'onlyoffice': {
|
||||
dev: 'http://localhost:9980',
|
||||
prod: 'http://localhost:9980'
|
||||
},
|
||||
'penpot': {
|
||||
dev: 'http://localhost:9001',
|
||||
prod: 'http://localhost:9001'
|
||||
},
|
||||
'nextcloud': { dev: 'http://localhost:8085', prod: 'http://localhost:8085' },
|
||||
'vaultwarden': { dev: 'http://localhost:8082', prod: 'http://localhost:8082' },
|
||||
'jellyfin': { dev: 'http://localhost:8096', prod: 'http://localhost:8096' },
|
||||
'photoprism': { dev: 'http://localhost:2342', prod: 'http://localhost:2342' },
|
||||
'immich': { dev: 'http://localhost:2283', prod: 'http://localhost:2283' },
|
||||
'filebrowser': { dev: 'http://localhost:8083', prod: 'http://localhost:8083' },
|
||||
'nginx-proxy-manager': { dev: 'http://localhost:81', prod: 'http://localhost:81' },
|
||||
'portainer': { dev: 'http://localhost:9000', prod: 'http://localhost:9000' },
|
||||
'uptime-kuma': { dev: 'http://localhost:3001', prod: 'http://localhost:3001' },
|
||||
'tailscale': { dev: 'http://localhost:8240', prod: 'http://localhost:8240' },
|
||||
'lnd': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
|
||||
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' },
|
||||
}
|
||||
|
||||
if (appUrls[id]) {
|
||||
let url = isDev ? appUrls[id].dev : appUrls[id].prod
|
||||
// Replace localhost with current hostname for remote access
|
||||
if (url.includes('localhost')) {
|
||||
url = url.replace('localhost', window.location.hostname)
|
||||
}
|
||||
useAppLauncherStore().open({ url, title: pkg.value.manifest.title })
|
||||
return
|
||||
}
|
||||
|
||||
// For other apps, construct the launch URL
|
||||
// In a real deployment, this would use the Tor or LAN address from interfaces
|
||||
const torAddress = pkg.value.manifest.interfaces?.main?.['tor-config']
|
||||
const lanConfig = pkg.value.manifest.interfaces?.main?.['lan-config']
|
||||
|
||||
if (torAddress || lanConfig) {
|
||||
showActionError(t('appDetails.noLaunchUrl'))
|
||||
}
|
||||
useAppLauncherStore().openSession(appId.value)
|
||||
}
|
||||
|
||||
async function startApp() {
|
||||
|
||||
674
neode-ui/src/views/AppSession.vue
Normal file
674
neode-ui/src/views/AppSession.vue
Normal file
@@ -0,0 +1,674 @@
|
||||
<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>
|
||||
@@ -279,7 +279,7 @@ const WEB_ONLY_APP_URLS: Record<string, string> = {
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
'call-the-operator': 'https://cta.tx1138.com',
|
||||
'arch-presentation': 'https://present.l484.com',
|
||||
// 'arch-presentation': hidden until X-Frame-Options fixed
|
||||
'syntropy-institute': 'https://syntropy.institute',
|
||||
't-zero': 'https://teeminuszero.net',
|
||||
}
|
||||
@@ -310,11 +310,12 @@ const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
|
||||
manifest: { id: 'call-the-operator', title: 'Call the Operator', version: '1.0.0', description: { short: 'Escape the Matrix — explore decentralized alternatives', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/call-the-operator.png' },
|
||||
},
|
||||
/* arch-presentation hidden until X-Frame-Options fixed
|
||||
'arch-presentation': {
|
||||
state: 'running' as PackageState,
|
||||
manifest: { id: 'arch-presentation', title: 'Arch Presentation', version: '1.0.0', description: { short: 'Archipelago: The Future of Decentralized Infrastructure', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/arch-presentation.png' },
|
||||
},
|
||||
}, */
|
||||
'syntropy-institute': {
|
||||
state: 'running' as PackageState,
|
||||
manifest: { id: 'syntropy-institute', title: 'Syntropy Institute', version: '1.0.0', description: { short: 'Medicine Reimagined — frequency analysis-therapy', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
@@ -382,59 +383,7 @@ function canLaunch(pkg: PackageDataEntry): boolean {
|
||||
}
|
||||
|
||||
function launchApp(id: string) {
|
||||
const isDev = import.meta.env.DEV
|
||||
const pkg = packages.value[id]
|
||||
|
||||
// Web-only apps — use their external URL directly
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (webOnlyUrl) {
|
||||
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg?.manifest?.title || id })
|
||||
return
|
||||
}
|
||||
|
||||
// Explicit URLs for apps that need them (checked first to avoid package data issues)
|
||||
const appUrls: Record<string, { dev: string, prod: string }> = {
|
||||
'lorabell': {
|
||||
dev: 'http://192.168.1.166',
|
||||
prod: 'http://192.168.1.166'
|
||||
},
|
||||
'atob': {
|
||||
dev: 'http://localhost:8102',
|
||||
prod: 'https://app.atobitcoin.io'
|
||||
},
|
||||
'k484': {
|
||||
dev: 'http://localhost:8103',
|
||||
prod: 'http://localhost:8103' // Self-hosted splash screen
|
||||
},
|
||||
}
|
||||
|
||||
if (appUrls[id]) {
|
||||
let url = isDev ? appUrls[id].dev : appUrls[id].prod
|
||||
// Replace localhost with current hostname for remote access (not for external IPs like LoraBell)
|
||||
if (url.includes('localhost')) {
|
||||
const currentHost = window.location.hostname
|
||||
url = url.replace('localhost', currentHost)
|
||||
}
|
||||
useAppLauncherStore().open({ url, title: pkg?.manifest?.title || id })
|
||||
return
|
||||
}
|
||||
|
||||
// Get the LAN address from the package
|
||||
let lanAddress = pkg?.installed?.['interface-addresses']?.main?.['lan-address']
|
||||
|
||||
// Replace localhost with the current hostname (for remote access)
|
||||
if (lanAddress && lanAddress.includes('localhost')) {
|
||||
const currentHost = window.location.hostname
|
||||
lanAddress = lanAddress.replace('localhost', currentHost)
|
||||
}
|
||||
|
||||
if (lanAddress) {
|
||||
useAppLauncherStore().open({ url: lanAddress, title: pkg?.manifest?.title || id })
|
||||
return
|
||||
}
|
||||
|
||||
// For other apps, navigate to app details which has launch functionality
|
||||
router.push(`/dashboard/apps/${id}`).catch(() => {})
|
||||
useAppLauncherStore().openSession(id)
|
||||
}
|
||||
|
||||
function getStatusClass(state: PackageState): string {
|
||||
|
||||
@@ -639,75 +639,8 @@ function isInstalled(appId: string): boolean {
|
||||
return aliases ? aliases.some((a) => a in installedPackages.value) : false
|
||||
}
|
||||
|
||||
/** Web-only apps — external URLs with no container */
|
||||
const WEB_ONLY_APP_URLS: Record<string, string> = {
|
||||
'botfights': 'https://botfights.net',
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
'call-the-operator': 'https://cta.tx1138.com',
|
||||
'arch-presentation': 'https://present.l484.com',
|
||||
'syntropy-institute': 'https://syntropy.institute',
|
||||
't-zero': 'https://teeminuszero.net',
|
||||
'nostrudel': 'https://nostrudel.ninja',
|
||||
}
|
||||
|
||||
/** App ID to port-based URL for container apps */
|
||||
const APP_LAUNCH_URLS: Record<string, string> = {
|
||||
'bitcoin-knots': 'http://localhost:8334',
|
||||
'btcpay-server': 'http://localhost:23000',
|
||||
'lnd': 'http://localhost:8081',
|
||||
'mempool': 'http://localhost:4080',
|
||||
'homeassistant': 'http://localhost:8123',
|
||||
'grafana': 'http://localhost:3000',
|
||||
'searxng': 'http://localhost:8888',
|
||||
'ollama': 'http://localhost:11434',
|
||||
'onlyoffice': 'http://localhost:9980',
|
||||
'penpot': 'http://localhost:9001',
|
||||
'nextcloud': 'http://localhost:8085',
|
||||
'vaultwarden': 'http://localhost:8082',
|
||||
'jellyfin': 'http://localhost:8096',
|
||||
'photoprism': 'http://localhost:2342',
|
||||
'immich': 'http://localhost:2283',
|
||||
'filebrowser': 'http://localhost:8083',
|
||||
'nginx-proxy-manager': 'http://localhost:81',
|
||||
'portainer': 'http://localhost:9000',
|
||||
'uptime-kuma': 'http://localhost:3001',
|
||||
'tailscale': 'http://localhost:8240',
|
||||
'fedimint': 'http://localhost:8175',
|
||||
'nostr-rs-relay': 'http://localhost:18081',
|
||||
'dwn': 'http://localhost:3100',
|
||||
'indeedhub': 'http://localhost:8190',
|
||||
}
|
||||
|
||||
function launchInstalledApp(app: MarketplaceApp) {
|
||||
const id = app.id
|
||||
|
||||
// Web-only apps
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (webOnlyUrl) {
|
||||
appLauncher.open({ url: webOnlyUrl, title: app.title || id })
|
||||
return
|
||||
}
|
||||
|
||||
// Web URL on the marketplace app object (e.g. Nostr-discovered apps)
|
||||
if (app.webUrl) {
|
||||
appLauncher.open({ url: app.webUrl, title: app.title || id })
|
||||
return
|
||||
}
|
||||
|
||||
// Container apps with known ports
|
||||
const portUrl = APP_LAUNCH_URLS[id]
|
||||
if (portUrl) {
|
||||
let url = portUrl
|
||||
if (url.includes('localhost')) {
|
||||
url = url.replace('localhost', window.location.hostname)
|
||||
}
|
||||
appLauncher.open({ url, title: app.title || id })
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: navigate to the app detail page
|
||||
router.push({ name: 'app-details', params: { id } })
|
||||
appLauncher.openSession(app.id)
|
||||
}
|
||||
|
||||
// Load community marketplace on mount
|
||||
|
||||
Reference in New Issue
Block a user