diff --git a/panel/server/src/config.rs b/panel/server/src/config.rs index ba9b23a..44df8f4 100644 --- a/panel/server/src/config.rs +++ b/panel/server/src/config.rs @@ -47,6 +47,9 @@ impl Config { pub fn textures_dir(&self) -> PathBuf { self.data_dir.join("textures") } + pub fn downloads_dir(&self) -> PathBuf { + self.data_dir.join("downloads") + } pub fn signing_key_path(&self) -> PathBuf { self.data_dir.join("yggdrasil-signing.pem") } diff --git a/panel/server/src/lib.rs b/panel/server/src/lib.rs index 99e7043..0f977e5 100644 --- a/panel/server/src/lib.rs +++ b/panel/server/src/lib.rs @@ -94,9 +94,11 @@ pub fn app(state: AppState) -> Router { Router::new() .route("/healthz", axum::routing::get(routes::public::health)) + .route("/download/launcher/{platform}", axum::routing::get(routes::landing::download_launcher)) .merge(routes::api(&state)) .nest_service("/files", ServeDir::new(state.cfg.files_dir())) .nest_service("/uploads", tower::ServiceBuilder::new().layer(long_cache).service(ServeDir::new(state.cfg.uploads_dir()))) + .nest_service("/downloads", ServeDir::new(state.cfg.downloads_dir())) .fallback_service(spa) // Lets authlib-injector users enter just the panel URL (API Location Indication). .layer(SetResponseHeaderLayer::if_not_present( diff --git a/panel/server/src/routes/landing.rs b/panel/server/src/routes/landing.rs new file mode 100644 index 0000000..e308a6d --- /dev/null +++ b/panel/server/src/routes/landing.rs @@ -0,0 +1,288 @@ +//! Public landing page & admin landing builder API. + +use crate::auth::AdminUser; +use crate::error::{AppError, AppResult}; +use crate::state::AppState; +use crate::store; +use axum::body::Body; +use axum::extract::{Multipart, Path, State}; +use axum::http::header; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::Json; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct HostedDownload { + pub platform: String, // "windows", "mac", "linux" + pub label: String, + pub filename: String, + pub file_url: String, + pub size: i64, + pub uploaded_at: String, +} + +impl Default for HostedDownload { + fn default() -> Self { + Self { + platform: "windows".into(), + label: "Windows".into(), + filename: "".into(), + file_url: "".into(), + size: 0, + uploaded_at: "".into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct FaqItem { + pub id: String, + pub question: String, + pub answer: String, +} + +impl Default for FaqItem { + fn default() -> Self { + Self { + id: "".into(), + question: "".into(), + answer: "".into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct LandingBlock { + pub id: String, + #[serde(rename = "type")] + pub block_type: String, // "hero", "download", "servers", "leaderboard", "stats", "instances", "news", "faq", "socials" + pub enabled: bool, + pub title: Option, + pub subtitle: Option, +} + +impl Default for LandingBlock { + fn default() -> Self { + Self { + id: "".into(), + block_type: "hero".into(), + enabled: true, + title: None, + subtitle: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct LandingConfig { + pub enabled: bool, + pub brand_name: String, + pub logo_url: Option, + pub tagline: String, + pub hero_title: String, + pub hero_subtitle: String, + pub hero_cta_text: String, + pub hero_bg_type: String, // "gradient", "image", "video" + pub hero_bg_url: Option, + pub server_ip: String, + pub server_port: u16, + pub hosted_downloads: Vec, + pub external_download_url: Option, + pub blocks: Vec, + pub faqs: Vec, + pub custom_css: String, +} + +impl Default for LandingConfig { + fn default() -> Self { + Self { + enabled: true, + brand_name: "SCOPENET".into(), + logo_url: None, + tagline: "Your worlds, one click away.".into(), + hero_title: "The Ultimate Minecraft Experience".into(), + hero_subtitle: "Custom instances, automated modpack sync, zero hassle. Download the launcher and dive straight into our servers.".into(), + hero_cta_text: "Download Launcher".into(), + hero_bg_type: "gradient".into(), + hero_bg_url: None, + server_ip: "".into(), + server_port: 25565, + hosted_downloads: vec![], + external_download_url: None, + blocks: vec![ + LandingBlock { id: "b_hero".into(), block_type: "hero".into(), enabled: true, title: None, subtitle: None }, + LandingBlock { id: "b_download".into(), block_type: "download".into(), enabled: true, title: Some("Get the Launcher".into()), subtitle: Some("Available on Windows, macOS, and Linux".into()) }, + LandingBlock { id: "b_servers".into(), block_type: "servers".into(), enabled: true, title: Some("Live Servers".into()), subtitle: Some("Check our current status and join the game".into()) }, + LandingBlock { id: "b_stats".into(), block_type: "stats".into(), enabled: true, title: Some("Community By Numbers".into()), subtitle: Some("Every block placed, every second played".into()) }, + LandingBlock { id: "b_leaderboard".into(), block_type: "leaderboard".into(), enabled: true, title: Some("Top Players".into()), subtitle: Some("The champions of our network".into()) }, + LandingBlock { id: "b_instances".into(), block_type: "instances".into(), enabled: true, title: Some("Featured Instances".into()), subtitle: Some("Hand-crafted modpacks ready to launch".into()) }, + LandingBlock { id: "b_news".into(), block_type: "news".into(), enabled: true, title: Some("Latest News".into()), subtitle: Some("Updates, events, and patch notes".into()) }, + LandingBlock { id: "b_faq".into(), block_type: "faq".into(), enabled: true, title: Some("Frequently Asked Questions".into()), subtitle: Some("Everything you need to know".into()) }, + LandingBlock { id: "b_socials".into(), block_type: "socials".into(), enabled: true, title: Some("Join Our Community".into()), subtitle: Some("Connect with thousands of players".into()) }, + ], + faqs: vec![ + FaqItem { + id: "faq_1".into(), + question: "How do I start playing?".into(), + answer: "Download our custom launcher, sign in or create an account, select an instance and hit Play! All mods and Java runtimes download automatically.".into(), + }, + FaqItem { + id: "faq_2".into(), + question: "Do I need to install Java manually?".into(), + answer: "No. The SCOPENET launcher automatically detects, downloads, and isolates the exact Java runtime required for each Minecraft version.".into(), + }, + FaqItem { + id: "faq_3".into(), + question: "Can I customize my skin and cape?".into(), + answer: "Yes! In the launcher settings, you can upload HD skins, choose arm styles, pick your earned capes, and save multiple wardrobe presets to swap anytime.".into(), + }, + ], + custom_css: "".into(), + } + } +} + +pub async fn get_config(state: &AppState) -> AppResult { + store::kv_get(state, "landing_page").await +} + +pub async fn public_landing(State(state): State) -> AppResult> { + let mut cfg = get_config(&state).await?; + // If branding has been configured, adopt brand name & tagline if default + if let Ok(branding) = store::branding(&state).await { + if cfg.brand_name == "SCOPENET" && !branding.name.is_empty() { + cfg.brand_name = branding.name; + } + if cfg.tagline == "Your worlds, one click away." && !branding.tagline.is_empty() { + cfg.tagline = branding.tagline; + } + } + // If server ip is empty, try to populate from first server + if cfg.server_ip.is_empty() { + if let Ok(Some(first_server)) = sqlx::query_scalar::<_, String>("SELECT address FROM game_servers ORDER BY id ASC LIMIT 1").fetch_optional(&state.db).await { + cfg.server_ip = first_server; + } + } + Ok(Json(cfg)) +} + +pub async fn admin_get_landing(_: AdminUser, State(state): State) -> AppResult> { + Ok(Json(get_config(&state).await?)) +} + +pub async fn admin_put_landing(_: AdminUser, State(state): State, Json(cfg): Json) -> AppResult> { + store::kv_set(&state, "landing_page", &cfg).await?; + Ok(Json(cfg)) +} + +pub async fn upload_launcher( + _: AdminUser, + State(state): State, + mut form: Multipart, +) -> AppResult> { + let mut platform = String::from("windows"); + let mut label = String::from("Windows Installer"); + let mut filename = String::new(); + let mut file_bytes = Vec::new(); + + while let Some(field) = form.next_field().await? { + match field.name().unwrap_or_default() { + "platform" => platform = field.text().await?, + "label" => label = field.text().await?, + "file" => { + if let Some(f_name) = field.file_name() { + filename = f_name.to_string(); + } + file_bytes = field.bytes().await?.to_vec(); + } + _ => {} + } + } + + if file_bytes.is_empty() { + return Err(AppError::bad_request("no launcher file was uploaded")); + } + if filename.is_empty() { + filename = format!("scopenet-launcher-{platform}"); + } + + // Sanitize filename to prevent directory traversal + let safe_name = PathBuf::from(&filename) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| format!("launcher-{platform}")); + + let dir = state.cfg.downloads_dir(); + tokio::fs::create_dir_all(&dir).await?; + let target = dir.join(&safe_name); + tokio::fs::write(&target, &file_bytes).await?; + + let size = file_bytes.len() as i64; + let file_url = format!("/download/launcher/{platform}"); + + let mut cfg = get_config(&state).await?; + let ts = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + + let download_entry = HostedDownload { + platform: platform.clone(), + label, + filename: safe_name, + file_url, + size, + uploaded_at: ts, + }; + + if let Some(idx) = cfg.hosted_downloads.iter().position(|d| d.platform == platform) { + cfg.hosted_downloads[idx] = download_entry; + } else { + cfg.hosted_downloads.push(download_entry); + } + + store::kv_set(&state, "landing_page", &cfg).await?; + Ok(Json(cfg)) +} + +pub async fn download_launcher( + State(state): State, + Path(platform): Path, +) -> AppResult { + let cfg = get_config(&state).await?; + let entry = cfg.hosted_downloads.iter().find(|d| d.platform == platform || d.platform == "all"); + + if let Some(d) = entry { + let file_path = state.cfg.downloads_dir().join(&d.filename); + if file_path.exists() { + let bytes = tokio::fs::read(&file_path).await?; + let cd = format!("attachment; filename=\"{}\"", d.filename); + let mime = if d.filename.ends_with(".exe") || d.filename.ends_with(".msi") { + "application/x-msdownload" + } else if d.filename.ends_with(".dmg") { + "application/x-apple-diskimage" + } else if d.filename.ends_with(".deb") { + "application/vnd.debian.binary-package" + } else if d.filename.ends_with(".AppImage") { + "application/x-executable" + } else { + "application/octet-stream" + }; + return Ok(( + [ + (header::CONTENT_TYPE, mime), + (header::CONTENT_DISPOSITION, cd.as_str()), + ], + Body::from(bytes), + ).into_response()); + } + } + + if let Some(ext) = cfg.external_download_url.filter(|u| !u.is_empty()) { + return Ok(Redirect::temporary(&ext).into_response()); + } + + Err(AppError::not_found("no launcher download available for this platform yet")) +} diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index eedbc0f..a4efa37 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -1,6 +1,7 @@ pub mod account; pub mod activity; pub mod admin; +pub mod landing; pub mod meta; pub mod public; pub mod servers; @@ -30,6 +31,7 @@ pub fn api(state: &AppState) -> Router { .route("/servers/public", get(servers::public_servers)) .route("/servers/{id}/leaderboard", get(servers::public_server_leaderboard)) .route("/leaderboard", get(servers::global_leaderboard)) + .route("/landing", get(landing::public_landing)) .route("/launcher/authlib-injector.json", get(account::authlib_index)) .route("/launcher/authlib-injector.jar", get(account::authlib_jar)) .layer(DefaultBodyLimit::max(4 * 1024 * 1024)); @@ -43,6 +45,8 @@ pub fn api(state: &AppState) -> Router { .route("/groups/{id}", delete(admin::delete_group)) .route("/branding", get(admin::get_branding).put(admin::put_branding)) .route("/settings", get(admin::get_settings).put(admin::put_settings)) + .route("/landing", get(landing::admin_get_landing).put(landing::admin_put_landing)) + .route("/landing/upload-launcher", post(landing::upload_launcher)) .route("/instances", get(admin::list_instances).post(admin::create_instance)) .route("/instances/{id}", get(admin::get_instance).put(admin::update_instance).delete(admin::delete_instance)) .route("/instances/{id}/reset", post(admin::reset_source)) diff --git a/panel/server/tests/api.rs b/panel/server/tests/api.rs index 3169328..1f270b7 100644 --- a/panel/server/tests/api.rs +++ b/panel/server/tests/api.rs @@ -223,3 +223,33 @@ async fn settings_never_leak_curseforge_key() { let (s, _) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"public_url": "ftp://nope"}))).await; assert_eq!(s, StatusCode::BAD_REQUEST, "public URL must be http(s)"); } + +#[tokio::test] +async fn landing_page_config_and_permissions() { + let t = setup().await; + // 1. Public landing endpoint accessible without authentication + let (s, v) = t.call("GET", "/api/v1/landing", None, None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(v["brand_name"], "SCOPENET"); + assert_eq!(v["enabled"], true); + assert!(v["blocks"].as_array().unwrap().len() >= 5); + + // 2. Unauthenticated user cannot modify landing config + let (s, _) = t.call("PUT", "/api/admin/landing", None, Some(json!({"brand_name": "Hacked"}))).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + + // 3. Admin can update landing config + let admin = t.login("admin", "supersecret").await; + let mut updated = v.clone(); + updated["brand_name"] = json!("PixelCraft"); + updated["hero_title"] = json!("Welcome to PixelCraft"); + let (s, v2) = t.call("PUT", "/api/admin/landing", Some(&admin), Some(updated)).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(v2["brand_name"], "PixelCraft"); + assert_eq!(v2["hero_title"], "Welcome to PixelCraft"); + + // 4. Public endpoint reflects the updated config + let (s, v3) = t.call("GET", "/api/v1/landing", None, None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(v3["brand_name"], "PixelCraft"); +} diff --git a/panel/web/src/App.svelte b/panel/web/src/App.svelte index 8aab04d..5e39377 100644 --- a/panel/web/src/App.svelte +++ b/panel/web/src/App.svelte @@ -12,6 +12,8 @@ import ServerDetail from './pages/ServerDetail.svelte'; import Capes from './pages/Capes.svelte'; import Activity from './pages/Activity.svelte'; + import LandingBuilder from './pages/LandingBuilder.svelte'; + import PublicLanding from './pages/PublicLanding.svelte'; import { get } from './lib/api'; import { route } from './lib/router.svelte'; import { logout, session, type Me } from './lib/session.svelte'; @@ -40,7 +42,9 @@ get('/api/v1/launcher/manifest') .then((m) => { brand = m.branding; - document.title = `${m.branding.name} · Admin`; + if (route.name !== 'landing') { + document.title = `${m.branding.name} · Admin`; + } }) .catch(() => {}); }); @@ -48,8 +52,14 @@ {#if checking}
+{:else if route.name === 'landing'} + {:else if !session.user} - + {#if route.name === 'login'} + + {:else} + + {/if} {:else}
@@ -71,6 +81,8 @@ {:else if route.name === 'branding'} (brand = b)} /> + {:else if route.name === 'landing-builder'} + {:else if route.name === 'settings'} {:else} diff --git a/panel/web/src/components/Sidebar.svelte b/panel/web/src/components/Sidebar.svelte index 67a41ec..adb9726 100644 --- a/panel/web/src/components/Sidebar.svelte +++ b/panel/web/src/components/Sidebar.svelte @@ -1,5 +1,5 @@ @@ -38,6 +39,10 @@
+ + View landing page + + Documentation diff --git a/panel/web/src/lib/types.ts b/panel/web/src/lib/types.ts index 8dc8a0b..fcb7e81 100644 --- a/panel/web/src/lib/types.ts +++ b/panel/web/src/lib/types.ts @@ -139,3 +139,47 @@ export interface ServerDetail { export interface UserActivity { servers: PlayerStats[]; events: ServerEvent[]; online_on: { id: number; name: string }[] } export type ServerDraft = { name: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean }; + +export interface HostedDownload { + platform: 'windows' | 'mac' | 'linux'; + label: string; + filename: string; + file_url: string; + size: number; + uploaded_at: string; +} + +export interface FaqItem { + id: string; + question: string; + answer: string; +} + +export type BlockType = 'hero' | 'download' | 'servers' | 'leaderboard' | 'stats' | 'instances' | 'news' | 'faq' | 'socials'; + +export interface LandingBlock { + id: string; + type: BlockType; + enabled: boolean; + title?: string; + subtitle?: string; +} + +export interface LandingConfig { + enabled: boolean; + brand_name: string; + logo_url: string | null; + tagline: string; + hero_title: string; + hero_subtitle: string; + hero_cta_text: string; + hero_bg_type: 'gradient' | 'image' | 'video'; + hero_bg_url: string | null; + server_ip: string; + server_port: number; + hosted_downloads: HostedDownload[]; + external_download_url: string | null; + blocks: LandingBlock[]; + faqs: FaqItem[]; + custom_css: string; +} diff --git a/panel/web/src/pages/LandingBuilder.svelte b/panel/web/src/pages/LandingBuilder.svelte new file mode 100644 index 0000000..08f3c07 --- /dev/null +++ b/panel/web/src/pages/LandingBuilder.svelte @@ -0,0 +1,403 @@ + + +{#if !config} +
+{:else} +
+
+
+
+

Landing Page Builder

+ + {config.enabled ? 'Live' : 'Disabled'} + +
+

Modular public landing page for your Minecraft network and hosted launcher downloads.

+
+ +
+ + View Live Page + + +
+
+ +
+ + + + +
+ +
+ {#if activeTab === 'blocks'} +
+ +
+

General & Hero Settings

+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+
+

Blocks Architecture

+

Toggle and reorder sections to build your custom landing page.

+
+
+ +
+ {#each config.blocks as block, idx (block.id)} + {@const meta = blockMeta[block.type as BlockType] || { label: block.type, icon: Sparkles, desc: '' }} + {@const Icon = meta.icon} +
+
+ + +
+ +
+ +
+ +
+
+ {meta.label} + {block.type} +
+ {meta.desc} +
+ +
+ {#if block.type !== 'hero'} + + {/if} + +
+
+ {/each} +
+
+
+ {:else if activeTab === 'downloads'} +
+
+

Hosted Launcher Installers

+

Upload desktop launcher packages so visitors can download them directly from your panel.

+ +
+ +
+
+ Windows (.exe / .msi) + {#if winDownload}Available{:else}Not uploaded{/if} +
+ {#if winDownload} +
+ File: {winDownload.filename} + Size: {formatBytes(winDownload.size)} +
+ {/if} + +
+ + +
+
+ macOS (.dmg / .zip) + {#if macDownload}Available{:else}Not uploaded{/if} +
+ {#if macDownload} +
+ File: {macDownload.filename} + Size: {formatBytes(macDownload.size)} +
+ {/if} + +
+ + +
+
+ Linux (.AppImage / .deb) + {#if linuxDownload}Available{:else}Not uploaded{/if} +
+ {#if linuxDownload} +
+ File: {linuxDownload.filename} + Size: {formatBytes(linuxDownload.size)} +
+ {/if} + +
+
+ +
+ + +
+
+ {:else if activeTab === 'faq'} +
+
+
+
+

Frequently Asked Questions

+

Add common questions and helpful answers for your community.

+
+ +
+ +
+ {#each config.faqs as faq, idx (faq.id)} +
+
+ Item #{idx + 1} + +
+ + +
+ {:else} +

No questions yet. Click "Add FAQ Item" above.

+ {/each} +
+
+
+ {:else if activeTab === 'css'} +
+
+

Custom CSS

+

Add CSS rules to override fonts, colors, animations, or styling on the public landing page.

+ +
+
+ {/if} +
+
+{/if} + + diff --git a/panel/web/src/pages/PublicLanding.svelte b/panel/web/src/pages/PublicLanding.svelte new file mode 100644 index 0000000..924a4bb --- /dev/null +++ b/panel/web/src/pages/PublicLanding.svelte @@ -0,0 +1,619 @@ + + + + {config?.brand_name ?? 'SCOPENET'} · Official Server & Launcher + + +
+ + + + +
+ {#if config} + {#each config.blocks.filter((b) => b.enabled) as block (block.id)} + {#if block.type === 'hero'} + +
+
+
+
+ + {totalOnline} Players Online Now +
+ +

{config.hero_title}

+

{config.hero_subtitle}

+ +
+ {#if primaryDownload} + + +
+ {config.hero_cta_text} + for {primaryDownload.label} ({formatBytes(primaryDownload.size)}) +
+
+ {:else if config.external_download_url} + + +
+ {config.hero_cta_text} + Download Latest Release +
+
+ {:else} + + {/if} + + {#if config.server_ip} + + {/if} +
+
+
+ + {:else if block.type === 'download'} + +
+
+
+

{block.title || 'Get the Launcher'}

+

{block.subtitle || 'Custom modpack synchronisation, fast Java auto-updates, and HD skin wardrobe.'}

+
+ +
+ {#each config.hosted_downloads as d} +
+ {#if d.platform === detectedOS} + Your Operating System + {/if} +
+ +
+

{d.label}

+

{d.filename} · {formatBytes(d.size)}

+ + Download + +
+ {:else} + {#if config.external_download_url} +
+

Download SCOPENET Launcher

+

Get the latest launcher package directly from our release repository.

+ + Download from Repository + +
+ {:else} +

No downloads currently configured. Check back soon!

+ {/if} + {/each} +
+
+
+ + {:else if block.type === 'servers'} + +
+
+
+

{block.title || 'Live Game Servers'}

+

{block.subtitle || 'Connect to our high-performance Minecraft worlds.'}

+
+ + {#if publicServers.length} +
+ {#each publicServers as s} +
+
+
+ + {s.online ? `${s.players_online} / ${s.players_max} Online` : 'Offline'} +
+ {#if s.mc_version} + {s.mc_version} + {/if} +
+ +

{s.name}

+
+ +
+
+ {/each} +
+ {:else} +
+ +

No public servers listed yet.

+
+ {/if} +
+
+ + {:else if block.type === 'leaderboard'} + +
+
+
+

{block.title || 'Community Leaderboards'}

+

{block.subtitle || 'Top players and champions across our Minecraft network.'}

+
+ +
+ {#each [ + { id: 'playtime_secs', label: 'Playtime', icon: Clock }, + { id: 'player_kills', label: 'Player Kills', icon: Swords }, + { id: 'mob_kills', label: 'Mob Kills', icon: Flame }, + { id: 'blocks_broken', label: 'Blocks Mined', icon: Pickaxe }, + ] as opt} + {@const Icon = opt.icon} + + {/each} +
+ + {#if leaderboard.length} + + {#if top3.length > 0} +
+ {#if top3[1]} +
+ 🥈 + {top3[1].name} + + {lbSort === 'playtime_secs' ? formatTime(top3[1].playtime_secs) : formatNumber(top3[1][lbSort as keyof typeof top3[1]] as number)} + +
+ {/if} + {#if top3[0]} +
+ 👑 + {top3[0].name} + + {lbSort === 'playtime_secs' ? formatTime(top3[0].playtime_secs) : formatNumber(top3[0][lbSort as keyof typeof top3[0]] as number)} + +
+ {/if} + {#if top3[2]} +
+ 🥉 + {top3[2].name} + + {lbSort === 'playtime_secs' ? formatTime(top3[2].playtime_secs) : formatNumber(top3[2][lbSort as keyof typeof top3[2]] as number)} + +
+ {/if} +
+ {/if} + + +
+
+ Rank + Player + Score + Playtime + Kills +
+ {#each restLb as row, i (row.uuid)} +
+ #{i + 4} + {row.name} + + {lbSort === 'playtime_secs' ? formatTime(row.playtime_secs) : formatNumber(row[lbSort as keyof typeof row] as number)} + + {formatTime(row.playtime_secs)} + {row.player_kills} +
+ {/each} +
+ {:else} +
+ +

No leaderboard data recorded yet.

+
+ {/if} +
+
+ + {:else if block.type === 'stats'} + +
+
+
+

{block.title || 'Network Highlights'}

+

{block.subtitle || 'Live statistics computed across all connected servers.'}

+
+ +
+
+ {formatNumber(totalPlayers)} + Total Players +
+
+ {formatTime(totalPlaytime)} + Time Explored +
+
+ {publicServers.length} + Connected Servers +
+
+ {instances.length} + Modpack Instances +
+
+
+
+ + {:else if block.type === 'instances'} + + {#if instances.length} +
+
+
+

{block.title || 'Available Instances'}

+

{block.subtitle || 'Optimized modpacks ready to play through our custom launcher.'}

+
+ +
+ {#each instances as inst} +
+
+ {inst.mc_version} + {inst.loader} +
+

{inst.name}

+

{inst.description || 'Pre-configured instance with automated mod updates.'}

+ +
+ {/each} +
+
+
+ {/if} + + {:else if block.type === 'news'} + + {#if news.length} +
+
+
+

{block.title || 'Latest News'}

+

{block.subtitle || 'Community events, server updates, and patch notes.'}

+
+ +
+ {#each news as item} +
+ {#if item.tag}{item.tag}{/if} +

{item.title}

+

{item.body}

+ {#if item.date}{item.date}{/if} +
+ {/each} +
+
+
+ {/if} + + {:else if block.type === 'faq'} + +
+
+
+

{block.title || 'Frequently Asked Questions'}

+

{block.subtitle || 'Got questions? We have answers.'}

+
+ +
+ {#each config.faqs as faq} + {@const isOpen = !!openFaq[faq.id]} +
+ + {#if isOpen} +
+

{faq.answer}

+
+ {/if} +
+ {/each} +
+
+
+ {/if} + {/each} + {/if} +
+ + +
+ +
+
+ +