From bb14490fb7319bd6c2f72475dc26a2afefe1dfa0 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sat, 11 Apr 2026 23:11:41 -0400 Subject: [PATCH] feat: botfights, discover, mobile gamepad, content handler, package config updates Miscellaneous improvements: botfights manifest, discover page curated apps, mobile gamepad enhancements, content HTTP handler, package install config updates, health monitor tweaks, shared content UI, container specs and image version updates. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/botfights/manifest.yml | 2 +- core/archipelago/src/api/handler/content.rs | 52 ++++++ core/archipelago/src/api/handler/mod.rs | 5 + core/archipelago/src/api/rpc/content.rs | 153 ++++++++++++++++++ .../archipelago/src/api/rpc/package/config.rs | 69 ++++---- .../src/api/rpc/package/install.rs | 10 ++ core/archipelago/src/health_monitor.rs | 10 +- .../assets/img/featured/indeedhub-banner.jpg | Bin 0 -> 31488 bytes neode-ui/public/catalog.json | 48 ++++++ neode-ui/src/components/cloud/FileCard.vue | 5 +- neode-ui/src/components/cloud/FileGrid.vue | 3 + neode-ui/src/style.css | 143 ++++++++++++++++ neode-ui/src/views/AppSession.vue | 5 +- neode-ui/src/views/Discover.vue | 81 ++++++++-- neode-ui/src/views/Marketplace.vue | 4 +- .../src/views/appSession/MobileGamepad.vue | 76 +++++++-- neode-ui/src/views/discover/curatedApps.ts | 84 ++++++++-- neode-ui/src/views/discover/types.ts | 1 + .../src/views/marketplace/marketplaceData.ts | 2 +- neode-ui/src/views/web5/Web5SharedContent.vue | 69 +++++++- scripts/container-specs.sh | 16 +- scripts/image-versions.sh | 7 +- scripts/reconcile-containers.sh | 12 +- 23 files changed, 782 insertions(+), 75 deletions(-) create mode 100644 neode-ui/public/assets/img/featured/indeedhub-banner.jpg create mode 100644 neode-ui/public/catalog.json diff --git a/apps/botfights/manifest.yml b/apps/botfights/manifest.yml index 8ab3dd5d..736a927c 100644 --- a/apps/botfights/manifest.yml +++ b/apps/botfights/manifest.yml @@ -6,7 +6,7 @@ app: category: community container: - image: git.tx1138.com/lfg2025/botfights:1.0.0 + image: git.tx1138.com/lfg2025/botfights:1.1.0 pull_policy: always dependencies: diff --git a/core/archipelago/src/api/handler/content.rs b/core/archipelago/src/api/handler/content.rs index 75ba3b01..74540e5f 100644 --- a/core/archipelago/src/api/handler/content.rs +++ b/core/archipelago/src/api/handler/content.rs @@ -119,4 +119,56 @@ impl ApiHandler { } } } + + /// Serve a degraded preview of paid content (blurred image or first 2% of video). + pub(super) async fn handle_content_preview( + path: &str, + config: &Config, + ) -> Result> { + // Path format: /content/{id}/preview + let content_id = path + .strip_prefix("/content/") + .and_then(|s| s.strip_suffix("/preview")) + .unwrap_or(""); + + if content_id.is_empty() || !is_valid_app_id(content_id) { + return Ok(build_response(StatusCode::BAD_REQUEST, "text/plain", hyper::Body::from("Invalid content ID"))); + } + + match content_server::serve_content_preview(&config.data_dir, content_id).await { + Ok(content_server::PreviewResult::FullContent(bytes, mime_type)) => { + let len = bytes.len(); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", mime_type) + .header("Content-Length", len.to_string()) + .body(hyper::Body::from(bytes)) + .unwrap()) + } + Ok(content_server::PreviewResult::BlurPreview(bytes, mime_type)) => { + let len = bytes.len(); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", mime_type) + .header("Content-Length", len.to_string()) + .header("X-Content-Preview", "blur") + .body(hyper::Body::from(bytes)) + .unwrap()) + } + Ok(content_server::PreviewResult::TruncatedPreview(bytes, mime_type, total_size)) => { + let len = bytes.len(); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", mime_type) + .header("Content-Length", len.to_string()) + .header("X-Content-Preview", "truncated") + .header("X-Content-Total-Size", total_size.to_string()) + .body(hyper::Body::from(bytes)) + .unwrap()) + } + Ok(content_server::PreviewResult::NotFound) | Err(_) => { + Ok(build_response(StatusCode::NOT_FOUND, "text/plain", hyper::Body::from("Preview not available"))) + } + } + } } diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index 15676165..7ca98daa 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -205,6 +205,11 @@ impl ApiHandler { Self::handle_node_message(body_bytes).await } + // Content preview — degraded previews for paid content (no auth, no payment) + (Method::GET, p) if p.starts_with("/content/") && p.ends_with("/preview") => { + Self::handle_content_preview(p, &self.config).await + } + // Content serving — peers access shared content over Tor (no session auth) (Method::GET, p) if p.starts_with("/content/") => { Self::handle_content_request(p, &headers, &self.config).await diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index 9c0f7864..922aa1fd 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -1,6 +1,7 @@ use super::RpcHandler; use crate::content_server::{self, AccessControl, Availability, ContentItem}; use crate::network::dwn_store::DwnStore; +use crate::wallet::ecash; use anyhow::{Context, Result}; use tracing::debug; @@ -313,4 +314,156 @@ impl RpcHandler { Ok(body) } + + /// Download paid content from a peer: mint ecash token, send with request. + pub(super) async fn handle_content_download_peer_paid( + &self, + params: Option, + ) -> Result { + 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"))?; + let price_sats = params + .get("price_sats") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing price_sats"))?; + + if price_sats == 0 { + return Err(anyhow::anyhow!("price_sats must be > 0")); + } + if !is_valid_v3_onion(onion) { + return Err(anyhow::anyhow!("Invalid v3 onion address")); + } + + // Mint ecash payment token + let token_str = ecash::send_token(&self.config.data_dir, price_sats) + .await + .context("Failed to create ecash payment token — check wallet balance")?; + + let socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY) + .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) + .header("X-Payment-Token", &token_str) + .send() + .await + .context("Failed to connect to peer over Tor")?; + + if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED { + // Payment was rejected — token is spent but content not received + return Err(anyhow::anyhow!( + "Payment rejected by peer — token may have been insufficient or invalid" + )); + } + + 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(), + "paid_sats": price_sats, + })) + } + + /// Fetch a preview of paid content from a peer (no payment required). + pub(super) async fn handle_content_preview_peer( + &self, + params: Option, + ) -> Result { + 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 !is_valid_v3_onion(onion) { + return Err(anyhow::anyhow!("Invalid v3 onion address")); + } + + let socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY) + .context("Failed to create SOCKS proxy")?; + + let client = reqwest::Client::builder() + .proxy(socks_proxy) + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("Failed to build Tor HTTP client")?; + + let url = format!("http://{}/content/{}/preview", onion, content_id); + debug!("Fetching content preview from {}", url); + + let response = client + .get(&url) + .send() + .await + .context("Failed to connect to peer for preview")?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Peer returned error for preview: {}", + response.status() + )); + } + + let is_preview = response + .headers() + .get("X-Content-Preview") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + + let bytes = response + .bytes() + .await + .context("Failed to read preview response")?; + + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + + Ok(serde_json::json!({ + "data": encoded, + "size": bytes.len(), + "content_type": content_type, + "preview_mode": is_preview, + })) + } } diff --git a/core/archipelago/src/api/rpc/package/config.rs b/core/archipelago/src/api/rpc/package/config.rs index 1901d86e..67de0f43 100644 --- a/core/archipelago/src/api/rpc/package/config.rs +++ b/core/archipelago/src/api/rpc/package/config.rs @@ -218,6 +218,11 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec ( + "curl -sf http://localhost:8176/ || exit 1", + "60s", + "3", + ), "nostr-rs-relay" | "nostr-relay" => { ("curl -sf http://localhost:8080/ || exit 1", "30s", "3") } @@ -754,37 +759,43 @@ pub(super) async fn get_app_config( Some(vec![ "--data-dir".to_string(), "/data".to_string(), - format!("--bitcoind-url=http://{}:{}@host.containers.internal:8332", rpc_user, rpc_pass), - ]), - ), - "fedimint-gateway" => ( - vec!["8176:8176".to_string(), "9737:9737".to_string()], - vec!["/var/lib/archipelago/fedimint-gateway:/data".to_string()], - vec![], - None, - Some(vec![ - "gatewayd".to_string(), - "--data-dir".to_string(), - "/data".to_string(), - "--listen".to_string(), - "0.0.0.0:8176".to_string(), - "--bcrypt-password-hash".to_string(), - "$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string(), - "--network".to_string(), - "bitcoin".to_string(), - "--bitcoind-url".to_string(), - format!("http://{}:8332", host_ip), - "--bitcoind-username".to_string(), - rpc_user.to_string(), - "--bitcoind-password".to_string(), - rpc_pass.to_string(), - "ldk".to_string(), - "--ldk-lightning-port".to_string(), - "9737".to_string(), - "--ldk-alias".to_string(), - "archipelago-gateway".to_string(), + format!("--bitcoind-url=http://{}:{}@{}:8332", rpc_user, rpc_pass, host_ip), ]), ), + "fedimint-gateway" => { + let fedi_hash = read_secret( + "fedimint-gateway-hash", + "$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC", + ); + ( + vec!["8176:8176".to_string(), "9737:9737".to_string()], + vec!["/var/lib/archipelago/fedimint-gateway:/data".to_string()], + vec![], + None, + Some(vec![ + "gatewayd".to_string(), + "--data-dir".to_string(), + "/data".to_string(), + "--listen".to_string(), + "0.0.0.0:8176".to_string(), + "--bcrypt-password-hash".to_string(), + fedi_hash, + "--network".to_string(), + "bitcoin".to_string(), + "--bitcoind-url".to_string(), + format!("http://{}:8332", host_ip), + "--bitcoind-username".to_string(), + rpc_user.to_string(), + "--bitcoind-password".to_string(), + rpc_pass.to_string(), + "ldk".to_string(), + "--ldk-lightning-port".to_string(), + "9737".to_string(), + "--ldk-alias".to_string(), + "archipelago-gateway".to_string(), + ]), + ) + } "indeedhub" => ( vec!["7778:7777".to_string()], vec![], diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index f4f1430a..4d2bafca 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -567,8 +567,18 @@ impl RpcHandler { debug!("Pulling image: {}", docker_image); self.set_install_progress(package_id, 0, 0).await; + // Set TMPDIR to user-writable location — rootless podman's user namespace + // makes /var/tmp read-only, which causes `podman pull` to fail with + // "mkdir /var/tmp/container_images_storage...: read-only file system" + let user_tmp = format!( + "{}/.local/share/containers/tmp", + std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()) + ); + let _ = std::fs::create_dir_all(&user_tmp); + let mut child = tokio::process::Command::new("podman") .args(["pull", docker_image]) + .env("TMPDIR", &user_tmp) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() diff --git a/core/archipelago/src/health_monitor.rs b/core/archipelago/src/health_monitor.rs index 6ffc3a3f..121cc9c9 100644 --- a/core/archipelago/src/health_monitor.rs +++ b/core/archipelago/src/health_monitor.rs @@ -76,7 +76,7 @@ fn container_dependencies(name: &str) -> &'static [&'static str] { "mempool-api" => &["mempool-db", "electrumx"], "mempool-web" => &["mempool-api"], "fedimint" => &["bitcoin-knots"], - "fedimint-gateway" => &["lnd"], + "fedimint-gateway" => &["bitcoin-knots", "fedimint"], // IndeedHub stack "indeedhub-api" => &["indeedhub-postgres", "indeedhub-redis"], @@ -525,6 +525,14 @@ pub fn spawn_health_monitor(state: Arc, data_dir: PathBuf) { let (mut data, _) = state.get_snapshot().await; for container in &containers { + // Skip optional/marketplace containers that aren't installed + if let Some(pkg) = data.package_data.get(&container.app_id) { + if pkg.installed.is_none() { + debug!("Skipping uninstalled container: {}", container.name); + continue; + } + } + if container.healthy { if tracker.attempt_count(&container.name) > 0 { info!("Container {} is healthy again after restart", container.name); diff --git a/neode-ui/public/assets/img/featured/indeedhub-banner.jpg b/neode-ui/public/assets/img/featured/indeedhub-banner.jpg new file mode 100644 index 0000000000000000000000000000000000000000..da0d3cb28cfaac1bd8b1927c2a2505c5a4136c56 GIT binary patch literal 31488 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 literal 0 HcmV?d00001 diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json new file mode 100644 index 00000000..790cab2a --- /dev/null +++ b/neode-ui/public/catalog.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "updated": "2026-04-11T00:00:00Z", + "registry": "git.tx1138.com/lfg2025", + "featured": { + "id": "indeedhub", + "banner": "/assets/img/featured/indeedhub-banner.jpg", + "headline": "Stream Sovereignty", + "description": "Bitcoin documentaries with Nostr identity. God Bless Bitcoin, The Bitcoin Psyop, and more — streaming from your own node.", + "tag": "NOSTR IDENTITY // YOUR NODE" + }, + "apps": [ + { "id": "bitcoin-knots", "title": "Bitcoin Knots", "version": "28.1.0", "description": "Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.", "icon": "/assets/img/app-icons/bitcoin-knots.webp", "author": "Bitcoin Knots", "dockerImage": "bitcoin-knots:latest", "repoUrl": "https://github.com/bitcoinknots/bitcoin", "category": "money", "tier": "core" }, + { "id": "lnd", "title": "LND", "version": "0.18.4", "description": "Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.", "icon": "/assets/img/app-icons/lnd.svg", "author": "Lightning Labs", "dockerImage": "lnd:v0.18.4-beta", "repoUrl": "https://github.com/lightningnetwork/lnd", "category": "money", "tier": "core" }, + { "id": "btcpay-server", "title": "BTCPay Server", "version": "1.13.7", "description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.", "icon": "/assets/img/app-icons/btcpay-server.png", "author": "BTCPay Server Foundation", "dockerImage": "btcpayserver:1.13.7", "repoUrl": "https://github.com/btcpayserver/btcpayserver", "category": "commerce", "tier": "core" }, + { "id": "mempool", "title": "Mempool Explorer", "version": "3.0.0", "description": "Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses.", "icon": "/assets/img/app-icons/mempool.webp", "author": "Mempool", "dockerImage": "mempool-frontend:v3.0.0", "repoUrl": "https://github.com/mempool/mempool", "category": "money", "tier": "core" }, + { "id": "electrumx", "title": "ElectrumX", "version": "1.18.0", "description": "Electrum protocol server. Index the blockchain for fast wallet lookups, privately.", "icon": "/assets/img/app-icons/electrumx.webp", "author": "Luke Childs", "dockerImage": "electrumx:v1.18.0", "repoUrl": "https://github.com/spesmilo/electrumx", "category": "money", "tier": "core" }, + { "id": "indeedhub", "title": "IndeeHub", "version": "1.0.0", "description": "Bitcoin documentary streaming with Nostr identity. Stream sovereignty content from your node.", "icon": "/assets/img/app-icons/indeedhub.png", "author": "IndeeHub Team", "dockerImage": "indeedhub:1.0.0", "repoUrl": "https://github.com/indeedhub/indeedhub", "category": "community" }, + { "id": "botfights", "title": "BotFights", "version": "1.0.0", "description": "Bot arena + 2-player arcade fighter with controller support.", "icon": "/assets/img/app-icons/botfights.svg", "author": "BotFights", "dockerImage": "botfights:1.1.0", "repoUrl": "https://botfights.net", "category": "community" }, + { "id": "filebrowser", "title": "File Browser", "version": "2.27.0", "description": "Web-based file manager. Browse, upload, and manage files on your server.", "icon": "/assets/img/app-icons/file-browser.webp", "author": "File Browser", "dockerImage": "filebrowser:v2.27.0", "repoUrl": "https://github.com/filebrowser/filebrowser", "category": "data", "tier": "core" }, + { "id": "vaultwarden", "title": "Vaultwarden", "version": "1.30.0", "description": "Self-hosted password vault. Bitwarden-compatible with zero-knowledge encryption.", "icon": "/assets/img/app-icons/vaultwarden.webp", "author": "Vaultwarden", "dockerImage": "vaultwarden:1.30.0-alpine", "repoUrl": "https://github.com/dani-garcia/vaultwarden", "category": "data", "tier": "recommended" }, + { "id": "searxng", "title": "SearXNG", "version": "2024.1.0", "description": "Privacy-respecting metasearch engine. Search the internet without being tracked.", "icon": "/assets/img/app-icons/searxng.png", "author": "SearXNG", "dockerImage": "searxng:latest", "repoUrl": "https://github.com/searxng/searxng", "category": "data", "tier": "recommended" }, + { "id": "nostr-rs-relay", "title": "Nostr Relay", "version": "0.9.0", "description": "Your own Nostr relay. Store events locally, relay for friends, publish over Tor.", "icon": "/assets/img/app-icons/nostr-rs-relay.svg", "author": "scsiblade", "dockerImage": "nostr-rs-relay:0.9.0", "repoUrl": "https://sr.ht/~gheartsfield/nostr-rs-relay/", "category": "nostr" }, + { "id": "fedimint", "title": "Fedimint", "version": "0.10.0", "description": "Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.", "icon": "/assets/img/app-icons/fedimint.png", "author": "Fedimint", "dockerImage": "fedimintd:v0.10.0", "repoUrl": "https://github.com/fedimint/fedimint", "category": "money" }, + { "id": "ollama", "title": "Ollama", "version": "0.5.4", "description": "Run AI models locally. Llama, Mistral, and more — on your hardware, completely private.", "icon": "/assets/img/app-icons/ollama.png", "author": "Ollama", "dockerImage": "ollama:latest", "repoUrl": "https://github.com/ollama/ollama", "category": "data" }, + { "id": "nextcloud", "title": "Nextcloud", "version": "28", "description": "Your own private cloud. File sync, calendars, contacts — all on your hardware.", "icon": "/assets/img/app-icons/nextcloud.webp", "author": "Nextcloud", "dockerImage": "nextcloud:28", "repoUrl": "https://github.com/nextcloud/server", "category": "data" }, + { "id": "jellyfin", "title": "Jellyfin", "version": "10.8.13", "description": "Free media server. Stream your movies, music, and photos to any device.", "icon": "/assets/img/app-icons/jellyfin.webp", "author": "Jellyfin", "dockerImage": "jellyfin:10.8.13", "repoUrl": "https://github.com/jellyfin/jellyfin", "category": "data" }, + { "id": "immich", "title": "Immich", "version": "1.90.0", "description": "High-performance photo and video backup. Mobile-first with ML features.", "icon": "/assets/img/app-icons/immich.png", "author": "Immich", "dockerImage": "immich-server:release", "repoUrl": "https://github.com/immich-app/immich", "category": "data" }, + { "id": "homeassistant", "title": "Home Assistant", "version": "2024.1", "description": "Open-source home automation. Control smart home devices privately.", "icon": "/assets/img/app-icons/homeassistant.png", "author": "Home Assistant", "dockerImage": "home-assistant:2024.1", "repoUrl": "https://github.com/home-assistant/core", "category": "home" }, + { "id": "grafana", "title": "Grafana", "version": "10.2.0", "description": "Analytics and monitoring platform. Dashboards for your node metrics.", "icon": "/assets/img/app-icons/grafana.png", "author": "Grafana Labs", "dockerImage": "grafana:10.2.0", "repoUrl": "https://github.com/grafana/grafana", "category": "data", "tier": "recommended" }, + { "id": "tailscale", "title": "Tailscale", "version": "1.78.0", "description": "Zero-config VPN. Secure remote access with WireGuard mesh networking.", "icon": "/assets/img/app-icons/tailscale.webp", "author": "Tailscale", "dockerImage": "tailscale:stable", "repoUrl": "https://github.com/tailscale/tailscale", "category": "networking", "tier": "recommended" }, + { "id": "penpot", "title": "Penpot", "version": "2.4", "description": "Open-source design platform. Self-hosted alternative to Figma.", "icon": "/assets/img/app-icons/penpot.webp", "author": "Penpot", "dockerImage": "penpot-frontend:2.4", "repoUrl": "https://github.com/penpot/penpot", "category": "data" }, + { "id": "photoprism", "title": "PhotoPrism", "version": "240915", "description": "AI-powered photo management with facial recognition, privately.", "icon": "/assets/img/app-icons/photoprism.svg", "author": "PhotoPrism", "dockerImage": "photoprism:240915", "repoUrl": "https://github.com/photoprism/photoprism", "category": "data" }, + { "id": "uptime-kuma", "title": "Uptime Kuma", "version": "1.23.0", "description": "Self-hosted uptime monitoring. Track HTTP, TCP, DNS, and more.", "icon": "/assets/img/app-icons/uptime-kuma.webp", "author": "Uptime Kuma", "dockerImage": "uptime-kuma:1", "repoUrl": "https://github.com/louislam/uptime-kuma", "category": "data", "tier": "recommended" }, + { "id": "nostr-vpn", "title": "Nostr VPN", "version": "0.3.7", "description": "Tailscale-style mesh VPN with Nostr control plane.", "icon": "/assets/img/app-icons/nostr-vpn.svg", "author": "Martti Malmi", "dockerImage": "nostr-vpn:v0.3.7", "repoUrl": "https://github.com/mmalmi/nostr-vpn", "category": "networking" }, + { "id": "fips", "title": "FIPS", "version": "0.1.0", "description": "Free Internetworking Peering System. Self-organizing encrypted mesh.", "icon": "/assets/img/app-icons/fips.svg", "author": "Jim Corgan", "dockerImage": "fips:v0.1.0", "repoUrl": "https://github.com/jmcorgan/fips", "category": "networking" }, + { "id": "routstr", "title": "Routstr", "version": "0.4.3", "description": "Decentralized AI inference proxy. Pay-per-request with Cashu ecash.", "icon": "/assets/img/app-icons/routstr.svg", "author": "Routstr", "dockerImage": "routstr:v0.4.3", "repoUrl": "https://github.com/routstr/routstr-core", "category": "community" }, + { "id": "dwn", "title": "Decentralized Web Node", "version": "0.4.0", "description": "Own your data with DID-based access control. Sync across devices.", "icon": "/assets/img/app-icons/dwn.svg", "author": "TBD", "dockerImage": "dwn-server:main", "repoUrl": "https://github.com/TBD54566975/dwn-server", "category": "data" }, + { "id": "cryptpad", "title": "CryptPad", "version": "2024.12.0", "description": "End-to-end encrypted documents and collaboration. Zero-knowledge.", "icon": "/assets/img/app-icons/cryptpad.webp", "author": "XWiki SAS", "dockerImage": "cryptpad:2024.12.0", "repoUrl": "https://github.com/cryptpad/cryptpad", "category": "data" }, + { "id": "nostrudel", "title": "noStrudel", "version": "0.40.0", "description": "Feature-rich Nostr web client.", "icon": "/assets/img/app-icons/nostrudel.svg", "author": "hzrd149", "dockerImage": "", "repoUrl": "https://github.com/hzrd149/nostrudel", "webUrl": "https://nostrudel.ninja", "category": "nostr" }, + { "id": "nwnn", "title": "Next Web News Network", "version": "1.0.0", "description": "Decentralized news aggregator.", "icon": "/assets/img/app-icons/nwnn.png", "author": "L484", "dockerImage": "", "webUrl": "https://nwnn.l484.com", "category": "l484" }, + { "id": "484-kitchen", "title": "484 Kitchen", "version": "1.0.0", "description": "K484 application platform.", "icon": "/assets/img/app-icons/484-kitchen.png", "author": "L484", "dockerImage": "", "webUrl": "https://484.kitchen", "category": "l484" }, + { "id": "call-the-operator", "title": "Call the Operator", "version": "1.0.0", "description": "Escape the Matrix.", "icon": "/assets/img/app-icons/call-the-operator.png", "author": "TX1138", "dockerImage": "", "webUrl": "https://cta.tx1138.com", "category": "l484" }, + { "id": "arch-presentation", "title": "Arch Presentation", "version": "1.0.0", "description": "The Future of Decentralized Infrastructure.", "icon": "/assets/img/app-icons/arch-presentation.png", "author": "L484", "dockerImage": "", "webUrl": "https://present.l484.com", "category": "l484" }, + { "id": "syntropy-institute", "title": "Syntropy Institute", "version": "1.0.0", "description": "Medicine Reimagined.", "icon": "/assets/img/app-icons/syntropy-institute.png", "author": "Syntropy Institute", "dockerImage": "", "webUrl": "https://syntropy.institute", "category": "l484" }, + { "id": "t-zero", "title": "T-0", "version": "1.0.0", "description": "Documentary series exploring decentralization.", "icon": "/assets/img/app-icons/t-zero.png", "author": "T-0", "dockerImage": "", "webUrl": "https://teeminuszero.net", "category": "l484" } + ] +} diff --git a/neode-ui/src/components/cloud/FileCard.vue b/neode-ui/src/components/cloud/FileCard.vue index 3e9bc144..31af8340 100644 --- a/neode-ui/src/components/cloud/FileCard.vue +++ b/neode-ui/src/components/cloud/FileCard.vue @@ -102,6 +102,7 @@ const emit = defineEmits<{ navigate: [path: string] delete: [path: string] share: [path: string, name: string, isDir: boolean] + preview: [path: string] }>() const cloudStore = useCloudStore() @@ -109,7 +110,7 @@ const imgFailed = ref(false) const ext = computed(() => props.item.extension) const isDir = computed(() => props.item.isDir) -const { isImage, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir) +const { isImage, isVideo, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir) const thumbnailUrl = computed(() => { if (!isImage.value || imgFailed.value) return null @@ -121,6 +122,8 @@ const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path)) function handleClick() { if (props.item.isDir) { emit('navigate', props.item.path) + } else if (isImage.value || isVideo.value) { + emit('preview', props.item.path) } } diff --git a/neode-ui/src/components/cloud/FileGrid.vue b/neode-ui/src/components/cloud/FileGrid.vue index a0f05948..6f98f60a 100644 --- a/neode-ui/src/components/cloud/FileGrid.vue +++ b/neode-ui/src/components/cloud/FileGrid.vue @@ -41,6 +41,7 @@ @delete="$emit('delete', $event)" @play="(path, name) => $emit('play', path, name)" @share="(path, name, isDir) => $emit('share', path, name, isDir)" + @preview="$emit('preview', $event)" /> @@ -53,6 +54,7 @@ @navigate="$emit('navigate', $event)" @delete="$emit('delete', $event)" @share="(path, name, isDir) => $emit('share', path, name, isDir)" + @preview="$emit('preview', $event)" /> @@ -76,5 +78,6 @@ defineEmits<{ delete: [path: string] play: [path: string, name: string] share: [path: string, name: string, isDir: boolean] + preview: [path: string] }>() diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css index b096bc25..2e4cd0e5 100644 --- a/neode-ui/src/style.css +++ b/neode-ui/src/style.css @@ -1894,6 +1894,113 @@ html:has(body.video-background-active)::before { cursor: not-allowed; } +/* ── Media Lightbox ── */ +.lightbox-backdrop { + position: fixed; + inset: 0; + z-index: 4000; + background: rgba(0, 0, 0, 0.92); + display: flex; + align-items: center; + justify-content: center; + outline: none; +} +.lightbox-close { + position: absolute; + top: 1rem; + right: 1rem; + z-index: 4010; + color: rgba(255, 255, 255, 0.7); + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 0.5rem; + padding: 0.5rem; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.lightbox-close:hover { + background: rgba(255, 255, 255, 0.2); + color: white; +} +.lightbox-counter { + position: absolute; + top: 1.25rem; + left: 50%; + transform: translateX(-50%); + z-index: 4010; + color: rgba(255, 255, 255, 0.5); + font-size: 0.875rem; + font-variant-numeric: tabular-nums; +} +.lightbox-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 4010; + color: rgba(255, 255, 255, 0.5); + background: rgba(255, 255, 255, 0.05); + border: none; + border-radius: 0.5rem; + padding: 0.75rem 0.5rem; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.lightbox-nav:hover { + background: rgba(255, 255, 255, 0.15); + color: white; +} +.lightbox-nav-prev { left: 1rem; } +.lightbox-nav-next { right: 1rem; } +.lightbox-content { + display: flex; + align-items: center; + justify-content: center; + max-width: calc(100vw - 8rem); + max-height: calc(100vh - 6rem); +} +.lightbox-media { + max-width: calc(100vw - 8rem); + max-height: calc(100vh - 6rem); + object-fit: contain; + border-radius: 0.25rem; +} +.lightbox-loading { + display: flex; + align-items: center; + justify-content: center; + width: 200px; + height: 200px; +} +.lightbox-error { + display: flex; + align-items: center; + justify-content: center; + width: 200px; + height: 200px; +} +.lightbox-filename { + position: absolute; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + z-index: 4010; + background: rgba(0, 0, 0, 0.6); + padding: 0.375rem 1rem; + border-radius: 0.5rem; +} +@media (max-width: 768px) { + .lightbox-nav-prev { left: 0.25rem; } + .lightbox-nav-next { right: 0.25rem; } + .lightbox-content { + max-width: calc(100vw - 2rem); + max-height: calc(100vh - 4rem); + } + .lightbox-media { + max-width: calc(100vw - 2rem); + max-height: calc(100vh - 4rem); + } +} + /* Share action button highlight */ .cloud-file-action-share:hover { background: rgba(251, 146, 60, 0.2); @@ -2302,6 +2409,42 @@ html:has(body.video-background-active)::before { border: 1px solid rgba(251, 146, 60, 0.2); } +/* Featured App Banner */ +.featured-banner { + position: relative; + min-height: 320px; + border-radius: 16px; + border: 1px solid rgba(251, 146, 60, 0.15); +} +.featured-banner-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 16px; + opacity: 0.6; + transition: opacity 0.4s ease; +} +.featured-banner:hover .featured-banner-img { + opacity: 0.75; +} +.featured-banner-overlay { + position: relative; + z-index: 1; + padding: 2.5rem; + display: flex; + flex-direction: column; + justify-content: flex-end; + min-height: 320px; + background: linear-gradient(to top, rgba(0,0,0,0.92) 0%, rgba(0,0,0,0.5) 40%, rgba(0,0,0,0.1) 100%); + border-radius: 16px; +} +@media (max-width: 768px) { + .featured-banner { min-height: 240px; } + .featured-banner-overlay { padding: 1.5rem; min-height: 240px; } +} + .discover-stat-pill { display: inline-flex; align-items: center; diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue index 75365048..29f73f54 100644 --- a/neode-ui/src/views/AppSession.vue +++ b/neode-ui/src/views/AppSession.vue @@ -38,11 +38,14 @@ @open-new-tab-and-back="openNewTabAndBack" /> - +
- +