feat: complete Phase 1 foundation hardening + three-mode UI design doc

Phase 1a — Gradient Removal:
- Replaced all gradient-button/gradient-card with glass-button/path-option-card
- Removed banned gradient CSS classes

Phase 1b — Security Hardening:
- SecretsManager: AES-256-GCM encryption (core/security)
- electrs_status: credentials from env vars instead of hardcoded
- port_manager: RwLock proper error handling (no unwrap)
- Pinned all 11 :latest manifest images to specific versions
- parmanode converter: pinned inferred image versions

Phase 1c — Code Quality:
- Split rpc.rs (1795 lines) into 6 handler modules (auth, node, container, package, peers)
- Removed sideload code (UI, store, RPC client, 3 doc files)
- Fixed body background flash on logout/refresh
- Replaced 30 TypeScript `any` types with proper types
- Deleted HelloWorld.vue, removed TODO comments
- Added set -euo pipefail to all shell scripts
- Made deploy script verbose with timestamps and elapsed time

Also adds:
- CLAUDE.md project guide
- docs/three-mode-ui-design.md — design spec for Easy/Pro/Chat UI modes
- OnlineStatusPill component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 05:23:42 +00:00
parent 62d6c13764
commit 486fc39249
58 changed files with 1902 additions and 2286 deletions

View File

