- Federation: nodeName() with Node-XXXX fallback for all views + map + sync results - Cloud: peerDisplayName() replaces raw DIDs, hides onion addresses - Sync timeout increased to 180s for Tor-connected nodes - Better error message when sync fails Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
287 lines
11 KiB
Vue
287 lines
11 KiB
Vue
<template>
|
|
<div class="pb-6">
|
|
|
|
<!-- Content Type Cards -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<div
|
|
v-for="section in contentSections"
|
|
:key="section.id"
|
|
data-controller-container
|
|
tabindex="0"
|
|
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
|
|
@click="openSection(section)"
|
|
>
|
|
<div class="flex items-center gap-4 mb-4">
|
|
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center" :class="section.iconBg">
|
|
<svg class="w-7 h-7" :class="section.iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
v-for="(path, index) in section.iconPaths"
|
|
: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-0.5 truncate">{{ section.name }}</h3>
|
|
<p class="text-xs text-white/50">{{ section.description }}</p>
|
|
</div>
|
|
<!-- Arrow indicator -->
|
|
<svg class="w-5 h-5 text-white/30" 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>
|
|
</div>
|
|
|
|
<!-- App status -->
|
|
<div class="flex items-center gap-2 text-xs">
|
|
<span
|
|
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full"
|
|
:class="isAppRunning(section.appId) ? 'bg-green-500/15 text-green-400' : 'bg-white/5 text-white/40'"
|
|
>
|
|
<span class="w-1.5 h-1.5 rounded-full" :class="isAppRunning(section.appId) ? 'bg-green-400' : 'bg-white/30'"></span>
|
|
{{ section.appLabel }}
|
|
</span>
|
|
<span v-if="!isAppRunning(section.appId)" class="text-white/30">Not installed</span>
|
|
<span v-else-if="countsLoading" class="text-white/30 animate-pulse">Loading...</span>
|
|
<span v-else-if="sectionCounts[section.id] !== undefined" class="text-white/30">{{ sectionCounts[section.id] }} items</span>
|
|
</div>
|
|
</div>
|
|
<!-- Individual Peer Cards -->
|
|
<div
|
|
v-for="peer in peerNodes"
|
|
:key="peer.did"
|
|
data-controller-container
|
|
tabindex="0"
|
|
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
|
|
@click="router.push({ name: 'peer-files', params: { peerId: peer.onion } })"
|
|
>
|
|
<div class="flex items-center gap-4 mb-4">
|
|
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
|
|
<svg class="w-7 h-7 text-purple-400" 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-2" />
|
|
</svg>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<h3 class="text-lg font-semibold text-white mb-0.5 truncate" :title="peer.did">{{ peer.name || peerDisplayName(peer.did) }}</h3>
|
|
<p class="text-xs text-white/40 truncate">{{ peer.name ? peer.did.slice(0, 20) + '...' : 'Peer node' }}</p>
|
|
</div>
|
|
<svg class="w-5 h-5 text-white/30" 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>
|
|
</div>
|
|
<div class="flex items-center gap-2 text-xs">
|
|
<span
|
|
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full"
|
|
:class="peer.trust_level === 'trusted' ? 'bg-green-500/15 text-green-400' : 'bg-purple-500/15 text-purple-400'"
|
|
>
|
|
<span class="w-1.5 h-1.5 rounded-full" :class="peer.trust_level === 'trusted' ? 'bg-green-400' : 'bg-purple-400'"></span>
|
|
{{ peer.trust_level }}
|
|
</span>
|
|
<span class="text-white/30">Peer Node</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- No Peers placeholder (only if no peers found) -->
|
|
<div
|
|
v-if="!peersLoading && peerNodes.length === 0"
|
|
data-controller-container
|
|
tabindex="0"
|
|
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
|
|
@click="router.push('/dashboard/server/federation')"
|
|
>
|
|
<div class="flex items-center gap-4 mb-4">
|
|
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
|
|
<svg class="w-7 h-7 text-purple-400" 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 min-w-0">
|
|
<h3 class="text-lg font-semibold text-white mb-0.5 truncate">Peer Files</h3>
|
|
<p class="text-xs text-white/50">Set up federation to share files with peers</p>
|
|
</div>
|
|
<svg class="w-5 h-5 text-white/30" 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>
|
|
</div>
|
|
<div class="flex items-center gap-2 text-xs">
|
|
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full bg-white/5 text-white/40">
|
|
<span class="w-1.5 h-1.5 rounded-full bg-white/30"></span>
|
|
No peers yet
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div v-if="loadError" class="alert-error mb-4">
|
|
{{ loadError }}
|
|
</div>
|
|
|
|
<!-- Not Installed Hint -->
|
|
<div v-if="!fileBrowserRunning" class="glass-card p-8 mt-6 text-center">
|
|
<p class="text-white/60 mb-3">Install File Browser from the App Store to get started with your cloud storage.</p>
|
|
<RouterLink to="/dashboard/marketplace" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
|
|
Open App Store
|
|
</RouterLink>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, onMounted } from 'vue'
|
|
import { useRouter, RouterLink } from 'vue-router'
|
|
import { useAppStore } from '../stores/app'
|
|
import { fileBrowserClient } from '@/api/filebrowser-client'
|
|
import { rpcClient } from '@/api/rpc-client'
|
|
|
|
const router = useRouter()
|
|
const store = useAppStore()
|
|
const sectionCounts = ref<Record<string, number>>({})
|
|
const countsLoading = ref(false)
|
|
|
|
interface PeerNode {
|
|
did: string
|
|
pubkey: string
|
|
onion: string
|
|
name?: string
|
|
trust_level: string
|
|
}
|
|
|
|
const peerNodes = ref<PeerNode[]>([])
|
|
const peersLoading = ref(true)
|
|
const loadError = ref('')
|
|
|
|
const APP_ALIASES: Record<string, string[]> = {
|
|
immich: ['immich_server', 'immich-server'],
|
|
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
|
}
|
|
|
|
function isAppRunning(appId: string): boolean {
|
|
const packages = store.packages
|
|
if (packages[appId]?.state === 'running') return true
|
|
const aliases = APP_ALIASES[appId]
|
|
if (aliases) {
|
|
for (const alias of aliases) {
|
|
if (packages[alias]?.state === 'running') return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
const fileBrowserRunning = computed(() => isAppRunning('filebrowser'))
|
|
|
|
interface ContentSection {
|
|
id: string
|
|
name: string
|
|
description: string
|
|
appId: string
|
|
appLabel: string
|
|
iconPaths: string[]
|
|
iconBg: string
|
|
iconColor: string
|
|
}
|
|
|
|
const contentSections: ContentSection[] = [
|
|
{
|
|
id: 'photos',
|
|
name: 'Photos & Videos',
|
|
description: 'Auto-backup & browse your media',
|
|
appId: 'filebrowser',
|
|
appLabel: 'File Browser',
|
|
iconPaths: ['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'],
|
|
iconBg: 'bg-blue-500/15',
|
|
iconColor: 'text-blue-400',
|
|
},
|
|
{
|
|
id: 'music',
|
|
name: 'Music',
|
|
description: 'Your music collection',
|
|
appId: 'filebrowser',
|
|
appLabel: 'File Browser',
|
|
iconPaths: ['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'],
|
|
iconBg: 'bg-orange-500/15',
|
|
iconColor: 'text-orange-400',
|
|
},
|
|
{
|
|
id: 'documents',
|
|
name: 'Documents',
|
|
description: 'Files, docs & spreadsheets',
|
|
appId: 'filebrowser',
|
|
appLabel: 'File Browser',
|
|
iconPaths: ['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'],
|
|
iconBg: 'bg-green-500/15',
|
|
iconColor: 'text-green-400',
|
|
},
|
|
{
|
|
id: 'files',
|
|
name: 'All Files',
|
|
description: 'Browse your server file system',
|
|
appId: 'filebrowser',
|
|
appLabel: 'File Browser',
|
|
iconPaths: ['M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z'],
|
|
iconBg: 'bg-white/10',
|
|
iconColor: 'text-white/70',
|
|
},
|
|
]
|
|
|
|
const SECTION_PATHS: Record<string, string> = {
|
|
photos: '/Photos',
|
|
music: '/Music',
|
|
documents: '/Documents',
|
|
files: '/',
|
|
}
|
|
|
|
async function loadCounts() {
|
|
if (!fileBrowserRunning.value) return
|
|
countsLoading.value = true
|
|
try {
|
|
const ok = await fileBrowserClient.login()
|
|
if (!ok) return
|
|
for (const section of contentSections) {
|
|
const path = SECTION_PATHS[section.id]
|
|
if (!path) continue
|
|
try {
|
|
const items = await fileBrowserClient.listDirectory(path)
|
|
sectionCounts.value[section.id] = items.length
|
|
} catch {
|
|
sectionCounts.value[section.id] = 0
|
|
}
|
|
}
|
|
} catch (e) {
|
|
loadError.value = e instanceof Error ? e.message : 'Failed to load file counts'
|
|
if (import.meta.env.DEV) console.warn('FileBrowser count loading failed', e)
|
|
} finally {
|
|
countsLoading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadCounts()
|
|
loadPeers()
|
|
})
|
|
|
|
async function loadPeers() {
|
|
peersLoading.value = true
|
|
try {
|
|
const result = await rpcClient.federationListNodes()
|
|
peerNodes.value = result?.nodes ?? []
|
|
} catch (e) {
|
|
peerNodes.value = []
|
|
loadError.value = e instanceof Error ? e.message : 'Failed to load peer nodes'
|
|
} finally {
|
|
peersLoading.value = false
|
|
}
|
|
}
|
|
|
|
function peerDisplayName(did: string): string {
|
|
const suffix = did.replace(/^did:key:z6Mk/, '').slice(-6).toUpperCase()
|
|
return `Node-${suffix}`
|
|
}
|
|
|
|
function openSection(section: ContentSection) {
|
|
router.push({ name: 'cloud-folder', params: { folderId: section.id } })
|
|
}
|
|
</script>
|