Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29a641ba28 |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 20 KiB |
@@ -23,10 +23,6 @@ COPY docker/lnd-ui /docker/lnd-ui
|
|||||||
COPY docker/fedimint-ui /docker/fedimint-ui
|
COPY docker/fedimint-ui /docker/fedimint-ui
|
||||||
COPY demo/files /demo/files
|
COPY demo/files /demo/files
|
||||||
COPY demo/content /demo/content
|
COPY demo/content /demo/content
|
||||||
# Peer catalog media (posters/covers/photos for content.browse-peer mocks) —
|
|
||||||
# deliberately OUTSIDE demo/content so it doesn't appear as the visitor's own
|
|
||||||
# cloud files.
|
|
||||||
COPY demo/peer-media /demo/peer-media
|
|
||||||
|
|
||||||
# This image only ever serves the public demo — scrub the private release/registry
|
# This image only ever serves the public demo — scrub the private release/registry
|
||||||
# server address from everything it serves (mock data, catalog.json, demo assets)
|
# server address from everything it serves (mock data, catalog.json, demo assets)
|
||||||
|
|||||||
@@ -1201,97 +1201,6 @@ function demoAppShell(title, sub, iconPath, bodyHtml) {
|
|||||||
</div><div class="demo-tag">Archipelago demo · signet</div></body></html>`
|
</div><div class="demo-tag">Archipelago demo · signet</div></body></html>`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rich peer content library (demo) ─────────────────────────────────────────
|
|
||||||
// What a healthy decentralised network's shared catalog looks like: films,
|
|
||||||
// series, books, music, photography and documents with real metadata. Music,
|
|
||||||
// photos and documents are backed by REAL committed files (they play/open);
|
|
||||||
// films/series/books are paid so the buy-flow poster/cover previews carry the
|
|
||||||
// visuals. Preview images live in demo/peer-media (sourced from the web via
|
|
||||||
// picsum.photos — Unsplash-licensed — seeds pinned in git history).
|
|
||||||
const PEER_MEDIA = (f) => path.join(__dirname, '..', 'demo', 'peer-media', f)
|
|
||||||
const PEER_CONTENT = (...p) => path.join(__dirname, '..', 'demo', 'content', ...p)
|
|
||||||
const PEER_LIBRARY = [
|
|
||||||
// Films & series — paid, poster previews
|
|
||||||
{ id: 'film-block-height', filename: 'Block Height (2025) 2160p.mp4', mime_type: 'video/mp4', size_bytes: 4_512_374_784, access: { paid: { price_sats: 2500 } }, preview: PEER_MEDIA('poster-film-block.jpg'),
|
|
||||||
description: 'Documentary · 2025 · 1h 42m · 3840×2160 HEVC 10-bit · dir. M. Castillo — three node operators ride out a halving epoch.' },
|
|
||||||
{ id: 'film-the-signal', filename: 'The Signal (2024) 1080p.mkv', mime_type: 'video/x-matroska', size_bytes: 2_147_483_648, access: { paid: { price_sats: 2100 } }, preview: PEER_MEDIA('poster-film-signal.jpg'),
|
|
||||||
description: 'Thriller · 2024 · 2h 08m · 1920×1080 x264 · A radio engineer discovers her mesh network is carrying more than messages.' },
|
|
||||||
{ id: 'film-meshwork', filename: 'Meshwork — infrastructure for the willing (720p).mp4', mime_type: 'video/mp4', size_bytes: 891_289_600, access: { paid: { price_sats: 800 } }, preview: PEER_MEDIA('poster-film-mesh.jpg'),
|
|
||||||
description: 'Documentary short · 2026 · 34m · 1280×720 · Community LoRa + Reticulum builds across the highlands.' },
|
|
||||||
{ id: 'series-node-runners-s01e01', filename: 'Node Runners S01E01 — Genesis.mkv', mime_type: 'video/x-matroska', size_bytes: 734_003_200, access: { paid: { price_sats: 500 } }, preview: PEER_MEDIA('poster-series-node.jpg'),
|
|
||||||
description: 'Series · S01E01 · 43m · 1080p · Docuseries following first-time sovereign node builders.' },
|
|
||||||
// Books — paid, cover previews
|
|
||||||
{ id: 'book-sovereign-notes', filename: 'The Sovereign Individual — annotated.epub', mime_type: 'application/epub+zip', size_bytes: 3_842_048, access: { paid: { price_sats: 300 } }, preview: PEER_MEDIA('cover-book-sovereign.jpg'),
|
|
||||||
description: 'EPUB · 512 pp · community margin-notes edition, 2026 · Davidson & Rees-Mogg.' },
|
|
||||||
{ id: 'book-cypherpunk-essays', filename: 'Cypherpunk Essays Vol. II.epub', mime_type: 'application/epub+zip', size_bytes: 2_097_152, access: { paid: { price_sats: 210 } }, preview: PEER_MEDIA('cover-book-cypher.jpg'),
|
|
||||||
description: 'EPUB · 348 pp · 2025 anthology — privacy engineering, remailers, digital cash history.' },
|
|
||||||
{ id: 'book-broadcast-power', filename: 'Broadcast Power — pirate radio to mesh.pdf', mime_type: 'application/pdf', size_bytes: 18_874_368, access: { paid: { price_sats: 150 } }, preview: PEER_MEDIA('cover-book-broadcast.jpg'),
|
|
||||||
description: 'PDF · 214 pp · illustrated 2024 history of community-run transmission networks.' },
|
|
||||||
// Music — free, streams the real committed files
|
|
||||||
{ id: 'song-leaves-brown', filename: 'All the leaves are brown.mp3', mime_type: 'audio/mpeg', size_bytes: 2_936_012, access: 'free', disk: PEER_CONTENT('music', 'All the leaves are brown.mp3'),
|
|
||||||
description: 'MP3 320 kbps · Archy Sessions, 2026 · folk cover, one-take living-room recording.' },
|
|
||||||
{ id: 'song-exploited-substrate', filename: 'An Exploited Substrate.mp3', mime_type: 'audio/mpeg', size_bytes: 4_194_304, access: 'free', disk: PEER_CONTENT('music', 'An Exploited Substrate.mp3'),
|
|
||||||
description: 'MP3 320 kbps · Node Noise EP, 2025 · instrumental synthwave.' },
|
|
||||||
{ id: 'song-architects', filename: 'Architects of Tomorrow.wav', mime_type: 'audio/wav', size_bytes: 34_734_080, access: 'free', disk: PEER_CONTENT('music', 'Architects of Tomorrow.wav'),
|
|
||||||
description: 'WAV 16-bit/44.1 kHz lossless master · Node Noise EP, 2025.' },
|
|
||||||
{ id: 'song-bitcoin-salad', filename: 'Bitcoin Salad.MP3', mime_type: 'audio/mpeg', size_bytes: 3_984_588, access: 'free', disk: PEER_CONTENT('music', 'Bitcoin Salad.MP3'),
|
|
||||||
description: 'MP3 256 kbps · Kitchen Mix Vol. 3, 2024 · novelty electro.' },
|
|
||||||
{ id: 'song-builders', filename: 'Builders, not talkers (Remastered).mp3', mime_type: 'audio/mpeg', size_bytes: 4_508_876, access: 'free', disk: PEER_CONTENT('music', 'Builders, not talkers (Remastered).mp3'),
|
|
||||||
description: 'MP3 320 kbps · 2026 remaster · hip-hop instrumental.' },
|
|
||||||
// Photography — free, the committed JPEGs themselves (preview = full image)
|
|
||||||
{ id: 'photo-aurora-fjord', filename: 'aurora-over-the-fjord.jpg', mime_type: 'image/jpeg', size_bytes: 79_422, access: 'free', disk: PEER_MEDIA('photo-aurora-fjord.jpg'),
|
|
||||||
description: '800×533 · f/1.8 · 8s · ISO 1600 · Sony A7 IV 24mm · Lofoten, Norway · free license, sourced via picsum.photos.' },
|
|
||||||
{ id: 'photo-mountain-lake', filename: 'mountain-lake-still.jpg', mime_type: 'image/jpeg', size_bytes: 29_200, access: 'free', disk: PEER_MEDIA('photo-mountain-lake.jpg'),
|
|
||||||
description: '800×533 · f/8 · 1/250s · ISO 100 · Fuji X-T5 23mm · Dolomites, Italy · free license, sourced via picsum.photos.' },
|
|
||||||
{ id: 'photo-city-neon', filename: 'city-neon-rain.jpg', mime_type: 'image/jpeg', size_bytes: 59_855, access: 'free', disk: PEER_MEDIA('photo-city-neon.jpg'),
|
|
||||||
description: '800×533 · f/1.4 · 1/60s · ISO 800 · Leica Q3 28mm · Osaka, Japan · free license, sourced via picsum.photos.' },
|
|
||||||
{ id: 'photo-desert-dunes', filename: 'desert-dunes-dawn.jpg', mime_type: 'image/jpeg', size_bytes: 24_646, access: 'free', disk: PEER_MEDIA('photo-desert-dunes.jpg'),
|
|
||||||
description: '800×533 · f/11 · 1/125s · ISO 64 · Nikon Z7 II 70-200mm · Erg Chebbi, Morocco · free license, sourced via picsum.photos.' },
|
|
||||||
{ id: 'photo-forest-mist', filename: 'forest-mist-morning.jpg', mime_type: 'image/jpeg', size_bytes: 48_504, access: 'free', disk: PEER_MEDIA('photo-forest-mist.jpg'),
|
|
||||||
description: '800×533 · f/4 · 1/200s · ISO 400 · Canon R5 85mm · Black Forest, Germany · free license, sourced via picsum.photos.' },
|
|
||||||
{ id: 'photo-ocean-cliff', filename: 'ocean-cliff-long-exposure.jpg', mime_type: 'image/jpeg', size_bytes: 30_517, access: 'free', disk: PEER_MEDIA('photo-ocean-cliff.jpg'),
|
|
||||||
description: '800×533 · f/16 · 30s ND1000 · ISO 50 · Sony A7R V 16-35mm · Moher, Ireland · free license, sourced via picsum.photos.' },
|
|
||||||
{ id: 'photo-northern-road', filename: 'northern-road-solitude.jpg', mime_type: 'image/jpeg', size_bytes: 72_053, access: 'free', disk: PEER_MEDIA('photo-northern-road.jpg'),
|
|
||||||
description: '800×533 · f/5.6 · 1/500s · ISO 200 · drone DJI Mavic 3 · Iceland Ring Road · free license, sourced via picsum.photos.' },
|
|
||||||
{ id: 'photo-autumn-valley', filename: 'autumn-valley-patchwork.jpg', mime_type: 'image/jpeg', size_bytes: 51_148, access: 'free', disk: PEER_MEDIA('photo-autumn-valley.jpg'),
|
|
||||||
description: '800×533 · f/9 · 1/160s · ISO 100 · Fuji GFX 100S 110mm · Vermont, USA · free license, sourced via picsum.photos.' },
|
|
||||||
{ id: 'photo-harbor-dawn', filename: 'harbor-dawn-fishing-boats.jpg', mime_type: 'image/jpeg', size_bytes: 36_271, access: 'free', disk: PEER_MEDIA('photo-harbor-dawn.jpg'),
|
|
||||||
description: '800×533 · f/2.8 · 1/80s · ISO 320 · Leica M11 35mm · Cornwall, UK · free license, sourced via picsum.photos.' },
|
|
||||||
{ id: 'photo-alpine-ridge', filename: 'alpine-ridge-hiker.jpg', mime_type: 'image/jpeg', size_bytes: 91_938, access: 'free', disk: PEER_MEDIA('photo-alpine-ridge.jpg'),
|
|
||||||
description: '800×533 · f/7.1 · 1/320s · ISO 100 · Canon R6 II 24-70mm · Bernese Oberland, Switzerland · free license, sourced via picsum.photos.' },
|
|
||||||
// Documents — free, real committed files
|
|
||||||
{ id: 'doc-whitepaper-notes', filename: 'bitcoin-whitepaper-notes.md', mime_type: 'text/markdown', size_bytes: 8_192, access: 'free', disk: PEER_CONTENT('documents', 'bitcoin-whitepaper-notes.md'),
|
|
||||||
description: 'Markdown study notes on the Bitcoin whitepaper, section by section.' },
|
|
||||||
{ id: 'doc-sovereignty-manifesto', filename: 'sovereignty-manifesto.txt', mime_type: 'text/plain', size_bytes: 4_096, access: 'free', disk: PEER_CONTENT('documents', 'sovereignty-manifesto.txt'),
|
|
||||||
description: 'Plain-text manifesto on self-hosted infrastructure.' },
|
|
||||||
{ id: 'doc-node-checklist', filename: 'node-setup-checklist.md', mime_type: 'text/markdown', size_bytes: 6_144, access: 'free', disk: PEER_CONTENT('documents', 'node-setup-checklist.md'),
|
|
||||||
description: 'Step-by-step hardening checklist for a fresh Archipelago node.' },
|
|
||||||
{ id: 'doc-lightning-csv', filename: 'lightning-channels.csv', mime_type: 'text/csv', size_bytes: 2_048, access: 'free', disk: PEER_CONTENT('documents', 'lightning-channels.csv'),
|
|
||||||
description: 'CSV export — public channel set with capacities and fee policies.' },
|
|
||||||
// Software
|
|
||||||
{ id: 'sw-archy-iso', filename: 'archipelago-1.7.99-amd64.iso', mime_type: 'application/x-iso9660-image', size_bytes: 2_684_354_560, access: 'free',
|
|
||||||
description: 'Archipelago Node OS installer ISO · amd64 · SHA256 in release notes.' },
|
|
||||||
]
|
|
||||||
|
|
||||||
/** Deterministic per-peer slice of the library — every peer shares a different
|
|
||||||
* handful, popular items appear on more than one node (it's a network). */
|
|
||||||
function peerCatalogFor(onion) {
|
|
||||||
let seed = 0
|
|
||||||
for (const c of String(onion)) seed = (seed * 31 + c.charCodeAt(0)) >>> 0
|
|
||||||
const slot = seed % 12
|
|
||||||
return PEER_LIBRARY
|
|
||||||
.map((item, i) => ({ item, i }))
|
|
||||||
.filter(({ i }) => i % 12 === slot || ((seed ^ (i * 2654435761)) >>> 0) % 12 < 3)
|
|
||||||
.map(({ item }) => ({
|
|
||||||
id: item.id,
|
|
||||||
filename: item.filename,
|
|
||||||
mime_type: item.mime_type,
|
|
||||||
size_bytes: item.size_bytes,
|
|
||||||
description: item.description,
|
|
||||||
access: item.access,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 12 trusted/federated nodes for the demo Federation view.
|
// 12 trusted/federated nodes for the demo Federation view.
|
||||||
function demoFederationNodes() {
|
function demoFederationNodes() {
|
||||||
const names = [
|
const names = [
|
||||||
@@ -2257,40 +2166,15 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
case 'content.browse-peer': {
|
case 'content.browse-peer': {
|
||||||
const onion = params?.onion || ''
|
const onion = params?.onion || ''
|
||||||
return res.json({ result: { items: peerCatalogFor(onion) } })
|
return res.json({
|
||||||
}
|
result: {
|
||||||
|
items: [
|
||||||
case 'content.preview-peer': {
|
{ id: 'peer-doc-1', filename: 'Bitcoin Whitepaper.pdf', mime_type: 'application/pdf', size_bytes: 184292, description: 'The original Bitcoin whitepaper by Satoshi Nakamoto', access: 'free' },
|
||||||
// Serve the item's committed preview image (film poster, book cover, or
|
{ id: 'peer-img-1', filename: 'node-setup-guide.png', mime_type: 'image/png', size_bytes: 524800, description: 'Visual guide for setting up a Bitcoin node', access: 'free' },
|
||||||
// the photo itself) so the demo NEVER renders a broken thumbnail.
|
{ id: 'peer-vid-1', filename: 'Lightning Demo.mp4', mime_type: 'video/mp4', size_bytes: 15728640, description: 'Lightning Network payment channel demo', access: { paid: { price_sats: 500 } } },
|
||||||
const entry = PEER_LIBRARY.find(it => it.id === params?.content_id)
|
],
|
||||||
const previewAbs = entry && (entry.preview || (entry.mime_type.startsWith('image/') && entry.disk))
|
},
|
||||||
if (previewAbs) {
|
})
|
||||||
try {
|
|
||||||
const data = fsSync.readFileSync(typeof previewAbs === 'string' ? previewAbs : entry.disk)
|
|
||||||
return res.json({ result: { data: data.toString('base64'), content_type: 'image/jpeg' } })
|
|
||||||
} catch { /* fall through to no preview (icon fallback) */ }
|
|
||||||
}
|
|
||||||
return res.json({ result: {} })
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'content.download-peer': {
|
|
||||||
// Free items backed by a real committed file stream the actual bytes —
|
|
||||||
// music plays, photos open, documents read. Anything else gets the
|
|
||||||
// demo placeholder note.
|
|
||||||
const entry = PEER_LIBRARY.find(it => it.id === params?.content_id)
|
|
||||||
if (entry?.disk) {
|
|
||||||
try {
|
|
||||||
const data = fsSync.readFileSync(entry.disk)
|
|
||||||
return res.json({ result: { data: data.toString('base64'), content_type: entry.mime_type } })
|
|
||||||
} catch { /* fall through to placeholder */ }
|
|
||||||
}
|
|
||||||
const placeholder = Buffer.from(
|
|
||||||
`Archipelago demo — "${entry?.filename || params?.content_id || 'file'}"\n\n` +
|
|
||||||
'This is sample shared content. On a real node this would be the actual file.\n',
|
|
||||||
'utf-8'
|
|
||||||
).toString('base64')
|
|
||||||
return res.json({ result: { data: placeholder, content_type: 'text/plain' } })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'content.remove': {
|
case 'content.remove': {
|
||||||
@@ -2308,18 +2192,10 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
return res.json({ result: { items: [] } })
|
return res.json({ result: { items: [] } })
|
||||||
}
|
}
|
||||||
case 'content.owned-get':
|
case 'content.owned-get':
|
||||||
|
case 'content.preview-peer':
|
||||||
case 'content.download-peer-paid':
|
case 'content.download-peer-paid':
|
||||||
case 'content.download-peer-invoice':
|
case 'content.download-peer-invoice':
|
||||||
case 'content.download-peer-onchain': {
|
case 'content.download-peer-onchain': {
|
||||||
// Deduct the price from the chosen rail so demo balances react.
|
|
||||||
const paid = params?.price_sats || 0
|
|
||||||
if (paid > 0 && method === 'content.download-peer-paid') {
|
|
||||||
if (params?.method === 'ark') walletState.ark_sats = Math.max(0, walletState.ark_sats - paid)
|
|
||||||
else if (params?.method === 'fedimint') {
|
|
||||||
const fed = (mockState.federations || [])[0]
|
|
||||||
if (fed) fed.balance_sats = Math.max(0, (fed.balance_sats || 0) - paid)
|
|
||||||
} else walletState.ecash_sats = Math.max(0, walletState.ecash_sats - paid)
|
|
||||||
}
|
|
||||||
const filename = params?.filename || 'demo-content'
|
const filename = params?.filename || 'demo-content'
|
||||||
const body = Buffer.from(
|
const body = Buffer.from(
|
||||||
`Archipelago demo — "${filename}"\n\nThis is sample paid content delivered over the ` +
|
`Archipelago demo — "${filename}"\n\nThis is sample paid content delivered over the ` +
|
||||||
@@ -3454,15 +3330,10 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
// Wallet / Ecash (Fedimint)
|
// Wallet / Ecash (Fedimint)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
case 'wallet.ecash-balance': {
|
case 'wallet.ecash-balance': {
|
||||||
const fedSats = (mockState.federations || []).reduce((s, f) => s + (f.balance_sats || 0), 0)
|
|
||||||
return res.json({
|
return res.json({
|
||||||
result: {
|
result: {
|
||||||
balance_sats: walletState.ecash_sats,
|
balance_sats: walletState.ecash_sats,
|
||||||
balance_msat: walletState.ecash_sats * 1000,
|
balance_msat: walletState.ecash_sats * 1000,
|
||||||
cashu_sats: walletState.ecash_sats,
|
|
||||||
fedimint_sats: fedSats,
|
|
||||||
ark_sats: walletState.ark_sats,
|
|
||||||
total_sats: walletState.ecash_sats + fedSats + walletState.ark_sats,
|
|
||||||
token_count: walletState.ecash_tokens,
|
token_count: walletState.ecash_tokens,
|
||||||
federations: [
|
federations: [
|
||||||
{ federation_id: 'fed1-demo', name: 'Archy Signet Mint', balance_msat: walletState.ecash_sats * 1000, gateway_active: true },
|
{ federation_id: 'fed1-demo', name: 'Archy Signet Mint', balance_msat: walletState.ecash_sats * 1000, gateway_active: true },
|
||||||
|
|||||||
@@ -395,11 +395,6 @@ onMounted(async () => {
|
|||||||
let seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
let seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||||
const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1'
|
const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1'
|
||||||
if (fromBoot) sessionStorage.removeItem('archipelago_from_boot')
|
if (fromBoot) sessionStorage.removeItem('archipelago_from_boot')
|
||||||
// One-shot "Replay intro" request from the login screen — must survive the
|
|
||||||
// auto-re-mark below (which otherwise instantly suppresses the replay on any
|
|
||||||
// onboarded node).
|
|
||||||
const replayRequested = sessionStorage.getItem('archipelago_replay_intro') === '1'
|
|
||||||
if (replayRequested) sessionStorage.removeItem('archipelago_replay_intro')
|
|
||||||
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
||||||
const splashCandidate = !seenIntro
|
const splashCandidate = !seenIntro
|
||||||
&& (fromBoot || (route.path === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
|
&& (fromBoot || (route.path === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
|
||||||
@@ -413,7 +408,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!replayRequested && !seenIntro && onboardingComplete === true) {
|
if (!seenIntro && onboardingComplete === true) {
|
||||||
try { localStorage.setItem('neode_intro_seen', '1') } catch { /* noop */ }
|
try { localStorage.setItem('neode_intro_seen', '1') } catch { /* noop */ }
|
||||||
seenIntro = true
|
seenIntro = true
|
||||||
}
|
}
|
||||||
@@ -426,7 +421,6 @@ onMounted(async () => {
|
|||||||
fromBoot,
|
fromBoot,
|
||||||
devMode: import.meta.env.VITE_DEV_MODE,
|
devMode: import.meta.env.VITE_DEV_MODE,
|
||||||
onboardingComplete,
|
onboardingComplete,
|
||||||
replayRequested,
|
|
||||||
})) {
|
})) {
|
||||||
// Coming from boot screen — show the full splash intro (Enter to Exit → typing → logo)
|
// Coming from boot screen — show the full splash intro (Enter to Exit → typing → logo)
|
||||||
showSplash.value = true
|
showSplash.value = true
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
<!-- Method tabs -->
|
<!-- Method tabs -->
|
||||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||||
<button
|
<button
|
||||||
v-for="m in (['onchain', 'lightning', 'ecash', 'ark'] as const)"
|
v-for="m in (['onchain', 'lightning', 'ecash'] as const)"
|
||||||
:key="m"
|
:key="m"
|
||||||
@click="receiveMethod = m"
|
@click="receiveMethod = m"
|
||||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
|
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
|
||||||
:class="receiveMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
:class="receiveMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||||
>{{ m === 'onchain' ? t('receiveBitcoin.onChain') : m === 'lightning' ? t('receiveBitcoin.lightning') : m === 'ecash' ? t('receiveBitcoin.ecash') : 'Ark' }}</button>
|
>{{ m === 'onchain' ? t('receiveBitcoin.onChain') : m === 'lightning' ? t('receiveBitcoin.lightning') : t('receiveBitcoin.ecash') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lightning -->
|
<!-- Lightning -->
|
||||||
@@ -43,19 +43,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ark -->
|
|
||||||
<div v-if="receiveMethod === 'ark'">
|
|
||||||
<div v-if="arkAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
|
|
||||||
<canvas ref="arkQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
|
|
||||||
<p class="text-white/50 text-xs mb-2">Your Ark address</p>
|
|
||||||
<p class="text-sm font-mono text-white/90 break-all">{{ arkAddress }}</p>
|
|
||||||
<button @click="copyText(arkAddress)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
|
|
||||||
</div>
|
|
||||||
<div v-else class="mb-3 text-center">
|
|
||||||
<p class="text-white/50 text-sm mb-2">Generate a fresh Ark address to receive off-chain sats instantly.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ecash -->
|
<!-- Ecash -->
|
||||||
<div v-if="receiveMethod === 'ecash'">
|
<div v-if="receiveMethod === 'ecash'">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -70,7 +57,7 @@
|
|||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||||
<button @click="receive" :disabled="processing" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
<button @click="receive" :disabled="processing" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||||
{{ processing ? t('receiveBitcoin.processing') : receiveMethod === 'onchain' ? t('receiveBitcoin.generateAddress') : receiveMethod === 'lightning' ? t('receiveBitcoin.createInvoice') : receiveMethod === 'ark' ? 'Get Ark address' : t('receiveBitcoin.receive') }}
|
{{ processing ? t('receiveBitcoin.processing') : receiveMethod === 'onchain' ? t('receiveBitcoin.generateAddress') : receiveMethod === 'lightning' ? t('receiveBitcoin.createInvoice') : t('receiveBitcoin.receive') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</BaseModal>
|
</BaseModal>
|
||||||
@@ -88,17 +75,15 @@ const { t } = useI18n()
|
|||||||
defineProps<{ show: boolean }>()
|
defineProps<{ show: boolean }>()
|
||||||
const emit = defineEmits<{ close: []; received: [] }>()
|
const emit = defineEmits<{ close: []; received: [] }>()
|
||||||
|
|
||||||
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash' | 'ark'>('onchain')
|
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash'>('onchain')
|
||||||
const invoiceAmount = ref<number>(0)
|
const invoiceAmount = ref<number>(0)
|
||||||
const invoiceMemo = ref('')
|
const invoiceMemo = ref('')
|
||||||
const invoiceResult = ref('')
|
const invoiceResult = ref('')
|
||||||
const onchainAddress = ref('')
|
const onchainAddress = ref('')
|
||||||
const arkAddress = ref('')
|
|
||||||
const ecashToken = ref('')
|
const ecashToken = ref('')
|
||||||
const ecashResult = ref('')
|
const ecashResult = ref('')
|
||||||
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
|
const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
const lightningQrCanvas = ref<HTMLCanvasElement | null>(null)
|
const lightningQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
const arkQrCanvas = ref<HTMLCanvasElement | null>(null)
|
|
||||||
const processing = ref(false)
|
const processing = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
@@ -117,7 +102,6 @@ async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix =
|
|||||||
function close() {
|
function close() {
|
||||||
invoiceResult.value = ''
|
invoiceResult.value = ''
|
||||||
onchainAddress.value = ''
|
onchainAddress.value = ''
|
||||||
arkAddress.value = ''
|
|
||||||
ecashToken.value = ''
|
ecashToken.value = ''
|
||||||
ecashResult.value = ''
|
ecashResult.value = ''
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -147,11 +131,6 @@ async function receive() {
|
|||||||
}
|
}
|
||||||
onchainAddress.value = res.address
|
onchainAddress.value = res.address
|
||||||
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
|
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
|
||||||
} else if (receiveMethod.value === 'ark') {
|
|
||||||
const res = await rpcClient.call<{ address: string }>({ method: 'wallet.ark-address' })
|
|
||||||
if (!res.address) throw new Error('barkd did not return an Ark address')
|
|
||||||
arkAddress.value = res.address
|
|
||||||
nextTick(() => renderQr(res.address, arkQrCanvas.value))
|
|
||||||
} else {
|
} else {
|
||||||
if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
|
if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
|
||||||
// The backend auto-detects the token type: a Cashu token (cashuA/B…) is
|
// The backend auto-detects the token type: a Cashu token (cashuA/B…) is
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
<!-- Method tabs -->
|
<!-- Method tabs -->
|
||||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||||
<button
|
<button
|
||||||
v-for="m in (['auto', 'lightning', 'onchain', 'ecash', 'ark'] as const)"
|
v-for="m in (['auto', 'lightning', 'onchain', 'ecash'] as const)"
|
||||||
:key="m"
|
:key="m"
|
||||||
@click="sendMethod = m"
|
@click="sendMethod = m"
|
||||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
|
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
|
||||||
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||||
>{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? t('sendBitcoin.ecash') : m === 'ark' ? 'Ark' : t('sendBitcoin.auto') }}</button>
|
>{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? t('sendBitcoin.ecash') : t('sendBitcoin.auto') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
||||||
@@ -22,9 +22,9 @@
|
|||||||
|
|
||||||
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
|
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
|
||||||
<label class="text-white/60 text-sm block mb-1">
|
<label class="text-white/60 text-sm block mb-1">
|
||||||
{{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : effectiveMethod === 'ark' ? 'Ark address, invoice or lightning address' : t('sendBitcoin.bitcoinAddress') }}
|
{{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : t('sendBitcoin.bitcoinAddress') }}
|
||||||
</label>
|
</label>
|
||||||
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : effectiveMethod === 'ark' ? 'tark1… / lnbc… / user@lnaddress' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
|
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
<div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
||||||
@@ -39,9 +39,6 @@
|
|||||||
<div v-if="resultHash" class="mb-3 alert-success">
|
<div v-if="resultHash" class="mb-3 alert-success">
|
||||||
<p class="text-xs">{{ t('sendBitcoin.paidHash', { hash: resultHash }) }}</p>
|
<p class="text-xs">{{ t('sendBitcoin.paidHash', { hash: resultHash }) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="resultArk" class="mb-3 alert-success">
|
|
||||||
<p class="text-xs">{{ resultArk }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
|
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
|
||||||
|
|
||||||
@@ -65,14 +62,13 @@ const { t } = useI18n()
|
|||||||
const props = defineProps<{ show: boolean }>()
|
const props = defineProps<{ show: boolean }>()
|
||||||
const emit = defineEmits<{ close: []; sent: [] }>()
|
const emit = defineEmits<{ close: []; sent: [] }>()
|
||||||
|
|
||||||
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'ark'>('auto')
|
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash'>('auto')
|
||||||
const amount = ref<number>(0)
|
const amount = ref<number>(0)
|
||||||
const dest = ref('')
|
const dest = ref('')
|
||||||
const processing = ref(false)
|
const processing = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const resultTxid = ref('')
|
const resultTxid = ref('')
|
||||||
const resultHash = ref('')
|
const resultHash = ref('')
|
||||||
const resultArk = ref('')
|
|
||||||
const ecashToken = ref('')
|
const ecashToken = ref('')
|
||||||
|
|
||||||
const effectiveMethod = computed(() => {
|
const effectiveMethod = computed(() => {
|
||||||
@@ -88,7 +84,6 @@ function close() {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
resultTxid.value = ''
|
resultTxid.value = ''
|
||||||
resultHash.value = ''
|
resultHash.value = ''
|
||||||
resultArk.value = ''
|
|
||||||
ecashToken.value = ''
|
ecashToken.value = ''
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
@@ -104,20 +99,10 @@ async function send() {
|
|||||||
ecashToken.value = ''
|
ecashToken.value = ''
|
||||||
resultTxid.value = ''
|
resultTxid.value = ''
|
||||||
resultHash.value = ''
|
resultHash.value = ''
|
||||||
resultArk.value = ''
|
|
||||||
|
|
||||||
const method = effectiveMethod.value
|
const method = effectiveMethod.value
|
||||||
try {
|
try {
|
||||||
if (method === 'ark') {
|
if (method === 'ecash') {
|
||||||
if (!dest.value.trim()) { error.value = 'Enter an Ark address, invoice or lightning address'; return }
|
|
||||||
await rpcClient.call<{ sent: boolean }>({
|
|
||||||
method: 'wallet.ark-send',
|
|
||||||
params: { destination: dest.value.trim(), amount_sats: amount.value },
|
|
||||||
// Ark sends can wait on round participation.
|
|
||||||
timeout: 130000,
|
|
||||||
})
|
|
||||||
resultArk.value = `Sent ${amount.value.toLocaleString()} sats via Ark`
|
|
||||||
} else if (method === 'ecash') {
|
|
||||||
const res = await rpcClient.call<{ token: string }>({
|
const res = await rpcClient.call<{ token: string }>({
|
||||||
method: 'wallet.ecash-send',
|
method: 'wallet.ecash-send',
|
||||||
params: { amount_sats: amount.value },
|
params: { amount_sats: amount.value },
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Mobile: cap at ~60% of the LIVE visible viewport (not dvh — see
|
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[90vh] flex flex-col" @close="close">
|
||||||
syncViewportHeightVar in main.ts) so the tx list doesn't fill the screen. -->
|
|
||||||
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[calc(var(--visual-viewport-height,100dvh)*0.6)] md:max-h-[90vh] flex flex-col" @close="close">
|
|
||||||
<!-- Rail filter: instant ecash micro-payments pile up fast and bury
|
<!-- Rail filter: instant ecash micro-payments pile up fast and bury
|
||||||
on-chain/Lightning rows; chips keep the standard txs reachable. -->
|
on-chain/Lightning rows; chips keep the standard txs reachable. -->
|
||||||
<div v-if="transactions.length > 0" class="flex gap-1.5 mb-3 shrink-0 flex-wrap">
|
<div v-if="transactions.length > 0" class="flex gap-1.5 mb-3 shrink-0 flex-wrap">
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ const imgFailed = ref(false)
|
|||||||
|
|
||||||
const ext = computed(() => props.item.extension)
|
const ext = computed(() => props.item.extension)
|
||||||
const isDir = computed(() => props.item.isDir)
|
const isDir = computed(() => props.item.isDir)
|
||||||
const { isImage, isVideo, isAudio, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
|
const { isImage, isVideo, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
|
||||||
|
|
||||||
const thumbnailUrl = computed(() => {
|
const thumbnailUrl = computed(() => {
|
||||||
if (!isImage.value || imgFailed.value) return null
|
if (!isImage.value || imgFailed.value) return null
|
||||||
@@ -122,12 +122,8 @@ const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
|
|||||||
function handleClick() {
|
function handleClick() {
|
||||||
if (props.item.isDir) {
|
if (props.item.isDir) {
|
||||||
emit('navigate', props.item.path)
|
emit('navigate', props.item.path)
|
||||||
} else if (isImage.value || isVideo.value || isAudio.value) {
|
} else if (isImage.value || isVideo.value) {
|
||||||
// MediaLightbox handles all three media kinds.
|
|
||||||
emit('preview', props.item.path)
|
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>
|
</script>
|
||||||
|
|||||||
@@ -251,15 +251,8 @@ export function playKeyboardTypingSound() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Gaming-style boot thud - soft impact when dashboard loads */
|
/** Gaming-style boot thud - soft impact when dashboard loads */
|
||||||
/**
|
export function playDashboardLoadOomph() {
|
||||||
* @param force Play regardless of install phase. The first dashboard entry
|
if (!isFirstInstallPhase()) return
|
||||||
* right after the onboarding wizard needs this: by then onboarding is already
|
|
||||||
* flagged complete in localStorage, so the isFirstInstallPhase() gate (which
|
|
||||||
* exists to keep ordinary re-logins quiet) would silence the one entrance
|
|
||||||
* that SHOULD be loud.
|
|
||||||
*/
|
|
||||||
export function playDashboardLoadOomph(force = false) {
|
|
||||||
if (!force && !isFirstInstallPhase()) return
|
|
||||||
const ctx = getContext()
|
const ctx = getContext()
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
|
|
||||||
|
|||||||
@@ -1715,28 +1715,26 @@ html.modal-scroll-locked .dashboard-scroll-panel {
|
|||||||
.cloud-file-list {
|
.cloud-file-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.125rem;
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cloud-file-item {
|
.cloud-file-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.625rem 0.75rem;
|
padding: 0.5rem;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
/* Readable card rows (like the Peer Files list) — bare text over the
|
background: none;
|
||||||
wallpaper was unreadable. */
|
border: none;
|
||||||
background: rgba(0, 0, 0, 0.45);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.cloud-file-item:hover {
|
.cloud-file-item:hover {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
.cloud-file-item:active {
|
.cloud-file-item:active {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
@@ -2089,7 +2087,6 @@ html.modal-scroll-locked .dashboard-scroll-panel {
|
|||||||
|
|
||||||
.mobile-category-pill {
|
.mobile-category-pill {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
white-space: nowrap;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
|||||||
@@ -28,14 +28,4 @@ describe('shouldShowIntroSplash', () => {
|
|||||||
onboardingComplete: null,
|
onboardingComplete: null,
|
||||||
})).toBe(false)
|
})).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('an explicit replay request overrides every suppression rule', () => {
|
|
||||||
expect(shouldShowIntroSplash({
|
|
||||||
seenIntro: true,
|
|
||||||
routePath: '/',
|
|
||||||
fromBoot: false,
|
|
||||||
onboardingComplete: true,
|
|
||||||
replayRequested: true,
|
|
||||||
})).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,12 +4,9 @@ export interface IntroSplashDecisionInput {
|
|||||||
fromBoot: boolean
|
fromBoot: boolean
|
||||||
devMode?: string
|
devMode?: string
|
||||||
onboardingComplete: boolean | null
|
onboardingComplete: boolean | null
|
||||||
/** Explicit "Replay intro" click — overrides every suppression rule. */
|
|
||||||
replayRequested?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean {
|
export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean {
|
||||||
if (input.replayRequested) return true
|
|
||||||
if (input.seenIntro) return false
|
if (input.seenIntro) return false
|
||||||
if (input.onboardingComplete === true) return false
|
if (input.onboardingComplete === true) return false
|
||||||
|
|
||||||
|
|||||||
@@ -1,197 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="apps-view pb-6">
|
<div class="pb-6">
|
||||||
|
|
||||||
<!-- Nav header — tabs + categories + search, matching the Apps layout -->
|
<!-- Content Type Cards -->
|
||||||
<div class="mb-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-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="cloud-tab-switcher hidden md:inline-flex">
|
|
||||||
<button
|
|
||||||
v-for="tab in TABS"
|
|
||||||
:key="tab.id"
|
|
||||||
class="cloud-tab-btn"
|
|
||||||
:class="{ 'cloud-tab-btn-active': activeTab === tab.id }"
|
|
||||||
@click="activeTab = tab.id"
|
|
||||||
>{{ tab.name }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-show="showCategories" 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: 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="cloud-tab-switcher cloud-tab-switcher-full mb-3">
|
|
||||||
<button
|
|
||||||
v-for="tab in TABS"
|
|
||||||
:key="tab.id"
|
|
||||||
@click="activeTab = tab.id"
|
|
||||||
class="cloud-tab-btn"
|
|
||||||
:class="{ 'cloud-tab-btn-active': activeTab === tab.id }"
|
|
||||||
type="button"
|
|
||||||
>{{ tab.name }}</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="showCategories" 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="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-else
|
|
||||||
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: 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">
|
|
||||||
<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 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">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- ═════════════ Folders — section (+ peer) cards ═════════════ -->
|
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<div
|
<div
|
||||||
v-for="section in contentSections"
|
v-for="section in contentSections"
|
||||||
:key="section.id"
|
:key="section.id"
|
||||||
@@ -237,7 +48,6 @@
|
|||||||
<span v-else-if="sectionCounts[section.id] !== undefined" class="text-white/30">{{ sectionCounts[section.id] }} items</span>
|
<span v-else-if="sectionCounts[section.id] !== undefined" class="text-white/30">{{ sectionCounts[section.id] }} items</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Individual Peer Cards -->
|
<!-- Individual Peer Cards -->
|
||||||
<div
|
<div
|
||||||
v-for="peer in peerNodes"
|
v-for="peer in peerNodes"
|
||||||
@@ -316,82 +126,32 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<div v-if="loadError" class="alert-error mt-4">
|
<div v-if="loadError" class="alert-error mb-4">
|
||||||
{{ loadError }}
|
{{ loadError }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Not Installed Hint (Folders tab only — My Files has its own) -->
|
<!-- Not Installed Hint -->
|
||||||
<div v-if="!fileBrowserRunning && !searchActive && activeTab === 'folders'" 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>
|
||||||
<RouterLink to="/dashboard/marketplace" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
|
<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
|
Open App Store
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, onMounted } from 'vue'
|
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 { useCloudStore } from '../stores/cloud'
|
import { fileBrowserClient } from '@/api/filebrowser-client'
|
||||||
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
|
|
||||||
import { rpcClient } from '@/api/rpc-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 router = useRouter()
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const cloudStore = useCloudStore()
|
|
||||||
const sectionCounts = ref<Record<string, number>>({})
|
const sectionCounts = ref<Record<string, number>>({})
|
||||||
const countsLoading = ref(false)
|
const countsLoading = ref(false)
|
||||||
|
|
||||||
// ── Tabs / categories / search state ────────────────────────────────────────
|
|
||||||
type TabId = 'folders' | 'mine' | 'peers'
|
|
||||||
type CategoryId = 'all' | 'photos' | 'music' | 'documents'
|
|
||||||
|
|
||||||
const TABS: Array<{ id: TabId; name: string }> = [
|
|
||||||
{ id: 'folders', name: 'Folders' },
|
|
||||||
{ 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>('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 {
|
interface PeerNode {
|
||||||
did: string
|
did: string
|
||||||
pubkey: string
|
pubkey: string
|
||||||
@@ -484,255 +244,6 @@ const SECTION_PATHS: Record<string, string> = {
|
|||||||
files: '/',
|
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`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Search (own files + all peer files) ─────────────────────────────────────
|
|
||||||
interface SearchResult {
|
|
||||||
key: string
|
|
||||||
name: string
|
|
||||||
detail: string
|
|
||||||
category: Exclude<CategoryId, 'all'>
|
|
||||||
item?: FileBrowserItem
|
|
||||||
peerName?: string
|
|
||||||
peerOnion?: 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)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function runSearch() {
|
|
||||||
const query = searchQuery.value.trim()
|
|
||||||
const seq = ++searchSeq
|
|
||||||
searching.value = true
|
|
||||||
try {
|
|
||||||
// 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 => ({
|
|
||||||
key: `peer:${f.key}`,
|
|
||||||
name: f.filename,
|
|
||||||
detail: `${formatSize(f.sizeBytes)}${f.priceSats ? ` · ${f.priceSats.toLocaleString()} sats` : ''}`,
|
|
||||||
category: f.category,
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 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() {
|
async function loadCounts() {
|
||||||
if (!fileBrowserRunning.value) return
|
if (!fileBrowserRunning.value) return
|
||||||
countsLoading.value = true
|
countsLoading.value = true
|
||||||
@@ -787,49 +298,3 @@ function openSection(section: ContentSection) {
|
|||||||
|
|
||||||
defineExpose({ loadPeers })
|
defineExpose({ loadPeers })
|
||||||
</script>
|
</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>
|
|
||||||
|
|||||||
@@ -369,9 +369,7 @@ onMounted(() => {
|
|||||||
if (loginTransition.justCompletedOnboarding) {
|
if (loginTransition.justCompletedOnboarding) {
|
||||||
// Full glitchy reveal — only on the very first dashboard entry
|
// Full glitchy reveal — only on the very first dashboard entry
|
||||||
// right after onboarding (one-time event, persists in feel).
|
// right after onboarding (one-time event, persists in feel).
|
||||||
// force=true: onboarding is already marked complete by now, so the
|
playDashboardLoadOomph()
|
||||||
// un-forced call would gate itself silent.
|
|
||||||
playDashboardLoadOomph(true)
|
|
||||||
showZoomIn.value = true
|
showZoomIn.value = true
|
||||||
loginTransition.setPendingWelcomeTyping(true)
|
loginTransition.setPendingWelcomeTyping(true)
|
||||||
loginTransition.setJustCompletedOnboarding(false)
|
loginTransition.setJustCompletedOnboarding(false)
|
||||||
|
|||||||
@@ -477,7 +477,6 @@ async function handleLogin() {
|
|||||||
stopSynthwave()
|
stopSynthwave()
|
||||||
whooshAway.value = true
|
whooshAway.value = true
|
||||||
playLoginSuccessWhoosh()
|
playLoginSuccessWhoosh()
|
||||||
consumeOnboardingFinale()
|
|
||||||
loginTransition.setJustLoggedIn(true)
|
loginTransition.setJustLoggedIn(true)
|
||||||
await new Promise(r => setTimeout(r, 520))
|
await new Promise(r => setTimeout(r, 520))
|
||||||
await router.replace(loginRedirectTo.value).catch(() => {
|
await router.replace(loginRedirectTo.value).catch(() => {
|
||||||
@@ -513,7 +512,6 @@ async function handleTotpVerify() {
|
|||||||
stopSynthwave()
|
stopSynthwave()
|
||||||
whooshAway.value = true
|
whooshAway.value = true
|
||||||
playLoginSuccessWhoosh()
|
playLoginSuccessWhoosh()
|
||||||
consumeOnboardingFinale()
|
|
||||||
loginTransition.setJustLoggedIn(true)
|
loginTransition.setJustLoggedIn(true)
|
||||||
await new Promise(r => setTimeout(r, 520))
|
await new Promise(r => setTimeout(r, 520))
|
||||||
await router.replace(loginRedirectTo.value).catch(() => {
|
await router.replace(loginRedirectTo.value).catch(() => {
|
||||||
@@ -535,28 +533,11 @@ async function handleTotpVerify() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A login right after the onboarding wizard (flag set by OnboardingDone)
|
|
||||||
* gets the FULL dashboard entrance — zoom + oomph — even though it's a
|
|
||||||
* regular password login (e.g. the demo). Subsequent logins stay
|
|
||||||
* deliberately low-key (justLoggedIn only). */
|
|
||||||
function consumeOnboardingFinale() {
|
|
||||||
try {
|
|
||||||
if (sessionStorage.getItem('archy_onboarding_finale') === '1') {
|
|
||||||
sessionStorage.removeItem('archy_onboarding_finale')
|
|
||||||
loginTransition.setJustCompletedOnboarding(true)
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function replayIntro() {
|
function replayIntro() {
|
||||||
// Clear the intro seen flag
|
// Clear the intro seen flag
|
||||||
localStorage.removeItem('neode_intro_seen')
|
localStorage.removeItem('neode_intro_seen')
|
||||||
// Demo: also clear the per-day gate so the intro plays again now.
|
// Demo: also clear the per-day gate so the intro plays again now.
|
||||||
if (IS_DEMO) clearDemoIntroSeen()
|
if (IS_DEMO) clearDemoIntroSeen()
|
||||||
// On an onboarded node App.vue instantly re-marks the intro as seen (that's
|
|
||||||
// what keeps fresh browsers on already-onboarded nodes from replaying it) —
|
|
||||||
// this explicit one-shot flag tells it the replay is deliberate.
|
|
||||||
try { sessionStorage.setItem('archipelago_replay_intro', '1') } catch { /* ignore */ }
|
|
||||||
// Navigate to root to trigger splash screen
|
// Navigate to root to trigger splash screen
|
||||||
window.location.href = '/'
|
window.location.href = '/'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,10 +120,6 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
function goToLogin() {
|
function goToLogin() {
|
||||||
playNavSound('action')
|
playNavSound('action')
|
||||||
// The login that follows the wizard gets the full dashboard entrance
|
|
||||||
// (zoom + oomph) even when it's a regular password login (e.g. the demo) —
|
|
||||||
// Login.vue consumes this flag on success.
|
|
||||||
try { sessionStorage.setItem('archy_onboarding_finale', '1') } catch { /* ignore */ }
|
|
||||||
router.push('/login').catch(() => {})
|
router.push('/login').catch(() => {})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -441,16 +441,16 @@
|
|||||||
switch to the other if it has enough balance. -->
|
switch to the other if it has enough balance. -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<button
|
<button
|
||||||
v-for="b in (['cashu', 'fedimint', 'ark'] as const)"
|
v-for="b in (['cashu', 'fedimint'] as const)"
|
||||||
:key="b"
|
:key="b"
|
||||||
@click="ecashPlan.chosen = b"
|
@click="ecashPlan.chosen = b"
|
||||||
:disabled="ecashBalanceOf(b) < getItemPrice(payItem.access)"
|
:disabled="ecashBalanceOf(b) < getItemPrice(payItem.access)"
|
||||||
class="w-full px-4 py-3 rounded-xl flex items-center gap-3 text-left border transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
class="w-full px-4 py-3 rounded-xl flex items-center gap-3 text-left border transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
:class="ecashPlan.chosen === b ? 'border-green-400/70 bg-green-400/10' : 'border-white/10 bg-white/5 hover:bg-white/10'"
|
:class="ecashPlan.chosen === b ? 'border-green-400/70 bg-green-400/10' : 'border-white/10 bg-white/5 hover:bg-white/10'"
|
||||||
>
|
>
|
||||||
<span class="text-xl shrink-0">{{ b === 'cashu' ? '🥜' : b === 'fedimint' ? '🤝' : '⚓' }}</span>
|
<span class="text-xl shrink-0">{{ b === 'cashu' ? '🥜' : '🤝' }}</span>
|
||||||
<span class="flex-1 min-w-0">
|
<span class="flex-1 min-w-0">
|
||||||
<span class="block text-base text-white">{{ b === 'cashu' ? 'Cashu' : b === 'fedimint' ? 'Fedimint' : 'Ark' }}</span>
|
<span class="block text-base text-white">{{ b === 'cashu' ? 'Cashu' : 'Fedimint' }}</span>
|
||||||
<span class="block text-xs text-white/50">Balance: {{ ecashBalanceOf(b).toLocaleString() }} sats<span v-if="ecashBalanceOf(b) < getItemPrice(payItem.access)"> · not enough</span></span>
|
<span class="block text-xs text-white/50">Balance: {{ ecashBalanceOf(b).toLocaleString() }} sats<span v-if="ecashBalanceOf(b) < getItemPrice(payItem.access)"> · not enough</span></span>
|
||||||
</span>
|
</span>
|
||||||
<svg v-if="ecashPlan.chosen === b" class="w-5 h-5 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg v-if="ecashPlan.chosen === b" class="w-5 h-5 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -707,11 +707,10 @@ const payMode = ref<'choose' | 'ecash-confirm' | 'qr'>('choose')
|
|||||||
// Ecash confirmation step: after the user picks "pay from this node's ecash",
|
// Ecash confirmation step: after the user picks "pay from this node's ecash",
|
||||||
// we look at both balances, decide which backend covers the price, and show a
|
// we look at both balances, decide which backend covers the price, and show a
|
||||||
// confirm screen so they see (and can switch) which ecash is spent (#3).
|
// confirm screen so they see (and can switch) which ecash is spent (#3).
|
||||||
type EcashBackend = 'cashu' | 'fedimint' | 'ark'
|
type EcashBackend = 'cashu' | 'fedimint'
|
||||||
const ecashPlan = ref<{
|
const ecashPlan = ref<{
|
||||||
cashu: number
|
cashu: number
|
||||||
fedimint: number
|
fedimint: number
|
||||||
ark: number
|
|
||||||
total: number
|
total: number
|
||||||
chosen: EcashBackend | null
|
chosen: EcashBackend | null
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
@@ -1136,7 +1135,7 @@ async function pollOnchain(address: string) {
|
|||||||
/** Spendable balance for a given ecash backend in the current plan. */
|
/** Spendable balance for a given ecash backend in the current plan. */
|
||||||
function ecashBalanceOf(b: EcashBackend): number {
|
function ecashBalanceOf(b: EcashBackend): number {
|
||||||
if (!ecashPlan.value) return 0
|
if (!ecashPlan.value) return 0
|
||||||
return b === 'cashu' ? ecashPlan.value.cashu : b === 'fedimint' ? ecashPlan.value.fedimint : ecashPlan.value.ark
|
return b === 'cashu' ? ecashPlan.value.cashu : ecashPlan.value.fedimint
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1153,25 +1152,23 @@ async function prepareEcashPay() {
|
|||||||
try {
|
try {
|
||||||
let cashu = 0
|
let cashu = 0
|
||||||
let fedimint = 0
|
let fedimint = 0
|
||||||
let ark = 0
|
|
||||||
try {
|
try {
|
||||||
const res = await rpcClient.call<{ cashu_sats?: number; fedimint_sats?: number; ark_sats?: number; total_sats?: number; balance_sats?: number }>({
|
const res = await rpcClient.call<{ cashu_sats?: number; fedimint_sats?: number; total_sats?: number; balance_sats?: number }>({
|
||||||
method: 'wallet.ecash-balance',
|
method: 'wallet.ecash-balance',
|
||||||
})
|
})
|
||||||
cashu = res?.cashu_sats ?? res?.balance_sats ?? 0
|
cashu = res?.cashu_sats ?? res?.balance_sats ?? 0
|
||||||
fedimint = res?.fedimint_sats ?? 0
|
fedimint = res?.fedimint_sats ?? 0
|
||||||
ark = res?.ark_sats ?? 0
|
|
||||||
} catch {
|
} catch {
|
||||||
// Couldn't read balances — let the user try anyway (auto backend).
|
// Couldn't read balances — let the user try anyway (auto backend).
|
||||||
}
|
}
|
||||||
const total = cashu + fedimint + ark
|
const total = cashu + fedimint
|
||||||
// Prefer Cashu when it covers the price, else Fedimint, else Ark, else
|
// Prefer Cashu when it covers the price, else Fedimint, else leave null
|
||||||
// leave null (insufficient — shown in the confirm screen, Confirm disabled).
|
// (insufficient — shown in the confirm screen, Confirm disabled).
|
||||||
const chosen: EcashBackend | null =
|
const chosen: EcashBackend | null =
|
||||||
cashu >= price ? 'cashu' : fedimint >= price ? 'fedimint' : ark >= price ? 'ark' : null
|
cashu >= price ? 'cashu' : fedimint >= price ? 'fedimint' : null
|
||||||
ecashPlan.value = { cashu, fedimint, ark, total, chosen }
|
ecashPlan.value = { cashu, fedimint, total, chosen }
|
||||||
if (!chosen) {
|
if (!chosen) {
|
||||||
purchaseError.value = `Not enough funds: Cashu ${cashu} + Fedimint ${fedimint} + Ark ${ark} sats, need ${price}. Fund a wallet, or pay another way.`
|
purchaseError.value = `Not enough ecash: Cashu ${cashu} + Fedimint ${fedimint} sats, need ${price}. Fund a wallet, or pay another way.`
|
||||||
}
|
}
|
||||||
payMode.value = 'ecash-confirm'
|
payMode.value = 'ecash-confirm'
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||