fix: prevent My Apps crash when installing apps + add filebrowser to demo

The My Apps page went blank after installing apps because pkg['static-files'].icon
was accessed without optional chaining on dynamically installed packages that lack
the static-files property.

- Make static-files optional in PackageDataEntry type
- Add defensive ?.icon access with fallback in Apps.vue and AppDetails.vue
- Add filebrowser to mock backend staticDevApps (enables Cloud page in demo)
- Expand portMappings and marketplaceMetadata for all marketplace apps
- installPackage now uses staticApp() format for consistent data shape

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-09 17:09:59 +00:00
co-authored by Claude Opus 4.6
parent 9c7ffbb263
commit a2aa9657b1
24 changed files with 1200 additions and 141 deletions
+3 -3
View File
@@ -9,7 +9,7 @@
<!-- Spotlight command palette (Cmd+K / Ctrl+K) -->
<SpotlightSearch />
<!-- CLI popup (Cmd+C / Ctrl+C) -->
<!-- CLI popup (F key) -->
<CLIPopup />
<!-- App launcher overlay (iframe popup) -->
@@ -125,8 +125,8 @@ function onKeyDown(e: KeyboardEvent) {
spotlightStore.toggle()
return
}
// Cmd+C / Ctrl+C - CLI popup (skip when in input so copy still works)
if (mod && (e.key === 'c' || e.key === 'C') && !isInput) {
// F key - CLI popup (skip when in input or modifier held)
if ((e.key === 'f' || e.key === 'F') && !isInput && !mod && !e.altKey) {
e.preventDefault()
cliStore.toggle()
return
+15 -10
View File
@@ -34,9 +34,10 @@ export class WebSocketClient {
this.visibilityChangeHandler = () => {
if (document.visibilityState === 'visible') {
console.log('[WebSocket] Page became visible, checking connection...')
// Reconnect if connection was lost while tab was hidden
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
// Only reconnect if we haven't been explicitly disconnected
if (this.shouldReconnect && (!this.ws || this.ws.readyState !== WebSocket.OPEN)) {
console.log('[WebSocket] Connection lost while hidden, reconnecting...')
this.reconnectAttempts = 0
this.connect().catch(err => {
console.error('[WebSocket] Failed to reconnect on visibility change:', err)
})
@@ -47,8 +48,11 @@ export class WebSocketClient {
// Handle network online/offline events
this.onlineHandler = () => {
// Only reconnect if we haven't been explicitly disconnected
if (!this.shouldReconnect) return
console.log('[WebSocket] Network came online, reconnecting...')
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
this.reconnectAttempts = 0
this.connect().catch(err => {
console.error('[WebSocket] Failed to reconnect when network came online:', err)
})
@@ -108,10 +112,11 @@ export class WebSocketClient {
return
}
// 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
// Only enable reconnect if not explicitly disconnected
// (shouldReconnect is set to false by disconnect())
if (this.shouldReconnect !== false) {
this.shouldReconnect = true
}
// In development, Vite proxies /ws to the backend
// In production, use the same host as the page
@@ -262,10 +267,10 @@ export class WebSocketClient {
// Check if we've received a message recently
const timeSinceLastMessage = Date.now() - this.lastMessageTime
// If no message for more than 60 seconds, assume connection is stale
if (timeSinceLastMessage > 60000) {
console.warn('[WebSocket] No messages for 60s, reconnecting...')
// If no message for more than 5 minutes, assume connection is stale
if (timeSinceLastMessage > 300000) {
console.warn('[WebSocket] No messages for 5m, reconnecting...')
this.ws.close()
return
}
+1 -1
View File
@@ -100,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> / <kbd class="px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px]">Ctrl+C</kbd> to open this anytime.
Tip: Press <kbd class="px-1.5 py-0.5 rounded bg-white/10 font-mono text-[10px]">F</kbd> to open this anytime.
</p>
</div>
</div>
+1 -1
View File
@@ -3,7 +3,7 @@
type="button"
data-controller-ignore
class="w-full flex items-center gap-2 text-white/80 hover:text-white transition-colors"
title="Open CLI (⌘C / Ctrl+C)"
title="Open CLI (F)"
@click="openCLI"
>
<div class="relative shrink-0">
@@ -50,6 +50,15 @@
<!-- Actions -->
<div class="cloud-file-item-actions" @click.stop>
<button
class="cloud-file-action-btn cloud-file-action-share"
title="Share with peers"
@click.stop="$emit('share', item.path, item.name, item.isDir)"
>
<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="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>
</button>
<a
v-if="!item.isDir"
:href="downloadHref"
@@ -92,6 +101,7 @@ const props = defineProps<{
const emit = defineEmits<{
navigate: [path: string]
delete: [path: string]
share: [path: string, name: string, isDir: boolean]
}>()
const cloudStore = useCloudStore()
+11 -1
View File
@@ -80,6 +80,15 @@
<!-- Actions overlay at top-left (visible on hover) -->
<div class="cloud-grid-card-actions" @click.stop>
<button
class="cloud-file-action-btn cloud-file-action-share"
title="Share with peers"
@click.stop="emit('share', item.path, item.name, item.isDir)"
>
<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="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>
</button>
<a
v-if="!item.isDir"
:href="downloadHref"
@@ -96,7 +105,7 @@
v-if="!item.isDir"
class="cloud-file-action-btn cloud-file-action-delete"
title="Delete"
@click.stop="$emit('delete', item.path)"
@click.stop="emit('delete', item.path)"
>
<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" />
@@ -122,6 +131,7 @@ const emit = defineEmits<{
navigate: [path: string]
delete: [path: string]
play: [path: string, name: string]
share: [path: string, name: string, isDir: boolean]
}>()
const cloudStore = useCloudStore()
@@ -40,6 +40,7 @@
@navigate="$emit('navigate', $event)"
@delete="$emit('delete', $event)"
@play="(path, name) => $emit('play', path, name)"
@share="(path, name, isDir) => $emit('share', path, name, isDir)"
/>
</div>
@@ -51,6 +52,7 @@
:item="item"
@navigate="$emit('navigate', $event)"
@delete="$emit('delete', $event)"
@share="(path, name, isDir) => $emit('share', path, name, isDir)"
/>
</div>
</div>
@@ -73,5 +75,6 @@ defineEmits<{
navigate: [path: string]
delete: [path: string]
play: [path: string, name: string]
share: [path: string, name: string, isDir: boolean]
}>()
</script>
@@ -0,0 +1,257 @@
<template>
<Teleport to="body">
<div class="share-modal-backdrop" @click.self="$emit('close')">
<div class="share-modal glass-card">
<!-- Header -->
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-orange-500/15 flex items-center justify-center">
<svg class="w-5 h-5 text-orange-400" 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>
<div>
<h3 class="text-base font-semibold text-white">Share with Peers</h3>
<p class="text-xs text-white/50 truncate max-w-[200px]">{{ filename }}</p>
</div>
</div>
<button class="share-modal-close" @click="$emit('close')">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Share Toggle -->
<div class="share-modal-row">
<div class="flex-1">
<p class="text-sm font-medium text-white/90">Share this {{ isDir ? 'folder' : 'file' }}</p>
<p class="text-xs text-white/50 mt-0.5">Make visible to connected peers</p>
</div>
<label class="share-toggle">
<input type="checkbox" v-model="shared" />
<span class="share-toggle-slider"></span>
</label>
</div>
<!-- Access Type (only when shared) -->
<div v-if="shared" class="mt-4 space-y-3">
<p class="text-xs font-medium text-white/60 uppercase tracking-wider">Access Type</p>
<div class="share-access-options">
<button
class="share-access-option"
:class="{ 'share-access-option-active': accessType === 'free' }"
@click="accessType = 'free'"
>
<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="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm font-medium">Free</span>
<span class="text-xs text-white/40">Open access</span>
</button>
<button
class="share-access-option"
:class="{ 'share-access-option-active': accessType === 'peers_only' }"
@click="accessType = 'peers_only'"
>
<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 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span class="text-sm font-medium">Peers Only</span>
<span class="text-xs text-white/40">Authenticated</span>
</button>
<button
class="share-access-option"
:class="{ 'share-access-option-active': accessType === 'paid' }"
@click="accessType = 'paid'"
>
<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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="text-sm font-medium">Paid</span>
<span class="text-xs text-white/40">Earn sats</span>
</button>
</div>
<!-- Price Input (only for paid) -->
<div v-if="accessType === 'paid'" class="share-price-input-wrap">
<div class="share-price-icon">
<svg class="w-4 h-4 text-orange-400" 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>
</div>
<input
v-model.number="priceSats"
type="number"
min="1"
max="1000000"
placeholder="Price in sats"
class="share-price-input"
/>
<span class="share-price-unit">sats</span>
</div>
</div>
<!-- Status messages -->
<div v-if="saving" class="share-modal-status mt-4">
<div class="w-4 h-4 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
<span class="text-sm text-white/60">Saving...</span>
</div>
<div v-if="errorMsg" class="share-modal-error mt-4">
<span class="text-sm text-red-400">{{ errorMsg }}</span>
</div>
<div v-if="successMsg" class="share-modal-success mt-4">
<svg class="w-4 h-4 text-green-400" 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>
<span class="text-sm text-green-400">{{ successMsg }}</span>
</div>
<!-- Action -->
<div class="flex justify-end gap-3 mt-5">
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="$emit('close')">Cancel</button>
<button
class="glass-button px-5 py-2 rounded-lg text-sm font-medium share-modal-save"
:disabled="saving || (shared && accessType === 'paid' && (!priceSats || priceSats < 1))"
@click="save"
>
{{ shared ? 'Share' : 'Stop Sharing' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
const props = defineProps<{
filename: string
filepath: string
isDir: boolean
existingItemId?: string
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const shared = ref(false)
const accessType = ref<'free' | 'peers_only' | 'paid'>('free')
const priceSats = ref<number>(100)
const saving = ref(false)
const errorMsg = ref<string | null>(null)
const successMsg = ref<string | null>(null)
// If we have an existing item, load its state
onMounted(async () => {
try {
const res = await rpcClient.call<{ items: Array<{
id: string
filename: string
access: { free?: unknown; peersonly?: unknown; paid?: { price_sats: number } } | string
availability: string | { allpeers?: unknown; nobody?: unknown }
}> }>({ method: 'content.list-mine' })
const match = res.items.find(
(i) => i.filename === props.filename || i.filename === props.filepath
)
if (match) {
shared.value = true
const access = match.access
if (typeof access === 'string') {
if (access === 'free') accessType.value = 'free'
else if (access === 'peersonly') accessType.value = 'peers_only'
} else if (access && typeof access === 'object') {
if ('paid' in access && access.paid) {
accessType.value = 'paid'
priceSats.value = access.paid.price_sats || 100
} else if ('peersonly' in access) {
accessType.value = 'peers_only'
}
}
}
} catch {
// Not shared yet, defaults are fine
}
})
async function save() {
saving.value = true
errorMsg.value = null
successMsg.value = null
try {
if (!shared.value) {
// Find and remove from catalog
const res = await rpcClient.call<{ items: Array<{ id: string; filename: string }> }>({
method: 'content.list-mine',
})
const match = res.items.find(
(i) => i.filename === props.filename || i.filename === props.filepath
)
if (match) {
await rpcClient.call({ method: 'content.remove', params: { id: match.id } })
}
successMsg.value = 'Sharing disabled'
} else {
// Check if already in catalog
const res = await rpcClient.call<{ items: Array<{ id: string; filename: string }> }>({
method: 'content.list-mine',
})
let itemId = res.items.find(
(i) => i.filename === props.filename || i.filename === props.filepath
)?.id
// Add if not in catalog
if (!itemId) {
const ext = props.filename.split('.').pop()?.toLowerCase() || ''
const mimeMap: Record<string, string> = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
webp: 'image/webp', mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska',
mp3: 'audio/mpeg', flac: 'audio/flac', ogg: 'audio/ogg', wav: 'audio/wav',
pdf: 'application/pdf', zip: 'application/zip', txt: 'text/plain',
}
const addRes = await rpcClient.call<{ item: { id: string } }>({
method: 'content.add',
params: {
filename: props.filepath || props.filename,
mime_type: mimeMap[ext] || 'application/octet-stream',
description: '',
},
})
itemId = addRes.item.id
}
// Set pricing
const pricingParams: Record<string, unknown> = { id: itemId, access: accessType.value }
if (accessType.value === 'paid') {
pricingParams.price_sats = priceSats.value
}
await rpcClient.call({ method: 'content.set-pricing', params: pricingParams })
// Set availability to all peers
await rpcClient.call({
method: 'content.set-availability',
params: { id: itemId, availability: 'all_peers' },
})
const label =
accessType.value === 'paid'
? `Shared for ${priceSats.value} sats`
: accessType.value === 'peers_only'
? 'Shared with peers'
: 'Shared (free)'
successMsg.value = label
}
setTimeout(() => emit('saved'), 800)
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : 'Failed to update sharing'
} finally {
saving.value = false
}
}
</script>
@@ -43,6 +43,11 @@ export function useMessageToast() {
lastMessageCount.value = msgs.length
}
} catch (e) {
// Stop polling on auth failure — session expired, no point retrying
if (e instanceof Error && /401|Unauthorized/i.test(e.message)) {
stopPolling()
return
}
console.error('Failed to load messages:', e)
} finally {
loadingMessages.value = false
-1
View File
@@ -116,7 +116,6 @@ export const useAppStore = defineStore('app', () => {
}
// 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
+219
View File
@@ -1311,6 +1311,225 @@ html:has(body.video-background-active)::before {
to { opacity: 1; }
}
/* ── Share Modal ──── */
.share-modal-backdrop {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
animation: share-modal-fade-in 0.2s ease-out;
}
@keyframes share-modal-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.share-modal {
width: 100%;
max-width: 28rem;
margin: 1rem;
padding: 1.5rem;
animation: share-modal-slide-in 0.25s ease-out;
}
@keyframes share-modal-slide-in {
from { opacity: 0; transform: translateY(12px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.share-modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
border: none;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.share-modal-close:hover {
background: rgba(255, 255, 255, 0.15);
color: white;
}
.share-modal-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
}
/* Toggle switch */
.share-toggle {
position: relative;
display: inline-flex;
width: 2.75rem;
height: 1.5rem;
flex-shrink: 0;
cursor: pointer;
}
.share-toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.share-toggle-slider {
position: absolute;
inset: 0;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.15);
transition: background-color 0.2s ease;
}
.share-toggle-slider::before {
content: '';
position: absolute;
width: 1.125rem;
height: 1.125rem;
left: 0.1875rem;
bottom: 0.1875rem;
border-radius: 9999px;
background: white;
transition: transform 0.2s ease;
}
.share-toggle input:checked + .share-toggle-slider {
background: #fb923c;
}
.share-toggle input:checked + .share-toggle-slider::before {
transform: translateX(1.25rem);
}
/* Access type options */
.share-access-options {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.share-access-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
padding: 0.75rem 0.5rem;
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
transition: all 0.2s ease;
}
.share-access-option:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.9);
}
.share-access-option-active {
background: rgba(251, 146, 60, 0.1);
border-color: rgba(251, 146, 60, 0.4);
color: #fb923c;
}
.share-access-option-active:hover {
background: rgba(251, 146, 60, 0.15);
border-color: rgba(251, 146, 60, 0.5);
color: #fb923c;
}
/* Price input */
.share-price-input-wrap {
display: flex;
align-items: center;
gap: 0;
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
overflow: hidden;
margin-top: 0.5rem;
}
.share-price-icon {
display: flex;
align-items: center;
justify-content: center;
padding: 0 0.75rem;
flex-shrink: 0;
}
.share-price-input {
flex: 1;
min-width: 0;
padding: 0.625rem 0;
background: transparent;
border: none;
outline: none;
color: white;
font-size: 0.875rem;
font-family: inherit;
}
.share-price-input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.share-price-unit {
padding: 0 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.4);
flex-shrink: 0;
}
/* Status messages */
.share-modal-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: rgba(255, 255, 255, 0.05);
}
.share-modal-error {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.share-modal-success {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: rgba(74, 222, 128, 0.1);
border: 1px solid rgba(74, 222, 128, 0.2);
}
.share-modal-save {
background: rgba(251, 146, 60, 0.15);
border-color: rgba(251, 146, 60, 0.3);
}
.share-modal-save:hover:not(:disabled) {
background: rgba(251, 146, 60, 0.25);
}
.share-modal-save:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Share action button highlight */
.cloud-file-action-share:hover {
background: rgba(251, 146, 60, 0.2);
color: #fb923c;
}
/* Smooth loading → content transition */
.content-fade-enter-active,
.content-fade-leave-active {
+1 -1
View File
@@ -68,7 +68,7 @@ export type PackageState = typeof PackageState[keyof typeof PackageState]
export interface PackageDataEntry {
state: PackageState
'static-files': {
'static-files'?: {
license: string
instructions: string
icon: string
+2 -2
View File
@@ -28,7 +28,7 @@
<div class="hidden md:flex items-center gap-6">
<!-- App Icon -->
<img
:src="pkg['static-files'].icon"
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${pkg.manifest?.id || appId}.png`"
:alt="pkg.manifest.title"
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
@error="handleImageError"
@@ -120,7 +120,7 @@
<div class="flex items-start gap-4 mb-4">
<!-- App Icon -->
<img
:src="pkg['static-files'].icon"
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${pkg.manifest?.id || appId}.png`"
:alt="pkg.manifest.title"
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
@error="handleImageError"
+3 -3
View File
@@ -62,8 +62,8 @@
<div class="flex items-start gap-4">
<img
:src="pkg['static-files'].icon"
:alt="pkg.manifest.title"
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${id}.png`"
:alt="pkg.manifest?.title || String(id)"
class="w-16 h-16 rounded-lg object-cover bg-white/10"
@error="handleImageError"
/>
@@ -72,7 +72,7 @@
{{ pkg.manifest.title }}
</h3>
<p class="text-sm text-white/70 mb-2 truncate">
{{ pkg.manifest.description.short }}
{{ pkg.manifest?.description?.short || '' }}
</p>
<div class="flex items-center gap-2">
<span
+17
View File
@@ -112,6 +112,7 @@
@navigate="cloudStore.navigate($event)"
@delete="handleDelete"
@play="handlePlay"
@share="handleShare"
/>
<!-- Mini Audio Player -->
@@ -152,6 +153,15 @@
/>
</div>
<!-- Share Modal -->
<ShareModal
v-if="shareTarget"
:filename="shareTarget.name"
:filepath="shareTarget.path"
:is-dir="shareTarget.isDir"
@close="shareTarget = null"
@saved="shareTarget = null"
/>
</div>
</template>
@@ -162,6 +172,7 @@ import { useAppStore } from '../stores/app'
import { useCloudStore } from '../stores/cloud'
import CloudToolbar from '../components/cloud/CloudToolbar.vue'
import FileGrid from '../components/cloud/FileGrid.vue'
import ShareModal from '../components/cloud/ShareModal.vue'
import { useAudioPlayer } from '../composables/useAudioPlayer'
const router = useRouter()
@@ -284,6 +295,12 @@ watch(useNativeUI, async (native) => {
}
}, { immediate: true })
const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(null)
function handleShare(path: string, name: string, isDir: boolean) {
shareTarget.value = { path, name, isDir }
}
const uploadError = ref<string | null>(null)
const draggingOver = ref(false)
let dragLeaveTimer: ReturnType<typeof setTimeout> | null = null
+10 -13
View File
@@ -483,20 +483,17 @@ const isResettingOnboarding = ref(false)
async function restartOnboarding() {
if (isResettingOnboarding.value) return
isResettingOnboarding.value = true
try {
await rpcClient.resetOnboarding()
localStorage.removeItem('neode_onboarding_complete')
localStorage.removeItem('neode_did')
localStorage.removeItem('neode_did_state')
localStorage.removeItem('neode_backup_created')
await router.push('/onboarding/intro')
// Local-only reset no RPC needed since user isn't logged in.
// Onboarding pages are all public, so clearing localStorage is enough.
localStorage.removeItem('neode_onboarding_complete')
localStorage.removeItem('neode_did')
localStorage.removeItem('neode_did_state')
localStorage.removeItem('neode_backup_created')
router.push('/onboarding/intro').then(() => {
window.location.reload()
} catch (err) {
console.error('Failed to reset onboarding:', err)
error.value = err instanceof Error ? err.message : 'Failed to reset onboarding'
} finally {
isResettingOnboarding.value = false
}
}).catch(() => {
window.location.href = '/onboarding/intro'
})
}
</script>
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="max-w-2xl w-full">
<div class="glass-card p-12 pt-20 text-center relative overflow-visible onb-card">
<!-- Logo - half in, half out of container -->
<div class="absolute -top-10 left-1/2 -translate-x-1/2 z-10 onb-logo">
<div class="absolute -top-10 left-0 right-0 flex justify-center z-10 onb-logo">
<div class="logo-gradient-border w-20 h-20">
<AnimatedLogo no-border fit />
</div>