Add Tauri launcher with Svelte UI
- Accounts: panel login/sign-up, Microsoft device-code sign-in, offline play; tokens encrypted with a key kept in the OS credential store - Home: instance rail, live server status, news feed, social links, Play button with download progress, speed and ETA - Full settings: accounts, memory/resolution/after-launch behaviour, Java and GC presets, Minecraft keybind editor applied to options.txt, theme overrides, storage manager, self-update via GitHub releases - Game console, crash dialog with hints, per-instance overrides - Live admin branding (colours, fonts, backgrounds, custom CSS) - Online install tests for vanilla/Fabric/Quilt/Forge/NeoForge (CI) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
60 files changed
+9424
-47
No files matched your search
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { SlidersHorizontal, SquareTerminal, Package, MessageCircle, Globe, ShoppingBag, CirclePlay, Tv, Code, AtSign, Link } from '@lucide/svelte';
|
||||
import PlayButton from '../components/PlayButton.svelte';
|
||||
import ServerStatus from '../components/ServerStatus.svelte';
|
||||
import NewsFeed from '../components/NewsFeed.svelte';
|
||||
import Console from '../components/Console.svelte';
|
||||
import { activeAccount, app, instances, selectedInstance } from '../lib/store.svelte';
|
||||
import { loaderName, shortLoaderVersion } from '../lib/format';
|
||||
import { openUrl } from '../lib/tauri';
|
||||
|
||||
const inst = $derived(selectedInstance());
|
||||
const b = $derived(app.manifest!.branding);
|
||||
// Lucide ships no brand logos, so each platform gets a recognisable generic icon.
|
||||
const linkIcon: Record<string, any> = { discord: MessageCircle, website: Globe, store: ShoppingBag, youtube: CirclePlay, twitch: Tv, github: Code, twitter: AtSign };
|
||||
const hour = new Date().getHours();
|
||||
const greet = hour < 5 ? 'Late night session' : hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
|
||||
</script>
|
||||
|
||||
<div class="home">
|
||||
{#if inst}
|
||||
{#key inst.id}
|
||||
<section class="hero">
|
||||
<p class="greet muted">{greet}, <strong>{activeAccount()?.username ?? 'player'}</strong></p>
|
||||
<div class="chips">
|
||||
<span class="chip">{inst.mc_version}</span>
|
||||
<span class="chip">{loaderName[inst.loader]} {shortLoaderVersion(inst.mc_version, inst.loader_version)}</span>
|
||||
{#if inst.file_count}<span class="chip"><Package size={12} /> {inst.file_count} mods & files</span>{/if}
|
||||
</div>
|
||||
<h1>{inst.name}</h1>
|
||||
<p class="desc">{inst.description || b.tagline}</p>
|
||||
</section>
|
||||
|
||||
<section class="actions">
|
||||
<PlayButton {inst} />
|
||||
<div class="row tools">
|
||||
<button class="glass tool" onclick={() => (app.instanceSettings = inst.id)} title="Instance settings"><SlidersHorizontal size={17} /></button>
|
||||
<button class="glass tool" class:on={app.consoleOpen} onclick={() => (app.consoleOpen = !app.consoleOpen)} title="Game console"><SquareTerminal size={17} /></button>
|
||||
{#if inst.server && b.features.server_status}<ServerStatus server={inst.server} />{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/key}
|
||||
{:else}
|
||||
<section class="hero empty">
|
||||
<h1>{b.name}</h1>
|
||||
<p class="desc">{instances().length === 0 ? "Nothing to play yet — your server admin hasn't published any instances you can access." : ''}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<aside class="side">
|
||||
{#if b.features.news && b.news.length}<NewsFeed news={b.news} />{:else}<div></div>{/if}
|
||||
{#if b.links.length}
|
||||
<div class="links">
|
||||
{#each b.links as l}
|
||||
{@const Icon = linkIcon[l.icon] ?? Link}
|
||||
<button class="glass link" onclick={() => openUrl(l.url)} title={l.label}><Icon size={16} /> <span>{l.label}</span></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
{#if app.consoleOpen}<Console />{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.home { position: relative; height: 100%; display: grid; grid-template-columns: 1fr minmax(17rem, 23rem); grid-template-rows: 1fr auto; gap: 1.5rem 2rem; padding: 1.5rem 1.8rem 1.8rem 2.2rem; }
|
||||
.hero { grid-column: 1; grid-row: 1; align-self: center; display: flex; flex-direction: column; gap: 0.9rem; animation: fade-up 0.45s var(--ease); max-width: 40rem; }
|
||||
.greet { font-size: 0.95rem; }
|
||||
.greet strong { color: var(--text); font-weight: 600; }
|
||||
.chips { display: flex; gap: 0.45rem; flex-wrap: wrap; }
|
||||
h1 { font-size: clamp(2.8rem, 6.2vw, 4.8rem); line-height: 0.98; font-weight: 800; letter-spacing: -0.035em; text-shadow: 0 0.2rem 2rem color-mix(in srgb, var(--bg) 60%, transparent); }
|
||||
.desc { font-size: 1.05rem; line-height: 1.55; color: color-mix(in srgb, var(--text) 72%, transparent); max-width: 32rem; }
|
||||
.actions { grid-column: 1; grid-row: 2; display: flex; flex-direction: column; gap: 0.9rem; animation: fade-up 0.5s var(--ease) 0.05s both; }
|
||||
.tools { flex-wrap: wrap; gap: 0.6rem; }
|
||||
.tool { width: 3.1rem; height: 3.1rem; padding: 0; border-radius: var(--radius); color: color-mix(in srgb, var(--text) 80%, transparent); }
|
||||
.tool.on { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
|
||||
.side { grid-column: 2; grid-row: 1 / 3; display: flex; flex-direction: column; justify-content: space-between; gap: 1rem; min-height: 0; animation: fade-up 0.5s var(--ease) 0.1s both; }
|
||||
.links { display: flex; flex-wrap: wrap; gap: 0.45rem; }
|
||||
.link { padding: 0.55rem 0.8rem; font-size: 0.84rem; border-radius: var(--radius-sm); flex: 1 1 auto; }
|
||||
.link:hover { border-color: var(--line-strong); background: color-mix(in srgb, var(--surface) 80%, transparent); }
|
||||
@media (max-width: 1060px) { .home { grid-template-columns: 1fr 16rem; } .link span { display: none; } }
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import SignIn from '../components/SignIn.svelte';
|
||||
import { abs, app } from '../lib/store.svelte';
|
||||
|
||||
const b = $derived(app.manifest?.branding);
|
||||
</script>
|
||||
|
||||
<div class="login">
|
||||
<section class="brand">
|
||||
{#if b?.logo_url}<img src={abs(b.logo_url)} alt="" class="logo" />{:else}<div class="mark"></div>{/if}
|
||||
<h1>{b?.name}</h1>
|
||||
<p>{b?.tagline}</p>
|
||||
</section>
|
||||
<section class="card glass">
|
||||
<h2>Welcome</h2>
|
||||
<p class="muted small">Sign in to start playing.</p>
|
||||
<SignIn />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login { height: 100%; display: grid; grid-template-columns: 1fr 25rem; align-items: center; gap: 3rem; padding: 2rem 4rem 3rem; }
|
||||
.brand { display: flex; flex-direction: column; gap: 1rem; animation: fade-up 0.5s var(--ease); }
|
||||
.logo { width: 5rem; height: 5rem; object-fit: contain; }
|
||||
.mark { width: 4.2rem; height: 4.2rem; border-radius: calc(var(--radius) + 6px); background: linear-gradient(135deg, var(--accent), var(--accent-2)); box-shadow: 0 1rem 3rem -1rem var(--accent); position: relative; }
|
||||
.mark::after { content: ''; position: absolute; inset: 1.1rem; border-radius: 50%; border: 0.35rem solid rgba(255, 255, 255, 0.9); }
|
||||
h1 { font-size: clamp(3rem, 6vw, 4.6rem); font-weight: 800; letter-spacing: -0.04em; line-height: 1; }
|
||||
.brand p { font-size: 1.15rem; color: color-mix(in srgb, var(--text) 70%, transparent); max-width: 26rem; line-height: 1.5; }
|
||||
.card { padding: 1.8rem; display: flex; flex-direction: column; gap: 0.4rem; box-shadow: 0 2rem 5rem -2rem rgba(0, 0, 0, 0.6); animation: fade-up 0.5s var(--ease) 0.08s both; }
|
||||
.card h2 { font-size: 1.4rem; }
|
||||
.card > :global(.signin) { margin-top: 0.8rem; }
|
||||
@media (max-width: 900px) { .login { grid-template-columns: 1fr; padding: 2rem; } .brand { display: none; } }
|
||||
</style>
|
||||
@@ -0,0 +1,430 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
UserRound, Gamepad2, Cpu, Keyboard, Palette, Rocket, HardDrive, Info, Plus, LogOut, Check, FolderOpen, Trash2,
|
||||
FileCode, RotateCcw, Download, LoaderCircle, RefreshCw, MousePointerClick, Zap, Gauge, Feather, Link2,
|
||||
} from '@lucide/svelte';
|
||||
import Avatar from '../components/Avatar.svelte';
|
||||
import Toggle from '../components/Toggle.svelte';
|
||||
import { app, connect, instances, refresh, removeAccount, saveSettings, switchAccount, toast } from '../lib/store.svelte';
|
||||
import { BIND_GROUPS, defaults, fromKeyboard, fromMouse, keyLabel } from '../lib/keys';
|
||||
import { bytes, gb } from '../lib/format';
|
||||
import { errorText, invoke, openUrl, pickFile } from '../lib/tauri';
|
||||
import type { Gc, UpdateInfo } from '../lib/types';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'account', label: 'Accounts', icon: UserRound },
|
||||
{ id: 'game', label: 'Game', icon: Gamepad2 },
|
||||
{ id: 'java', label: 'Java & performance', icon: Cpu },
|
||||
{ id: 'controls', label: 'Controls', icon: Keyboard },
|
||||
{ id: 'appearance', label: 'Appearance', icon: Palette },
|
||||
{ id: 'launcher', label: 'Launcher', icon: Rocket },
|
||||
{ id: 'storage', label: 'Storage', icon: HardDrive },
|
||||
{ id: 'about', label: 'About', icon: Info },
|
||||
];
|
||||
|
||||
const s = $derived(app.settings!);
|
||||
const b = $derived(app.manifest?.branding);
|
||||
const ram = $derived(app.boot?.app.total_ram_mb ?? 8192);
|
||||
const maxRam = $derived(Math.max(2048, Math.floor((ram - 1024) / 512) * 512));
|
||||
const recommended = $derived(Math.min(8192, Math.max(2048, Math.floor(ram / 2 / 512) * 512)));
|
||||
const advanced = $derived(b?.features.allow_advanced_java !== false);
|
||||
const themeAllowed = $derived(b?.features.allow_user_theme !== false);
|
||||
const kindLabel = { panel: 'Server account', microsoft: 'Microsoft account', offline: 'Offline' };
|
||||
|
||||
// ---- controls ----
|
||||
let capturing = $state<string | null>(null);
|
||||
const binds = $derived({ ...defaults(), ...s.keybinds });
|
||||
|
||||
function capture(e: KeyboardEvent | MouseEvent) {
|
||||
if (!capturing) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
let key: string | null = null;
|
||||
if (e instanceof KeyboardEvent) key = e.code === 'Escape' ? 'key.keyboard.unknown' : fromKeyboard(e.code);
|
||||
else key = fromMouse(e.button);
|
||||
if (key) {
|
||||
s.keybinds = { ...s.keybinds, [capturing]: key };
|
||||
saveSettings();
|
||||
}
|
||||
capturing = null;
|
||||
}
|
||||
const conflicts = $derived.by(() => {
|
||||
const seen = new Map<string, number>();
|
||||
Object.values(binds).forEach((k) => k !== 'key.keyboard.unknown' && seen.set(k, (seen.get(k) ?? 0) + 1));
|
||||
return new Set([...seen].filter(([, n]) => n > 1).map(([k]) => k));
|
||||
});
|
||||
|
||||
// ---- storage ----
|
||||
let storage = $state<{ shared: number; runtimes: number; instances: [string, number][] } | null>(null);
|
||||
function loadStorage() {
|
||||
invoke<typeof storage>('storage_info').then((x) => (storage = x)).catch(() => {});
|
||||
}
|
||||
$effect(() => {
|
||||
if (app.settingsTab === 'storage') loadStorage();
|
||||
});
|
||||
const storageTotal = $derived(storage ? storage.shared + storage.runtimes + storage.instances.reduce((a, [, n]) => a + n, 0) : 0);
|
||||
const instName = (id: string) => instances().find((i) => i.id === id)?.name ?? id;
|
||||
|
||||
// ---- java ----
|
||||
let javaMajor = $state<number | null>(null);
|
||||
async function browseJava() {
|
||||
const p = await pickFile('Choose your java executable', [{ name: 'Java', extensions: ['exe', ''] }]);
|
||||
if (!p) return;
|
||||
s.java_path = p;
|
||||
saveSettings();
|
||||
javaMajor = await invoke<number | null>('detect_java', { path: p });
|
||||
}
|
||||
|
||||
// ---- launcher ----
|
||||
let panelInput = $state(app.panelUrl ?? '');
|
||||
let panelBusy = $state(false);
|
||||
async function changePanel() {
|
||||
panelBusy = true;
|
||||
try {
|
||||
await connect(panelInput);
|
||||
toast('Connected');
|
||||
} catch (e) {
|
||||
toast(errorText(e), 'error');
|
||||
} finally {
|
||||
panelBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
let checking = $state(false);
|
||||
let installing = $state(false);
|
||||
async function checkUpdate() {
|
||||
checking = true;
|
||||
try {
|
||||
app.update = await invoke<UpdateInfo | null>('check_update');
|
||||
if (!app.update) toast("You're on the latest version");
|
||||
} catch (e) {
|
||||
toast(errorText(e), 'error');
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
}
|
||||
async function installUpdate() {
|
||||
if (!app.update) return;
|
||||
installing = true;
|
||||
try {
|
||||
await invoke('install_update', { url: app.update.url });
|
||||
} catch (e) {
|
||||
toast(errorText(e), 'error');
|
||||
installing = false;
|
||||
}
|
||||
}
|
||||
|
||||
const gcPresets: { id: Gc; title: string; text: string; icon: any }[] = [
|
||||
{ id: 'g1', title: 'Balanced', text: 'Tuned G1 — smooth on most PCs', icon: Gauge },
|
||||
{ id: 'zgc', title: 'Low latency', text: 'ZGC — fewest stutters, needs more RAM', icon: Zap },
|
||||
{ id: 'default', title: 'Java default', text: 'No tuning flags', icon: Feather },
|
||||
];
|
||||
const accents = ['#7c5cff', '#22d3ee', '#22c55e', '#f59e0b', '#f97316', '#ef4444', '#ec4899', '#a855f7', '#e5e7eb'];
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={capturing ? capture : undefined} onmousedown={capturing ? capture : undefined} />
|
||||
|
||||
<div class="settings">
|
||||
<nav class="tabs glass">
|
||||
<h2>Settings</h2>
|
||||
{#each tabs as t}
|
||||
<button class="tab" class:active={app.settingsTab === t.id} onclick={() => (app.settingsTab = t.id)}>
|
||||
<t.icon size={17} /> {t.label}
|
||||
{#if t.id === 'about' && app.update}<span class="dot"></span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="content">
|
||||
{#key app.settingsTab}
|
||||
<div class="pane">
|
||||
{#if app.settingsTab === 'account'}
|
||||
<h1>Accounts</h1>
|
||||
<p class="lead">Switch between accounts or add another one.</p>
|
||||
<div class="card glass list">
|
||||
{#each app.accounts as a (a.id)}
|
||||
<div class="acc" class:current={a.id === app.active}>
|
||||
<Avatar account={a} size={2.6} />
|
||||
<div class="who"><strong>{a.username}</strong><span class="tiny muted">{kindLabel[a.kind]}{a.role === 'admin' ? ' · Admin' : ''}</span></div>
|
||||
{#if a.id === app.active}
|
||||
<span class="active-badge"><Check size={13} /> Active</span>
|
||||
{:else}
|
||||
<button class="sm" onclick={() => switchAccount(a.id)}>Use</button>
|
||||
{/if}
|
||||
<button class="ghost icon" title="Sign out" aria-label="Sign out {a.username}" onclick={() => removeAccount(a.id)}><LogOut size={16} /></button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted small">No accounts yet.</p>
|
||||
{/each}
|
||||
<button class="add" onclick={() => (app.addAccount = true)}><Plus size={16} /> Add account</button>
|
||||
</div>
|
||||
{:else if app.settingsTab === 'game'}
|
||||
<h1>Game</h1>
|
||||
<p class="lead">Applies to every instance unless you override it in the instance's settings.</p>
|
||||
<div class="card glass col">
|
||||
<label class="field">
|
||||
<span class="row">Memory (RAM) <span class="spacer"></span><strong class="val">{s.memory_max_mb ? gb(s.memory_max_mb) : 'Server default'}</strong></span>
|
||||
<input type="range" min="0" max={maxRam} step="512" bind:value={s.memory_max_mb} oninput={saveSettings} />
|
||||
<div class="scale tiny muted"><span>Default</span><span>Recommended for your PC: {gb(recommended)}</span><span>{gb(maxRam)}</span></div>
|
||||
{#if s.memory_max_mb > ram * 0.75}<span class="warn tiny">That's most of your {gb(ram)} — your PC may slow down.</span>{/if}
|
||||
</label>
|
||||
<button class="sm link-btn" onclick={() => { s.memory_max_mb = recommended; saveSettings(); }}>Use recommended</button>
|
||||
</div>
|
||||
<div class="card glass col">
|
||||
<Toggle bind:checked={s.custom_resolution} onchange={saveSettings} label="Custom window size" help="Start Minecraft at a specific resolution." />
|
||||
{#if s.custom_resolution}
|
||||
<div class="row">
|
||||
<label class="field grow">Width<input type="number" min="640" bind:value={s.width} oninput={saveSettings} /></label>
|
||||
<span class="x">×</span>
|
||||
<label class="field grow">Height<input type="number" min="480" bind:value={s.height} oninput={saveSettings} /></label>
|
||||
</div>
|
||||
{/if}
|
||||
<Toggle bind:checked={s.fullscreen} onchange={saveSettings} label="Start in fullscreen" />
|
||||
</div>
|
||||
<div class="card glass col">
|
||||
<label class="field">When the game starts
|
||||
<div class="segmented full">
|
||||
{#each [['minimize', 'Minimise'], ['hide', 'Hide'], ['keep', 'Stay open'], ['close', 'Close launcher']] as [id, label]}
|
||||
<button class:active={s.after_launch === id} onclick={() => { s.after_launch = id as typeof s.after_launch; saveSettings(); }}>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="help">{s.after_launch === 'close' ? 'Frees the most memory, but game logs and crash help are unavailable.' : s.after_launch === 'hide' ? 'The launcher comes back when you quit the game.' : 'Minimising keeps the console and crash help available.'}</span>
|
||||
</label>
|
||||
<Toggle bind:checked={s.show_console} onchange={saveSettings} label="Open the console when the game starts" />
|
||||
</div>
|
||||
{:else if app.settingsTab === 'java'}
|
||||
<h1>Java & performance</h1>
|
||||
<p class="lead">The launcher downloads the right Java for each Minecraft version automatically.</p>
|
||||
<div class="card glass col">
|
||||
<span class="label">Garbage collector</span>
|
||||
<div class="presets">
|
||||
{#each gcPresets as g}
|
||||
<button class="preset" class:active={s.gc === g.id} onclick={() => { s.gc = g.id; saveSettings(); }}>
|
||||
<g.icon size={20} /><strong>{g.title}</strong><span class="tiny muted">{g.text}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if advanced}
|
||||
<div class="card glass col">
|
||||
<label class="field">Java executable
|
||||
<div class="row">
|
||||
<input value={s.java_path ?? ''} placeholder="Automatic (recommended)" readonly />
|
||||
<button class="sm" onclick={browseJava}><FileCode size={14} /> Browse</button>
|
||||
{#if s.java_path}<button class="sm ghost" onclick={() => { s.java_path = null; javaMajor = null; saveSettings(); }}><RotateCcw size={14} /> Automatic</button>{/if}
|
||||
</div>
|
||||
{#if javaMajor}<span class="help">Detected Java {javaMajor}.</span>{/if}
|
||||
</label>
|
||||
<label class="field">JVM arguments <span class="help">Added to every instance. Leave empty unless you know what you're doing.</span>
|
||||
<input class="mono" bind:value={s.jvm_args} oninput={saveSettings} placeholder="-XX:+UseStringDeduplication" />
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="card glass col">
|
||||
<label class="field">
|
||||
<span class="row">Parallel downloads <span class="spacer"></span><strong class="val">{s.concurrent_downloads}</strong></span>
|
||||
<input type="range" min="2" max="32" bind:value={s.concurrent_downloads} oninput={saveSettings} />
|
||||
<span class="help">Lower this on slow or unstable connections.</span>
|
||||
</label>
|
||||
</div>
|
||||
{:else if app.settingsTab === 'controls'}
|
||||
<h1>Controls</h1>
|
||||
<p class="lead">Set your Minecraft keybinds once — the launcher applies them to every instance before it starts.</p>
|
||||
<div class="card glass col">
|
||||
<Toggle bind:checked={s.keybinds_enabled} onchange={saveSettings} label="Apply these keybinds to the game" help="Writes to options.txt before launch (Minecraft 1.13 and newer)." />
|
||||
</div>
|
||||
<div class="binds" class:disabled={!s.keybinds_enabled}>
|
||||
{#each BIND_GROUPS as group}
|
||||
<div class="card glass">
|
||||
<h3 class="group">{group.title}</h3>
|
||||
{#each group.binds as bind}
|
||||
{@const key = binds[bind.id]}
|
||||
<div class="bind">
|
||||
<span>{bind.label}</span>
|
||||
<button class="key" class:capturing={capturing === bind.id} class:conflict={conflicts.has(key)} class:changed={key !== bind.default}
|
||||
onclick={(e) => { e.stopPropagation(); capturing = bind.id; }}>
|
||||
{#if capturing === bind.id}<MousePointerClick size={13} /> Press a key…{:else}{keyLabel(key)}{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="sm" onclick={() => { s.keybinds = {}; saveSettings(); }}><RotateCcw size={14} /> Reset to defaults</button>
|
||||
<span class="tiny muted">Press Esc while choosing to unbind. Shortcut in the launcher: <kbd>Ctrl</kbd> + <kbd>Enter</kbd> to play.</span>
|
||||
</div>
|
||||
{:else if app.settingsTab === 'appearance'}
|
||||
<h1>Appearance</h1>
|
||||
<p class="lead">Make the launcher feel like yours{themeAllowed ? '' : ' — your server has locked the theme'}.</p>
|
||||
{#if themeAllowed}
|
||||
<div class="card glass col">
|
||||
<div class="segmented">
|
||||
<button class:active={s.theme_mode === 'server'} onclick={() => { s.theme_mode = 'server'; saveSettings(); }}>Server theme</button>
|
||||
<button class:active={s.theme_mode === 'custom'} onclick={() => { s.theme_mode = 'custom'; s.accent ??= accents[0]; saveSettings(); }}>Custom accent</button>
|
||||
</div>
|
||||
{#if s.theme_mode === 'custom'}
|
||||
<div class="swatches">
|
||||
{#each accents as c}
|
||||
<button class="swatch" class:on={s.accent === c} style:background={c} aria-label="Accent {c}" onclick={() => { s.accent = c; saveSettings(); }}>{#if s.accent === c}<Check size={15} />{/if}</button>
|
||||
{/each}
|
||||
<label class="swatch picker" title="Custom colour"><input type="color" value={s.accent ?? '#7c5cff'} oninput={(e) => { s.accent = (e.target as HTMLInputElement).value; saveSettings(); }} /></label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="card glass col">
|
||||
<Toggle bind:checked={() => s.glass ?? b?.glass ?? true, (v) => (s.glass = v)} onchange={saveSettings}
|
||||
label="Frosted glass effects" help="Turn off for better performance on older graphics cards." />
|
||||
<Toggle bind:checked={s.background_video} onchange={saveSettings} label="Animated backgrounds" help="Video backgrounds pause automatically while you play." />
|
||||
<Toggle bind:checked={s.reduce_motion} onchange={saveSettings} label="Reduce motion" help="Turns off animations and transitions." />
|
||||
<label class="field">
|
||||
<span class="row">Interface size <span class="spacer"></span><strong class="val">{s.ui_scale}%</strong></span>
|
||||
<input type="range" min="80" max="130" step="5" bind:value={s.ui_scale} oninput={saveSettings} />
|
||||
</label>
|
||||
</div>
|
||||
{:else if app.settingsTab === 'launcher'}
|
||||
<h1>Launcher</h1>
|
||||
<p class="lead">Connection and behaviour.</p>
|
||||
<div class="card glass col">
|
||||
<label class="field">Server panel
|
||||
{#if app.boot?.app.panel_locked}
|
||||
<input value={app.panelUrl} readonly />
|
||||
<span class="help">This launcher is built for this server.</span>
|
||||
{:else}
|
||||
<div class="row">
|
||||
<input bind:value={panelInput} placeholder="panel.yourserver.com" />
|
||||
<button class="sm" disabled={panelBusy || !panelInput || panelInput === app.panelUrl} onclick={changePanel}>{#if panelBusy}<LoaderCircle class="spin" size={14} />{:else}<Link2 size={14} />{/if} Connect</button>
|
||||
</div>
|
||||
{/if}
|
||||
</label>
|
||||
<button class="sm link-btn" onclick={() => refresh().then(() => toast('Refreshed')).catch((e) => toast(errorText(e), 'error'))}><RefreshCw size={14} /> Refresh from server</button>
|
||||
</div>
|
||||
<div class="card glass col">
|
||||
<Toggle bind:checked={s.check_updates} onchange={saveSettings} label="Check for launcher updates" />
|
||||
<Toggle bind:checked={s.send_stats} onchange={saveSettings} label="Share play stats with the server" help="Lets your admins see which instances are popular. Only sends the instance and your username." />
|
||||
</div>
|
||||
{:else if app.settingsTab === 'storage'}
|
||||
<h1>Storage</h1>
|
||||
<p class="lead">Game files are shared between instances, so extra instances of the same version take almost no space.</p>
|
||||
{#if storage}
|
||||
<div class="card glass col">
|
||||
<div class="row"><strong class="big">{bytes(storageTotal)}</strong><span class="muted small">used in total</span><span class="spacer"></span>
|
||||
<button class="sm" onclick={() => invoke('open_folder', { kind: 'data' })}><FolderOpen size={14} /> Open folder</button>
|
||||
</div>
|
||||
<div class="usage">
|
||||
<span style:flex={storage.shared} class="u1" title="Game files"></span>
|
||||
<span style:flex={storage.runtimes} class="u2" title="Java"></span>
|
||||
{#each storage.instances as [, n]}<span style:flex={n} class="u3"></span>{/each}
|
||||
</div>
|
||||
<div class="legend tiny">
|
||||
<span><i class="u1"></i> Game files {bytes(storage.shared)}</span>
|
||||
<span><i class="u2"></i> Java {bytes(storage.runtimes)}</span>
|
||||
<span><i class="u3"></i> Instances {bytes(storage.instances.reduce((a, [, n]) => a + n, 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card glass list">
|
||||
{#each storage.instances as [id, n]}
|
||||
<div class="acc">
|
||||
<div class="who"><strong>{instName(id)}</strong><span class="tiny muted">{bytes(n)} · worlds, mods & settings</span></div>
|
||||
<button class="sm" onclick={() => invoke('open_folder', { kind: 'instance', instanceId: id })}><FolderOpen size={14} /></button>
|
||||
<button class="sm danger" onclick={async () => { try { await invoke('delete_instance_data', { instanceId: id }); loadStorage(); toast('Deleted'); } catch (e) { toast(errorText(e), 'error'); } }}><Trash2 size={14} /> Delete</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted small">No instances downloaded yet.</p>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="sm link-btn" onclick={async () => { await invoke('clear_cache'); toast('Cache cleared'); loadStorage(); }}><Trash2 size={14} /> Clear download cache</button>
|
||||
{:else}
|
||||
<p class="muted row"><LoaderCircle class="spin" size={16} /> Measuring…</p>
|
||||
{/if}
|
||||
{:else if app.settingsTab === 'about'}
|
||||
<h1>About</h1>
|
||||
<div class="card glass col about">
|
||||
<div class="row">
|
||||
<div class="mark"></div>
|
||||
<div><strong class="big">{b?.name ?? 'ScopeNet'} Launcher</strong><p class="muted small">Version {app.boot?.app.version} · {app.boot?.app.os} {app.boot?.app.arch}</p></div>
|
||||
</div>
|
||||
{#if app.update}
|
||||
<div class="update">
|
||||
<Download size={20} />
|
||||
<div class="grow"><strong>Version {app.update.version} is available</strong><p class="tiny muted">{bytes(app.update.size)}</p></div>
|
||||
<button class="primary" disabled={installing} onclick={installUpdate}>{#if installing}<LoaderCircle class="spin" size={16} /> Downloading…{:else}Update now{/if}</button>
|
||||
</div>
|
||||
{#if app.update.notes}<pre class="notes selectable">{app.update.notes}</pre>{/if}
|
||||
{:else if app.boot?.app.repo}
|
||||
<button class="sm link-btn" disabled={checking} onclick={checkUpdate}>{#if checking}<LoaderCircle class="spin" size={14} />{:else}<RefreshCw size={14} />{/if} Check for updates</button>
|
||||
{/if}
|
||||
<p class="tiny muted">Built with Tauri, Rust and Svelte. Minecraft is a trademark of Mojang Studios; this launcher is not affiliated with Mojang or Microsoft.</p>
|
||||
{#if app.boot?.app.repo}<button class="sm link-btn" onclick={() => openUrl(`https://github.com/${app.boot?.app.repo}`)}>Source code</button>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings { height: 100%; display: flex; gap: 1rem; padding: 0 1rem 0.8rem 1rem; min-height: 0; }
|
||||
.tabs { width: 14.5rem; flex-shrink: 0; padding: 1.1rem 0.7rem; display: flex; flex-direction: column; gap: 0.2rem; border-radius: calc(var(--radius) + 4px); }
|
||||
.tabs h2 { font-size: 1.15rem; padding: 0 0.7rem 0.8rem; }
|
||||
.tab { justify-content: flex-start; gap: 0.75rem; background: transparent; border-color: transparent; color: color-mix(in srgb, var(--text) 72%, transparent); padding: 0.62rem 0.75rem; font-weight: 520; position: relative; }
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--text); }
|
||||
.tab.active :global(svg) { color: var(--accent); }
|
||||
.dot { width: 0.45rem; height: 0.45rem; border-radius: 50%; background: var(--accent-2); margin-left: auto; box-shadow: 0 0 0.5rem var(--accent-2); }
|
||||
.content { flex: 1; overflow-y: auto; min-width: 0; padding: 0.4rem 0.6rem 2rem 0.6rem; }
|
||||
.pane { max-width: 44rem; display: flex; flex-direction: column; gap: 1rem; animation: fade-up 0.3s var(--ease); }
|
||||
h1 { font-size: 1.8rem; font-weight: 750; margin-top: 0.4rem; }
|
||||
.lead { color: var(--muted); margin-top: -0.6rem; line-height: 1.5; }
|
||||
.card { padding: 1.1rem 1.2rem; }
|
||||
.card.col { gap: 1rem; }
|
||||
.list { display: flex; flex-direction: column; gap: 0.4rem; padding: 0.6rem; }
|
||||
.acc { display: flex; align-items: center; gap: 0.8rem; padding: 0.6rem 0.7rem; border-radius: var(--radius-sm); }
|
||||
.acc.current { background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
||||
.who { flex: 1; display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; }
|
||||
.active-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 650; color: var(--accent); padding: 0.3rem 0.6rem; }
|
||||
.add { border-style: dashed; background: transparent; margin: 0.2rem; }
|
||||
.val { color: var(--accent); font-variant-numeric: tabular-nums; }
|
||||
.scale { display: flex; justify-content: space-between; }
|
||||
.warn { color: var(--warn); }
|
||||
.link-btn { align-self: flex-start; }
|
||||
.grow { flex: 1; }
|
||||
.x { color: var(--muted); padding-top: 1.4rem; }
|
||||
.full { width: 100%; }
|
||||
.full button { flex: 1; }
|
||||
.label { font-size: 0.85rem; font-weight: 520; color: color-mix(in srgb, var(--text) 80%, transparent); }
|
||||
.presets { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.6rem; }
|
||||
.preset { flex-direction: column; align-items: flex-start; gap: 0.3rem; padding: 0.9rem; white-space: normal; text-align: left; background: color-mix(in srgb, var(--bg) 50%, transparent); }
|
||||
.preset :global(svg) { color: var(--muted); margin-bottom: 0.2rem; }
|
||||
.preset.active { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); }
|
||||
.preset.active :global(svg) { color: var(--accent); }
|
||||
.binds { display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem; transition: opacity 0.2s; }
|
||||
.binds.disabled { opacity: 0.55; }
|
||||
.group { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); margin-bottom: 0.5rem; }
|
||||
.bind { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; padding: 0.3rem 0; font-size: 0.88rem; }
|
||||
.key { min-width: 7.5rem; padding: 0.4rem 0.7rem; font-size: 0.82rem; font-family: var(--mono); font-weight: 600; background: color-mix(in srgb, var(--bg) 60%, transparent); }
|
||||
.key.changed { border-color: color-mix(in srgb, var(--accent) 45%, transparent); }
|
||||
.key.conflict { border-color: color-mix(in srgb, var(--warn) 60%, transparent); color: var(--warn); }
|
||||
.key.capturing { border-color: var(--accent); color: var(--accent); animation: glow 1s ease-in-out infinite alternate; font-family: var(--font); }
|
||||
@keyframes glow { to { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); } }
|
||||
kbd { font-family: var(--mono); font-size: 0.72rem; padding: 0.1rem 0.35rem; border-radius: 0.3rem; border: 1px solid var(--line-strong); background: color-mix(in srgb, var(--text) 6%, transparent); }
|
||||
.swatches { display: flex; gap: 0.55rem; flex-wrap: wrap; }
|
||||
.swatch { width: 2.3rem; height: 2.3rem; border-radius: 50%; padding: 0; border: 2px solid transparent; color: white; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15); }
|
||||
.swatch.on { border-color: var(--text); }
|
||||
.picker { background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red); position: relative; overflow: hidden; cursor: pointer; display: block; }
|
||||
.picker input { opacity: 0; width: 100%; height: 100%; cursor: pointer; }
|
||||
.big { font-size: 1.3rem; }
|
||||
.usage { display: flex; height: 0.8rem; border-radius: 99rem; overflow: hidden; gap: 2px; background: color-mix(in srgb, var(--text) 8%, transparent); }
|
||||
.usage span { min-width: 2px; }
|
||||
.u1 { background: var(--accent); }
|
||||
.u2 { background: var(--accent-2); }
|
||||
.u3 { background: var(--success); }
|
||||
.legend { display: flex; gap: 1.2rem; color: var(--muted); }
|
||||
.legend i { display: inline-block; width: 0.6rem; height: 0.6rem; border-radius: 0.2rem; margin-right: 0.3rem; vertical-align: -0.05rem; }
|
||||
.about .mark { width: 3.2rem; height: 3.2rem; border-radius: var(--radius); background: linear-gradient(135deg, var(--accent), var(--accent-2)); flex-shrink: 0; }
|
||||
.update { display: flex; align-items: center; gap: 0.9rem; padding: 0.9rem 1rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); }
|
||||
.update :global(svg) { color: var(--accent); }
|
||||
.notes { margin: 0; white-space: pre-wrap; font-family: var(--font); font-size: 0.84rem; color: var(--muted); max-height: 12rem; overflow: auto; }
|
||||
@media (max-width: 1050px) { .binds { grid-template-columns: 1fr; } .tabs { width: 12rem; } }
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { Link2, LoaderCircle, ArrowRight } from '@lucide/svelte';
|
||||
import { connect } from '../lib/store.svelte';
|
||||
import { errorText } from '../lib/tauri';
|
||||
|
||||
let url = $state('');
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
await connect(url);
|
||||
} catch (err) {
|
||||
error = errorText(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="center">
|
||||
<form class="card glass" onsubmit={submit}>
|
||||
<div class="icon"><Link2 size={26} /></div>
|
||||
<h1>Connect to your server</h1>
|
||||
<p class="muted">Enter the panel address your community gave you. You only need to do this once.</p>
|
||||
<input bind:value={url} placeholder="panel.yourserver.com" autocomplete="url" required />
|
||||
{#if error}<div class="error selectable">{error}</div>{/if}
|
||||
<button class="primary big" disabled={busy || !url.trim()}>
|
||||
{#if busy}<LoaderCircle class="spin" size={18} />{:else}<ArrowRight size={18} />{/if} Connect
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.center { height: 100%; display: grid; place-items: center; padding: 2rem; }
|
||||
.card { width: 100%; max-width: 28rem; padding: 2.2rem; display: flex; flex-direction: column; gap: 1rem; animation: fade-up 0.4s var(--ease); box-shadow: 0 2rem 5rem -2rem rgba(0, 0, 0, 0.7); }
|
||||
.icon { width: 3.4rem; height: 3.4rem; border-radius: var(--radius); display: grid; place-items: center; background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: white; box-shadow: 0 0.8rem 2rem -0.8rem var(--accent); }
|
||||
h1 { font-size: 1.6rem; }
|
||||
p { line-height: 1.5; font-size: 0.93rem; }
|
||||
.big { padding: 0.85rem; font-size: 1rem; }
|
||||
.error { font-size: 0.87rem; padding: 0.7rem 0.9rem; border-radius: var(--radius-sm); color: color-mix(in srgb, var(--danger) 60%, white); background: color-mix(in srgb, var(--danger) 12%, transparent); }
|
||||
</style>
|
||||
Reference in new issue
Block a user