frontend: polish app launch and release experience
This commit is contained in:
@@ -39,6 +39,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
show: boolean
|
||||
@@ -65,6 +66,7 @@ function close() {
|
||||
}
|
||||
|
||||
useModalKeyboard(modalRef, computed(() => props.show), close)
|
||||
useBodyScrollLock(computed(() => props.show))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,10 +11,18 @@
|
||||
class="glass-card p-5 w-full max-w-sm relative z-10 mb-20 sm:mb-0"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Icon -->
|
||||
<div class="flex justify-center mb-3">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center">
|
||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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">
|
||||
<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" />
|
||||
@@ -22,38 +30,44 @@
|
||||
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold text-white text-center mb-2">Remote Companion</h3>
|
||||
<p class="text-sm text-white/60 text-center mb-4 leading-relaxed">
|
||||
Control your node from another device. Install the
|
||||
<span class="text-orange-400 font-medium">Archipelago</span>
|
||||
companion app on your phone, connect to the same network, and use it as a
|
||||
gamepad or keyboard.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2 text-xs text-white/40">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-5 h-5 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-[10px] font-bold">1</span>
|
||||
<span>Install the APK on your phone</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-5 h-5 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-[10px] font-bold">2</span>
|
||||
<span>Enter your node address and password</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-5 h-5 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-[10px] font-bold">3</span>
|
||||
<span>Use D-pad & buttons or keyboard to control apps</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="mt-4 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"
|
||||
@click="dismiss"
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
Download companion app
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -61,11 +75,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import * as QRCode from 'qrcode'
|
||||
|
||||
const STORAGE_KEY = 'neode_companion_intro_seen'
|
||||
const DEFAULT_DOWNLOAD_URL = '/packages/archipelago-companion.apk.zip'
|
||||
|
||||
const visible = ref(false)
|
||||
const qrDataUrl = ref('')
|
||||
const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
@@ -78,6 +96,19 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
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' })
|
||||
|
||||
function dismiss() {
|
||||
visible.value = false
|
||||
try {
|
||||
|
||||
@@ -20,6 +20,7 @@ const sharingLocation = ref(false)
|
||||
const locationSource = ref<'browser' | 'device'>('browser')
|
||||
const locationError = ref('')
|
||||
const hasDeviceGps = computed(() => mesh.deadmanStatus?.has_gps ?? false)
|
||||
const locationPermissionDenied = computed(() => locationError.value === 'Location permission denied')
|
||||
let geoWatchId: number | null = null
|
||||
|
||||
function toggleLocationSharing() {
|
||||
@@ -337,7 +338,7 @@ onUnmounted(() => {
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
<span>Enable location sharing to see nodes on the map</span>
|
||||
<span>{{ locationPermissionDenied ? 'Local location is off. Other device positions will appear when received.' : 'Waiting for mesh device positions.' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Location sharing overlay -->
|
||||
@@ -373,7 +374,9 @@ onUnmounted(() => {
|
||||
>Mesh Radio GPS</button>
|
||||
</div>
|
||||
|
||||
<div v-if="locationError" class="mesh-map-location-error">{{ locationError }}</div>
|
||||
<div v-if="locationError" class="mesh-map-location-error">
|
||||
{{ locationPermissionDenied ? 'Location permission denied. Peer locations can still appear on the map.' : locationError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
</div>
|
||||
<div v-else class="mb-3 text-center">
|
||||
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
|
||||
<p v-if="processing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,6 +68,7 @@ import { ref, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -124,6 +126,9 @@ async function receive() {
|
||||
nextTick(() => renderQr(res.payment_request, lightningQrCanvas.value, 'lightning:'))
|
||||
} else if (receiveMethod.value === 'onchain') {
|
||||
const res = await rpcClient.call<{ address: string }>({ method: 'lnd.newaddress' })
|
||||
if (!res.address) {
|
||||
throw new Error('LND did not return a Bitcoin address')
|
||||
}
|
||||
onchainAddress.value = res.address
|
||||
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
|
||||
} else {
|
||||
@@ -136,7 +141,9 @@ async function receive() {
|
||||
emit('received')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed'
|
||||
error.value = receiveMethod.value === 'onchain'
|
||||
? explainReceiveAddressFailure(err)
|
||||
: err instanceof Error ? err.message : 'Failed'
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
|
||||
@@ -93,14 +93,6 @@
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- AI Assistant placeholder -->
|
||||
<div class="p-2 border-t border-white/10">
|
||||
<div class="px-3 py-2 text-xs font-medium text-white/50 uppercase tracking-wider">AI Assistant</div>
|
||||
<div class="px-3 py-3 rounded-lg bg-white/5 text-white/50 text-sm">
|
||||
Coming soon — ask questions about your node, apps, and Bitcoin.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import BaseModal from '../BaseModal.vue'
|
||||
|
||||
describe('BaseModal', () => {
|
||||
afterEach(() => {
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
it('locks page scroll while open and restores it when closed', async () => {
|
||||
const wrapper = mount(BaseModal, {
|
||||
props: { show: true, title: 'Test modal' },
|
||||
slots: { default: '<p>Modal content</p>' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
|
||||
expect(document.body.style.overflow).toBe('hidden')
|
||||
|
||||
await wrapper.setProps({ show: false })
|
||||
expect(document.body.style.overflow).toBe('')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MeshMap from '../MeshMap.vue'
|
||||
|
||||
const meshState = vi.hoisted(() => ({
|
||||
nodePositions: new Map(),
|
||||
peers: [],
|
||||
status: null,
|
||||
deadmanStatus: null,
|
||||
updateSelfPosition: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/mesh', () => ({
|
||||
useMeshStore: () => meshState,
|
||||
}))
|
||||
|
||||
vi.mock('leaflet', () => ({
|
||||
default: {
|
||||
map: vi.fn(() => ({
|
||||
invalidateSize: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
})),
|
||||
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
|
||||
layerGroup: vi.fn(() => ({ addTo: vi.fn(), clearLayers: vi.fn(), addLayer: vi.fn() })),
|
||||
divIcon: vi.fn((opts) => opts),
|
||||
marker: vi.fn(() => ({ bindPopup: vi.fn() })),
|
||||
polyline: vi.fn(() => ({})),
|
||||
latLngBounds: vi.fn(() => ({ pad: vi.fn() })),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('MeshMap', () => {
|
||||
beforeEach(() => {
|
||||
meshState.nodePositions.clear()
|
||||
meshState.peers = []
|
||||
meshState.status = null
|
||||
meshState.deadmanStatus = null
|
||||
meshState.updateSelfPosition.mockClear()
|
||||
})
|
||||
|
||||
it('treats denied browser location as optional for peer positions', async () => {
|
||||
let errorHandler!: (error: { code: number; message: string }) => void
|
||||
const watchPosition = vi.fn((_success, error) => {
|
||||
errorHandler = error
|
||||
return 7
|
||||
})
|
||||
const clearWatch = vi.fn()
|
||||
const resizeObserver = vi.fn(() => ({
|
||||
observe: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal('navigator', {
|
||||
geolocation: { watchPosition, clearWatch },
|
||||
})
|
||||
vi.stubGlobal('ResizeObserver', resizeObserver)
|
||||
|
||||
const wrapper = mount(MeshMap)
|
||||
|
||||
expect(wrapper.text()).toContain('Waiting for mesh device positions.')
|
||||
|
||||
await wrapper.get('[role="switch"]').trigger('click')
|
||||
errorHandler({ code: 1, message: 'denied' })
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Location permission denied. Peer locations can still appear on the map.')
|
||||
expect(wrapper.text()).toContain('Local location is off. Other device positions will appear when received.')
|
||||
expect(wrapper.text()).not.toContain('location sharing is required')
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user