feat(cloud): Folders/My Files/Peer Files polish + readable file rows

- Top-level Cloud tabs get their own orange-tinted switcher (clearly
  distinct from the category pills); full-width thirds on mobile
- 'All Files' tab renamed Folders; categories only show on the file-list
  tabs (My Files / Peer Files / search)
- My Files is now a flat list of every own file rendered with FileCard —
  real actions (share/download/delete) and clicking opens the FILE
  (media lightbox incl. audio, downloads for documents), never a folder;
  own search results get the same treatment
- Folder list rows get card backgrounds (readable over the wallpaper),
  same info as before

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-15 10:35:42 +01:00
parent bdf283fcff
commit 8f6312fe7b
20 changed files with 328 additions and 172 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -110,7 +110,7 @@ const imgFailed = ref(false)
const ext = computed(() => props.item.extension)
const isDir = computed(() => props.item.isDir)
const { isImage, isVideo, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
const { isImage, isVideo, isAudio, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
const thumbnailUrl = computed(() => {
if (!isImage.value || imgFailed.value) return null
@ -122,8 +122,12 @@ const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
function handleClick() {
if (props.item.isDir) {
emit('navigate', props.item.path)
} else if (isImage.value || isVideo.value) {
} else if (isImage.value || isVideo.value || isAudio.value) {
// MediaLightbox handles all three media kinds.
emit('preview', props.item.path)
} else {
// Non-media files open by downloading a click should always act on the file.
window.location.href = downloadHref.value
}
}
</script>

View File

@ -1715,26 +1715,28 @@ html.modal-scroll-locked .dashboard-scroll-panel {
.cloud-file-list {
display: flex;
flex-direction: column;
gap: 0.125rem;
gap: 0.5rem;
padding-bottom: 1rem;
}
.cloud-file-item {
display: flex;
gap: 0.75rem;
padding: 0.5rem;
padding: 0.625rem 0.75rem;
border-radius: 0.75rem;
transition: background-color 0.2s ease;
text-align: left;
width: 100%;
cursor: pointer;
background: none;
border: none;
/* Readable card rows (like the Peer Files list) bare text over the
wallpaper was unreadable. */
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.08);
color: inherit;
align-items: center;
}
.cloud-file-item:hover {
background: rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.08);
}
.cloud-file-item:active {
background: rgba(255, 255, 255, 0.1);
@ -2087,6 +2089,7 @@ html.modal-scroll-locked .dashboard-scroll-panel {
.mobile-category-pill {
flex: 0 0 auto;
white-space: nowrap;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);

View File

@ -6,17 +6,17 @@
<!-- 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">
<div class="cloud-tab-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 }"
class="cloud-tab-btn"
:class="{ 'cloud-tab-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">
<div v-show="showCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex">
<button
v-for="category in CATEGORIES"
:key="category.id"
@ -37,21 +37,21 @@
</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. -->
<!-- Mobile: full-width tab switcher (distinct from the category pills),
category pill strip, then search. .mobile-category-strip opts the
pills 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">
<div class="cloud-tab-switcher cloud-tab-switcher-full mb-3">
<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 }"
class="cloud-tab-btn"
:class="{ 'cloud-tab-btn-active': activeTab === tab.id }"
type="button"
>{{ tab.name }}</button>
</div>
<div class="mobile-category-strip mb-3" aria-label="File categories">
<div v-if="showCategories" class="mobile-category-strip mb-3" aria-label="File categories">
<button
v-for="category in CATEGORIES"
:key="category.id"
@ -85,12 +85,20 @@
<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">
<div v-else class="cloud-file-list">
<template v-for="r in filteredSearchResults" :key="r.key">
<!-- Own files act like real file rows: actions + click opens the file -->
<FileCard
v-if="r.item"
:item="r.item"
@delete="handleDelete"
@share="handleShare"
@preview="(p: string) => handlePreview(p, searchMineItems)"
/>
<button
v-for="r in filteredSearchResults"
:key="r.key"
v-else
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)"
@click="router.push({ name: 'peer-files', params: { peerId: r.peerOnion } })"
>
<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">
@ -101,15 +109,43 @@
<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>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 shrink-0">{{ r.peerName }}</span>
</button>
</template>
</div>
</template>
</div>
<!-- My Files every own file, flat, with real file actions -->
<div v-else-if="activeTab === 'mine'">
<div v-if="myFilesLoading" 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>
Loading your files
</div>
<div v-else-if="!fileBrowserRunning" class="glass-card p-8 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 v-else-if="filteredMyFiles.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
{{ selectedCategory === 'all' ? 'No files yet — upload some from the Folders tab.' : 'No files in this category.' }}
</div>
<div v-else class="cloud-file-list">
<FileCard
v-for="item in filteredMyFiles"
:key="item.path"
:item="item"
@delete="handleDelete"
@share="handleShare"
@preview="(p: string) => handlePreview(p, filteredMyFiles)"
/>
</div>
</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">
@ -154,10 +190,10 @@
</template>
</div>
<!-- All Files / My Files section (+ peer) cards -->
<!-- Folders 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 visibleSections"
v-for="section in contentSections"
:key="section.id"
data-controller-container
tabindex="0"
@ -202,8 +238,7 @@
</div>
</div>
<!-- Individual Peer Cards (All Files tab only) -->
<template v-if="activeTab === 'all'">
<!-- Individual Peer Cards -->
<div
v-for="peer in peerNodes"
:key="peer.did"
@ -251,7 +286,7 @@
<!-- No Peers placeholder (only if no peers found) -->
<div
v-if="!peersLoading && peerNodes.length === 0 && selectedCategory === 'all'"
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"
@ -278,7 +313,6 @@
</span>
</div>
</div>
</template>
</div>
<!-- Error State -->
@ -286,13 +320,34 @@
{{ loadError }}
</div>
<!-- Not Installed Hint -->
<div v-if="!fileBrowserRunning && !searchActive && activeTab !== 'peers'" class="glass-card p-8 mt-6 text-center">
<!-- Not Installed Hint (Folders tab only My Files has its own) -->
<div v-if="!fileBrowserRunning && !searchActive && activeTab === 'folders'" 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>
<!-- Share with peers -->
<ShareModal
v-if="shareTarget"
:filename="shareTarget.name"
:filepath="shareTarget.path"
:is-dir="shareTarget.isDir"
@close="shareTarget = null"
@saved="shareTarget = null"
/>
<!-- Media viewer for own files -->
<MediaLightbox
v-if="lightboxIndex !== null"
:items="lightboxItems"
:start-index="lightboxIndex"
:show="lightboxIndex !== null"
:fetch-blob-url="cloudStore.fetchBlobUrl"
:stream-url="cloudStore.streamUrl"
@close="lightboxIndex = null"
/>
</div>
</template>
@ -300,20 +355,26 @@
import { computed, ref, watch, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app'
import { fileBrowserClient } from '@/api/filebrowser-client'
import { useCloudStore } from '../stores/cloud'
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
import { rpcClient } from '@/api/rpc-client'
import { getFileCategory } from '../composables/useFileType'
import FileCard from '../components/cloud/FileCard.vue'
import ShareModal from '../components/cloud/ShareModal.vue'
import MediaLightbox from '../components/cloud/MediaLightbox.vue'
const router = useRouter()
const store = useAppStore()
const cloudStore = useCloudStore()
const sectionCounts = ref<Record<string, number>>({})
const countsLoading = ref(false)
// Tabs / categories / search state
type TabId = 'all' | 'mine' | 'peers'
type TabId = 'folders' | 'mine' | 'peers'
type CategoryId = 'all' | 'photos' | 'music' | 'documents'
const TABS: Array<{ id: TabId; name: string }> = [
{ id: 'all', name: 'All Files' },
{ id: 'folders', name: 'Folders' },
{ id: 'mine', name: 'My Files' },
{ id: 'peers', name: 'Peer Files' },
]
@ -324,10 +385,12 @@ const CATEGORIES: Array<{ id: CategoryId; name: string }> = [
{ id: 'documents', name: 'Documents' },
]
const activeTab = ref<TabId>('all')
const activeTab = ref<TabId>('folders')
const selectedCategory = ref<CategoryId>('all')
const searchQuery = ref('')
const searchActive = computed(() => searchQuery.value.trim().length > 0)
// Categories narrow file LISTS; the Folders tab is already organized by kind.
const showCategories = computed(() => activeTab.value !== 'folders' || searchActive.value)
interface PeerNode {
did: string
@ -414,13 +477,6 @@ 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',
@ -449,6 +505,88 @@ function formatSize(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
// My Files (flat list of every own file across the sections)
const myFiles = ref<FileBrowserItem[]>([])
const myFilesLoading = ref(false)
const myFilesLoaded = ref(false)
/** Depth-limited walk of the section folders; flat file list, capped. */
async function loadMyFiles(force = false) {
if (myFilesLoading.value || (myFilesLoaded.value && !force)) return
if (!fileBrowserRunning.value) { myFilesLoaded.value = true; return }
myFilesLoading.value = true
try {
const ok = await cloudStore.init()
if (!ok) return
const out: FileBrowserItem[] = []
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 < 500) {
const { path, depth } = queue.shift()!
let items: FileBrowserItem[]
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 {
out.push({ ...item, path: itemPath })
}
}
}
}
out.sort((a, b) => a.name.localeCompare(b.name))
myFiles.value = out
myFilesLoaded.value = true
} finally {
myFilesLoading.value = false
}
}
const filteredMyFiles = computed(() =>
selectedCategory.value === 'all'
? myFiles.value
: myFiles.value.filter(f => categoryOf(f.name) === selectedCategory.value),
)
watch(activeTab, (tab) => {
if (tab === 'mine') void loadMyFiles()
if (tab === 'peers') void loadPeerFiles()
if (tab === 'folders') selectedCategory.value = 'all' // no hidden filter behind the cards
})
// File actions shared by My Files rows and own-file search results
const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(null)
const lightboxIndex = ref<number | null>(null)
const lightboxItems = ref<FileBrowserItem[]>([])
function handleShare(path: string, name: string, isDir: boolean) {
shareTarget.value = { path, name, isDir }
}
function handlePreview(path: string, context: FileBrowserItem[]) {
// MediaLightbox filters to media internally; index within that filtered list.
const mediaItems = context.filter(item => {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
const cat = getFileCategory(ext, item.isDir)
return cat === 'image' || cat === 'video' || cat === 'audio'
})
const idx = mediaItems.findIndex(item => item.path === path)
lightboxItems.value = context
lightboxIndex.value = idx >= 0 ? idx : 0
}
async function handleDelete(path: string) {
try {
await cloudStore.deleteItem(path)
myFiles.value = myFiles.value.filter(f => f.path !== path)
searchResults.value = searchResults.value.filter(r => r.item?.path !== path)
} catch (e) {
loadError.value = e instanceof Error ? e.message : 'Delete failed'
}
}
// Peer files (aggregated across every federation peer)
interface PeerFileEntry {
key: string
@ -526,19 +664,15 @@ const filteredPeerFiles = computed(() =>
: 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'
item?: FileBrowserItem
peerName?: string
peerOnion?: string
sectionId?: string
}
const searching = ref(false)
@ -553,51 +687,24 @@ watch(searchQuery, () => {
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()])
// Both corpora are cached flat lists after their first load.
await Promise.all([loadMyFiles(), loadPeerFiles()])
if (seq !== searchSeq) return // a newer query superseded this run
const q = query.toLowerCase()
const mine: SearchResult[] = myFiles.value
.filter(f => f.name.toLowerCase().includes(q))
.map(f => ({
key: `mine:${f.path}`,
name: f.name,
detail: f.path,
category: categoryOf(f.name),
item: f,
}))
const peers: SearchResult[] = peerFiles.value
.filter(f => f.filename.toLowerCase().includes(q))
.map(f => ({
@ -605,7 +712,6 @@ async function runSearch() {
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,
}))
@ -621,13 +727,10 @@ const filteredSearchResults = computed(() =>
: 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 } })
}
}
/** Own-file items among the current search results (lightbox context). */
const searchMineItems = computed(() =>
filteredSearchResults.value.flatMap(r => (r.item ? [r.item] : [])),
)
// Existing counts / peers loading
async function loadCounts() {
@ -684,3 +787,49 @@ function openSection(section: ContentSection) {
defineExpose({ loadPeers })
</script>
<style scoped>
/* Top-level tab switcher deliberately a DIFFERENT look from the neutral
category pills below it: orange-tinted container + active state. */
.cloud-tab-switcher {
gap: 2px;
padding: 3px;
border-radius: 0.5rem;
background: rgba(247, 147, 26, 0.08);
border: 1px solid rgba(247, 147, 26, 0.22);
}
.cloud-tab-btn {
padding: 0.45rem 0.9rem;
border-radius: 0.375rem;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.6);
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.cloud-tab-btn:hover {
color: rgba(255, 255, 255, 0.9);
}
.cloud-tab-btn-active {
background: rgba(247, 147, 26, 0.25);
color: #ffd9a8;
}
/* Mobile: the three tabs share the full width equally. */
.cloud-tab-switcher-full {
display: flex;
width: 100%;
}
.cloud-tab-switcher-full .cloud-tab-btn {
flex: 1 1 0;
text-align: center;
padding: 0.6rem 0.25rem;
}
</style>