feat: add Peer Files UI for browsing and downloading federated content
- New PeerFiles.vue view shows federated peers and their shared catalogs - Peer Files card in Cloud.vue shows when federation peers exist - New content.download-peer RPC fetches content from peer via Tor - Route: /dashboard/cloud/peers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bd7911843d
commit
2e20984686
@ -139,6 +139,71 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::json!({ "updated": true }))
|
Ok(serde_json::json!({ "updated": true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Download content from a peer over Tor, returning base64-encoded data.
|
||||||
|
pub(super) async fn handle_content_download_peer(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let onion = params
|
||||||
|
.get("onion")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
||||||
|
let content_id = params
|
||||||
|
.get("content_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
||||||
|
|
||||||
|
if !onion.ends_with(".onion") || onion.len() < 10 {
|
||||||
|
return Err(anyhow::anyhow!("Invalid onion address"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let socks_proxy = reqwest::Proxy::all("socks5h://127.0.0.1:9050")
|
||||||
|
.context("Failed to create SOCKS proxy")?;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.proxy(socks_proxy)
|
||||||
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
|
.build()
|
||||||
|
.context("Failed to build Tor HTTP client")?;
|
||||||
|
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||||
|
|
||||||
|
let url = format!("http://{}/content/{}", onion, content_id);
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.header("X-Federation-DID", &local_did)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to connect to peer over Tor")?;
|
||||||
|
|
||||||
|
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||||
|
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"error": "payment_required",
|
||||||
|
"price_sats": body.get("price_sats").and_then(|v| v.as_u64()).unwrap_or(0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(anyhow::anyhow!("Peer returned: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = response
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.context("Failed to read response body")?;
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"data": encoded,
|
||||||
|
"size": bytes.len(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// Browse a peer's content catalog over Tor.
|
/// Browse a peer's content catalog over Tor.
|
||||||
pub(super) async fn handle_content_browse_peer(
|
pub(super) async fn handle_content_browse_peer(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@ -417,6 +417,7 @@ impl RpcHandler {
|
|||||||
"content.set-pricing" => self.handle_content_set_pricing(params).await,
|
"content.set-pricing" => self.handle_content_set_pricing(params).await,
|
||||||
"content.set-availability" => self.handle_content_set_availability(params).await,
|
"content.set-availability" => self.handle_content_set_availability(params).await,
|
||||||
"content.browse-peer" => self.handle_content_browse_peer(params).await,
|
"content.browse-peer" => self.handle_content_browse_peer(params).await,
|
||||||
|
"content.download-peer" => self.handle_content_download_peer(params).await,
|
||||||
|
|
||||||
// DWN (Decentralized Web Node)
|
// DWN (Decentralized Web Node)
|
||||||
"dwn.status" => self.handle_dwn_status().await,
|
"dwn.status" => self.handle_dwn_status().await,
|
||||||
|
|||||||
@ -518,7 +518,7 @@
|
|||||||
|
|
||||||
- [x] **SHARE-03** — Test file sharing at scale. Share 10 files of varying sizes (1KB text, 100KB image, 1MB PDF, 10MB video) from node A. Browse the catalog from nodes B, C, and D simultaneously. Download the 10MB file from all 3 nodes at once. Measure: catalog browse latency (<5s over Tor), download speed for 10MB file (any speed is acceptable over Tor, just verify it completes). Verify no corrupted transfers (checksum all downloads). **Acceptance**: All files transfer correctly to all 3 peers. No timeouts, no corruption. Document transfer speeds.
|
- [x] **SHARE-03** — Test file sharing at scale. Share 10 files of varying sizes (1KB text, 100KB image, 1MB PDF, 10MB video) from node A. Browse the catalog from nodes B, C, and D simultaneously. Download the 10MB file from all 3 nodes at once. Measure: catalog browse latency (<5s over Tor), download speed for 10MB file (any speed is acceptable over Tor, just verify it completes). Verify no corrupted transfers (checksum all downloads). **Acceptance**: All files transfer correctly to all 3 peers. No timeouts, no corruption. Document transfer speeds.
|
||||||
|
|
||||||
- [ ] **SHARE-04** — Add peer content browsing to Cloud UI. In `neode-ui/src/views/Cloud.vue`, add a "Peer Files" tab alongside Photos/Music/Documents/All Files. This tab shows a list of federated peers (from `federation.list-nodes`). Clicking a peer calls `content.browse-peer` with their onion address and displays their shared catalog in the same FileGrid component. Add a download button on each file that fetches the content over Tor and saves locally. Show loading state while Tor connection establishes (can take 5-10s). **Acceptance**: Can browse and download peer-shared files from the Cloud page. Deploy and verify.
|
- [x] **SHARE-04** — Add peer content browsing to Cloud UI. In `neode-ui/src/views/Cloud.vue`, add a "Peer Files" tab alongside Photos/Music/Documents/All Files. This tab shows a list of federated peers (from `federation.list-nodes`). Clicking a peer calls `content.browse-peer` with their onion address and displays their shared catalog in the same FileGrid component. Add a download button on each file that fetches the content over Tor and saves locally. Show loading state while Tor connection establishes (can take 5-10s). **Acceptance**: Can browse and download peer-shared files from the Cloud page. Deploy and verify.
|
||||||
|
|
||||||
### Sprint 45: DWN Multi-Node Sync (June 2026 Week 3-4)
|
### Sprint 45: DWN Multi-Node Sync (June 2026 Week 3-4)
|
||||||
|
|
||||||
|
|||||||
@ -113,6 +113,11 @@ const router = createRouter({
|
|||||||
name: 'cloud',
|
name: 'cloud',
|
||||||
component: () => import('../views/Cloud.vue'),
|
component: () => import('../views/Cloud.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'cloud/peers',
|
||||||
|
name: 'peer-files',
|
||||||
|
component: () => import('../views/PeerFiles.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'cloud/:folderId',
|
path: 'cloud/:folderId',
|
||||||
name: 'cloud-folder',
|
name: 'cloud-folder',
|
||||||
|
|||||||
@ -58,6 +58,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Peer Files Card -->
|
||||||
|
<div
|
||||||
|
v-if="hasFederatedPeers"
|
||||||
|
data-controller-container
|
||||||
|
tabindex="0"
|
||||||
|
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10 mt-4"
|
||||||
|
@click="router.push({ name: 'peer-files' })"
|
||||||
|
>
|
||||||
|
<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">Browse files shared by federated nodes</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-purple-500/15 text-purple-400">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
|
||||||
|
{{ peerCount }} peers
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Not Installed Hint -->
|
<!-- Not Installed Hint -->
|
||||||
<div v-if="!fileBrowserRunning" class="glass-card p-8 mt-6 text-center">
|
<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>
|
<p class="text-white/60 mb-3">Install File Browser from the App Store to get started with your cloud storage.</p>
|
||||||
@ -73,11 +103,14 @@ import { computed, ref, onMounted } from 'vue'
|
|||||||
import { useRouter, RouterLink } from 'vue-router'
|
import { useRouter, RouterLink } from 'vue-router'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
import { fileBrowserClient } from '@/api/filebrowser-client'
|
import { fileBrowserClient } from '@/api/filebrowser-client'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const sectionCounts = ref<Record<string, number>>({})
|
const sectionCounts = ref<Record<string, number>>({})
|
||||||
const countsLoading = ref(false)
|
const countsLoading = ref(false)
|
||||||
|
const peerCount = ref(0)
|
||||||
|
const hasFederatedPeers = computed(() => peerCount.value > 0)
|
||||||
|
|
||||||
const APP_ALIASES: Record<string, string[]> = {
|
const APP_ALIASES: Record<string, string[]> = {
|
||||||
immich: ['immich_server', 'immich-server'],
|
immich: ['immich_server', 'immich-server'],
|
||||||
@ -182,7 +215,19 @@ async function loadCounts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => loadCounts())
|
onMounted(() => {
|
||||||
|
loadCounts()
|
||||||
|
loadPeerCount()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadPeerCount() {
|
||||||
|
try {
|
||||||
|
const result = await rpcClient.federationListNodes()
|
||||||
|
peerCount.value = result?.nodes?.length ?? 0
|
||||||
|
} catch {
|
||||||
|
peerCount.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openSection(section: ContentSection) {
|
function openSection(section: ContentSection) {
|
||||||
router.push({ name: 'cloud-folder', params: { folderId: section.id } })
|
router.push({ name: 'cloud-folder', params: { folderId: section.id } })
|
||||||
|
|||||||
257
neode-ui/src/views/PeerFiles.vue
Normal file
257
neode-ui/src/views/PeerFiles.vue
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3 mb-6">
|
||||||
|
<button class="glass-button p-2 rounded-lg" @click="router.push({ name: 'cloud' })">
|
||||||
|
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">Peer Files</h1>
|
||||||
|
<p class="text-sm text-white/50">Browse files shared by federated nodes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Peer list -->
|
||||||
|
<div v-if="!selectedPeer" class="space-y-3">
|
||||||
|
<div v-if="loading" class="glass-card p-8 text-center">
|
||||||
|
<p class="text-white/50 animate-pulse">Loading federation peers...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="peers.length === 0" class="glass-card p-8 text-center">
|
||||||
|
<p class="text-white/50">No federated peers found. Join a federation from Settings to share files.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="peer in peers"
|
||||||
|
:key="peer.did"
|
||||||
|
data-controller-container
|
||||||
|
tabindex="0"
|
||||||
|
class="glass-card p-5 cursor-pointer transition-all hover:-translate-y-0.5 hover:bg-white/10"
|
||||||
|
@click="browsePeer(peer)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 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-base font-semibold text-white truncate">{{ peer.name || truncateDid(peer.did) }}</h3>
|
||||||
|
<p class="text-xs text-white/40 truncate">{{ peer.onion }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full"
|
||||||
|
:class="peer.trust_level === 'trusted' ? 'bg-green-500/15 text-green-400' : 'bg-yellow-500/15 text-yellow-400'"
|
||||||
|
>
|
||||||
|
{{ peer.trust_level }}
|
||||||
|
</span>
|
||||||
|
<svg class="w-4 h-4 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Peer content catalog -->
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<button class="glass-button p-2 rounded-lg" @click="selectedPeer = null">
|
||||||
|
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-white">{{ selectedPeer.name || truncateDid(selectedPeer.did) }}</h2>
|
||||||
|
<p class="text-xs text-white/40">{{ selectedPeer.onion }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="catalogLoading" class="glass-card p-8 text-center">
|
||||||
|
<p class="text-white/50 animate-pulse">Connecting via Tor... This may take a few seconds.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="catalogError" class="glass-card p-6">
|
||||||
|
<p class="text-red-400 text-sm">{{ catalogError }}</p>
|
||||||
|
<button class="glass-button mt-3 px-4 py-2 rounded-lg text-sm" @click="browsePeer(selectedPeer!)">Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="catalogItems.length === 0" class="glass-card p-8 text-center">
|
||||||
|
<p class="text-white/50">This peer has no shared files.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="item in catalogItems"
|
||||||
|
:key="item.id"
|
||||||
|
class="glass-card p-4 flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<div class="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center" :class="fileIconBg(item.mime_type)">
|
||||||
|
<svg class="w-5 h-5" :class="fileIconColor(item.mime_type)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="fileIconPath(item.mime_type)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-white truncate">{{ item.filename }}</p>
|
||||||
|
<p class="text-xs text-white/40">{{ formatSize(item.size_bytes) }} · {{ item.mime_type }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full"
|
||||||
|
:class="accessBadgeClass(item.access)"
|
||||||
|
>
|
||||||
|
{{ accessLabel(item.access) }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="canDownload(item.access)"
|
||||||
|
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium"
|
||||||
|
:disabled="downloading === item.id"
|
||||||
|
@click="downloadFile(item)"
|
||||||
|
>
|
||||||
|
{{ downloading === item.id ? 'Downloading...' : 'Download' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
interface PeerNode {
|
||||||
|
did: string
|
||||||
|
pubkey: string
|
||||||
|
onion: string
|
||||||
|
name?: string
|
||||||
|
trust_level: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogItem {
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
mime_type: string
|
||||||
|
size_bytes: number
|
||||||
|
description: string
|
||||||
|
access: string | { paid: { price_sats: number } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const peers = ref<PeerNode[]>([])
|
||||||
|
const selectedPeer = ref<PeerNode | null>(null)
|
||||||
|
const catalogLoading = ref(false)
|
||||||
|
const catalogError = ref('')
|
||||||
|
const catalogItems = ref<CatalogItem[]>([])
|
||||||
|
const downloading = ref<string | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const result = await rpcClient.federationListNodes()
|
||||||
|
peers.value = result?.nodes ?? []
|
||||||
|
} catch {
|
||||||
|
peers.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function truncateDid(did: string): string {
|
||||||
|
if (did.length <= 24) return did
|
||||||
|
return did.slice(0, 16) + '...' + did.slice(-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browsePeer(peer: PeerNode) {
|
||||||
|
selectedPeer.value = peer
|
||||||
|
catalogLoading.value = true
|
||||||
|
catalogError.value = ''
|
||||||
|
catalogItems.value = []
|
||||||
|
try {
|
||||||
|
const result = await rpcClient.call<{ items?: CatalogItem[] }>({ method: 'content.browse-peer', params: { onion: peer.onion }, timeout: 30000 })
|
||||||
|
catalogItems.value = result?.items ?? []
|
||||||
|
} catch (e: unknown) {
|
||||||
|
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
|
||||||
|
} finally {
|
||||||
|
catalogLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||||
|
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIconBg(mime: string): string {
|
||||||
|
if (mime.startsWith('image/')) return 'bg-blue-500/15'
|
||||||
|
if (mime.startsWith('audio/')) return 'bg-orange-500/15'
|
||||||
|
if (mime.startsWith('video/')) return 'bg-pink-500/15'
|
||||||
|
if (mime.startsWith('text/')) return 'bg-green-500/15'
|
||||||
|
return 'bg-white/10'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIconColor(mime: string): string {
|
||||||
|
if (mime.startsWith('image/')) return 'text-blue-400'
|
||||||
|
if (mime.startsWith('audio/')) return 'text-orange-400'
|
||||||
|
if (mime.startsWith('video/')) return 'text-pink-400'
|
||||||
|
if (mime.startsWith('text/')) return 'text-green-400'
|
||||||
|
return 'text-white/60'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIconPath(mime: string): string {
|
||||||
|
if (mime.startsWith('image/')) return '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'
|
||||||
|
if (mime.startsWith('audio/')) return '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'
|
||||||
|
if (mime.startsWith('video/')) return 'M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z'
|
||||||
|
return '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'
|
||||||
|
}
|
||||||
|
|
||||||
|
function accessLabel(access: CatalogItem['access']): string {
|
||||||
|
if (access === 'free') return 'Free'
|
||||||
|
if (access === 'peersonly') return 'Peers Only'
|
||||||
|
if (typeof access === 'object' && 'paid' in access) return `${access.paid.price_sats} sats`
|
||||||
|
return String(access)
|
||||||
|
}
|
||||||
|
|
||||||
|
function accessBadgeClass(access: CatalogItem['access']): string {
|
||||||
|
if (access === 'free') return 'bg-green-500/15 text-green-400'
|
||||||
|
if (access === 'peersonly') return 'bg-blue-500/15 text-blue-400'
|
||||||
|
if (typeof access === 'object' && 'paid' in access) return 'bg-orange-500/15 text-orange-400'
|
||||||
|
return 'bg-white/10 text-white/50'
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDownload(access: CatalogItem['access']): boolean {
|
||||||
|
return access === 'free' || access === 'peersonly'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(item: CatalogItem) {
|
||||||
|
if (!selectedPeer.value) return
|
||||||
|
downloading.value = item.id
|
||||||
|
try {
|
||||||
|
const result = await rpcClient.call<{ data?: string }>({
|
||||||
|
method: 'content.download-peer',
|
||||||
|
params: { onion: selectedPeer.value.onion, content_id: item.id },
|
||||||
|
timeout: 120000,
|
||||||
|
})
|
||||||
|
if (result?.data) {
|
||||||
|
const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], { type: item.mime_type })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = item.filename.split('/').pop() || item.filename
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (import.meta.env.DEV) console.warn('Download failed', e)
|
||||||
|
} finally {
|
||||||
|
downloading.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Loading…
x
Reference in New Issue
Block a user