From 6deff62a3a7df5097cd9bda480d9fe405c4755e2 Mon Sep 17 00:00:00 2001 From: SCOPEDD Date: Wed, 30 Sep 2026 06:48:03 -0400 Subject: [PATCH] Add launcher preferences and modular landing studio [skip ci] --- crates/core/src/options.rs | 42 + crates/shared/src/lib.rs | 17 + launcher/src-tauri/src/accounts.rs | 2 +- launcher/src-tauri/src/commands.rs | 34 +- launcher/src-tauri/src/game.rs | 53 +- launcher/src-tauri/src/lib.rs | 1 + launcher/src-tauri/src/settings.rs | 11 + launcher/src-tauri/src/state.rs | 7 +- launcher/src/components/Background.svelte | 2 +- .../src/components/InstanceSettings.svelte | 13 + launcher/src/components/PlayButton.svelte | 28 +- launcher/src/components/Rail.svelte | 2 +- launcher/src/lib/mock.ts | 14 +- launcher/src/lib/store.svelte.ts | 15 +- launcher/src/lib/types.ts | 9 +- launcher/src/pages/Home.svelte | 2 +- launcher/src/pages/Settings.svelte | 31 +- panel/server/src/routes/landing.rs | 35 + panel/server/tests/api.rs | 15 + panel/web/src/App.svelte | 3 +- panel/web/src/lib/landingSchema.ts | 62 ++ panel/web/src/lib/types.ts | 5 +- panel/web/src/pages/Branding.svelte | 17 + panel/web/src/pages/LandingBuilder.svelte | 779 ++++++------------ panel/web/src/pages/PublicLanding.svelte | 305 +++++-- 25 files changed, 822 insertions(+), 682 deletions(-) create mode 100644 panel/web/src/lib/landingSchema.ts diff --git a/crates/core/src/options.rs b/crates/core/src/options.rs index 0588b59..e8447aa 100644 --- a/crates/core/src/options.rs +++ b/crates/core/src/options.rs @@ -4,6 +4,36 @@ use anyhow::Result; use std::collections::BTreeMap; 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> { + 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_:` lines into options.txt, keeping everything /// else the game has written. Only for 1.13+ (named key codes). pub fn apply_keybinds(game_dir: &Path, binds: &BTreeMap) -> Result<()> { @@ -51,4 +81,16 @@ mod tests { 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"); } + + #[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")); + } } diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 45bdf7a..0cbebd4 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -31,6 +31,7 @@ pub struct Branding { pub version_label: String, pub news: Vec, pub links: Vec, + pub about: AboutContent, pub features: Features, /// Raw CSS appended to the launcher stylesheet (power users). pub custom_css: String, @@ -60,12 +61,28 @@ impl Default for Branding { tag: Some("News".into()), }], links: vec![], + about: AboutContent::default(), features: Features::default(), custom_css: String::new(), } } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct AboutContent { + pub title: String, + pub icon_url: Option, + pub body: String, + pub links: Vec, +} + +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)] #[serde(default)] pub struct Background { diff --git a/launcher/src-tauri/src/accounts.rs b/launcher/src-tauri/src/accounts.rs index 2a88cf0..6d85d1c 100644 --- a/launcher/src-tauri/src/accounts.rs +++ b/launcher/src-tauri/src/accounts.rs @@ -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 { - 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"); } let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?; diff --git a/launcher/src-tauri/src/commands.rs b/launcher/src-tauri/src/commands.rs index 8a1baed..0b848c9 100644 --- a/launcher/src-tauri/src/commands.rs +++ b/launcher/src-tauri/src/commands.rs @@ -52,9 +52,12 @@ pub struct Bootstrap { accounts: Vec, active_account: Option, manifest: Option, - game_running: Option, + game_running: Vec, } +#[derive(Serialize)] +struct RunningGameInfo { run_id: String, instance_id: String } + #[tauri::command] pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap { let accounts = state.accounts.read().unwrap().clone(); @@ -74,7 +77,7 @@ pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap { accounts: accounts.accounts, active_account: accounts.active, 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, settings: Settings) -> Res<()> { let mut current = state.settings.write().unwrap(); 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); state.save_settings().map_err(aerr) } +#[tauri::command] +pub fn save_instance_options(state: State<'_, AppState>, instance_id: String) -> Res> { + 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 ---- #[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<()> { - if state.game.lock().unwrap().is_some() { - return Err("Minecraft is already running".into()); + if deep && state.game.lock().unwrap().iter().any(|g| g.instance_id == instance_id) { + return Err("Close this instance's game windows before repairing it".into()); } let mut task = state.task.lock().unwrap(); 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] -pub fn kill_game(state: State<'_, AppState>) { - if let Some(g) = state.game.lock().unwrap().take() { - g.kill.send(()).ok(); +pub fn kill_game(state: State<'_, AppState>, run_id: String) { + if let Some(g) = state.game.lock().unwrap().iter_mut().find(|g| g.run_id == run_id) { + if let Some(kill) = g.kill.take() { kill.send(()).ok(); } } } @@ -444,7 +460,7 @@ pub async fn storage_info(state: State<'_, AppState>) -> Res { #[tauri::command] 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()); } let dir = state.layout.instance_dir(&instance_id); diff --git a/launcher/src-tauri/src/game.rs b/launcher/src-tauri/src/game.rs index dacfe34..d393c28 100644 --- a/launcher/src-tauri/src/game.rs +++ b/launcher/src-tauri/src/game.rs @@ -19,6 +19,7 @@ use tokio::io::{AsyncBufReadExt, BufReader}; #[derive(Serialize, Clone)] pub struct StatePayload { pub instance_id: String, + pub run_id: Option, /// "preparing" | "running" | "idle" | "error" pub state: String, pub message: Option, @@ -27,6 +28,7 @@ pub struct StatePayload { #[derive(Serialize, Clone)] pub struct ExitPayload { pub instance_id: String, + pub run_id: String, pub code: Option, pub crashed: bool, pub tail: Vec, @@ -34,7 +36,7 @@ pub struct ExitPayload { } pub fn emit_state(app: &AppHandle, id: &str, state: &str, message: Option) { - 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 @@ -119,7 +121,19 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) - deep_verify: deep, }; 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 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(); } } - 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(); } @@ -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(); - *state.game.lock().unwrap() = Some(RunningGame { instance_id: instance_id.clone(), kill: kill_tx }); - emit_state(&app, &instance_id, "running", None); + let run_id = uuid::Uuid::new_v4().to_string(); + 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") { 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 id2 = instance_id.clone(); + let run_id2 = run_id.clone(); tokio::spawn(async move { let mut tail: VecDeque = VecDeque::with_capacity(200); let mut batch: Vec = Vec::new(); @@ -297,15 +319,22 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) - }, &id2, ); - *st.game.lock().unwrap() = None; - if let Some(w) = app2.get_webview_window("main") { - w.show().ok(); - w.unminimize().ok(); - if crashed { - w.set_focus().ok(); + let preferences = options::read_vanilla_preferences(&st.layout.instance_dir(&id2)).unwrap_or_default(); + if !preferences.is_empty() { + st.settings.write().unwrap().instance_game_options.insert(id2.clone(), preferences); + st.save_settings().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(); emit_state(&app2, &id2, "idle", None); }); diff --git a/launcher/src-tauri/src/lib.rs b/launcher/src-tauri/src/lib.rs index cc8abaf..c2df5cc 100644 --- a/launcher/src-tauri/src/lib.rs +++ b/launcher/src-tauri/src/lib.rs @@ -51,6 +51,7 @@ pub fn run() { commands::refresh_manifest, commands::set_panel_url, commands::save_settings, + commands::save_instance_options, commands::login_panel, commands::register_panel, commands::add_offline, diff --git a/launcher/src-tauri/src/settings.rs b/launcher/src-tauri/src/settings.rs index a956d1e..6f23303 100644 --- a/launcher/src-tauri/src/settings.rs +++ b/launcher/src-tauri/src/settings.rs @@ -57,6 +57,12 @@ pub struct Settings { // ---- Controls ---- pub keybinds_enabled: bool, pub keybinds: BTreeMap, + 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>, pub instance_overrides: HashMap, } @@ -88,6 +94,11 @@ impl Default for Settings { send_stats: true, keybinds_enabled: false, 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(), } } diff --git a/launcher/src-tauri/src/state.rs b/launcher/src-tauri/src/state.rs index bddcfb2..9e2729c 100644 --- a/launcher/src-tauri/src/state.rs +++ b/launcher/src-tauri/src/state.rs @@ -21,8 +21,9 @@ pub mod build { } pub struct RunningGame { + pub run_id: String, pub instance_id: String, - pub kill: tokio::sync::oneshot::Sender<()>, + pub kill: Option>, } pub struct AppState { @@ -35,7 +36,7 @@ pub struct AppState { pub manifest: RwLock>, /// Install/launch pipeline in progress (abortable). pub task: Mutex>, - pub game: Mutex>, + pub game: Mutex>, } impl AppState { @@ -52,7 +53,7 @@ impl AppState { accounts: RwLock::new(accounts), manifest: RwLock::new(manifest), task: Mutex::new(None), - game: Mutex::new(None), + game: Mutex::new(Vec::new()), data_dir, } } diff --git a/launcher/src/components/Background.svelte b/launcher/src/components/Background.svelte index 57427fa..5f9cd2a 100644 --- a/launcher/src/components/Background.svelte +++ b/launcher/src/components/Background.svelte @@ -5,7 +5,7 @@ const b = $derived(app.manifest?.branding); const inst = $derived(selectedInstance()); 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 dim = $derived((b?.background.dim ?? 55) / 100); let hidden = $state(false); diff --git a/launcher/src/components/InstanceSettings.svelte b/launcher/src/components/InstanceSettings.svelte index f019fea..4f12b6f 100644 --- a/launcher/src/components/InstanceSettings.svelte +++ b/launcher/src/components/InstanceSettings.svelte @@ -68,6 +68,19 @@ {/if} +
+ Vanilla game settings + Brightness, graphics, FOV, view distance, sound and other vanilla options are saved automatically when the game closes, then restored after instance updates. + {Object.keys(app.settings.instance_game_options?.[inst.id] ?? {}).length} saved options for this instance + +
+
{local?.installed ? `Uses ${bytes(local.size)} on disk` : 'Not downloaded yet'} diff --git a/launcher/src/components/PlayButton.svelte b/launcher/src/components/PlayButton.svelte index 87c197e..7b920b4 100644 --- a/launcher/src/components/PlayButton.svelte +++ b/launcher/src/components/PlayButton.svelte @@ -9,8 +9,8 @@ const l = $derived(app.launch); const mine = $derived(app.launchingId === inst.id); const busy = $derived(mine && l.state === 'preparing'); - const running = $derived(app.running === inst.id); - const otherRunning = $derived(!!app.running && app.running !== inst.id); + const running = $derived(app.running.filter((g) => g.instance_id === inst.id)); + const preparing = $derived(l.state === 'preparing'); 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 stageNames: Record = { preparing: 'Preparing', java: 'Java', game: 'Game files', loader: 'Mod loader', files: 'Instance files', launching: 'Launching' }; @@ -18,7 +18,7 @@ { - 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); }} />
@@ -37,18 +37,19 @@ {#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}
- {:else if running} - {:else} - {/if} + {#if running.length} +
+ {#each running as game, index (game.run_id)} + + {/each} +
+ {/if} {#if mine && l.state === 'error'}
@@ -60,6 +61,8 @@ diff --git a/panel/web/src/pages/PublicLanding.svelte b/panel/web/src/pages/PublicLanding.svelte index 51e0677..540a37d 100644 --- a/panel/web/src/pages/PublicLanding.svelte +++ b/panel/web/src/pages/PublicLanding.svelte @@ -21,6 +21,63 @@ let copied = $state(false); let openFaq = $state>({}); + 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 { + 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 { + 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 const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase() : ''; @@ -54,11 +111,6 @@ get('/api/v1/landing') .then((cfg) => { config = cfg; - if (cfg.custom_css) { - const style = document.createElement('style'); - style.textContent = cfg.custom_css; - document.head.appendChild(style); - } }) .catch(() => {}); @@ -94,6 +146,14 @@ .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(() => { void lbSort; get<{ leaderboard: typeof leaderboard }>(`/api/v1/leaderboard?sort=${lbSort}`) @@ -112,11 +172,13 @@ const restLb = $derived(leaderboard.slice(3, 10)); + + {config?.brand_name ?? 'SCOPENET'} · Official Server & Launcher -
+