Add launcher preferences and modular landing studio [skip ci]

This commit is contained in:
SCOPEDD committed 2026-09-30 06:48:03 -04:00
1 parent 454620f21e
commit 6deff62a3a
25 files changed
+822 -682

No files matched your search

+42
View File
@@ -4,6 +4,36 @@ use anyhow::Result;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::Path; use std::path::Path;
/// Player-owned vanilla preferences. Pack-managed options.txt may be replaced
/// during sync, so these are captured separately and merged back afterward.
const VANILLA_KEYS: &[&str] = &[
"fov", "gamma", "graphicsMode", "fancyGraphics", "renderDistance", "simulationDistance",
"maxFps", "enableVsync", "fullscreen", "fullscreenResolution", "guiScale", "particles",
"clouds", "renderClouds", "entityShadows", "entityDistanceScaling", "biomeBlendRadius",
"mipmapLevels", "ao", "ambientOcclusion", "bobView", "attackIndicator", "autoJump",
"mouseSensitivity", "invertYMouse", "discrete_mouse_scroll", "mouseWheelSensitivity",
"rawMouseInput", "toggleCrouch", "toggleSprint", "sneakToggled", "sprintToggled",
"screenEffectScale", "fovEffectScale", "darknessEffectScale", "narrator", "subtitles",
"chatVisibility", "chatColors", "chatLinks", "chatLinksPrompt", "chatOpacity", "textBackgroundOpacity",
"backgroundForChatOnly", "chatScale", "chatWidth", "chatHeightFocused", "chatHeightUnfocused",
"mainHand", "lang", "forceUnicodeFont", "tutorialStep", "hideBundleTutorial",
"soundDevice", "directionalAudio", "musicFrequency", "notificationDisplayTime",
"joinedFirstServer", "pauseOnLostFocus", "reducedDebugInfo", "showSubtitles",
];
pub fn read_vanilla_preferences(game_dir: &Path) -> Result<BTreeMap<String, String>> {
let path = game_dir.join("options.txt");
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
Err(e) => return Err(e.into()),
};
Ok(contents.lines().filter_map(|line| {
let (key, value) = line.split_once(':')?;
(VANILLA_KEYS.contains(&key) || key.starts_with("soundCategory_")).then(|| (key.to_owned(), value.to_owned()))
}).collect())
}
/// Merge `key_<binding>:<key>` lines into options.txt, keeping everything /// Merge `key_<binding>:<key>` lines into options.txt, keeping everything
/// else the game has written. Only for 1.13+ (named key codes). /// else the game has written. Only for 1.13+ (named key codes).
pub fn apply_keybinds(game_dir: &Path, binds: &BTreeMap<String, String>) -> Result<()> { pub fn apply_keybinds(game_dir: &Path, binds: &BTreeMap<String, String>) -> Result<()> {
@@ -51,4 +81,16 @@ mod tests {
let out = std::fs::read_to_string(dir.path().join("options.txt")).unwrap(); let out = std::fs::read_to_string(dir.path().join("options.txt")).unwrap();
assert_eq!(out, "version:3465\nkey_key.forward:key.keyboard.up\nfov:0.0\nkey_key.sprint:key.keyboard.left.control\n"); assert_eq!(out, "version:3465\nkey_key.forward:key.keyboard.up\nfov:0.0\nkey_key.sprint:key.keyboard.left.control\n");
} }
#[test]
fn captures_only_vanilla_preferences() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("options.txt"), "fov:0.8\ngamma:1.0\nautoJump:false\nsoundCategory_music:0.25\nkey_key.forward:key.keyboard.w\nmodded:yes\n").unwrap();
let prefs = read_vanilla_preferences(dir.path()).unwrap();
assert_eq!(prefs.get("fov").map(String::as_str), Some("0.8"));
assert_eq!(prefs.get("autoJump").map(String::as_str), Some("false"));
assert_eq!(prefs.get("soundCategory_music").map(String::as_str), Some("0.25"));
assert!(!prefs.contains_key("modded"));
assert!(!prefs.contains_key("key_key.forward"));
}
} }
+17
View File
@@ -31,6 +31,7 @@ pub struct Branding {
pub version_label: String, pub version_label: String,
pub news: Vec<NewsItem>, pub news: Vec<NewsItem>,
pub links: Vec<SocialLink>, pub links: Vec<SocialLink>,
pub about: AboutContent,
pub features: Features, pub features: Features,
/// Raw CSS appended to the launcher stylesheet (power users). /// Raw CSS appended to the launcher stylesheet (power users).
pub custom_css: String, pub custom_css: String,
@@ -60,12 +61,28 @@ impl Default for Branding {
tag: Some("News".into()), tag: Some("News".into()),
}], }],
links: vec![], links: vec![],
about: AboutContent::default(),
features: Features::default(), features: Features::default(),
custom_css: String::new(), custom_css: String::new(),
} }
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct AboutContent {
pub title: String,
pub icon_url: Option<String>,
pub body: String,
pub links: Vec<SocialLink>,
}
impl Default for AboutContent {
fn default() -> Self {
Self { title: String::new(), icon_url: None, body: String::new(), links: Vec::new() }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)] #[serde(default)]
pub struct Background { pub struct Background {
+1 -1
View File
@@ -135,7 +135,7 @@ fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Resu
} }
pub async fn rename_panel(state: &AppState, username: &str, password: &str) -> Result<Account> { pub async fn rename_panel(state: &AppState, username: &str, password: &str) -> Result<Account> {
if state.game.lock().unwrap().is_some() || state.task.lock().unwrap().as_ref().is_some_and(|t| !t.is_finished()) { if !state.game.lock().unwrap().is_empty() || state.task.lock().unwrap().as_ref().is_some_and(|t| !t.is_finished()) {
bail!("close Minecraft and wait for any installation to finish before changing your username"); bail!("close Minecraft and wait for any installation to finish before changing your username");
} }
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?; let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
+25 -9
View File
@@ -52,9 +52,12 @@ pub struct Bootstrap {
accounts: Vec<Account>, accounts: Vec<Account>,
active_account: Option<String>, active_account: Option<String>,
manifest: Option<LauncherManifest>, manifest: Option<LauncherManifest>,
game_running: Option<String>, game_running: Vec<RunningGameInfo>,
} }
#[derive(Serialize)]
struct RunningGameInfo { run_id: String, instance_id: String }
#[tauri::command] #[tauri::command]
pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap { pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
let accounts = state.accounts.read().unwrap().clone(); let accounts = state.accounts.read().unwrap().clone();
@@ -74,7 +77,7 @@ pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
accounts: accounts.accounts, accounts: accounts.accounts,
active_account: accounts.active, active_account: accounts.active,
manifest: state.manifest.read().unwrap().clone(), manifest: state.manifest.read().unwrap().clone(),
game_running: state.game.lock().unwrap().as_ref().map(|g| g.instance_id.clone()), game_running: state.game.lock().unwrap().iter().map(|g| RunningGameInfo { run_id: g.run_id.clone(), instance_id: g.instance_id.clone() }).collect(),
} }
} }
@@ -119,11 +122,24 @@ pub async fn set_panel_url(state: State<'_, AppState>, url: String) -> Res<Launc
pub fn save_settings(state: State<'_, AppState>, settings: Settings) -> Res<()> { pub fn save_settings(state: State<'_, AppState>, settings: Settings) -> Res<()> {
let mut current = state.settings.write().unwrap(); let mut current = state.settings.write().unwrap();
let panel = current.panel_url.clone(); let panel = current.panel_url.clone();
*current = Settings { panel_url: panel, ..settings }; let captured = current.instance_game_options.clone();
*current = Settings { panel_url: panel, instance_game_options: captured, ..settings };
drop(current); drop(current);
state.save_settings().map_err(aerr) state.save_settings().map_err(aerr)
} }
#[tauri::command]
pub fn save_instance_options(state: State<'_, AppState>, instance_id: String) -> Res<std::collections::BTreeMap<String, String>> {
if state.game.lock().unwrap().iter().any(|g| g.instance_id == instance_id) {
return Err("close this instance before saving its game settings".into());
}
let options = scopenet_core::options::read_vanilla_preferences(&state.layout.instance_dir(&instance_id)).map_err(aerr)?;
if options.is_empty() { return Err("no vanilla options.txt settings found yet".into()); }
state.settings.write().unwrap().instance_game_options.insert(instance_id, options.clone());
state.save_settings().map_err(aerr)?;
Ok(options)
}
// ---- accounts ---- // ---- accounts ----
#[tauri::command] #[tauri::command]
@@ -345,8 +361,8 @@ pub fn repair_instance(app: AppHandle, state: State<'_, AppState>, instance_id:
} }
fn start_task(app: AppHandle, state: &AppState, instance_id: String, start: bool, deep: bool) -> Res<()> { fn start_task(app: AppHandle, state: &AppState, instance_id: String, start: bool, deep: bool) -> Res<()> {
if state.game.lock().unwrap().is_some() { if deep && state.game.lock().unwrap().iter().any(|g| g.instance_id == instance_id) {
return Err("Minecraft is already running".into()); return Err("Close this instance's game windows before repairing it".into());
} }
let mut task = state.task.lock().unwrap(); let mut task = state.task.lock().unwrap();
if task.as_ref().is_some_and(|t| !t.is_finished()) { if task.as_ref().is_some_and(|t| !t.is_finished()) {
@@ -373,9 +389,9 @@ pub fn cancel_launch(app: AppHandle, state: State<'_, AppState>, instance_id: St
} }
#[tauri::command] #[tauri::command]
pub fn kill_game(state: State<'_, AppState>) { pub fn kill_game(state: State<'_, AppState>, run_id: String) {
if let Some(g) = state.game.lock().unwrap().take() { if let Some(g) = state.game.lock().unwrap().iter_mut().find(|g| g.run_id == run_id) {
g.kill.send(()).ok(); if let Some(kill) = g.kill.take() { kill.send(()).ok(); }
} }
} }
@@ -444,7 +460,7 @@ pub async fn storage_info(state: State<'_, AppState>) -> Res<Storage> {
#[tauri::command] #[tauri::command]
pub async fn delete_instance_data(state: State<'_, AppState>, instance_id: String) -> Res<()> { pub async fn delete_instance_data(state: State<'_, AppState>, instance_id: String) -> Res<()> {
if state.game.lock().unwrap().as_ref().is_some_and(|g| g.instance_id == instance_id) { if state.game.lock().unwrap().iter().any(|g| g.instance_id == instance_id) {
return Err("close the game first".into()); return Err("close the game first".into());
} }
let dir = state.layout.instance_dir(&instance_id); let dir = state.layout.instance_dir(&instance_id);
+41 -12
View File
@@ -19,6 +19,7 @@ use tokio::io::{AsyncBufReadExt, BufReader};
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
pub struct StatePayload { pub struct StatePayload {
pub instance_id: String, pub instance_id: String,
pub run_id: Option<String>,
/// "preparing" | "running" | "idle" | "error" /// "preparing" | "running" | "idle" | "error"
pub state: String, pub state: String,
pub message: Option<String>, pub message: Option<String>,
@@ -27,6 +28,7 @@ pub struct StatePayload {
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
pub struct ExitPayload { pub struct ExitPayload {
pub instance_id: String, pub instance_id: String,
pub run_id: String,
pub code: Option<i32>, pub code: Option<i32>,
pub crashed: bool, pub crashed: bool,
pub tail: Vec<String>, pub tail: Vec<String>,
@@ -34,7 +36,7 @@ pub struct ExitPayload {
} }
pub fn emit_state(app: &AppHandle, id: &str, state: &str, message: Option<String>) { pub fn emit_state(app: &AppHandle, id: &str, state: &str, message: Option<String>) {
app.emit("launch://state", StatePayload { instance_id: id.into(), state: state.into(), message }).ok(); app.emit("launch://state", StatePayload { instance_id: id.into(), run_id: None, state: state.into(), message }).ok();
} }
/// Fetch the instance definition, falling back to the last copy we saw so /// Fetch the instance definition, falling back to the last copy we saw so
@@ -119,7 +121,19 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
deep_verify: deep, deep_verify: deep,
}; };
let installed = install::install(&state.http, &state.layout, &spec, &report).await?; let installed = install::install(&state.http, &state.layout, &spec, &report).await?;
sync::sync(&state.http, &panel, &game_dir, inst.revision, &manifest.files, spec.concurrency, deep, &report).await?; let already_running = state.game.lock().unwrap().iter().any(|g| g.instance_id == instance_id);
// The on-disk file may be newer when the launcher was closed before Minecraft.
let current_options = options::read_vanilla_preferences(&game_dir).unwrap_or_default();
let mut saved_options = if current_options.is_empty() {
settings.instance_game_options.get(&instance_id).cloned().unwrap_or_default()
} else {
current_options
};
// Do not rewrite pack files or options while another copy is using this directory.
if !already_running {
sync::sync(&state.http, &panel, &game_dir, inst.revision, &manifest.files, spec.concurrency, deep, &report).await?;
options::apply(&game_dir, &mut saved_options)?;
}
if let Some(server) = &inst.server { if let Some(server) = &inst.server {
if server.inject && !server.address.is_empty() { if server.inject && !server.address.is_empty() {
@@ -127,7 +141,13 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
servers_dat::upsert(&game_dir, &name, &servers_dat::format_address(&server.address, server.port)).ok(); servers_dat::upsert(&game_dir, &name, &servers_dat::format_address(&server.address, server.port)).ok();
} }
} }
if settings.keybinds_enabled && installed.version.is_modern() && !settings.keybinds.is_empty() { if !already_running && settings.vanilla_controls_enabled {
let mut controls = std::collections::BTreeMap::new();
controls.insert("autoJump".to_string(), settings.auto_jump.to_string());
controls.insert("mouseSensitivity".to_string(), settings.sensitivity.clamp(0.0, 1.0).to_string());
options::apply(&game_dir, &mut controls)?;
}
if !already_running && settings.keybinds_enabled && installed.version.is_modern() && !settings.keybinds.is_empty() {
options::apply_keybinds(&game_dir, &settings.keybinds).ok(); options::apply_keybinds(&game_dir, &settings.keybinds).ok();
} }
@@ -203,8 +223,9 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
} }
let (kill_tx, kill_rx) = tokio::sync::oneshot::channel(); let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
*state.game.lock().unwrap() = Some(RunningGame { instance_id: instance_id.clone(), kill: kill_tx }); let run_id = uuid::Uuid::new_v4().to_string();
emit_state(&app, &instance_id, "running", None); state.game.lock().unwrap().push(RunningGame { run_id: run_id.clone(), instance_id: instance_id.clone(), kill: Some(kill_tx) });
app.emit("launch://state", StatePayload { instance_id: instance_id.clone(), run_id: Some(run_id.clone()), state: "running".into(), message: None }).ok();
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
match settings.after_launch.as_str() { match settings.after_launch.as_str() {
@@ -241,6 +262,7 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
let app2 = app.clone(); let app2 = app.clone();
let id2 = instance_id.clone(); let id2 = instance_id.clone();
let run_id2 = run_id.clone();
tokio::spawn(async move { tokio::spawn(async move {
let mut tail: VecDeque<String> = VecDeque::with_capacity(200); let mut tail: VecDeque<String> = VecDeque::with_capacity(200);
let mut batch: Vec<String> = Vec::new(); let mut batch: Vec<String> = Vec::new();
@@ -297,15 +319,22 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
}, },
&id2, &id2,
); );
*st.game.lock().unwrap() = None; let preferences = options::read_vanilla_preferences(&st.layout.instance_dir(&id2)).unwrap_or_default();
if let Some(w) = app2.get_webview_window("main") { if !preferences.is_empty() {
w.show().ok(); st.settings.write().unwrap().instance_game_options.insert(id2.clone(), preferences);
w.unminimize().ok(); st.save_settings().ok();
if crashed { }
w.set_focus().ok(); st.game.lock().unwrap().retain(|g| g.run_id != run_id2);
if st.game.lock().unwrap().is_empty() {
if let Some(w) = app2.get_webview_window("main") {
w.show().ok();
w.unminimize().ok();
if crashed {
w.set_focus().ok();
}
} }
} }
app2.emit("game://exit", ExitPayload { instance_id: id2.clone(), code, crashed, tail: tail.into_iter().collect(), crash_report }) app2.emit("game://exit", ExitPayload { instance_id: id2.clone(), run_id: run_id2, code, crashed, tail: tail.into_iter().collect(), crash_report })
.ok(); .ok();
emit_state(&app2, &id2, "idle", None); emit_state(&app2, &id2, "idle", None);
}); });
+1
View File
@@ -51,6 +51,7 @@ pub fn run() {
commands::refresh_manifest, commands::refresh_manifest,
commands::set_panel_url, commands::set_panel_url,
commands::save_settings, commands::save_settings,
commands::save_instance_options,
commands::login_panel, commands::login_panel,
commands::register_panel, commands::register_panel,
commands::add_offline, commands::add_offline,
+11
View File
@@ -57,6 +57,12 @@ pub struct Settings {
// ---- Controls ---- // ---- Controls ----
pub keybinds_enabled: bool, pub keybinds_enabled: bool,
pub keybinds: BTreeMap<String, String>, pub keybinds: BTreeMap<String, String>,
pub vanilla_controls_enabled: bool,
pub auto_jump: bool,
pub sensitivity: f64,
pub show_news: bool,
/// Vanilla options captured from each instance after the game exits.
pub instance_game_options: HashMap<String, BTreeMap<String, String>>,
pub instance_overrides: HashMap<String, InstanceOverride>, pub instance_overrides: HashMap<String, InstanceOverride>,
} }
@@ -88,6 +94,11 @@ impl Default for Settings {
send_stats: true, send_stats: true,
keybinds_enabled: false, keybinds_enabled: false,
keybinds: BTreeMap::new(), keybinds: BTreeMap::new(),
vanilla_controls_enabled: false,
auto_jump: true,
sensitivity: 0.5,
show_news: true,
instance_game_options: HashMap::new(),
instance_overrides: HashMap::new(), instance_overrides: HashMap::new(),
} }
} }
+4 -3
View File
@@ -21,8 +21,9 @@ pub mod build {
} }
pub struct RunningGame { pub struct RunningGame {
pub run_id: String,
pub instance_id: String, pub instance_id: String,
pub kill: tokio::sync::oneshot::Sender<()>, pub kill: Option<tokio::sync::oneshot::Sender<()>>,
} }
pub struct AppState { pub struct AppState {
@@ -35,7 +36,7 @@ pub struct AppState {
pub manifest: RwLock<Option<LauncherManifest>>, pub manifest: RwLock<Option<LauncherManifest>>,
/// Install/launch pipeline in progress (abortable). /// Install/launch pipeline in progress (abortable).
pub task: Mutex<Option<tokio::task::AbortHandle>>, pub task: Mutex<Option<tokio::task::AbortHandle>>,
pub game: Mutex<Option<RunningGame>>, pub game: Mutex<Vec<RunningGame>>,
} }
impl AppState { impl AppState {
@@ -52,7 +53,7 @@ impl AppState {
accounts: RwLock::new(accounts), accounts: RwLock::new(accounts),
manifest: RwLock::new(manifest), manifest: RwLock::new(manifest),
task: Mutex::new(None), task: Mutex::new(None),
game: Mutex::new(None), game: Mutex::new(Vec::new()),
data_dir, data_dir,
} }
} }
+1 -1
View File
@@ -5,7 +5,7 @@
const b = $derived(app.manifest?.branding); const b = $derived(app.manifest?.branding);
const inst = $derived(selectedInstance()); const inst = $derived(selectedInstance());
const image = $derived(abs(inst?.banner_url) ?? (b?.background.kind === 'image' ? abs(b.background.url) : null)); const image = $derived(abs(inst?.banner_url) ?? (b?.background.kind === 'image' ? abs(b.background.url) : null));
const video = $derived(!inst?.banner_url && b?.background.kind === 'video' && app.settings?.background_video !== false && !app.running ? abs(b.background.url) : null); const video = $derived(!inst?.banner_url && b?.background.kind === 'video' && app.settings?.background_video !== false && app.running.length === 0 ? abs(b.background.url) : null);
const blur = $derived(b?.background.blur ?? 0); const blur = $derived(b?.background.blur ?? 0);
const dim = $derived((b?.background.dim ?? 55) / 100); const dim = $derived((b?.background.dim ?? 55) / 100);
let hidden = $state(false); let hidden = $state(false);
@@ -68,6 +68,19 @@
</label> </label>
{/if} {/if}
<div class="card col">
<strong>Vanilla game settings</strong>
<span class="help">Brightness, graphics, FOV, view distance, sound and other vanilla options are saved automatically when the game closes, then restored after instance updates.</span>
<span class="tiny muted">{Object.keys(app.settings.instance_game_options?.[inst.id] ?? {}).length} saved options for this instance</span>
<button class="sm" disabled={app.running.some((g) => g.instance_id === inst.id)} onclick={async () => {
try {
const options = await invoke<Record<string, string>>('save_instance_options', { instanceId: inst.id });
app.settings!.instance_game_options[inst.id] = options;
toast('Vanilla game settings saved');
} catch (e) { toast(errorText(e), 'error'); }
}}>Save options.txt now</button>
</div>
<div class="danger-zone"> <div class="danger-zone">
<span class="tiny muted">{local?.installed ? `Uses ${bytes(local.size)} on disk` : 'Not downloaded yet'}</span> <span class="tiny muted">{local?.installed ? `Uses ${bytes(local.size)} on disk` : 'Not downloaded yet'}</span>
<span class="spacer"></span> <span class="spacer"></span>
+14 -14
View File
@@ -9,8 +9,8 @@
const l = $derived(app.launch); const l = $derived(app.launch);
const mine = $derived(app.launchingId === inst.id); const mine = $derived(app.launchingId === inst.id);
const busy = $derived(mine && l.state === 'preparing'); const busy = $derived(mine && l.state === 'preparing');
const running = $derived(app.running === inst.id); const running = $derived(app.running.filter((g) => g.instance_id === inst.id));
const otherRunning = $derived(!!app.running && app.running !== inst.id); const preparing = $derived(l.state === 'preparing');
const pct = $derived(l.total > 0 ? Math.min(100, (l.done / l.total) * 100) : 0); const pct = $derived(l.total > 0 ? Math.min(100, (l.done / l.total) * 100) : 0);
const eta = $derived(l.speed > 0 && l.total > l.done ? Math.ceil((l.total - l.done) / l.speed) : 0); const eta = $derived(l.speed > 0 && l.total > l.done ? Math.ceil((l.total - l.done) / l.speed) : 0);
const stageNames: Record<string, string> = { preparing: 'Preparing', java: 'Java', game: 'Game files', loader: 'Mod loader', files: 'Instance files', launching: 'Launching' }; const stageNames: Record<string, string> = { preparing: 'Preparing', java: 'Java', game: 'Game files', loader: 'Mod loader', files: 'Instance files', launching: 'Launching' };
@@ -18,7 +18,7 @@
</script> </script>
<svelte:window onkeydown={(e) => { <svelte:window onkeydown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !busy && !running && !otherRunning && !noAccount && app.view === 'home') play(inst.id); if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !preparing && !noAccount && app.view === 'home') play(inst.id);
}} /> }} />
<div class="wrap"> <div class="wrap">
@@ -37,18 +37,19 @@
<span>{#if l.total}{bytes(l.done)} / {bytes(l.total)}{#if l.speed > 0} · {bytes(l.speed)}/s{#if eta} · {eta < 60 ? `${eta}s` : `${Math.ceil(eta / 60)}m`} left{/if}{/if}{/if}</span> <span>{#if l.total}{bytes(l.done)} / {bytes(l.total)}{#if l.speed > 0} · {bytes(l.speed)}/s{#if eta} · {eta < 60 ? `${eta}s` : `${Math.ceil(eta / 60)}m`} left{/if}{/if}{/if}</span>
</div> </div>
</div> </div>
{:else if running}
<button class="play running" onclick={kill} title="Force-close Minecraft">
<span class="pulse"></span>
<span class="lbl">Playing</span>
<span class="stop"><Square size={13} fill="currentColor" /> Stop</span>
</button>
{:else} {:else}
<button class="play" onclick={() => play(inst.id)} disabled={otherRunning || noAccount} title={noAccount ? 'Sign in first' : otherRunning ? 'Another instance is running' : 'Ctrl + Enter'}> <button class="play" onclick={() => play(inst.id)} disabled={preparing || noAccount} title={noAccount ? 'Sign in first' : 'Ctrl + Enter'}>
<Play size={18} fill="currentColor" /> <Play size={18} fill="currentColor" />
<span class="lbl">Play</span> <span class="lbl">{running.length ? 'Launch another window' : 'Play'}</span>
</button> </button>
{/if} {/if}
{#if running.length}
<div class="sessions">
{#each running as game, index (game.run_id)}
<button class="sm session" onclick={() => kill(game.run_id)} title="Stop this Minecraft window"><span class="pulse"></span> Window {index + 1} running <Square size={12} /> Stop</button>
{/each}
</div>
{/if}
{#if mine && l.state === 'error'} {#if mine && l.state === 'error'}
<div class="error glass selectable"> <div class="error glass selectable">
<TriangleAlert size={17} /> <TriangleAlert size={17} />
@@ -60,6 +61,8 @@
<style> <style>
.wrap { display: flex; flex-direction: column; align-items: flex-start; gap: 0.6rem; } .wrap { display: flex; flex-direction: column; align-items: flex-start; gap: 0.6rem; }
.sessions { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.session { gap: 0.4rem; }
.play { .play {
height: 3.4rem; min-width: 14.5rem; padding: 0 2rem; border: none; border-radius: var(--radius); height: 3.4rem; min-width: 14.5rem; padding: 0 2rem; border: none; border-radius: var(--radius);
background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 80%, black)); background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 80%, black));
@@ -71,11 +74,8 @@
.play:active:not(:disabled) { transform: translateY(0); box-shadow: 0 2px 10px color-mix(in srgb, var(--accent) 30%, transparent); } .play:active:not(:disabled) { transform: translateY(0); box-shadow: 0 2px 10px color-mix(in srgb, var(--accent) 30%, transparent); }
.play:disabled { background: color-mix(in srgb, var(--text) 10%, transparent); color: var(--muted); box-shadow: none; transform: none; } .play:disabled { background: color-mix(in srgb, var(--text) 10%, transparent); color: var(--muted); box-shadow: none; transform: none; }
.lbl { position: relative; } .lbl { position: relative; }
.running { background: color-mix(in srgb, var(--success) 16%, var(--surface)); color: var(--text); border: 1px solid color-mix(in srgb, var(--success) 35%, transparent); padding: 0 0.6rem 0 1.4rem; box-shadow: 0 4px 16px color-mix(in srgb, var(--success) 15%, transparent); }
.running:hover:not(:disabled) { background: color-mix(in srgb, var(--success) 22%, var(--surface)); }
.pulse { width: 0.55rem; height: 0.55rem; border-radius: 50%; background: var(--success); box-shadow: 0 0 8px var(--success); animation: pulse-dot 1.5s ease-in-out infinite; } .pulse { width: 0.55rem; height: 0.55rem; border-radius: 50%; background: var(--success); box-shadow: 0 0 8px var(--success); animation: pulse-dot 1.5s ease-in-out infinite; }
@keyframes pulse-dot { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.6; transform: scale(0.85); } } @keyframes pulse-dot { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.6; transform: scale(0.85); } }
.stop { font-size: 0.8rem; font-weight: 520; letter-spacing: 0; display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.4rem 0.7rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--text) 8%, transparent); color: color-mix(in srgb, var(--text) 80%, transparent); margin-left: 0.6rem; }
.progress { width: 26rem; max-width: 100%; padding: 0.85rem 1rem 0.8rem; display: flex; flex-direction: column; gap: 0.55rem; background: var(--surface); border-radius: var(--radius); } .progress { width: 26rem; max-width: 100%; padding: 0.85rem 1rem 0.8rem; display: flex; flex-direction: column; gap: 0.55rem; background: var(--surface); border-radius: var(--radius); }
.top { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; } .top { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.stage { display: flex; align-items: center; gap: 0.5rem; font-weight: 560; font-size: 0.9rem; } .stage { display: flex; align-items: center; gap: 0.5rem; font-weight: 560; font-size: 0.9rem; }
+1 -1
View File
@@ -16,7 +16,7 @@
<button <button
class="inst" class="inst"
class:active={app.view === 'home' && app.selected === inst.id} class:active={app.view === 'home' && app.selected === inst.id}
class:running={app.running === inst.id} class:running={app.running.some((g) => g.instance_id === inst.id)}
onclick={() => { selectInstance(inst.id); app.view = 'home'; }} onclick={() => { selectInstance(inst.id); app.view = 'home'; }}
aria-label={inst.name} aria-label={inst.name}
> >
+11 -3
View File
@@ -30,6 +30,11 @@ const settings: Settings = {
send_stats: true, send_stats: true,
keybinds_enabled: false, keybinds_enabled: false,
keybinds: {}, keybinds: {},
vanilla_controls_enabled: false,
auto_jump: true,
sensitivity: 0.5,
show_news: true,
instance_game_options: {},
instance_overrides: {}, instance_overrides: {},
}; };
@@ -59,6 +64,7 @@ const manifest: Manifest = {
{ label: 'Website', url: 'https://example.com', icon: 'website' }, { label: 'Website', url: 'https://example.com', icon: 'website' },
{ label: 'Store', url: 'https://example.com/store', icon: 'store' }, { label: 'Store', url: 'https://example.com/store', icon: 'store' },
], ],
about: { title: 'SCOPENET Launcher', icon_url: null, body: 'Your worlds, one click away.', links: [] },
features: { news: true, server_status: true, allow_user_theme: true, allow_advanced_java: true }, features: { news: true, server_status: true, allow_user_theme: true, allow_advanced_java: true },
custom_css: '', custom_css: '',
}, },
@@ -107,7 +113,7 @@ async function fakeLaunch(id: string) {
mockEmit('launch://progress', { type: 'stage', stage: 'launching', label: 'Starting Minecraft' }); mockEmit('launch://progress', { type: 'stage', stage: 'launching', label: 'Starting Minecraft' });
await sleep(500); await sleep(500);
running = true; running = true;
mockEmit('launch://state', { instance_id: id, state: 'running', message: null }); mockEmit('launch://state', { instance_id: id, run_id: 'mock-run', state: 'running', message: null });
const lines = ['[main/INFO]: Loading Minecraft 1.21.1 with Fabric Loader 0.16.9', '[Render thread/INFO]: Setting user: Notch', '[Render thread/INFO]: Backend library: LWJGL version 3.3.3', '[Render thread/INFO]: Reloading ResourceManager: vanilla, fabric', '[Render thread/INFO]: Sound engine started', '[Render thread/INFO]: Connecting to play.scopenet.gg, 25565']; const lines = ['[main/INFO]: Loading Minecraft 1.21.1 with Fabric Loader 0.16.9', '[Render thread/INFO]: Setting user: Notch', '[Render thread/INFO]: Backend library: LWJGL version 3.3.3', '[Render thread/INFO]: Reloading ResourceManager: vanilla, fabric', '[Render thread/INFO]: Sound engine started', '[Render thread/INFO]: Connecting to play.scopenet.gg, 25565'];
for (const l of lines) { for (const l of lines) {
await sleep(300); await sleep(300);
@@ -126,7 +132,7 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
accounts, accounts,
active_account: active, active_account: active,
manifest: scenario === 'setup' ? null : manifest, manifest: scenario === 'setup' ? null : manifest,
game_running: running ? 'survival-smp' : null, game_running: running ? [{ run_id: 'mock-run', instance_id: 'survival-smp' }] : [],
}; };
return structuredClone(b); return structuredClone(b);
} }
@@ -167,9 +173,11 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
return null; return null;
case 'kill_game': case 'kill_game':
running = false; running = false;
mockEmit('game://exit', { instance_id: 'survival-smp', code: 0, crashed: false, tail: [], crash_report: null }); mockEmit('game://exit', { instance_id: 'survival-smp', run_id: args?.runId ?? 'mock-run', code: 0, crashed: false, tail: [], crash_report: null });
mockEmit('launch://state', { instance_id: 'survival-smp', state: 'idle', message: null }); mockEmit('launch://state', { instance_id: 'survival-smp', state: 'idle', message: null });
return null; return null;
case 'save_instance_options':
return { fov: '0.8', gamma: '1.0', graphicsMode: '1', autoJump: 'true' };
case 'ping_server': case 'ping_server':
await sleep(200); await sleep(200);
return { online: true, version: 'Paper 1.21.1', players_online: 42, players_max: 100, sample: ['Notch', 'jeb_', 'Dinnerbone'], motd: '§b§lSCOPENET §r§7» §fSeason 4 is live!', favicon: null, latency_ms: 23 }; return { online: true, version: 'Paper 1.21.1', players_online: 42, players_max: 100, sample: ['Notch', 'jeb_', 'Dinnerbone'], motd: '§b§lSCOPENET §r§7» §fSeason 4 is live!', favicon: null, latency_ms: 23 };
+7 -8
View File
@@ -14,7 +14,7 @@ export interface LaunchState {
message: string | null; message: string | null;
} }
export interface ExitInfo { instance_id: string; code: number | null; crashed: boolean; tail: string[]; crash_report: string | null } export interface ExitInfo { instance_id: string; run_id: string; code: number | null; crashed: boolean; tail: string[]; crash_report: string | null }
const idle = (): LaunchState => ({ state: 'idle', stage: null, label: '', done: 0, total: 0, filesDone: 0, filesTotal: 0, speed: 0, message: null }); const idle = (): LaunchState => ({ state: 'idle', stage: null, label: '', done: 0, total: 0, filesDone: 0, filesTotal: 0, speed: 0, message: null });
@@ -33,7 +33,7 @@ export const app = $state({
selected: null as string | null, selected: null as string | null,
launch: idle(), launch: idle(),
launchingId: null as string | null, launchingId: null as string | null,
running: null as string | null, running: [] as { run_id: string; instance_id: string }[],
logs: [] as string[], logs: [] as string[],
consoleOpen: false, consoleOpen: false,
crash: null as ExitInfo | null, crash: null as ExitInfo | null,
@@ -182,8 +182,8 @@ export async function cancel() {
if (app.launchingId) await invoke('cancel_launch', { instanceId: app.launchingId }); if (app.launchingId) await invoke('cancel_launch', { instanceId: app.launchingId });
} }
export async function kill() { export async function kill(runId: string) {
await invoke('kill_game'); await invoke('kill_game', { runId });
} }
let subscribed = false; let subscribed = false;
@@ -218,15 +218,14 @@ function subscribe() {
} }
}); });
listen<{ instance_id: string; state: LaunchState['state']; message: string | null }>('launch://state', (ev) => { listen<{ instance_id: string; run_id: string | null; state: LaunchState['state']; message: string | null }>('launch://state', (ev) => {
if (ev.state === 'running') { if (ev.state === 'running') {
app.running = ev.instance_id; if (ev.run_id && !app.running.some((g) => g.run_id === ev.run_id)) app.running.push({ run_id: ev.run_id, instance_id: ev.instance_id });
app.launch = { ...idle(), state: 'running' }; app.launch = { ...idle(), state: 'running' };
if (app.settings?.show_console) app.consoleOpen = true; if (app.settings?.show_console) app.consoleOpen = true;
} else if (ev.state === 'error') { } else if (ev.state === 'error') {
app.launch = { ...idle(), state: 'error', message: ev.message }; app.launch = { ...idle(), state: 'error', message: ev.message };
} else if (ev.state === 'idle') { } else if (ev.state === 'idle') {
if (app.running === ev.instance_id) app.running = null;
if (app.launch.state !== 'error') app.launch = idle(); if (app.launch.state !== 'error') app.launch = idle();
if (ev.message && ev.message !== 'Cancelled') toast(ev.message); if (ev.message && ev.message !== 'Cancelled') toast(ev.message);
} }
@@ -235,7 +234,7 @@ function subscribe() {
listen<string[]>('game://log', (lines) => pushLogs(lines)); listen<string[]>('game://log', (lines) => pushLogs(lines));
listen<ExitInfo>('game://exit', (ev) => { listen<ExitInfo>('game://exit', (ev) => {
app.running = null; app.running = app.running.filter((g) => g.run_id !== ev.run_id);
if (ev.crashed) app.crash = ev; if (ev.crashed) app.crash = ev;
}); });
} }
+7 -2
View File
@@ -37,6 +37,7 @@ export interface Branding {
version_label: string; version_label: string;
news: NewsItem[]; news: NewsItem[];
links: { label: string; url: string; icon: string }[]; links: { label: string; url: string; icon: string }[];
about: { title: string; icon_url: string | null; body: string; links: { label: string; url: string; icon: string }[] };
features: { news: boolean; server_status: boolean; allow_user_theme: boolean; allow_advanced_java: boolean }; features: { news: boolean; server_status: boolean; allow_user_theme: boolean; allow_advanced_java: boolean };
custom_css: string; custom_css: string;
} }
@@ -88,6 +89,11 @@ export interface Settings {
send_stats: boolean; send_stats: boolean;
keybinds_enabled: boolean; keybinds_enabled: boolean;
keybinds: Record<string, string>; keybinds: Record<string, string>;
vanilla_controls_enabled: boolean;
auto_jump: boolean;
sensitivity: number;
show_news: boolean;
instance_game_options: Record<string, Record<string, string>>;
instance_overrides: Record<string, InstanceOverride>; instance_overrides: Record<string, InstanceOverride>;
} }
@@ -98,7 +104,7 @@ export interface Bootstrap {
accounts: Account[]; accounts: Account[];
active_account: string | null; active_account: string | null;
manifest: Manifest | null; manifest: Manifest | null;
game_running: string | null; game_running: { run_id: string; instance_id: string }[];
} }
export type Stage = 'preparing' | 'java' | 'game' | 'loader' | 'files' | 'launching'; export type Stage = 'preparing' | 'java' | 'game' | 'loader' | 'files' | 'launching';
@@ -360,4 +366,3 @@ export interface BaltopEntry {
username: string; username: string;
balance: number; balance: number;
} }
+1 -1
View File
@@ -54,7 +54,7 @@
{/if} {/if}
<aside class="side"> <aside class="side">
{#if b.features.news && b.news.length}<NewsFeed news={b.news} />{:else}<div></div>{/if} {#if b.features.news && app.settings?.show_news !== false && b.news.length}<NewsFeed news={b.news} />{:else}<div></div>{/if}
{#if b.links.length} {#if b.links.length}
<div class="links"> <div class="links">
{#each b.links as l} {#each b.links as l}
+26 -5
View File
@@ -8,7 +8,7 @@
import Modal from '../components/Modal.svelte'; import Modal from '../components/Modal.svelte';
import SkinView from '../components/SkinView.svelte'; import SkinView from '../components/SkinView.svelte';
import Toggle from '../components/Toggle.svelte'; import Toggle from '../components/Toggle.svelte';
import { activeAccount, app, connect, instances, refresh, removeAccount, saveSettings, switchAccount, toast } from '../lib/store.svelte'; import { abs, activeAccount, app, connect, instances, refresh, removeAccount, saveSettings, switchAccount, toast } from '../lib/store.svelte';
import { BIND_GROUPS, defaults, fromKeyboard, fromMouse, keyLabel } from '../lib/keys'; import { BIND_GROUPS, defaults, fromKeyboard, fromMouse, keyLabel } from '../lib/keys';
import { bytes, gb } from '../lib/format'; import { bytes, gb } from '../lib/format';
import { errorText, invoke, openUrl, pickFile } from '../lib/tauri'; import { errorText, invoke, openUrl, pickFile } from '../lib/tauri';
@@ -330,7 +330,7 @@
<p class="muted small">Your UUID, inventory and server progress stay with your account. Close Minecraft first. Previous names remain reserved to you.</p> <p class="muted small">Your UUID, inventory and server progress stay with your account. Close Minecraft first. Previous names remain reserved to you.</p>
<label>New username<input bind:value={newUsername} required minlength="3" maxlength="16" pattern="[A-Za-z0-9_]+" autocomplete="username" /></label> <label>New username<input bind:value={newUsername} required minlength="3" maxlength="16" pattern="[A-Za-z0-9_]+" autocomplete="username" /></label>
<label>Current password<input bind:value={renamePassword} required type="password" autocomplete="current-password" /></label> <label>Current password<input bind:value={renamePassword} required type="password" autocomplete="current-password" /></label>
<button disabled={renaming || !!app.running} type="submit">{renaming ? 'Updating…' : 'Change username'}</button> <button disabled={renaming || app.running.length > 0} type="submit">{renaming ? 'Updating…' : 'Change username'}</button>
</form> </form>
<p class="lead">Stored on {b?.name ?? 'the server'} — visible to everyone on servers that use its sign-in.</p> <p class="lead">Stored on {b?.name ?? 'the server'} — visible to everyone on servers that use its sign-in.</p>
{#if profileError} {#if profileError}
@@ -558,7 +558,16 @@
</div> </div>
{:else if app.settingsTab === 'controls'} {:else if app.settingsTab === 'controls'}
<h1>Controls</h1> <h1>Controls</h1>
<p class="lead">Set your Minecraft keybinds once — the launcher applies them to every instance before it starts.</p> <p class="lead">Customize keyboard, mouse, and movement controls across your instances.</p>
<div class="card glass col">
<Toggle bind:checked={s.vanilla_controls_enabled} onchange={saveSettings} label="Apply movement and mouse settings" help="Sets vanilla Auto-Jump and mouse sensitivity before launch." />
{#if s.vanilla_controls_enabled}
<Toggle bind:checked={s.auto_jump} onchange={saveSettings} label="Auto-Jump" />
<label class="field"><span class="row">Mouse sensitivity <span class="spacer"></span><strong>{Math.round(s.sensitivity * 200)}%</strong></span>
<input type="range" min="0" max="1" step="0.01" bind:value={s.sensitivity} oninput={saveSettings} />
</label>
{/if}
</div>
<div class="card glass col"> <div class="card glass col">
<Toggle bind:checked={s.keybinds_enabled} onchange={saveSettings} label="Apply these keybinds to the game" help="Writes to options.txt before launch (Minecraft 1.13 and newer)." /> <Toggle bind:checked={s.keybinds_enabled} onchange={saveSettings} label="Apply these keybinds to the game" help="Writes to options.txt before launch (Minecraft 1.13 and newer)." />
</div> </div>
@@ -631,6 +640,7 @@
</div> </div>
<div class="card glass col"> <div class="card glass col">
<Toggle bind:checked={s.check_updates} onchange={saveSettings} label="Check for launcher updates" /> <Toggle bind:checked={s.check_updates} onchange={saveSettings} label="Check for launcher updates" />
<Toggle bind:checked={s.show_news} onchange={saveSettings} label="Show news and announcements" help="Hide the news feed on the launcher home screen." />
<p class="help">Official server accounts report launcher activity and gameplay to the panel, linked to your permanent UUID. Chat is counted; message text, passwords and command arguments are not collected.</p> <p class="help">Official server accounts report launcher activity and gameplay to the panel, linked to your permanent UUID. Chat is counted; message text, passwords and command arguments are not collected.</p>
</div> </div>
{:else if app.settingsTab === 'storage'} {:else if app.settingsTab === 'storage'}
@@ -671,9 +681,17 @@
<h1>About</h1> <h1>About</h1>
<div class="card glass col about"> <div class="card glass col about">
<div class="row"> <div class="row">
<div class="mark"></div> {#if b?.about?.icon_url}<img class="about-icon" src={abs(b.about.icon_url)} alt="" />{:else}<div class="mark"></div>{/if}
<div><strong class="big">{b?.name ?? 'SCOPENET'} Launcher</strong><p class="muted small">Version {app.boot?.app.version} · {app.boot?.app.os} {app.boot?.app.arch}</p></div> <div><strong class="big">{b?.about?.title || `${b?.name ?? 'SCOPENET'} Launcher`}</strong><p class="muted small">Version {app.boot?.app.version} · {app.boot?.app.os} {app.boot?.app.arch}</p></div>
</div> </div>
{#if b?.about?.body}<p class="about-body selectable">{b.about.body}</p>{/if}
{#if b?.about?.links?.length}
<div class="row about-links">
{#each b.about.links as link}
{#if link.label && link.url}<button class="sm" onclick={() => openUrl(link.url)}>{link.label}</button>{/if}
{/each}
</div>
{/if}
{#if app.update} {#if app.update}
<div class="update"> <div class="update">
<Download size={20} /> <Download size={20} />
@@ -708,6 +726,9 @@
.lead { color: var(--muted); margin-top: -0.5rem; line-height: 1.5; font-size: 0.9rem; margin-bottom: 0.3rem; } .lead { color: var(--muted); margin-top: -0.5rem; line-height: 1.5; font-size: 0.9rem; margin-bottom: 0.3rem; }
.card { padding: 1rem 1.1rem; background: var(--surface); } .card { padding: 1rem 1.1rem; background: var(--surface); }
.card.col { gap: 1rem; } .card.col { gap: 1rem; }
.about-icon { width: 3rem; height: 3rem; object-fit: contain; border-radius: 0.6rem; }
.about-body { white-space: pre-line; line-height: 1.6; }
.about-links { flex-wrap: wrap; }
.list { display: flex; flex-direction: column; gap: 0.4rem; padding: 0.6rem; } .list { display: flex; flex-direction: column; gap: 0.4rem; padding: 0.6rem; }
.acc { display: flex; align-items: center; gap: 0.8rem; padding: 0.6rem 0.7rem; border-radius: var(--radius-sm); } .acc { display: flex; align-items: center; gap: 0.8rem; padding: 0.6rem 0.7rem; border-radius: var(--radius-sm); }
.acc.current { background: color-mix(in srgb, var(--text) 5%, transparent); } .acc.current { background: color-mix(in srgb, var(--text) 5%, transparent); }
+35
View File
@@ -66,6 +66,30 @@ pub struct LandingBlock {
pub options: serde_json::Value, pub options: serde_json::Value,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct LandingTheme {
pub background: String,
pub surface: String,
pub accent: String,
pub text: String,
pub muted: String,
pub max_width: u16,
pub radius: u8,
pub font: String,
pub footer_text: String,
}
impl Default for LandingTheme {
fn default() -> Self {
Self {
background: "#090a0f".into(), surface: "#151620".into(), accent: "#6d6af5".into(),
text: "#f1f2f6".into(), muted: "#8b8f9a".into(), max_width: 1140, radius: 14,
font: "Inter".into(), footer_text: "Your worlds, one click away.".into(),
}
}
}
impl Default for LandingBlock { impl Default for LandingBlock {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -98,6 +122,7 @@ pub struct LandingConfig {
pub blocks: Vec<LandingBlock>, pub blocks: Vec<LandingBlock>,
pub faqs: Vec<FaqItem>, pub faqs: Vec<FaqItem>,
pub custom_css: String, pub custom_css: String,
pub theme: LandingTheme,
} }
impl Default for LandingConfig { impl Default for LandingConfig {
@@ -145,6 +170,7 @@ impl Default for LandingConfig {
}, },
], ],
custom_css: "".into(), custom_css: "".into(),
theme: LandingTheme::default(),
} }
} }
} }
@@ -178,6 +204,15 @@ pub async fn admin_get_landing(_: AdminUser, State(state): State<AppState>) -> A
} }
pub async fn admin_put_landing(_: AdminUser, State(state): State<AppState>, Json(cfg): Json<LandingConfig>) -> AppResult<Json<LandingConfig>> { pub async fn admin_put_landing(_: AdminUser, State(state): State<AppState>, Json(cfg): Json<LandingConfig>) -> AppResult<Json<LandingConfig>> {
if cfg.blocks.len() > 100 || cfg.custom_css.len() > 100_000 {
return Err(AppError::bad_request("landing page exceeds the editor limits"));
}
let mut ids = std::collections::HashSet::new();
for block in &cfg.blocks {
if block.id.is_empty() || !ids.insert(&block.id) {
return Err(AppError::bad_request("landing section IDs must be unique"));
}
}
store::kv_set(&state, "landing_page", &cfg).await?; store::kv_set(&state, "landing_page", &cfg).await?;
Ok(Json(cfg)) Ok(Json(cfg))
} }
+15
View File
@@ -233,6 +233,7 @@ async fn landing_page_config_and_permissions() {
assert_eq!(v["brand_name"], "SCOPENET"); assert_eq!(v["brand_name"], "SCOPENET");
assert_eq!(v["enabled"], true); assert_eq!(v["enabled"], true);
assert!(v["blocks"].as_array().unwrap().len() >= 5); assert!(v["blocks"].as_array().unwrap().len() >= 5);
assert_eq!(v["theme"]["accent"], "#6d6af5");
// 2. Unauthenticated user cannot modify landing config // 2. Unauthenticated user cannot modify landing config
let (s, _) = t.call("PUT", "/api/admin/landing", None, Some(json!({"brand_name": "Hacked"}))).await; let (s, _) = t.call("PUT", "/api/admin/landing", None, Some(json!({"brand_name": "Hacked"}))).await;
@@ -243,10 +244,24 @@ async fn landing_page_config_and_permissions() {
let mut updated = v.clone(); let mut updated = v.clone();
updated["brand_name"] = json!("PixelCraft"); updated["brand_name"] = json!("PixelCraft");
updated["hero_title"] = json!("Welcome to PixelCraft"); updated["hero_title"] = json!("Welcome to PixelCraft");
updated["theme"]["accent"] = json!("#17bebb");
updated["blocks"].as_array_mut().unwrap().push(json!({
"id": "b_custom_text", "type": "text", "enabled": true, "title": "Our story",
"subtitle": "", "options": { "body": "Built by players." }
}));
let (s, v2) = t.call("PUT", "/api/admin/landing", Some(&admin), Some(updated)).await; let (s, v2) = t.call("PUT", "/api/admin/landing", Some(&admin), Some(updated)).await;
assert_eq!(s, StatusCode::OK); assert_eq!(s, StatusCode::OK);
assert_eq!(v2["brand_name"], "PixelCraft"); assert_eq!(v2["brand_name"], "PixelCraft");
assert_eq!(v2["hero_title"], "Welcome to PixelCraft"); assert_eq!(v2["hero_title"], "Welcome to PixelCraft");
assert_eq!(v2["theme"]["accent"], "#17bebb");
assert_eq!(v2["blocks"].as_array().unwrap().last().unwrap()["type"], "text");
// Duplicate IDs would break drag ordering and keyed public rendering.
let mut duplicate = v2.clone();
let first = duplicate["blocks"][0].clone();
duplicate["blocks"].as_array_mut().unwrap().push(first);
let (s, _) = t.call("PUT", "/api/admin/landing", Some(&admin), Some(duplicate)).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// 4. Public endpoint reflects the updated config // 4. Public endpoint reflects the updated config
let (s, v3) = t.call("GET", "/api/v1/landing", None, None).await; let (s, v3) = t.call("GET", "/api/v1/landing", None, None).await;
+2 -1
View File
@@ -25,6 +25,7 @@
let brand = $state<{ name: string; logo_url: string | null }>({ name: 'SCOPENET', logo_url: null }); let brand = $state<{ name: string; logo_url: string | null }>({ name: 'SCOPENET', logo_url: null });
let landingEnabled = $state<boolean | null>(null); let landingEnabled = $state<boolean | null>(null);
const preview = new URLSearchParams(location.search).has('landingPreview');
$effect(() => { get<{enabled: boolean}>('/api/v1/landing').then(c => landingEnabled = c.enabled).catch(() => landingEnabled = false); }); $effect(() => { get<{enabled: boolean}>('/api/v1/landing').then(c => landingEnabled = c.enabled).catch(() => landingEnabled = false); });
let checking = $state(!!session.token); let checking = $state(!!session.token);
@@ -58,7 +59,7 @@
{#if checking || landingEnabled === null} {#if checking || landingEnabled === null}
<div class="boot"><span class="pulse"></span></div> <div class="boot"><span class="pulse"></span></div>
{:else if route.name === 'landing' && landingEnabled} {:else if route.name === 'landing' && (landingEnabled || (preview && !!session.user))}
<PublicLanding /> <PublicLanding />
{:else if !session.user} {:else if !session.user}
{#if route.name === 'login' || !landingEnabled} {#if route.name === 'login' || !landingEnabled}
+62
View File
@@ -0,0 +1,62 @@
import type { BlockType, LandingBlock } from './types';
export type Field = {
key: string;
label: string;
type: 'text' | 'textarea' | 'url' | 'media' | 'number' | 'color' | 'toggle' | 'select' | 'list';
options?: { label: string; value: string }[];
items?: Field[];
defaultItem?: Record<string, string>;
help?: string;
};
export type BlockSchema = {
type: BlockType;
label: string;
description: string;
group: 'Network' | 'Content' | 'Media' | 'Conversion' | 'Layout';
defaults: Record<string, any>;
fields: Field[];
};
const field = (key: string, label: string, type: Field['type'] = 'text'): Field => ({ key, label, type });
const choice = (key: string, label: string, choices: string[]): Field => ({
key, label, type: 'select', options: choices.map((value) => ({ label: value.replace(/_/g, ' '), value })),
});
const list = (key: string, label: string, items: Field[], defaultItem: Record<string, string>): Field => ({ key, label, type: 'list', items, defaultItem });
export const BLOCK_SCHEMAS: Record<BlockType, BlockSchema> = {
hero: { type: 'hero', label: 'Hero', description: 'Opening headline, media and calls to action', group: 'Conversion', defaults: { badge_text: '', headline: '', subtitle: '', cta_text: '', cta_url: '', show_ip_copy: true, image_url: '' }, fields: [field('badge_text', 'Badge'), field('headline', 'Headline'), field('subtitle', 'Description', 'textarea'), field('cta_text', 'Button text'), field('cta_url', 'Button URL', 'url'), field('image_url', 'Hero image', 'media'), field('show_ip_copy', 'Show copy IP button', 'toggle')] },
download: { type: 'download', label: 'Launcher downloads', description: 'Hosted installers and external downloads', group: 'Network', defaults: { layout: 'cards', badge_note: '' }, fields: [choice('layout', 'Layout', ['cards', 'compact']), field('badge_note', 'Highlight note')] },
servers: { type: 'servers', label: 'Server status', description: 'Live game server cards and copyable addresses', group: 'Network', defaults: { show_offline: true, show_badges: true, cta_label: 'Copy address' }, fields: [field('show_offline', 'Show offline servers', 'toggle'), field('show_badges', 'Show version badges', 'toggle'), field('cta_label', 'Action label')] },
leaderboard: { type: 'leaderboard', label: 'Leaderboard', description: 'Player rankings from the panel', group: 'Network', defaults: { default_metric: 'playtime_secs', limit: 10, show_podium: true }, fields: [choice('default_metric', 'Metric', ['playtime_secs', 'global_level', 'player_kills', 'mob_kills', 'blocks_broken']), field('limit', 'Maximum players', 'number'), field('show_podium', 'Show podium', 'toggle')] },
stats: { type: 'stats', label: 'Community stats', description: 'Live counters from your community', group: 'Network', defaults: { show_players: true, show_playtime: true, show_guilds: true, show_blocks: true }, fields: [field('show_players', 'Players', 'toggle'), field('show_playtime', 'Playtime', 'toggle'), field('show_guilds', 'Guilds', 'toggle'), field('show_blocks', 'Blocks', 'toggle')] },
instances: { type: 'instances', label: 'Instances', description: 'Featured packs from the launcher manifest', group: 'Network', defaults: { limit: 6 }, fields: [field('limit', 'Maximum instances', 'number')] },
news: { type: 'news', label: 'Announcements', description: 'Updates from launcher news', group: 'Network', defaults: { limit: 3 }, fields: [field('limit', 'Maximum posts', 'number')] },
faq: { type: 'faq', label: 'FAQ', description: 'Expandable questions and answers', group: 'Content', defaults: {}, fields: [] },
socials: { type: 'socials', label: 'Community links', description: 'Discord, store and social channels', group: 'Conversion', defaults: { discord_url: '', store_url: '', video_url: '', social_url: '' }, fields: [field('discord_url', 'Discord URL', 'url'), field('store_url', 'Store URL', 'url'), field('video_url', 'Video URL', 'url'), field('social_url', 'Social URL', 'url')] },
text: { type: 'text', label: 'Rich text', description: 'A headline and readable body copy', group: 'Content', defaults: { eyebrow: '', body: '', align: 'left' }, fields: [field('eyebrow', 'Eyebrow'), field('body', 'Body', 'textarea'), choice('align', 'Alignment', ['left', 'center'])] },
image: { type: 'image', label: 'Image', description: 'Full-width image with caption and link', group: 'Media', defaults: { url: '', alt: '', caption: '', link: '', fit: 'cover' }, fields: [field('url', 'Image', 'media'), field('alt', 'Alt text'), field('caption', 'Caption'), field('link', 'Link URL', 'url'), choice('fit', 'Fit', ['cover', 'contain'])] },
split: { type: 'split', label: 'Image + text', description: 'Two-column story with image and button', group: 'Content', defaults: { image_url: '', image_alt: '', body: '', button_text: '', button_url: '', image_side: 'right' }, fields: [field('image_url', 'Image', 'media'), field('image_alt', 'Alt text'), field('body', 'Body', 'textarea'), field('button_text', 'Button text'), field('button_url', 'Button URL', 'url'), choice('image_side', 'Image side', ['left', 'right'])] },
features: { type: 'features', label: 'Feature cards', description: 'Flexible grid of benefits or highlights', group: 'Content', defaults: { columns: '3', items: [{ icon: '✦', title: 'Feature', body: 'Describe why it matters.' }] }, fields: [choice('columns', 'Columns', ['2', '3', '4']), list('items', 'Cards', [field('icon', 'Icon or emoji'), field('title', 'Title'), field('body', 'Description', 'textarea')], { icon: '✦', title: 'New feature', body: '' })] },
gallery: { type: 'gallery', label: 'Gallery', description: 'Grid of screenshots or community images', group: 'Media', defaults: { columns: '3', items: [] }, fields: [choice('columns', 'Columns', ['2', '3', '4']), list('items', 'Images', [field('url', 'Image', 'media'), field('alt', 'Alt text'), field('caption', 'Caption')], { url: '', alt: '', caption: '' })] },
cta: { type: 'cta', label: 'Call to action', description: 'Focused invitation with one strong button', group: 'Conversion', defaults: { body: '', button_text: 'Join now', button_url: '', secondary_text: '', secondary_url: '' }, fields: [field('body', 'Body', 'textarea'), field('button_text', 'Button text'), field('button_url', 'Button URL', 'url'), field('secondary_text', 'Secondary button'), field('secondary_url', 'Secondary URL', 'url')] },
divider: { type: 'divider', label: 'Divider', description: 'Space or rule between sections', group: 'Layout', defaults: { style: 'line', height: 48 }, fields: [choice('style', 'Style', ['line', 'space', 'glow']), field('height', 'Height', 'number')] },
testimonials: { type: 'testimonials', label: 'Testimonials', description: 'Player quotes and reviews', group: 'Content', defaults: { columns: '3', items: [] }, fields: [choice('columns', 'Columns', ['2', '3']), list('items', 'Quotes', [field('quote', 'Quote', 'textarea'), field('name', 'Player name'), field('role', 'Role')], { quote: '', name: '', role: '' })] },
video: { type: 'video', label: 'Video', description: 'Hosted video or YouTube embed', group: 'Media', defaults: { url: '', caption: '', poster: '' }, fields: [field('url', 'Video or YouTube URL', 'media'), field('poster', 'Poster image', 'media'), field('caption', 'Caption')] },
buttons: { type: 'buttons', label: 'Button group', description: 'Custom links and action buttons', group: 'Conversion', defaults: { align: 'center', items: [] }, fields: [choice('align', 'Alignment', ['left', 'center']), list('items', 'Buttons', [field('label', 'Label'), field('url', 'URL', 'url'), choice('style', 'Style', ['primary', 'secondary'])], { label: 'Visit', url: '', style: 'primary' })] },
};
export const BLOCK_GROUPS: BlockSchema['group'][] = ['Network', 'Content', 'Media', 'Conversion', 'Layout'];
export function createLandingBlock(type: BlockType): LandingBlock {
const schema = BLOCK_SCHEMAS[type];
return { id: 'b_' + crypto.randomUUID().replaceAll('-', '').slice(0, 12), type, enabled: true, title: schema.label, subtitle: '', options: structuredClone(schema.defaults) };
}
export function upgradeLandingBlock(block: LandingBlock): LandingBlock {
const schema = BLOCK_SCHEMAS[block.type];
if (!schema) return block;
block.options = { ...structuredClone(schema.defaults), ...(block.options ?? {}) };
return block;
}
+3 -2
View File
@@ -48,6 +48,7 @@ export interface Branding {
version_label: string; version_label: string;
news: NewsItem[]; news: NewsItem[];
links: SocialLink[]; links: SocialLink[];
about: { title: string; icon_url: string | null; body: string; links: SocialLink[] };
features: { news: boolean; server_status: boolean; allow_user_theme: boolean; allow_advanced_java: boolean }; features: { news: boolean; server_status: boolean; allow_user_theme: boolean; allow_advanced_java: boolean };
custom_css: string; custom_css: string;
} }
@@ -156,7 +157,7 @@ export interface FaqItem {
answer: string; answer: string;
} }
export type BlockType = 'hero' | 'download' | 'servers' | 'leaderboard' | 'stats' | 'instances' | 'news' | 'faq' | 'socials'; export type BlockType = 'hero' | 'download' | 'servers' | 'leaderboard' | 'stats' | 'instances' | 'news' | 'faq' | 'socials' | 'text' | 'image' | 'split' | 'features' | 'gallery' | 'cta' | 'divider' | 'testimonials' | 'video' | 'buttons';
export interface LandingBlock { export interface LandingBlock {
id: string; id: string;
@@ -184,6 +185,7 @@ export interface LandingConfig {
blocks: LandingBlock[]; blocks: LandingBlock[];
faqs: FaqItem[]; faqs: FaqItem[];
custom_css: string; custom_css: string;
theme: { background: string; surface: string; accent: string; text: string; muted: string; max_width: number; radius: number; font: string; footer_text: string };
} }
export interface LevelReward { export interface LevelReward {
@@ -362,4 +364,3 @@ export interface UserProfileView {
stats: PlayerStats | null; stats: PlayerStats | null;
created_at: string; created_at: string;
} }
+17
View File
@@ -76,6 +76,7 @@
{ id: 'style', label: 'Style' }, { id: 'style', label: 'Style' },
{ id: 'news', label: 'News' }, { id: 'news', label: 'News' },
{ id: 'links', label: 'Links' }, { id: 'links', label: 'Links' },
{ id: 'about', label: 'About tab' },
{ id: 'features', label: 'Features' }, { id: 'features', label: 'Features' },
{ id: 'css', label: 'Custom CSS' }, { id: 'css', label: 'Custom CSS' },
]; ];
@@ -200,6 +201,22 @@
{/each} {/each}
<button class="add" onclick={() => b?.links.push({ icon: 'discord', label: 'Discord', url: '' })}><Plus size={16} /> Add link</button> <button class="add" onclick={() => b?.links.push({ icon: 'discord', label: 'Discord', url: '' })}><Plus size={16} /> Add link</button>
</div> </div>
{:else if section === 'about'}
<div class="card col">
<p class="muted small">Shown in the launcher’s Settings → About tab. Leave fields blank to use the launcher defaults.</p>
<ImageInput bind:value={b.about.icon_url} label="About icon" />
<label class="field">Title<input bind:value={b.about.title} placeholder="About our network" maxlength="80" /></label>
<label class="field">Description<textarea bind:value={b.about.body} rows="6" placeholder="Tell players about your community and launcher."></textarea></label>
<h3>Links</h3>
{#each b.about.links as link, i}
<div class="row">
<input bind:value={link.label} placeholder="Label" aria-label="Link label" />
<input bind:value={link.url} placeholder="https://..." aria-label="Link URL" />
<button class="ghost icon" aria-label="Remove link" onclick={() => b?.about.links.splice(i, 1)}><Trash2 size={15} /></button>
</div>
{/each}
<button class="add" onclick={() => b?.about.links.push({ label: 'Website', url: '', icon: 'website' })}><Plus size={16} /> Add link</button>
</div>
{:else if section === 'features'} {:else if section === 'features'}
<div class="card col"> <div class="card col">
<Toggle bind:checked={b.features.news} label="News feed" help="Show your posts on the home screen." /> <Toggle bind:checked={b.features.news} label="News feed" help="Show your posts on the home screen." />
+235 -544
View File
@@ -1,598 +1,289 @@
<script lang="ts"> <script lang="ts">
import { import { ArrowDown, ArrowUp, Copy, Download, ExternalLink, Eye, EyeOff, GripVertical, LoaderCircle, Plus, RotateCcw, Save, Trash2, Upload } from '@lucide/svelte';
Save, LoaderCircle, Plus, Trash2, ArrowUp, ArrowDown, ExternalLink, import { api, get, put, formatBytes, uploadMedia } from '../lib/api';
Upload, Globe, Sparkles, HelpCircle, Server, Trophy, BarChart3, import ImageInput from '../components/ImageInput.svelte';
Boxes, Newspaper, MessageSquare, Download, Check, Eye, Code,
ChevronDown, ChevronUp, Sliders,
} from '@lucide/svelte';
import Toggle from '../components/Toggle.svelte';
import { api, get, put, formatBytes } from '../lib/api';
import { toast, toastError } from '../lib/toast.svelte'; import { toast, toastError } from '../lib/toast.svelte';
import type { LandingConfig, BlockType, HostedDownload, FaqItem } from '../lib/types'; import { BLOCK_GROUPS, BLOCK_SCHEMAS, createLandingBlock, upgradeLandingBlock } from '../lib/landingSchema';
import type { BlockType, LandingBlock, LandingConfig } from '../lib/types';
let config = $state<LandingConfig | null>(null); let config = $state<LandingConfig | null>(null);
let saved = $state(''); let saved = $state('');
let tab = $state<'sections' | 'design' | 'downloads' | 'faq' | 'advanced'>('sections');
let selectedId = $state<string | null>(null);
let galleryOpen = $state(false);
let saving = $state(false); let saving = $state(false);
let uploading = $state(false); let uploading = $state(false);
let activeTab = $state<'blocks' | 'downloads' | 'faq' | 'css'>('blocks'); let uploadingMedia = $state(false);
let openBlocks = $state<Record<string, boolean>>({}); let dragFrom = $state<number | null>(null);
let frame = $state<HTMLIFrameElement | null>(null);
function toggleBlockOptions(id: string) { const previewUrl = `${location.pathname}?landingPreview=1#/landing`;
openBlocks[id] = !openBlocks[id];
}
const blockMeta: Record<BlockType, { label: string; icon: any; desc: string }> = {
hero: { label: 'Hero Header', icon: Sparkles, desc: 'Top visual headline with CTA buttons and particle effects' },
download: { label: 'Launcher Download', icon: Download, desc: 'Hosted download cards for Windows, macOS, and Linux' },
servers: { label: 'Live Server Status', icon: Server, desc: 'Online player counts, MOTD, and 1-click IP copy' },
leaderboard: { label: 'Top Leaderboard', icon: Trophy, desc: 'Rankings of top players across the server' },
stats: { label: 'Stats Counters', icon: BarChart3, desc: 'Community stats: total playtime, players, blocks placed' },
instances: { label: 'Instances Showcase', icon: Boxes, desc: 'Featured modpacks and instances playable through the launcher' },
news: { label: 'News & Updates', icon: Newspaper, desc: 'Community updates and announcements feed' },
faq: { label: 'FAQ Accordion', icon: HelpCircle, desc: 'Frequently asked questions for new players' },
socials: { label: 'Social & Community', icon: MessageSquare, desc: 'Discord, YouTube, Store, and Twitter links' },
};
$effect(() => { $effect(() => {
get<LandingConfig>('/api/admin/landing') get<LandingConfig>('/api/admin/landing').then((cfg) => {
.then((cfg) => { cfg.blocks.forEach(upgradeLandingBlock);
config = cfg; config = cfg;
saved = JSON.stringify(cfg); saved = JSON.stringify(cfg);
}) selectedId = cfg.blocks[0]?.id ?? null;
.catch((e) => toastError(e)); }).catch(toastError);
}); });
const selected = $derived(config?.blocks.find((b) => b.id === selectedId) ?? null);
const schema = $derived(selected ? BLOCK_SCHEMAS[selected.type] : null);
const dirty = $derived(!!config && JSON.stringify(config) !== saved); const dirty = $derived(!!config && JSON.stringify(config) !== saved);
const winDownload = $derived(config?.hosted_downloads.find((d) => d.platform === 'windows'));
const macDownload = $derived(config?.hosted_downloads.find((d) => d.platform === 'mac')); $effect(() => {
const linuxDownload = $derived(config?.hosted_downloads.find((d) => d.platform === 'linux')); if (!config || !frame) return;
JSON.stringify(config);
frame.contentWindow?.postMessage({ type: 'scopenet-landing-preview', config: $state.snapshot(config) }, location.origin);
});
async function save() { async function save() {
if (!config) return; if (!config) return;
saving = true; saving = true;
try { try {
const res = await put<LandingConfig>('/api/admin/landing', $state.snapshot(config)); const updated = await put<LandingConfig>('/api/admin/landing', $state.snapshot(config));
config = res; updated.blocks.forEach(upgradeLandingBlock);
saved = JSON.stringify(res); config = updated;
toast('Landing page updated successfully'); saved = JSON.stringify(updated);
} catch (e) { toast('Landing page published');
toastError(e); } catch (e) { toastError(e); }
} finally { finally { saving = false; }
saving = false;
}
} }
function moveBlock(index: number, dir: -1 | 1) { function add(type: BlockType) {
if (!config) return; if (!config) return;
const target = index + dir; const block = createLandingBlock(type);
if (target < 0 || target >= config.blocks.length) return; config.blocks.push(block);
const item = config.blocks.splice(index, 1)[0]; selectedId = block.id;
config.blocks.splice(target, 0, item); galleryOpen = false;
} }
async function uploadInstaller(platform: string, label: string, file: File | undefined) { function duplicate(index: number) {
if (!file) return; if (!config) return;
const copy = structuredClone($state.snapshot(config.blocks[index]));
copy.id = createLandingBlock(copy.type).id;
copy.title = `${copy.title || BLOCK_SCHEMAS[copy.type].label} copy`;
config.blocks.splice(index + 1, 0, copy);
selectedId = copy.id;
}
function remove(index: number) {
if (!config) return;
config.blocks.splice(index, 1);
if (!config.blocks.some((b) => b.id === selectedId)) selectedId = config.blocks[Math.min(index, config.blocks.length - 1)]?.id ?? null;
}
function move(from: number, to: number) {
if (!config || to < 0 || to >= config.blocks.length || from === to) return;
const [item] = config.blocks.splice(from, 1);
config.blocks.splice(to, 0, item);
}
function drop(index: number) {
if (dragFrom === null) return;
move(dragFrom, index);
dragFrom = null;
}
async function upload(platform: string, file: File | undefined) {
if (!file || !config) return;
uploading = true; uploading = true;
try { try {
const form = new FormData(); const form = new FormData();
form.append('platform', platform); form.append('platform', platform);
form.append('label', label); form.append('label', platform[0].toUpperCase() + platform.slice(1));
form.append('file', file); form.append('file', file);
const res = await api<LandingConfig>('/api/admin/landing/upload-launcher', { const updated = await api<LandingConfig>('/api/admin/landing/upload-launcher', { method: 'POST', form });
method: 'POST', const prior = JSON.parse(saved) as LandingConfig;
form, prior.hosted_downloads = updated.hosted_downloads;
}); saved = JSON.stringify(prior);
config = res; config.hosted_downloads = updated.hosted_downloads;
saved = JSON.stringify(res); toast(`Uploaded ${file.name} (${formatBytes(file.size)})`);
toast(`Uploaded ${label} (${formatBytes(file.size)})`); } catch (e) { toastError(e); }
} catch (e) { finally { uploading = false; }
toastError(e);
} finally {
uploading = false;
}
} }
function addFaq() { function setOption(block: LandingBlock, key: string, value: unknown) {
if (!config) return; block.options ??= {};
config.faqs.push({ block.options[key] = value;
id: 'faq_' + Math.random().toString(36).slice(2, 9),
question: 'New Question',
answer: 'Write the answer here…',
});
} }
function removeFaq(idx: number) { async function uploadSectionMedia(file: File | undefined, apply: (url: string) => void) {
if (!config) return; if (!file) return;
config.faqs.splice(idx, 1); uploadingMedia = true;
try { apply(await uploadMedia(file)); }
catch (e) { toastError(e); }
finally { uploadingMedia = false; }
} }
</script> </script>
{#if !config} {#if !config}
<div class="center-loading"><LoaderCircle size={28} class="spin" /></div> <div class="center-loading"><LoaderCircle class="spin" size={28} /></div>
{:else} {:else}
<div class="page"> <div class="builder">
<header class="header"> <header class="builder-head">
<div class="title-area"> <div><h1>Landing page studio</h1><p>Compose the public page from reusable sections. Your changes appear in the preview before publishing.</p></div>
<div class="row align-center gap-sm"> <div class="head-actions">
<h1>Landing Page Builder</h1> <label class="enabled"><input type="checkbox" bind:checked={config.enabled} /> Public page enabled</label>
<span class="badge" class:on={config.enabled} class:off={!config.enabled}> <a class="btn secondary" href="#/landing" target="_blank"><ExternalLink size={15} /> Live page</a>
{config.enabled ? 'Live' : 'Disabled'} <button class="btn secondary" disabled={!dirty} onclick={() => { config = JSON.parse(saved); selectedId = config?.blocks[0]?.id ?? null; }}><RotateCcw size={15} /> Discard</button>
</span> <button class="btn primary" disabled={!dirty || saving} onclick={save}>{#if saving}<LoaderCircle class="spin" size={15} />{:else}<Save size={15} />{/if} Publish</button>
</div>
<p class="sub">Modular public landing page for your Minecraft network and hosted launcher downloads.</p>
</div>
<div class="actions row align-center gap-sm">
<a href="#/landing" target="_blank" class="btn secondary">
<Eye size={15} /> View Live Page <ExternalLink size={13} />
</a>
<button class="btn primary" disabled={saving || !dirty} onclick={save}>
{#if saving}<LoaderCircle size={15} class="spin" />{:else}<Save size={15} />{/if}
Save Changes
</button>
</div> </div>
</header> </header>
<div class="tabs-bar"> <div class="studio-tabs">
<button class:active={activeTab === 'blocks'} onclick={() => (activeTab = 'blocks')}> {#each [['sections', 'Sections'], ['design', 'Design'], ['downloads', 'Downloads'], ['faq', 'FAQ'], ['advanced', 'Advanced']] as [id, label]}
<Sparkles size={16} /> Modular Blocks <button class:active={tab === id} onclick={() => (tab = id as typeof tab)}>{label}</button>
</button> {/each}
<button class:active={activeTab === 'downloads'} onclick={() => (activeTab = 'downloads')}>
<Download size={16} /> Launcher Downloads
</button>
<button class:active={activeTab === 'faq'} onclick={() => (activeTab = 'faq')}>
<HelpCircle size={16} /> FAQ Builder
</button>
<button class:active={activeTab === 'css'} onclick={() => (activeTab = 'css')}>
<Code size={16} /> Custom CSS
</button>
</div> </div>
<div class="content"> {#if tab === 'sections'}
{#if activeTab === 'blocks'} <div class="studio-grid">
<div class="col gap-lg"> <aside class="section-list card">
<!-- Global Settings Card --> <div class="list-head"><strong>Sections <span class="muted">{config.blocks.length}</span></strong><button class="btn primary sm" onclick={() => (galleryOpen = !galleryOpen)}><Plus size={15} /> Add</button></div>
<div class="card"> {#if galleryOpen}
<h3>General & Hero Settings</h3> <div class="gallery">
<div class="grid-2"> {#each BLOCK_GROUPS as group}
<Toggle bind:checked={config.enabled} label="Enable Public Landing Page" help="When enabled, visitors to your domain see this landing page." /> <strong class="group-label">{group}</strong>
<label class="field"> {#each Object.values(BLOCK_SCHEMAS).filter((s) => s.group === group) as item}
<span>Brand Name</span> <button class="gallery-item" onclick={() => add(item.type)}><strong>{item.label}</strong><small>{item.description}</small></button>
<input bind:value={config.brand_name} placeholder="e.g. SCOPENET" /> {/each}
</label>
</div>
<div class="grid-2">
<label class="field">
<span>Brand Logo URL (Optional)</span>
<input bind:value={config.logo_url} placeholder="https://... or /favicon.svg" />
</label>
<label class="field">
<span>Tagline</span>
<input bind:value={config.tagline} placeholder="e.g. Your worlds, one click away." />
</label>
</div>
<div class="grid-2">
<label class="field">
<span>Server Join IP</span>
<input bind:value={config.server_ip} placeholder="e.g. play.myserver.com" />
</label>
<label class="field">
<span>Hero Headline</span>
<input bind:value={config.hero_title} placeholder="The Ultimate Minecraft Experience" />
</label>
</div>
<div class="grid-2">
<label class="field">
<span>Hero CTA Button Text</span>
<input bind:value={config.hero_cta_text} placeholder="Download Launcher" />
</label>
</div>
<label class="field">
<span>Hero Subtitle / Description</span>
<textarea bind:value={config.hero_subtitle} rows="2"></textarea>
</label>
</div>
<!-- Modular Blocks Reorder & Toggle Card -->
<div class="card">
<div class="row justify-between align-center">
<div>
<h3>Blocks Architecture</h3>
<p class="muted small">Toggle and reorder sections to build your custom landing page.</p>
</div>
</div>
<div class="blocks-list">
{#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}
{@const isOpen = !!openBlocks[block.id]}
<div class="block-item-container" class:disabled={!block.enabled}>
<div class="block-row">
<div class="block-reorder">
<button class="icon-btn" disabled={idx === 0} onclick={() => moveBlock(idx, -1)} title="Move up">
<ArrowUp size={14} />
</button>
<button class="icon-btn" disabled={idx === config.blocks.length - 1} onclick={() => moveBlock(idx, 1)} title="Move down">
<ArrowDown size={14} />
</button>
</div>
<div class="block-icon">
<Icon size={18} />
</div>
<div class="block-info grow" role="button" tabindex="0" onclick={() => toggleBlockOptions(block.id)} onkeydown={(e) => e.key === 'Enter' && toggleBlockOptions(block.id)}>
<div class="row align-center gap-xs">
<strong>{meta.label}</strong>
<span class="block-type-tag tiny muted">{block.type}</span>
{#if block.title}
<span class="badge-sub small muted">"{block.title}"</span>
{/if}
</div>
<span class="tiny muted">{meta.desc}</span>
</div>
<div class="block-inputs row gap-sm align-center">
<button class="btn secondary sm" onclick={() => toggleBlockOptions(block.id)} title="Configure Module Settings">
<Sliders size={13} /> {isOpen ? 'Hide Options' : 'Configure'}
{#if isOpen}<ChevronUp size={13} />{:else}<ChevronDown size={13} />{/if}
</button>
<Toggle bind:checked={block.enabled} label="" />
</div>
</div>
{#if isOpen}
<div class="block-config-pane">
{#if !block.options}
{((block.options = {}), '')}
{/if}
<div class="grid-2">
<label class="field">
<span>Section Title</span>
<input bind:value={block.title} placeholder="Custom Section Title" />
</label>
<label class="field">
<span>Section Subtitle</span>
<input bind:value={block.subtitle} placeholder="Sub-heading or explanatory text" />
</label>
</div>
{#if block.type === 'hero'}
<div class="module-fields grid-2">
<label class="field">
<span>Headline Override</span>
<input bind:value={block.options.headline} placeholder="Defaults to General Hero Headline" />
</label>
<label class="field">
<span>Top Badge Text</span>
<input bind:value={block.options.badge_text} placeholder="e.g. Season 2 Now Live!" />
</label>
<label class="field">
<span>Primary CTA Button Text</span>
<input bind:value={block.options.cta_text} placeholder="e.g. Download Launcher" />
</label>
<label class="field">
<span>Custom CTA URL (Overrides auto-download)</span>
<input bind:value={block.options.cta_url} placeholder="https://..." />
</label>
<label class="field">
<span>Show Server IP Copy Button</span>
<Toggle bind:checked={block.options.show_ip_copy} label="Enable 1-Click Server IP Copy" />
</label>
</div>
{:else if block.type === 'download'}
<div class="module-fields grid-2">
<label class="field">
<span>Display Layout</span>
<select bind:value={block.options.layout}>
<option value="cards">Feature Cards (Default)</option>
<option value="compact">Compact List</option>
</select>
</label>
<label class="field">
<span>Badge Highlight Note</span>
<input bind:value={block.options.badge_note} placeholder="e.g. Java 21 Bundled · Auto-Sync" />
</label>
<label class="field">
<span>Show System Requirements</span>
<Toggle bind:checked={block.options.show_specs} label="Display OS requirements info" />
</label>
</div>
{:else if block.type === 'servers'}
<div class="module-fields grid-2">
<label class="field">
<span>Show Offline Servers</span>
<Toggle bind:checked={block.options.show_offline} label="List servers even when offline" />
</label>
<label class="field">
<span>Show Version / Software Badges</span>
<Toggle bind:checked={block.options.show_badges} label="Display MC version and loader" />
</label>
<label class="field">
<span>Action Button Text</span>
<input bind:value={block.options.cta_label} placeholder="e.g. Copy Server IP" />
</label>
</div>
{:else if block.type === 'leaderboard'}
<div class="module-fields grid-2">
<label class="field">
<span>Default Metric</span>
<select bind:value={block.options.default_metric}>
<option value="playtime_secs">Playtime</option>
<option value="global_level">Global Level</option>
<option value="player_kills">Player Kills</option>
<option value="mob_kills">Mob Kills</option>
<option value="blocks_broken">Blocks Mined</option>
</select>
</label>
<label class="field">
<span>Leaderboard Size</span>
<select bind:value={block.options.limit}>
<option value={5}>Top 5</option>
<option value={10}>Top 10 (Default)</option>
<option value={20}>Top 20</option>
<option value={50}>Top 50</option>
</select>
</label>
<label class="field">
<span>Show Top 3 Podium</span>
<Toggle bind:checked={block.options.show_podium} label="Highlight Gold, Silver, and Bronze winners" />
</label>
</div>
{:else if block.type === 'stats'}
<div class="module-fields grid-2">
<label class="field">
<span>Show Players Online Counter</span>
<Toggle bind:checked={block.options.show_players} label="Display total registered / online players" />
</label>
<label class="field">
<span>Show Total Playtime Counter</span>
<Toggle bind:checked={block.options.show_playtime} label="Display cumulative playtime" />
</label>
<label class="field">
<span>Show Guilds Counter</span>
<Toggle bind:checked={block.options.show_guilds} label="Display active guilds count" />
</label>
<label class="field">
<span>Custom Stat (Label & Value)</span>
<div class="row gap-xs">
<input bind:value={block.options.custom_stat1_label} placeholder="e.g. Blocks Placed" />
<input bind:value={block.options.custom_stat1_value} placeholder="e.g. 1.2M+" style="max-width: 100px" />
</div>
</label>
</div>
{:else if block.type === 'instances'}
<div class="module-fields grid-2">
<label class="field">
<span>Grid Columns</span>
<select bind:value={block.options.columns}>
<option value={2}>2 Columns</option>
<option value={3}>3 Columns (Default)</option>
<option value={4}>4 Columns</option>
</select>
</label>
<label class="field">
<span>Show Mod Loader Badges</span>
<Toggle bind:checked={block.options.show_loaders} label="Display Fabric / Forge / NeoForge pills" />
</label>
</div>
{:else if block.type === 'news'}
<div class="module-fields grid-2">
<label class="field">
<span>Max Articles Shown</span>
<select bind:value={block.options.max_items}>
<option value={3}>3 Articles</option>
<option value={5}>5 Articles (Default)</option>
<option value={10}>10 Articles</option>
</select>
</label>
<label class="field">
<span>Show Date Badges</span>
<Toggle bind:checked={block.options.show_dates} label="Show publication date on posts" />
</label>
</div>
{:else if block.type === 'faq'}
<div class="module-fields grid-2">
<label class="field">
<span>Allow Multiple Open Questions</span>
<Toggle bind:checked={block.options.allow_multiple} label="Visitors can expand multiple FAQs at once" />
</label>
</div>
{:else if block.type === 'socials'}
<div class="module-fields grid-2">
<label class="field">
<span>Discord Invite URL</span>
<input bind:value={block.options.discord_url} placeholder="https://discord.gg/..." />
</label>
<label class="field">
<span>Server Webstore URL</span>
<input bind:value={block.options.store_url} placeholder="https://store.myserver.com" />
</label>
<label class="field">
<span>YouTube / Twitch URL</span>
<input bind:value={block.options.media_url} placeholder="https://youtube.com/@..." />
</label>
<label class="field">
<span>Twitter / X or Bluesky URL</span>
<input bind:value={block.options.social_url} placeholder="https://x.com/..." />
</label>
</div>
{/if}
</div>
{/if}
</div>
{/each} {/each}
</div> </div>
{/if}
<div class="list-items" role="list">
{#each config.blocks as block, index (block.id)}
<div class="list-item" role="listitem" class:selected={selectedId === block.id} class:muted-block={!block.enabled}
draggable="true" ondragstart={() => (dragFrom = index)} ondragend={() => (dragFrom = null)} ondragover={(e) => e.preventDefault()} ondrop={(e) => { e.preventDefault(); drop(index); }}>
<button class="select-block" onclick={() => (selectedId = block.id)}><GripVertical size={14} /><span><strong>{block.title || BLOCK_SCHEMAS[block.type]?.label || block.type}</strong><small>{BLOCK_SCHEMAS[block.type]?.label || block.type}</small></span></button>
<button class="icon-action" aria-label={block.enabled ? 'Hide section' : 'Show section'} onclick={() => (block.enabled = !block.enabled)}>{#if block.enabled}<Eye size={14} />{:else}<EyeOff size={14} />{/if}</button>
</div>
{/each}
</div> </div>
</div> {#if !config.blocks.length}<p class="empty">Start with a Hero, then add any sections you need.</p>{/if}
{:else if activeTab === 'downloads'} </aside>
<div class="col gap-lg">
<div class="card">
<h3>Hosted Launcher Installers</h3>
<p class="muted small">Upload desktop launcher packages so visitors can download them directly from your panel.</p>
<div class="downloads-grid"> <div class="inspector card">
<!-- Windows --> {#if selected && schema}
<div class="platform-card"> {@const index = config.blocks.findIndex((b) => b.id === selected.id)}
<div class="platform-header"> <div class="inspector-head"><div><span class="eyebrow">{schema.group} / {schema.label}</span><h2>{selected.title || schema.label}</h2><p>{schema.description}</p></div></div>
<strong>Windows (.exe / .msi)</strong> <div class="section-actions">
{#if winDownload}<span class="badge on">Available</span>{:else}<span class="badge off">Not uploaded</span>{/if} <button class="btn secondary sm" disabled={index === 0} onclick={() => move(index, index - 1)}><ArrowUp size={14} /> Up</button>
</div> <button class="btn secondary sm" disabled={index === config.blocks.length - 1} onclick={() => move(index, index + 1)}><ArrowDown size={14} /> Down</button>
{#if winDownload} <button class="btn secondary sm" onclick={() => duplicate(index)}><Copy size={14} /> Duplicate</button>
<div class="file-details tiny muted"> <button class="btn secondary sm danger" onclick={() => remove(index)}><Trash2 size={14} /> Delete</button>
<span>File: {winDownload.filename}</span>
<span>Size: {formatBytes(winDownload.size)}</span>
</div>
{/if}
<label class="btn secondary file-upload-btn" class:disabled={uploading}>
<Upload size={14} /> {winDownload ? 'Replace Windows file' : 'Upload Windows file'}
<input type="file" accept=".exe,.msi" onchange={(e) => uploadInstaller('windows', 'Windows Installer', e.currentTarget.files?.[0])} />
</label>
</div>
<!-- macOS -->
<div class="platform-card">
<div class="platform-header">
<strong>macOS (.dmg / .zip)</strong>
{#if macDownload}<span class="badge on">Available</span>{:else}<span class="badge off">Not uploaded</span>{/if}
</div>
{#if macDownload}
<div class="file-details tiny muted">
<span>File: {macDownload.filename}</span>
<span>Size: {formatBytes(macDownload.size)}</span>
</div>
{/if}
<label class="btn secondary file-upload-btn" class:disabled={uploading}>
<Upload size={14} /> {macDownload ? 'Replace macOS file' : 'Upload macOS file'}
<input type="file" accept=".dmg,.zip" onchange={(e) => uploadInstaller('mac', 'macOS Disk Image', e.currentTarget.files?.[0])} />
</label>
</div>
<!-- Linux -->
<div class="platform-card">
<div class="platform-header">
<strong>Linux (.AppImage / .deb)</strong>
{#if linuxDownload}<span class="badge on">Available</span>{:else}<span class="badge off">Not uploaded</span>{/if}
</div>
{#if linuxDownload}
<div class="file-details tiny muted">
<span>File: {linuxDownload.filename}</span>
<span>Size: {formatBytes(linuxDownload.size)}</span>
</div>
{/if}
<label class="btn secondary file-upload-btn" class:disabled={uploading}>
<Upload size={14} /> {linuxDownload ? 'Replace Linux file' : 'Upload Linux file'}
<input type="file" accept=".AppImage,.deb,.tar.gz" onchange={(e) => uploadInstaller('linux', 'Linux AppImage', e.currentTarget.files?.[0])} />
</label>
</div>
</div> </div>
<fieldset><legend>Content</legend>
<div class="divider"></div> <label>Section title<input bind:value={selected.title} placeholder={schema.label} /></label>
<label>Subtitle<textarea bind:value={selected.subtitle} rows="2"></textarea></label>
<label class="field"> {#each schema.fields as field (field.key)}
<span>External Launcher Download URL (Fallback)</span> {#if field.type === 'list'}
<input bind:value={config.external_download_url} placeholder="https://github.com/.../releases/latest" /> <div class="list-field"><div class="list-head"><strong>{field.label}</strong><button class="btn secondary sm" onclick={() => setOption(selected, field.key, [...(selected.options?.[field.key] ?? []), structuredClone(field.defaultItem ?? {})])}><Plus size={14} /> Add</button></div>
<span class="help">If no hosted file matches the visitor's operating system, they will be sent to this link.</span> {#each (selected.options?.[field.key] ?? []) as item, itemIndex}
</label> <div class="item-editor">
</div> <div class="list-head"><strong>Item {itemIndex + 1}</strong><button class="icon-action danger" aria-label="Remove item" onclick={() => setOption(selected, field.key, selected.options?.[field.key].filter((_: unknown, i: number) => i !== itemIndex))}><Trash2 size={14} /></button></div>
</div> {#each field.items ?? [] as sub}
{:else if activeTab === 'faq'} <label>{sub.label}
<div class="col gap-lg"> {#if sub.type === 'textarea'}<textarea rows="2" value={item[sub.key] ?? ''} oninput={(e) => (item[sub.key] = e.currentTarget.value)}></textarea>
<div class="card"> {:else if sub.type === 'select'}<select value={item[sub.key] ?? ''} onchange={(e) => (item[sub.key] = e.currentTarget.value)}>{#each sub.options ?? [] as opt}<option value={opt.value}>{opt.label}</option>{/each}</select>
<div class="row justify-between align-center"> {:else}<input type={sub.type === 'url' || sub.type === 'media' ? 'url' : 'text'} value={item[sub.key] ?? ''} oninput={(e) => (item[sub.key] = e.currentTarget.value)} />{/if}
<div> </label>
<h3>Frequently Asked Questions</h3> {#if sub.type === 'media'}<label class="upload-button">{uploadingMedia ? 'Uploading…' : 'Upload image'}<input type="file" accept="image/*" hidden disabled={uploadingMedia} onchange={(e) => uploadSectionMedia(e.currentTarget.files?.[0], (url) => (item[sub.key] = url))} /></label>{/if}
<p class="muted small">Add common questions and helpful answers for your community.</p> {/each}
</div> </div>
<button class="btn secondary sm" onclick={addFaq}> {/each}
<Plus size={15} /> Add FAQ Item
</button>
</div>
<div class="faq-list">
{#each config.faqs as faq, idx (faq.id)}
<div class="faq-card">
<div class="faq-top row justify-between align-center">
<span class="tiny muted font-bold">Item #{idx + 1}</span>
<button class="icon-btn danger" onclick={() => removeFaq(idx)} title="Remove item">
<Trash2 size={14} />
</button>
</div> </div>
<label class="field"> {:else if field.type === 'toggle'}
<span>Question</span> <label class="check"><input type="checkbox" checked={selected.options?.[field.key] ?? false} onchange={(e) => setOption(selected, field.key, e.currentTarget.checked)} /> {field.label}</label>
<input bind:value={faq.question} placeholder="e.g. How do I join the server?" /> {:else}<label>{field.label}
</label> {#if field.type === 'textarea'}<textarea rows="4" value={selected.options?.[field.key] ?? ''} oninput={(e) => setOption(selected, field.key, e.currentTarget.value)}></textarea>
<label class="field"> {:else if field.type === 'select'}<select value={selected.options?.[field.key] ?? ''} onchange={(e) => setOption(selected, field.key, e.currentTarget.value)}>{#each field.options ?? [] as opt}<option value={opt.value}>{opt.label}</option>{/each}</select>
<span>Answer</span> {:else}<input type={field.type === 'number' ? 'number' : field.type === 'color' ? 'color' : field.type === 'url' || field.type === 'media' ? 'url' : 'text'} value={selected.options?.[field.key] ?? ''} oninput={(e) => setOption(selected, field.key, field.type === 'number' ? Number(e.currentTarget.value) : e.currentTarget.value)} />{/if}
<textarea bind:value={faq.answer} rows="2" placeholder="Explain the answer clearly…"></textarea> </label>{#if field.type === 'media'}<label class="upload-button">{uploadingMedia ? 'Uploading…' : 'Upload media'}<input type="file" accept={schema.type === 'video' && field.key === 'url' ? 'video/mp4,video/webm' : 'image/*'} hidden disabled={uploadingMedia} onchange={(e) => uploadSectionMedia(e.currentTarget.files?.[0], (url) => setOption(selected, field.key, url))} /></label>{/if}{/if}
</label>
</div>
{:else}
<p class="muted small">No questions yet. Click "Add FAQ Item" above.</p>
{/each} {/each}
</div> </fieldset>
</div> <fieldset><legend>Section layout</legend>
<div class="two"><label>Width<select value={selected.options?.width ?? 'default'} onchange={(e) => setOption(selected, 'width', e.currentTarget.value)}><option value="narrow">Narrow</option><option value="default">Default</option><option value="wide">Wide</option><option value="full">Full</option></select></label>
<label>Spacing<select value={selected.options?.spacing ?? 'normal'} onchange={(e) => setOption(selected, 'spacing', e.currentTarget.value)}><option value="none">None</option><option value="compact">Compact</option><option value="normal">Normal</option><option value="roomy">Roomy</option></select></label></div>
<div class="two"><label>Background<select value={selected.options?.background ?? 'default'} onchange={(e) => setOption(selected, 'background', e.currentTarget.value)}><option value="default">Page</option><option value="surface">Surface</option><option value="accent">Accent</option><option value="gradient">Gradient</option><option value="image">Image</option></select></label>
<label>Anchor<input value={selected.options?.anchor ?? ''} placeholder="e.g. join" oninput={(e) => setOption(selected, 'anchor', e.currentTarget.value)} /></label></div>
{#if selected.options?.background === 'image'}<label>Background image URL<input type="url" value={selected.options?.background_image ?? ''} oninput={(e) => setOption(selected, 'background_image', e.currentTarget.value)} /></label><label class="upload-button">Upload background<input type="file" accept="image/*" hidden onchange={(e) => uploadSectionMedia(e.currentTarget.files?.[0], (url) => setOption(selected, 'background_image', url))} /></label>{/if}
</fieldset>
{:else}<div class="empty">Select a section or add a new one.</div>{/if}
</div> </div>
{:else if activeTab === 'css'}
<div class="col gap-lg"> <aside class="preview card"><div class="list-head"><strong>Live preview</strong><span class="muted">Unsaved changes shown</span></div><iframe title="Landing page preview" src={previewUrl} bind:this={frame} onload={() => config && frame?.contentWindow?.postMessage({ type: 'scopenet-landing-preview', config: $state.snapshot(config) }, location.origin)}></iframe></aside>
<div class="card"> </div>
<h3>Custom CSS</h3> {:else if tab === 'design'}
<p class="muted small">Add CSS rules to override fonts, colors, animations, or styling on the public landing page.</p> <div class="settings-grid card"><h2>Site identity & theme</h2><p>These settings apply to every section. Each section can override its own background and spacing.</p>
<textarea bind:value={config.custom_css} rows="12" class="mono code-box" placeholder="/* Custom styling for landing page */"></textarea> <div class="two"><label>Brand name<input bind:value={config.brand_name} /></label><ImageInput bind:value={config.logo_url} label="Brand logo" /></div>
</div> <label>Tagline<input bind:value={config.tagline} /></label>
</div> <div class="two"><label>Server address<input bind:value={config.server_ip} /></label><label>Port<input type="number" min="1" max="65535" bind:value={config.server_port} /></label></div>
{/if} <div class="two"><label>Default hero title<input bind:value={config.hero_title} /></label><label>Hero button text<input bind:value={config.hero_cta_text} /></label></div>
</div> <label>Default hero subtitle<textarea rows="3" bind:value={config.hero_subtitle}></textarea></label>
<div class="two"><label>Hero background<select bind:value={config.hero_bg_type}><option value="gradient">Theme gradient</option><option value="image">Image</option><option value="video">Video</option></select></label>
{#if config.hero_bg_type !== 'gradient'}<ImageInput bind:value={config.hero_bg_url} label="Background media" video={config.hero_bg_type === 'video'} />{/if}</div>
<div class="theme-grid"><label>Background<input type="color" bind:value={config.theme.background} /></label><label>Surface<input type="color" bind:value={config.theme.surface} /></label><label>Accent<input type="color" bind:value={config.theme.accent} /></label><label>Text<input type="color" bind:value={config.theme.text} /></label><label>Muted<input type="color" bind:value={config.theme.muted} /></label></div>
<div class="two"><label>Content width (px)<input type="number" min="720" max="1800" bind:value={config.theme.max_width} /></label><label>Corner radius (px)<input type="number" min="0" max="32" bind:value={config.theme.radius} /></label></div>
<label>Font<select bind:value={config.theme.font}><option>Inter</option><option>Outfit</option><option>Space Grotesk</option><option>Plus Jakarta Sans</option><option>system-ui</option></select></label>
<label>Footer text<input bind:value={config.theme.footer_text} /></label>
</div>
{:else if tab === 'downloads'}
<div class="settings-grid card"><h2>Launcher downloads</h2><p>Upload installers or point players to an external release page.</p>
<label>External download URL<input type="url" bind:value={config.external_download_url} placeholder="https://github.com/.../releases" /></label>
{#each ['windows', 'mac', 'linux'] as platform}
{@const download = config.hosted_downloads.find((d) => d.platform === platform)}
<div class="download-row"><div><strong>{platform[0].toUpperCase() + platform.slice(1)}</strong><p>{download ? `${download.filename} · ${formatBytes(download.size)}` : 'No installer uploaded'}</p></div><label class="btn secondary sm"><Upload size={15} /> {uploading ? 'Uploading…' : 'Upload installer'}<input type="file" hidden disabled={uploading} onchange={(e) => upload(platform, e.currentTarget.files?.[0])} /></label></div>
{/each}
</div>
{:else if tab === 'faq'}
<div class="settings-grid card"><div class="list-head"><div><h2>Frequently asked questions</h2><p>Shown by each FAQ section.</p></div><button class="btn primary sm" onclick={() => config?.faqs.push({ id: crypto.randomUUID(), question: '', answer: '' })}><Plus size={15} /> Add question</button></div>
{#each config.faqs as item, index (item.id)}
<div class="item-editor"><div class="list-head"><strong>Question {index + 1}</strong><button class="icon-action danger" aria-label="Remove question" onclick={() => config?.faqs.splice(index, 1)}><Trash2 size={15} /></button></div><label>Question<input bind:value={item.question} /></label><label>Answer<textarea rows="4" bind:value={item.answer}></textarea></label></div>
{/each}
</div>
{:else}
<div class="settings-grid card"><h2>Advanced CSS</h2><p>Custom CSS applies to the public page after the built-in styles. Section IDs are stable for targeted styling.</p><textarea class="code" rows="20" spellcheck="false" bind:value={config.custom_css} placeholder="Add custom CSS rules"></textarea></div>
{/if}
</div> </div>
{/if} {/if}
<style> <style>
.page { padding: 24px 32px 64px; max-width: 1080px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; } .builder { max-width: 1800px; margin: 0 auto; padding: 24px; display: flex; flex-direction: column; gap: 20px; }
.header { display: flex; justify-content: space-between; align-items: flex-end; flex-wrap: wrap; gap: 16px; border-bottom: 1px solid var(--line); padding-bottom: 18px; } .builder-head, .head-actions, .studio-tabs, .list-head, .section-actions, .download-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.title-area h1 { font-size: 24px; font-weight: 700; margin: 0; } .builder-head { align-items: flex-start; flex-wrap: wrap; }
.sub { color: var(--muted); font-size: 14px; margin-top: 4px; } h1 { margin: 0 0 5px; font-size: 1.7rem; } h2 { margin: 0; font-size: 1.15rem; } p { color: var(--text-2); margin: 4px 0; line-height: 1.5; }
.badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 99rem; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; } .head-actions { flex-wrap: wrap; justify-content: flex-end; } .enabled { display: flex; align-items: center; gap: 6px; white-space: nowrap; font-size: .84rem; }
.badge.on { background: color-mix(in srgb, var(--success) 15%, transparent); color: var(--success); } .studio-tabs { justify-content: flex-start; border-bottom: 1px solid var(--line); gap: 4px; overflow-x: auto; }
.badge.off { background: color-mix(in srgb, var(--danger) 15%, transparent); color: var(--danger); } .studio-tabs button { border: 0; border-radius: 7px 7px 0 0; padding: 10px 16px; background: transparent; color: var(--text-2); }
.tabs-bar { display: flex; gap: 8px; border-bottom: 1px solid var(--line); padding-bottom: 2px; } .studio-tabs button.active { color: var(--text); background: var(--bg-2); box-shadow: inset 0 -2px var(--accent); }
.tabs-bar button { background: transparent; border: none; padding: 8px 14px; font-size: 14px; font-weight: 560; color: var(--muted); display: inline-flex; align-items: center; gap: 7px; cursor: pointer; border-radius: var(--radius-sm); transition: color 0.15s, background 0.15s; } .studio-grid { display: grid; grid-template-columns: minmax(220px, 270px) minmax(350px, 470px) minmax(350px, 1fr); gap: 16px; align-items: start; }
.tabs-bar button:hover { color: var(--text); background: var(--surface-2); } .card { background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; padding: 18px; min-width: 0; }
.tabs-bar button.active { color: var(--text); background: var(--surface-2); font-weight: 650; } .section-list, .inspector, .preview { min-height: 450px; } .section-list { position: sticky; top: 12px; max-height: calc(100vh - 100px); overflow-y: auto; }
.card { padding: 20px 24px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); display: flex; flex-direction: column; gap: 16px; } .list-head { margin-bottom: 12px; } .list-head .muted { font-weight: 400; color: var(--text-2); font-size: .8rem; }
.card h3 { margin: 0; font-size: 17px; font-weight: 650; } .list-items { display: flex; flex-direction: column; gap: 5px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .list-item { display: flex; align-items: center; border: 1px solid transparent; border-radius: 9px; background: var(--bg); }
.field { display: flex; flex-direction: column; gap: 6px; font-size: 13px; font-weight: 560; } .list-item.selected { border-color: var(--accent); } .list-item.muted-block { opacity: .55; }
.field input, .field textarea { background: var(--bg); border: 1px solid var(--line); color: var(--text); padding: 8px 12px; border-radius: var(--radius-sm); font-size: 14px; } .select-block { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; padding: 9px; text-align: left; border: 0; background: transparent; color: var(--text); }
.field input:focus, .field textarea:focus { border-color: var(--accent); outline: none; } .select-block span { display: flex; flex-direction: column; min-width: 0; } .select-block strong { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: .84rem; } .select-block small { color: var(--text-2); }
.blocks-list { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; } .icon-action { padding: 6px; border: 0; background: transparent; color: var(--text-2); } .icon-action:hover { color: var(--text); } .danger { color: #e5484d; }
.block-item-container { border-radius: var(--radius-sm); border: 1px solid var(--line); background: var(--bg); overflow: hidden; transition: opacity 0.15s, border-color 0.15s; } .gallery { max-height: 350px; overflow-y: auto; border: 1px solid var(--line); border-radius: 8px; padding: 8px; margin-bottom: 12px; background: var(--bg); }
.block-item-container.disabled { opacity: 0.55; } .group-label { display: block; color: var(--accent); font-size: .68rem; letter-spacing: .09em; text-transform: uppercase; margin: 10px 5px 4px; }
.block-row { display: flex; align-items: center; gap: 12px; padding: 12px 14px; } .gallery-item { display: flex; flex-direction: column; align-items: flex-start; text-align: left; gap: 2px; width: 100%; border: 0; background: transparent; padding: 7px; border-radius: 6px; color: var(--text); }
.block-config-pane { border-top: 1px dashed var(--line); padding: 16px; background: color-mix(in srgb, var(--surface) 50%, transparent); display: flex; flex-direction: column; gap: 14px; } .gallery-item:hover { background: var(--bg-2); } .gallery-item small { color: var(--text-2); line-height: 1.35; }
.module-fields { border-top: 1px solid var(--line); padding-top: 12px; } .inspector-head { margin-bottom: 12px; } .eyebrow { font-size: .7rem; color: var(--accent); text-transform: uppercase; letter-spacing: .08em; }
.badge-sub { font-style: italic; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .section-actions { justify-content: flex-start; flex-wrap: wrap; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
select { background: var(--bg); border: 1px solid var(--line); color: var(--text); padding: 8px 12px; border-radius: var(--radius-sm); font-size: 14px; outline: none; } fieldset { border: 0; border-bottom: 1px solid var(--line); padding: 16px 0; margin: 0; display: flex; flex-direction: column; gap: 12px; } legend { font-weight: 700; padding: 0; }
select:focus { border-color: var(--accent); } label { display: flex; flex-direction: column; gap: 5px; font-size: .82rem; font-weight: 550; } label.check { flex-direction: row; align-items: center; }
.block-reorder { display: flex; flex-direction: column; gap: 2px; } input, textarea, select { width: 100%; font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: 7px; padding: 8px 10px; box-sizing: border-box; }
.block-icon { width: 34px; height: 34px; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; } input[type='checkbox'], input[type='color'] { width: auto; } input[type='color'] { min-width: 64px; height: 38px; padding: 2px; }
.block-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; cursor: pointer; } textarea { resize: vertical; } .two, .theme-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } .theme-grid { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); }
.block-type-tag { text-transform: uppercase; font-size: 10px; letter-spacing: 0.05em; } .list-field { display: flex; flex-direction: column; gap: 10px; } .item-editor { display: flex; flex-direction: column; gap: 10px; padding: 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); }
.sm-input { width: 180px; padding: 6px 10px; font-size: 12px; } .preview { position: sticky; top: 12px; } .preview iframe { border: 1px solid var(--line); border-radius: 8px; width: 100%; height: min(72vh, 850px); background: white; }
.downloads-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; margin-top: 6px; } .settings-grid { width: min(100%, 850px); display: flex; flex-direction: column; gap: 14px; } .settings-grid h2 { margin-bottom: 0; }
.platform-card { padding: 16px; border-radius: var(--radius-sm); background: var(--bg); border: 1px solid var(--line); display: flex; flex-direction: column; gap: 10px; } .download-row { border: 1px solid var(--line); border-radius: 8px; padding: 14px; } .download-row label { display: inline-flex; flex-direction: row; align-items: center; cursor: pointer; white-space: nowrap; }
.platform-header { display: flex; justify-content: space-between; align-items: center; font-size: 14px; } .code { font-family: monospace; min-height: 300px; } .empty { color: var(--text-2); padding: 24px 8px; }
.file-details { display: flex; flex-direction: column; gap: 2px; } .upload-button { display: inline-flex; width: fit-content; padding: 6px 10px; border: 1px solid var(--line); border-radius: 7px; cursor: pointer; color: var(--accent); background: var(--bg); }
.file-upload-btn { position: relative; overflow: hidden; cursor: pointer; text-align: center; } @media (max-width: 1250px) { .studio-grid { grid-template-columns: 240px minmax(350px, 1fr); } .preview { grid-column: 1 / -1; position: static; } }
.file-upload-btn input { position: absolute; inset: 0; opacity: 0; cursor: pointer; } @media (max-width: 700px) { .builder { padding: 12px; } .studio-grid { grid-template-columns: 1fr; } .section-list { position: static; max-height: none; } .two { grid-template-columns: 1fr; } }
.divider { height: 1px; background: var(--line); margin: 6px 0; }
.faq-list { display: flex; flex-direction: column; gap: 14px; margin-top: 8px; }
.faq-card { padding: 14px 16px; border-radius: var(--radius-sm); background: var(--bg); border: 1px solid var(--line); display: flex; flex-direction: column; gap: 10px; }
.code-box { font-family: var(--mono); font-size: 13px; line-height: 1.5; background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 12px; color: var(--text); resize: vertical; }
.center-loading { display: grid; place-items: center; min-height: 300px; color: var(--accent); }
.icon-btn { background: transparent; border: none; padding: 4px; border-radius: 4px; color: var(--muted); cursor: pointer; }
.icon-btn:hover:not(:disabled) { color: var(--text); background: var(--surface-2); }
.icon-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.icon-btn.danger:hover { color: var(--danger); background: color-mix(in srgb, var(--danger) 15%, transparent); }
</style> </style>
+230 -75
View File
@@ -21,6 +21,63 @@
let copied = $state(false); let copied = $state(false);
let openFaq = $state<Record<string, boolean>>({}); let openFaq = $state<Record<string, boolean>>({});
const isPreview = new URLSearchParams(location.search).has('landingPreview');
function previewMessage(event: MessageEvent) {
if (!isPreview || event.origin !== location.origin || event.data?.type !== 'scopenet-landing-preview') return;
config = event.data.config as LandingConfig;
}
function safeLink(value: string | undefined | null): string {
if (!value) return '#';
return /^(https?:\/\/|mailto:|\/|#)/i.test(value) ? value : '#';
}
function mediaUrl(value: string | undefined | null): string {
if (!value) return '';
return /^(https?:\/\/|\/)/i.test(value) ? value : '';
}
function videoEmbed(value: string): string | null {
try {
const url = new URL(value);
const id = url.hostname === 'youtu.be' ? url.pathname.slice(1) : url.hostname.endsWith('youtube.com') ? url.searchParams.get('v') : null;
return id && /^[\w-]{11}$/.test(id) ? `https://www.youtube-nocookie.com/embed/${id}` : null;
} catch { return null; }
}
function sectionId(block: { id: string; options?: Record<string, any> }): string {
const anchor = String(block.options?.anchor ?? '').replace(/[^a-zA-Z0-9_-]/g, '');
return anchor || block.id;
}
function firstSection(type: BlockType): string | null {
const block = config?.blocks.find((b) => b.enabled && b.type === type);
return block ? sectionId(block) : null;
}
function sectionBackground(block: { type?: string; options?: Record<string, any> }): string {
const kind = block.options?.background;
if (kind === 'surface') return 'var(--landing-surface)';
if (kind === 'accent') return 'color-mix(in srgb, var(--landing-accent) 24%, var(--landing-bg))';
if (kind === 'gradient') return 'linear-gradient(135deg, var(--landing-bg), var(--landing-surface))';
const image = mediaUrl(block.options?.background_image);
if (kind === 'image' && image && !/["'()\\]/.test(image)) return `linear-gradient(#0008,#0008),url("${image}") center/cover`;
if (block.type === 'hero' && (!kind || kind === 'default')) {
const heroImage = mediaUrl(config?.hero_bg_url);
if (config?.hero_bg_type === 'image' && heroImage && !/["'()\\]/.test(heroImage)) return `linear-gradient(#0008,#0008),url("${heroImage}") center/cover`;
return 'linear-gradient(135deg, var(--landing-bg), var(--landing-surface))';
}
return 'transparent';
}
function color(value: string | undefined, fallback: string): string {
return value && /^#[0-9a-fA-F]{6}$/.test(value) ? value : fallback;
}
function font(value: string | undefined): string {
return ['Inter', 'Outfit', 'Space Grotesk', 'Plus Jakarta Sans', 'system-ui'].includes(value ?? '') ? value! : 'Inter';
}
// Detect user OS // Detect user OS
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase() : ''; const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase() : '';
@@ -54,11 +111,6 @@
get<LandingConfig>('/api/v1/landing') get<LandingConfig>('/api/v1/landing')
.then((cfg) => { .then((cfg) => {
config = cfg; config = cfg;
if (cfg.custom_css) {
const style = document.createElement('style');
style.textContent = cfg.custom_css;
document.head.appendChild(style);
}
}) })
.catch(() => {}); .catch(() => {});
@@ -94,6 +146,14 @@
.catch(() => {}); .catch(() => {});
}); });
$effect(() => {
if (!config?.custom_css) return;
const style = document.createElement('style');
style.textContent = config.custom_css;
document.head.appendChild(style);
return () => style.remove();
});
$effect(() => { $effect(() => {
void lbSort; void lbSort;
get<{ leaderboard: typeof leaderboard }>(`/api/v1/leaderboard?sort=${lbSort}`) get<{ leaderboard: typeof leaderboard }>(`/api/v1/leaderboard?sort=${lbSort}`)
@@ -112,11 +172,13 @@
const restLb = $derived(leaderboard.slice(3, 10)); const restLb = $derived(leaderboard.slice(3, 10));
</script> </script>
<svelte:window onmessage={previewMessage} />
<svelte:head> <svelte:head>
<title>{config?.brand_name ?? 'SCOPENET'} · Official Server & Launcher</title> <title>{config?.brand_name ?? 'SCOPENET'} · Official Server & Launcher</title>
</svelte:head> </svelte:head>
<div class="landing-wrap"> <div class="landing-wrap" style={`--landing-bg:${color(config?.theme?.background, '#090a0f')};--landing-surface:${color(config?.theme?.surface, '#151620')};--landing-accent:${color(config?.theme?.accent, '#6d6af5')};--landing-text:${color(config?.theme?.text, '#f1f2f6')};--landing-muted:${color(config?.theme?.muted, '#8b8f9a')};--landing-width:${Math.min(1800, Math.max(720, config?.theme?.max_width ?? 1140))}px;--landing-radius:${Math.min(32, Math.max(0, config?.theme?.radius ?? 14))}px;--landing-font:${font(config?.theme?.font)}` }>
<!-- Navigation Header --> <!-- Navigation Header -->
<header class="navbar"> <header class="navbar">
<div class="nav-container"> <div class="nav-container">
@@ -130,10 +192,10 @@
</div> </div>
<nav class="nav-links"> <nav class="nav-links">
<button type="button" class="nav-link-btn" onclick={() => scrollToSection('servers')}>Servers</button> {#if firstSection('servers')}<button type="button" class="nav-link-btn" onclick={() => scrollToSection(firstSection('servers')!)}>Servers</button>{/if}
<button type="button" class="nav-link-btn" onclick={() => scrollToSection('leaderboard')}>Leaderboard</button> {#if firstSection('leaderboard')}<button type="button" class="nav-link-btn" onclick={() => scrollToSection(firstSection('leaderboard')!)}>Leaderboard</button>{/if}
<button type="button" class="nav-link-btn" onclick={() => scrollToSection('download')}>Downloads</button> {#if firstSection('download')}<button type="button" class="nav-link-btn" onclick={() => scrollToSection(firstSection('download')!)}>Downloads</button>{/if}
<button type="button" class="nav-link-btn" onclick={() => scrollToSection('faq')}>FAQ</button> {#if firstSection('faq')}<button type="button" class="nav-link-btn" onclick={() => scrollToSection(firstSection('faq')!)}>FAQ</button>{/if}
</nav> </nav>
<div class="nav-actions"> <div class="nav-actions">
@@ -164,9 +226,11 @@
<main class="blocks"> <main class="blocks">
{#if config} {#if config}
{#each config.blocks.filter((b) => b.enabled) as block (block.id)} {#each config.blocks.filter((b) => b.enabled) as block (block.id)}
<div class="section-shell" style={`--section-bg:${sectionBackground(block)};--section-width:${block.options?.width === 'full' ? '100%' : block.options?.width === 'wide' ? 'min(96vw, 1600px)' : block.options?.width === 'narrow' ? 'min(92vw, 820px)' : 'var(--landing-width)'};--section-padding:${block.options?.spacing === 'none' ? '0px' : block.options?.spacing === 'compact' ? '36px' : block.options?.spacing === 'roomy' ? '120px' : '80px'}` }>
{#if block.type === 'hero'} {#if block.type === 'hero'}
<!-- Hero Section --> <!-- Hero Section -->
<section class="hero-block" id="hero"> <section class="hero-block" id={sectionId(block)}>
{#if config.hero_bg_type === 'video' && mediaUrl(config.hero_bg_url) && (!block.options?.background || block.options.background === 'default')}<video class="hero-video" src={mediaUrl(config.hero_bg_url)} autoplay muted loop playsinline aria-hidden="true"></video>{/if}
<div class="hero-bg-glow"></div> <div class="hero-bg-glow"></div>
<div class="container hero-content"> <div class="container hero-content">
<div class="hero-badge"> <div class="hero-badge">
@@ -179,7 +243,7 @@
<div class="hero-cta-group"> <div class="hero-cta-group">
{#if block.options?.cta_url} {#if block.options?.cta_url}
<a href={block.options.cta_url} target="_blank" class="btn-hero-primary"> <a href={safeLink(block.options.cta_url)} target="_blank" rel="noopener noreferrer" class="btn-hero-primary">
<Download size={20} /> <Download size={20} />
<div class="cta-text-group"> <div class="cta-text-group">
<span class="cta-main">{block.options?.cta_text || config.hero_cta_text}</span> <span class="cta-main">{block.options?.cta_text || config.hero_cta_text}</span>
@@ -194,7 +258,7 @@
</div> </div>
</a> </a>
{:else if config.external_download_url} {:else if config.external_download_url}
<a href={config.external_download_url} target="_blank" class="btn-hero-primary"> <a href={safeLink(config.external_download_url)} target="_blank" rel="noopener noreferrer" class="btn-hero-primary">
<Download size={20} /> <Download size={20} />
<div class="cta-text-group"> <div class="cta-text-group">
<span class="cta-main">{block.options?.cta_text || config.hero_cta_text}</span> <span class="cta-main">{block.options?.cta_text || config.hero_cta_text}</span>
@@ -202,7 +266,7 @@
</div> </div>
</a> </a>
{:else} {:else}
<button type="button" class="btn-hero-primary" onclick={() => scrollToSection('download')}> <button type="button" class="btn-hero-primary" onclick={() => firstSection('download') && scrollToSection(firstSection('download')!)}>
<Download size={20} /> <Download size={20} />
<span class="cta-main">{block.options?.cta_text || config.hero_cta_text}</span> <span class="cta-main">{block.options?.cta_text || config.hero_cta_text}</span>
</button> </button>
@@ -214,12 +278,13 @@
</button> </button>
{/if} {/if}
</div> </div>
{#if mediaUrl(block.options?.image_url)}<img class="hero-image" src={mediaUrl(block.options?.image_url)} alt="" />{/if}
</div> </div>
</section> </section>
{:else if block.type === 'download'} {:else if block.type === 'download'}
<!-- Downloads Section --> <!-- Downloads Section -->
<section class="section" id="download"> <section class="section" id={sectionId(block)}>
<div class="container"> <div class="container">
<div class="section-head"> <div class="section-head">
<h2>{block.title || 'Get the Launcher'}</h2> <h2>{block.title || 'Get the Launcher'}</h2>
@@ -249,7 +314,7 @@
<div class="download-card full-span"> <div class="download-card full-span">
<h3>Download SCOPENET Launcher</h3> <h3>Download SCOPENET Launcher</h3>
<p class="muted">Get the latest launcher package directly from our release repository.</p> <p class="muted">Get the latest launcher package directly from our release repository.</p>
<a href={config.external_download_url} target="_blank" class="btn-download"> <a href={safeLink(config.external_download_url)} target="_blank" rel="noopener noreferrer" class="btn-download">
Download from Repository <ExternalLink size={15} /> Download from Repository <ExternalLink size={15} />
</a> </a>
</div> </div>
@@ -263,7 +328,7 @@
{:else if block.type === 'servers'} {:else if block.type === 'servers'}
<!-- Servers Section --> <!-- Servers Section -->
<section class="section alt-bg" id="servers"> <section class="section alt-bg" id={sectionId(block)}>
<div class="container"> <div class="container">
<div class="section-head"> <div class="section-head">
<h2>{block.title || 'Live Game Servers'}</h2> <h2>{block.title || 'Live Game Servers'}</h2>
@@ -305,7 +370,7 @@
{:else if block.type === 'leaderboard'} {:else if block.type === 'leaderboard'}
<!-- Leaderboard Section --> <!-- Leaderboard Section -->
<section class="section" id="leaderboard"> <section class="section" id={sectionId(block)}>
<div class="container"> <div class="container">
<div class="section-head"> <div class="section-head">
<h2>{block.title || 'Community Leaderboards'}</h2> <h2>{block.title || 'Community Leaderboards'}</h2>
@@ -444,7 +509,7 @@
{:else if block.type === 'stats'} {:else if block.type === 'stats'}
<!-- Stats Highlights Section --> <!-- Stats Highlights Section -->
<section class="section alt-bg" id="stats"> <section class="section alt-bg" id={sectionId(block)}>
<div class="container"> <div class="container">
<div class="section-head"> <div class="section-head">
<h2>{block.title || 'Network Highlights'}</h2> <h2>{block.title || 'Network Highlights'}</h2>
@@ -487,7 +552,7 @@
{:else if block.type === 'instances'} {:else if block.type === 'instances'}
<!-- Instances Showcase Section --> <!-- Instances Showcase Section -->
{#if instances.length} {#if instances.length}
<section class="section" id="instances"> <section class="section" id={sectionId(block)}>
<div class="container"> <div class="container">
<div class="section-head"> <div class="section-head">
<h2>{block.title || 'Available Instances'}</h2> <h2>{block.title || 'Available Instances'}</h2>
@@ -495,7 +560,7 @@
</div> </div>
<div class="instances-cards-grid"> <div class="instances-cards-grid">
{#each instances as inst} {#each instances.slice(0, Math.max(1, Number(block.options?.limit) || 6)) as inst}
<div class="inst-card"> <div class="inst-card">
<div class="inst-card-top"> <div class="inst-card-top">
<span class="badge mc-tag">{inst.mc_version}</span> <span class="badge mc-tag">{inst.mc_version}</span>
@@ -516,7 +581,7 @@
{:else if block.type === 'news'} {:else if block.type === 'news'}
<!-- News Section --> <!-- News Section -->
{#if news.length} {#if news.length}
<section class="section alt-bg" id="news"> <section class="section alt-bg" id={sectionId(block)}>
<div class="container"> <div class="container">
<div class="section-head"> <div class="section-head">
<h2>{block.title || 'Latest News'}</h2> <h2>{block.title || 'Latest News'}</h2>
@@ -524,7 +589,7 @@
</div> </div>
<div class="news-cards-grid"> <div class="news-cards-grid">
{#each news as item} {#each news.slice(0, Math.max(1, Number(block.options?.limit) || 3)) as item}
<div class="news-card"> <div class="news-card">
{#if item.tag}<span class="news-tag">{item.tag}</span>{/if} {#if item.tag}<span class="news-tag">{item.tag}</span>{/if}
<h3>{item.title}</h3> <h3>{item.title}</h3>
@@ -539,7 +604,7 @@
{:else if block.type === 'faq'} {:else if block.type === 'faq'}
<!-- FAQ Section --> <!-- FAQ Section -->
<section class="section" id="faq"> <section class="section" id={sectionId(block)}>
<div class="container max-w-narrow"> <div class="container max-w-narrow">
<div class="section-head"> <div class="section-head">
<h2>{block.title || 'Frequently Asked Questions'}</h2> <h2>{block.title || 'Frequently Asked Questions'}</h2>
@@ -567,7 +632,7 @@
{:else if block.type === 'socials'} {:else if block.type === 'socials'}
<!-- Socials Block --> <!-- Socials Block -->
<section class="section" id="socials"> <section class="section" id={sectionId(block)}>
<div class="container"> <div class="container">
<div class="section-head"> <div class="section-head">
<h2>{block.title || 'Join Our Community'}</h2> <h2>{block.title || 'Join Our Community'}</h2>
@@ -576,29 +641,95 @@
<div class="socials-buttons-grid"> <div class="socials-buttons-grid">
{#if block.options?.discord_url} {#if block.options?.discord_url}
<a href={block.options.discord_url} target="_blank" rel="noreferrer" class="social-btn discord"> <a href={safeLink(block.options.discord_url)} target="_blank" rel="noopener noreferrer" class="social-btn discord">
<MessageSquare size={18} /> Join our Discord <MessageSquare size={18} /> Join our Discord
</a> </a>
{/if} {/if}
{#if block.options?.store_url} {#if block.options?.store_url}
<a href={block.options.store_url} target="_blank" rel="noreferrer" class="social-btn store"> <a href={safeLink(block.options.store_url)} target="_blank" rel="noopener noreferrer" class="social-btn store">
<Sparkles size={18} /> Visit Webstore <Sparkles size={18} /> Visit Webstore
</a> </a>
{/if} {/if}
{#if block.options?.media_url} {#if block.options?.video_url || block.options?.media_url}
<a href={block.options.media_url} target="_blank" rel="noreferrer" class="social-btn media"> <a href={safeLink(block.options.video_url || block.options.media_url)} target="_blank" rel="noreferrer" class="social-btn media">
<Flame size={18} /> YouTube / Twitch <Flame size={18} /> YouTube / Twitch
</a> </a>
{/if} {/if}
{#if block.options?.social_url} {#if block.options?.social_url}
<a href={block.options.social_url} target="_blank" rel="noreferrer" class="social-btn twitter"> <a href={safeLink(block.options.social_url)} target="_blank" rel="noopener noreferrer" class="social-btn twitter">
<ExternalLink size={18} /> Follow on Twitter / X <ExternalLink size={18} /> Follow on Twitter / X
</a> </a>
{/if} {/if}
</div> </div>
</div> </div>
</section> </section>
{:else if block.type === 'text'}
<section class="section custom-text" id={sectionId(block)} style:text-align={block.options?.align === 'center' ? 'center' : 'left'}><div class="container">
{#if block.options?.eyebrow}<span class="custom-eyebrow">{block.options.eyebrow}</span>{/if}
<h2>{block.title}</h2>{#if block.subtitle}<p class="muted">{block.subtitle}</p>{/if}
<p class="custom-body">{block.options?.body}</p>
</div></section>
{:else if block.type === 'image'}
<section class="section" id={sectionId(block)}><div class="container custom-media">
{#if block.title}<h2>{block.title}</h2>{/if}
{#if mediaUrl(block.options?.url)}
{#if block.options?.link}<a href={safeLink(block.options.link)} target="_blank" rel="noopener noreferrer"><img src={mediaUrl(block.options?.url)} alt={block.options?.alt || block.title || ''} style:object-fit={block.options?.fit === 'contain' ? 'contain' : 'cover'} /></a>
{:else}<img src={mediaUrl(block.options?.url)} alt={block.options?.alt || block.title || ''} style:object-fit={block.options?.fit === 'contain' ? 'contain' : 'cover'} />{/if}
{#if block.options?.caption}<p class="muted">{block.options.caption}</p>{/if}
{/if}
</div></section>
{:else if block.type === 'split'}
<section class="section" id={sectionId(block)}><div class="container custom-split" class:reverse={block.options?.image_side === 'left'}>
<div><h2>{block.title}</h2>{#if block.subtitle}<p class="muted">{block.subtitle}</p>{/if}<p class="custom-body">{block.options?.body}</p>
{#if block.options?.button_text && block.options?.button_url}<a class="custom-button" href={safeLink(block.options.button_url)}>{block.options.button_text}</a>{/if}
</div>
{#if mediaUrl(block.options?.image_url)}<img src={mediaUrl(block.options?.image_url)} alt={block.options?.image_alt || ''} />{/if}
</div></section>
{:else if block.type === 'features'}
<section class="section" id={sectionId(block)}><div class="container"><div class="section-head"><h2>{block.title}</h2><p class="muted">{block.subtitle}</p></div>
<div class="custom-grid" style:grid-template-columns={`repeat(${Math.min(4, Math.max(2, Number(block.options?.columns) || 3))}, minmax(0, 1fr))`}>
{#each block.options?.items ?? [] as item}<article class="custom-card"><span class="custom-icon">{item.icon}</span><h3>{item.title}</h3><p class="muted">{item.body}</p></article>{/each}
</div>
</div></section>
{:else if block.type === 'gallery'}
<section class="section" id={sectionId(block)}><div class="container"><div class="section-head"><h2>{block.title}</h2><p class="muted">{block.subtitle}</p></div>
<div class="custom-grid gallery-grid" style:grid-template-columns={`repeat(${Math.min(4, Math.max(2, Number(block.options?.columns) || 3))}, minmax(0, 1fr))`}>
{#each block.options?.items ?? [] as item}{#if mediaUrl(item.url)}<figure><img src={mediaUrl(item.url)} alt={item.alt || item.caption || ''} loading="lazy" />{#if item.caption}<figcaption>{item.caption}</figcaption>{/if}</figure>{/if}{/each}
</div>
</div></section>
{:else if block.type === 'cta'}
<section class="section custom-cta" id={sectionId(block)}><div class="container"><h2>{block.title}</h2><p>{block.options?.body || block.subtitle}</p><div class="custom-actions">
{#if block.options?.button_text && block.options?.button_url}<a class="custom-button" href={safeLink(block.options.button_url)}>{block.options.button_text}</a>{/if}
{#if block.options?.secondary_text && block.options?.secondary_url}<a class="custom-button secondary" href={safeLink(block.options.secondary_url)}>{block.options.secondary_text}</a>{/if}
</div></div></section>
{:else if block.type === 'divider'}
<div class="custom-divider" id={sectionId(block)} style:height={`${Math.min(240, Math.max(0, Number(block.options?.height) || 48))}px`} class:glow={block.options?.style === 'glow'}>{#if block.options?.style !== 'space'}<span></span>{/if}</div>
{:else if block.type === 'testimonials'}
<section class="section" id={sectionId(block)}><div class="container"><div class="section-head"><h2>{block.title}</h2><p class="muted">{block.subtitle}</p></div><div class="custom-grid" style:grid-template-columns={`repeat(${Math.min(3, Math.max(2, Number(block.options?.columns) || 3))}, minmax(0, 1fr))`}>
{#each block.options?.items ?? [] as item}<blockquote class="custom-card"><p>“{item.quote}”</p><footer>{item.name}{#if item.role}<small> · {item.role}</small>{/if}</footer></blockquote>{/each}
</div></div></section>
{:else if block.type === 'video'}
<section class="section" id={sectionId(block)}><div class="container custom-media"><div class="section-head"><h2>{block.title}</h2><p class="muted">{block.subtitle}</p></div>
{#if videoEmbed(block.options?.url ?? '')}<iframe title={block.title || 'Video'} src={videoEmbed(block.options?.url ?? '')!} allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
{:else if mediaUrl(block.options?.url)}<video controls poster={mediaUrl(block.options?.poster)} src={mediaUrl(block.options?.url)}><track kind="captions" /></video>{/if}
{#if block.options?.caption}<p class="muted">{block.options.caption}</p>{/if}
</div></section>
{:else if block.type === 'buttons'}
<section class="section" id={sectionId(block)}><div class="container"><div class="section-head"><h2>{block.title}</h2><p class="muted">{block.subtitle}</p></div><div class="custom-actions" class:align-left={block.options?.align === 'left'}>
{#each block.options?.items ?? [] as item}{#if item.label && item.url}<a class="custom-button" class:secondary={item.style === 'secondary'} href={safeLink(item.url)}>{item.label}</a>{/if}{/each}
</div></div></section>
{/if} {/if}
</div>
{/each} {/each}
{/if} {/if}
</main> </main>
@@ -611,7 +742,7 @@
<img src={config.logo_url} alt="" class="brand-img" /> <img src={config.logo_url} alt="" class="brand-img" />
{/if} {/if}
<strong>{config?.brand_name ?? 'SCOPENET'}</strong> <strong>{config?.brand_name ?? 'SCOPENET'}</strong>
<p class="tiny muted">Your worlds, one click away.</p> <p class="tiny muted">{config?.theme?.footer_text || config?.tagline || 'Your worlds, one click away.'}</p>
</div> </div>
<div class="footer-right tiny muted"> <div class="footer-right tiny muted">
<span>Minecraft is a trademark of Mojang Studios. Not affiliated with Mojang or Microsoft.</span> <span>Minecraft is a trademark of Mojang Studios. Not affiliated with Mojang or Microsoft.</span>
@@ -626,22 +757,43 @@
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #090a0f; background: var(--landing-bg);
color: #f1f2f6; color: var(--landing-text);
font-family: var(--landing-font), sans-serif;
} }
.container { max-width: 1140px; margin: 0 auto; padding: 0 24px; width: 100%; box-sizing: border-box; } .container { max-width: var(--section-width, var(--landing-width)); margin: 0 auto; padding: 0 24px; width: 100%; box-sizing: border-box; }
.section-shell { background: var(--section-bg); }
.section-shell .section { padding: var(--section-padding) 0; }
.section-shell .section.alt-bg { background: transparent; }
.section-shell .hero-block { padding-top: var(--section-padding); padding-bottom: var(--section-padding); }
.section-shell h2 { color: var(--landing-text); }
.section-shell .muted { color: var(--landing-muted); }
.section-shell .custom-text h2, .section-shell .custom-split h2, .section-shell .custom-cta h2, .section-shell .custom-media h2 { font-size: clamp(26px, 4vw, 42px); margin: 0 0 14px; }
.custom-eyebrow { color: var(--landing-accent); text-transform: uppercase; letter-spacing: .12em; font-weight: 700; font-size: 12px; display: inline-block; margin-bottom: 12px; }
.custom-body { white-space: pre-line; line-height: 1.75; font-size: 17px; max-width: 850px; margin: 20px auto 0 0; }
.custom-text[style*="center"] .custom-body { margin-left: auto; margin-right: auto; }
.custom-media { text-align: center; } .custom-media img { width: 100%; max-height: 700px; border-radius: var(--landing-radius); } .custom-media iframe, .custom-media video { width: 100%; aspect-ratio: 16/9; border: 0; border-radius: var(--landing-radius); background: #000; }
.custom-split { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(30px, 5vw, 90px); align-items: center; } .custom-split.reverse img { order: -1; } .custom-split img { width: 100%; border-radius: var(--landing-radius); object-fit: cover; max-height: 560px; }
.custom-grid { display: grid; gap: 20px; } .custom-card { background: var(--landing-surface); border: 1px solid color-mix(in srgb, var(--landing-text) 12%, transparent); border-radius: var(--landing-radius); padding: 28px; margin: 0; }
.custom-card h3 { margin: 12px 0 8px; font-size: 19px; } .custom-card p { line-height: 1.6; } .custom-card footer { margin-top: 18px; font-weight: 700; color: var(--landing-accent); } .custom-card footer small { color: var(--landing-muted); font-weight: 400; }
.custom-icon { font-size: 30px; color: var(--landing-accent); } .gallery-grid figure { margin: 0; } .gallery-grid img { width: 100%; aspect-ratio: 4/3; object-fit: cover; border-radius: var(--landing-radius); } .gallery-grid figcaption { color: var(--landing-muted); font-size: 13px; padding-top: 8px; }
.custom-cta { text-align: center; } .custom-cta p { color: var(--landing-muted); font-size: 18px; margin: 0 auto 24px; max-width: 680px; }
.custom-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; } .custom-actions.align-left { justify-content: flex-start; }
.custom-button { display: inline-flex; align-items: center; justify-content: center; padding: 12px 22px; border-radius: var(--landing-radius); background: var(--landing-accent); color: #fff; font-weight: 700; text-decoration: none; }
.custom-button.secondary { background: var(--landing-surface); color: var(--landing-text); border: 1px solid color-mix(in srgb, var(--landing-text) 20%, transparent); }
.custom-divider { display: grid; place-items: center; } .custom-divider span { width: min(90%, var(--landing-width)); height: 1px; background: color-mix(in srgb, var(--landing-text) 18%, transparent); } .custom-divider.glow span { height: 2px; background: var(--landing-accent); box-shadow: 0 0 24px var(--landing-accent); }
.max-w-narrow { max-width: 820px; } .max-w-narrow { max-width: 820px; }
.navbar { position: sticky; top: 0; z-index: 100; backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); background: rgba(9, 10, 15, 0.82); border-bottom: 1px solid rgba(255, 255, 255, 0.08); } .navbar { position: sticky; top: 0; z-index: 100; backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); background: rgba(9, 10, 15, 0.82); border-bottom: 1px solid rgba(255, 255, 255, 0.08); }
.nav-container { max-width: 1140px; margin: 0 auto; padding: 14px 24px; display: flex; align-items: center; justify-content: space-between; gap: 16px; } .nav-container { max-width: 1140px; margin: 0 auto; padding: 14px 24px; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.brand-group { display: flex; align-items: center; gap: 10px; } .brand-group { display: flex; align-items: center; gap: 10px; }
.brand-img { width: 28px; height: 28px; border-radius: 6px; object-fit: contain; } .brand-img { width: 28px; height: 28px; border-radius: 6px; object-fit: contain; }
.brand-logo { width: 14px; height: 14px; border-radius: 50%; background: #6d6af5; box-shadow: 0 0 12px #6d6af5; } .brand-logo { width: 14px; height: 14px; border-radius: 50%; background: var(--landing-accent); box-shadow: 0 0 12px var(--landing-accent); }
.brand-title { font-size: 18px; font-weight: 700; letter-spacing: -0.01em; } .brand-title { font-size: 18px; font-weight: 700; letter-spacing: -0.01em; }
.nav-links { display: flex; gap: 24px; } .nav-links { display: flex; gap: 24px; }
.nav-link-btn { .nav-link-btn {
background: transparent; background: transparent;
border: none; border: none;
color: #8b8f9a; color: var(--landing-muted);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -649,93 +801,96 @@
transition: color 0.15s; transition: color 0.15s;
font-family: inherit; font-family: inherit;
} }
.nav-link-btn:hover { color: #f1f2f6; } .nav-link-btn:hover { color: var(--landing-text); }
.nav-actions { display: flex; align-items: center; gap: 12px; } .nav-actions { display: flex; align-items: center; gap: 12px; }
.ip-pill { display: inline-flex; align-items: center; gap: 8px; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.12); padding: 6px 12px; border-radius: 99rem; color: #f1f2f6; cursor: pointer; font-size: 13px; font-weight: 560; font-family: monospace; transition: background 0.15s; } .ip-pill { display: inline-flex; align-items: center; gap: 8px; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.12); padding: 6px 12px; border-radius: 99rem; color: var(--landing-text); cursor: pointer; font-size: 13px; font-weight: 560; font-family: monospace; transition: background 0.15s; }
.ip-pill:hover { background: rgba(255, 255, 255, 0.1); } .ip-pill:hover { background: rgba(255, 255, 255, 0.1); }
.pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: #3fb97c; box-shadow: 0 0 8px #3fb97c; } .pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: #3fb97c; box-shadow: 0 0 8px #3fb97c; }
.copy-badge { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: #8b8f9a; margin-left: 4px; } .copy-badge { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: var(--landing-muted); margin-left: 4px; }
.nav-login-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 8px; background: transparent; border: 1px solid rgba(255, 255, 255, 0.14); color: #8b8f9a; text-decoration: none; font-size: 13px; font-weight: 500; } .nav-login-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 8px; background: transparent; border: 1px solid rgba(255, 255, 255, 0.14); color: var(--landing-muted); text-decoration: none; font-size: 13px; font-weight: 500; }
.nav-login-btn:hover { color: #f1f2f6; border-color: rgba(255, 255, 255, 0.3); } .nav-login-btn:hover { color: var(--landing-text); border-color: rgba(255, 255, 255, 0.3); }
.hero-block { position: relative; padding: 110px 0 90px; text-align: center; overflow: hidden; } .hero-block { position: relative; padding: 110px 0 90px; text-align: center; overflow: hidden; }
.hero-bg-glow { position: absolute; top: -100px; left: 50%; transform: translateX(-50%); width: 700px; height: 450px; background: radial-gradient(circle, rgba(109, 106, 245, 0.22) 0%, transparent 70%); filter: blur(50px); pointer-events: none; } .hero-video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: .4; pointer-events: none; }
.hero-bg-glow { position: absolute; top: -100px; left: 50%; transform: translateX(-50%); width: 700px; height: 450px; background: radial-gradient(circle, color-mix(in srgb, var(--landing-accent) 22%, transparent) 0%, transparent 70%); filter: blur(50px); pointer-events: none; }
.hero-content { position: relative; z-index: 10; display: flex; flex-direction: column; align-items: center; gap: 20px; } .hero-content { position: relative; z-index: 10; display: flex; flex-direction: column; align-items: center; gap: 20px; }
.hero-badge { display: inline-flex; align-items: center; gap: 8px; padding: 6px 16px; border-radius: 99rem; background: rgba(109, 106, 245, 0.12); border: 1px solid rgba(109, 106, 245, 0.35); color: #a5a3ff; font-size: 13px; font-weight: 600; } .hero-image { width: min(100%, 1000px); max-height: 520px; object-fit: cover; border-radius: var(--landing-radius); box-shadow: 0 30px 80px #0008; margin-top: 28px; }
.hero-badge { display: inline-flex; align-items: center; gap: 8px; padding: 6px 16px; border-radius: 99rem; background: color-mix(in srgb, var(--landing-accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--landing-accent) 35%, transparent); color: color-mix(in srgb, var(--landing-accent) 60%, white); font-size: 13px; font-weight: 600; }
.hero-badge-dot { width: 6px; height: 6px; border-radius: 50%; background: #3fb97c; box-shadow: 0 0 6px #3fb97c; } .hero-badge-dot { width: 6px; height: 6px; border-radius: 50%; background: #3fb97c; box-shadow: 0 0 6px #3fb97c; }
.hero-title { font-size: clamp(32px, 5vw, 56px); font-weight: 800; line-height: 1.1; margin: 0; letter-spacing: -0.03em; max-width: 840px; } .hero-title { font-size: clamp(32px, 5vw, 56px); font-weight: 800; line-height: 1.1; margin: 0; letter-spacing: -0.03em; max-width: 840px; }
.hero-sub { font-size: 18px; line-height: 1.6; color: #8b8f9a; max-width: 640px; margin: 0; } .hero-sub { font-size: 18px; line-height: 1.6; color: var(--landing-muted); max-width: 640px; margin: 0; }
.hero-cta-group { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 14px; justify-content: center; } .hero-cta-group { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 14px; justify-content: center; }
.btn-hero-primary { display: inline-flex; align-items: center; gap: 12px; padding: 14px 28px; border-radius: 12px; background: #6d6af5; color: white; text-decoration: none; font-weight: 650; box-shadow: 0 8px 30px rgba(109, 106, 245, 0.4); transition: transform 0.15s, background 0.15s; } .btn-hero-primary { display: inline-flex; align-items: center; gap: 12px; padding: 14px 28px; border-radius: var(--landing-radius); background: var(--landing-accent); color: white; text-decoration: none; font-weight: 650; box-shadow: 0 8px 30px color-mix(in srgb, var(--landing-accent) 40%, transparent); transition: transform 0.15s, background 0.15s; }
.btn-hero-primary:hover { transform: translateY(-2px); background: #7c79ff; } .btn-hero-primary:hover { transform: translateY(-2px); background: color-mix(in srgb, var(--landing-accent) 84%, white); }
.cta-text-group { display: flex; flex-direction: column; text-align: left; } .cta-text-group { display: flex; flex-direction: column; text-align: left; }
.cta-main { font-size: 16px; line-height: 1.2; } .cta-main { font-size: 16px; line-height: 1.2; }
.cta-sub { font-size: 11px; opacity: 0.85; font-weight: 400; } .cta-sub { font-size: 11px; opacity: 0.85; font-weight: 400; }
.btn-hero-secondary { display: inline-flex; align-items: center; gap: 10px; padding: 14px 24px; border-radius: 12px; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.15); color: #f1f2f6; font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.15s; } .btn-hero-secondary { display: inline-flex; align-items: center; gap: 10px; padding: 14px 24px; border-radius: var(--landing-radius); background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.15); color: var(--landing-text); font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.15s; }
.btn-hero-secondary:hover { background: rgba(255, 255, 255, 0.1); } .btn-hero-secondary:hover { background: rgba(255, 255, 255, 0.1); }
.section { padding: 80px 0; } .section { padding: 80px 0; }
.section.alt-bg { background: rgba(255, 255, 255, 0.02); border-top: 1px solid rgba(255, 255, 255, 0.05); border-bottom: 1px solid rgba(255, 255, 255, 0.05); } .section.alt-bg { background: rgba(255, 255, 255, 0.02); border-top: 1px solid rgba(255, 255, 255, 0.05); border-bottom: 1px solid rgba(255, 255, 255, 0.05); }
.section-head { text-align: center; margin-bottom: 44px; display: flex; flex-direction: column; gap: 8px; } .section-head { text-align: center; margin-bottom: 44px; display: flex; flex-direction: column; gap: 8px; }
.section-head h2 { font-size: 32px; font-weight: 700; margin: 0; letter-spacing: -0.02em; } .section-head h2 { font-size: 32px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
.muted { color: #8b8f9a; margin: 0; } .muted { color: var(--landing-muted); margin: 0; }
.downloads-cards-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; } .downloads-cards-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; }
.download-card { padding: 32px 24px; border-radius: 16px; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.08); display: flex; flex-direction: column; align-items: center; text-align: center; gap: 12px; position: relative; } .download-card { padding: 32px 24px; border-radius: var(--landing-radius); background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.08); display: flex; flex-direction: column; align-items: center; text-align: center; gap: 12px; position: relative; }
.download-card.recommended { border-color: rgba(109, 106, 245, 0.5); background: rgba(109, 106, 245, 0.06); } .download-card.recommended { border-color: color-mix(in srgb, var(--landing-accent) 50%, transparent); background: color-mix(in srgb, var(--landing-accent) 6%, transparent); }
.recommended-badge { position: absolute; top: 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; padding: 2px 10px; border-radius: 99rem; background: #6d6af5; color: white; } .recommended-badge { position: absolute; top: 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; padding: 2px 10px; border-radius: 99rem; background: var(--landing-accent); color: white; }
.download-card-icon { width: 56px; height: 56px; border-radius: 14px; background: rgba(255, 255, 255, 0.06); display: grid; place-items: center; color: #6d6af5; margin-bottom: 4px; } .download-card-icon { width: 56px; height: 56px; border-radius: var(--landing-radius); background: rgba(255, 255, 255, 0.06); display: grid; place-items: center; color: var(--landing-accent); margin-bottom: 4px; }
.download-card h3 { margin: 0; font-size: 18px; font-weight: 650; } .download-card h3 { margin: 0; font-size: 18px; font-weight: 650; }
.btn-download { display: inline-flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 12px; border-radius: 10px; background: #6d6af5; color: white; text-decoration: none; font-weight: 600; font-size: 14px; margin-top: 8px; } .btn-download { display: inline-flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 12px; border-radius: 10px; background: var(--landing-accent); color: white; text-decoration: none; font-weight: 600; font-size: 14px; margin-top: 8px; }
.btn-download:hover { background: #7c79ff; } .btn-download:hover { background: color-mix(in srgb, var(--landing-accent) 84%, white); }
.servers-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 20px; } .servers-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 20px; }
.server-card { padding: 24px; border-radius: 14px; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.08); display: flex; flex-direction: column; gap: 14px; } .server-card { padding: 24px; border-radius: var(--landing-radius); background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.08); display: flex; flex-direction: column; gap: 14px; }
.server-card-top { display: flex; justify-content: space-between; align-items: center; } .server-card-top { display: flex; justify-content: space-between; align-items: center; }
.server-status-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: #8b8f9a; } .server-status-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--landing-muted); }
.server-status-pill .dot { width: 6px; height: 6px; border-radius: 50%; background: #e5484d; } .server-status-pill .dot { width: 6px; height: 6px; border-radius: 50%; background: #e5484d; }
.server-status-pill.online { color: #3fb97c; } .server-status-pill.online { color: #3fb97c; }
.server-status-pill.online .dot { background: #3fb97c; box-shadow: 0 0 6px #3fb97c; } .server-status-pill.online .dot { background: #3fb97c; box-shadow: 0 0 6px #3fb97c; }
.mc-ver-tag { background: rgba(255, 255, 255, 0.06); padding: 2px 8px; border-radius: 6px; } .mc-ver-tag { background: rgba(255, 255, 255, 0.06); padding: 2px 8px; border-radius: 6px; }
.server-card-title { margin: 0; font-size: 20px; font-weight: 700; } .server-card-title { margin: 0; font-size: 20px; font-weight: 700; }
.server-copy-btn { display: flex; justify-content: space-between; align-items: center; width: 100%; padding: 10px 14px; border-radius: 8px; background: rgba(0, 0, 0, 0.25); border: 1px solid rgba(255, 255, 255, 0.1); color: #f1f2f6; font-family: monospace; font-size: 13px; cursor: pointer; } .server-copy-btn { display: flex; justify-content: space-between; align-items: center; width: 100%; padding: 10px 14px; border-radius: 8px; background: rgba(0, 0, 0, 0.25); border: 1px solid rgba(255, 255, 255, 0.1); color: var(--landing-text); font-family: monospace; font-size: 13px; cursor: pointer; }
.server-copy-btn:hover { border-color: #6d6af5; } .server-copy-btn:hover { border-color: var(--landing-accent); }
.sort-chips-bar { display: flex; justify-content: center; flex-wrap: wrap; gap: 8px; margin-bottom: 28px; } .sort-chips-bar { display: flex; justify-content: center; flex-wrap: wrap; gap: 8px; margin-bottom: 28px; }
.sort-chip { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 99rem; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.09); color: #8b8f9a; font-size: 13px; font-weight: 560; cursor: pointer; transition: all 0.15s; } .sort-chip { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 99rem; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.09); color: var(--landing-muted); font-size: 13px; font-weight: 560; cursor: pointer; transition: all 0.15s; }
.sort-chip.active { background: rgba(109, 106, 245, 0.15); border-color: #6d6af5; color: #f1f2f6; } .sort-chip.active { background: color-mix(in srgb, var(--landing-accent) 15%, transparent); border-color: var(--landing-accent); color: var(--landing-text); }
.podium-row { display: grid; grid-template-columns: 1fr 1.2fr 1fr; gap: 16px; align-items: end; max-width: 580px; margin: 0 auto 32px; text-align: center; } .podium-row { display: grid; grid-template-columns: 1fr 1.2fr 1fr; gap: 16px; align-items: end; max-width: 580px; margin: 0 auto 32px; text-align: center; }
.podium-item { padding: 24px 16px; border-radius: 16px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); display: flex; flex-direction: column; gap: 6px; } .podium-item { padding: 24px 16px; border-radius: var(--landing-radius); background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); display: flex; flex-direction: column; gap: 6px; }
.podium-item.gold { border-color: rgba(245, 158, 11, 0.4); background: rgba(245, 158, 11, 0.08); padding-bottom: 36px; } .podium-item.gold { border-color: rgba(245, 158, 11, 0.4); background: rgba(245, 158, 11, 0.08); padding-bottom: 36px; }
.podium-item .medal { font-size: 26px; } .podium-item .medal { font-size: 26px; }
.podium-val { font-size: 18px; font-weight: 700; color: #6d6af5; } .podium-val { font-size: 18px; font-weight: 700; color: var(--landing-accent); }
.lb-table-wrap { background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 14px; overflow: hidden; } .lb-table-wrap { background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: var(--landing-radius); overflow: hidden; }
.lb-table-head, .lb-table-row { display: grid; grid-template-columns: 70px 1fr 120px 120px 100px; padding: 14px 20px; align-items: center; font-size: 14px; } .lb-table-head, .lb-table-row { display: grid; grid-template-columns: 70px 1fr 120px 120px 100px; padding: 14px 20px; align-items: center; font-size: 14px; }
.lb-table-head { border-bottom: 1px solid rgba(255, 255, 255, 0.08); font-size: 12px; font-weight: 700; text-transform: uppercase; color: #8b8f9a; } .lb-table-head { border-bottom: 1px solid rgba(255, 255, 255, 0.08); font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--landing-muted); }
.lb-table-row { border-bottom: 1px solid rgba(255, 255, 255, 0.04); } .lb-table-row { border-bottom: 1px solid rgba(255, 255, 255, 0.04); }
.text-right { text-align: right; } .text-right { text-align: right; }
.score-highlight { color: #6d6af5; font-weight: 700; } .score-highlight { color: var(--landing-accent); font-weight: 700; }
.stats-counter-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; } .stats-counter-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
.counter-card { padding: 32px 20px; border-radius: 16px; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.08); text-align: center; display: flex; flex-direction: column; gap: 6px; } .counter-card { padding: 32px 20px; border-radius: var(--landing-radius); background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.08); text-align: center; display: flex; flex-direction: column; gap: 6px; }
.counter-num { font-size: 38px; font-weight: 800; color: #6d6af5; letter-spacing: -0.02em; } .counter-num { font-size: 38px; font-weight: 800; color: var(--landing-accent); letter-spacing: -0.02em; }
.counter-label { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #8b8f9a; } .counter-label { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--landing-muted); }
.instances-cards-grid, .news-cards-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; } .instances-cards-grid, .news-cards-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; }
.inst-card, .news-card { padding: 24px; border-radius: 14px; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.08); display: flex; flex-direction: column; gap: 10px; } .inst-card, .news-card { padding: 24px; border-radius: var(--landing-radius); background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.08); display: flex; flex-direction: column; gap: 10px; }
.inst-card h3, .news-card h3 { margin: 0; font-size: 18px; font-weight: 700; } .inst-card h3, .news-card h3 { margin: 0; font-size: 18px; font-weight: 700; }
.badge { font-size: 11px; padding: 2px 8px; border-radius: 6px; background: rgba(255, 255, 255, 0.08); } .badge { font-size: 11px; padding: 2px 8px; border-radius: 6px; background: rgba(255, 255, 255, 0.08); }
.news-tag { align-self: flex-start; padding: 2px 8px; border-radius: 4px; background: rgba(109, 106, 245, 0.2); color: #a5a3ff; font-size: 11px; font-weight: 600; text-transform: uppercase; } .news-tag { align-self: flex-start; padding: 2px 8px; border-radius: 4px; background: color-mix(in srgb, var(--landing-accent) 20%, transparent); color: color-mix(in srgb, var(--landing-accent) 60%, white); font-size: 11px; font-weight: 600; text-transform: uppercase; }
.faq-accordion { display: flex; flex-direction: column; gap: 12px; } .faq-accordion { display: flex; flex-direction: column; gap: 12px; }
.faq-item { border-radius: 12px; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.08); overflow: hidden; } .faq-item { border-radius: var(--landing-radius); background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.08); overflow: hidden; }
.faq-q-btn { width: 100%; display: flex; justify-content: space-between; align-items: center; padding: 18px 20px; background: transparent; border: none; color: #f1f2f6; font-size: 16px; font-weight: 600; cursor: pointer; text-align: left; } .faq-q-btn { width: 100%; display: flex; justify-content: space-between; align-items: center; padding: 18px 20px; background: transparent; border: none; color: var(--landing-text); font-size: 16px; font-weight: 600; cursor: pointer; text-align: left; }
.faq-ans { padding: 0 20px 18px; color: #8b8f9a; line-height: 1.6; font-size: 14px; } .faq-ans { padding: 0 20px 18px; color: var(--landing-muted); line-height: 1.6; font-size: 14px; }
.footer { border-top: 1px solid rgba(255, 255, 255, 0.08); padding: 40px 0; margin-top: auto; } .footer { border-top: 1px solid rgba(255, 255, 255, 0.08); padding: 40px 0; margin-top: auto; }
.footer-content { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; } .footer-content { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; }
.empty-state-box { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 40px; } .empty-state-box { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 40px; }
.badge-note-pill { display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 99rem; background: rgba(109, 106, 245, 0.15); color: #a5a3ff; font-size: 13px; font-weight: 600; margin-top: 10px; } .badge-note-pill { display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 99rem; background: color-mix(in srgb, var(--landing-accent) 15%, transparent); color: color-mix(in srgb, var(--landing-accent) 60%, white); font-size: 13px; font-weight: 600; margin-top: 10px; }
.downloads-cards-grid.compact-list { grid-template-columns: 1fr; } .downloads-cards-grid.compact-list { grid-template-columns: 1fr; }
.socials-buttons-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-top: 24px; } .socials-buttons-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-top: 24px; }
.social-btn { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 16px 20px; border-radius: 12px; font-weight: 600; text-decoration: none; transition: transform 0.15s, opacity 0.15s; color: #fff; font-size: 15px; } .social-btn { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 16px 20px; border-radius: var(--landing-radius); font-weight: 600; text-decoration: none; transition: transform 0.15s, opacity 0.15s; color: #fff; font-size: 15px; }
.social-btn:hover { transform: translateY(-2px); opacity: 0.95; } .social-btn:hover { transform: translateY(-2px); opacity: 0.95; }
.social-btn.discord { background: #5865F2; } .social-btn.discord { background: #5865F2; }
.social-btn.store { background: #8b5cf6; } .social-btn.store { background: #8b5cf6; }
.social-btn.media { background: #ef4444; } .social-btn.media { background: #ef4444; }
.social-btn.twitter { background: #0284c7; } .social-btn.twitter { background: #0284c7; }
@media (max-width: 768px) { @media (max-width: 768px) {
.custom-split { grid-template-columns: 1fr; } .custom-split.reverse img { order: 0; } .custom-grid { grid-template-columns: 1fr !important; }
.nav-links { display: none; } .nav-links { display: none; }
.hero-title { font-size: 34px; } .hero-title { font-size: 34px; }
.hero-cta-group { flex-direction: column; width: 100%; } .hero-cta-group { flex-direction: column; width: 100%; }