@@ -9,7 +9,7 @@
<!-- Spotlight command palette (Cmd+K / Ctrl+K) -->
<SpotlightSearch />
<!-- CLI popup (Cmd+Shift+` / Ctrl+Shift+`) -->
<!-- CLI popup (Cmd+C / Ctrl+C) -->
<CLIPopup />
<!-- App launcher overlay (iframe popup) -->
@@ -119,8 +119,8 @@ function onKeyDown(e: KeyboardEvent) {
spotlightStore.toggle()
return
}
// Cmd+Shift+` / Ctrl+Shift+` or Cmd+Shift+C / Ctrl+Shift+C - CLI popup (modifier required)
if ((mod && e.shiftKey && e.key === '`') || (mod && e.shiftKey && (e.key === 'c' || e.key === 'C'))) {
// Cmd+C / Ctrl+C - CLI popup (skip when in input so copy still works)
if (mod && (e.key === 'c' || e.key === 'C') && !isInput) {
e.preventDefault()
cliStore.toggle()
return

View File

@@ -2,7 +2,7 @@
export interface RPCOptions {
method: string
params?: any
params?: Record<string, unknown>
timeout?: number
}
@@ -11,7 +11,7 @@ export interface RPCResponse<T> {
error?: {
code: number
message: string
data?: any
data?: unknown
}
}
@@ -271,7 +271,7 @@ class RPCClient {
})
}
async getMetrics(): Promise<any> {
async getMetrics(): Promise<Record<string, unknown>> {
return this.call({
method: 'server.metrics',
params: {},
@@ -334,20 +334,13 @@ class RPCClient {
})
}
async getMarketplace(url: string): Promise<any> {
async getMarketplace(url: string): Promise<Record<string, unknown>> {
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()

View File

@@ -1,7 +1,7 @@
// WebSocket handler for real-time updates
import type { Update, PatchOperation } from '../types/api'
import { applyPatch } from 'fast-json-patch'
import { applyPatch, type Operation } from 'fast-json-patch'
type WebSocketCallback = (update: Update) => void
type ConnectionStateCallback = (connected: boolean) => void
@@ -336,7 +336,7 @@ function getWebSocketClient(): WebSocketClient {
}
// Check if we have a persisted instance from HMR
const existing = (window as any).__archipelago_ws_client
const existing = (window as unknown as Record<string, unknown>).__archipelago_ws_client
if (existing && existing instanceof WebSocketClient) {
// Check if the WebSocket is still valid
if (existing.isConnected()) {
@@ -350,7 +350,7 @@ function getWebSocketClient(): WebSocketClient {
if (!wsClientInstance) {
wsClientInstance = new WebSocketClient()
if (typeof window !== 'undefined') {
(window as any).__archipelago_ws_client = wsClientInstance
;(window as unknown as Record<string, unknown>).__archipelago_ws_client = wsClientInstance
}
if (import.meta.env.DEV) console.debug('[WebSocket] Created new client instance')
}
@@ -385,7 +385,7 @@ export function applyDataPatch<T>(data: T, patch: PatchOperation[]): T {
}
try {
const result = applyPatch(data, patch as any, false, false)
const result = applyPatch(data, patch as Operation[], false, false)
return result.newDocument as T
} catch (error) {
console.error('Failed to apply patch:', error, 'Patch:', patch)

View File

@@ -31,7 +31,6 @@
</svg>
<span class="text-white font-medium">CLI Access</span>
</div>
<AppSwitcher />
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded">Esc</kbd>
</div>
@@ -101,7 +100,7 @@
From the terminal menu you can install to disk, configure Bitcoin, Lightning, view logs, and more.
</p>
<p class="text-white/40 text-xs">
Tip: Press <kbd class="px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px]">C</kbd> or <kbd class="px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px]">`</kbd> to open this anytime.
Tip: Press <kbd class="px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px]">C</kbd> / <kbd class="px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px]">Ctrl+C</kbd> to open this anytime.
</p>
</div>
</div>
@@ -116,8 +115,6 @@
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useCLIStore } from '@/stores/cli'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import AppSwitcher from '@/components/AppSwitcher.vue'
const cliStore = useCLIStore()
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)

View File

@@ -1,41 +0,0 @@
<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>

View File

@@ -0,0 +1,25 @@
<template>
<button
type="button"
data-controller-ignore
class="flex items-center gap-1.5 px-3 py-2 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
title="Open CLI (⌘C / Ctrl+C)"
@click="openCLI"
>
<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-50"></div>
</div>
<span class="text-xs">Online</span>
</button>
</template>
<script setup lang="ts">
import { useCLIStore } from '@/stores/cli'
const cliStore = useCLIStore()
function openCLI() {
cliStore.open()
}
</script>

View File

@@ -24,7 +24,7 @@
</button>
<button
@click="install"
class="px-4 py-2 gradient-button rounded-lg text-sm font-medium"
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
>
Install
</button>

View File

@@ -36,7 +36,7 @@
</button>
<button
@click="handleUpdate"
class="px-4 py-2 gradient-button rounded-lg text-sm font-medium"
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
>
Update Now
</button>

View File

@@ -27,7 +27,11 @@ const FOCUSABLE_SELECTOR = [
function getFocusableElements(container: Document | HTMLElement = document): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) => !el.hasAttribute('disabled') && el.offsetParent !== null
(el) =>
!el.hasAttribute('disabled') &&
el.offsetParent !== null &&
!el.hasAttribute('data-controller-ignore') &&
!el.closest('[data-controller-ignore]')
)
}

View File

@@ -15,7 +15,7 @@ function getContext(): AudioContext | null {
function ensureContext(): AudioContext | null {
if (audioContext) return audioContext
try {
const Ctx = window.AudioContext || (window as any).webkitAudioContext
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
if (!Ctx) return null
audioContext = new Ctx()
return audioContext

View File

@@ -1,24 +1,39 @@
import { ref } from 'vue'
export interface MarketplaceAppInfo {
id: string
title: string
version: string
icon: string
category: string
description: string | { short: string; long: string }
author: string
source: string
manifestUrl: string
url: string
repoUrl: string
s9pkUrl: string
}
// Simple in-memory store for the current marketplace app
const currentMarketplaceApp = ref<any>(null)
const currentMarketplaceApp = ref<MarketplaceAppInfo | null>(null)
export function useMarketplaceApp() {
function setCurrentApp(app: any) {
function setCurrentApp(app: Partial<MarketplaceAppInfo> & { id: string }) {
// 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
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 ?? '',
}
}
@@ -36,4 +51,3 @@ export function useMarketplaceApp() {
clearCurrentApp
}
}

View File

@@ -8,7 +8,7 @@ let audioContext: AudioContext | null = null
function getContext(): AudioContext | null {
if (audioContext) return audioContext
try {
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)()
return audioContext
} catch {
return null

View File

@@ -90,7 +90,7 @@ export const useAppStore = defineStore('app', () => {
}
})
wsClient.subscribe((update: any) => {
wsClient.subscribe((update: { type?: string; data?: DataModel; rev?: number; patch?: import('@/types/api').PatchOperation[] }) => {
// Handle mock backend format: {type: 'initial', data: {...}}
if (update?.type === 'initial' && update?.data) {
console.log('[Store] Received initial data from mock backend')
@@ -256,19 +256,15 @@ export const useAppStore = defineStore('app', () => {
return rpcClient.shutdownServer()
}
async function getMetrics(): Promise<any> {
async function getMetrics(): Promise<Record<string, unknown>> {
return rpcClient.getMetrics()
}
// Marketplace actions
async function getMarketplace(url: string): Promise<any> {
async function getMarketplace(url: string): Promise<Record<string, unknown>> {
return rpcClient.getMarketplace(url)
}
async function sideloadPackage(manifest: any, icon: string): Promise<string> {
return rpcClient.sideloadPackage(manifest, icon)
}
return {
// State
data,
@@ -303,7 +299,6 @@ export const useAppStore = defineStore('app', () => {
shutdownServer,
getMetrics,
getMarketplace,
sideloadPackage,
}
})

View File

@@ -66,28 +66,56 @@
overflow-x: hidden;
overflow-y: visible;
}
.glass-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
height: 48px;
min-height: 48px;
padding-block: 0 !important;
line-height: 48px;
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);
padding-inline: 1.25rem;
background: rgba(0, 0, 0, 0.6);
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: 0.75rem;
border: none;
color: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
}
.glass-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;
}
.glass-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);
}
.glass-button:hover::before {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
}
.glass-button-sm {
min-height: 0 !important;
height: auto !important;
line-height: inherit;
padding-block: 0.375rem !important;
padding-block: 0.375rem;
padding-inline: 0.75rem;
font-size: 0.875rem;
}
/* Toast - glassmorphic, top-right */
@@ -111,39 +139,10 @@
transform: translateX(1rem);
}
/* 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);
}
/* BANNED: gradient-card, gradient-card-dark, gradient-button
Use .glass-card or .path-option-card for containers.
Use .glass-button for all buttons.
These gradient styles break the clean glass aesthetic. */
/* Gradient border for logo badge */
.logo-gradient-border {
@@ -198,7 +197,7 @@
-webkit-backdrop-filter: blur(40px);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow:
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
display: flex;
@@ -236,8 +235,8 @@
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,
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
@@ -248,7 +247,7 @@
.path-option-card svg {
color: rgba(255, 255, 255, 0.85);
transition: all 0.3s ease;
filter:
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));
@@ -269,7 +268,7 @@
.path-option-card:hover svg {
color: rgba(255, 255, 255, 1);
filter:
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));
@@ -291,7 +290,7 @@
.path-option-card--selected svg {
color: rgba(255, 255, 255, 1);
filter:
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));
@@ -299,7 +298,7 @@
.path-option-card--selected h3 {
color: rgba(255, 255, 255, 1);
}
}
/* Action Buttons */
.path-action-button {
@@ -415,7 +414,7 @@ body {
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;
background: #000;
color: white;
min-height: 100vh;
}

View File

@@ -228,7 +228,7 @@ export namespace RR {
export interface PatchOperation {
op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test'
path: string
value?: any
value?: unknown
from?: string
}

View File

@@ -100,7 +100,7 @@ export async function fetchGitHubAppInfo(repoUrl: string, appId: string): Promis
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) =>
const asset = releasesData.assets?.find((a: { name: string; browser_download_url: string }) =>
a.name.includes('icon') || a.name.includes('logo')
)
if (asset) {

View File

@@ -57,7 +57,7 @@
<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"
class="glass-button glass-button-sm 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" />
@@ -151,7 +151,7 @@
<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"
class="glass-button glass-button-sm 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" />

View File

@@ -78,7 +78,7 @@
v-if="canLaunch(pkg)"
data-controller-launch-btn
@click.stop="launchApp(id as string)"
class="flex-1 px-4 py-2 gradient-button rounded-lg text-sm font-medium"
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
>
Launch
</button>

View File

@@ -73,8 +73,8 @@
:class="{ 'sidebar-animate': showZoomIn }"
>
<div class="sidebar-shell">
<div class="sidebar-inner">
<div class="sidebar-logo flex items-center gap-3 mb-8 p-6 pb-0">
<div class="sidebar-inner flex flex-col min-h-full">
<div class="sidebar-logo flex items-center gap-3 mb-8 p-6 pb-0 shrink-0">
<AnimatedLogo />
<div class="min-w-0 flex-1">
<h2 class="text-lg font-semibold text-white truncate">{{ serverName }}</h2>
@@ -82,7 +82,7 @@
</div>
</div>
<nav class="sidebar-nav space-y-2 p-6 pt-4">
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-4">
<RouterLink
v-for="(item, idx) in desktopNavItems"
:key="item.path"
@@ -105,11 +105,11 @@
</RouterLink>
</nav>
<div class="sidebar-controller px-6 pb-2">
<div class="sidebar-controller px-6 pb-2 shrink-0">
<ControllerIndicator />
</div>
<div class="sidebar-logout p-6">
<div class="sidebar-logout p-6 shrink-0">
<button
@click="handleLogout"
class="sidebar-logout-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
@@ -120,6 +120,11 @@
<span>Logout</span>
</button>
</div>
<!-- Online status pill - bottom of sidebar (desktop only; sidebar is hidden on mobile) -->
<div class="px-6 pb-6 shrink-0">
<OnlineStatusPill />
</div>
</div>
</div>
</aside>
@@ -130,9 +135,8 @@
class="flex-1 overflow-hidden relative pb-20 md:pb-0 glass-piece z-10"
:class="{ 'glass-throw-main': showZoomIn }"
>
<!-- App Switcher - top right, compact (Right arrow from sidebar goes here first) -->
<div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
<AppSwitcher />
<!-- Controller zone entry point - no switcher -->
</div>
<!-- Connection Status Banner -->
@@ -309,7 +313,7 @@ import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
import { useAppStore } from '../stores/app'
import { useLoginTransitionStore } from '../stores/loginTransition'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import AppSwitcher from '@/components/AppSwitcher.vue'
import OnlineStatusPill from '@/components/OnlineStatusPill.vue'
import ControllerIndicator from '@/components/ControllerIndicator.vue'
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'

View File

@@ -79,16 +79,6 @@
<p class="hidden md:block text-white/70">Discover and install apps for your new sovereign life</p>
</div>
<!-- Sideload Button -->
<button
@click="showSideloadModal = true"
class="hidden md:flex px-6 py-3 gradient-button rounded-lg font-medium items-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="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>
Sideload
</button>
</div>
<!-- Category Tabs + Search (Desktop only) -->
@@ -177,7 +167,7 @@
data-controller-install-btn
@click.stop="app.source === 'local' ? installApp(app) : installCommunityApp(app)"
:disabled="installingApps.has(app.id)"
class="flex-1 px-4 py-2 gradient-button rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="installingApps.has(app.id)" class="flex items-center justify-center gap-2">
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@@ -213,63 +203,6 @@
</div>
<!-- End Scrollable Apps Section -->
<!-- Sideload Modal -->
<Transition name="modal">
<div
v-if="showSideloadModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
@click.self="closeSideloadModal()"
>
<div ref="sideloadModalRef" class="glass-card p-8 max-w-2xl w-full relative">
<!-- Close Button -->
<button
@click="closeSideloadModal()"
class="absolute top-4 right-4 text-white/60 hover:text-white transition-colors"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h2 class="text-2xl font-bold text-white mb-2">Sideload Package</h2>
<p class="text-white/70 mb-6">Install a package from an s9pk file URL or local path</p>
<div class="flex flex-col gap-4">
<input
v-model="sideloadUrl"
type="text"
placeholder="https://example.com/package.s9pk or /packages/package.s9pk"
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-white/40"
@keyup.enter="sideloadPackage"
/>
<button
@click="sideloadPackage"
:disabled="!sideloadUrl || sideloading"
class="px-8 py-3 gradient-button rounded-lg font-medium disabled:opacity-50 flex items-center justify-center gap-2"
>
<svg v-if="sideloading" 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>
{{ sideloading ? 'Installing...' : 'Install Package' }}
</button>
</div>
<p v-if="sideloadError" class="mt-4 text-red-400 text-sm">{{ sideloadError }}</p>
<p v-if="sideloadSuccess" class="mt-4 text-green-400 text-sm">{{ sideloadSuccess }}</p>
<!-- Examples -->
<div class="mt-6 p-4 bg-white/5 rounded-lg">
<p class="text-white/80 text-sm font-medium mb-2">Examples:</p>
<ul class="text-white/60 text-sm space-y-1">
<li> <code class="text-blue-400">https://github.com/.../releases/download/v1.0.0/app.s9pk</code></li>
<li> <code class="text-blue-400">/packages/myapp.s9pk</code> (local file)</li>
</ul>
</div>
</div>
</div>
</Transition>
<!-- Floating Filter Button (Mobile only) -->
<button
@click="showFilterModal = true"
@@ -407,20 +340,6 @@ interface InstallProgress {
const installingApps = ref<Map<string, InstallProgress>>(new Map())
const maxAttempts = ref(60)
// Sideload modal state
const showSideloadModal = ref(false)
const sideloadModalRef = ref<HTMLElement | null>(null)
const sideloadRestoreFocusRef = ref<HTMLElement | null>(null)
function closeSideloadModal() {
sideloadRestoreFocusRef.value?.focus?.()
showSideloadModal.value = false
}
useModalKeyboard(sideloadModalRef, showSideloadModal, closeSideloadModal, { restoreFocusRef: sideloadRestoreFocusRef })
const sideloadUrl = ref('')
const sideloading = ref(false)
const sideloadError = ref('')
const sideloadSuccess = ref('')
// Filter modal state (for mobile)
const showFilterModal = ref(false)
const filterModalRef = ref<HTMLElement | null>(null)
@@ -438,7 +357,6 @@ const communityApps = ref<any[]>([])
const searchQuery = ref('')
// Available apps in marketplace
// Note: s9pk packages disabled until sideload functionality is implemented
// const availableApps = ref([
// {
// id: 'atob',
@@ -1000,31 +918,6 @@ async function installCommunityApp(app: any) {
}
}
async function sideloadPackage() {
if (!sideloadUrl.value || sideloading.value) return
sideloading.value = true
sideloadError.value = ''
sideloadSuccess.value = ''
try {
await rpcClient.call({ method: 'package.sideload', params: { url: sideloadUrl.value } })
sideloadSuccess.value = 'Package installed successfully!'
sideloadUrl.value = ''
trackTimeout(() => {
showSideloadModal.value = false
router.push('/dashboard/apps').catch(() => {})
}, 1500)
} catch (err: any) {
console.error('Sideload failed:', err)
sideloadError.value = err.message || 'Failed to install package'
} finally {
sideloading.value = false
}
}
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement
img.src = '/assets/img/logo-archipelago.svg'

View File

@@ -73,7 +73,7 @@
<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"
class="glass-button glass-button-sm 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" />
@@ -84,7 +84,7 @@
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"
class="glass-button glass-button-sm 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>
@@ -138,7 +138,7 @@
<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"
class="glass-button glass-button-sm 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" />
@@ -149,7 +149,7 @@
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"
class="glass-button glass-button-sm 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>
@@ -330,7 +330,7 @@ import { ref, computed, onMounted, onBeforeUnmount } 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 { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
import { useMobileBackButton } from '../composables/useMobileBackButton'
const { bottomPosition } = useMobileBackButton()
@@ -340,7 +340,7 @@ const route = useRoute()
const store = useAppStore()
const { getCurrentApp } = useMarketplaceApp()
const app = ref<any>(null)
const app = ref<MarketplaceAppInfo | null>(null)
const installing = ref(false)
const installError = ref<string | null>(null)
const loading = ref(true)
@@ -481,8 +481,8 @@ async function installApp() {
await new Promise(resolve => setTimeout(resolve, 1000))
router.push(`/dashboard/apps/${appId.value}`).catch(() => {})
} catch (err: any) {
installError.value = err.message || 'Installation failed. Please try again.'
} catch (err: unknown) {
installError.value = err instanceof Error ? err.message : 'Installation failed. Please try again.'
console.error('[MarketplaceAppDetails] Failed to install app:', err)
} finally {
installing.value = false

View File

@@ -224,6 +224,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
// Connected nodes
const connectedNodes = ref(12)
@@ -242,37 +243,38 @@ const autoSyncEnabled = ref(true)
// Logs
const logCount = ref(3)
function restartServices() {
async function restartServices() {
restarting.value = true
servicesRunning.value = false
// TODO: Implement restart services API call
console.log('Restarting services...')
try {
await rpcClient.restartServer()
} catch {
if (import.meta.env.DEV) console.warn('Restart RPC unavailable, using mock')
}
setTimeout(() => {
restarting.value = false
servicesRunning.value = true
}, 2000)
}
function checkConnectivity() {
async function checkConnectivity() {
checkingConnectivity.value = true
connectivityStatus.value = 'checking'
// TODO: Implement connectivity check API call
console.log('Checking connectivity...')
setTimeout(() => {
checkingConnectivity.value = false
try {
await rpcClient.call({ method: 'server.health', params: {} })
connectivityStatus.value = 'connected'
}, 2000)
} catch {
connectivityStatus.value = 'disconnected'
} finally {
checkingConnectivity.value = false
}
}
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>