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:
co-authored by
Claude Opus 4.6
parent
9c7ffbb263
commit
a2aa9657b1
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user