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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
|
||||
function goToLogin() {
|
||||
router.push('/login')
|
||||
router.push('/login').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
|
||||
function goToOptions() {
|
||||
router.push('/onboarding/path')
|
||||
router.push('/onboarding/path').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user