2026-04-11 20:00:05 +01:00
|
|
|
<template>
|
|
|
|
|
<Teleport to="body">
|
|
|
|
|
<Transition name="overlay-fade">
|
|
|
|
|
<div
|
|
|
|
|
v-if="visible"
|
|
|
|
|
class="fixed inset-0 flex items-end sm:items-center justify-center p-4 z-[3000]"
|
|
|
|
|
@click.self="dismiss"
|
|
|
|
|
>
|
|
|
|
|
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" />
|
|
|
|
|
<div
|
|
|
|
|
class="glass-card p-5 w-full max-w-sm relative z-10 mb-20 sm:mb-0"
|
|
|
|
|
@click.stop
|
|
|
|
|
>
|
2026-06-11 00:24:40 -04:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="absolute right-3 top-3 h-8 w-8 rounded-full bg-white/5 border border-white/10 text-white/60 hover:text-white hover:bg-white/10 transition-colors"
|
|
|
|
|
aria-label="Close companion modal"
|
|
|
|
|
@click="dismiss"
|
|
|
|
|
>
|
|
|
|
|
×
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<div class="flex items-start gap-4 mb-4">
|
|
|
|
|
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
|
|
|
|
|
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
2026-04-11 20:00:05 +01:00
|
|
|
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
|
|
|
|
|
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
|
|
|
|
|
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
|
|
|
|
|
<circle cx="16" cy="11" r="1.2" fill="currentColor" />
|
|
|
|
|
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
2026-06-11 00:24:40 -04:00
|
|
|
<div class="min-w-0 flex-1">
|
|
|
|
|
<h3 class="text-lg font-semibold text-white mb-1">Remote Companion</h3>
|
|
|
|
|
<p class="text-sm text-white/60 leading-relaxed">
|
|
|
|
|
Install the Archipelago companion app on your phone, scan the code, and connect to the same node.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-04-11 20:00:05 +01:00
|
|
|
</div>
|
|
|
|
|
|
2026-06-11 00:24:40 -04:00
|
|
|
<div class="mb-4">
|
|
|
|
|
<a
|
|
|
|
|
:href="companionDownloadUrl"
|
|
|
|
|
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-4 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors"
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
>
|
|
|
|
|
Download companion app
|
|
|
|
|
</a>
|
|
|
|
|
<div class="hidden md:flex justify-center">
|
|
|
|
|
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
|
|
|
|
<img
|
|
|
|
|
v-if="qrDataUrl"
|
|
|
|
|
:src="qrDataUrl"
|
|
|
|
|
alt="Companion app download QR code"
|
|
|
|
|
class="block w-full max-w-full h-auto rounded-lg bg-white"
|
|
|
|
|
/>
|
|
|
|
|
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
|
|
|
|
|
</div>
|
2026-04-11 20:00:05 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-11 00:24:40 -04:00
|
|
|
<a
|
|
|
|
|
:href="companionDownloadUrl"
|
|
|
|
|
class="hidden md:block w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30 text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors text-center"
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
2026-04-11 20:00:05 +01:00
|
|
|
>
|
2026-06-11 00:24:40 -04:00
|
|
|
Download companion app
|
|
|
|
|
</a>
|
2026-04-11 20:00:05 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Transition>
|
|
|
|
|
</Teleport>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-07-16 17:42:57 -04:00
|
|
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
2026-06-11 00:24:40 -04:00
|
|
|
import * as QRCode from 'qrcode'
|
2026-07-14 11:38:51 -04:00
|
|
|
import { IS_DEMO } from '@/composables/useDemoIntro'
|
2026-07-16 06:57:42 -04:00
|
|
|
import { useLoginTransitionStore } from '@/stores/loginTransition'
|
2026-04-11 20:00:05 +01:00
|
|
|
|
|
|
|
|
const STORAGE_KEY = 'neode_companion_intro_seen'
|
2026-06-19 09:50:10 -04:00
|
|
|
// Absolute URL so the QR works when scanned by a phone (a relative path has no
|
|
|
|
|
// host to resolve). Points at the companion APK hosted on the 146 release server
|
|
|
|
|
// (publicly reachable) rather than the local node's /packages copy.
|
2026-07-14 11:38:51 -04:00
|
|
|
// The demo serves the APK from its own public origin instead, so the QR never
|
|
|
|
|
// exposes the release-server address.
|
|
|
|
|
const DEFAULT_DOWNLOAD_URL = IS_DEMO
|
|
|
|
|
? `${window.location.origin}/packages/archipelago-companion.apk`
|
|
|
|
|
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk'
|
2026-04-11 20:00:05 +01:00
|
|
|
|
|
|
|
|
const visible = ref(false)
|
2026-06-11 00:24:40 -04:00
|
|
|
const qrDataUrl = ref('')
|
|
|
|
|
const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL
|
2026-04-11 20:00:05 +01:00
|
|
|
|
2026-07-16 06:57:42 -04:00
|
|
|
const loginTransition = useLoginTransitionStore()
|
|
|
|
|
|
|
|
|
|
// Base delay before the popup may appear, and extra breathing room after the
|
|
|
|
|
// dashboard entrance cinematic ends so the popup never cuts into the reveal.
|
|
|
|
|
const BASE_DELAY_MS = 5000
|
|
|
|
|
const POST_INTRO_GRACE_MS = 2000
|
|
|
|
|
|
2026-07-16 17:42:57 -04:00
|
|
|
let calmTicker: ReturnType<typeof setInterval> | null = null
|
|
|
|
|
|
2026-04-11 20:00:05 +01:00
|
|
|
onMounted(() => {
|
|
|
|
|
try {
|
|
|
|
|
if (localStorage.getItem(STORAGE_KEY) !== '1') {
|
2026-07-16 06:57:42 -04:00
|
|
|
setTimeout(maybeShow, BASE_DELAY_MS)
|
2026-04-11 20:00:05 +01:00
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// localStorage unavailable
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-16 17:42:57 -04:00
|
|
|
onUnmounted(() => {
|
|
|
|
|
if (calmTicker) clearInterval(calmTicker)
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-16 06:57:42 -04:00
|
|
|
function maybeShow() {
|
2026-07-16 17:42:57 -04:00
|
|
|
// Show only after the scene has been CONTINUOUSLY calm (no reveal
|
|
|
|
|
// cinematic) for the full grace window. The previous point-in-time check
|
|
|
|
|
// raced a slow-starting reveal — on a cold cache the entrance video can
|
|
|
|
|
// begin buffering after the 5s base delay, so the flag was still false
|
|
|
|
|
// when sampled and the popup cut straight into the cinematic.
|
|
|
|
|
let calmSince = loginTransition.introCinematicPlaying ? null : Date.now()
|
|
|
|
|
calmTicker = setInterval(() => {
|
|
|
|
|
if (loginTransition.introCinematicPlaying) {
|
|
|
|
|
calmSince = null
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (calmSince === null) calmSince = Date.now()
|
|
|
|
|
if (Date.now() - calmSince >= POST_INTRO_GRACE_MS) {
|
|
|
|
|
if (calmTicker) clearInterval(calmTicker)
|
|
|
|
|
calmTicker = null
|
|
|
|
|
visible.value = true
|
|
|
|
|
}
|
|
|
|
|
}, 250)
|
2026-07-16 06:57:42 -04:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 00:24:40 -04:00
|
|
|
watch(visible, async (isVisible) => {
|
|
|
|
|
if (!isVisible) return
|
|
|
|
|
qrDataUrl.value = await QRCode.toDataURL(companionDownloadUrl, {
|
|
|
|
|
width: 112,
|
|
|
|
|
margin: 1,
|
|
|
|
|
errorCorrectionLevel: 'M',
|
|
|
|
|
color: {
|
|
|
|
|
dark: '#111111',
|
|
|
|
|
light: '#ffffff',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}, { immediate: true, flush: 'post' })
|
|
|
|
|
|
2026-04-11 20:00:05 +01:00
|
|
|
function dismiss() {
|
|
|
|
|
visible.value = false
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(STORAGE_KEY, '1')
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.overlay-fade-enter-active { transition: opacity 0.3s ease; }
|
|
|
|
|
.overlay-fade-leave-active { transition: opacity 0.2s ease; }
|
|
|
|
|
.overlay-fade-enter-from,
|
|
|
|
|
.overlay-fade-leave-to { opacity: 0; }
|
|
|
|
|
.overlay-fade-enter-active .glass-card { transition: transform 0.3s ease; }
|
|
|
|
|
.overlay-fade-enter-from .glass-card { transform: translateY(20px); }
|
|
|
|
|
</style>
|