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

+35
View File
@@ -66,6 +66,30 @@ pub struct LandingBlock {
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 {
fn default() -> Self {
Self {
@@ -98,6 +122,7 @@ pub struct LandingConfig {
pub blocks: Vec<LandingBlock>,
pub faqs: Vec<FaqItem>,
pub custom_css: String,
pub theme: LandingTheme,
}
impl Default for LandingConfig {
@@ -145,6 +170,7 @@ impl Default for LandingConfig {
},
],
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>> {
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?;
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["enabled"], true);
assert!(v["blocks"].as_array().unwrap().len() >= 5);
assert_eq!(v["theme"]["accent"], "#6d6af5");
// 2. Unauthenticated user cannot modify landing config
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();
updated["brand_name"] = json!("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;
assert_eq!(s, StatusCode::OK);
assert_eq!(v2["brand_name"], "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
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 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); });
let checking = $state(!!session.token);
@@ -58,7 +59,7 @@
{#if checking || landingEnabled === null}
<div class="boot"><span class="pulse"></span></div>
{:else if route.name === 'landing' && landingEnabled}
{:else if route.name === 'landing' && (landingEnabled || (preview && !!session.user))}
<PublicLanding />
{:else if !session.user}
{#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;
news: NewsItem[];
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 };
custom_css: string;
}
@@ -156,7 +157,7 @@ export interface FaqItem {
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 {
id: string;
@@ -184,6 +185,7 @@ export interface LandingConfig {
blocks: LandingBlock[];
faqs: FaqItem[];
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 {
@@ -362,4 +364,3 @@ export interface UserProfileView {
stats: PlayerStats | null;
created_at: string;
}
+17
View File
@@ -76,6 +76,7 @@
{ id: 'style', label: 'Style' },
{ id: 'news', label: 'News' },
{ id: 'links', label: 'Links' },
{ id: 'about', label: 'About tab' },
{ id: 'features', label: 'Features' },
{ id: 'css', label: 'Custom CSS' },
];
@@ -200,6 +201,22 @@
{/each}
<button class="add" onclick={() => b?.links.push({ icon: 'discord', label: 'Discord', url: '' })}><Plus size={16} /> Add link</button>
</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'}
<div class="card col">
<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">
import {
Save, LoaderCircle, Plus, Trash2, ArrowUp, ArrowDown, ExternalLink,
Upload, Globe, Sparkles, HelpCircle, Server, Trophy, BarChart3,
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 { ArrowDown, ArrowUp, Copy, Download, ExternalLink, Eye, EyeOff, GripVertical, LoaderCircle, Plus, RotateCcw, Save, Trash2, Upload } from '@lucide/svelte';
import { api, get, put, formatBytes, uploadMedia } from '../lib/api';
import ImageInput from '../components/ImageInput.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 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 uploading = $state(false);
let activeTab = $state<'blocks' | 'downloads' | 'faq' | 'css'>('blocks');
let openBlocks = $state<Record<string, boolean>>({});
function toggleBlockOptions(id: string) {
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' },
};
let uploadingMedia = $state(false);
let dragFrom = $state<number | null>(null);
let frame = $state<HTMLIFrameElement | null>(null);
const previewUrl = `${location.pathname}?landingPreview=1#/landing`;
$effect(() => {
get<LandingConfig>('/api/admin/landing')
.then((cfg) => {
config = cfg;
saved = JSON.stringify(cfg);
})
.catch((e) => toastError(e));
get<LandingConfig>('/api/admin/landing').then((cfg) => {
cfg.blocks.forEach(upgradeLandingBlock);
config = cfg;
saved = JSON.stringify(cfg);
selectedId = cfg.blocks[0]?.id ?? null;
}).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 winDownload = $derived(config?.hosted_downloads.find((d) => d.platform === 'windows'));
const macDownload = $derived(config?.hosted_downloads.find((d) => d.platform === 'mac'));
const linuxDownload = $derived(config?.hosted_downloads.find((d) => d.platform === 'linux'));
$effect(() => {
if (!config || !frame) return;
JSON.stringify(config);
frame.contentWindow?.postMessage({ type: 'scopenet-landing-preview', config: $state.snapshot(config) }, location.origin);
});
async function save() {
if (!config) return;
saving = true;
try {
const res = await put<LandingConfig>('/api/admin/landing', $state.snapshot(config));
config = res;
saved = JSON.stringify(res);
toast('Landing page updated successfully');
} catch (e) {
toastError(e);
} finally {
saving = false;
}
const updated = await put<LandingConfig>('/api/admin/landing', $state.snapshot(config));
updated.blocks.forEach(upgradeLandingBlock);
config = updated;
saved = JSON.stringify(updated);
toast('Landing page published');
} catch (e) { toastError(e); }
finally { saving = false; }
}
function moveBlock(index: number, dir: -1 | 1) {
function add(type: BlockType) {
if (!config) return;
const target = index + dir;
if (target < 0 || target >= config.blocks.length) return;
const item = config.blocks.splice(index, 1)[0];
config.blocks.splice(target, 0, item);
const block = createLandingBlock(type);
config.blocks.push(block);
selectedId = block.id;
galleryOpen = false;
}
async function uploadInstaller(platform: string, label: string, file: File | undefined) {
if (!file) return;
function duplicate(index: number) {
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;
try {
const form = new FormData();
form.append('platform', platform);
form.append('label', label);
form.append('label', platform[0].toUpperCase() + platform.slice(1));
form.append('file', file);
const res = await api<LandingConfig>('/api/admin/landing/upload-launcher', {
method: 'POST',
form,
});
config = res;
saved = JSON.stringify(res);
toast(`Uploaded ${label} (${formatBytes(file.size)})`);
} catch (e) {
toastError(e);
} finally {
uploading = false;
}
const updated = await api<LandingConfig>('/api/admin/landing/upload-launcher', { method: 'POST', form });
const prior = JSON.parse(saved) as LandingConfig;
prior.hosted_downloads = updated.hosted_downloads;
saved = JSON.stringify(prior);
config.hosted_downloads = updated.hosted_downloads;
toast(`Uploaded ${file.name} (${formatBytes(file.size)})`);
} catch (e) { toastError(e); }
finally { uploading = false; }
}
function addFaq() {
if (!config) return;
config.faqs.push({
id: 'faq_' + Math.random().toString(36).slice(2, 9),
question: 'New Question',
answer: 'Write the answer here…',
});
function setOption(block: LandingBlock, key: string, value: unknown) {
block.options ??= {};
block.options[key] = value;
}
function removeFaq(idx: number) {
if (!config) return;
config.faqs.splice(idx, 1);
async function uploadSectionMedia(file: File | undefined, apply: (url: string) => void) {
if (!file) return;
uploadingMedia = true;
try { apply(await uploadMedia(file)); }
catch (e) { toastError(e); }
finally { uploadingMedia = false; }
}
</script>
{#if !config}
<div class="center-loading"><LoaderCircle size={28} class="spin" /></div>
<div class="center-loading"><LoaderCircle class="spin" size={28} /></div>
{:else}
<div class="page">
<header class="header">
<div class="title-area">
<div class="row align-center gap-sm">
<h1>Landing Page Builder</h1>
<span class="badge" class:on={config.enabled} class:off={!config.enabled}>
{config.enabled ? 'Live' : 'Disabled'}
</span>
</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 class="builder">
<header class="builder-head">
<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="head-actions">
<label class="enabled"><input type="checkbox" bind:checked={config.enabled} /> Public page enabled</label>
<a class="btn secondary" href="#/landing" target="_blank"><ExternalLink size={15} /> Live page</a>
<button class="btn secondary" disabled={!dirty} onclick={() => { config = JSON.parse(saved); selectedId = config?.blocks[0]?.id ?? null; }}><RotateCcw size={15} /> Discard</button>
<button class="btn primary" disabled={!dirty || saving} onclick={save}>{#if saving}<LoaderCircle class="spin" size={15} />{:else}<Save size={15} />{/if} Publish</button>
</div>
</header>
<div class="tabs-bar">
<button class:active={activeTab === 'blocks'} onclick={() => (activeTab = 'blocks')}>
<Sparkles size={16} /> Modular Blocks
</button>
<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 class="studio-tabs">
{#each [['sections', 'Sections'], ['design', 'Design'], ['downloads', 'Downloads'], ['faq', 'FAQ'], ['advanced', 'Advanced']] as [id, label]}
<button class:active={tab === id} onclick={() => (tab = id as typeof tab)}>{label}</button>
{/each}
</div>
<div class="content">
{#if activeTab === 'blocks'}
<div class="col gap-lg">
<!-- Global Settings Card -->
<div class="card">
<h3>General & Hero Settings</h3>
<div class="grid-2">
<Toggle bind:checked={config.enabled} label="Enable Public Landing Page" help="When enabled, visitors to your domain see this landing page." />
<label class="field">
<span>Brand Name</span>
<input bind:value={config.brand_name} placeholder="e.g. SCOPENET" />
</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>
{#if tab === 'sections'}
<div class="studio-grid">
<aside class="section-list 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>
{#if galleryOpen}
<div class="gallery">
{#each BLOCK_GROUPS as group}
<strong class="group-label">{group}</strong>
{#each Object.values(BLOCK_SCHEMAS).filter((s) => s.group === group) as item}
<button class="gallery-item" onclick={() => add(item.type)}><strong>{item.label}</strong><small>{item.description}</small></button>
{/each}
{/each}
</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>
{:else if activeTab === 'downloads'}
<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>
{#if !config.blocks.length}<p class="empty">Start with a Hero, then add any sections you need.</p>{/if}
</aside>
<div class="downloads-grid">
<!-- Windows -->
<div class="platform-card">
<div class="platform-header">
<strong>Windows (.exe / .msi)</strong>
{#if winDownload}<span class="badge on">Available</span>{:else}<span class="badge off">Not uploaded</span>{/if}
</div>
{#if winDownload}
<div class="file-details tiny muted">
<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 class="inspector card">
{#if selected && schema}
{@const index = config.blocks.findIndex((b) => b.id === selected.id)}
<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>
<div class="section-actions">
<button class="btn secondary sm" disabled={index === 0} onclick={() => move(index, index - 1)}><ArrowUp size={14} /> Up</button>
<button class="btn secondary sm" disabled={index === config.blocks.length - 1} onclick={() => move(index, index + 1)}><ArrowDown size={14} /> Down</button>
<button class="btn secondary sm" onclick={() => duplicate(index)}><Copy size={14} /> Duplicate</button>
<button class="btn secondary sm danger" onclick={() => remove(index)}><Trash2 size={14} /> Delete</button>
</div>
<div class="divider"></div>
<label class="field">
<span>External Launcher Download URL (Fallback)</span>
<input bind:value={config.external_download_url} placeholder="https://github.com/.../releases/latest" />
<span class="help">If no hosted file matches the visitor's operating system, they will be sent to this link.</span>
</label>
</div>
</div>
{:else if activeTab === 'faq'}
<div class="col gap-lg">
<div class="card">
<div class="row justify-between align-center">
<div>
<h3>Frequently Asked Questions</h3>
<p class="muted small">Add common questions and helpful answers for your community.</p>
</div>
<button class="btn secondary sm" onclick={addFaq}>
<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>
<fieldset><legend>Content</legend>
<label>Section title<input bind:value={selected.title} placeholder={schema.label} /></label>
<label>Subtitle<textarea bind:value={selected.subtitle} rows="2"></textarea></label>
{#each schema.fields as field (field.key)}
{#if field.type === 'list'}
<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>
{#each (selected.options?.[field.key] ?? []) as item, itemIndex}
<div class="item-editor">
<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>
{#each field.items ?? [] as sub}
<label>{sub.label}
{#if sub.type === 'textarea'}<textarea rows="2" value={item[sub.key] ?? ''} oninput={(e) => (item[sub.key] = e.currentTarget.value)}></textarea>
{: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>
{:else}<input type={sub.type === 'url' || sub.type === 'media' ? 'url' : 'text'} value={item[sub.key] ?? ''} oninput={(e) => (item[sub.key] = e.currentTarget.value)} />{/if}
</label>
{#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}
{/each}
</div>
{/each}
</div>
<label class="field">
<span>Question</span>
<input bind:value={faq.question} placeholder="e.g. How do I join the server?" />
</label>
<label class="field">
<span>Answer</span>
<textarea bind:value={faq.answer} rows="2" placeholder="Explain the answer clearly…"></textarea>
</label>
</div>
{:else}
<p class="muted small">No questions yet. Click "Add FAQ Item" above.</p>
{:else if field.type === 'toggle'}
<label class="check"><input type="checkbox" checked={selected.options?.[field.key] ?? false} onchange={(e) => setOption(selected, field.key, e.currentTarget.checked)} /> {field.label}</label>
{:else}<label>{field.label}
{#if field.type === 'textarea'}<textarea rows="4" value={selected.options?.[field.key] ?? ''} oninput={(e) => setOption(selected, field.key, e.currentTarget.value)}></textarea>
{: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>
{: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}
</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}
{/each}
</div>
</div>
</fieldset>
<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>
{:else if activeTab === 'css'}
<div class="col gap-lg">
<div class="card">
<h3>Custom CSS</h3>
<p class="muted small">Add CSS rules to override fonts, colors, animations, or styling on the public landing page.</p>
<textarea bind:value={config.custom_css} rows="12" class="mono code-box" placeholder="/* Custom styling for landing page */"></textarea>
</div>
</div>
{/if}
</div>
<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>
{:else if tab === 'design'}
<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>
<div class="two"><label>Brand name<input bind:value={config.brand_name} /></label><ImageInput bind:value={config.logo_url} label="Brand logo" /></div>
<label>Tagline<input bind:value={config.tagline} /></label>
<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>
<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>
<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>
{/if}
<style>
.page { padding: 24px 32px 64px; max-width: 1080px; margin: 0 auto; 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; }
.title-area h1 { font-size: 24px; font-weight: 700; margin: 0; }
.sub { color: var(--muted); font-size: 14px; margin-top: 4px; }
.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; }
.badge.on { background: color-mix(in srgb, var(--success) 15%, transparent); color: var(--success); }
.badge.off { background: color-mix(in srgb, var(--danger) 15%, transparent); color: var(--danger); }
.tabs-bar { display: flex; gap: 8px; border-bottom: 1px solid var(--line); padding-bottom: 2px; }
.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; }
.tabs-bar button:hover { color: var(--text); background: var(--surface-2); }
.tabs-bar button.active { color: var(--text); background: var(--surface-2); font-weight: 650; }
.card { padding: 20px 24px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); display: flex; flex-direction: column; gap: 16px; }
.card h3 { margin: 0; font-size: 17px; font-weight: 650; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; font-size: 13px; font-weight: 560; }
.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; }
.field input:focus, .field textarea:focus { border-color: var(--accent); outline: none; }
.blocks-list { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
.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; }
.block-item-container.disabled { opacity: 0.55; }
.block-row { display: flex; align-items: center; gap: 12px; padding: 12px 14px; }
.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; }
.module-fields { border-top: 1px solid var(--line); padding-top: 12px; }
.badge-sub { font-style: italic; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
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; }
select:focus { border-color: var(--accent); }
.block-reorder { display: flex; flex-direction: column; gap: 2px; }
.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; }
.block-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; cursor: pointer; }
.block-type-tag { text-transform: uppercase; font-size: 10px; letter-spacing: 0.05em; }
.sm-input { width: 180px; padding: 6px 10px; font-size: 12px; }
.downloads-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; margin-top: 6px; }
.platform-card { padding: 16px; border-radius: var(--radius-sm); background: var(--bg); border: 1px solid var(--line); display: flex; flex-direction: column; gap: 10px; }
.platform-header { display: flex; justify-content: space-between; align-items: center; font-size: 14px; }
.file-details { display: flex; flex-direction: column; gap: 2px; }
.file-upload-btn { position: relative; overflow: hidden; cursor: pointer; text-align: center; }
.file-upload-btn input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.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); }
.builder { max-width: 1800px; margin: 0 auto; padding: 24px; display: flex; flex-direction: column; gap: 20px; }
.builder-head, .head-actions, .studio-tabs, .list-head, .section-actions, .download-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.builder-head { align-items: flex-start; flex-wrap: wrap; }
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; }
.head-actions { flex-wrap: wrap; justify-content: flex-end; } .enabled { display: flex; align-items: center; gap: 6px; white-space: nowrap; font-size: .84rem; }
.studio-tabs { justify-content: flex-start; border-bottom: 1px solid var(--line); gap: 4px; overflow-x: auto; }
.studio-tabs button { border: 0; border-radius: 7px 7px 0 0; padding: 10px 16px; background: transparent; color: var(--text-2); }
.studio-tabs button.active { color: var(--text); background: var(--bg-2); box-shadow: inset 0 -2px var(--accent); }
.studio-grid { display: grid; grid-template-columns: minmax(220px, 270px) minmax(350px, 470px) minmax(350px, 1fr); gap: 16px; align-items: start; }
.card { background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; padding: 18px; min-width: 0; }
.section-list, .inspector, .preview { min-height: 450px; } .section-list { position: sticky; top: 12px; max-height: calc(100vh - 100px); overflow-y: auto; }
.list-head { margin-bottom: 12px; } .list-head .muted { font-weight: 400; color: var(--text-2); font-size: .8rem; }
.list-items { display: flex; flex-direction: column; gap: 5px; }
.list-item { display: flex; align-items: center; border: 1px solid transparent; border-radius: 9px; background: var(--bg); }
.list-item.selected { border-color: var(--accent); } .list-item.muted-block { opacity: .55; }
.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); }
.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); }
.icon-action { padding: 6px; border: 0; background: transparent; color: var(--text-2); } .icon-action:hover { color: var(--text); } .danger { color: #e5484d; }
.gallery { max-height: 350px; overflow-y: auto; border: 1px solid var(--line); border-radius: 8px; padding: 8px; margin-bottom: 12px; background: var(--bg); }
.group-label { display: block; color: var(--accent); font-size: .68rem; letter-spacing: .09em; text-transform: uppercase; margin: 10px 5px 4px; }
.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); }
.gallery-item:hover { background: var(--bg-2); } .gallery-item small { color: var(--text-2); line-height: 1.35; }
.inspector-head { margin-bottom: 12px; } .eyebrow { font-size: .7rem; color: var(--accent); text-transform: uppercase; letter-spacing: .08em; }
.section-actions { justify-content: flex-start; flex-wrap: wrap; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
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; }
label { display: flex; flex-direction: column; gap: 5px; font-size: .82rem; font-weight: 550; } label.check { flex-direction: row; align-items: center; }
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; }
input[type='checkbox'], input[type='color'] { width: auto; } input[type='color'] { min-width: 64px; height: 38px; padding: 2px; }
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)); }
.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); }
.preview { position: sticky; top: 12px; } .preview iframe { border: 1px solid var(--line); border-radius: 8px; width: 100%; height: min(72vh, 850px); background: white; }
.settings-grid { width: min(100%, 850px); display: flex; flex-direction: column; gap: 14px; } .settings-grid h2 { margin-bottom: 0; }
.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; }
.code { font-family: monospace; min-height: 300px; } .empty { color: var(--text-2); padding: 24px 8px; }
.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); }
@media (max-width: 1250px) { .studio-grid { grid-template-columns: 240px minmax(350px, 1fr); } .preview { grid-column: 1 / -1; position: static; } }
@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; } }
</style>
+230 -75
View File
@@ -21,6 +21,63 @@
let copied = $state(false);
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
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase() : '';
@@ -54,11 +111,6 @@
get<LandingConfig>('/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));
</script>
<svelte:window onmessage={previewMessage} />
<svelte:head>
<title>{config?.brand_name ?? 'SCOPENET'} · Official Server & Launcher</title>
</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 -->
<header class="navbar">
<div class="nav-container">
@@ -130,10 +192,10 @@
</div>
<nav class="nav-links">
<button type="button" class="nav-link-btn" onclick={() => scrollToSection('servers')}>Servers</button>
<button type="button" class="nav-link-btn" onclick={() => scrollToSection('leaderboard')}>Leaderboard</button>
<button type="button" class="nav-link-btn" onclick={() => scrollToSection('download')}>Downloads</button>
<button type="button" class="nav-link-btn" onclick={() => scrollToSection('faq')}>FAQ</button>
{#if firstSection('servers')}<button type="button" class="nav-link-btn" onclick={() => scrollToSection(firstSection('servers')!)}>Servers</button>{/if}
{#if firstSection('leaderboard')}<button type="button" class="nav-link-btn" onclick={() => scrollToSection(firstSection('leaderboard')!)}>Leaderboard</button>{/if}
{#if firstSection('download')}<button type="button" class="nav-link-btn" onclick={() => scrollToSection(firstSection('download')!)}>Downloads</button>{/if}
{#if firstSection('faq')}<button type="button" class="nav-link-btn" onclick={() => scrollToSection(firstSection('faq')!)}>FAQ</button>{/if}
</nav>
<div class="nav-actions">
@@ -164,9 +226,11 @@
<main class="blocks">
{#if config}
{#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'}
<!-- 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="container hero-content">
<div class="hero-badge">
@@ -179,7 +243,7 @@
<div class="hero-cta-group">
{#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} />
<div class="cta-text-group">
<span class="cta-main">{block.options?.cta_text || config.hero_cta_text}</span>
@@ -194,7 +258,7 @@
</div>
</a>
{: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} />
<div class="cta-text-group">
<span class="cta-main">{block.options?.cta_text || config.hero_cta_text}</span>
@@ -202,7 +266,7 @@
</div>
</a>
{: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} />
<span class="cta-main">{block.options?.cta_text || config.hero_cta_text}</span>
</button>
@@ -214,12 +278,13 @@
</button>
{/if}
</div>
{#if mediaUrl(block.options?.image_url)}<img class="hero-image" src={mediaUrl(block.options?.image_url)} alt="" />{/if}
</div>
</section>
{:else if block.type === 'download'}
<!-- Downloads Section -->
<section class="section" id="download">
<section class="section" id={sectionId(block)}>
<div class="container">
<div class="section-head">
<h2>{block.title || 'Get the Launcher'}</h2>
@@ -249,7 +314,7 @@
<div class="download-card full-span">
<h3>Download SCOPENET Launcher</h3>
<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} />
</a>
</div>
@@ -263,7 +328,7 @@
{:else if block.type === 'servers'}
<!-- Servers Section -->
<section class="section alt-bg" id="servers">
<section class="section alt-bg" id={sectionId(block)}>
<div class="container">
<div class="section-head">
<h2>{block.title || 'Live Game Servers'}</h2>
@@ -305,7 +370,7 @@
{:else if block.type === 'leaderboard'}
<!-- Leaderboard Section -->
<section class="section" id="leaderboard">
<section class="section" id={sectionId(block)}>
<div class="container">
<div class="section-head">
<h2>{block.title || 'Community Leaderboards'}</h2>
@@ -444,7 +509,7 @@
{:else if block.type === 'stats'}
<!-- Stats Highlights Section -->
<section class="section alt-bg" id="stats">
<section class="section alt-bg" id={sectionId(block)}>
<div class="container">
<div class="section-head">
<h2>{block.title || 'Network Highlights'}</h2>
@@ -487,7 +552,7 @@
{:else if block.type === 'instances'}
<!-- Instances Showcase Section -->
{#if instances.length}
<section class="section" id="instances">
<section class="section" id={sectionId(block)}>
<div class="container">
<div class="section-head">
<h2>{block.title || 'Available Instances'}</h2>
@@ -495,7 +560,7 @@
</div>
<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-top">
<span class="badge mc-tag">{inst.mc_version}</span>
@@ -516,7 +581,7 @@
{:else if block.type === 'news'}
<!-- News Section -->
{#if news.length}
<section class="section alt-bg" id="news">
<section class="section alt-bg" id={sectionId(block)}>
<div class="container">
<div class="section-head">
<h2>{block.title || 'Latest News'}</h2>
@@ -524,7 +589,7 @@
</div>
<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">
{#if item.tag}<span class="news-tag">{item.tag}</span>{/if}
<h3>{item.title}</h3>
@@ -539,7 +604,7 @@
{:else if block.type === 'faq'}
<!-- FAQ Section -->
<section class="section" id="faq">
<section class="section" id={sectionId(block)}>
<div class="container max-w-narrow">
<div class="section-head">
<h2>{block.title || 'Frequently Asked Questions'}</h2>
@@ -567,7 +632,7 @@
{:else if block.type === 'socials'}
<!-- Socials Block -->
<section class="section" id="socials">
<section class="section" id={sectionId(block)}>
<div class="container">
<div class="section-head">
<h2>{block.title || 'Join Our Community'}</h2>
@@ -576,29 +641,95 @@
<div class="socials-buttons-grid">
{#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
</a>
{/if}
{#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
</a>
{/if}
{#if block.options?.media_url}
<a href={block.options.media_url} target="_blank" rel="noreferrer" class="social-btn media">
{#if block.options?.video_url || block.options?.media_url}
<a href={safeLink(block.options.video_url || block.options.media_url)} target="_blank" rel="noreferrer" class="social-btn media">
<Flame size={18} /> YouTube / Twitch
</a>
{/if}
{#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
</a>
{/if}
</div>
</div>
</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}
</div>
{/each}
{/if}
</main>
@@ -611,7 +742,7 @@
<img src={config.logo_url} alt="" class="brand-img" />
{/if}
<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 class="footer-right tiny muted">
<span>Minecraft is a trademark of Mojang Studios. Not affiliated with Mojang or Microsoft.</span>
@@ -626,22 +757,43 @@
min-height: 100vh;
display: flex;
flex-direction: column;
background: #090a0f;
color: #f1f2f6;
background: var(--landing-bg);
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; }
.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; }
.brand-group { display: flex; align-items: center; gap: 10px; }
.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; }
.nav-links { display: flex; gap: 24px; }
.nav-link-btn {
background: transparent;
border: none;
color: #8b8f9a;
color: var(--landing-muted);
font-size: 14px;
font-weight: 500;
cursor: pointer;
@@ -649,93 +801,96 @@
transition: color 0.15s;
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; }
.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); }
.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; }
.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:hover { color: #f1f2f6; border-color: rgba(255, 255, 255, 0.3); }
.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: var(--landing-muted); text-decoration: none; font-size: 13px; font-weight: 500; }
.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-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-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-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; }
.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:hover { transform: translateY(-2px); background: #7c79ff; }
.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: color-mix(in srgb, var(--landing-accent) 84%, white); }
.cta-text-group { display: flex; flex-direction: column; text-align: left; }
.cta-main { font-size: 16px; line-height: 1.2; }
.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); }
.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-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; }
.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; }
.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.recommended { border-color: rgba(109, 106, 245, 0.5); background: rgba(109, 106, 245, 0.06); }
.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; }
.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 { 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: 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: var(--landing-accent); color: white; }
.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; }
.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:hover { background: #7c79ff; }
.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: color-mix(in srgb, var(--landing-accent) 84%, white); }
.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-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.online { color: #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; }
.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:hover { border-color: #6d6af5; }
.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: var(--landing-accent); }
.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.active { background: rgba(109, 106, 245, 0.15); border-color: #6d6af5; color: #f1f2f6; }
.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: 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-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 .medal { font-size: 26px; }
.podium-val { font-size: 18px; font-weight: 700; color: #6d6af5; }
.lb-table-wrap { background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 14px; overflow: hidden; }
.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: 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 { 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); }
.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; }
.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-num { font-size: 38px; font-weight: 800; color: #6d6af5; letter-spacing: -0.02em; }
.counter-label { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #8b8f9a; }
.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: var(--landing-accent); letter-spacing: -0.02em; }
.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; }
.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; }
.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-item { border-radius: 12px; 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-ans { padding: 0 20px 18px; color: #8b8f9a; line-height: 1.6; font-size: 14px; }
.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: var(--landing-text); font-size: 16px; font-weight: 600; cursor: pointer; text-align: left; }
.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-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; }
.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; }
.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.discord { background: #5865F2; }
.social-btn.store { background: #8b5cf6; }
.social-btn.media { background: #ef4444; }
.social-btn.twitter { background: #0284c7; }
@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; }
.hero-title { font-size: 34px; }
.hero-cta-group { flex-direction: column; width: 100%; }