feat(cloud): Apps-style tabs, categories and federated file search #84

Merged
ai merged 2 commits from cloud-tabs-search into main 2026-07-15 09:02:57 +00:00

View File

@ -1,10 +1,163 @@
<template>
<div class="pb-6">
<div class="apps-view pb-6">
<!-- Content Type Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Nav header tabs + categories + search, matching the Apps layout -->
<div class="mb-4">
<!-- Desktop: page tabs + category tabs + search on one row -->
<div class="app-header-desktop items-center gap-4">
<div class="flex-shrink-0">
<div class="mode-switcher hidden md:inline-flex">
<button
v-for="tab in TABS"
:key="tab.id"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': activeTab === tab.id }"
@click="activeTab = tab.id"
>{{ tab.name }}</button>
</div>
</div>
<div class="mode-switcher category-tabs-wide hidden md:inline-flex">
<button
v-for="category in CATEGORIES"
:key="category.id"
@click="selectedCategory = category.id"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
>{{ category.name }}</button>
</div>
<div class="app-header-search-wrap flex items-center gap-2">
<input
v-model="searchQuery"
type="text"
placeholder="Search your files and peer files…"
aria-label="Search files"
data-controller-no-submit
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
/>
</div>
</div>
<!-- Mobile: pill strips (NOT the fixed top tabs those stay page-level
navigation) + search below. .mobile-category-strip also opts these
rows out of the dashboard's swipe-to-switch-page gesture. -->
<div class="app-header-mobile mb-4">
<div class="mobile-category-strip mb-2" aria-label="Cloud tabs">
<button
v-for="tab in TABS"
:key="tab.id"
@click="activeTab = tab.id"
class="mobile-category-pill"
:class="{ 'mobile-category-pill-active': activeTab === tab.id }"
type="button"
>{{ tab.name }}</button>
</div>
<div class="mobile-category-strip mb-3" aria-label="File categories">
<button
v-for="category in CATEGORIES"
:key="category.id"
@click="selectedCategory = category.id"
class="mobile-category-pill"
:class="{ 'mobile-category-pill-active': selectedCategory === category.id }"
type="button"
>{{ category.name }}</button>
</div>
<input
v-model="searchQuery"
type="text"
placeholder="Search your files and peer files…"
aria-label="Search files"
data-controller-no-submit
class="app-header-search w-full min-w-0 text-white placeholder-white/50 focus:outline-none transition-colors"
/>
</div>
</div>
<!-- Search results (any tab, when a query is active) -->
<div v-if="searchActive">
<div v-if="searching" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3">
<svg class="animate-spin h-4 w-4" 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>
Searching your files and peers
</div>
<template v-else>
<div v-if="filteredSearchResults.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
No files match {{ searchQuery }}.
</div>
<div v-else class="space-y-2">
<button
v-for="r in filteredSearchResults"
:key="r.key"
class="w-full glass-card px-4 py-3 flex items-center gap-3 text-left hover:bg-white/10 transition-colors"
@click="openSearchResult(r)"
>
<span class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" :class="categoryMeta(r.category).iconBg">
<svg class="w-5 h-5" :class="categoryMeta(r.category).iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-for="(p, i) in categoryMeta(r.category).iconPaths" :key="i" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="p" />
</svg>
</span>
<span class="flex-1 min-w-0">
<span class="block text-sm text-white truncate">{{ r.name }}</span>
<span class="block text-[11px] text-white/40 truncate">{{ r.detail }}</span>
</span>
<span
class="text-[10px] px-2 py-0.5 rounded-full shrink-0"
:class="r.source === 'mine' ? 'bg-blue-500/15 text-blue-400' : 'bg-purple-500/15 text-purple-400'"
>{{ r.source === 'mine' ? 'My Files' : r.peerName }}</span>
</button>
</div>
</template>
</div>
<!-- Peer Files tab every file shared by every peer -->
<div v-else-if="activeTab === 'peers'">
<div v-if="peerFilesLoading" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3">
<svg class="animate-spin h-4 w-4" 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>
Fetching files from {{ peerNodes.length || '' }} peer{{ peerNodes.length === 1 ? '' : 's' }}
</div>
<template v-else>
<div v-if="peerNodes.length === 0" class="glass-card p-8 text-center">
<p class="text-white/60 mb-3">No peers yet. Set up federation to browse files shared by other nodes.</p>
<RouterLink to="/dashboard/server/federation" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
Open Federation
</RouterLink>
</div>
<div v-else-if="filteredPeerFiles.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
{{ selectedCategory === 'all' ? 'Your peers are not sharing any files yet.' : 'No peer files in this category.' }}
</div>
<div v-else class="space-y-2">
<button
v-for="f in filteredPeerFiles"
:key="f.key"
class="w-full glass-card px-4 py-3 flex items-center gap-3 text-left hover:bg-white/10 transition-colors"
@click="router.push({ name: 'peer-files', params: { peerId: f.peerOnion } })"
>
<span class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0" :class="categoryMeta(f.category).iconBg">
<svg class="w-5 h-5" :class="categoryMeta(f.category).iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-for="(p, i) in categoryMeta(f.category).iconPaths" :key="i" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="p" />
</svg>
</span>
<span class="flex-1 min-w-0">
<span class="block text-sm text-white truncate">{{ f.filename }}</span>
<span class="block text-[11px] text-white/40 truncate">{{ formatSize(f.sizeBytes) }}<template v-if="f.priceSats"> · {{ f.priceSats.toLocaleString() }} sats</template></span>
</span>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 shrink-0">{{ f.peerName }}</span>
</button>
</div>
<p v-if="peerFilesErrors > 0" class="text-[11px] text-white/35 text-center mt-3">
{{ peerFilesErrors }} peer{{ peerFilesErrors === 1 ? '' : 's' }} unreachable showing what answered.
</p>
</template>
</div>
<!-- All Files / My Files section (+ peer) cards -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="section in contentSections"
v-for="section in visibleSections"
:key="section.id"
data-controller-container
tabindex="0"
@ -48,90 +201,93 @@
<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" />
<!-- Individual Peer Cards (All Files tab only) -->
<template v-if="activeTab === 'all'">
<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-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 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>
<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" />
</div>
<div
v-if="peersLoading && peerNodes.length > 0"
class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2"
>
<svg class="animate-spin h-3.5 w-3.5" 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>
Refreshing peer nodes...
</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>
<div
v-if="peersLoading && peerNodes.length > 0"
class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2"
>
<svg class="animate-spin h-3.5 w-3.5" 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>
Refreshing peer nodes...
</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" />
<!-- No Peers placeholder (only if no peers found) -->
<div
v-if="!peersLoading && peerNodes.length === 0 && selectedCategory === 'all'"
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-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 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>
<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>
</template>
</div>
<!-- Error State -->
<div v-if="loadError" class="alert-error mb-4">
<div v-if="loadError" class="alert-error mt-4">
{{ loadError }}
</div>
<!-- Not Installed Hint -->
<div v-if="!fileBrowserRunning" class="glass-card p-8 mt-6 text-center">
<div v-if="!fileBrowserRunning && !searchActive && activeTab !== 'peers'" 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
@ -141,7 +297,7 @@
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { computed, ref, watch, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app'
import { fileBrowserClient } from '@/api/filebrowser-client'
@ -152,6 +308,27 @@ const store = useAppStore()
const sectionCounts = ref<Record<string, number>>({})
const countsLoading = ref(false)
// Tabs / categories / search state
type TabId = 'all' | 'mine' | 'peers'
type CategoryId = 'all' | 'photos' | 'music' | 'documents'
const TABS: Array<{ id: TabId; name: string }> = [
{ id: 'all', name: 'All Files' },
{ id: 'mine', name: 'My Files' },
{ id: 'peers', name: 'Peer Files' },
]
const CATEGORIES: Array<{ id: CategoryId; name: string }> = [
{ id: 'all', name: 'All' },
{ id: 'photos', name: 'Photos & Video' },
{ id: 'music', name: 'Music' },
{ id: 'documents', name: 'Documents' },
]
const activeTab = ref<TabId>('all')
const selectedCategory = ref<CategoryId>('all')
const searchQuery = ref('')
const searchActive = computed(() => searchQuery.value.trim().length > 0)
interface PeerNode {
did: string
pubkey: string
@ -237,6 +414,13 @@ const contentSections: ContentSection[] = [
},
]
/** Section cards for the current tab, narrowed by the category pills. The
* browse-everything "All Files" card only makes sense unfiltered. */
const visibleSections = computed(() => {
if (selectedCategory.value === 'all') return contentSections
return contentSections.filter(s => s.id === selectedCategory.value)
})
const SECTION_PATHS: Record<string, string> = {
photos: '/Photos',
music: '/Music',
@ -244,6 +428,208 @@ const SECTION_PATHS: Record<string, string> = {
files: '/',
}
// Category helpers
function categoryOf(nameOrMime: string): Exclude<CategoryId, 'all'> {
const s = nameOrMime.toLowerCase()
if (s.startsWith('image/') || s.startsWith('video/') || /\.(jpe?g|png|gif|webp|heic|svg|mp4|mov|mkv|webm|avi)$/.test(s)) return 'photos'
if (s.startsWith('audio/') || /\.(mp3|flac|wav|ogg|m4a|aac|opus)$/.test(s)) return 'music'
return 'documents'
}
const FALLBACK_SECTION: ContentSection = contentSections[2]!
function categoryMeta(cat: Exclude<CategoryId, 'all'>): ContentSection {
return contentSections.find(s => s.id === cat) ?? FALLBACK_SECTION
}
function formatSize(bytes: number): string {
if (!bytes) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
// Peer files (aggregated across every federation peer)
interface PeerFileEntry {
key: string
filename: string
sizeBytes: number
priceSats: number
category: Exclude<CategoryId, 'all'>
peerName: string
peerOnion: string
}
const peerFiles = ref<PeerFileEntry[]>([])
const peerFilesLoading = ref(false)
const peerFilesLoaded = ref(false)
const peerFilesErrors = ref(0)
interface CatalogItem {
id: string
filename: string
mime_type: string
size_bytes: number
description: string
access: string | { paid: { price_sats: number } }
}
function priceOf(access: CatalogItem['access']): number {
return typeof access === 'object' && access?.paid ? access.paid.price_sats : 0
}
/** Fan out content.browse-peer over every federation node; tolerate stragglers. */
async function loadPeerFiles(force = false) {
if (peerFilesLoading.value || (peerFilesLoaded.value && !force)) return
peerFilesLoading.value = true
peerFilesErrors.value = 0
try {
if (peerNodes.value.length === 0) await loadPeers()
const results = await Promise.allSettled(
peerNodes.value.map(async (peer) => {
const res = await rpcClient.call<{ items?: CatalogItem[] }>({
method: 'content.browse-peer',
params: { onion: peer.onion },
timeout: 30000,
})
return { peer, items: res?.items ?? [] }
}),
)
const merged: PeerFileEntry[] = []
for (const r of results) {
if (r.status !== 'fulfilled') { peerFilesErrors.value++; continue }
const { peer, items } = r.value
const peerName = peer.name || peerDisplayName(peer.did)
for (const item of items) {
merged.push({
key: `${peer.onion}:${item.id}`,
filename: item.filename,
sizeBytes: item.size_bytes,
priceSats: priceOf(item.access),
category: categoryOf(item.mime_type || item.filename),
peerName,
peerOnion: peer.onion,
})
}
}
merged.sort((a, b) => a.filename.localeCompare(b.filename))
peerFiles.value = merged
peerFilesLoaded.value = true
} finally {
peerFilesLoading.value = false
}
}
const filteredPeerFiles = computed(() =>
selectedCategory.value === 'all'
? peerFiles.value
: peerFiles.value.filter(f => f.category === selectedCategory.value),
)
// Fetch the aggregated list lazily, the first time the tab (or a search) needs it.
watch(activeTab, (tab) => { if (tab === 'peers') void loadPeerFiles() })
// Search (own files + all peer files)
interface SearchResult {
key: string
name: string
detail: string
category: Exclude<CategoryId, 'all'>
source: 'mine' | 'peer'
peerName?: string
peerOnion?: string
sectionId?: string
}
const searching = ref(false)
const searchResults = ref<SearchResult[]>([])
let searchTimer: ReturnType<typeof setTimeout> | null = null
let searchSeq = 0
watch(searchQuery, () => {
if (searchTimer) clearTimeout(searchTimer)
if (!searchActive.value) { searchResults.value = []; searching.value = false; return }
searching.value = true
searchTimer = setTimeout(() => void runSearch(), 350)
})
/** Depth-limited walk of the own-file sections via the FileBrowser API. */
async function searchOwnFiles(query: string): Promise<SearchResult[]> {
if (!fileBrowserRunning.value) return []
const q = query.toLowerCase()
const out: SearchResult[] = []
try {
const ok = await fileBrowserClient.login()
if (!ok) return []
for (const [sectionId, root] of Object.entries(SECTION_PATHS)) {
if (sectionId === 'files') continue // '/' would double-visit the sections
const queue: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }]
while (queue.length > 0 && out.length < 100) {
const { path, depth } = queue.shift()!
let items
try { items = await fileBrowserClient.listDirectory(path) } catch { continue }
for (const item of items) {
const itemPath = item.path || `${path.replace(/\/$/, '')}/${item.name}`
if (item.isDir) {
if (depth < 3) queue.push({ path: itemPath, depth: depth + 1 })
} else if (item.name.toLowerCase().includes(q)) {
out.push({
key: `mine:${itemPath}`,
name: item.name,
detail: itemPath,
category: categoryOf(item.name),
source: 'mine',
sectionId,
})
}
}
}
}
} catch { /* own-file search is best-effort; peer results still render */ }
return out
}
async function runSearch() {
const query = searchQuery.value.trim()
const seq = ++searchSeq
searching.value = true
try {
// Peer catalog piggybacks on the aggregated list (cached after first load).
const [mine] = await Promise.all([searchOwnFiles(query), loadPeerFiles()])
if (seq !== searchSeq) return // a newer query superseded this run
const q = query.toLowerCase()
const peers: SearchResult[] = peerFiles.value
.filter(f => f.filename.toLowerCase().includes(q))
.map(f => ({
key: `peer:${f.key}`,
name: f.filename,
detail: `${formatSize(f.sizeBytes)}${f.priceSats ? ` · ${f.priceSats.toLocaleString()} sats` : ''}`,
category: f.category,
source: 'peer' as const,
peerName: f.peerName,
peerOnion: f.peerOnion,
}))
searchResults.value = [...mine, ...peers]
} finally {
if (seq === searchSeq) searching.value = false
}
}
const filteredSearchResults = computed(() =>
selectedCategory.value === 'all'
? searchResults.value
: searchResults.value.filter(r => r.category === selectedCategory.value),
)
function openSearchResult(r: SearchResult) {
if (r.source === 'peer' && r.peerOnion) {
router.push({ name: 'peer-files', params: { peerId: r.peerOnion } })
} else if (r.sectionId) {
router.push({ name: 'cloud-folder', params: { folderId: r.sectionId } })
}
}
// Existing counts / peers loading
async function loadCounts() {
if (!fileBrowserRunning.value) return
countsLoading.value = true