mid coding commit
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<!-- Splash Screen (only on first visit) -->
|
||||
<SplashScreen v-if="showSplash" @complete="handleSplashComplete" />
|
||||
|
||||
<!-- Main App Content - only show after splash and routing is complete -->
|
||||
<RouterView v-if="!showSplash && isReady" />
|
||||
|
||||
<!-- PWA Update Prompt -->
|
||||
<PWAUpdatePrompt />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import SplashScreen from './components/SplashScreen.vue'
|
||||
import PWAUpdatePrompt from './components/PWAUpdatePrompt.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const showSplash = ref(true)
|
||||
const isReady = ref(false)
|
||||
|
||||
/**
|
||||
* Determine if splash screen should be shown
|
||||
* Splash is skipped if:
|
||||
* - User has already seen the intro
|
||||
* - User is on a direct route (refresh/bookmark)
|
||||
*/
|
||||
onMounted(() => {
|
||||
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||
const isDirectRoute = route.path !== '/'
|
||||
|
||||
if (seenIntro || isDirectRoute) {
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
isReady.value = true
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Handle splash screen completion
|
||||
* Routes user directly to appropriate screen based on onboarding status
|
||||
*/
|
||||
function handleSplashComplete() {
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
|
||||
// Determine destination based on onboarding status
|
||||
const seenOnboarding = localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
const destination = seenOnboarding ? '/login' : '/onboarding/intro'
|
||||
|
||||
// Route immediately for seamless video transition (no delay needed)
|
||||
router.push(destination).then(() => {
|
||||
// Mark as ready after navigation completes
|
||||
isReady.value = true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Global styles are in style.css */
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
// RPC Client for connecting to Archipelago backend
|
||||
|
||||
export interface RPCOptions {
|
||||
method: string
|
||||
params?: any
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export interface RPCResponse<T> {
|
||||
result?: T
|
||||
error?: {
|
||||
code: number
|
||||
message: string
|
||||
data?: any
|
||||
}
|
||||
}
|
||||
|
||||
class RPCClient {
|
||||
private baseUrl: string
|
||||
|
||||
constructor(baseUrl: string = '/rpc/v1') {
|
||||
this.baseUrl = baseUrl
|
||||
}
|
||||
|
||||
async call<T>(options: RPCOptions): Promise<T> {
|
||||
const { method, params = {}, timeout = 30000 } = options
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
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')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
throw new Error('Unknown error occurred')
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
async login(password: string): Promise<void> {
|
||||
return this.call({
|
||||
method: 'auth.login',
|
||||
params: {
|
||||
password,
|
||||
metadata: {
|
||||
// Add any metadata needed
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
return this.call({
|
||||
method: 'auth.logout',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async echo(message: string): Promise<string> {
|
||||
return this.call({
|
||||
method: 'server.echo',
|
||||
params: { message },
|
||||
})
|
||||
}
|
||||
|
||||
async getSystemTime(): Promise<{ now: string; uptime: number }> {
|
||||
return this.call({
|
||||
method: 'server.time',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async getMetrics(): Promise<any> {
|
||||
return this.call({
|
||||
method: 'server.metrics',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async updateServer(marketplaceUrl: string): Promise<'updating' | 'no-updates'> {
|
||||
return this.call({
|
||||
method: 'server.update',
|
||||
params: { 'marketplace-url': marketplaceUrl },
|
||||
})
|
||||
}
|
||||
|
||||
async restartServer(): Promise<void> {
|
||||
return this.call({
|
||||
method: 'server.restart',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async shutdownServer(): Promise<void> {
|
||||
return this.call({
|
||||
method: 'server.shutdown',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async installPackage(id: string, marketplaceUrl: string, version: string): Promise<string> {
|
||||
return this.call({
|
||||
method: 'package.install',
|
||||
params: { id, 'marketplace-url': marketplaceUrl, version },
|
||||
})
|
||||
}
|
||||
|
||||
async uninstallPackage(id: string): Promise<void> {
|
||||
return this.call({
|
||||
method: 'package.uninstall',
|
||||
params: { id },
|
||||
})
|
||||
}
|
||||
|
||||
async startPackage(id: string): Promise<void> {
|
||||
return this.call({
|
||||
method: 'package.start',
|
||||
params: { id },
|
||||
})
|
||||
}
|
||||
|
||||
async stopPackage(id: string): Promise<void> {
|
||||
return this.call({
|
||||
method: 'package.stop',
|
||||
params: { id },
|
||||
})
|
||||
}
|
||||
|
||||
async restartPackage(id: string): Promise<void> {
|
||||
return this.call({
|
||||
method: 'package.restart',
|
||||
params: { id },
|
||||
})
|
||||
}
|
||||
|
||||
async getMarketplace(url: string): Promise<any> {
|
||||
return this.call({
|
||||
method: 'marketplace.get',
|
||||
params: { url },
|
||||
})
|
||||
}
|
||||
|
||||
async sideloadPackage(manifest: any, icon: string): Promise<string> {
|
||||
return this.call({
|
||||
method: 'package.sideload',
|
||||
params: { manifest, icon },
|
||||
timeout: 120000, // 2 minutes for upload
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const rpcClient = new RPCClient()
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
// WebSocket handler for real-time updates
|
||||
|
||||
import type { Update, PatchOperation } from '../types/api'
|
||||
import { applyPatch } from 'fast-json-patch'
|
||||
|
||||
type WebSocketCallback = (update: Update) => void
|
||||
|
||||
export class WebSocketClient {
|
||||
private ws: WebSocket | null = null
|
||||
private callbacks: Set<WebSocketCallback> = new Set()
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 10
|
||||
private reconnectDelay = 1000
|
||||
private shouldReconnect = true
|
||||
private url: string
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private isConnecting = false
|
||||
|
||||
constructor(url: string = '/ws/db') {
|
||||
this.url = url
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// If already connected, resolve immediately
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
console.log('[WebSocket] Already connected, skipping')
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// If connecting, wait for it
|
||||
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
||||
console.log('[WebSocket] Already connecting, waiting...')
|
||||
const checkInterval = setInterval(() => {
|
||||
if (this.ws) {
|
||||
if (this.ws.readyState === WebSocket.OPEN) {
|
||||
clearInterval(checkInterval)
|
||||
resolve()
|
||||
} else if (this.ws.readyState === WebSocket.CLOSED) {
|
||||
clearInterval(checkInterval)
|
||||
// Connection failed, will be handled by onclose
|
||||
reject(new Error('Connection closed during connect'))
|
||||
}
|
||||
} else {
|
||||
clearInterval(checkInterval)
|
||||
reject(new Error('WebSocket was cleared'))
|
||||
}
|
||||
}, 100)
|
||||
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval)
|
||||
if (this.ws && this.ws.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error('Connection timeout'))
|
||||
}
|
||||
}, 5000)
|
||||
return
|
||||
}
|
||||
|
||||
// Close existing connection if any (but don't prevent reconnection)
|
||||
if (this.ws) {
|
||||
const oldWs = this.ws
|
||||
this.ws = null
|
||||
// Temporarily disable reconnection to prevent loop
|
||||
const wasReconnecting = this.shouldReconnect
|
||||
this.shouldReconnect = false
|
||||
oldWs.onclose = null // Remove close handler
|
||||
oldWs.close()
|
||||
// Restore reconnection flag after a moment
|
||||
setTimeout(() => {
|
||||
this.shouldReconnect = wasReconnecting
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// Reset shouldReconnect flag when explicitly connecting
|
||||
this.shouldReconnect = true
|
||||
// Reset reconnect attempts only if we're explicitly connecting (not auto-reconnecting)
|
||||
// This allows reconnection attempts to continue
|
||||
|
||||
// In development, Vite proxies /ws to the backend
|
||||
// In production, use the same host as the page
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = window.location.host
|
||||
const wsUrl = `${protocol}//${host}${this.url}`
|
||||
|
||||
console.log('[WebSocket] Connecting to:', wsUrl)
|
||||
|
||||
this.ws = new WebSocket(wsUrl)
|
||||
|
||||
// Timeout handler in case connection hangs
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
||||
console.warn('WebSocket connection timeout, retrying...')
|
||||
this.ws.close()
|
||||
reject(new Error('Connection timeout'))
|
||||
}
|
||||
}, 3000) // 3 second timeout
|
||||
|
||||
this.ws.onopen = () => {
|
||||
clearTimeout(connectionTimeout)
|
||||
this.isConnecting = false
|
||||
this.reconnectAttempts = 0
|
||||
console.log('[WebSocket] Connected successfully')
|
||||
resolve()
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
clearTimeout(connectionTimeout)
|
||||
this.isConnecting = false
|
||||
console.error('[WebSocket] Connection error:', error)
|
||||
// Don't reject immediately - let onclose handle reconnection
|
||||
// This prevents errors from blocking reconnection
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const update: Update = JSON.parse(event.data)
|
||||
this.callbacks.forEach((callback) => callback(update))
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
clearTimeout(connectionTimeout)
|
||||
this.isConnecting = false
|
||||
console.log('[WebSocket] Closed', { code: event.code, reason: event.reason, wasClean: event.wasClean })
|
||||
|
||||
// Clear the WebSocket reference
|
||||
this.ws = null
|
||||
|
||||
// Don't reconnect if we explicitly disconnected
|
||||
if (!this.shouldReconnect) {
|
||||
console.log('[WebSocket] Reconnection disabled')
|
||||
return
|
||||
}
|
||||
|
||||
// Always try to reconnect unless we've exceeded max attempts
|
||||
// Code 1001 (Going Away) happens on HMR reloads - reconnect IMMEDIATELY
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
// Immediate reconnection for HMR (code 1001) - no delay
|
||||
const isHMR = event.code === 1001 || event.code === 1006
|
||||
const delay = isHMR ? 0 : (this.reconnectAttempts === 0 ? 100 : Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 5000))
|
||||
console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}, code: ${event.code}, HMR: ${isHMR})`)
|
||||
|
||||
// Clear any existing reconnect timer
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
|
||||
const doReconnect = () => {
|
||||
// Check again if we should reconnect (might have been disabled)
|
||||
if (!this.shouldReconnect) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't increment attempts for HMR disconnects - they're expected
|
||||
if (!isHMR) {
|
||||
this.reconnectAttempts++
|
||||
}
|
||||
|
||||
console.log('[WebSocket] Attempting reconnection...')
|
||||
this.connect().catch((err) => {
|
||||
console.error('[WebSocket] Reconnection failed:', err)
|
||||
// onclose will be called again and will retry
|
||||
})
|
||||
}
|
||||
|
||||
if (delay === 0) {
|
||||
// Immediate reconnection for HMR
|
||||
doReconnect()
|
||||
} else {
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
doReconnect()
|
||||
}, delay)
|
||||
}
|
||||
} else {
|
||||
console.warn('[WebSocket] Max reconnection attempts reached')
|
||||
this.shouldReconnect = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
subscribe(callback: WebSocketCallback): () => void {
|
||||
this.callbacks.add(callback)
|
||||
return () => {
|
||||
this.callbacks.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false
|
||||
this.reconnectAttempts = 0
|
||||
this.isConnecting = false
|
||||
|
||||
// Clear reconnect timer
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
// Remove handlers to prevent reconnection
|
||||
this.ws.onclose = null
|
||||
this.ws.onerror = null
|
||||
try {
|
||||
this.ws.close()
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
this.ws = null
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.disconnect()
|
||||
this.callbacks.clear()
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton that persists across HMR
|
||||
let wsClientInstance: WebSocketClient | null = null
|
||||
|
||||
function getWebSocketClient(): WebSocketClient {
|
||||
if (typeof window === 'undefined') {
|
||||
// SSR - create new instance
|
||||
return new WebSocketClient()
|
||||
}
|
||||
|
||||
// Check if we have a persisted instance from HMR
|
||||
if ((window as any).__archipelago_ws_client && (window as any).__archipelago_ws_client.ws) {
|
||||
const existing = (window as any).__archipelago_ws_client
|
||||
// Check if the WebSocket is still valid
|
||||
if (existing.ws && existing.ws.readyState === WebSocket.OPEN) {
|
||||
console.log('[WebSocket] Using existing connected client from HMR')
|
||||
return existing
|
||||
}
|
||||
}
|
||||
|
||||
// Create new instance
|
||||
if (!wsClientInstance) {
|
||||
wsClientInstance = new WebSocketClient()
|
||||
(window as any).__archipelago_ws_client = wsClientInstance
|
||||
console.log('[WebSocket] Created new client instance')
|
||||
}
|
||||
|
||||
return wsClientInstance
|
||||
}
|
||||
|
||||
export const wsClient = getWebSocketClient()
|
||||
|
||||
// Helper to apply patches to data
|
||||
export function applyDataPatch<T>(data: T, patch: PatchOperation[]): T {
|
||||
// Validate patch is an array before applying
|
||||
if (!Array.isArray(patch) || patch.length === 0) {
|
||||
console.warn('Invalid or empty patch received, returning original data')
|
||||
return data
|
||||
}
|
||||
|
||||
try {
|
||||
const result = applyPatch(data, patch as any, false, false)
|
||||
return result.newDocument as T
|
||||
} catch (error) {
|
||||
console.error('Failed to apply patch:', error, 'Patch:', patch)
|
||||
return data // Return original data on error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showUpdatePrompt"
|
||||
class="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 z-[9999]"
|
||||
>
|
||||
<div class="glass-card p-4 flex items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<p class="text-white/90 text-sm font-medium mb-1">Update Available</p>
|
||||
<p class="text-white/70 text-xs">A new version is available. Click to update.</p>
|
||||
</div>
|
||||
<button
|
||||
@click="handleUpdate"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm font-medium transition-all hover:bg-black/70 hover:border-white/30"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
<button
|
||||
@click="dismissUpdate"
|
||||
class="text-white/50 hover:text-white/80 transition-colors p-2"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const showUpdatePrompt = ref(false)
|
||||
let updateCallback: (() => Promise<void>) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
// Listen for service worker updates
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
// Service worker updated, reload the page
|
||||
window.location.reload()
|
||||
})
|
||||
|
||||
// Check for updates periodically
|
||||
const checkForUpdates = async () => {
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
if (registration) {
|
||||
await registration.update()
|
||||
}
|
||||
}
|
||||
|
||||
// Check for updates every 5 minutes
|
||||
setInterval(checkForUpdates, 5 * 60 * 1000)
|
||||
|
||||
// Listen for updatefound event
|
||||
navigator.serviceWorker.getRegistration().then((registration) => {
|
||||
if (registration) {
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// New service worker installed, show update prompt
|
||||
showUpdatePrompt.value = true
|
||||
updateCallback = async () => {
|
||||
if (newWorker.state === 'installed' && registration.waiting) {
|
||||
// Skip waiting and activate the new service worker
|
||||
registration.waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function dismissUpdate() {
|
||||
showUpdatePrompt.value = false
|
||||
}
|
||||
|
||||
async function handleUpdate() {
|
||||
if (updateCallback) {
|
||||
await updateCallback()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
<template>
|
||||
<Transition name="splash-fade">
|
||||
<div v-if="showSplash" class="fixed inset-0 z-[2000] flex items-center justify-center bg-black" style="will-change: opacity, transform;">
|
||||
<!-- Video background - shown during Welcome Noderunner and Logo (seamless, no zoom) -->
|
||||
<video
|
||||
v-if="showWelcome || showLogo"
|
||||
ref="videoElement"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
:style="{ opacity: backgroundOpacity, transform: 'scale(1)', transition: 'opacity 1.2s ease-out' }"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="auto"
|
||||
poster="/assets/img/bg-4.jpg"
|
||||
>
|
||||
<source src="/assets/video/video-intro.mp4?v=7" type="video/mp4">
|
||||
<!-- Fallback to image if video fails -->
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{
|
||||
backgroundImage: 'url(/assets/img/bg-4.jpg)',
|
||||
backgroundSize: 'auto 100vh',
|
||||
backgroundPosition: 'center top',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}"
|
||||
/>
|
||||
</video>
|
||||
|
||||
<!-- Static image background - shown during alien intro -->
|
||||
<div
|
||||
v-else
|
||||
class="absolute inset-0"
|
||||
:style="{
|
||||
backgroundImage: 'url(/assets/img/bg-4.jpg)',
|
||||
backgroundSize: 'auto 100vh',
|
||||
backgroundPosition: 'center top',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
opacity: backgroundOpacity,
|
||||
transform: 'scale(1)',
|
||||
transition: 'opacity 1.2s ease-out',
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- Alien Intro -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="!alienIntroComplete"
|
||||
class="absolute inset-0 z-10 flex items-center justify-center transition-opacity duration-800"
|
||||
:class="{ 'opacity-0': fadeAlienIntro }"
|
||||
>
|
||||
<div class="font-mono text-white px-4 sm:px-5 max-w-[95vw] sm:max-w-[90vw] md:max-w-[1200px] text-base sm:text-lg md:text-[24px] leading-relaxed break-words">
|
||||
<div v-if="showLine1" class="flex items-start mb-4 sm:mb-6 opacity-0" :class="{ 'opacity-100': showLine1 }">
|
||||
<span class="text-[#00ffff] mr-3 sm:mr-6 flex-shrink-0">></span>
|
||||
<span class="text-white break-words" :class="{ 'typing-text': typingLine1 }">
|
||||
In the future there will be 3 types of humans
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="showLine2" class="flex items-start mb-4 sm:mb-6 opacity-0" :class="{ 'opacity-100': showLine2 }">
|
||||
<span class="text-[#00ffff] mr-3 sm:mr-6 flex-shrink-0">></span>
|
||||
<span class="text-white break-words" :class="{ 'typing-text': typingLine2 }">
|
||||
Government Employees
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="showLine3" class="flex items-start mb-4 sm:mb-6 opacity-0" :class="{ 'opacity-100': showLine3 }">
|
||||
<span class="text-[#00ffff] mr-3 sm:mr-6 flex-shrink-0">></span>
|
||||
<span class="text-white break-words" :class="{ 'typing-text': typingLine3 }">
|
||||
Corporate Employees
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="showLine4" class="flex items-start mb-8 sm:mb-12 opacity-0" :class="{ 'opacity-100': showLine4 }">
|
||||
<span class="text-[#00ffff] mr-3 sm:mr-6 flex-shrink-0">></span>
|
||||
<span class="text-white break-words" :class="{ 'typing-text': typingLine4 }">
|
||||
And Noderunners...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Welcome Message -->
|
||||
<Transition name="welcome-fade">
|
||||
<div
|
||||
v-if="showWelcome"
|
||||
class="absolute inset-0 z-[15] flex items-center justify-center font-mono text-3xl sm:text-4xl md:text-5xl px-4"
|
||||
:class="{ 'welcome-fade-out': fadeWelcome }"
|
||||
>
|
||||
<div class="typing-container">
|
||||
<span class="text-white" :class="{ 'typing-text': typingWelcome }">
|
||||
Welcome Noderunner
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Logo - Archipelago logo for splash -->
|
||||
<Transition name="logo-zoom">
|
||||
<div v-if="showLogo" class="relative z-20 logo-container">
|
||||
<img
|
||||
src="/assets/img/logo-archipelago.svg"
|
||||
alt="Archipelago"
|
||||
class="w-[min(80vw,900px)] max-w-[90vw] h-auto filter drop-shadow-[0_6px_24px_rgba(0,0,0,0.35)] m-5 object-contain logo-zoom-bounce"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Skip Button -->
|
||||
<button
|
||||
v-if="!alienIntroComplete"
|
||||
@click="skipIntro"
|
||||
class="absolute bottom-8 right-8 z-20 bg-black/60 border border-white/30 text-white/70 font-mono text-xs px-4 py-2 rounded backdrop-blur-[10px] hover:bg-black/80 hover:text-white/90 hover:border-white/50 hover:-translate-y-0.5 active:translate-y-0 transition-all duration-300"
|
||||
>
|
||||
Skip Intro
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: []
|
||||
}>()
|
||||
|
||||
const showSplash = ref(true)
|
||||
const backgroundOpacity = ref(0)
|
||||
const alienIntroComplete = ref(false)
|
||||
const fadeAlienIntro = ref(false)
|
||||
const showWelcome = ref(false)
|
||||
const fadeWelcome = ref(false)
|
||||
const typingWelcome = ref(false)
|
||||
const showLogo = ref(false)
|
||||
const showLine1 = ref(false)
|
||||
const showLine2 = ref(false)
|
||||
const showLine3 = ref(false)
|
||||
const showLine4 = ref(false)
|
||||
const typingLine1 = ref(false)
|
||||
const typingLine2 = ref(false)
|
||||
const typingLine3 = ref(false)
|
||||
const typingLine4 = ref(false)
|
||||
const videoElement = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
// Ensure video plays continuously from Welcome Noderunner through logo
|
||||
watch([showWelcome, showLogo], ([welcome, logo]) => {
|
||||
if ((welcome || logo) && videoElement.value) {
|
||||
// Ensure video is playing and doesn't pause
|
||||
if (videoElement.value.paused) {
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video autoplay failed:', err)
|
||||
})
|
||||
}
|
||||
// Keep video playing - prevent any pauses
|
||||
videoElement.value.addEventListener('pause', (e) => {
|
||||
if (welcome || logo) {
|
||||
e.preventDefault()
|
||||
videoElement.value?.play()
|
||||
}
|
||||
}, { once: false })
|
||||
}
|
||||
})
|
||||
|
||||
// Start video as soon as welcome appears
|
||||
watch(showWelcome, (isShowing) => {
|
||||
if (isShowing && videoElement.value) {
|
||||
// Start video immediately when welcome appears
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video autoplay failed on welcome:', err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Store video currentTime continuously and before unmounting for seamless transition
|
||||
watch(showSplash, (isShowing) => {
|
||||
if (!isShowing && videoElement.value) {
|
||||
// Store current video time for seamless transition
|
||||
const currentTime = videoElement.value.currentTime
|
||||
const wasPlaying = !videoElement.value.paused
|
||||
sessionStorage.setItem('video_intro_currentTime', currentTime.toString())
|
||||
sessionStorage.setItem('video_intro_wasPlaying', wasPlaying.toString())
|
||||
// Store video playback rate to maintain smooth playback
|
||||
sessionStorage.setItem('video_intro_playbackRate', videoElement.value.playbackRate.toString())
|
||||
}
|
||||
})
|
||||
|
||||
// Continuously update video time while playing (for more accurate restoration)
|
||||
let videoTimeUpdateInterval: number | null = null
|
||||
watch([showWelcome, showLogo], ([welcome, logo]) => {
|
||||
if ((welcome || logo) && videoElement.value) {
|
||||
// Update stored time every 50ms for better accuracy and smoother transition
|
||||
videoTimeUpdateInterval = window.setInterval(() => {
|
||||
if (videoElement.value && !videoElement.value.paused) {
|
||||
sessionStorage.setItem('video_intro_currentTime', videoElement.value.currentTime.toString())
|
||||
sessionStorage.setItem('video_intro_wasPlaying', 'true')
|
||||
sessionStorage.setItem('video_intro_playbackRate', videoElement.value.playbackRate.toString())
|
||||
}
|
||||
}, 50) // More frequent updates for smoother transition
|
||||
} else {
|
||||
if (videoTimeUpdateInterval) {
|
||||
clearInterval(videoTimeUpdateInterval)
|
||||
videoTimeUpdateInterval = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Check if user has seen intro
|
||||
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||
|
||||
function skipIntro() {
|
||||
// Jump to "Welcome Noderunner" part
|
||||
alienIntroComplete.value = true
|
||||
fadeAlienIntro.value = true
|
||||
showWelcome.value = true
|
||||
typingWelcome.value = true
|
||||
|
||||
// Stop alien intro typing animations
|
||||
typingLine1.value = false
|
||||
typingLine2.value = false
|
||||
typingLine3.value = false
|
||||
typingLine4.value = false
|
||||
|
||||
// Start background fade in at 0.3 opacity when welcome appears
|
||||
setTimeout(() => {
|
||||
backgroundOpacity.value = 0.3
|
||||
}, 0)
|
||||
|
||||
// Continue with welcome fade out after typing (2s) + cursor continues (1.5s) + 3 blinks (1.35s)
|
||||
setTimeout(() => {
|
||||
fadeWelcome.value = true
|
||||
typingWelcome.value = false
|
||||
}, 4850)
|
||||
|
||||
// Show logo - no zoom, just fade
|
||||
setTimeout(() => {
|
||||
showLogo.value = true
|
||||
// Keep background at 0.3 opacity during logo display
|
||||
}, 5500)
|
||||
|
||||
// Hide welcome after logo starts appearing
|
||||
setTimeout(() => {
|
||||
showWelcome.value = false
|
||||
}, 6000)
|
||||
|
||||
// Fade background to full opacity just before completing (for smooth transition to modal)
|
||||
setTimeout(() => {
|
||||
backgroundOpacity.value = 1
|
||||
}, 9000)
|
||||
|
||||
// Complete splash with smooth transition - wait for zoom to complete
|
||||
setTimeout(() => {
|
||||
// Add a small delay to ensure smooth transition
|
||||
setTimeout(() => {
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
localStorage.setItem('neode_intro_seen', '1')
|
||||
emit('complete')
|
||||
}, 500)
|
||||
}, 9500)
|
||||
}
|
||||
|
||||
function startAlienIntro() {
|
||||
// Line 1 - types and blinks
|
||||
setTimeout(() => {
|
||||
showLine1.value = true
|
||||
typingLine1.value = true
|
||||
}, 500)
|
||||
|
||||
// Line 2 - wait for line 1 typing (4s) + blinking (1.5s)
|
||||
setTimeout(() => {
|
||||
typingLine1.value = false
|
||||
showLine2.value = true
|
||||
typingLine2.value = true
|
||||
}, 6000)
|
||||
|
||||
// Line 3 - wait for line 2 typing (4s) + blinking (1.5s)
|
||||
setTimeout(() => {
|
||||
typingLine2.value = false
|
||||
showLine3.value = true
|
||||
typingLine3.value = true
|
||||
}, 11500)
|
||||
|
||||
// Line 4 - wait for line 3 typing (4s) + blinking (1.5s)
|
||||
setTimeout(() => {
|
||||
typingLine3.value = false
|
||||
showLine4.value = true
|
||||
typingLine4.value = true
|
||||
}, 17000)
|
||||
|
||||
// Fade out alien intro - wait for line 4 typing (4s) + blinking (1.5s)
|
||||
setTimeout(() => {
|
||||
typingLine4.value = false
|
||||
fadeAlienIntro.value = true
|
||||
}, 22500)
|
||||
|
||||
// Show welcome and start video immediately
|
||||
setTimeout(() => {
|
||||
alienIntroComplete.value = true
|
||||
showWelcome.value = true
|
||||
typingWelcome.value = true
|
||||
// Start video immediately when welcome appears
|
||||
if (videoElement.value) {
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video autoplay failed on welcome:', err)
|
||||
})
|
||||
}
|
||||
}, 23300)
|
||||
|
||||
// Start background fade in at 0.3 opacity when welcome appears
|
||||
setTimeout(() => {
|
||||
backgroundOpacity.value = 0.3
|
||||
}, 23300)
|
||||
|
||||
// Fade out welcome - typing (2s) + cursor continues (1.5s) + 3 blinks (1.35s) = 4.85s
|
||||
setTimeout(() => {
|
||||
fadeWelcome.value = true
|
||||
typingWelcome.value = false
|
||||
}, 28150)
|
||||
|
||||
// Show logo - background stays at 0.3 opacity
|
||||
setTimeout(() => {
|
||||
showLogo.value = true
|
||||
}, 29000)
|
||||
|
||||
// Hide welcome after logo starts appearing
|
||||
setTimeout(() => {
|
||||
showWelcome.value = false
|
||||
}, 30500)
|
||||
|
||||
// Fade background to full opacity just before completing (for smooth transition to modal)
|
||||
setTimeout(() => {
|
||||
backgroundOpacity.value = 1
|
||||
}, 33000)
|
||||
|
||||
// Complete splash with smooth transition
|
||||
setTimeout(() => {
|
||||
// Store final video time right before unmounting
|
||||
if (videoElement.value && !videoElement.value.paused) {
|
||||
sessionStorage.setItem('video_intro_currentTime', videoElement.value.currentTime.toString())
|
||||
sessionStorage.setItem('video_intro_wasPlaying', 'true')
|
||||
}
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
localStorage.setItem('neode_intro_seen', '1')
|
||||
emit('complete')
|
||||
}, 34500)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (seenIntro) {
|
||||
// Skip intro if already seen
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
emit('complete')
|
||||
} else {
|
||||
// Play intro
|
||||
startAlienIntro()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.splash-fade-enter-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.splash-fade-leave-active {
|
||||
transition: opacity 1s ease-out, transform 1s ease-out;
|
||||
}
|
||||
|
||||
.splash-fade-enter-from,
|
||||
.splash-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.splash-fade-leave-to {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Welcome message fade out */
|
||||
.welcome-fade-enter-active {
|
||||
transition: opacity 0.8s ease-out;
|
||||
}
|
||||
|
||||
.welcome-fade-leave-active {
|
||||
transition: opacity 0.6s ease-in;
|
||||
}
|
||||
|
||||
.welcome-fade-enter-from,
|
||||
.welcome-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.welcome-fade-out {
|
||||
opacity: 0;
|
||||
transition: opacity 0.6s ease-in;
|
||||
}
|
||||
|
||||
/* Logo zoom bounce animation - smooth and buttery */
|
||||
.logo-zoom-enter-active {
|
||||
transition: all 1s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.logo-zoom-leave-active {
|
||||
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
|
||||
}
|
||||
|
||||
.logo-zoom-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
|
||||
.logo-zoom-enter-to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.logo-zoom-leave-from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.logo-zoom-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.logo-zoom-bounce {
|
||||
animation: logoZoomBounce 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
}
|
||||
|
||||
@keyframes logoZoomBounce {
|
||||
0% {
|
||||
transform: scale(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
opacity: 0.9;
|
||||
}
|
||||
75% {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.95;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Container to keep the typing text centered */
|
||||
.typing-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Ensure text wraps smoothly on mobile */
|
||||
.font-mono {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
/* Smooth line transitions for mobile */
|
||||
@media (max-width: 640px) {
|
||||
.font-mono span {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Background zoom transition - matches OnboardingWrapper style */
|
||||
.bg-zoom-transition {
|
||||
transition: transform 1.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 1.2s ease-out;
|
||||
transform: scale(1);
|
||||
transform-origin: center center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.bg-zoom-transition.bg-zoom-in {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Simple in-memory store for the current marketplace app
|
||||
const currentMarketplaceApp = ref<any>(null)
|
||||
|
||||
export function useMarketplaceApp() {
|
||||
function setCurrentApp(app: any) {
|
||||
// Create a clean, serializable copy
|
||||
currentMarketplaceApp.value = {
|
||||
id: app.id,
|
||||
title: app.title,
|
||||
version: app.version,
|
||||
icon: app.icon,
|
||||
category: app.category,
|
||||
description: app.description,
|
||||
author: app.author,
|
||||
source: app.source,
|
||||
manifestUrl: app.manifestUrl || app.s9pkUrl || app.url,
|
||||
url: app.url || app.s9pkUrl || app.manifestUrl,
|
||||
repoUrl: app.repoUrl,
|
||||
s9pkUrl: app.s9pkUrl
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentApp() {
|
||||
return currentMarketplaceApp.value
|
||||
}
|
||||
|
||||
function clearCurrentApp() {
|
||||
currentMarketplaceApp.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
setCurrentApp,
|
||||
getCurrentApp,
|
||||
clearCurrentApp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
/**
|
||||
* Composable for mobile back button positioning
|
||||
* Ensures back buttons are always 16px above the mobile tab bar
|
||||
* Uses ResizeObserver to reactively update when tab bar height changes
|
||||
*/
|
||||
export function useMobileBackButton() {
|
||||
const tabBarHeight = ref<number>(72) // Default fallback height
|
||||
|
||||
// Computed property for bottom position - always 16px above tab bar
|
||||
const bottomPosition = computed(() => {
|
||||
return `${tabBarHeight.value + 16}px`
|
||||
})
|
||||
|
||||
// Computed property for Tailwind class (for use in class bindings)
|
||||
const bottomClass = computed(() => {
|
||||
// Use Tailwind arbitrary value with the computed height
|
||||
return `bottom-[${tabBarHeight.value + 16}px]`
|
||||
})
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let mutationObserver: MutationObserver | null = null
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function updateTabBarHeight() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// Try to find the mobile tab bar element
|
||||
const tabBar = document.querySelector('[data-mobile-tab-bar]') as HTMLElement
|
||||
if (tabBar) {
|
||||
tabBarHeight.value = tabBar.offsetHeight
|
||||
} else {
|
||||
// Fallback: read from CSS variable if available
|
||||
const cssVar = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--mobile-tab-bar-height')
|
||||
.trim()
|
||||
|
||||
if (cssVar) {
|
||||
const height = parseFloat(cssVar)
|
||||
if (!isNaN(height)) {
|
||||
tabBarHeight.value = height
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Initial update
|
||||
updateTabBarHeight()
|
||||
|
||||
// Watch for CSS variable changes
|
||||
mutationObserver = new MutationObserver(() => {
|
||||
updateTabBarHeight()
|
||||
})
|
||||
|
||||
mutationObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
})
|
||||
|
||||
// Watch for tab bar size changes
|
||||
const tabBar = document.querySelector('[data-mobile-tab-bar]') as HTMLElement
|
||||
if (tabBar && 'ResizeObserver' in window) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
updateTabBarHeight()
|
||||
})
|
||||
resizeObserver.observe(tabBar)
|
||||
}
|
||||
|
||||
// Also listen to window resize as fallback
|
||||
window.addEventListener('resize', updateTabBarHeight)
|
||||
|
||||
// Periodic check to ensure we're always in sync (safety net)
|
||||
intervalId = setInterval(() => {
|
||||
updateTabBarHeight()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (mutationObserver) {
|
||||
mutationObserver.disconnect()
|
||||
}
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
window.removeEventListener('resize', updateTabBarHeight)
|
||||
})
|
||||
|
||||
return {
|
||||
bottomPosition,
|
||||
bottomClass,
|
||||
tabBarHeight,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,178 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../views/OnboardingWrapper.vue'),
|
||||
meta: { public: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: () => {
|
||||
// Initial routing logic - determines first screen after splash
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
const seenOnboarding = localStorage.getItem('neode_onboarding_complete') === '1'
|
||||
const isSetup = localStorage.getItem('neode_setup_complete') === '1'
|
||||
|
||||
// Setup mode: go directly to login (original StartOS setup)
|
||||
if (devMode === 'setup') {
|
||||
return isSetup ? '/login' : '/login' // Always login for setup mode
|
||||
}
|
||||
|
||||
// Onboarding mode: go to experimental onboarding flow
|
||||
if (devMode === 'onboarding') {
|
||||
return seenOnboarding ? '/login' : '/onboarding/intro'
|
||||
}
|
||||
|
||||
// Existing user mode: go to login
|
||||
if (devMode === 'existing') {
|
||||
return '/login'
|
||||
}
|
||||
|
||||
// Default: check if user has completed onboarding
|
||||
return seenOnboarding ? '/login' : '/onboarding/intro'
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
name: 'login',
|
||||
component: () => import('../views/Login.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/intro',
|
||||
name: 'onboarding-intro',
|
||||
component: () => import('../views/OnboardingIntro.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/options',
|
||||
name: 'onboarding-options',
|
||||
component: () => import('../views/OnboardingOptions.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/path',
|
||||
name: 'onboarding-path',
|
||||
component: () => import('../views/OnboardingPath.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/did',
|
||||
name: 'onboarding-did',
|
||||
component: () => import('../views/OnboardingDid.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/backup',
|
||||
name: 'onboarding-backup',
|
||||
component: () => import('../views/OnboardingBackup.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/verify',
|
||||
name: 'onboarding-verify',
|
||||
component: () => import('../views/OnboardingVerify.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/done',
|
||||
name: 'onboarding-done',
|
||||
component: () => import('../views/OnboardingDone.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: () => import('../views/Dashboard.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'home',
|
||||
component: () => import('../views/Home.vue'),
|
||||
},
|
||||
{
|
||||
path: 'apps',
|
||||
name: 'apps',
|
||||
component: () => import('../views/Apps.vue'),
|
||||
},
|
||||
{
|
||||
path: 'apps/:id',
|
||||
name: 'app-details',
|
||||
component: () => import('../views/AppDetails.vue'),
|
||||
},
|
||||
{
|
||||
path: 'marketplace',
|
||||
name: 'marketplace',
|
||||
component: () => import('../views/Marketplace.vue'),
|
||||
},
|
||||
{
|
||||
path: 'marketplace/:id',
|
||||
name: 'marketplace-app-detail',
|
||||
component: () => import('../views/MarketplaceAppDetails.vue'),
|
||||
},
|
||||
{
|
||||
path: 'cloud',
|
||||
name: 'cloud',
|
||||
component: () => import('../views/Cloud.vue'),
|
||||
},
|
||||
{
|
||||
path: 'cloud/:folderId',
|
||||
name: 'cloud-folder',
|
||||
component: () => import('../views/CloudFolder.vue'),
|
||||
},
|
||||
{
|
||||
path: 'server',
|
||||
name: 'server',
|
||||
component: () => import('../views/Server.vue'),
|
||||
},
|
||||
{
|
||||
path: 'web5',
|
||||
name: 'web5',
|
||||
component: () => import('../views/Web5.vue'),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('../views/Settings.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
/**
|
||||
* Navigation Guard
|
||||
* Handles authentication and onboarding flow routing
|
||||
*/
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const store = useAppStore()
|
||||
const isPublic = to.meta.public
|
||||
|
||||
// Allow all public routes (login, onboarding) without auth check
|
||||
if (isPublic) {
|
||||
// If already authenticated and trying to access login, redirect to dashboard
|
||||
if (to.path === '/login' && store.isAuthenticated) {
|
||||
next('/dashboard')
|
||||
return
|
||||
}
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// Protected routes require authentication
|
||||
// Check session if not already authenticated
|
||||
if (!store.isAuthenticated) {
|
||||
const hasSession = await store.checkSession()
|
||||
if (hasSession) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// No valid session - redirect to login
|
||||
next('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticated user accessing protected route
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// Main application store using Pinia
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { DataModel } from '../types/api'
|
||||
import { wsClient, applyDataPatch } from '../api/websocket'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
// State
|
||||
const data = ref<DataModel | null>(null)
|
||||
const isAuthenticated = ref(localStorage.getItem('neode-auth') === 'true')
|
||||
const isConnected = ref(false)
|
||||
const isReconnecting = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
let isWsSubscribed = false
|
||||
|
||||
// Computed
|
||||
const serverInfo = computed(() => data.value?.['server-info'])
|
||||
const packages = computed(() => data.value?.['package-data'] || {})
|
||||
const uiData = computed(() => data.value?.ui)
|
||||
const serverName = computed(() => serverInfo.value?.name || 'Archipelago')
|
||||
const isRestarting = computed(() => serverInfo.value?.['status-info']?.restarting || false)
|
||||
const isShuttingDown = computed(() => serverInfo.value?.['status-info']?.['shutting-down'] || false)
|
||||
const isOffline = computed(() => !isConnected.value || isRestarting.value || isShuttingDown.value)
|
||||
|
||||
// Actions
|
||||
async function login(password: string): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await rpcClient.login(password)
|
||||
isAuthenticated.value = true
|
||||
localStorage.setItem('neode-auth', 'true')
|
||||
|
||||
// Connect WebSocket after successful login
|
||||
await connectWebSocket()
|
||||
|
||||
// Initialize data
|
||||
await initializeData()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Login failed'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await rpcClient.logout()
|
||||
} catch (err) {
|
||||
console.error('Logout error:', err)
|
||||
} finally {
|
||||
isAuthenticated.value = 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
|
||||
}
|
||||
}
|
||||
|
||||
async function connectWebSocket(): Promise<void> {
|
||||
try {
|
||||
console.log('[Store] Connecting WebSocket...')
|
||||
isReconnecting.value = true
|
||||
|
||||
// Don't create multiple subscriptions - check if already subscribed
|
||||
if (!isWsSubscribed) {
|
||||
// Subscribe to updates BEFORE connecting (so we catch initial data)
|
||||
isWsSubscribed = true
|
||||
wsClient.subscribe((update: any) => {
|
||||
// Handle mock backend format: {type: 'initial', data: {...}}
|
||||
if (update?.type === 'initial' && update?.data) {
|
||||
console.log('[Store] Received initial data from mock backend')
|
||||
data.value = update.data
|
||||
isConnected.value = true
|
||||
isReconnecting.value = false
|
||||
}
|
||||
// Handle real backend format: {rev: 0, data: {...}}
|
||||
else if (update?.data && update?.rev !== undefined) {
|
||||
console.log('[Store] Received dump from real backend at revision', update.rev)
|
||||
data.value = update.data
|
||||
isConnected.value = true
|
||||
isReconnecting.value = false
|
||||
}
|
||||
// Handle patch updates (both backends)
|
||||
else if (data.value && update?.patch) {
|
||||
try {
|
||||
console.log('[Store] Applying patch at revision', update.rev || 'unknown')
|
||||
data.value = applyDataPatch(data.value, update.patch)
|
||||
// Mark as connected once we receive any valid patch
|
||||
if (!isConnected.value) {
|
||||
isConnected.value = true
|
||||
isReconnecting.value = false
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Store] Failed to apply WebSocket patch:', err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Now connect (or reconnect if already connected)
|
||||
await wsClient.connect()
|
||||
console.log('[Store] WebSocket connected')
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Store] WebSocket connection failed:', err)
|
||||
// Don't mark as disconnected immediately - let reconnection logic handle it
|
||||
// The WebSocket client will retry automatically
|
||||
isReconnecting.value = true
|
||||
// Don't throw - allow app to work without real-time updates
|
||||
// The WebSocket will reconnect in the background
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeData(): Promise<void> {
|
||||
// Initialize with empty data structure
|
||||
// The WebSocket will populate it with real data
|
||||
data.value = {
|
||||
'server-info': {
|
||||
id: '',
|
||||
version: '',
|
||||
name: null,
|
||||
pubkey: '',
|
||||
'status-info': {
|
||||
restarting: false,
|
||||
'shutting-down': false,
|
||||
updated: false,
|
||||
'backup-progress': null,
|
||||
'update-progress': null,
|
||||
},
|
||||
'lan-address': null,
|
||||
unread: 0,
|
||||
'wifi-ssids': [],
|
||||
'zram-enabled': false,
|
||||
},
|
||||
'package-data': {},
|
||||
ui: {
|
||||
name: null,
|
||||
'ack-welcome': '',
|
||||
marketplace: {
|
||||
'selected-hosts': [],
|
||||
'known-hosts': {},
|
||||
},
|
||||
theme: 'dark',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Check session validity on app load
|
||||
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...')
|
||||
|
||||
// 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
|
||||
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
|
||||
isWsSubscribed = false
|
||||
isConnected.value = false
|
||||
isReconnecting.value = false
|
||||
// Disconnect WebSocket if session is invalid
|
||||
wsClient.disconnect()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Package actions
|
||||
async function installPackage(id: string, marketplaceUrl: string, version: string): Promise<string> {
|
||||
return rpcClient.installPackage(id, marketplaceUrl, version)
|
||||
}
|
||||
|
||||
async function uninstallPackage(id: string): Promise<void> {
|
||||
return rpcClient.uninstallPackage(id)
|
||||
}
|
||||
|
||||
async function startPackage(id: string): Promise<void> {
|
||||
return rpcClient.startPackage(id)
|
||||
}
|
||||
|
||||
async function stopPackage(id: string): Promise<void> {
|
||||
return rpcClient.stopPackage(id)
|
||||
}
|
||||
|
||||
async function restartPackage(id: string): Promise<void> {
|
||||
return rpcClient.restartPackage(id)
|
||||
}
|
||||
|
||||
// Server actions
|
||||
async function updateServer(marketplaceUrl: string): Promise<'updating' | 'no-updates'> {
|
||||
return rpcClient.updateServer(marketplaceUrl)
|
||||
}
|
||||
|
||||
async function restartServer(): Promise<void> {
|
||||
return rpcClient.restartServer()
|
||||
}
|
||||
|
||||
async function shutdownServer(): Promise<void> {
|
||||
return rpcClient.shutdownServer()
|
||||
}
|
||||
|
||||
async function getMetrics(): Promise<any> {
|
||||
return rpcClient.getMetrics()
|
||||
}
|
||||
|
||||
// Marketplace actions
|
||||
async function getMarketplace(url: string): Promise<any> {
|
||||
return rpcClient.getMarketplace(url)
|
||||
}
|
||||
|
||||
async function sideloadPackage(manifest: any, icon: string): Promise<string> {
|
||||
return rpcClient.sideloadPackage(manifest, icon)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
data,
|
||||
isAuthenticated,
|
||||
isConnected,
|
||||
isReconnecting,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Computed
|
||||
serverInfo,
|
||||
packages,
|
||||
uiData,
|
||||
serverName,
|
||||
isRestarting,
|
||||
isShuttingDown,
|
||||
isOffline,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
logout,
|
||||
checkSession,
|
||||
connectWebSocket,
|
||||
installPackage,
|
||||
uninstallPackage,
|
||||
startPackage,
|
||||
stopPackage,
|
||||
restartPackage,
|
||||
updateServer,
|
||||
restartServer,
|
||||
shutdownServer,
|
||||
getMetrics,
|
||||
getMarketplace,
|
||||
sideloadPackage,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Global glassmorphism utilities */
|
||||
@layer components {
|
||||
.glass {
|
||||
background-color: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background-color: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background-color: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
border-radius: 1rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.glass-button {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* Gradient containers - transparent to black */
|
||||
.gradient-card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(0, 0, 0, 0.8) 100%);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.gradient-card-dark {
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.9) 100%);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.gradient-button {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(0, 0, 0, 0.8) 100%);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.gradient-button:hover {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, rgba(0, 0, 0, 0.9) 100%);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Gradient border for logo badge */
|
||||
.logo-gradient-border {
|
||||
position: relative;
|
||||
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);
|
||||
}
|
||||
|
||||
.logo-gradient-border::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
border-radius: 9999px;
|
||||
background: #000;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.logo-gradient-border img {
|
||||
border-radius: 9999px;
|
||||
display: block;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Gradient border container for large content areas */
|
||||
.gradient-border-container {
|
||||
position: relative;
|
||||
border-radius: 1.5rem;
|
||||
padding: 4px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.1) 50%, rgba(0, 0, 0, 0.6) 100%);
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.gradient-border-container-inner {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border-radius: 1.25rem;
|
||||
}
|
||||
|
||||
/* Choose Your Path - Main Container */
|
||||
.path-glass-container {
|
||||
width: calc(100% - 48px);
|
||||
max-width: 1200px;
|
||||
margin: 40px auto;
|
||||
padding: 32px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Choose Your Path - Option Cards */
|
||||
.path-option-card {
|
||||
position: relative;
|
||||
background: rgba(0, 0, 0, 0.60);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
border-radius: 16px;
|
||||
padding: 12px 10px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Gradient border effect using CSS mask - default is subtle */
|
||||
.path-option-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 2px;
|
||||
background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Icon styling with black glass effect */
|
||||
.path-option-card svg {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
transition: all 0.3s ease;
|
||||
filter:
|
||||
drop-shadow(0 1px 1px rgba(255, 255, 255, 0.3))
|
||||
drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8))
|
||||
drop-shadow(0 -1px 2px rgba(0, 0, 0, 0.6));
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.path-option-card:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.path-option-card:hover::before {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
|
||||
}
|
||||
|
||||
.path-option-card:hover svg {
|
||||
color: rgba(255, 255, 255, 1);
|
||||
filter:
|
||||
drop-shadow(0 1px 2px rgba(255, 255, 255, 0.5))
|
||||
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.9))
|
||||
drop-shadow(0 -1px 3px rgba(0, 0, 0, 0.7));
|
||||
}
|
||||
|
||||
/* Selected state */
|
||||
.path-option-card--selected {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.6),
|
||||
0 0 30px rgba(255, 255, 255, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.path-option-card--selected::before {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.6), transparent);
|
||||
}
|
||||
|
||||
.path-option-card--selected svg {
|
||||
color: rgba(255, 255, 255, 1);
|
||||
filter:
|
||||
drop-shadow(0 1px 2px rgba(255, 255, 255, 0.6))
|
||||
drop-shadow(0 3px 8px rgba(0, 0, 0, 1))
|
||||
drop-shadow(0 0 12px rgba(255, 255, 255, 0.3));
|
||||
}
|
||||
|
||||
.path-option-card--selected h3 {
|
||||
color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.path-action-button {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
border-radius: 16px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: none;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.path-action-button--skip {
|
||||
padding: 12px 40px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.path-action-button--skip::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.path-action-button--continue {
|
||||
padding: 16px 40px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.path-action-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 2px;
|
||||
background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.path-action-button:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.path-action-button:hover::before {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
|
||||
}
|
||||
|
||||
.path-action-button:active {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* Active Navigation Tab Style - matches hover container */
|
||||
.nav-tab-active {
|
||||
position: relative;
|
||||
background: rgba(0, 0, 0, 0.35) !important;
|
||||
box-shadow:
|
||||
0 6px 16px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25) !important;
|
||||
color: rgba(255, 255, 255, 1) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.nav-tab-active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 2px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Background image */
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Avenir Next', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #000 url('/assets/img/bg.jpg') center top / auto 100vh no-repeat fixed;
|
||||
color: white;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for glass containers */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.3) 0%, rgba(255, 255, 255, 0.1) 100%);
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.2) 100%);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeUpIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(28px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes caretBlink {
|
||||
0%, 100% {
|
||||
border-right-color: #00ffff;
|
||||
}
|
||||
50% {
|
||||
border-right-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-up {
|
||||
animation: fadeUpIn 900ms cubic-bezier(0.22, 1, 0.36, 1) 120ms both;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease forwards;
|
||||
}
|
||||
|
||||
.animate-fade-out {
|
||||
animation: fadeOut 0.8s ease forwards;
|
||||
}
|
||||
|
||||
.typing-text {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
max-width: 0;
|
||||
border-right: 4px solid #00ffff;
|
||||
animation:
|
||||
typing 2s steps(30, end) forwards,
|
||||
caretBlink 0.5s step-end 3 2.6s;
|
||||
}
|
||||
|
||||
/* Adjust typing animation to use max-width for better centering */
|
||||
@keyframes typing {
|
||||
from {
|
||||
max-width: 0;
|
||||
}
|
||||
to {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes caretBlink {
|
||||
0%, 100% {
|
||||
border-right-color: #00ffff;
|
||||
}
|
||||
50% {
|
||||
border-right-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Splash screen styles */
|
||||
.splash-complete .login-card {
|
||||
animation: fadeUpIn 900ms cubic-bezier(0.22, 1, 0.36, 1) 120ms both;
|
||||
}
|
||||
|
||||
/* Background glitch effect - continuous every 5s */
|
||||
body::before,
|
||||
body::after,
|
||||
html::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Subtle black glitch overlay */
|
||||
body::before {
|
||||
background-image: url('/assets/img/bg.jpg');
|
||||
background-size: auto 100vh;
|
||||
background-position: center top;
|
||||
background-attachment: fixed;
|
||||
mix-blend-mode: multiply;
|
||||
filter: brightness(0.4) contrast(1.2);
|
||||
will-change: transform, clip-path, opacity;
|
||||
animation: bg-glitch-shift-repeat 5s steps(10, end) infinite;
|
||||
}
|
||||
|
||||
/* Second subtle black layer */
|
||||
html::before {
|
||||
background-image: url('/assets/img/bg.jpg');
|
||||
background-size: auto 100vh;
|
||||
background-position: center top;
|
||||
background-attachment: fixed;
|
||||
mix-blend-mode: multiply;
|
||||
filter: brightness(0.3) contrast(1.3);
|
||||
will-change: transform, clip-path, opacity;
|
||||
animation: bg-glitch-shift-2-repeat 5s steps(9, end) infinite;
|
||||
}
|
||||
|
||||
/* Subtle scanline sweep */
|
||||
body::after {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(0,0,0,0.08), rgba(0,0,0,0) 60%),
|
||||
repeating-linear-gradient(180deg, rgba(0,0,0,0.02) 0 2px, rgba(0,0,0,0) 2px 4px),
|
||||
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;
|
||||
}
|
||||
|
||||
/* Disable glitch effect on dashboard */
|
||||
.dashboard-view ~ body::before,
|
||||
.dashboard-view ~ body::after,
|
||||
.dashboard-view ~ html::before {
|
||||
animation: none !important;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
/* Alternative approach - disable on body when dashboard is active */
|
||||
body:has(.dashboard-view)::before,
|
||||
body:has(.dashboard-view)::after,
|
||||
html:has(.dashboard-view)::before {
|
||||
animation: none !important;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
/* Disable glitch effect on video background screens (onboarding intro/login) */
|
||||
body.video-background-active::before,
|
||||
body.video-background-active::after,
|
||||
html:has(body.video-background-active)::before {
|
||||
animation: none !important;
|
||||
opacity: 0 !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Repeating glitch animations - every 5 seconds (subtle) */
|
||||
@keyframes bg-glitch-shift-repeat {
|
||||
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
82.1% { opacity: .08; }
|
||||
84% { transform: translate(3px,-1px); clip-path: inset(8% 0 70% 0); }
|
||||
86% { transform: translate(-3px,1px); clip-path: inset(42% 0 40% 0); }
|
||||
88% { transform: translate(2px,0); clip-path: inset(68% 0 10% 0); }
|
||||
91% { transform: translate(-2px,2px); clip-path: inset(18% 0 60% 0); }
|
||||
93% { transform: translate(3px,-2px); clip-path: inset(55% 0 20% 0); }
|
||||
95% { transform: translate(-2px,1px); clip-path: inset(10% 0 80% 0); }
|
||||
100% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes bg-glitch-scan-repeat {
|
||||
0%, 82% { opacity: 0; transform: translateY(-20%); }
|
||||
84% { opacity: 0.15; }
|
||||
90% { opacity: 0.10; }
|
||||
100% { opacity: 0; transform: translateY(115%); }
|
||||
}
|
||||
|
||||
@keyframes bg-glitch-shift-2-repeat {
|
||||
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
82.1% { opacity: .06; }
|
||||
84% { transform: translate(-3px,1px); clip-path: inset(12% 0 65% 0); }
|
||||
86% { transform: translate(3px,-1px) skewX(0.3deg); clip-path: inset(36% 0 42% 0); }
|
||||
89% { transform: translate(-2px,1px); clip-path: inset(72% 0 8% 0); }
|
||||
92% { transform: translate(2px,-2px); clip-path: inset(22% 0 58% 0); }
|
||||
95% { transform: translate(-2px,1px); clip-path: inset(50% 0 26% 0); }
|
||||
100% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// API Types ported from Angular codebase
|
||||
|
||||
export interface DataModel {
|
||||
'server-info': ServerInfo
|
||||
'package-data': { [id: string]: PackageDataEntry }
|
||||
ui: UIData
|
||||
}
|
||||
|
||||
export interface ServerInfo {
|
||||
id: string
|
||||
version: string
|
||||
name: string | null
|
||||
pubkey: string
|
||||
'status-info': StatusInfo
|
||||
'lan-address': string | null
|
||||
unread: number
|
||||
'wifi-ssids': string[]
|
||||
'zram-enabled': boolean
|
||||
}
|
||||
|
||||
export interface StatusInfo {
|
||||
restarting: boolean
|
||||
'shutting-down': boolean
|
||||
'updated': boolean
|
||||
'backup-progress': number | null
|
||||
'update-progress': number | null
|
||||
}
|
||||
|
||||
export interface UIData {
|
||||
name: string | null
|
||||
'ack-welcome': string
|
||||
marketplace: UIMarketplaceData
|
||||
theme: string
|
||||
}
|
||||
|
||||
export interface UIMarketplaceData {
|
||||
'selected-hosts': string[]
|
||||
'known-hosts': Record<string, MarketplaceHost>
|
||||
}
|
||||
|
||||
export interface MarketplaceHost {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export const PackageState = {
|
||||
Installing: 'installing',
|
||||
Installed: 'installed',
|
||||
Stopping: 'stopping',
|
||||
Stopped: 'stopped',
|
||||
Starting: 'starting',
|
||||
Running: 'running',
|
||||
Restarting: 'restarting',
|
||||
Creating: 'creating-backup',
|
||||
Restoring: 'restoring-backup',
|
||||
Removing: 'removing',
|
||||
BackingUp: 'backing-up',
|
||||
} as const
|
||||
|
||||
export type PackageState = typeof PackageState[keyof typeof PackageState]
|
||||
|
||||
export interface PackageDataEntry {
|
||||
state: PackageState
|
||||
'static-files': {
|
||||
license: string
|
||||
instructions: string
|
||||
icon: string
|
||||
}
|
||||
manifest: Manifest
|
||||
installed?: InstalledPackageDataEntry
|
||||
'install-progress'?: InstallProgress
|
||||
}
|
||||
|
||||
export interface Manifest {
|
||||
id: string
|
||||
title: string
|
||||
version: string
|
||||
description: {
|
||||
short: string
|
||||
long: string
|
||||
}
|
||||
'release-notes': string
|
||||
license: string
|
||||
'wrapper-repo': string
|
||||
'upstream-repo': string
|
||||
'support-site': string
|
||||
'marketing-site': string
|
||||
'donation-url': string | null
|
||||
author?: string
|
||||
website?: string
|
||||
interfaces?: {
|
||||
main?: {
|
||||
ui?: string
|
||||
'tor-config'?: string
|
||||
'lan-config'?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface InstalledPackageDataEntry {
|
||||
'current-dependents': Record<string, CurrentDependencyInfo>
|
||||
'current-dependencies': Record<string, CurrentDependencyInfo>
|
||||
'last-backup': string | null
|
||||
'interface-addresses': Record<string, InterfaceAddress>
|
||||
status: ServiceStatus
|
||||
}
|
||||
|
||||
export interface CurrentDependencyInfo {
|
||||
'health-checks': string[]
|
||||
}
|
||||
|
||||
export interface InterfaceAddress {
|
||||
'tor-address': string
|
||||
'lan-address': string | null
|
||||
}
|
||||
|
||||
export const ServiceStatus = {
|
||||
Stopped: 'stopped',
|
||||
Starting: 'starting',
|
||||
Running: 'running',
|
||||
Stopping: 'stopping',
|
||||
Restarting: 'restarting',
|
||||
} as const
|
||||
|
||||
export type ServiceStatus = typeof ServiceStatus[keyof typeof ServiceStatus]
|
||||
|
||||
export interface InstallProgress {
|
||||
size: number
|
||||
downloaded: number
|
||||
}
|
||||
|
||||
// RPC Request/Response types
|
||||
export namespace RR {
|
||||
// Auth
|
||||
export interface LoginReq {
|
||||
password: string
|
||||
metadata: SessionMetadata
|
||||
}
|
||||
export type LoginRes = null
|
||||
|
||||
export interface SessionMetadata {
|
||||
// Add session metadata fields
|
||||
}
|
||||
|
||||
export interface LogoutReq {}
|
||||
export type LogoutRes = null
|
||||
|
||||
export interface ResetPasswordReq {
|
||||
'old-password': string
|
||||
'new-password': string
|
||||
}
|
||||
export type ResetPasswordRes = null
|
||||
|
||||
// Server
|
||||
export interface EchoReq {
|
||||
message: string
|
||||
timeout?: number
|
||||
}
|
||||
export type EchoRes = string
|
||||
|
||||
export interface GetSystemTimeReq {}
|
||||
export interface GetSystemTimeRes {
|
||||
now: string
|
||||
uptime: number
|
||||
}
|
||||
|
||||
export interface GetServerMetricsReq {}
|
||||
export interface GetServerMetricsRes {
|
||||
cpu: number
|
||||
disk: DiskInfo
|
||||
memory: MemoryInfo
|
||||
}
|
||||
|
||||
export interface DiskInfo {
|
||||
used: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface MemoryInfo {
|
||||
used: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface UpdateServerReq {
|
||||
'marketplace-url': string
|
||||
}
|
||||
export type UpdateServerRes = 'updating' | 'no-updates'
|
||||
|
||||
export interface RestartServerReq {}
|
||||
export type RestartServerRes = null
|
||||
|
||||
export interface ShutdownServerReq {}
|
||||
export type ShutdownServerRes = null
|
||||
|
||||
// Packages
|
||||
export interface InstallPackageReq {
|
||||
id: string
|
||||
'marketplace-url': string
|
||||
version: string
|
||||
}
|
||||
export type InstallPackageRes = string // guid
|
||||
|
||||
export interface UninstallPackageReq {
|
||||
id: string
|
||||
}
|
||||
export type UninstallPackageRes = null
|
||||
|
||||
export interface StartPackageReq {
|
||||
id: string
|
||||
}
|
||||
export type StartPackageRes = null
|
||||
|
||||
export interface StopPackageReq {
|
||||
id: string
|
||||
}
|
||||
export type StopPackageRes = null
|
||||
|
||||
export interface RestartPackageReq {
|
||||
id: string
|
||||
}
|
||||
export type RestartPackageRes = null
|
||||
}
|
||||
|
||||
// JSON Patch types
|
||||
export interface PatchOperation {
|
||||
op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test'
|
||||
path: string
|
||||
value?: any
|
||||
from?: string
|
||||
}
|
||||
|
||||
export interface Update {
|
||||
sequence: number
|
||||
patch: PatchOperation[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
// Dummy apps data for first launch
|
||||
// This can be easily replaced with real package data later
|
||||
// Similar to how atob and k484 are handled
|
||||
|
||||
import type { PackageDataEntry } from '../types/api'
|
||||
import { PackageState, ServiceStatus } from '../types/api'
|
||||
|
||||
export const dummyApps: Record<string, PackageDataEntry> = {
|
||||
'bitcoin': {
|
||||
state: PackageState.Running,
|
||||
'static-files': {
|
||||
license: 'MIT',
|
||||
instructions: 'Bitcoin Core node for the Neode network',
|
||||
icon: '/assets/img/app-icons/bitcoin.svg'
|
||||
},
|
||||
manifest: {
|
||||
id: 'bitcoin',
|
||||
title: 'Bitcoin Core',
|
||||
version: '24.0.0',
|
||||
description: {
|
||||
short: 'Full Bitcoin node implementation',
|
||||
long: 'Bitcoin Core is the reference implementation of Bitcoin. It provides a full node implementation that validates and relays transactions, maintains the blockchain, and serves as a wallet.'
|
||||
},
|
||||
'release-notes': 'Initial release',
|
||||
license: 'MIT',
|
||||
'wrapper-repo': 'https://github.com/Start9Labs/bitcoind-startos',
|
||||
'upstream-repo': 'https://github.com/bitcoin/bitcoin',
|
||||
'support-site': 'https://github.com/bitcoin/bitcoin/issues',
|
||||
'marketing-site': 'https://bitcoin.org',
|
||||
'donation-url': null
|
||||
},
|
||||
installed: {
|
||||
'current-dependents': {},
|
||||
'current-dependencies': {},
|
||||
'last-backup': null,
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': 'bitcoin.onion',
|
||||
'lan-address': 'http://localhost:8332'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Running
|
||||
}
|
||||
},
|
||||
'btcpay-server': {
|
||||
state: PackageState.Running,
|
||||
'static-files': {
|
||||
license: 'MIT',
|
||||
instructions: 'BTCPay Server payment processor',
|
||||
icon: '/assets/img/app-icons/btcpay-server.png'
|
||||
},
|
||||
manifest: {
|
||||
id: 'btcpay-server',
|
||||
title: 'BTCPay Server',
|
||||
version: '1.12.0',
|
||||
description: {
|
||||
short: 'Self-hosted Bitcoin payment processor',
|
||||
long: 'BTCPay Server is a free, open-source cryptocurrency payment processor. Accept Bitcoin payments without intermediaries or fees. Complete merchant solution with invoicing, point of sale, and more.'
|
||||
},
|
||||
'release-notes': 'Initial release',
|
||||
license: 'MIT',
|
||||
'wrapper-repo': 'https://github.com/Start9Labs/btcpayserver-startos',
|
||||
'upstream-repo': 'https://github.com/btcpayserver/btcpayserver',
|
||||
'support-site': 'https://github.com/btcpayserver/btcpayserver/issues',
|
||||
'marketing-site': 'https://btcpayserver.org',
|
||||
'donation-url': null
|
||||
},
|
||||
installed: {
|
||||
'current-dependents': {},
|
||||
'current-dependencies': {},
|
||||
'last-backup': null,
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': 'btcpay.onion',
|
||||
'lan-address': 'http://localhost:14142'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Running
|
||||
}
|
||||
},
|
||||
'homeassistant': {
|
||||
state: PackageState.Running,
|
||||
'static-files': {
|
||||
license: 'Apache-2.0',
|
||||
instructions: 'Home automation platform',
|
||||
icon: '/assets/img/app-icons/homeassistant.png'
|
||||
},
|
||||
manifest: {
|
||||
id: 'homeassistant',
|
||||
title: 'Home Assistant',
|
||||
version: '2024.1.0',
|
||||
description: {
|
||||
short: 'Open source home automation platform',
|
||||
long: 'Home Assistant is an open-source home automation platform running on Python. It tracks and controls all devices at home and offers a platform for automating control.'
|
||||
},
|
||||
'release-notes': 'Initial release',
|
||||
license: 'Apache-2.0',
|
||||
'wrapper-repo': 'https://github.com/Start9Labs/home-assistant-startos',
|
||||
'upstream-repo': 'https://github.com/home-assistant/core',
|
||||
'support-site': 'https://github.com/home-assistant/core/issues',
|
||||
'marketing-site': 'https://www.home-assistant.io',
|
||||
'donation-url': null
|
||||
},
|
||||
installed: {
|
||||
'current-dependents': {},
|
||||
'current-dependencies': {},
|
||||
'last-backup': null,
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': 'homeassistant.onion',
|
||||
'lan-address': 'http://localhost:8123'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Running
|
||||
}
|
||||
},
|
||||
'grafana': {
|
||||
state: PackageState.Running,
|
||||
'static-files': {
|
||||
license: 'Apache-2.0',
|
||||
instructions: 'Analytics and monitoring platform',
|
||||
icon: '/assets/img/grafana.png'
|
||||
},
|
||||
manifest: {
|
||||
id: 'grafana',
|
||||
title: 'Grafana',
|
||||
version: '10.2.0',
|
||||
description: {
|
||||
short: 'Analytics and monitoring platform',
|
||||
long: 'Grafana is an open-source analytics and monitoring platform. Create dashboards, query metrics, and visualize data from multiple sources.'
|
||||
},
|
||||
'release-notes': 'Initial release',
|
||||
license: 'Apache-2.0',
|
||||
'wrapper-repo': 'https://github.com/grafana/grafana',
|
||||
'upstream-repo': 'https://github.com/grafana/grafana',
|
||||
'support-site': 'https://github.com/grafana/grafana/issues',
|
||||
'marketing-site': 'https://grafana.com',
|
||||
'donation-url': null
|
||||
},
|
||||
installed: {
|
||||
'current-dependents': {},
|
||||
'current-dependencies': {},
|
||||
'last-backup': null,
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': 'grafana.onion',
|
||||
'lan-address': 'http://localhost:3000'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Running
|
||||
}
|
||||
},
|
||||
'endurain': {
|
||||
state: PackageState.Stopped,
|
||||
'static-files': {
|
||||
license: 'MIT',
|
||||
instructions: 'Endurain application',
|
||||
icon: '/assets/img/endurain.png'
|
||||
},
|
||||
manifest: {
|
||||
id: 'endurain',
|
||||
title: 'Endurain',
|
||||
version: '1.0.0',
|
||||
description: {
|
||||
short: 'Endurain application platform',
|
||||
long: 'Endurain provides a platform for decentralized applications and services.'
|
||||
},
|
||||
'release-notes': 'Initial release',
|
||||
license: 'MIT',
|
||||
'wrapper-repo': 'https://github.com/endurain/endurain',
|
||||
'upstream-repo': 'https://github.com/endurain/endurain',
|
||||
'support-site': 'https://github.com/endurain/endurain/issues',
|
||||
'marketing-site': 'https://endurain.io',
|
||||
'donation-url': null
|
||||
},
|
||||
installed: {
|
||||
'current-dependents': {},
|
||||
'current-dependencies': {},
|
||||
'last-backup': null,
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': 'endurain.onion',
|
||||
'lan-address': 'http://localhost:8080'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Stopped
|
||||
}
|
||||
},
|
||||
'fedimint': {
|
||||
state: PackageState.Running,
|
||||
'static-files': {
|
||||
license: 'MIT',
|
||||
instructions: 'Federated Bitcoin mint',
|
||||
icon: '/assets/img/icon-fedimint.jpeg'
|
||||
},
|
||||
manifest: {
|
||||
id: 'fedimint',
|
||||
title: 'Fedimint',
|
||||
version: '0.3.0',
|
||||
description: {
|
||||
short: 'Federated Bitcoin minting service',
|
||||
long: 'Fedimint is a federated Bitcoin mint that enables private, scalable Bitcoin transactions through a federation of guardians.'
|
||||
},
|
||||
'release-notes': 'Initial release',
|
||||
license: 'MIT',
|
||||
'wrapper-repo': 'https://github.com/fedimint/fedimint',
|
||||
'upstream-repo': 'https://github.com/fedimint/fedimint',
|
||||
'support-site': 'https://github.com/fedimint/fedimint/issues',
|
||||
'marketing-site': 'https://fedimint.org',
|
||||
'donation-url': null
|
||||
},
|
||||
installed: {
|
||||
'current-dependents': {},
|
||||
'current-dependencies': {},
|
||||
'last-backup': null,
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': 'fedimint.onion',
|
||||
'lan-address': 'http://localhost:8173'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Running
|
||||
}
|
||||
},
|
||||
'morphos-server': {
|
||||
state: PackageState.Running,
|
||||
'static-files': {
|
||||
license: 'MIT',
|
||||
instructions: 'MorphOS server application',
|
||||
icon: '/assets/img/morphos.png'
|
||||
},
|
||||
manifest: {
|
||||
id: 'morphos-server',
|
||||
title: 'MorphOS Server',
|
||||
version: '1.0.0',
|
||||
description: {
|
||||
short: 'MorphOS server platform',
|
||||
long: 'MorphOS Server provides a flexible server platform for various applications and services.'
|
||||
},
|
||||
'release-notes': 'Initial release',
|
||||
license: 'MIT',
|
||||
'wrapper-repo': 'https://github.com/morphos/morphos',
|
||||
'upstream-repo': 'https://github.com/morphos/morphos',
|
||||
'support-site': 'https://github.com/morphos/morphos/issues',
|
||||
'marketing-site': 'https://morphos.io',
|
||||
'donation-url': null
|
||||
},
|
||||
installed: {
|
||||
'current-dependents': {},
|
||||
'current-dependencies': {},
|
||||
'last-backup': null,
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': 'morphos.onion',
|
||||
'lan-address': 'http://localhost:8081'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Running
|
||||
}
|
||||
},
|
||||
'lightning-stack': {
|
||||
state: PackageState.Running,
|
||||
'static-files': {
|
||||
license: 'MIT',
|
||||
instructions: 'Lightning Network stack',
|
||||
icon: '/assets/img/app-icons/lightning-stack.png'
|
||||
},
|
||||
manifest: {
|
||||
id: 'lightning-stack',
|
||||
title: 'Lightning Stack',
|
||||
version: '0.12.0',
|
||||
description: {
|
||||
short: 'Complete Lightning Network implementation',
|
||||
long: 'Lightning Stack provides a complete Lightning Network node implementation with LND, CLN, and supporting services for fast Bitcoin transactions.'
|
||||
},
|
||||
'release-notes': 'Initial release',
|
||||
license: 'MIT',
|
||||
'wrapper-repo': 'https://github.com/Start9Labs/lnd-startos',
|
||||
'upstream-repo': 'https://github.com/lightningnetwork/lnd',
|
||||
'support-site': 'https://github.com/lightningnetwork/lnd/issues',
|
||||
'marketing-site': 'https://lightning.network',
|
||||
'donation-url': null
|
||||
},
|
||||
installed: {
|
||||
'current-dependents': {},
|
||||
'current-dependencies': {},
|
||||
'last-backup': null,
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': 'lightning.onion',
|
||||
'lan-address': 'http://localhost:9735'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Running
|
||||
}
|
||||
},
|
||||
'mempool': {
|
||||
state: PackageState.Running,
|
||||
'static-files': {
|
||||
license: 'AGPL-3.0',
|
||||
instructions: 'Bitcoin mempool explorer',
|
||||
icon: '/assets/img/app-icons/mempool.png'
|
||||
},
|
||||
manifest: {
|
||||
id: 'mempool',
|
||||
title: 'Mempool',
|
||||
version: '2.5.0',
|
||||
description: {
|
||||
short: 'Bitcoin mempool and blockchain explorer',
|
||||
long: 'Mempool is an open-source Bitcoin mempool and blockchain explorer. View transactions, blocks, and network statistics in real-time.'
|
||||
},
|
||||
'release-notes': 'Initial release',
|
||||
license: 'AGPL-3.0',
|
||||
'wrapper-repo': 'https://github.com/Start9Labs/mempool-startos',
|
||||
'upstream-repo': 'https://github.com/mempool/mempool',
|
||||
'support-site': 'https://github.com/mempool/mempool/issues',
|
||||
'marketing-site': 'https://mempool.space',
|
||||
'donation-url': null
|
||||
},
|
||||
installed: {
|
||||
'current-dependents': {},
|
||||
'current-dependencies': {},
|
||||
'last-backup': null,
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': 'mempool.onion',
|
||||
'lan-address': 'http://localhost:4080'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Running
|
||||
}
|
||||
},
|
||||
'ollama': {
|
||||
state: PackageState.Running,
|
||||
'static-files': {
|
||||
license: 'MIT',
|
||||
instructions: 'Local AI model runner',
|
||||
icon: '/assets/img/ollama.webp'
|
||||
},
|
||||
manifest: {
|
||||
id: 'ollama',
|
||||
title: 'Ollama',
|
||||
version: '0.1.0',
|
||||
description: {
|
||||
short: 'Run large language models locally',
|
||||
long: 'Ollama allows you to run large language models locally on your Neode server. Download and run models like Llama, Mistral, and more without cloud dependencies.'
|
||||
},
|
||||
'release-notes': 'Initial release',
|
||||
license: 'MIT',
|
||||
'wrapper-repo': 'https://github.com/ollama/ollama',
|
||||
'upstream-repo': 'https://github.com/ollama/ollama',
|
||||
'support-site': 'https://github.com/ollama/ollama/issues',
|
||||
'marketing-site': 'https://ollama.ai',
|
||||
'donation-url': null
|
||||
},
|
||||
installed: {
|
||||
'current-dependents': {},
|
||||
'current-dependencies': {},
|
||||
'last-backup': null,
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': 'ollama.onion',
|
||||
'lan-address': 'http://localhost:11434'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Running
|
||||
}
|
||||
},
|
||||
'searxng': {
|
||||
state: PackageState.Running,
|
||||
'static-files': {
|
||||
license: 'AGPL-3.0',
|
||||
instructions: 'Privacy-respecting search engine',
|
||||
icon: '/assets/img/app-icons/searxng.png'
|
||||
},
|
||||
manifest: {
|
||||
id: 'searxng',
|
||||
title: 'SearXNG',
|
||||
version: '2024.1.0',
|
||||
description: {
|
||||
short: 'Privacy-respecting metasearch engine',
|
||||
long: 'SearXNG is a privacy-respecting, hackable metasearch engine. Aggregate results from multiple search engines without tracking or ads.'
|
||||
},
|
||||
'release-notes': 'Initial release',
|
||||
license: 'AGPL-3.0',
|
||||
'wrapper-repo': 'https://github.com/searxng/searxng',
|
||||
'upstream-repo': 'https://github.com/searxng/searxng',
|
||||
'support-site': 'https://github.com/searxng/searxng/issues',
|
||||
'marketing-site': 'https://searxng.org',
|
||||
'donation-url': null
|
||||
},
|
||||
installed: {
|
||||
'current-dependents': {},
|
||||
'current-dependencies': {},
|
||||
'last-backup': null,
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': 'searxng.onion',
|
||||
'lan-address': 'http://localhost:8082'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Running
|
||||
}
|
||||
},
|
||||
'onlyoffice': {
|
||||
state: PackageState.Running,
|
||||
'static-files': {
|
||||
license: 'AGPL-3.0',
|
||||
instructions: 'Office suite and document collaboration',
|
||||
icon: '/assets/img/onlyoffice.webp'
|
||||
},
|
||||
manifest: {
|
||||
id: 'onlyoffice',
|
||||
title: 'OnlyOffice',
|
||||
version: '7.5.0',
|
||||
description: {
|
||||
short: 'Office suite and document collaboration',
|
||||
long: 'OnlyOffice is a secure office suite that enables real-time collaborative editing of documents, spreadsheets, and presentations. Self-hosted alternative to Google Workspace and Microsoft 365.'
|
||||
},
|
||||
'release-notes': 'Initial release',
|
||||
license: 'AGPL-3.0',
|
||||
'wrapper-repo': 'https://github.com/ONLYOFFICE/DocumentServer',
|
||||
'upstream-repo': 'https://github.com/ONLYOFFICE/DocumentServer',
|
||||
'support-site': 'https://github.com/ONLYOFFICE/DocumentServer/issues',
|
||||
'marketing-site': 'https://www.onlyoffice.com',
|
||||
'donation-url': null
|
||||
},
|
||||
installed: {
|
||||
'current-dependents': {},
|
||||
'current-dependencies': {},
|
||||
'last-backup': null,
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': 'onlyoffice.onion',
|
||||
'lan-address': 'http://localhost:8083'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Running
|
||||
}
|
||||
},
|
||||
'penpot': {
|
||||
state: PackageState.Running,
|
||||
'static-files': {
|
||||
license: 'MPL-2.0',
|
||||
instructions: 'Design and prototyping platform',
|
||||
icon: '/assets/img/penpot.webp'
|
||||
},
|
||||
manifest: {
|
||||
id: 'penpot',
|
||||
title: 'Penpot',
|
||||
version: '2.0.0',
|
||||
description: {
|
||||
short: 'Open-source design and prototyping platform',
|
||||
long: 'Penpot is an open-source design and prototyping platform for teams. Create designs, prototypes, and collaborate in real-time. Self-hosted alternative to Figma.'
|
||||
},
|
||||
'release-notes': 'Initial release',
|
||||
license: 'MPL-2.0',
|
||||
'wrapper-repo': 'https://github.com/penpot/penpot',
|
||||
'upstream-repo': 'https://github.com/penpot/penpot',
|
||||
'support-site': 'https://github.com/penpot/penpot/issues',
|
||||
'marketing-site': 'https://penpot.app',
|
||||
'donation-url': null
|
||||
},
|
||||
installed: {
|
||||
'current-dependents': {},
|
||||
'current-dependencies': {},
|
||||
'last-backup': null,
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': 'penpot.onion',
|
||||
'lan-address': 'http://localhost:9001'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Running
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// Utility to fetch app information from GitHub repositories
|
||||
// Used to get icons, descriptions, and other metadata for dummy apps
|
||||
|
||||
export interface GitHubAppInfo {
|
||||
icon?: string
|
||||
description?: string
|
||||
readme?: string
|
||||
homepage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch app information from GitHub repository
|
||||
* @param repoUrl GitHub repository URL (e.g., https://github.com/start9labs/bitcoin)
|
||||
* @param appId App ID to help find the correct repository
|
||||
*/
|
||||
export async function fetchGitHubAppInfo(repoUrl: string, appId: string): Promise<GitHubAppInfo> {
|
||||
try {
|
||||
// Extract owner and repo from URL
|
||||
const match = repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/)
|
||||
if (!match) {
|
||||
console.warn(`[GitHub] Invalid repo URL: ${repoUrl}`)
|
||||
return {}
|
||||
}
|
||||
|
||||
const [, owner, repo] = match
|
||||
|
||||
// Try to find Start9 wrapper repo first (e.g., bitcoin-startos)
|
||||
const start9RepoName = `${appId}-startos`
|
||||
let targetOwner = owner
|
||||
let targetRepo = repo
|
||||
|
||||
// If the repo URL doesn't match the expected pattern, try Start9Labs
|
||||
if (repo && !repo.includes('startos') && !repo.includes('start9')) {
|
||||
// Try Start9Labs wrapper repo
|
||||
try {
|
||||
const start9RepoUrl = `https://api.github.com/repos/Start9Labs/${start9RepoName}`
|
||||
const start9Response = await fetch(start9RepoUrl)
|
||||
if (start9Response.ok) {
|
||||
targetOwner = 'Start9Labs'
|
||||
targetRepo = start9RepoName
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall back to original repo
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch repository info
|
||||
const repoApiUrl = `https://api.github.com/repos/${targetOwner}/${targetRepo}`
|
||||
const repoResponse = await fetch(repoApiUrl)
|
||||
|
||||
if (!repoResponse.ok) {
|
||||
console.warn(`[GitHub] Failed to fetch repo ${targetOwner}/${targetRepo}: ${repoResponse.status}`)
|
||||
return {}
|
||||
}
|
||||
|
||||
const repoData = await repoResponse.json()
|
||||
|
||||
// Fetch README
|
||||
let readme = ''
|
||||
try {
|
||||
const readmeResponse = await fetch(`https://api.github.com/repos/${targetOwner}/${targetRepo}/readme`)
|
||||
if (readmeResponse.ok) {
|
||||
const readmeData = await readmeResponse.json()
|
||||
readme = atob(readmeData.content) // Base64 decode
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[GitHub] Failed to fetch README for ${targetOwner}/${targetRepo}`)
|
||||
}
|
||||
|
||||
// Try to find icon in repository
|
||||
// Common locations: icon.png, icon.svg, assets/icon.png, etc.
|
||||
let icon: string | undefined
|
||||
const iconPaths = [
|
||||
'icon.png',
|
||||
'icon.svg',
|
||||
'assets/icon.png',
|
||||
'assets/icon.svg',
|
||||
'icon/icon.png',
|
||||
'icon/icon.svg'
|
||||
]
|
||||
|
||||
for (const iconPath of iconPaths) {
|
||||
try {
|
||||
const iconResponse = await fetch(`https://api.github.com/repos/${targetOwner}/${targetRepo}/contents/${iconPath}`)
|
||||
if (iconResponse.ok) {
|
||||
const iconData = await iconResponse.json()
|
||||
if (iconData.download_url) {
|
||||
icon = iconData.download_url
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Try next path
|
||||
}
|
||||
}
|
||||
|
||||
// If no icon found, try to get from releases/assets
|
||||
if (!icon) {
|
||||
try {
|
||||
const releasesResponse = await fetch(`https://api.github.com/repos/${targetOwner}/${targetRepo}/releases/latest`)
|
||||
if (releasesResponse.ok) {
|
||||
const releasesData = await releasesResponse.json()
|
||||
const asset = releasesData.assets?.find((a: any) =>
|
||||
a.name.includes('icon') || a.name.includes('logo')
|
||||
)
|
||||
if (asset) {
|
||||
icon = asset.browser_download_url
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// No icon from releases
|
||||
}
|
||||
}
|
||||
|
||||
// If still no icon, try raw GitHub content URLs for common icon names
|
||||
if (!icon) {
|
||||
const rawIconPaths = [
|
||||
`https://raw.githubusercontent.com/${targetOwner}/${targetRepo}/main/icon.png`,
|
||||
`https://raw.githubusercontent.com/${targetOwner}/${targetRepo}/main/icon.svg`,
|
||||
`https://raw.githubusercontent.com/${targetOwner}/${targetRepo}/master/icon.png`,
|
||||
`https://raw.githubusercontent.com/${targetOwner}/${targetRepo}/master/icon.svg`,
|
||||
`https://raw.githubusercontent.com/${targetOwner}/${targetRepo}/main/assets/icon.png`,
|
||||
`https://raw.githubusercontent.com/${targetOwner}/${targetRepo}/main/assets/icon.svg`,
|
||||
]
|
||||
|
||||
// Test each URL
|
||||
for (const iconUrl of rawIconPaths) {
|
||||
try {
|
||||
const testResponse = await fetch(iconUrl, { method: 'HEAD' })
|
||||
if (testResponse.ok) {
|
||||
icon = iconUrl
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
// Try next URL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon,
|
||||
description: repoData.description || '',
|
||||
readme,
|
||||
homepage: repoData.homepage || repoData.html_url
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[GitHub] Error fetching app info for ${repoUrl}:`, error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch fetch app info for multiple apps
|
||||
*/
|
||||
export async function fetchMultipleAppInfo(
|
||||
apps: Array<{ id: string; 'wrapper-repo': string }>
|
||||
): Promise<Record<string, GitHubAppInfo>> {
|
||||
const results: Record<string, GitHubAppInfo> = {}
|
||||
|
||||
// Fetch in parallel with rate limiting (max 5 concurrent)
|
||||
const batchSize = 5
|
||||
for (let i = 0; i < apps.length; i += batchSize) {
|
||||
const batch = apps.slice(i, i + batchSize)
|
||||
const batchPromises = batch.map(async (app) => {
|
||||
const info = await fetchGitHubAppInfo(app['wrapper-repo'], app.id)
|
||||
return { id: app.id, info }
|
||||
})
|
||||
|
||||
const batchResults = await Promise.all(batchPromises)
|
||||
batchResults.forEach(({ id, info }) => {
|
||||
results[id] = info
|
||||
})
|
||||
|
||||
// Rate limit: wait 1 second between batches
|
||||
if (i + batchSize < apps.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -0,0 +1,702 @@
|
||||
<template>
|
||||
<div class="app-details-container pb-16 md:pb-16">
|
||||
<!-- Desktop Back Button -->
|
||||
<button @click="goBack" class="hidden md:flex mb-6 items-center gap-2 text-white/70 hover:text-white transition-colors">
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{{ backButtonText }}
|
||||
</button>
|
||||
|
||||
<!-- Mobile Full-Width Back Button -->
|
||||
<button
|
||||
@click="goBack"
|
||||
class="md:hidden fixed left-4 right-4 z-40 glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
|
||||
:style="{
|
||||
bottom: bottomPosition,
|
||||
filter: 'drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5))'
|
||||
}"
|
||||
>
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>{{ backButtonText }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="pkg">
|
||||
<!-- Compact Hero Section -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<!-- Desktop: Single Row Layout -->
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<!-- App Icon -->
|
||||
<img
|
||||
:src="pkg['static-files'].icon"
|
||||
:alt="pkg.manifest.title"
|
||||
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- App Info (grows to fill space) -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-2xl font-bold text-white mb-1">{{ pkg.manifest.title }}</h1>
|
||||
<p class="text-white/70 text-sm mb-2">{{ pkg.manifest.description.short }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium"
|
||||
:class="getStatusClass(pkg.state)"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full mr-1.5" :class="getStatusDotClass(pkg.state)"></span>
|
||||
{{ pkg.state }}
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
v-if="canLaunch"
|
||||
@click="launchApp"
|
||||
class="gradient-button px-6 py-2.5 rounded-lg text-sm font-semibold 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>
|
||||
Launch
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'stopped'"
|
||||
@click="startApp"
|
||||
class="px-4 py-2.5 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors 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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
</svg>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
@click="restartApp"
|
||||
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors 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="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>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'running'"
|
||||
@click="stopApp"
|
||||
class="px-4 py-2.5 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors 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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
@click="uninstallApp"
|
||||
class="px-4 py-2.5 bg-red-600/20 border border-red-600/40 rounded-lg text-red-300 text-sm font-medium hover:bg-red-600/30 transition-colors 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Two Column Grid Layout -->
|
||||
<div class="md:hidden">
|
||||
<!-- Header: Icon + Info + Delete -->
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<!-- App Icon -->
|
||||
<img
|
||||
:src="pkg['static-files'].icon"
|
||||
:alt="pkg.manifest.title"
|
||||
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- App Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-xl font-bold text-white mb-1">{{ pkg.manifest.title }}</h1>
|
||||
<p class="text-white/70 text-xs mb-2 line-clamp-2">{{ pkg.manifest.description.short }}</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="getStatusClass(pkg.state)"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full mr-1" :class="getStatusDotClass(pkg.state)"></span>
|
||||
{{ pkg.state }}
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uninstall Icon Button -->
|
||||
<button
|
||||
@click="uninstallApp"
|
||||
class="flex-shrink-0 w-9 h-9 rounded-lg bg-red-600/20 border border-red-600/40 text-red-300 hover:bg-red-600/30 transition-colors flex items-center justify-center"
|
||||
title="Uninstall"
|
||||
>
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons (Auto Grid) -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
v-if="canLaunch"
|
||||
@click="launchApp"
|
||||
class="gradient-button px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-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>
|
||||
Launch
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'stopped'"
|
||||
@click="startApp"
|
||||
class="px-4 py-2.5 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors flex items-center justify-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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
</svg>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'running'"
|
||||
@click="stopApp"
|
||||
class="px-4 py-2.5 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors flex items-center justify-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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
@click="restartApp"
|
||||
:class="[canLaunch && (pkg.state === 'stopped' || pkg.state === 'running') ? 'col-span-2' : '']"
|
||||
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors flex items-center justify-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="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>
|
||||
Restart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Main Content -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Screenshots Gallery -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Screenshots</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
class="aspect-video rounded-xl bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg class="w-16 h-16 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-white/60 text-sm mt-3 text-center">Screenshot placeholders - images coming soon</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">About {{ pkg.manifest.title }}</h2>
|
||||
<p class="text-white/80 leading-relaxed whitespace-pre-line">
|
||||
{{ pkg.manifest.description.long }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features (if available) -->
|
||||
<div v-if="features.length > 0" class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Features</h2>
|
||||
<ul class="space-y-3">
|
||||
<li
|
||||
v-for="(feature, index) in features"
|
||||
:key="index"
|
||||
class="flex items-start gap-3 text-white/80"
|
||||
>
|
||||
<svg class="w-6 h-6 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- App Info Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Information</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Version</span>
|
||||
<span class="text-white font-medium">{{ pkg.manifest.version }}</span>
|
||||
</div>
|
||||
<div v-if="pkg.manifest.author" class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Developer</span>
|
||||
<span class="text-white font-medium">{{ pkg.manifest.author }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Status</span>
|
||||
<span class="text-white font-medium capitalize">{{ pkg.state }}</span>
|
||||
</div>
|
||||
<div v-if="pkg.manifest.license" class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">License</span>
|
||||
<span class="text-white font-medium">{{ pkg.manifest.license }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="text-white/60 text-sm">Category</span>
|
||||
<span class="text-white font-medium">App</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requirements Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Requirements</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">RAM</p>
|
||||
<p class="text-white/60 text-sm">Minimum 512MB</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-purple-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">Storage</p>
|
||||
<p class="text-white/60 text-sm">~100MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Links Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Links</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
v-if="pkg.manifest.website"
|
||||
:href="pkg.manifest.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<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="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
Website
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.840 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
Source Code
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Not Found -->
|
||||
<div v-else class="glass-card p-12 text-center">
|
||||
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="text-2xl font-semibold text-white mb-2">App Not Found</h3>
|
||||
<p class="text-white/70">The requested application could not be found</p>
|
||||
</div>
|
||||
|
||||
<!-- Spacer for mobile back button -->
|
||||
<div class="md:hidden h-[calc(var(--mobile-tab-bar-height,_64px)+96px)]"></div>
|
||||
|
||||
<!-- Uninstall Confirmation Modal -->
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="uninstallModal.show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@click="uninstallModal.show = false"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div
|
||||
@click.stop
|
||||
class="glass-card p-6 md:p-8 max-w-md w-full relative z-10"
|
||||
>
|
||||
<div class="flex items-start gap-4 mb-6">
|
||||
<div class="p-3 bg-red-500/20 rounded-lg flex-shrink-0">
|
||||
<svg class="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Uninstall App?</h3>
|
||||
<p class="text-white/70 text-sm">
|
||||
Are you sure you want to uninstall <span class="text-white font-medium">{{ uninstallModal.appTitle }}</span>?
|
||||
This will remove the app and stop its container.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse md:flex-row gap-3 md:justify-end">
|
||||
<button
|
||||
@click="uninstallModal.show = false"
|
||||
class="w-full md:w-auto px-6 py-3 glass-button rounded-lg text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirmUninstall"
|
||||
class="w-full md:w-auto px-6 py-3 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { PackageState } from '../types/api'
|
||||
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
||||
import { dummyApps } from '../utils/dummyApps'
|
||||
|
||||
const { bottomPosition } = useMobileBackButton()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
// Check both store.packages and dummyApps
|
||||
const pkg = computed(() => {
|
||||
// First check real packages
|
||||
if (store.packages[appId.value]) {
|
||||
return store.packages[appId.value]
|
||||
}
|
||||
// Fall back to dummy apps
|
||||
if (dummyApps[appId.value]) {
|
||||
return dummyApps[appId.value]
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const uninstallModal = ref({
|
||||
show: false,
|
||||
appTitle: ''
|
||||
})
|
||||
|
||||
// Determine back button text based on where user came from
|
||||
const backButtonText = computed(() => {
|
||||
// Check if we came from marketplace via query parameter
|
||||
if (route.query.from === 'marketplace') {
|
||||
return 'Back to App Store'
|
||||
}
|
||||
|
||||
// Default to My Apps
|
||||
return 'Back to My Apps'
|
||||
})
|
||||
|
||||
// Check if app has a UI interface and is running
|
||||
const canLaunch = computed(() => {
|
||||
if (!pkg.value) return false
|
||||
// For dummy apps, allow launch if running (they have interface addresses)
|
||||
// For real apps, check for UI interface
|
||||
const hasUI = pkg.value.manifest.interfaces?.main?.ui || pkg.value.installed?.['interface-addresses']?.main
|
||||
const isRunning = pkg.value.state === 'running'
|
||||
return hasUI && isRunning
|
||||
})
|
||||
|
||||
// Placeholder features - could be extracted from manifest later
|
||||
const features = computed(() => {
|
||||
return [
|
||||
'Self-hosted and privacy-focused',
|
||||
'Easy installation and updates',
|
||||
'Automatic backups',
|
||||
'Secure by default'
|
||||
]
|
||||
})
|
||||
|
||||
function handleImageError(e: Event) {
|
||||
const target = e.target as HTMLImageElement
|
||||
const currentSrc = target.src
|
||||
const id = appId.value
|
||||
|
||||
// If it's a dummy app, try to get icon from GitHub or use placeholder
|
||||
if (dummyApps[id]) {
|
||||
// Try alternative icon paths
|
||||
const iconPaths = [
|
||||
`https://raw.githubusercontent.com/Start9Labs/${id}-startos/main/icon.png`,
|
||||
`https://raw.githubusercontent.com/Start9Labs/${id}-startos/main/icon.svg`,
|
||||
`/assets/img/app-icons/${id}.png`,
|
||||
`/assets/img/app-icons/${id}.svg`,
|
||||
]
|
||||
|
||||
// Try next path if available
|
||||
const currentIndex = iconPaths.findIndex(path => currentSrc.includes(path))
|
||||
if (currentIndex < iconPaths.length - 1) {
|
||||
const nextPath = iconPaths[currentIndex + 1]
|
||||
if (nextPath !== undefined) {
|
||||
target.src = nextPath
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a simple placeholder SVG
|
||||
const placeholderSvg = `data:image/svg+xml,${encodeURIComponent(`
|
||||
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="80" height="80" rx="16" fill="rgba(255,255,255,0.1)"/>
|
||||
<path d="M40 25L50 35H45V50H35V35H30L40 25Z" fill="rgba(255,255,255,0.6)"/>
|
||||
<path d="M25 55H55V60H25V55Z" fill="rgba(255,255,255,0.4)"/>
|
||||
</svg>
|
||||
`)}`
|
||||
|
||||
// Only set fallback if we haven't already tried it
|
||||
if (!currentSrc.includes('data:image')) {
|
||||
target.src = placeholderSvg
|
||||
} else {
|
||||
// Ultimate fallback
|
||||
target.src = '/assets/img/logo-archipelago.svg'
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
function launchApp() {
|
||||
if (!pkg.value) return
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
const id = appId.value
|
||||
|
||||
// Special handling for apps with Docker containers
|
||||
// TODO: Replace dummy app URLs with real URLs when apps are packaged
|
||||
const appUrls: Record<string, { dev: string, prod: string }> = {
|
||||
'atob': {
|
||||
dev: 'http://localhost:8102',
|
||||
prod: 'https://app.atobitcoin.io'
|
||||
},
|
||||
'k484': {
|
||||
dev: 'http://localhost:8103',
|
||||
prod: 'http://localhost:8103' // Self-hosted splash screen
|
||||
},
|
||||
// Dummy apps - replace with real URLs when packaged
|
||||
'bitcoin': {
|
||||
dev: 'http://localhost:8332',
|
||||
prod: 'http://localhost:8332'
|
||||
},
|
||||
'btcpay-server': {
|
||||
dev: 'http://localhost:14142',
|
||||
prod: 'http://localhost:14142'
|
||||
},
|
||||
'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:8173',
|
||||
prod: 'http://localhost:8173'
|
||||
},
|
||||
'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:8082',
|
||||
prod: 'http://localhost:8082'
|
||||
},
|
||||
'onlyoffice': {
|
||||
dev: 'http://localhost:8083',
|
||||
prod: 'http://localhost:8083'
|
||||
},
|
||||
'penpot': {
|
||||
dev: 'http://localhost:9001',
|
||||
prod: 'http://localhost:9001'
|
||||
}
|
||||
}
|
||||
|
||||
if (appUrls[id]) {
|
||||
const url = isDev ? appUrls[id].dev : appUrls[id].prod
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
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) {
|
||||
// In development, just alert - in production would open the actual interface
|
||||
alert(`Would launch ${pkg.value.manifest.title} interface`)
|
||||
}
|
||||
}
|
||||
|
||||
async function startApp() {
|
||||
try {
|
||||
await store.startPackage(appId.value)
|
||||
} catch (err) {
|
||||
console.error('Failed to start app:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function stopApp() {
|
||||
try {
|
||||
await store.stopPackage(appId.value)
|
||||
} catch (err) {
|
||||
console.error('Failed to stop app:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function restartApp() {
|
||||
try {
|
||||
await store.restartPackage(appId.value)
|
||||
} catch (err) {
|
||||
console.error('Failed to restart app:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function showUninstallModal() {
|
||||
if (!pkg.value) return
|
||||
uninstallModal.value = {
|
||||
show: true,
|
||||
appTitle: pkg.value.manifest.title
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmUninstall() {
|
||||
uninstallModal.value.show = false
|
||||
|
||||
try {
|
||||
await store.uninstallPackage(appId.value)
|
||||
// Navigate back to apps after uninstall
|
||||
router.push('/dashboard/apps')
|
||||
} catch (err) {
|
||||
console.error('Failed to uninstall app:', err)
|
||||
alert('Failed to uninstall app')
|
||||
}
|
||||
}
|
||||
|
||||
// Keep for backwards compatibility but redirect to modal
|
||||
async function uninstallApp() {
|
||||
showUninstallModal()
|
||||
}
|
||||
|
||||
function getStatusClass(state: PackageState): string {
|
||||
switch (state) {
|
||||
case PackageState.Running:
|
||||
return 'bg-green-500/20 text-green-200 border border-green-500/30'
|
||||
case PackageState.Stopped:
|
||||
return 'bg-gray-500/20 text-gray-200 border border-gray-500/30'
|
||||
case PackageState.Starting:
|
||||
case PackageState.Stopping:
|
||||
case PackageState.Restarting:
|
||||
return 'bg-yellow-500/20 text-yellow-200 border border-yellow-500/30'
|
||||
case PackageState.Installing:
|
||||
return 'bg-blue-500/20 text-blue-200 border border-blue-500/30'
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-200 border border-gray-500/30'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusDotClass(state: PackageState): string {
|
||||
switch (state) {
|
||||
case PackageState.Running:
|
||||
return 'bg-green-400'
|
||||
case PackageState.Stopped:
|
||||
return 'bg-gray-400'
|
||||
case PackageState.Starting:
|
||||
case PackageState.Stopping:
|
||||
case PackageState.Restarting:
|
||||
return 'bg-yellow-400 animate-pulse'
|
||||
case PackageState.Installing:
|
||||
return 'bg-blue-400 animate-pulse'
|
||||
default:
|
||||
return 'bg-gray-400'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-active .glass-card,
|
||||
.modal-leave-active .glass-card {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-from .glass-card,
|
||||
.modal-leave-to .glass-card {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,407 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">My Apps</h1>
|
||||
<p class="text-white/70">Manage your installed applications</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State - This should never show since we always show dummy apps -->
|
||||
<div v-if="false" class="text-center py-16 pb-6">
|
||||
<div class="glass-card p-12 max-w-md mx-auto">
|
||||
<svg class="w-16 h-16 mx-auto text-white/40 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">No Apps Installed</h3>
|
||||
<p class="text-white/70 mb-6">Get started by browsing the app store</p>
|
||||
<RouterLink
|
||||
to="/dashboard/marketplace"
|
||||
class="inline-block glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30"
|
||||
>
|
||||
Browse App Store
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apps Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
|
||||
<div
|
||||
v-for="(pkg, id) in packages"
|
||||
:key="id"
|
||||
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative"
|
||||
@click="goToApp(id as string)"
|
||||
>
|
||||
<!-- Uninstall Icon -->
|
||||
<button
|
||||
@click.stop="showUninstallModal(id as string, pkg)"
|
||||
class="absolute top-4 right-4 p-2 rounded-lg text-white/60 hover:text-red-400 hover:bg-red-500/20 transition-colors z-10"
|
||||
title="Uninstall"
|
||||
>
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<img
|
||||
:src="pkg['static-files'].icon"
|
||||
:alt="pkg.manifest.title"
|
||||
class="w-16 h-16 rounded-lg object-cover bg-white/10"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-white mb-1 truncate">
|
||||
{{ pkg.manifest.title }}
|
||||
</h3>
|
||||
<p class="text-sm text-white/70 mb-2 truncate">
|
||||
{{ pkg.manifest.description.short }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium"
|
||||
:class="getStatusClass(pkg.state)"
|
||||
>
|
||||
{{ pkg.state }}
|
||||
</span>
|
||||
<span class="text-xs text-white/50">
|
||||
v{{ pkg.manifest.version }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mt-4 flex gap-2">
|
||||
<button
|
||||
v-if="canLaunch(pkg)"
|
||||
@click.stop="launchApp(id as string)"
|
||||
class="flex-1 px-4 py-2 gradient-button rounded-lg text-sm font-medium"
|
||||
>
|
||||
Launch
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'stopped'"
|
||||
@click.stop="startApp(id as string)"
|
||||
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'running'"
|
||||
@click.stop="stopApp(id as string)"
|
||||
class="flex-1 px-4 py-2 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uninstall Confirmation Modal -->
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="uninstallModal.show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@click="uninstallModal.show = false"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div
|
||||
@click.stop
|
||||
class="glass-card p-6 max-w-md w-full relative z-10"
|
||||
>
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="p-3 bg-red-500/20 rounded-lg">
|
||||
<svg class="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Uninstall App?</h3>
|
||||
<p class="text-white/70">
|
||||
Are you sure you want to uninstall <span class="text-white font-medium">{{ uninstallModal.appTitle }}</span>?
|
||||
This will remove the app and stop its container.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
@click="uninstallModal.show = false"
|
||||
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirmUninstall"
|
||||
class="px-4 py-2 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { PackageState } from '../types/api'
|
||||
import { dummyApps } from '../utils/dummyApps'
|
||||
import { fetchMultipleAppInfo } from '../utils/githubAppInfo'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
|
||||
// TEMPORARY: Always show dummy apps for now (until real apps are ready)
|
||||
// TODO: Remove this and use real packages when they're available
|
||||
const packages = computed(() => {
|
||||
const realPackages = store.packages
|
||||
const packageKeys = realPackages ? Object.keys(realPackages) : []
|
||||
|
||||
console.log('[Apps] Real packages from store:', packageKeys.length, 'apps:', packageKeys)
|
||||
console.log('[Apps] Dummy apps available:', Object.keys(dummyApps).length, 'apps:', Object.keys(dummyApps))
|
||||
|
||||
// FOR NOW: Always return dummy apps regardless of what's in store
|
||||
// This ensures all dummy apps show up for development
|
||||
console.log('[Apps] Returning dummy apps')
|
||||
return dummyApps
|
||||
|
||||
// TODO: Uncomment this when ready to use real packages
|
||||
// if (packageKeys.length === 0) {
|
||||
// return dummyApps
|
||||
// }
|
||||
// return realPackages
|
||||
})
|
||||
|
||||
const uninstallModal = ref({
|
||||
show: false,
|
||||
appId: '',
|
||||
appTitle: ''
|
||||
})
|
||||
|
||||
function canLaunch(pkg: any): boolean {
|
||||
// For dummy apps, allow launch if running (they have interface addresses)
|
||||
// For real apps, check for UI interface
|
||||
const hasUI = pkg.manifest.interfaces?.main?.ui || pkg.installed?.['interface-addresses']?.main
|
||||
const isRunning = pkg.state === 'running'
|
||||
return hasUI && isRunning
|
||||
}
|
||||
|
||||
function launchApp(id: string) {
|
||||
const isDev = import.meta.env.DEV
|
||||
|
||||
// Special handling for apps with Docker containers
|
||||
const appUrls: Record<string, { dev: string, prod: string }> = {
|
||||
'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]) {
|
||||
const url = isDev ? appUrls[id].dev : appUrls[id].prod
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
// For other apps, navigate to app details which has launch functionality
|
||||
router.push(`/dashboard/apps/${id}`)
|
||||
}
|
||||
|
||||
function getStatusClass(state: PackageState): string {
|
||||
switch (state) {
|
||||
case PackageState.Running:
|
||||
return 'bg-green-500/20 text-green-200'
|
||||
case PackageState.Stopped:
|
||||
return 'bg-gray-500/20 text-gray-200'
|
||||
case PackageState.Starting:
|
||||
case PackageState.Stopping:
|
||||
case PackageState.Restarting:
|
||||
return 'bg-yellow-500/20 text-yellow-200'
|
||||
case PackageState.Installing:
|
||||
return 'bg-blue-500/20 text-blue-200'
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
function goToApp(id: string) {
|
||||
router.push(`/dashboard/apps/${id}`)
|
||||
}
|
||||
|
||||
async function startApp(id: string) {
|
||||
try {
|
||||
await store.startPackage(id)
|
||||
} catch (err) {
|
||||
console.error('Failed to start app:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function stopApp(id: string) {
|
||||
try {
|
||||
await store.stopPackage(id)
|
||||
} catch (err) {
|
||||
console.error('Failed to stop app:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore - Function kept for future use
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async function _restartApp(_id: string) {
|
||||
try {
|
||||
await store.restartPackage(_id)
|
||||
} catch (err) {
|
||||
console.error('Failed to restart app:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function showUninstallModal(id: string, pkg: any) {
|
||||
uninstallModal.value = {
|
||||
show: true,
|
||||
appId: id,
|
||||
appTitle: pkg.manifest.title
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmUninstall() {
|
||||
const { appId } = uninstallModal.value
|
||||
uninstallModal.value.show = false
|
||||
|
||||
try {
|
||||
await store.uninstallPackage(appId)
|
||||
} catch (err) {
|
||||
console.error('Failed to uninstall app:', err)
|
||||
alert('Failed to uninstall app')
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageError(e: Event) {
|
||||
const target = e.target as HTMLImageElement
|
||||
const currentSrc = target.src
|
||||
|
||||
// Try fallback icon - use a simple placeholder SVG
|
||||
// Create a data URI for a simple icon placeholder
|
||||
const placeholderSvg = `data:image/svg+xml,${encodeURIComponent(`
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="12" fill="rgba(255,255,255,0.1)"/>
|
||||
<path d="M32 20L40 28H36V40H28V28H24L32 20Z" fill="rgba(255,255,255,0.6)"/>
|
||||
<path d="M20 44H44V48H20V44Z" fill="rgba(255,255,255,0.4)"/>
|
||||
</svg>
|
||||
`)}`
|
||||
|
||||
// Only set fallback if we haven't already tried it
|
||||
if (!currentSrc.includes('data:image')) {
|
||||
target.src = placeholderSvg
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch GitHub app info for dummy apps on mount
|
||||
const appInfoCache = ref<Record<string, any>>({})
|
||||
|
||||
// Watch for packages and fetch app info when showing dummy apps
|
||||
watch(() => Object.keys(store.packages).length, async (packageCount) => {
|
||||
// Only fetch if we're showing dummy apps (no real packages)
|
||||
if (packageCount === 0) {
|
||||
try {
|
||||
// First try Start9 registry for icons
|
||||
console.log('[Apps] Fetching app info from Start9 registry...')
|
||||
try {
|
||||
const registryResponse = await fetch('https://registry.start9.com/api/v1/packages')
|
||||
if (registryResponse.ok) {
|
||||
const registryData = await registryResponse.json()
|
||||
|
||||
// Update dummy apps with registry data
|
||||
Object.entries(registryData).forEach(([id, pkg]: [string, any]) => {
|
||||
if (dummyApps[id]) {
|
||||
const latestVersion = pkg.versions ? Object.keys(pkg.versions).sort().reverse()[0] : null
|
||||
const versionData = latestVersion ? pkg.versions[latestVersion] : {}
|
||||
|
||||
// Update icon from registry
|
||||
if (versionData.icon) {
|
||||
dummyApps[id]['static-files'].icon = versionData.icon
|
||||
} else if (pkg.icon) {
|
||||
dummyApps[id]['static-files'].icon = pkg.icon
|
||||
}
|
||||
|
||||
// Update description
|
||||
if (versionData.description) {
|
||||
const desc = typeof versionData.description === 'string'
|
||||
? versionData.description
|
||||
: versionData.description.short || versionData.description.long || ''
|
||||
if (desc) {
|
||||
dummyApps[id].manifest.description.short = desc.substring(0, 100)
|
||||
if (!dummyApps[id].manifest.description.long) {
|
||||
dummyApps[id].manifest.description.long = desc
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[Apps] Updated apps from Start9 registry')
|
||||
return
|
||||
}
|
||||
} catch (registryErr) {
|
||||
console.warn('[Apps] Start9 registry unavailable, trying GitHub...', registryErr)
|
||||
}
|
||||
|
||||
// Fallback to GitHub fetching
|
||||
const appsToFetch = Object.entries(dummyApps).map(([id, pkg]) => ({
|
||||
id,
|
||||
'wrapper-repo': pkg.manifest['wrapper-repo']
|
||||
}))
|
||||
|
||||
console.log('[Apps] Fetching GitHub info for dummy apps...')
|
||||
const githubInfo = await fetchMultipleAppInfo(appsToFetch)
|
||||
appInfoCache.value = githubInfo
|
||||
|
||||
// Update dummy apps with fetched info
|
||||
Object.entries(githubInfo).forEach(([id, info]) => {
|
||||
if (dummyApps[id] && info.icon) {
|
||||
dummyApps[id]['static-files'].icon = info.icon
|
||||
}
|
||||
if (dummyApps[id] && info.description) {
|
||||
dummyApps[id].manifest.description.short = info.description.substring(0, 100)
|
||||
if (!dummyApps[id].manifest.description.long) {
|
||||
dummyApps[id].manifest.description.long = info.description
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[Apps] GitHub info fetched:', Object.keys(githubInfo).length, 'apps')
|
||||
} catch (err) {
|
||||
console.error('[Apps] Failed to fetch app info:', err)
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-active .glass-card,
|
||||
.modal-leave-active .glass-card {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-from .glass-card,
|
||||
.modal-leave-to .glass-card {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Cloud</h1>
|
||||
<p class="text-white/70">Cloud services and storage</p>
|
||||
</div>
|
||||
|
||||
<!-- Folders Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="folder in folders"
|
||||
:key="folder.id"
|
||||
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
|
||||
@click="openFolder(folder.id)"
|
||||
>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-12 h-12 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-for="(path, index) in getFolderIcon(folder.type)"
|
||||
:key="index"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="path"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-white mb-1 truncate">
|
||||
{{ folder.name }}
|
||||
</h3>
|
||||
<p class="text-sm text-white/60">
|
||||
{{ folder.itemCount }} {{ folder.itemCount === 1 ? 'item' : 'items' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
interface Folder {
|
||||
id: string
|
||||
name: string
|
||||
type: 'pictures' | 'videos' | 'music' | 'documents' | 'downloads'
|
||||
itemCount: number
|
||||
}
|
||||
|
||||
const folders = ref<Folder[]>([
|
||||
{
|
||||
id: 'pictures',
|
||||
name: 'Pictures',
|
||||
type: 'pictures',
|
||||
itemCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'videos',
|
||||
name: 'Videos',
|
||||
type: 'videos',
|
||||
itemCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'music',
|
||||
name: 'Music',
|
||||
type: 'music',
|
||||
itemCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'documents',
|
||||
name: 'Documents',
|
||||
type: 'documents',
|
||||
itemCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'downloads',
|
||||
name: 'Downloads',
|
||||
type: 'downloads',
|
||||
itemCount: 0,
|
||||
},
|
||||
])
|
||||
|
||||
function getFolderIcon(type: string): string[] {
|
||||
const icons: Record<string, string[]> = {
|
||||
pictures: ['M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'],
|
||||
videos: ['M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z'],
|
||||
music: ['M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3'],
|
||||
documents: ['M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'],
|
||||
downloads: ['M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4'],
|
||||
}
|
||||
return icons[type] || ['M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z']
|
||||
}
|
||||
|
||||
function openFolder(folderId: string) {
|
||||
router.push({
|
||||
name: 'cloud-folder',
|
||||
params: { folderId }
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="cloud-folder-container pb-16 md:pb-16">
|
||||
<!-- Desktop Back Button -->
|
||||
<button @click="goBack" class="hidden md:flex mb-6 items-center gap-2 text-white/70 hover:text-white transition-colors">
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Cloud
|
||||
</button>
|
||||
|
||||
<!-- Mobile Full-Width Back Button -->
|
||||
<button
|
||||
@click="goBack"
|
||||
class="md:hidden fixed left-4 right-4 z-40 glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
|
||||
:style="{
|
||||
bottom: bottomPosition,
|
||||
filter: 'drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5))'
|
||||
}"
|
||||
>
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back to Cloud</span>
|
||||
</button>
|
||||
|
||||
<!-- Folder Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-16 h-16 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-for="(path, index) in getFolderIcon(folderType)"
|
||||
:key="index"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="path"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ folderName }}</h1>
|
||||
<p class="text-white/70">{{ items.length }} {{ items.length === 1 ? 'item' : 'items' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="items.length === 0" class="glass-card p-12 text-center">
|
||||
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-for="(path, index) in getFolderIcon(folderType)"
|
||||
:key="index"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="path"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-2xl font-semibold text-white mb-2">No items</h3>
|
||||
<p class="text-white/70">This folder is empty</p>
|
||||
</div>
|
||||
|
||||
<!-- Items Grid -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="glass-card p-4 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
|
||||
@click="openItem(item)"
|
||||
>
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="mb-3">
|
||||
<svg class="w-12 h-12 text-white/60 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-for="(path, index) in getItemIcon(folderType)"
|
||||
:key="index"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
:d="path"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-sm font-medium text-white mb-1 truncate w-full">
|
||||
{{ item.name }}
|
||||
</h3>
|
||||
<p class="text-xs text-white/60">
|
||||
{{ formatSize(item.size) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spacer for mobile back button -->
|
||||
<div class="md:hidden h-[calc(var(--mobile-tab-bar-height,_64px)+96px)]"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
||||
|
||||
const { bottomPosition } = useMobileBackButton()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const folderId = computed(() => route.params.folderId as string)
|
||||
|
||||
const folderNames: Record<string, string> = {
|
||||
pictures: 'Pictures',
|
||||
videos: 'Videos',
|
||||
music: 'Music',
|
||||
documents: 'Documents',
|
||||
downloads: 'Downloads',
|
||||
}
|
||||
|
||||
const folderName = computed(() => folderNames[folderId.value] || 'Folder')
|
||||
const folderType = computed(() => folderId.value as string)
|
||||
|
||||
// Dummy items for each folder type
|
||||
const items = ref<any[]>([])
|
||||
|
||||
function getFolderIcon(type: string): string[] {
|
||||
const icons: Record<string, string[]> = {
|
||||
pictures: ['M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'],
|
||||
videos: ['M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z'],
|
||||
music: ['M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3'],
|
||||
documents: ['M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'],
|
||||
downloads: ['M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4'],
|
||||
}
|
||||
return icons[type] || ['M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z']
|
||||
}
|
||||
|
||||
function getItemIcon(type: string): string[] {
|
||||
const icons: Record<string, string[]> = {
|
||||
pictures: ['M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'],
|
||||
videos: ['M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z'],
|
||||
music: ['M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3'],
|
||||
documents: ['M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'],
|
||||
downloads: ['M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4'],
|
||||
}
|
||||
return icons[type] || ['M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z']
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
function openItem(item: any) {
|
||||
console.log('Opening item:', item)
|
||||
// TODO: Implement item opening logic
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/dashboard/cloud')
|
||||
}
|
||||
|
||||
// Generate dummy items based on folder type
|
||||
onMounted(() => {
|
||||
// For now, we'll leave items empty to show the empty state
|
||||
// In the future, this would fetch real items from the backend
|
||||
items.value = []
|
||||
})
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-8 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">Welcome Noderunner</h1>
|
||||
<p class="text-white/80">Here's an overview of your sovereign life</p>
|
||||
</div>
|
||||
<!-- Compact Status Indicator -->
|
||||
<div class="flex items-center gap-2 px-4 py-2 glass-card rounded-lg">
|
||||
<div class="relative">
|
||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
<div class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-white">Online</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Overviews -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- My Apps Overview -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">My Apps</h2>
|
||||
<p class="text-sm text-white/70">Manage your installed applications</p>
|
||||
</div>
|
||||
<RouterLink
|
||||
to="/dashboard/apps"
|
||||
class="text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Installed</p>
|
||||
<p class="text-2xl font-bold text-white">{{ appCount }}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Running</p>
|
||||
<p class="text-2xl font-bold text-white">{{ runningCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<RouterLink
|
||||
to="/dashboard/marketplace"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
>
|
||||
Browse Store
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/apps"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
>
|
||||
Manage Apps
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cloud Overview -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Cloud</h2>
|
||||
<p class="text-sm text-white/70">Cloud services and storage</p>
|
||||
</div>
|
||||
<RouterLink
|
||||
to="/dashboard/cloud"
|
||||
class="text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Storage Used</p>
|
||||
<p class="text-2xl font-bold text-white">2.4 GB</p>
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Folders</p>
|
||||
<p class="text-2xl font-bold text-white">5</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<RouterLink
|
||||
to="/dashboard/cloud"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
>
|
||||
View Folders
|
||||
</RouterLink>
|
||||
<button
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors"
|
||||
@click="() => {}"
|
||||
>
|
||||
Upload Files
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Overview -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Network</h2>
|
||||
<p class="text-sm text-white/70">Network infrastructure and Web3 services</p>
|
||||
</div>
|
||||
<RouterLink
|
||||
to="/dashboard/server"
|
||||
class="text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-4">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
<span class="text-sm text-white/80">Services Status</span>
|
||||
</div>
|
||||
<span class="text-sm text-green-400 font-medium">All Running</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
<span class="text-sm text-white/80">Connectivity</span>
|
||||
</div>
|
||||
<span class="text-sm text-green-400 font-medium">Connected</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-blue-400"></div>
|
||||
<span class="text-sm text-white/80">Connected Nodes</span>
|
||||
</div>
|
||||
<span class="text-sm text-white/80 font-medium">12</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<RouterLink
|
||||
to="/dashboard/server"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
>
|
||||
Manage Network
|
||||
</RouterLink>
|
||||
<button
|
||||
class="px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors"
|
||||
@click="() => {}"
|
||||
>
|
||||
<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="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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Web5 Overview -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Web5</h2>
|
||||
<p class="text-sm text-white/70">Decentralized identity and data protocols</p>
|
||||
</div>
|
||||
<RouterLink
|
||||
to="/dashboard/web5"
|
||||
class="text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-4">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
<span class="text-sm text-white/80">DID Status</span>
|
||||
</div>
|
||||
<span class="text-sm text-green-400 font-medium">Active</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
<span class="text-sm text-white/80">DWN Sync</span>
|
||||
</div>
|
||||
<span class="text-sm text-green-400 font-medium">Synced</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg text-orange-500 font-bold">₿</span>
|
||||
<span class="text-sm text-white/80">Networking Profits</span>
|
||||
</div>
|
||||
<span class="text-sm text-orange-500 font-medium">₿0.024</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<RouterLink
|
||||
to="/dashboard/web5"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
>
|
||||
Manage Web5
|
||||
</RouterLink>
|
||||
<button
|
||||
class="px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors"
|
||||
@click="() => {}"
|
||||
>
|
||||
<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="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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions - Hidden for now -->
|
||||
<!--
|
||||
<div class="path-option-card cursor-default px-6 py-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-4">Quick Actions</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<RouterLink
|
||||
to="/dashboard/marketplace"
|
||||
class="path-action-button path-action-button--continue flex items-center justify-center gap-3"
|
||||
>
|
||||
<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="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
|
||||
</svg>
|
||||
<span>Browse App Store</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
to="/dashboard/apps"
|
||||
class="path-action-button path-action-button--continue flex items-center justify-center gap-3"
|
||||
>
|
||||
<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="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
<span>Manage My Apps</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
to="/dashboard/server"
|
||||
class="path-action-button path-action-button--continue flex items-center justify-center gap-3"
|
||||
>
|
||||
<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="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
<span>Server Settings</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { PackageState } from '../types/api'
|
||||
|
||||
const store = useAppStore()
|
||||
|
||||
// @ts-ignore - Computed kept for future use
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const version = computed(() => store.serverInfo?.version || '0.0.0')
|
||||
const packages = computed(() => store.packages)
|
||||
const appCount = computed(() => Object.keys(packages.value).length)
|
||||
const runningCount = computed(() =>
|
||||
Object.values(packages.value).filter(pkg => pkg.state === PackageState.Running).length
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-4 relative z-10">
|
||||
<div class="w-full max-w-md relative z-20">
|
||||
<!-- Login Card -->
|
||||
<div class="glass-card p-8 pt-20 relative login-card overflow-visible">
|
||||
<!-- Logo - half in, half out of container -->
|
||||
<div class="absolute -top-10 left-1/2 -translate-x-1/2 z-10">
|
||||
<div class="logo-gradient-border">
|
||||
<img
|
||||
src="/assets/img/favico.svg"
|
||||
alt="Archipelago"
|
||||
class="w-20 h-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="text-2xl font-semibold text-white/96 text-center mb-8 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
<span v-if="isSetupMode && !isSetup">Set Up Your Node</span>
|
||||
<span v-else>Welcome to Archipelago</span>
|
||||
</h1>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" class="mb-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg text-red-200 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Setup Mode: Password Setup -->
|
||||
<template v-if="isSetupMode && !isSetup">
|
||||
<div class="mb-4 p-4 bg-white/5 border border-white/10 rounded-lg text-white/80 text-sm">
|
||||
<p class="mb-2">Create a password to secure your Archipelago node.</p>
|
||||
<p class="text-white/60 text-xs">This password will be required to access your node.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="password" class="block text-sm font-medium text-white/80 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
placeholder="Enter a password (min 8 characters)"
|
||||
@keyup.enter="handleSetup"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="confirmPassword" class="block text-sm font-medium text-white/80 mb-2">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
placeholder="Confirm your password"
|
||||
@keyup.enter="handleSetup"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleSetup"
|
||||
:disabled="loading || !password || password !== confirmPassword"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="!loading">Set Up Node</span>
|
||||
<span v-else class="flex items-center justify-center">
|
||||
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
Setting up...
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Normal Login Mode -->
|
||||
<template v-else>
|
||||
<div class="mb-6">
|
||||
<label for="password" class="block text-sm font-medium text-white/80 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
placeholder="Enter your password"
|
||||
@keyup.enter="handleLogin"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleLogin"
|
||||
:disabled="loading || !password"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="!loading">Login</span>
|
||||
<span v-else class="flex items-center justify-center">
|
||||
<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
Logging in...
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Footer Links -->
|
||||
<div class="mt-6 text-center text-sm text-white/60">
|
||||
<a href="#" class="hover:text-white/80 transition-colors">Forgot password?</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replay Intro - Bottom of Page -->
|
||||
<div class="mt-8 text-center">
|
||||
<button
|
||||
@click="replayIntro"
|
||||
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline"
|
||||
>
|
||||
Replay Intro
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isSetup = ref(false)
|
||||
|
||||
// Check if we're in setup mode (original StartOS node setup)
|
||||
const isSetupMode = computed(() => {
|
||||
return import.meta.env.VITE_DEV_MODE === 'setup'
|
||||
})
|
||||
|
||||
// Check if node is already set up
|
||||
onMounted(async () => {
|
||||
if (isSetupMode.value) {
|
||||
try {
|
||||
const result = await rpcClient.call({ method: 'auth.isSetup', params: {} })
|
||||
isSetup.value = result?.result || false
|
||||
} catch (err) {
|
||||
console.error('Failed to check setup status:', err)
|
||||
// Assume not set up if check fails
|
||||
isSetup.value = false
|
||||
}
|
||||
} else {
|
||||
// Not in setup mode, assume already set up
|
||||
isSetup.value = true
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSetup() {
|
||||
if (!password.value || password.value.length < 8) {
|
||||
error.value = 'Password must be at least 8 characters'
|
||||
return
|
||||
}
|
||||
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = 'Passwords do not match'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await rpcClient.call({
|
||||
method: 'auth.setup',
|
||||
params: { password: password.value }
|
||||
})
|
||||
|
||||
// After setup, automatically log in
|
||||
await store.login(password.value)
|
||||
router.push('/dashboard')
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Setup failed. Please try again.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
if (!password.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await store.login(password.value)
|
||||
router.push('/dashboard')
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Login failed. Please check your password.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function replayIntro() {
|
||||
// Clear the intro seen flag
|
||||
localStorage.removeItem('neode_intro_seen')
|
||||
// Navigate to root to trigger splash screen
|
||||
window.location.href = '/'
|
||||
}
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,529 @@
|
||||
<template>
|
||||
<div class="app-details-container pb-16 md:pb-16">
|
||||
<!-- Desktop Back Button -->
|
||||
<button @click="goBack" class="hidden md:flex mb-6 items-center gap-2 text-white/70 hover:text-white transition-colors">
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to App Store
|
||||
</button>
|
||||
|
||||
<!-- Mobile Full-Width Back Button -->
|
||||
<button
|
||||
@click="goBack"
|
||||
class="md:hidden fixed left-4 right-4 z-40 glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
|
||||
:style="{
|
||||
bottom: bottomPosition,
|
||||
filter: 'drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5))'
|
||||
}"
|
||||
>
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back to App Store</span>
|
||||
</button>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="glass-card p-12 text-center">
|
||||
<svg class="animate-spin h-12 w-12 text-blue-400 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
<p class="text-white/70">Loading app details...</p>
|
||||
</div>
|
||||
|
||||
<!-- App Details -->
|
||||
<div v-else-if="app">
|
||||
<!-- Compact Hero Section -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<!-- Desktop: Single Row Layout -->
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<!-- App Icon -->
|
||||
<img
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="w-20 h-20 rounded-xl bg-white/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- App Info (grows to fill space) -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-2xl font-bold text-white mb-1">{{ app.title }}</h1>
|
||||
<p class="text-white/70 text-sm mb-2">{{ shortDescription }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="isInstalled"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-green-500/20 text-green-200 border border-green-500/30"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1.5"></span>
|
||||
Installed
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">{{ app.version ? `v${app.version}` : 'latest' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
v-if="isInstalled"
|
||||
@click="goToInstalledApp"
|
||||
class="gradient-button px-6 py-2.5 rounded-lg text-sm font-semibold 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
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="installApp"
|
||||
:disabled="installing || !app.manifestUrl"
|
||||
class="gradient-button px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
<svg v-else 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ installing ? 'Installing...' : 'Install' }}
|
||||
</button>
|
||||
<a
|
||||
v-if="app.repoUrl"
|
||||
:href="app.repoUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.840 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
Source
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Two Column Grid Layout -->
|
||||
<div class="md:hidden">
|
||||
<!-- Top: Icon + Info -->
|
||||
<div class="grid grid-cols-[80px_1fr] gap-4 mb-4">
|
||||
<!-- App Icon -->
|
||||
<img
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="w-20 h-20 rounded-xl shadow-xl"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="w-20 h-20 rounded-xl bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- App Info -->
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-xl font-bold text-white mb-1">{{ app.title }}</h1>
|
||||
<p class="text-white/70 text-xs mb-2 line-clamp-2">{{ shortDescription }}</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
v-if="isInstalled"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-200 border border-green-500/30"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1"></span>
|
||||
Installed
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">{{ app.version ? `v${app.version}` : 'latest' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: Action Buttons -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
v-if="isInstalled"
|
||||
@click="goToInstalledApp"
|
||||
class="gradient-button px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-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
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="installApp"
|
||||
:disabled="installing || !app.manifestUrl"
|
||||
class="gradient-button px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
|
||||
>
|
||||
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
<svg v-else 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ installing ? 'Installing...' : 'Install' }}
|
||||
</button>
|
||||
<a
|
||||
v-if="app.repoUrl"
|
||||
:href="app.repoUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:class="isInstalled ? 'col-span-1' : 'col-span-2'"
|
||||
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.840 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
Source
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Installation Error Banner (Mobile) -->
|
||||
<div v-if="installError" class="mt-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg">
|
||||
<div class="flex items-start gap-2">
|
||||
<svg class="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-red-200 font-medium text-sm">Installation Failed</p>
|
||||
<p class="text-red-300 text-xs mt-1">{{ installError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Installation Error Banner (Desktop) -->
|
||||
<div v-if="installError" class="hidden md:block mt-4 p-4 bg-red-500/20 border border-red-500/40 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-red-200 font-medium">Installation Failed</p>
|
||||
<p class="text-red-300 text-sm mt-1">{{ installError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Main Content -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Screenshots Gallery -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Screenshots</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
class="aspect-video rounded-xl bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg class="w-16 h-16 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-white/60 text-sm mt-3 text-center">Screenshot placeholders - images coming soon</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">About {{ app.title }}</h2>
|
||||
<p class="text-white/80 leading-relaxed whitespace-pre-line">
|
||||
{{ longDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Features</h2>
|
||||
<ul class="space-y-3">
|
||||
<li
|
||||
v-for="(feature, index) in features"
|
||||
:key="index"
|
||||
class="flex items-start gap-3 text-white/80"
|
||||
>
|
||||
<svg class="w-6 h-6 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- App Info Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Information</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Version</span>
|
||||
<span class="text-white font-medium">{{ app.version || 'latest' }}</span>
|
||||
</div>
|
||||
<div v-if="app.author" class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Developer</span>
|
||||
<span class="text-white font-medium">{{ app.author }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Status</span>
|
||||
<span class="text-white font-medium">{{ isInstalled ? 'Installed' : 'Not Installed' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-white/10">
|
||||
<span class="text-white/60 text-sm">Category</span>
|
||||
<span class="text-white font-medium capitalize">{{ app.category || 'App' }}</span>
|
||||
</div>
|
||||
<div v-if="app.manifestUrl" class="flex items-center justify-between py-2">
|
||||
<span class="text-white/60 text-sm">Package</span>
|
||||
<span class="text-white font-medium text-xs">.s9pk</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requirements Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Requirements</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">RAM</p>
|
||||
<p class="text-white/60 text-sm">Minimum 512MB</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-purple-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-white/80 font-medium">Storage</p>
|
||||
<p class="text-white/60 text-sm">~100MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Links Card -->
|
||||
<div v-if="app.repoUrl || app.manifestUrl" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Links</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
v-if="app.repoUrl"
|
||||
:href="app.repoUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.840 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
GitHub Repository
|
||||
</a>
|
||||
<a
|
||||
v-if="app.manifestUrl"
|
||||
:href="app.manifestUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Download Package
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Not Found -->
|
||||
<div v-else class="glass-card p-12 text-center">
|
||||
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="text-2xl font-semibold text-white mb-2">App Not Found</h3>
|
||||
<p class="text-white/70">The requested application could not be found in the marketplace</p>
|
||||
</div>
|
||||
|
||||
<!-- Spacer for mobile back button -->
|
||||
<div class="md:hidden h-[calc(var(--mobile-tab-bar-height,_64px)+96px)]"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { useMarketplaceApp } from '../composables/useMarketplaceApp'
|
||||
import { useMobileBackButton } from '../composables/useMobileBackButton'
|
||||
|
||||
const { bottomPosition } = useMobileBackButton()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const { getCurrentApp } = useMarketplaceApp()
|
||||
|
||||
const app = ref<any>(null)
|
||||
const installing = ref(false)
|
||||
const installError = ref<string | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
|
||||
// Check if app is already installed
|
||||
const isInstalled = computed(() => {
|
||||
return !!store.packages[appId.value]
|
||||
})
|
||||
|
||||
// Extract descriptions with safety checks
|
||||
const shortDescription = computed(() => {
|
||||
try {
|
||||
if (!app.value) return ''
|
||||
const desc = app.value.description
|
||||
if (typeof desc === 'object' && desc) {
|
||||
return desc.short || desc.long || ''
|
||||
}
|
||||
return desc || ''
|
||||
} catch (e) {
|
||||
console.error('[MarketplaceAppDetails] Error in shortDescription:', e)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const longDescription = computed(() => {
|
||||
try {
|
||||
if (!app.value) return ''
|
||||
const desc = app.value.description
|
||||
if (typeof desc === 'object' && desc) {
|
||||
return desc.long || desc.short || ''
|
||||
}
|
||||
return desc || ''
|
||||
} catch (e) {
|
||||
console.error('[MarketplaceAppDetails] Error in longDescription:', e)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
// Placeholder features
|
||||
const features = computed(() => {
|
||||
return [
|
||||
'Self-hosted and privacy-focused',
|
||||
'Easy installation and updates',
|
||||
'Automatic backups',
|
||||
'Secure by default',
|
||||
'Open source'
|
||||
]
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
console.log('[MarketplaceAppDetails] Loading app ID:', appId.value)
|
||||
|
||||
try {
|
||||
// Get app data from composable
|
||||
const loadedApp = getCurrentApp()
|
||||
|
||||
if (loadedApp && loadedApp.id === appId.value) {
|
||||
app.value = loadedApp
|
||||
console.log('[MarketplaceAppDetails] App loaded successfully:', app.value)
|
||||
loading.value = false
|
||||
} else {
|
||||
console.warn('[MarketplaceAppDetails] App data not found in composable')
|
||||
loading.value = false
|
||||
// Navigate back to marketplace
|
||||
setTimeout(() => {
|
||||
router.push('/dashboard/marketplace')
|
||||
}, 500)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[MarketplaceAppDetails] Error loading app data:', e)
|
||||
loading.value = false
|
||||
setTimeout(() => {
|
||||
router.push('/dashboard/marketplace')
|
||||
}, 500)
|
||||
}
|
||||
})
|
||||
|
||||
function handleImageError(e: Event) {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = '/assets/img/logo-archipelago.svg'
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/dashboard/marketplace')
|
||||
}
|
||||
|
||||
function goToInstalledApp() {
|
||||
router.push({
|
||||
path: `/dashboard/apps/${appId.value}`,
|
||||
query: { from: 'marketplace' }
|
||||
})
|
||||
}
|
||||
|
||||
async function installApp() {
|
||||
if (installing.value || !app.value?.manifestUrl) {
|
||||
console.warn('[MarketplaceAppDetails] Cannot install - no manifestUrl:', app.value)
|
||||
return
|
||||
}
|
||||
|
||||
installing.value = true
|
||||
installError.value = null
|
||||
|
||||
try {
|
||||
const installUrl = app.value.url || app.value.manifestUrl
|
||||
console.log('[MarketplaceAppDetails] Installing app:', {
|
||||
id: app.value.id,
|
||||
url: installUrl,
|
||||
version: app.value.version,
|
||||
source: app.value.source
|
||||
})
|
||||
|
||||
if (app.value.source === 'local') {
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: {
|
||||
id: app.value.id,
|
||||
url: installUrl,
|
||||
version: app.value.version,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Community marketplace app
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: {
|
||||
id: app.value.id,
|
||||
url: installUrl,
|
||||
version: app.value.version || 'latest',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Wait a moment for the package to be registered
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// Navigate to the installed app
|
||||
router.push(`/dashboard/apps/${appId.value}`)
|
||||
} catch (err: any) {
|
||||
installError.value = err.message || 'Installation failed. Please try again.'
|
||||
console.error('[MarketplaceAppDetails] Failed to install app:', err)
|
||||
} finally {
|
||||
installing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="h-screen flex items-center justify-center p-3 sm:p-4 md:p-6 overflow-hidden">
|
||||
<!-- Main Glass Container - Scrollable -->
|
||||
<div class="max-w-[800px] w-full relative z-10 path-glass-container max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] overflow-y-auto overflow-x-hidden">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-4 sm:mb-6 flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-2 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Backup Your Identity
|
||||
</h1>
|
||||
<p class="text-sm sm:text-base md:text-[20px] text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
||||
Create a secure backup of your identity. Set a passphrase and download your encrypted backup file.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex flex-col items-center gap-4 sm:gap-6 mb-4 sm:mb-6 px-3 sm:px-4">
|
||||
<div class="w-full max-w-[600px] space-y-4 sm:space-y-6">
|
||||
<!-- Passphrase Input -->
|
||||
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-6">
|
||||
<div class="text-left w-full">
|
||||
<label class="block text-xs sm:text-sm font-semibold text-white/80 mb-2 sm:mb-3 uppercase tracking-wide">
|
||||
Backup Passphrase
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
|
||||
<input
|
||||
v-model="passphrase"
|
||||
type="password"
|
||||
placeholder="Enter a strong passphrase"
|
||||
class="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-3 pl-12 text-white/95 placeholder-white/40 focus:outline-none focus:border-white/30 focus:bg-black/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs sm:text-sm md:text-base text-white/60 mt-2 sm:mt-3">
|
||||
Keep this passphrase safe. You'll need it to restore your identity from backup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download Button -->
|
||||
<button
|
||||
@click="downloadBackup"
|
||||
:disabled="!passphrase || isDownloading"
|
||||
class="path-action-button path-action-button--continue w-full"
|
||||
>
|
||||
<span v-if="!isDownloading && !downloaded">Download Backup</span>
|
||||
<span v-else-if="isDownloading" class="flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
Downloading...
|
||||
</span>
|
||||
<span v-else class="flex items-center justify-center gap-2">
|
||||
<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="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Downloaded
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div v-if="downloaded" class="text-center">
|
||||
<p class="text-sm text-white/70">
|
||||
Backup saved as <span class="font-mono text-white/90">neode-did-backup.json</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-3 sm:gap-4 max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6">
|
||||
<button
|
||||
@click="skipForNow"
|
||||
class="path-action-button path-action-button--skip"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
@click="proceed"
|
||||
:disabled="!passphrase"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const passphrase = ref('')
|
||||
const isDownloading = ref(false)
|
||||
const downloaded = ref(false)
|
||||
|
||||
async function downloadBackup() {
|
||||
if (!passphrase.value) return
|
||||
|
||||
isDownloading.value = true
|
||||
|
||||
// Simulate backup creation
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// Get DID from localStorage
|
||||
const didStateStr = localStorage.getItem('neode_did_state')
|
||||
const didState = didStateStr ? JSON.parse(didStateStr) : { did: 'did:key:unknown', kid: 'kid:mock' }
|
||||
|
||||
// Create backup data
|
||||
const backupData = {
|
||||
version: '1.0',
|
||||
did: didState.did,
|
||||
kid: didState.kid,
|
||||
encrypted: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
// In production, this would be properly encrypted with the passphrase
|
||||
note: 'This is a mock backup. In production, this would contain encrypted key material.'
|
||||
}
|
||||
|
||||
// Create and download file
|
||||
const blob = new Blob([JSON.stringify(backupData, null, 2)], { type: 'application/json' })
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = 'neode-did-backup.json'
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
|
||||
downloaded.value = true
|
||||
isDownloading.value = false
|
||||
|
||||
// Store passphrase hint (not the actual passphrase!)
|
||||
localStorage.setItem('neode_backup_created', '1')
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
router.push('/onboarding/verify')
|
||||
}
|
||||
|
||||
function skipForNow() {
|
||||
router.push('/onboarding/verify')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<!-- Main Glass Container -->
|
||||
<div class="max-w-[800px] w-full relative z-10 path-glass-container">
|
||||
<!-- Header -->
|
||||
<div v-if="!generatedDid" class="text-center flex-shrink-0">
|
||||
<h1 class="text-[26px] font-semibold text-white/96 mb-6 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Take control of your new identity
|
||||
</h1>
|
||||
<p class="text-[20px] text-white/75 leading-relaxed max-w-[600px] mx-auto mb-6">
|
||||
Generate a Decentralized Identifier (DID) for secure, passwordless authentication. Your identity, your control.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex flex-col items-center gap-6 mb-6">
|
||||
<!-- Generate Button (if no DID yet) -->
|
||||
<button
|
||||
v-if="!generatedDid"
|
||||
@click="generateDid"
|
||||
:disabled="isGenerating"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
<span v-if="!isGenerating">Generate DID</span>
|
||||
<span v-else class="flex items-center gap-2">
|
||||
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
Generating...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Generated DID Display -->
|
||||
<div v-if="generatedDid" class="w-full max-w-[600px] space-y-4">
|
||||
<!-- Success Message -->
|
||||
<div class="text-center mb-6">
|
||||
<div class="flex justify-center mb-6">
|
||||
<div class="path-option-card cursor-default w-20 h-20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-6">
|
||||
Your decentralized identifier has been generated
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- DID Display Card -->
|
||||
<div class="path-option-card cursor-default px-6 py-6">
|
||||
<div class="text-left">
|
||||
<h3 class="text-sm font-semibold text-white/80 mb-2 uppercase tracking-wide">Your DID</h3>
|
||||
<div class="bg-black/40 rounded-lg p-4 mb-3 backdrop-blur-sm border border-white/10">
|
||||
<p class="text-white/95 font-mono text-sm break-all leading-relaxed">
|
||||
{{ generatedDid }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-base text-white/60">
|
||||
This identifier is stored securely on your device
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-4 max-w-[600px] mx-auto flex-shrink-0">
|
||||
<button
|
||||
@click="skipForNow"
|
||||
class="path-action-button path-action-button--skip"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
v-if="generatedDid"
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const generatedDid = ref<string>('')
|
||||
const isGenerating = ref(false)
|
||||
|
||||
async function generateDid() {
|
||||
isGenerating.value = true
|
||||
|
||||
// Simulate DID generation (replace with actual implementation)
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
// Mock DID generation - in production, this would call the backend
|
||||
const mockDid = `did:key:z6Mk${generateRandomString(44)}`
|
||||
generatedDid.value = mockDid
|
||||
|
||||
// Store in localStorage
|
||||
localStorage.setItem('neode_did', mockDid)
|
||||
|
||||
isGenerating.value = false
|
||||
}
|
||||
|
||||
function generateRandomString(length: number): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
// Store DID state and continue to backup
|
||||
if (generatedDid.value) {
|
||||
localStorage.setItem('neode_did_state', JSON.stringify({
|
||||
did: generatedDid.value,
|
||||
kid: 'kid:mock'
|
||||
}))
|
||||
router.push('/onboarding/backup')
|
||||
}
|
||||
}
|
||||
|
||||
function skipForNow() {
|
||||
// Skip to backup screen
|
||||
router.push('/onboarding/backup')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="h-screen flex items-center justify-center p-3 sm:p-4 md:p-6 overflow-hidden">
|
||||
<!-- Main Glass Container - Scrollable -->
|
||||
<div class="max-w-[800px] w-full relative z-10 path-glass-container max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] overflow-y-auto overflow-x-hidden">
|
||||
<!-- Success Content -->
|
||||
<div class="text-center space-y-4 sm:space-y-6 px-3 sm:px-4 py-4 sm:py-6">
|
||||
<!-- Success Icon -->
|
||||
<div class="flex justify-center mb-4 sm:mb-6">
|
||||
<div class="path-option-card cursor-default w-16 h-16 sm:w-20 sm:h-20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-sm sm:text-base md:text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-6 sm:mb-8">
|
||||
Your sovereign identity is ready. You can now log in and start your journey as a noderunner.
|
||||
</p>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-4 mb-6 sm:mb-8 max-w-[700px] mx-auto">
|
||||
<div class="path-option-card cursor-default py-6">
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
|
||||
<h3 class="text-sm font-semibold text-white/90">Sovereign Identity</h3>
|
||||
</div>
|
||||
|
||||
<div class="path-option-card cursor-default py-6">
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<h3 class="text-sm font-semibold text-white/90">Encrypted Backup</h3>
|
||||
</div>
|
||||
|
||||
<div class="path-option-card cursor-default py-6">
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-white/80" 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>
|
||||
<h3 class="text-sm font-semibold text-white/90">Ready to Use</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Go to Login Button -->
|
||||
<button
|
||||
@click="goToLogin"
|
||||
class="path-action-button path-action-button--continue mx-auto"
|
||||
>
|
||||
Go to Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function goToLogin() {
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="max-w-2xl w-full">
|
||||
<div class="glass-card p-12 pt-20 text-center animate-fade-up relative overflow-visible">
|
||||
<!-- Logo - half in, half out of container -->
|
||||
<div class="absolute -top-[52px] left-1/2 -translate-x-1/2">
|
||||
<div class="logo-gradient-border">
|
||||
<img
|
||||
src="/assets/img/favico.svg"
|
||||
alt="Archipelago"
|
||||
class="w-20 h-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl font-bold text-white mb-4">
|
||||
Welcome to Archipelago
|
||||
</h1>
|
||||
|
||||
<p class="text-xl text-white/80 mb-12 max-w-2xl mx-auto">
|
||||
Your personal server for a sovereign digital life
|
||||
</p>
|
||||
|
||||
<button
|
||||
@click="goToOptions"
|
||||
class="glass-button px-8 py-4 rounded-lg text-lg font-medium transition-all hover:bg-black/70 hover:border-white/30"
|
||||
>
|
||||
Unlock your sovereignty →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function goToOptions() {
|
||||
router.push('/onboarding/path')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-4 overflow-x-hidden">
|
||||
<div class="max-w-6xl w-full overflow-x-hidden">
|
||||
<div class="text-center mb-8">
|
||||
<div class="logo-gradient-border inline-block mb-8">
|
||||
<img
|
||||
src="/assets/img/favico.svg"
|
||||
alt="Archipelago"
|
||||
class="w-20 h-20"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="text-4xl font-bold text-white mb-4">Choose Your Setup</h1>
|
||||
<p class="text-xl text-white/80">How would you like to get started?</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- Fresh Start -->
|
||||
<button
|
||||
@click="selectOption('fresh')"
|
||||
class="glass-card p-8 text-center transition-all hover:-translate-y-1 hover:shadow-glass"
|
||||
:class="{ 'bg-white/12 shadow-[0_12px_32px_rgba(0,0,0,0.6),0_0_30px_rgba(255,255,255,0.2)]': selected === 'fresh' }"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<div class="w-16 h-16 mx-auto bg-white/10 rounded-full flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-3">Fresh Start</h3>
|
||||
<p class="text-white/70 text-sm">
|
||||
Set up a new server from scratch
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<!-- Restore Backup -->
|
||||
<button
|
||||
@click="selectOption('restore')"
|
||||
class="glass-card p-8 text-center transition-all hover:-translate-y-1 hover:shadow-glass"
|
||||
:class="{ 'bg-white/12 shadow-[0_12px_32px_rgba(0,0,0,0.6),0_0_30px_rgba(255,255,255,0.2)]': selected === 'restore' }"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<div class="w-16 h-16 mx-auto bg-white/10 rounded-full flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-3">Restore Backup</h3>
|
||||
<p class="text-white/70 text-sm">
|
||||
Restore from a previous backup
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<!-- Connect Existing -->
|
||||
<button
|
||||
@click="selectOption('connect')"
|
||||
class="glass-card p-8 text-center transition-all hover:-translate-y-1 hover:shadow-glass"
|
||||
:class="{ 'bg-white/12 shadow-[0_12px_32px_rgba(0,0,0,0.6),0_0_30px_rgba(255,255,255,0.2)]': selected === 'connect' }"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<div class="w-16 h-16 mx-auto bg-white/10 rounded-full flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-3">Connect Existing</h3>
|
||||
<p class="text-white/70 text-sm">
|
||||
Connect to an existing Archipelago server
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 text-center">
|
||||
<button
|
||||
@click="proceed"
|
||||
:disabled="!selected"
|
||||
class="glass-button px-8 py-4 rounded-lg text-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Continue →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const selected = ref<string | null>(null)
|
||||
|
||||
function selectOption(option: string) {
|
||||
selected.value = option
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
if (selected.value) {
|
||||
// Mark onboarding as complete
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
|
||||
// For now, just go to login
|
||||
// In a real app, you'd have different flows for each option
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="h-screen flex items-center justify-center p-3 sm:p-4 md:p-6 relative overflow-hidden">
|
||||
<!-- Main Glass Container - Scrollable -->
|
||||
<div class="max-w-[1200px] w-full relative z-10 path-glass-container max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] overflow-y-auto overflow-x-hidden">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-4 md:mb-6 flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6">
|
||||
<h1 class="text-xl md:text-[26px] font-semibold text-white/96 mb-2 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">Choose Your Path</h1>
|
||||
<p class="text-xs md:text-sm text-white/75 leading-relaxed">You can enable or disable any of these options later from your settings.</p>
|
||||
</div>
|
||||
|
||||
<!-- Options Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4 flex-shrink-0 mb-4 md:mb-6 px-3 sm:px-4">
|
||||
<!-- Self Sovereignty -->
|
||||
<button
|
||||
@click="toggleOption('self-sovereignty')"
|
||||
class="path-option-card"
|
||||
:class="{ 'path-option-card--selected': selectedOptions.includes('self-sovereignty') }"
|
||||
>
|
||||
<div class="icon-wrapper transition-all duration-300">
|
||||
<svg class="w-10 h-10 text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white/96 mb-1.5">Self Sovereignty</h3>
|
||||
<p class="text-sm text-white/75 leading-snug">
|
||||
Data, files, ownership, property of my data estate. Own, manage, edit, and even sell your personal data.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<!-- Community Commerce -->
|
||||
<button
|
||||
@click="toggleOption('community-commerce')"
|
||||
class="path-option-card"
|
||||
:class="{ 'path-option-card--selected': selectedOptions.includes('community-commerce') }"
|
||||
>
|
||||
<svg class="w-12 h-12 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white/96 mb-2">Community Commerce</h3>
|
||||
<p class="text-[15px] text-white/75 leading-snug">
|
||||
Self contained and owned ecommerce system built on bitcoin and mesh networks. Trade freely without intermediaries.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<!-- Sovereign Projects -->
|
||||
<button
|
||||
@click="toggleOption('sovereign-projects')"
|
||||
class="path-option-card"
|
||||
:class="{ 'path-option-card--selected': selectedOptions.includes('sovereign-projects') }"
|
||||
>
|
||||
<svg class="w-12 h-12 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white/96 mb-2">Sovereign Projects</h3>
|
||||
<p class="text-[15px] text-white/75 leading-snug">
|
||||
Logistics and project management self owned with privacy control. Collaborate without surveillance.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<!-- Data Transmitter -->
|
||||
<button
|
||||
@click="toggleOption('data-transmitter')"
|
||||
class="path-option-card"
|
||||
:class="{ 'path-option-card--selected': selectedOptions.includes('data-transmitter') }"
|
||||
>
|
||||
<svg class="w-12 h-12 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white/96 mb-2">Data Transmitter</h3>
|
||||
<p class="text-[15px] text-white/75 leading-snug">
|
||||
Assist the new sovereign net with relay points and networking where you get paid for your value.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<!-- Hoster -->
|
||||
<button
|
||||
@click="toggleOption('hoster')"
|
||||
class="path-option-card"
|
||||
:class="{ 'path-option-card--selected': selectedOptions.includes('hoster') }"
|
||||
>
|
||||
<svg class="w-12 h-12 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white/96 mb-2">Hoster</h3>
|
||||
<p class="text-[15px] text-white/75 leading-snug">
|
||||
Host services and content, archives, and more to others for micro bitcoin payments. Earn while you serve.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<!-- Sovereign AI -->
|
||||
<button
|
||||
@click="toggleOption('sovereign-ai')"
|
||||
class="path-option-card"
|
||||
:class="{ 'path-option-card--selected': selectedOptions.includes('sovereign-ai') }"
|
||||
>
|
||||
<svg class="w-12 h-12 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white/96 mb-2">Sovereign AI</h3>
|
||||
<p class="text-[15px] text-white/75 leading-snug">
|
||||
Run AI models locally on your hardware. No cloud surveillance, complete privacy, full control over your AI assistant.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-3 sm:gap-4 max-w-[600px] mx-auto flex-shrink-0 px-3 sm:px-4 pb-4 sm:pb-6">
|
||||
<button
|
||||
@click="skipForNow"
|
||||
class="path-action-button path-action-button--skip"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const selectedOptions = ref<string[]>([])
|
||||
|
||||
function toggleOption(option: string) {
|
||||
const index = selectedOptions.value.indexOf(option)
|
||||
if (index > -1) {
|
||||
selectedOptions.value.splice(index, 1)
|
||||
} else {
|
||||
selectedOptions.value.push(option)
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
function skipForNow() {
|
||||
// Skip to DID creation
|
||||
router.push('/onboarding/did')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<!-- Main Glass Container -->
|
||||
<div class="max-w-[800px] w-full relative z-10 path-glass-container">
|
||||
<!-- Header -->
|
||||
<div v-if="!verified" class="text-center mb-6 flex-shrink-0">
|
||||
<h1 class="text-[26px] font-semibold text-white/96 mb-2 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Verify Your Identity
|
||||
</h1>
|
||||
<p class="text-[20px] text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
||||
Sign a challenge to verify your decentralized identity is working correctly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex flex-col items-center gap-6 mb-6">
|
||||
<!-- Sign Button (if not verified yet) -->
|
||||
<button
|
||||
v-if="!verified"
|
||||
@click="signChallenge"
|
||||
:disabled="isSigning"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
<span v-if="!isSigning">Sign Challenge</span>
|
||||
<span v-else class="flex items-center gap-2">
|
||||
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
Signing...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Verification Success -->
|
||||
<div v-if="verified" class="w-full max-w-[600px]">
|
||||
<div class="text-center mb-6">
|
||||
<div class="flex justify-center mb-6">
|
||||
<div class="path-option-card cursor-default w-20 h-20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-6">
|
||||
Your identity has been successfully verified and is ready to use.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Signature Display -->
|
||||
<div class="path-option-card cursor-default px-6 py-6">
|
||||
<div class="text-left">
|
||||
<h3 class="text-sm font-semibold text-white/80 mb-2 uppercase tracking-wide">Signature</h3>
|
||||
<div class="bg-black/40 rounded-lg p-4 backdrop-blur-sm border border-white/10">
|
||||
<p class="text-white/95 font-mono text-xs break-all leading-relaxed">
|
||||
{{ signature }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-4 max-w-[600px] mx-auto flex-shrink-0">
|
||||
<button
|
||||
@click="skipForNow"
|
||||
class="path-action-button path-action-button--skip"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
v-if="verified"
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Finish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const verified = ref(false)
|
||||
const isSigning = ref(false)
|
||||
const signature = ref('')
|
||||
|
||||
async function signChallenge() {
|
||||
isSigning.value = true
|
||||
|
||||
// Simulate signing challenge
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
// Mock signature generation
|
||||
const mockSignature = generateMockSignature()
|
||||
signature.value = mockSignature
|
||||
verified.value = true
|
||||
|
||||
isSigning.value = false
|
||||
}
|
||||
|
||||
function generateMockSignature(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
|
||||
let result = ''
|
||||
for (let i = 0; i < 128; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
// Mark onboarding as complete
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
router.push('/onboarding/done')
|
||||
}
|
||||
|
||||
function skipForNow() {
|
||||
// Mark onboarding as complete
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
router.push('/onboarding/done')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
<template>
|
||||
<div class="min-h-screen relative overflow-hidden">
|
||||
<!-- Background layers with 3D perspective and zoom effect -->
|
||||
<div class="bg-perspective-container">
|
||||
<!-- Video background for intro/login routes (smooth transition from splash) -->
|
||||
<video
|
||||
v-if="useVideoBackground"
|
||||
ref="videoElement"
|
||||
class="bg-layer"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="auto"
|
||||
poster="/assets/img/bg-4.jpg"
|
||||
style="width: 100%; height: 100%; object-fit: cover; object-position: center; position: absolute; inset: 0; transform: scale(1); transition: none;"
|
||||
@pause.prevent="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
>
|
||||
<source src="/assets/video/video-intro.mp4?v=7" type="video/mp4">
|
||||
</video>
|
||||
|
||||
<!-- Static image background for other routes (not using video) -->
|
||||
<div
|
||||
v-else
|
||||
class="bg-layer bg-zoom"
|
||||
:class="{ 'bg-zoom-in': isTransitioning }"
|
||||
:style="{ backgroundImage: `url('/assets/img/${currentBackground}')` }"
|
||||
:key="currentBackground"
|
||||
></div>
|
||||
|
||||
|
||||
<!-- Glitch overlay layer - only for non-video backgrounds -->
|
||||
<div v-show="isGlitching && !useVideoBackground" class="bg-glitch-layer"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content with 3D transitions -->
|
||||
<div class="perspective-container-wrapper">
|
||||
<div class="perspective-container">
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition name="depth-forward">
|
||||
<div :key="route.path" class="view-wrapper">
|
||||
<component :is="Component" class="view-container" />
|
||||
</div>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const currentBackground = ref('bg-4.jpg')
|
||||
const isGlitching = ref(false)
|
||||
const isTransitioning = ref(false)
|
||||
const videoElement = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
// Routes that should use video background (smooth transition from splash)
|
||||
const videoBackgroundRoutes = ['/onboarding/intro', '/login']
|
||||
|
||||
// Check if current route should use video background
|
||||
const useVideoBackground = computed(() => {
|
||||
return videoBackgroundRoutes.includes(route.path)
|
||||
})
|
||||
|
||||
// Map each route to a specific background image
|
||||
// Note: bg-4.jpg is used for splash and /onboarding/intro for seamless transition
|
||||
const routeBackgrounds: Record<string, string> = {
|
||||
'/onboarding/intro': 'bg-4.jpg', // Video will be used instead
|
||||
'/onboarding/options': 'bg-5.jpg',
|
||||
'/onboarding/path': 'bg-3.jpg',
|
||||
'/onboarding/did': 'bg-6.jpg',
|
||||
'/onboarding/backup': 'bg-7.jpg',
|
||||
'/onboarding/verify': 'bg-2.jpg',
|
||||
'/onboarding/done': 'bg-1.jpg',
|
||||
'/login': 'bg-1.jpg' // Video will be used instead
|
||||
}
|
||||
|
||||
// Restore video time from splash screen for seamless transition
|
||||
function restoreVideoTime() {
|
||||
if (videoElement.value && useVideoBackground.value) {
|
||||
const savedTime = sessionStorage.getItem('video_intro_currentTime')
|
||||
const wasPlaying = sessionStorage.getItem('video_intro_wasPlaying') === 'true'
|
||||
const savedPlaybackRate = sessionStorage.getItem('video_intro_playbackRate')
|
||||
|
||||
if (savedTime) {
|
||||
const time = parseFloat(savedTime)
|
||||
const playbackRate = savedPlaybackRate ? parseFloat(savedPlaybackRate) : 1.0
|
||||
|
||||
const setVideoTime = () => {
|
||||
if (videoElement.value) {
|
||||
// Set playback rate first for smooth playback
|
||||
videoElement.value.playbackRate = playbackRate
|
||||
// Set time with slight offset to ensure smooth transition (avoid frame boundary issues)
|
||||
videoElement.value.currentTime = Math.max(0, time - 0.05)
|
||||
|
||||
// If video was playing, ensure it continues playing immediately
|
||||
if (wasPlaying) {
|
||||
// Use requestAnimationFrame for smoother transition
|
||||
requestAnimationFrame(() => {
|
||||
if (videoElement.value) {
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video play failed after time restore:', err)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Clean up session storage after successful restore
|
||||
sessionStorage.removeItem('video_intro_currentTime')
|
||||
sessionStorage.removeItem('video_intro_wasPlaying')
|
||||
sessionStorage.removeItem('video_intro_playbackRate')
|
||||
}
|
||||
}
|
||||
|
||||
if (videoElement.value.readyState >= 2) {
|
||||
// Video can play through current position, set time immediately
|
||||
setVideoTime()
|
||||
} else if (videoElement.value.readyState >= 1) {
|
||||
// Video metadata loaded, set time immediately
|
||||
setVideoTime()
|
||||
} else {
|
||||
// Wait for metadata to load
|
||||
const handleLoadedMetadata = () => {
|
||||
setVideoTime()
|
||||
if (videoElement.value) {
|
||||
videoElement.value.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||
}
|
||||
}
|
||||
videoElement.value.addEventListener('loadedmetadata', handleLoadedMetadata, { once: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure video plays when route uses video background
|
||||
watch([useVideoBackground, route], ([useVideo]) => {
|
||||
if (useVideo && videoElement.value) {
|
||||
// Use requestAnimationFrame for smoother transition
|
||||
requestAnimationFrame(() => {
|
||||
if (videoElement.value) {
|
||||
// Restore video time for seamless transition first
|
||||
restoreVideoTime()
|
||||
// Then ensure it's playing - use double RAF for smoother transition
|
||||
requestAnimationFrame(() => {
|
||||
if (videoElement.value && videoElement.value.paused) {
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video autoplay failed:', err)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Also ensure video plays on mount if route uses video
|
||||
onMounted(() => {
|
||||
if (useVideoBackground.value && videoElement.value) {
|
||||
// Use requestAnimationFrame for smoother transition
|
||||
requestAnimationFrame(() => {
|
||||
if (videoElement.value) {
|
||||
// Restore video time for seamless transition
|
||||
restoreVideoTime()
|
||||
// Use double RAF for smoother playback start
|
||||
requestAnimationFrame(() => {
|
||||
if (videoElement.value) {
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video autoplay failed on mount:', err)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for video element to restore time when it becomes available
|
||||
watch(videoElement, (element) => {
|
||||
if (element && useVideoBackground.value) {
|
||||
// Use requestAnimationFrame for smoother transition
|
||||
requestAnimationFrame(() => {
|
||||
if (element) {
|
||||
// Try to restore immediately if metadata already loaded
|
||||
if (element.readyState >= 2) {
|
||||
restoreVideoTime()
|
||||
} else if (element.readyState >= 1) {
|
||||
restoreVideoTime()
|
||||
} else {
|
||||
// Wait for metadata to load
|
||||
const handleLoadedMetadata = () => {
|
||||
restoreVideoTime()
|
||||
element.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||
}
|
||||
element.addEventListener('loadedmetadata', handleLoadedMetadata, { once: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Watch route changes for background swaps, zoom, and glitch
|
||||
watch(() => route.path, (newPath, oldPath) => {
|
||||
const newBg = routeBackgrounds[newPath]
|
||||
const oldUsesVideo = videoBackgroundRoutes.includes(oldPath || '')
|
||||
const newUsesVideo = videoBackgroundRoutes.includes(newPath)
|
||||
|
||||
// If both old and new routes use video, don't restart video - keep it playing seamlessly
|
||||
if (oldUsesVideo && newUsesVideo && videoElement.value) {
|
||||
// Video continues seamlessly, just ensure it's playing
|
||||
if (videoElement.value.paused) {
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video play failed on route change:', err)
|
||||
})
|
||||
}
|
||||
// No glitch effect, no zoom, no transitions for video-to-video
|
||||
isGlitching.value = false
|
||||
isTransitioning.value = false
|
||||
return // Skip background change logic for video-to-video transitions
|
||||
}
|
||||
|
||||
// If transitioning from video to non-video or vice versa, no glitch, no zoom (smooth transition)
|
||||
if (oldUsesVideo || newUsesVideo) {
|
||||
isGlitching.value = false
|
||||
isTransitioning.value = false
|
||||
}
|
||||
|
||||
// Only update if we have a defined background for this route and it's different
|
||||
if (newBg && newBg !== currentBackground.value) {
|
||||
// Trigger zoom animation ONLY for non-video routes (never for video)
|
||||
if (!newUsesVideo && !oldUsesVideo) {
|
||||
isTransitioning.value = true
|
||||
|
||||
// Change background
|
||||
currentBackground.value = newBg
|
||||
|
||||
// Only trigger glitch for non-video background changes
|
||||
setTimeout(() => {
|
||||
isGlitching.value = true
|
||||
setTimeout(() => {
|
||||
isGlitching.value = false
|
||||
}, 500) // Match glitch duration
|
||||
|
||||
// Reset zoom after glitch
|
||||
isTransitioning.value = false
|
||||
}, 1500 + 50) // Wait for 3D transition (1500ms) + small delay - matches splash timing
|
||||
} else {
|
||||
// Smooth transition for video routes - no glitch, no zoom, no effects at all
|
||||
currentBackground.value = newBg
|
||||
isTransitioning.value = false
|
||||
isGlitching.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Prevent video from pausing during transitions
|
||||
function handleVideoPause(event: Event) {
|
||||
if (useVideoBackground.value && videoElement.value) {
|
||||
// Prevent pause and immediately resume playback
|
||||
event.preventDefault()
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video play failed after pause prevention:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle video ended - restart immediately for seamless loop
|
||||
function handleVideoEnded() {
|
||||
if (useVideoBackground.value && videoElement.value) {
|
||||
videoElement.value.currentTime = 0
|
||||
videoElement.value.play().catch(err => {
|
||||
console.warn('Video play failed after loop:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update body class to disable global glitch effects ONLY for video backgrounds
|
||||
// This class is ONLY added on /onboarding/intro and /login routes
|
||||
// All other routes will have glitch effects enabled (normal behavior)
|
||||
watch(useVideoBackground, (usesVideo) => {
|
||||
if (usesVideo) {
|
||||
// Add class ONLY on video background screens (/onboarding/intro, /login)
|
||||
// This disables glitch effects ONLY on these screens
|
||||
document.body.classList.add('video-background-active')
|
||||
} else {
|
||||
// Remove class on all other screens to re-enable glitch effects
|
||||
document.body.classList.remove('video-background-active')
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Initialize background on mount based on current route
|
||||
onMounted(() => {
|
||||
const bg = routeBackgrounds[route.path]
|
||||
if (bg) {
|
||||
currentBackground.value = bg
|
||||
}
|
||||
|
||||
// Ensure no transitions or effects on mount for video backgrounds
|
||||
if (useVideoBackground.value) {
|
||||
isTransitioning.value = false
|
||||
isGlitching.value = false
|
||||
document.body.classList.add('video-background-active')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Wrapper to contain perspective without clipping */
|
||||
.perspective-container-wrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Perspective container for 3D depth effect */
|
||||
.perspective-container {
|
||||
perspective: 1200px;
|
||||
perspective-origin: 50% 50%;
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* View wrapper - allows smooth transitions with absolute positioning */
|
||||
.view-wrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.view-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Forward transition: Current screen pulls forward, new screen emerges from back */
|
||||
.depth-forward-enter-active.view-wrapper,
|
||||
.depth-forward-leave-active.view-wrapper {
|
||||
transition: all 0.7s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
|
||||
.depth-forward-enter-from.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateZ(-1500px) scale(0.5);
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
.depth-forward-enter-to.view-wrapper {
|
||||
opacity: 1;
|
||||
transform: translateZ(0) scale(1);
|
||||
filter: blur(0px);
|
||||
}
|
||||
|
||||
.depth-forward-leave-from.view-wrapper {
|
||||
opacity: 1;
|
||||
transform: translateZ(0) scale(1);
|
||||
filter: blur(0px);
|
||||
}
|
||||
|
||||
.depth-forward-leave-to.view-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateZ(600px) scale(1.4);
|
||||
filter: blur(12px);
|
||||
}
|
||||
|
||||
/* Background zoom effect - makes you feel like you're going deeper */
|
||||
.bg-zoom {
|
||||
transition: transform 1.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.bg-zoom-in {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
/* Enhanced effect with rotation for more console-like feel */
|
||||
@media (min-width: 768px) {
|
||||
.depth-forward-enter-from.view-wrapper {
|
||||
transform: translateZ(-1500px) scale(0.5) rotateX(15deg);
|
||||
}
|
||||
|
||||
.depth-forward-leave-to.view-wrapper {
|
||||
transform: translateZ(600px) scale(1.4) rotateX(-10deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Background 3D container */
|
||||
.bg-perspective-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
perspective: 1000px;
|
||||
perspective-origin: 50% 50%;
|
||||
z-index: -10;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* Video background styling - video element itself has bg-layer class */
|
||||
.bg-layer video,
|
||||
video.bg-layer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.bg-static {
|
||||
opacity: 1;
|
||||
transform: translateZ(0) scale(1);
|
||||
}
|
||||
|
||||
/* Glitch overlay layer */
|
||||
.bg-glitch-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0;
|
||||
animation: bg-glitch-flash 500ms ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes bg-glitch-flash {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: translateX(0);
|
||||
}
|
||||
10% {
|
||||
opacity: 0.3;
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
20% {
|
||||
opacity: 0;
|
||||
transform: translateX(3px);
|
||||
}
|
||||
30% {
|
||||
opacity: 0.4;
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
40% {
|
||||
opacity: 0;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
60% {
|
||||
opacity: 0;
|
||||
transform: translateX(1px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Network</h1>
|
||||
<p class="text-white/70">Manage your network infrastructure and Web3 services</p>
|
||||
<p class="text-sm text-white/60 mt-2">{{ connectedNodes }} connected nodes</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions Container -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Service Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="w-3 h-3 rounded-full" :class="servicesRunning ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
<div v-if="servicesRunning" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">Services</p>
|
||||
<p class="text-xs text-white/60">{{ servicesRunning ? 'All Running' : 'Some Stopped' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="restartServices"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
:disabled="restarting"
|
||||
>
|
||||
{{ restarting ? 'Restarting...' : 'Restart' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Connectivity Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="w-3 h-3 rounded-full" :class="connectivityStatus === 'connected' ? 'bg-green-400' : connectivityStatus === 'checking' ? 'bg-yellow-400' : 'bg-red-400'"></div>
|
||||
<div v-if="connectivityStatus === 'connected'" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">Connectivity</p>
|
||||
<p class="text-xs text-white/60 capitalize">{{ connectivityStatus }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="checkConnectivity"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
:disabled="checkingConnectivity"
|
||||
>
|
||||
{{ checkingConnectivity ? 'Checking...' : 'Check' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Auto-Sync Toggle -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">Auto-Sync</p>
|
||||
<p class="text-xs text-white/60">{{ autoSyncEnabled ? 'Enabled' : 'Disabled' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="toggleAutoSync"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
:class="autoSyncEnabled ? 'bg-green-500' : 'bg-white/20'"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="autoSyncEnabled ? 'translate-x-6' : 'translate-x-1'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Logs & Diagnostics -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">Logs</p>
|
||||
<p class="text-xs text-white/60">{{ logCount > 0 ? `${logCount} new` : 'No new logs' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="viewLogs"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview Cards -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Local Network Card -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Local Network</h2>
|
||||
<p class="text-white/70 text-sm mb-4">OpenWRT-integrated router and network management</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Firewall Active</span>
|
||||
</div>
|
||||
<span class="text-green-400 text-sm font-medium">Protected</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
|
||||
<span class="text-white/80 text-sm">WiFi Networks</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">2 configured</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">DHCP Clients</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">12 active</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Port Forwarding</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">5 rules</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
Manage Local Network
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Web3 Card -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Web3</h2>
|
||||
<p class="text-white/70 text-sm mb-4">Decentralized web hosting and services</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Hosted Websites</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">3 active</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">SSL Certificates</span>
|
||||
</div>
|
||||
<span class="text-green-400 text-sm font-medium">Valid</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">IPFS Storage</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">2.4 GB used</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">ENS Domains</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">1 configured</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
Manage Web3 Services
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Connected nodes
|
||||
const connectedNodes = ref(12)
|
||||
|
||||
// Service status
|
||||
const servicesRunning = ref(true)
|
||||
const restarting = ref(false)
|
||||
|
||||
// Connectivity status: 'connected' | 'disconnected' | 'checking'
|
||||
const connectivityStatus = ref<'connected' | 'disconnected' | 'checking'>('connected')
|
||||
const checkingConnectivity = ref(false)
|
||||
|
||||
// Auto-sync toggle
|
||||
const autoSyncEnabled = ref(true)
|
||||
|
||||
// Logs
|
||||
const logCount = ref(3)
|
||||
|
||||
function restartServices() {
|
||||
restarting.value = true
|
||||
servicesRunning.value = false
|
||||
// TODO: Implement restart services API call
|
||||
console.log('Restarting services...')
|
||||
setTimeout(() => {
|
||||
restarting.value = false
|
||||
servicesRunning.value = true
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function checkConnectivity() {
|
||||
checkingConnectivity.value = true
|
||||
connectivityStatus.value = 'checking'
|
||||
// TODO: Implement connectivity check API call
|
||||
console.log('Checking connectivity...')
|
||||
setTimeout(() => {
|
||||
checkingConnectivity.value = false
|
||||
connectivityStatus.value = 'connected'
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function toggleAutoSync() {
|
||||
autoSyncEnabled.value = !autoSyncEnabled.value
|
||||
// TODO: Implement auto-sync toggle API call
|
||||
console.log('Auto-sync:', autoSyncEnabled.value ? 'enabled' : 'disabled')
|
||||
}
|
||||
|
||||
function viewLogs() {
|
||||
// TODO: Navigate to logs view or open logs modal
|
||||
console.log('Viewing logs...')
|
||||
logCount.value = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">Settings</h1>
|
||||
<p class="text-white/80">Configure your Archipelago experience</p>
|
||||
</div>
|
||||
|
||||
<!-- Account Section -->
|
||||
<div class="path-option-card cursor-default px-6 py-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-6">Account</h2>
|
||||
|
||||
<!-- Info Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<!-- Server Name Card -->
|
||||
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Server Name</p>
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-white/95">{{ serverName }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Version Card -->
|
||||
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Version</p>
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-white/95">{{ version }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Session Card -->
|
||||
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 md:col-span-2">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Session Status</p>
|
||||
</div>
|
||||
<p class="text-base font-medium text-white/90">Currently logged in</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logout Button -->
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="w-full path-action-button path-action-button--continue flex items-center justify-center gap-2"
|
||||
>
|
||||
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- System Section -->
|
||||
<div class="path-option-card cursor-default px-6 py-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-4">System</h2>
|
||||
<div class="text-center py-8">
|
||||
<svg class="w-12 h-12 mx-auto text-white/40 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<p class="text-white/70">Additional settings coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
|
||||
const serverName = computed(() => store.serverName)
|
||||
const version = computed(() => store.serverInfo?.version || '0.0.0')
|
||||
|
||||
async function handleLogout() {
|
||||
await store.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,508 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Web5</h1>
|
||||
<p class="text-white/70">Decentralized identity and data protocols</p>
|
||||
<p class="text-sm text-white/60 mt-2">Earn networking profits by hosting decentralized services</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions Container -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Networking Profits -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<span class="text-2xl text-orange-500 font-bold">₿</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">Networking Profits</p>
|
||||
<p class="text-xs text-orange-500 font-medium">₿0.024</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DID Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="w-3 h-3 rounded-full" :class="didStatus === 'active' ? 'bg-green-400' : 'bg-yellow-400'"></div>
|
||||
<div v-if="didStatus === 'active'" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">DID Status</p>
|
||||
<p class="text-xs text-white/60 capitalize">{{ didStatus }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="manageDIDs"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Wallet Connection -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="w-3 h-3 rounded-full" :class="walletConnected ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
<div v-if="walletConnected" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">Wallet</p>
|
||||
<p class="text-xs text-white/60">{{ walletConnected ? 'Connected' : 'Disconnected' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="connectWallet"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
:disabled="connectingWallet"
|
||||
>
|
||||
{{ connectingWallet ? 'Connecting...' : walletConnected ? 'Disconnect' : 'Connect' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Nostr Relay Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="w-3 h-3 rounded-full" :class="nostrRelaysConnected > 0 ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
<div v-if="nostrRelaysConnected > 0" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">Nostr Relays</p>
|
||||
<p class="text-xs text-white/60">{{ nostrRelaysConnected }} connected</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="manageRelays"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Core Services Overview Cards -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Bitcoin Domain Name Portfolio -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Bitcoin Domain Names</h2>
|
||||
<p class="text-white/70 text-sm mb-4">Manage your .btc domain portfolio</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Domains Owned</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">5 domains</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Registration Status</span>
|
||||
</div>
|
||||
<span class="text-green-400 text-sm font-medium">Active</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Expiring Soon</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">1 domain</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
Manage Domains
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Web5 Wallet -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Web5 Wallet</h2>
|
||||
<p class="text-white/70 text-sm mb-4">Decentralized wallet for Web5 assets</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg text-orange-500 font-bold">₿</span>
|
||||
<span class="text-white/80 text-sm">Balance</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm text-orange-500">₿0.025</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Wallet Status</span>
|
||||
</div>
|
||||
<span class="text-green-400 text-sm font-medium">Connected</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Transactions</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">12 pending</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
Open Wallet
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Nostr Relays -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Nostr Relays</h2>
|
||||
<p class="text-white/70 text-sm mb-4">Decentralized social networking relays</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Relays Connected</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">8 active</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Relay Status</span>
|
||||
</div>
|
||||
<span class="text-green-400 text-sm font-medium">Syncing</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Events Stored</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">1.2M events</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
Manage Relays
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Protocol Overview Cards -->
|
||||
<div v-if="false" class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Decentralized Identifiers (DIDs) -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Decentralized Identifiers</h2>
|
||||
<p class="text-white/70 text-sm mb-4">Self-owned identifiers for verifiable digital identity</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Active DIDs</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">3 created</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Verification Status</span>
|
||||
</div>
|
||||
<span class="text-green-400 text-sm font-medium">Verified</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
|
||||
<span class="text-white/80 text-sm">Key Management</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">Secure</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
Manage DIDs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Decentralized Web Nodes (DWNs) -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Decentralized Web Nodes</h2>
|
||||
<p class="text-white/70 text-sm mb-4">Personal data stores you control</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Data Stores</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">2 active</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Storage Used</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">1.2 GB</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Sync Status</span>
|
||||
</div>
|
||||
<span class="text-green-400 text-sm font-medium">Synced</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
Manage DWNs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Self-Sovereign Identity Service -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Self-Sovereign Identity</h2>
|
||||
<p class="text-white/70 text-sm mb-4">Create and manage decentralized identities</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Identities</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">2 managed</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Service Status</span>
|
||||
</div>
|
||||
<span class="text-green-400 text-sm font-medium">Running</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
|
||||
<span class="text-white/80 text-sm">Credentials</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">5 issued</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
Manage SSI Service
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Self-Sovereign Identity SDK -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">SSI SDK</h2>
|
||||
<p class="text-white/70 text-sm mb-4">Developer toolkit for Web5 applications</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">SDK Version</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">v1.2.0</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">Active Apps</span>
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">4 integrated</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">API Status</span>
|
||||
</div>
|
||||
<span class="text-green-400 text-sm font-medium">Available</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
View SDK Documentation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
// DID Status: 'active' | 'inactive' | 'pending'
|
||||
const didStatus = ref<'active' | 'inactive' | 'pending'>('active')
|
||||
|
||||
// DWN Sync Status: 'synced' | 'syncing' | 'error'
|
||||
const dwnSyncStatus = ref<'synced' | 'syncing' | 'error'>('synced')
|
||||
const syncingDWNs = ref(false)
|
||||
|
||||
// Wallet Connection
|
||||
const walletConnected = ref(true)
|
||||
const connectingWallet = ref(false)
|
||||
|
||||
// Nostr Relays
|
||||
const nostrRelaysConnected = ref(8)
|
||||
|
||||
function manageDIDs() {
|
||||
// TODO: Navigate to DID management or open modal
|
||||
console.log('Managing DIDs...')
|
||||
}
|
||||
|
||||
// @ts-ignore - Function kept for future use
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function _syncDWNs() {
|
||||
syncingDWNs.value = true
|
||||
dwnSyncStatus.value = 'syncing'
|
||||
// TODO: Implement DWN sync API call
|
||||
console.log('Syncing DWNs...')
|
||||
setTimeout(() => {
|
||||
syncingDWNs.value = false
|
||||
dwnSyncStatus.value = 'synced'
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function connectWallet() {
|
||||
if (walletConnected.value) {
|
||||
walletConnected.value = false
|
||||
// TODO: Implement wallet disconnect API call
|
||||
console.log('Disconnecting wallet...')
|
||||
} else {
|
||||
connectingWallet.value = true
|
||||
// TODO: Implement wallet connect API call
|
||||
console.log('Connecting wallet...')
|
||||
setTimeout(() => {
|
||||
connectingWallet.value = false
|
||||
walletConnected.value = true
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
function manageRelays() {
|
||||
// TODO: Navigate to relay management or open modal
|
||||
console.log('Managing Nostr relays...')
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user