Refactor Indeehub integration and enhance deployment documentation

- Updated Indeehub references throughout the codebase, changing the name from "IndeedHub" to "Indeehub" for consistency.
- Implemented a virtual app structure for Indeehub, allowing it to open an external URL without requiring a container.
- Enhanced deployment scripts and documentation to clarify SSH access and password management for Indeehub.
- Improved error handling and retry logic in various components to ensure better user experience during onboarding and app interactions.
- Updated CSS for visual enhancements and added new buttons for improved navigation in the AppLauncherOverlay.
This commit is contained in:
Dorian
2026-03-01 17:53:18 +00:00
parent 2c15311ab6
commit 7a05e11834
34 changed files with 877 additions and 163 deletions
+53 -31
View File
@@ -24,44 +24,66 @@ class RPCClient {
async call<T>(options: RPCOptions): Promise<T> {
const { method, params = {}, timeout = 30000 } = options
const maxRetries = 3
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
for (let attempt = 0; attempt < maxRetries; attempt++) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(this.baseUrl, {
method: 'POST',
credentials: 'include', // Important for session cookies
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ method, params }),
signal: controller.signal,
})
try {
const response = await fetch(this.baseUrl, {
method: 'POST',
credentials: 'include', // Important for session cookies
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ method, params }),
signal: controller.signal,
})
clearTimeout(timeoutId)
clearTimeout(timeoutId)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data: RPCResponse<T> = await response.json()
if (data.error) {
throw new Error(data.error.message || 'RPC Error')
}
return data.result as T
} catch (error) {
clearTimeout(timeoutId)
if (error instanceof Error) {
if (error.name === 'AbortError') {
throw new Error('Request timeout')
if (!response.ok) {
const err = new Error(`HTTP ${response.status}: ${response.statusText}`)
const isRetryable = response.status === 502 || response.status === 503
if (isRetryable && attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)))
continue
}
throw err
}
throw error
const data: RPCResponse<T> = await response.json()
if (data.error) {
throw new Error(data.error.message || 'RPC Error')
}
return data.result as T
} catch (error) {
clearTimeout(timeoutId)
if (error instanceof Error) {
if (error.name === 'AbortError') {
const timeoutErr = new Error('Request timeout')
if (attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)))
continue
}
throw timeoutErr
}
const msg = error.message
const isRetryable = /502|503|Bad Gateway|fetch|network/i.test(msg)
if (isRetryable && attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)))
continue
}
throw error
}
throw new Error('Unknown error occurred')
}
throw new Error('Unknown error occurred')
}
throw new Error('Request failed after retries')
}
// Convenience methods
+1 -1
View File
@@ -352,7 +352,7 @@ function getWebSocketClient(): WebSocketClient {
if (typeof window !== 'undefined') {
(window as any).__archipelago_ws_client = wsClientInstance
}
console.log('[WebSocket] Created new client instance')
if (import.meta.env.DEV) console.debug('[WebSocket] Created new client instance')
}
return wsClientInstance
@@ -39,6 +39,17 @@
<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>
<button
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-white/15 text-white/70 hover:text-white transition-colors"
aria-label="Open in new tab"
title="Open in new tab"
@click="openInNewTab"
>
<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
ref="closeBtnRef"
type="button"
@@ -86,6 +97,12 @@ function refreshIframe() {
iframeRefreshKey.value++
}
function openInNewTab() {
if (store.url) {
window.open(store.url, '_blank', 'noopener,noreferrer')
}
}
function onIframeLoad() {
injectScrollbarHideIfSameOrigin()
isRefreshing.value = false
+13 -5
View File
@@ -6,10 +6,18 @@ let audioContext: AudioContext | null = null
let introAudio: HTMLAudioElement | null = null
let introGain: GainNode | null = null
/** Get AudioContext - only returns existing. Create via resumeAudioContext() after user gesture. */
function getContext(): AudioContext | null {
return audioContext
}
/** Create AudioContext if needed (call only from user gesture - click/tap/key) */
function ensureContext(): AudioContext | null {
if (audioContext) return audioContext
try {
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
const Ctx = window.AudioContext || (window as any).webkitAudioContext
if (!Ctx) return null
audioContext = new Ctx()
return audioContext
} catch {
return null
@@ -44,21 +52,21 @@ export function playLoopStart() {
.catch(() => {})
}
/** Resume audio context (call on first user interaction to unlock autoplay) */
/** Resume audio context - MUST be called from user gesture (click/tap/key). Creates context if needed. */
export function resumeAudioContext() {
const ctx = getContext()
const ctx = ensureContext()
if (ctx?.state === 'suspended') {
ctx.resume().catch(() => {})
}
}
/** Start intro loop - Cosmic Updrift (free royalty) */
/** Start intro loop - Cosmic Updrift. Only works after resumeAudioContext() (user gesture). */
export function startSynthwave() {
const ctxOrNull = getContext()
if (!ctxOrNull) return
try {
if (ctxOrNull.state === 'suspended') ctxOrNull.resume()
if (ctxOrNull.state === 'suspended') ctxOrNull.resume().catch(() => {})
} catch {
return
}
+20 -10
View File
@@ -1,20 +1,30 @@
/**
* Onboarding state - prefers backend, falls back to localStorage for mock/offline.
* Hardened: retries on 502/503, never blocks completion.
*/
import { rpcClient } from '@/api/rpc-client'
export async function isOnboardingComplete(): Promise<boolean> {
try {
return await rpcClient.isOnboardingComplete()
} catch {
return localStorage.getItem('neode_onboarding_complete') === '1'
async function callWithRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T | null> {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn()
} catch (e) {
const msg = e instanceof Error ? e.message : ''
const isRetryable = /502|503|timeout|fetch|network/i.test(msg)
if (!isRetryable || i === maxRetries - 1) return null
await new Promise((r) => setTimeout(r, 800 * (i + 1)))
}
}
return null
}
export async function isOnboardingComplete(): Promise<boolean> {
const result = await callWithRetry(() => rpcClient.isOnboardingComplete(), 2)
if (result !== null) return result
return localStorage.getItem('neode_onboarding_complete') === '1'
}
export async function completeOnboarding(): Promise<void> {
try {
await rpcClient.completeOnboarding()
} finally {
localStorage.setItem('neode_onboarding_complete', '1')
}
await callWithRetry(() => rpcClient.completeOnboarding(), 3)
localStorage.setItem('neode_onboarding_complete', '1')
}
+24 -9
View File
@@ -134,8 +134,18 @@ router.beforeEach(async (to, _from, next) => {
// Allow all public routes (login, onboarding) without auth check
if (isPublic) {
// If already authenticated and trying to access login, redirect to home
// If authenticated and visiting /login, validate session first
if (to.path === '/login' && store.isAuthenticated) {
if (store.needsSessionValidation()) {
const valid = await store.checkSession()
if (valid) {
next({ name: 'home' })
return
}
// Session invalid, allow login page
next()
return
}
next({ name: 'home' })
return
}
@@ -143,30 +153,35 @@ router.beforeEach(async (to, _from, next) => {
return
}
// Protected routes require authentication
// Check session if not already authenticated
// Protected routes: validate session if stale auth from localStorage
if (store.needsSessionValidation()) {
const valid = await store.checkSession()
if (!valid) {
next('/login')
return
}
next()
return
}
// Not authenticated at all
if (!store.isAuthenticated) {
const hasSession = await store.checkSession()
if (hasSession) {
next()
return
}
// No valid session - redirect to login
next('/login')
return
}
// User is already authenticated (from localStorage on page load)
// Make sure WebSocket is connected
// Validated and authenticated - ensure WebSocket is connected
if (!store.isConnected && !store.isReconnecting) {
console.log('[Router] User authenticated but WebSocket not connected, connecting...')
store.connectWebSocket().catch((err) => {
console.warn('[Router] WebSocket connection failed:', err)
})
}
// Authenticated user accessing protected route
next()
})
+18 -19
View File
@@ -15,6 +15,7 @@ export const useAppStore = defineStore('app', () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
let isWsSubscribed = false
let sessionValidated = false
// Computed
const serverInfo = computed(() => data.value?.['server-info'])
@@ -33,13 +34,16 @@ export const useAppStore = defineStore('app', () => {
try {
await rpcClient.login(password)
isAuthenticated.value = true
sessionValidated = true
localStorage.setItem('neode-auth', 'true')
// Connect WebSocket after successful login
await connectWebSocket()
// Initialize data
// Initialize data structure immediately so dashboard can render
await initializeData()
// Connect WebSocket in background - don't block login flow
connectWebSocket().catch((err) => {
console.warn('[Store] WebSocket connection failed after login, will retry:', err)
})
} catch (err) {
error.value = err instanceof Error ? err.message : 'Login failed'
throw err
@@ -55,10 +59,10 @@ export const useAppStore = defineStore('app', () => {
console.error('Logout error:', err)
} finally {
isAuthenticated.value = false
sessionValidated = false
localStorage.removeItem('neode-auth')
data.value = null
isWsSubscribed = false
// Disconnect WebSocket on logout (this prevents reconnection)
wsClient.disconnect()
isConnected.value = false
isReconnecting.value = false
@@ -182,48 +186,42 @@ export const useAppStore = defineStore('app', () => {
}
}
// Check session validity on app load
// Check session validity on app load or stale auth
async function checkSession(): Promise<boolean> {
console.log('[Store] Checking session...')
if (!localStorage.getItem('neode-auth')) {
console.log('[Store] No auth token found')
return false
}
try {
// Try to make an authenticated request to verify session
console.log('[Store] Validating session with backend...')
await rpcClient.call({ method: 'server.echo', params: { message: 'ping' } })
isAuthenticated.value = true
console.log('[Store] Session valid, reconnecting WebSocket...')
sessionValidated = true
// Initialize data structure first
await initializeData()
// Connect WebSocket - don't wait for it, let it reconnect in background
// This ensures the page loads quickly even if WebSocket is slow
connectWebSocket().catch((err) => {
console.warn('[Store] WebSocket reconnection failed, will retry automatically:', err)
// The WebSocket client will handle retries automatically
console.warn('[Store] WebSocket reconnection failed, will retry:', err)
isReconnecting.value = true
})
return true
} catch (err) {
console.error('[Store] Session check failed:', err)
// Session invalid, clear auth
localStorage.removeItem('neode-auth')
isAuthenticated.value = false
sessionValidated = false
isWsSubscribed = false
isConnected.value = false
isReconnecting.value = false
// Disconnect WebSocket if session is invalid
wsClient.disconnect()
return false
}
}
function needsSessionValidation(): boolean {
return isAuthenticated.value && !sessionValidated
}
// Package actions
async function installPackage(id: string, marketplaceUrl: string, version: string): Promise<string> {
return rpcClient.installPackage(id, marketplaceUrl, version)
@@ -293,6 +291,7 @@ export const useAppStore = defineStore('app', () => {
login,
logout,
checkSession,
needsSessionValidation,
connectWebSocket,
installPackage,
uninstallPackage,
+38 -6
View File
@@ -16,14 +16,45 @@ function mustOpenInNewTab(url: string): boolean {
}
}
/** Rewrite to same-origin proxy so iframe can embed (nginx strips X-Frame-Options) */
/** Port → proxy path for apps (nginx strips X-Frame-Options) */
const PORT_TO_PROXY: Record<string, string> = {
'81': '/app/nginx-proxy-manager/',
'3000': '/app/grafana/',
'3001': '/app/uptime-kuma/',
'8080': '/app/endurain/',
'8081': '/app/lnd/',
'8082': '/app/vaultwarden/',
'8083': '/app/filebrowser/',
'8085': '/app/nextcloud/',
'8096': '/app/jellyfin/',
'8123': '/app/homeassistant/',
'8240': '/app/tailscale/',
'8334': '/app/bitcoin-ui/',
'8888': '/app/searxng/',
'9000': '/app/portainer/',
'9001': '/app/penpot/',
'9980': '/app/onlyoffice/',
'11434': '/app/ollama/',
'2283': '/app/immich/',
'23000': '/app/btcpay/',
'2342': '/app/photoprism/',
'4080': '/app/mempool/',
'50002': '/app/electrs/',
'8175': '/app/fedimint/',
}
/** Rewrite to same-origin proxy so iframe can embed (avoids mixed content on HTTPS) */
function toEmbeddableUrl(url: string): string {
try {
const u = new URL(url)
const origin = window.location.origin
// Only Vaultwarden and Penpot support subpath proxy; Nextcloud/Immich open in new tab
if (u.port === '8082') return `${origin}/app/vaultwarden/`
if (u.port === '9001') return `${origin}/app/penpot/`
const proxyPath = PORT_TO_PROXY[u.port]
const sameHost = u.hostname === window.location.hostname
const needsProxy = window.location.protocol === 'https:' && u.protocol === 'http:'
// Use proxy when: (a) mixed content, or (b) vaultwarden/penpot always (subpath required)
if (proxyPath && sameHost && (needsProxy || u.port === '8082' || u.port === '9001')) {
return `${origin}${proxyPath}`
}
} catch {
/* ignore */
}
@@ -37,12 +68,13 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
let previousActiveElement: HTMLElement | null = null
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
const embeddableUrl = toEmbeddableUrl(payload.url)
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
window.open(payload.url, '_blank', 'noopener,noreferrer')
window.open(embeddableUrl, '_blank', 'noopener,noreferrer')
return
}
previousActiveElement = (document.activeElement as HTMLElement) || null
url.value = toEmbeddableUrl(payload.url)
url.value = embeddableUrl
title.value = payload.title
isOpen.value = true
}
+7 -1
View File
@@ -556,7 +556,7 @@ html::before {
opacity: 0;
}
/* Subtle black glitch overlay */
/* Subtle black glitch overlay - delay to avoid flash of partial clip-path on first paint (black crescent bug) */
body::before {
background-image: url('/assets/img/bg.jpg');
background-size: auto 100vh;
@@ -566,6 +566,8 @@ body::before {
filter: brightness(0.4) contrast(1.2);
will-change: transform, clip-path, opacity;
animation: bg-glitch-shift-repeat 5s steps(10, end) infinite;
animation-delay: 1.5s;
animation-fill-mode: backwards;
}
/* Second subtle black layer */
@@ -578,6 +580,8 @@ html::before {
filter: brightness(0.3) contrast(1.3);
will-change: transform, clip-path, opacity;
animation: bg-glitch-shift-2-repeat 5s steps(9, end) infinite;
animation-delay: 1.6s;
animation-fill-mode: backwards;
}
/* Subtle scanline sweep */
@@ -588,6 +592,8 @@ body::after {
radial-gradient(ellipse at center, rgba(0,0,0,0) 40%, rgba(0,0,0,0.15) 100%);
will-change: transform, opacity;
animation: bg-glitch-scan-repeat 5s ease-out infinite;
animation-delay: 1.5s;
animation-fill-mode: backwards;
}
/* Dashboard: full viewport width, no letterboxing */
+5 -5
View File
@@ -515,15 +515,15 @@ export const dummyApps: Record<string, PackageDataEntry> = {
'static-files': {
license: 'MIT',
instructions: 'Decentralized media streaming platform',
icon: '/assets/img/app-icons/indeedhub.png'
icon: 'https://indeehub.studio/favicon.ico'
},
manifest: {
id: 'indeedhub',
title: 'IndeedHub',
title: 'Indeehub',
version: '0.1.0',
description: {
short: 'Decentralized media streaming platform',
long: 'IndeedHub is a decentralized media streaming platform built on Nostr. Stream Bitcoin-focused documentaries, educational content, and independent films. Netflix-inspired interface with glassmorphism design, supporting content creators through the decentralized web.'
long: 'Indeehub is a decentralized media streaming platform built on Nostr. Stream Bitcoin-focused documentaries, educational content, and independent films. Netflix-inspired interface with glassmorphism design, supporting content creators through the decentralized web.'
},
'release-notes': 'Initial release with Netflix-inspired interface',
license: 'MIT',
@@ -539,8 +539,8 @@ export const dummyApps: Record<string, PackageDataEntry> = {
'last-backup': null,
'interface-addresses': {
main: {
'tor-address': 'indeedhub.onion',
'lan-address': 'http://localhost:7777'
'tor-address': '',
'lan-address': 'https://archipelago.indeehub.studio'
}
},
status: ServiceStatus.Running
+2 -2
View File
@@ -643,8 +643,8 @@ function launchApp() {
prod: 'http://localhost:8103' // Self-hosted splash screen
},
'indeedhub': {
dev: 'http://localhost:7777',
prod: 'http://localhost:7777' // Containerized indeehub prototype
dev: 'https://archipelago.indeehub.studio',
prod: 'https://archipelago.indeehub.studio'
},
// Dummy apps - replace with real URLs when packaged
'bitcoin': {
+2 -2
View File
@@ -245,8 +245,8 @@ function launchApp(id: string) {
prod: 'http://localhost:8103' // Self-hosted splash screen
},
'indeedhub': {
dev: 'http://localhost:7777',
prod: 'http://localhost:7777' // Containerized indeehub prototype
dev: 'https://archipelago.indeehub.studio',
prod: 'https://archipelago.indeehub.studio'
}
}
+8 -2
View File
@@ -701,8 +701,14 @@ function getIconPath(iconName: string): string[] {
}
async function handleLogout() {
await store.logout()
router.push('/login')
try {
await store.logout()
} catch {
/* proceed to login regardless */
}
router.push('/login').catch(() => {
window.location.href = '/login'
})
}
// Track previous route for transition logic
+34 -12
View File
@@ -136,7 +136,7 @@ import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { useAppStore } from '../stores/app'
import { useLoginTransitionStore } from '../stores/loginTransition'
import { rpcClient } from '../api/rpc-client'
import { startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
import { resumeAudioContext, startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
const router = useRouter()
const store = useAppStore()
@@ -155,17 +155,25 @@ const isSetupMode = computed(() => {
})
onMounted(async () => {
if (sessionStorage.getItem('archipelago_from_splash') !== '1') {
startSynthwave()
} else {
sessionStorage.removeItem('archipelago_from_splash')
const fromSplash = sessionStorage.getItem('archipelago_from_splash') === '1'
if (fromSplash) sessionStorage.removeItem('archipelago_from_splash')
const unlock = () => {
if (!fromSplash) {
resumeAudioContext()
startSynthwave()
}
document.removeEventListener('click', unlock)
document.removeEventListener('touchstart', unlock)
document.removeEventListener('keydown', unlock)
}
document.addEventListener('click', unlock, { once: true })
document.addEventListener('touchstart', unlock, { once: true })
document.addEventListener('keydown', unlock, { once: true })
if (isSetupMode.value) {
try {
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {} })
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {}, timeout: 8000 })
isSetup.value = Boolean(result)
} catch (err) {
console.error('Failed to check setup status:', err)
} catch {
isSetup.value = false
}
} else {
@@ -207,10 +215,17 @@ async function handleSetup() {
loginTransition.setJustLoggedIn(true)
await store.login(password.value)
await new Promise(r => setTimeout(r, 520))
router.replace({ name: 'home' })
await router.replace({ name: 'home' }).catch(() => {
window.location.href = '/dashboard'
})
} catch (err) {
whooshAway.value = false
error.value = err instanceof Error ? err.message : 'Setup failed. Please try again.'
const msg = err instanceof Error ? err.message : ''
if (/502|503|Bad Gateway|timeout|fetch|network/i.test(msg)) {
error.value = 'Server is starting up. Please try again in a moment.'
} else {
error.value = msg || 'Setup failed. Please try again.'
}
startSynthwave()
} finally {
loading.value = false
@@ -237,10 +252,17 @@ async function handleLogin() {
playLoginSuccessWhoosh()
loginTransition.setJustLoggedIn(true)
await new Promise(r => setTimeout(r, 520))
router.replace({ name: 'home' })
await router.replace({ name: 'home' }).catch(() => {
window.location.href = '/dashboard'
})
} catch (err) {
whooshAway.value = false
error.value = err instanceof Error ? err.message : 'Login failed. Please check your password.'
const msg = err instanceof Error ? err.message : ''
if (/502|503|Bad Gateway|timeout|fetch|network/i.test(msg)) {
error.value = 'Server is starting up. Please try again in a moment.'
} else {
error.value = msg || 'Login failed. Please check your password.'
}
startSynthwave()
} finally {
loading.value = false
+11
View File
@@ -851,6 +851,17 @@ function getCuratedAppList() {
dockerImage: 'docker.io/fedimint/fedimintd:v0.10.0',
manifestUrl: null,
repoUrl: 'https://github.com/fedimint/fedimint'
},
{
id: 'indeedhub',
title: 'Indeehub',
version: '0.1.0',
description: 'Bitcoin documentary streaming platform. Stream God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology.',
icon: 'https://indeehub.studio/favicon.ico',
author: 'Indeehub Team',
dockerImage: 'localhost/indeedhub:latest',
manifestUrl: null,
repoUrl: 'https://github.com/indeedhub/indeedhub'
}
]
}
+2 -2
View File
@@ -139,11 +139,11 @@ async function downloadBackup() {
}
function proceed() {
router.push('/onboarding/verify')
router.push('/onboarding/verify').catch(() => {})
}
function skipForNow() {
router.push('/onboarding/verify')
router.push('/onboarding/verify').catch(() => {})
}
</script>
+17 -14
View File
@@ -101,30 +101,33 @@ async function generateDid() {
isGenerating.value = true
errorMessage.value = ''
try {
const { did, pubkey } = await rpcClient.getNodeDid()
generatedDid.value = did
localStorage.setItem('neode_did', did)
localStorage.setItem('neode_did_state', JSON.stringify({ did, kid: pubkey }))
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : 'Failed to load node identity'
// Fallback: show placeholder if backend unavailable (e.g. mock mode)
if (!generatedDid.value) {
generatedDid.value = 'did:key:z6Mk... (connect to server)'
for (let attempt = 0; attempt < 3; attempt++) {
try {
const { did, pubkey } = await rpcClient.getNodeDid()
generatedDid.value = did
localStorage.setItem('neode_did', did)
localStorage.setItem('neode_did_state', JSON.stringify({ did, kid: pubkey }))
break
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : 'Server unavailable. Retrying...'
if (attempt < 2) {
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
} else {
generatedDid.value = 'did:key:z6Mk... (connect to server)'
}
}
} finally {
isGenerating.value = false
}
isGenerating.value = false
}
function proceed() {
if (generatedDid.value && !generatedDid.value.includes('...')) {
router.push('/onboarding/backup')
router.push('/onboarding/backup').catch(() => {})
}
}
function skipForNow() {
router.push('/onboarding/backup')
router.push('/onboarding/backup').catch(() => {})
}
</script>
+1 -1
View File
@@ -60,7 +60,7 @@ import { useRouter } from 'vue-router'
const router = useRouter()
function goToLogin() {
router.push('/login')
router.push('/login').catch(() => {})
}
</script>
+1 -1
View File
@@ -38,7 +38,7 @@ import { useRouter } from 'vue-router'
const router = useRouter()
function goToOptions() {
router.push('/onboarding/path')
router.push('/onboarding/path').catch(() => {})
}
</script>
+6 -2
View File
@@ -99,8 +99,12 @@ function selectOption(option: string) {
async function proceed() {
if (selected.value) {
await completeOnboarding()
router.push('/login')
try {
await completeOnboarding()
} catch {
/* localStorage fallback ensures onboarding is marked complete */
}
router.push('/login').catch(() => {})
}
}
</script>
+2 -5
View File
@@ -139,15 +139,12 @@ function toggleOption(option: string) {
}
function proceed() {
// Save selected options to localStorage
localStorage.setItem('neode_selected_paths', JSON.stringify(selectedOptions.value))
// Don't mark onboarding complete yet - continue to DID creation
router.push('/onboarding/did')
router.push('/onboarding/did').catch(() => {})
}
function skipForNow() {
// Skip to DID creation
router.push('/onboarding/did')
router.push('/onboarding/did').catch(() => {})
}
</script>
+12 -4
View File
@@ -113,13 +113,21 @@ function generateMockSignature(): string {
}
async function proceed() {
await completeOnboarding()
router.push('/login')
try {
await completeOnboarding()
} catch {
/* localStorage fallback ensures we can proceed */
}
router.push('/login').catch(() => {})
}
async function skipForNow() {
await completeOnboarding()
router.push('/login')
try {
await completeOnboarding()
} catch {
/* localStorage fallback ensures we can proceed */
}
router.push('/login').catch(() => {})
}
</script>
+5 -3
View File
@@ -322,16 +322,18 @@ onMounted(() => {
isTransitioning.value = false
isGlitching.value = false
document.body.classList.add('video-background-active')
if (sessionStorage.getItem('archipelago_from_splash') !== '1') {
startSynthwave()
}
const unlock = () => {
resumeAudioContext()
if (sessionStorage.getItem('archipelago_from_splash') !== '1') {
startSynthwave()
}
document.removeEventListener('click', unlock)
document.removeEventListener('touchstart', unlock)
document.removeEventListener('keydown', unlock)
}
document.addEventListener('click', unlock, { once: true })
document.addEventListener('touchstart', unlock, { once: true })
document.addEventListener('keydown', unlock, { once: true })
}
})
</script>
+12 -3
View File
@@ -20,11 +20,20 @@ const router = useRouter()
onMounted(async () => {
const devMode = import.meta.env.VITE_DEV_MODE
if (devMode === 'setup' || devMode === 'existing') {
router.replace('/login')
router.replace('/login').catch(() => {})
return
}
const seenOnboarding = await isOnboardingComplete()
router.replace(seenOnboarding ? '/login' : '/onboarding/intro')
let seenOnboarding = false
try {
const result = await Promise.race([
isOnboardingComplete(),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 5000)),
])
seenOnboarding = result
} catch {
seenOnboarding = localStorage.getItem('neode_onboarding_complete') === '1'
}
router.replace(seenOnboarding ? '/login' : '/onboarding/intro').catch(() => {})
})
</script>