Files
SCOPENET-MC/panel/web/src/pages/Branding.svelte
T
Claude 1b1bb984bc Restyle to a clean, flat look and rename brand to SCOPENET
- Brand is now SCOPENET everywhere (defaults, UI, installer, docs)
- Launcher: solid backgrounds (no animated orbs/grain), flat Play button
  without gradient/shine/glow, quieter rail with a simple active
  indicator, smaller type scale, Inter as default font, glass off by
  default, restyled sliders
- Panel: same flat treatment; live preview mirrors the new launcher
- New flat scope-reticle logo and app icons
- Calmer default palette
- Fix: lock the shared cargo cache in the Docker build (amd64/arm64 raced)
- Fix: Windows-only clippy lint in sys.rs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
2026-09-28 05:30:19 +00:00

253 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { Save, LoaderCircle, Plus, Trash2, GripVertical, Pin, Sparkles, RotateCcw } from '@lucide/svelte';
import ColorInput from '../components/ColorInput.svelte';
import ImageInput from '../components/ImageInput.svelte';
import LauncherPreview from '../components/LauncherPreview.svelte';
import Toggle from '../components/Toggle.svelte';
import { get, put } from '../lib/api';
import { toast, toastError } from '../lib/toast.svelte';
import type { Branding } from '../lib/types';
let { onsaved }: { onsaved?: (b: Branding) => void } = $props();
let b = $state<Branding | null>(null);
let saved = $state('');
let saving = $state(false);
let section = $state('identity');
$effect(() => {
get<Branding>('/api/admin/branding').then((x) => {
b = x;
saved = JSON.stringify(x);
});
});
const dirty = $derived(!!b && JSON.stringify(b) !== saved);
async function save() {
if (!b) return;
saving = true;
try {
const r = await put<Branding>('/api/admin/branding', $state.snapshot(b));
saved = JSON.stringify(r);
onsaved?.(r);
toast('Published — launchers pick it up on their next refresh');
} catch (e) {
toastError(e);
} finally {
saving = false;
}
}
const presets = [
{ name: 'SCOPENET', c: { accent: '#6d6af5', accent_2: '#8b88f8', background: '#0d0e12', surface: '#16181e', text: '#ecedf1', muted: '#8b8f9a' } },
{ name: 'Nebula', c: { accent: '#7c5cff', accent_2: '#22d3ee', background: '#0b0d17', surface: '#141828', text: '#eef0ff', muted: '#8b90b3' } },
{ name: 'Creeper', c: { accent: '#22c55e', accent_2: '#a3e635', background: '#07110b', surface: '#0f1d15', text: '#ecfdf3', muted: '#86a893' } },
{ name: 'Nether', c: { accent: '#f97316', accent_2: '#ef4444', background: '#130806', surface: '#22100c', text: '#fff1eb', muted: '#b48a7e' } },
{ name: 'Ocean', c: { accent: '#0ea5e9', accent_2: '#2dd4bf', background: '#06111a', surface: '#0c1d2b', text: '#e8f6ff', muted: '#7ea1b8' } },
{ name: 'Sakura', c: { accent: '#ec4899', accent_2: '#f9a8d4', background: '#140a12', surface: '#22121e', text: '#fff0f8', muted: '#b88aa6' } },
{ name: 'End', c: { accent: '#a855f7', accent_2: '#facc15', background: '#0d0a14', surface: '#18122a', text: '#f5f0ff', muted: '#9d8fbd' } },
{ name: 'Mono', c: { accent: '#e5e7eb', accent_2: '#9ca3af', background: '#0a0a0a', surface: '#161616', text: '#fafafa', muted: '#8a8a8a' } },
];
function applyPreset(p: (typeof presets)[number]) {
if (b) b.colors = { ...b.colors, ...p.c };
}
const fonts = ['Outfit', 'Inter', 'Space Grotesk', 'Plus Jakarta Sans'];
const linkIcons = ['discord', 'website', 'store', 'youtube', 'twitter', 'twitch', 'github'];
function addNews() {
b?.news.unshift({ id: crypto.randomUUID().slice(0, 8), title: '', body: '', image_url: null, link: null, date: new Date().toISOString().slice(0, 10), pinned: false, tag: 'News' });
}
let dragIndex = $state<number | null>(null);
function dropNews(i: number) {
if (!b || dragIndex === null || dragIndex === i) return;
const [item] = b.news.splice(dragIndex, 1);
b.news.splice(i, 0, item);
dragIndex = null;
}
const sections = [
{ id: 'identity', label: 'Identity' },
{ id: 'colors', label: 'Colours' },
{ id: 'background', label: 'Background' },
{ id: 'style', label: 'Style' },
{ id: 'news', label: 'News' },
{ id: 'links', label: 'Links' },
{ id: 'features', label: 'Features' },
{ id: 'css', label: 'Custom CSS' },
];
</script>
<div class="page wide">
<div class="page-head">
<div>
<h1>Launcher design</h1>
<p>Everything here updates every player's launcher live — no rebuild or reinstall.</p>
</div>
<div class="row">
{#if dirty}<button class="ghost" onclick={() => (b = JSON.parse(saved))}><RotateCcw size={16} /> Discard</button>{/if}
<button class="primary" disabled={!dirty || saving} onclick={save}>
{#if saving}<LoaderCircle class="spin" size={16} />{:else}<Save size={16} />{/if} Publish
</button>
</div>
</div>
{#if b}
<div class="layout">
<div class="editor">
<div class="tabs">
{#each sections as s}<button class:active={section === s.id} onclick={() => (section = s.id)}>{s.label}</button>{/each}
</div>
{#if section === 'identity'}
<div class="card col">
<label class="field">Launcher name<input bind:value={b.name} maxlength="40" /></label>
<label class="field">Tagline<input bind:value={b.tagline} maxlength="120" /></label>
<ImageInput bind:value={b.logo_url} label="Logo" help="Shown in the title bar and sign-in screen. Transparent PNG works best." />
<ImageInput bind:value={b.icon_url} label="Avatar / small icon" />
<label class="field">In-game version label <span class="help">Appears on the Minecraft title screen and F3 menu.</span>
<input bind:value={b.version_label} maxlength="32" />
</label>
</div>
{:else if section === 'colors'}
<div class="card col">
<div class="presets">
{#each presets as p}
<button class="preset" onclick={() => applyPreset(p)} title={p.name}>
<span class="sw" style:background={p.c.background}><i style:background={p.c.accent}></i><i style:background={p.c.surface}></i></span>
<span>{p.name}</span>
</button>
{/each}
</div>
<div class="colors">
<ColorInput bind:value={b.colors.accent} label="Accent" />
<ColorInput bind:value={b.colors.accent_2} label="Accent 2" />
<ColorInput bind:value={b.colors.background} label="Background" />
<ColorInput bind:value={b.colors.surface} label="Surface" />
<ColorInput bind:value={b.colors.text} label="Text" />
<ColorInput bind:value={b.colors.muted} label="Muted text" />
<ColorInput bind:value={b.colors.success} label="Success" />
<ColorInput bind:value={b.colors.danger} label="Danger" />
</div>
</div>
{:else if section === 'background'}
<div class="card col">
<div class="segmented">
{#each ['gradient', 'image', 'video'] as k}
<button class:active={b.background.kind === k} onclick={() => b && (b.background.kind = k as Branding['background']['kind'])}>{k === 'gradient' ? 'Solid colour' : k[0].toUpperCase() + k.slice(1)}</button>
{/each}
</div>
{#if b.background.kind !== 'gradient'}
<ImageInput bind:value={b.background.url} label={b.background.kind === 'video' ? 'Video (mp4/webm)' : 'Image'} video={b.background.kind === 'video'}
help={b.background.kind === 'video' ? 'Keep it short and under ~10 MB. Players can turn video off to save resources.' : 'At least 1600×900 looks sharp.'} />
{:else}
<p class="muted small">A plain background in your background colour. Clean, and the lightest option on player PCs.</p>
{/if}
<label class="field"><span class="row">Blur <span class="spacer"></span>{b.background.blur}px</span><input type="range" min="0" max="30" bind:value={b.background.blur} /></label>
<label class="field"><span class="row">Darken <span class="spacer"></span>{b.background.dim}%</span><input type="range" min="0" max="90" bind:value={b.background.dim} /></label>
</div>
{:else if section === 'style'}
<div class="card col">
<div class="field">
<span class="lbl">Font</span>
<div class="fonts">
{#each fonts as f}
<button class="font" class:active={b.font === f} style:font-family="'{f} Variable', '{f}', sans-serif" onclick={() => b && (b.font = f)}>
<span class="aa">Aa</span><span>{f}</span>
</button>
{/each}
</div>
</div>
<label class="field"><span class="row">Corner radius <span class="spacer"></span>{b.radius}px</span><input type="range" min="0" max="28" bind:value={b.radius} /></label>
<Toggle bind:checked={b.glass} label="Frosted glass" help="Translucent, blurred panels over image/video backgrounds. Off keeps panels solid." />
</div>
{:else if section === 'news'}
<div class="col">
<button class="add" onclick={addNews}><Plus size={16} /> Add a post</button>
{#each b.news as n, i (n.id)}
<div class="card col news" draggable="true" role="listitem"
ondragstart={() => (dragIndex = i)} ondragover={(e) => e.preventDefault()} ondrop={() => dropNews(i)}>
<div class="row">
<span class="grip" title="Drag to reorder"><GripVertical size={16} /></span>
<input class="title" bind:value={n.title} placeholder="Headline" />
<button class="ghost icon" class:pinned={n.pinned} title="Pin to top" aria-label="Pin" onclick={() => (n.pinned = !n.pinned)}><Pin size={15} /></button>
<button class="ghost icon" aria-label="Delete post" onclick={() => b?.news.splice(i, 1)}><Trash2 size={15} /></button>
</div>
<textarea bind:value={n.body} rows="3" placeholder="What's new?"></textarea>
<div class="grid three">
<label class="field">Tag<input bind:value={n.tag} placeholder="Update" /></label>
<label class="field">Date<input type="date" bind:value={n.date} /></label>
<label class="field">Link<input bind:value={n.link} placeholder="https://…" /></label>
</div>
<ImageInput bind:value={n.image_url} label="Image" />
</div>
{:else}
<div class="card empty"><Sparkles size={28} /><h3>No news yet</h3><p>Posts show up on the launcher home screen.</p></div>
{/each}
</div>
{:else if section === 'links'}
<div class="card col">
{#each b.links as l, i}
<div class="row link">
<select bind:value={l.icon} style="width: 130px">{#each linkIcons as ic}<option value={ic}>{ic}</option>{/each}</select>
<input bind:value={l.label} placeholder="Label" style="width: 150px" />
<input bind:value={l.url} placeholder="https://discord.gg/…" />
<button class="ghost icon" aria-label="Remove link" onclick={() => b?.links.splice(i, 1)}><Trash2 size={15} /></button>
</div>
{/each}
<button class="add" onclick={() => b?.links.push({ icon: 'discord', label: 'Discord', url: '' })}><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." />
<Toggle bind:checked={b.features.server_status} label="Live server status" help="Player count, MOTD and ping for each instance's server." />
<Toggle bind:checked={b.features.allow_user_theme} label="Let players change the theme" help="Players can pick their own accent colour and turn off effects." />
<Toggle bind:checked={b.features.allow_advanced_java} label="Advanced Java settings" help="Let players set custom JVM arguments and Java paths." />
</div>
{:else if section === 'css'}
<div class="card col">
<p class="muted small">Injected into the launcher after the built-in styles. Useful variables: <code>--accent</code>, <code>--surface</code>, <code>--radius</code>.</p>
<textarea class="mono css" bind:value={b.custom_css} rows="16" spellcheck="false" placeholder={'.play-button {\n letter-spacing: 0.2em;\n}'}></textarea>
</div>
{/if}
</div>
<div class="preview">
<div class="sticky">
<span class="tiny muted label">Live preview</span>
<LauncherPreview {b} />
</div>
</div>
</div>
{/if}
</div>
<style>
.wide { max-width: 1500px; }
.layout { display: grid; grid-template-columns: minmax(380px, 520px) 1fr; gap: 28px; align-items: start; }
@media (max-width: 1150px) { .layout { grid-template-columns: 1fr; } .preview { order: -1; } }
.editor .tabs { margin-bottom: 16px; }
.sticky { position: sticky; top: 24px; display: flex; flex-direction: column; gap: 10px; }
.label { text-transform: uppercase; letter-spacing: 0.1em; font-weight: 600; }
.field { display: flex; flex-direction: column; gap: 7px; }
.lbl { font-size: 0.85rem; color: var(--text-2); font-weight: 500; }
.presets { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; }
.preset { flex-direction: column; gap: 6px; padding: 10px; background: var(--bg-2); font-size: 0.8rem; position: relative; }
.sw { width: 100%; height: 28px; border-radius: 6px; display: flex; gap: 4px; align-items: center; padding: 0 6px; border: 1px solid var(--line); }
.sw i { width: 14px; height: 14px; border-radius: 4px; }
.colors { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.fonts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.font { flex-direction: column; align-items: flex-start; gap: 2px; padding: 12px 14px; background: var(--bg-2); font-weight: 400; }
.font .aa { font-size: 1.6rem; font-weight: 700; }
.font.active { border-color: var(--accent); background: var(--accent-soft); }
.add { border-style: dashed; background: transparent; color: var(--text-2); }
.news .title { font-weight: 600; }
.grip { cursor: grab; color: var(--muted); display: flex; }
.pinned { color: var(--accent) !important; }
.three { grid-template-columns: 1fr 1fr 1.4fr; }
.css { min-height: 300px; font-size: 0.85rem; }
</style>