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 00000000..da0d3cb2 Binary files /dev/null and b/neode-ui/public/assets/img/featured/indeedhub-banner.jpg differ 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" /> - +
- +