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:
Claude committed 2026-09-28 05:00:30 +00:00
1 parent af9147ec75
commit 50eb1a0cac
60 files changed
+9424 -47

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
<script lang="ts">
import { onMount } from 'svelte';
import { LoaderCircle, Download, X } from '@lucide/svelte';
import Background from './components/Background.svelte';
import TitleBar from './components/TitleBar.svelte';
import Rail from './components/Rail.svelte';
import Modal from './components/Modal.svelte';
import SignIn from './components/SignIn.svelte';
import InstanceSettings from './components/InstanceSettings.svelte';
import CrashDialog from './components/CrashDialog.svelte';
import Toasts from './components/Toasts.svelte';
import Setup from './pages/Setup.svelte';
import Login from './pages/Login.svelte';
import Home from './pages/Home.svelte';
import Settings from './pages/Settings.svelte';
import { app, init } from './lib/store.svelte';
import { errorText } from './lib/tauri';
let fatal = $state('');
let updateDismissed = $state(false);
onMount(() => {
init().catch((e) => (fatal = errorText(e)));
});
</script>
<Background />
<div class="shell">
<TitleBar />
{#if fatal && !app.manifest}
<div class="center"><div class="glass msg"><h2>Can't reach the server</h2><p class="muted selectable">{fatal}</p><button class="primary" onclick={() => location.reload()}>Try again</button></div></div>
{:else if !app.ready}
<div class="center"><LoaderCircle class="spin" size={28} /></div>
{:else if !app.panelUrl || !app.manifest}
<Setup />
{:else if app.accounts.length === 0}
<Login />
{:else}
<div class="body">
<Rail />
<main>
{#if app.view === 'settings'}<Settings />{:else}<Home />{/if}
</main>
</div>
{/if}
</div>
{#if app.update && !updateDismissed && app.view !== 'settings' && app.accounts.length}
<div class="update glass">
<Download size={16} />
<span>Launcher {app.update.version} is available</span>
<button class="sm primary" onclick={() => { app.view = 'settings'; app.settingsTab = 'about'; }}>View</button>
<button class="ghost icon sm" aria-label="Dismiss" onclick={() => (updateDismissed = true)}><X size={14} /></button>
</div>
{/if}
<Modal bind:open={app.addAccount} title="Add account" width={26}>
<SignIn />
</Modal>
<InstanceSettings />
<CrashDialog />
<Toasts />
<style>
.shell { position: relative; z-index: 1; height: 100%; display: flex; flex-direction: column; }
.body { flex: 1; display: flex; min-height: 0; }
main { flex: 1; min-width: 0; min-height: 0; position: relative; }
.center { flex: 1; display: grid; place-items: center; color: var(--accent); }
.msg { padding: 2rem; max-width: 28rem; display: flex; flex-direction: column; gap: 0.8rem; color: var(--text); }
.update { position: fixed; top: 3.2rem; left: 50%; transform: translateX(-50%); z-index: 40; display: flex; align-items: center; gap: 0.7rem; padding: 0.45rem 0.5rem 0.45rem 1rem; font-size: 0.87rem; border-radius: 99rem; animation: fade-up 0.3s var(--ease); box-shadow: 0 1rem 2.5rem -1rem rgba(0, 0, 0, 0.6); }
.update :global(svg:first-child) { color: var(--accent-2); }
</style>
+154
View File
@@ -0,0 +1,154 @@
/* Sizes are in rem so the "UI scale" setting scales everything (1rem = 15px at 100%). */
:root {
--accent: #7c5cff;
--accent-2: #22d3ee;
--bg: #0b0d17;
--surface: #141828;
--text: #eef0ff;
--muted: #8b90b3;
--success: #34d399;
--danger: #f43f5e;
--warn: #fbbf24;
--radius: 14px;
--radius-sm: 9px;
--font: 'Outfit Variable', system-ui, sans-serif;
--mono: 'JetBrains Mono Variable', ui-monospace, monospace;
--line: color-mix(in srgb, var(--text) 8%, transparent);
--line-strong: color-mix(in srgb, var(--text) 14%, transparent);
--panel: color-mix(in srgb, var(--surface) 62%, transparent);
--panel-solid: var(--surface);
--blur: blur(18px) saturate(1.3);
--ease: cubic-bezier(0.2, 0.8, 0.2, 1);
font-size: 15px;
color-scheme: dark;
}
.no-glass {
--panel: var(--surface);
--blur: none;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
overflow: hidden;
background: var(--bg);
color: var(--text);
font-family: var(--font);
-webkit-font-smoothing: antialiased;
user-select: none;
-webkit-user-select: none;
}
#app { height: 100%; }
h1, h2, h3, h4 { margin: 0; letter-spacing: -0.015em; }
p { margin: 0; }
a { color: var(--accent); text-decoration: none; }
code, .mono { font-family: var(--mono); }
.selectable, input, textarea { user-select: text; -webkit-user-select: text; }
::-webkit-scrollbar { width: 0.6rem; height: 0.6rem; }
::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--text) 14%, transparent); border-radius: 1rem; border: 0.15rem solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-track { background: transparent; }
.reduce-motion *, .reduce-motion *::before, .reduce-motion *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
/* ---------- surfaces ---------- */
.glass {
background: var(--panel);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
border: 1px solid var(--line);
border-radius: var(--radius);
}
/* ---------- controls ---------- */
button {
font: inherit;
color: var(--text);
cursor: pointer;
border: 1px solid var(--line-strong);
background: color-mix(in srgb, var(--text) 6%, transparent);
border-radius: var(--radius-sm);
padding: 0.6rem 1.05rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-weight: 560;
font-size: 0.93rem;
transition: background 0.15s, border-color 0.15s, transform 0.1s var(--ease), opacity 0.15s, box-shadow 0.2s;
white-space: nowrap;
}
button:hover { background: color-mix(in srgb, var(--text) 11%, transparent); }
button:active { transform: scale(0.98); }
button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button.primary {
background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 60%, var(--accent-2)));
border-color: transparent;
color: white;
box-shadow: 0 0.5rem 1.6rem -0.6rem var(--accent);
}
button.primary:hover { filter: brightness(1.1); }
button.ghost { background: transparent; border-color: transparent; color: color-mix(in srgb, var(--text) 75%, transparent); }
button.ghost:hover { background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); }
button.danger { color: color-mix(in srgb, var(--danger) 70%, white); background: color-mix(in srgb, var(--danger) 12%, transparent); border-color: color-mix(in srgb, var(--danger) 30%, transparent); }
button.danger:hover { background: color-mix(in srgb, var(--danger) 20%, transparent); }
button.icon { padding: 0.5rem; }
button.sm { padding: 0.4rem 0.75rem; font-size: 0.85rem; }
input, select, textarea {
font: inherit;
font-size: 0.93rem;
width: 100%;
color: var(--text);
background: color-mix(in srgb, var(--bg) 70%, transparent);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
padding: 0.65rem 0.8rem;
transition: border-color 0.15s, background 0.15s;
}
input:hover, select:hover, textarea:hover { border-color: color-mix(in srgb, var(--text) 22%, transparent); }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); background: color-mix(in srgb, var(--bg) 90%, transparent); }
input::placeholder, textarea::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
select { appearance: none; padding-right: 2.2rem; background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%); background-position: calc(100% - 1.15rem) 55%, calc(100% - 0.8rem) 55%; background-size: 0.33rem 0.33rem; background-repeat: no-repeat; }
select option { background: var(--surface); }
input[type='range'] { padding: 0; border: none; background: transparent; accent-color: var(--accent); }
input[type='checkbox'] { width: auto; accent-color: var(--accent); }
textarea { resize: vertical; min-height: 5rem; line-height: 1.5; }
label.field { display: flex; flex-direction: column; gap: 0.45rem; font-size: 0.85rem; font-weight: 520; color: color-mix(in srgb, var(--text) 80%, transparent); }
.help { font-size: 0.8rem; color: var(--muted); font-weight: 400; line-height: 1.45; }
.chip {
display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.78rem; font-weight: 620;
padding: 0.28rem 0.7rem; border-radius: 99rem; background: color-mix(in srgb, var(--text) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--text) 12%, transparent); backdrop-filter: var(--blur); white-space: nowrap;
}
.chip.accent { background: color-mix(in srgb, var(--accent) 22%, transparent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
.segmented { display: inline-flex; padding: 0.2rem; gap: 0.15rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--bg) 70%, transparent); border: 1px solid var(--line-strong); }
.segmented button { border: none; background: transparent; padding: 0.45rem 0.9rem; font-size: 0.87rem; color: var(--muted); border-radius: calc(var(--radius-sm) - 2px); }
.segmented button.active { background: color-mix(in srgb, var(--text) 12%, transparent); color: var(--text); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); }
.col > .segmented { align-self: flex-start; }
.row { display: flex; align-items: center; gap: 0.75rem; }
.col { display: flex; flex-direction: column; gap: 0.75rem; }
.spacer { flex: 1; }
.muted { color: var(--muted); }
.small { font-size: 0.87rem; }
.tiny { font-size: 0.78rem; }
.spin { animation: spin 0.9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fade-up { from { opacity: 0; transform: translateY(0.6rem); } to { opacity: 1; transform: none; } }
@keyframes fade { from { opacity: 0; } }
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import type { Account } from '../lib/types';
let { account, size = 2.2 }: { account: Account | null; size?: number } = $props();
let failed = $state(false);
const hue = $derived([...(account?.username ?? '?')].reduce((a, c) => a + c.charCodeAt(0), 0) % 360);
// Microsoft accounts have real skins; everyone else gets a head by name.
const src = $derived(account ? `https://mc-heads.net/avatar/${account.kind === 'microsoft' ? account.uuid : encodeURIComponent(account.username)}/64` : '');
</script>
{#if account && !failed}
<img {src} alt="" style:width="{size}rem" style:height="{size}rem" onerror={() => (failed = true)} />
{:else}
<span class="fb" style:width="{size}rem" style:height="{size}rem" style:background="hsl({hue} 55% 42%)">{account?.username.slice(0, 1).toUpperCase() ?? '?'}</span>
{/if}
<style>
img, .fb { border-radius: 28%; image-rendering: pixelated; flex-shrink: 0; display: inline-grid; place-items: center; font-weight: 700; color: white; font-size: 0.9rem; }
</style>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
// Full-window backdrop: instance banner → admin background → animated gradient.
import { abs, app, selectedInstance } from '../lib/store.svelte';
const b = $derived(app.manifest?.branding);
const inst = $derived(selectedInstance());
const image = $derived(abs(inst?.banner_url) ?? (b?.background.kind === 'image' ? abs(b.background.url) : null));
const video = $derived(!inst?.banner_url && b?.background.kind === 'video' && app.settings?.background_video !== false && !app.running ? abs(b.background.url) : null);
const blur = $derived(b?.background.blur ?? 0);
const dim = $derived((b?.background.dim ?? 45) / 100);
let hidden = $state(false);
</script>
<svelte:document onvisibilitychange={() => (hidden = document.hidden)} />
<div class="bg" aria-hidden="true">
{#key image ?? video ?? 'gradient'}
<div class="layer">
{#if video && !hidden}
<video src={video} autoplay muted loop playsinline style:filter="blur({blur}px)"></video>
{:else if image}
<div class="img" style:background-image="url(&quot;{image}&quot;)" style:filter="blur({blur}px)"></div>
{:else}
<div class="gradient"><span class="o1"></span><span class="o2"></span><span class="o3"></span></div>
{/if}
</div>
{/key}
<div class="shade" style:--dim={dim}></div>
<div class="grain"></div>
</div>
<style>
.bg { position: fixed; inset: 0; z-index: 0; overflow: hidden; background: var(--bg); }
.layer { position: absolute; inset: 0; animation: fade 0.6s ease; }
video, .img { position: absolute; inset: -2rem; width: calc(100% + 4rem); height: calc(100% + 4rem); object-fit: cover; background-size: cover; background-position: center; }
.gradient { position: absolute; inset: 0; }
.gradient span { position: absolute; border-radius: 50%; filter: blur(5rem); opacity: 0.55; will-change: transform; }
.o1 { width: 55vw; height: 55vw; right: -12vw; top: -22vw; background: var(--accent); animation: drift 22s ease-in-out infinite alternate; }
.o2 { width: 45vw; height: 45vw; left: -10vw; bottom: -25vw; background: var(--accent-2); opacity: 0.3 !important; animation: drift 28s ease-in-out infinite alternate-reverse; }
.o3 { width: 30vw; height: 30vw; left: 35vw; top: 30vh; background: color-mix(in srgb, var(--accent) 50%, var(--accent-2)); opacity: 0.18 !important; animation: drift 34s ease-in-out infinite alternate; }
@keyframes drift { to { transform: translate(-6vw, 5vh) scale(1.15); } }
.shade {
position: absolute; inset: 0;
background:
linear-gradient(90deg, color-mix(in srgb, var(--bg) calc(var(--dim) * 100% + 30%), transparent) 0%, color-mix(in srgb, var(--bg) calc(var(--dim) * 100%), transparent) 45%, transparent 80%),
linear-gradient(0deg, color-mix(in srgb, var(--bg) 85%, transparent) 0%, transparent 45%),
color-mix(in srgb, var(--bg) calc(var(--dim) * 60%), transparent);
}
.grain { position: absolute; inset: 0; opacity: 0.035; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
</style>
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import { X, Copy, ArrowDown, Search, Trash2 } from '@lucide/svelte';
import { app, toast } from '../lib/store.svelte';
let box = $state<HTMLDivElement>();
let follow = $state(true);
let filter = $state('');
const shown = $derived(filter ? app.logs.filter((l) => l.toLowerCase().includes(filter.toLowerCase())) : app.logs.slice(-1500));
$effect(() => {
void shown.length;
if (follow && box) requestAnimationFrame(() => box && (box.scrollTop = box.scrollHeight));
});
function level(line: string) {
if (/\b(ERROR|FATAL|Exception)\b/.test(line)) return 'err';
if (/\bWARN\b/.test(line)) return 'warn';
return '';
}
</script>
<div class="console glass">
<header>
<strong>Game output</strong>
<span class="tiny muted">{app.logs.length.toLocaleString()} lines</span>
<span class="spacer"></span>
<div class="search"><Search size={13} /><input bind:value={filter} placeholder="Filter" /></div>
<button class="ghost icon sm" title="Copy" aria-label="Copy log" onclick={() => { navigator.clipboard.writeText(app.logs.join('\n')); toast('Log copied'); }}><Copy size={14} /></button>
<button class="ghost icon sm" title="Clear" aria-label="Clear log" onclick={() => (app.logs = [])}><Trash2 size={14} /></button>
<button class="ghost icon sm" aria-label="Close console" onclick={() => (app.consoleOpen = false)}><X size={15} /></button>
</header>
<div class="lines selectable" bind:this={box} onscroll={() => box && (follow = box.scrollHeight - box.scrollTop - box.clientHeight < 40)}>
{#each shown as line, i (i)}<div class="line {level(line)}">{line}</div>{:else}<div class="muted empty">Logs appear here while the game is running.</div>{/each}
</div>
{#if !follow}
<button class="jump sm" onclick={() => { follow = true; if (box) box.scrollTop = box.scrollHeight; }}><ArrowDown size={13} /> Latest</button>
{/if}
</div>
<style>
.console { position: absolute; left: 1rem; right: 1rem; bottom: 1rem; height: 42%; display: flex; flex-direction: column; z-index: 25; background: color-mix(in srgb, var(--bg) 97%, transparent); box-shadow: 0 -1rem 3rem -1rem rgba(0, 0, 0, 0.6); animation: fade-up 0.25s var(--ease); overflow: hidden; }
header { display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0.6rem 0.5rem 1rem; border-bottom: 1px solid var(--line); font-size: 0.88rem; }
.search { display: flex; align-items: center; gap: 0.3rem; color: var(--muted); }
.search input { width: 10rem; padding: 0.3rem 0.5rem; font-size: 0.8rem; }
.lines { flex: 1; overflow: auto; padding: 0.6rem 1rem; font-family: var(--mono); font-size: 0.74rem; line-height: 1.55; }
.line { white-space: pre-wrap; word-break: break-all; color: color-mix(in srgb, var(--text) 78%, transparent); }
.line.warn { color: var(--warn); }
.line.err { color: color-mix(in srgb, var(--danger) 75%, white); }
.empty { font-family: var(--font); padding: 1rem 0; }
.jump { position: absolute; right: 1.5rem; bottom: 1rem; background: var(--surface); }
</style>
@@ -0,0 +1,39 @@
<script lang="ts">
import { FolderOpen, FileText, Copy, RotateCcw } from '@lucide/svelte';
import Modal from './Modal.svelte';
import { app, play, toast } from '../lib/store.svelte';
import { invoke } from '../lib/tauri';
let open = $state(false);
$effect(() => {
open = !!app.crash;
});
const c = $derived(app.crash);
const hint = $derived.by(() => {
const text = c?.tail.join('\n') ?? '';
if (/OutOfMemoryError|Java heap space/.test(text)) return 'Minecraft ran out of memory. Give it more RAM in Settings → Game.';
if (/UnsupportedClassVersionError/.test(text)) return 'Wrong Java version. Switch Java back to "Automatic" in Settings → Java.';
if (/Could not reserve enough space|Invalid maximum heap size/.test(text)) return "Your PC can't give Minecraft that much memory. Lower it in Settings → Game.";
if (/Mixin|mixin.*failed|Incompatible mod set|requires .* but/i.test(text)) return 'A mod failed to load. If you added mods yourself, try removing them.';
return null;
});
</script>
<Modal bind:open title="Minecraft closed unexpectedly" width={40} onclose={() => (app.crash = null)}>
{#if c}
<p class="muted small">The game exited with code {c.code}. {hint ?? 'The last lines of the log are below — they usually explain what went wrong.'}</p>
{#if hint}<div class="hint">{hint}</div>{/if}
<pre class="tail selectable">{c.tail.slice(-40).join('\n')}</pre>
{/if}
{#snippet footer()}
<button class="ghost" onclick={() => { navigator.clipboard.writeText(c?.tail.join('\n') ?? ''); toast('Copied'); }}><Copy size={15} /> Copy</button>
{#if c?.crash_report}<button onclick={() => invoke('open_file', { path: c?.crash_report })}><FileText size={15} /> Crash report</button>{/if}
<button onclick={() => c && invoke('open_folder', { kind: 'logs', instanceId: c.instance_id })}><FolderOpen size={15} /> Logs</button>
<button class="primary" onclick={() => { const id = c?.instance_id; app.crash = null; if (id) play(id); }}><RotateCcw size={15} /> Try again</button>
{/snippet}
</Modal>
<style>
.hint { padding: 0.7rem 0.9rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--warn) 12%, transparent); border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); font-size: 0.88rem; }
.tail { margin: 0; max-height: 16rem; overflow: auto; font-family: var(--mono); font-size: 0.72rem; line-height: 1.5; padding: 0.8rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--bg) 80%, transparent); border: 1px solid var(--line); white-space: pre-wrap; word-break: break-all; }
</style>
@@ -0,0 +1,84 @@
<script lang="ts">
import { FolderOpen, Wrench, Trash2, FileCode } from '@lucide/svelte';
import Modal from './Modal.svelte';
import { app, repair, saveSettings, toast } from '../lib/store.svelte';
import { bytes, gb } from '../lib/format';
import { errorText, invoke, pickFile } from '../lib/tauri';
import type { InstanceOverride } from '../lib/types';
let open = $state(false);
$effect(() => {
open = !!app.instanceSettings;
});
const inst = $derived(app.manifest?.instances.find((i) => i.id === app.instanceSettings) ?? null);
const advanced = $derived(app.manifest?.branding.features.allow_advanced_java !== false);
const ram = $derived(app.boot?.app.total_ram_mb ?? 8192);
let local = $state<{ installed: boolean; size: number } | null>(null);
// Create the override entry up front (never mutate state inside $derived).
$effect(() => {
if (inst && app.settings && !app.settings.instance_overrides[inst.id]) {
app.settings.instance_overrides[inst.id] = { memory_max_mb: 0, memory_min_mb: 0, jvm_args: '', java_path: null };
}
});
const ov = $derived<InstanceOverride | undefined>(inst ? app.settings?.instance_overrides[inst.id] : undefined);
$effect(() => {
if (inst) invoke<{ installed: boolean; size: number }>('instance_local', { instanceId: inst.id }).then((l) => (local = l)).catch(() => {});
});
const folders = ['instance', 'mods', 'resourcepacks', 'shaderpacks', 'saves', 'screenshots', 'logs'];
async function del() {
if (!inst) return;
try {
await invoke('delete_instance_data', { instanceId: inst.id });
toast('Local files deleted — they download again next time you play');
local = { installed: false, size: 0 };
} catch (e) {
toast(errorText(e), 'error');
}
}
</script>
<Modal bind:open title={inst ? `${inst.name} settings` : ''} width={34} onclose={() => (app.instanceSettings = null)}>
{#if inst && app.settings && ov}
<div class="grid2">
{#each folders as f}
<button class="sm" onclick={() => invoke('open_folder', { kind: f, instanceId: inst.id })}><FolderOpen size={14} /> {f === 'instance' ? 'Game folder' : f}</button>
{/each}
</div>
<label class="field">
<span class="row">Memory for this instance <span class="spacer"></span><strong>{ov.memory_max_mb ? gb(ov.memory_max_mb) : `Default (${gb(app.settings.memory_max_mb || inst.memory.max_mb)})`}</strong></span>
<input type="range" min="0" max={Math.max(2048, ram - 1024)} step="512" bind:value={ov.memory_max_mb} oninput={saveSettings} />
<span class="help">Slide to the far left to use the global/admin default.</span>
</label>
{#if advanced}
<label class="field">Extra JVM arguments
<input class="mono" bind:value={ov.jvm_args} oninput={saveSettings} placeholder="-XX:+UseStringDeduplication" />
</label>
<label class="field">Java for this instance
<div class="row">
<input value={ov.java_path ?? ''} placeholder="Automatic" readonly />
<button class="sm" onclick={async () => { const p = await pickFile('Choose java executable', [{ name: 'Java', extensions: ['exe', ''] }]); if (p) { ov.java_path = p; saveSettings(); } }}><FileCode size={14} /> Browse</button>
{#if ov.java_path}<button class="sm ghost" onclick={() => { ov.java_path = null; saveSettings(); }}>Reset</button>{/if}
</div>
</label>
{/if}
<div class="danger-zone">
<span class="tiny muted">{local?.installed ? `Uses ${bytes(local.size)} on disk` : 'Not downloaded yet'}</span>
<span class="spacer"></span>
<button class="sm" onclick={() => { app.instanceSettings = null; repair(inst.id); }}><Wrench size={14} /> Repair files</button>
<button class="sm danger" onclick={del}><Trash2 size={14} /> Delete local files</button>
</div>
{/if}
</Modal>
<style>
.grid2 { display: grid; grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); gap: 0.45rem; }
.grid2 button { justify-content: flex-start; text-transform: capitalize; }
.danger-zone { display: flex; align-items: center; gap: 0.5rem; padding-top: 0.9rem; border-top: 1px solid var(--line); flex-wrap: wrap; }
</style>
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import type { Snippet } from 'svelte';
let { open = $bindable(false), title, width = 30, children, footer, onclose }: { open: boolean; title: string; width?: number; children: Snippet; footer?: Snippet; onclose?: () => void } = $props();
function close() {
open = false;
onclose?.();
}
</script>
<svelte:window onkeydown={(e) => open && e.key === 'Escape' && close()} />
{#if open}
<div class="backdrop" role="presentation" onclick={(e) => e.target === e.currentTarget && close()}>
<div class="modal glass" role="dialog" aria-modal="true" aria-label={title} style:max-width="{width}rem">
<header><h3>{title}</h3><button class="ghost icon" onclick={close} aria-label="Close"><X size={17} /></button></header>
<div class="body">{@render children()}</div>
{#if footer}<footer>{@render footer()}</footer>{/if}
</div>
</div>
{/if}
<style>
.backdrop { position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; padding: 1.5rem; background: color-mix(in srgb, var(--bg) 60%, transparent); backdrop-filter: blur(6px); animation: fade 0.15s ease; }
.modal { width: 100%; max-height: calc(100vh - 4rem); overflow: auto; background: color-mix(in srgb, var(--surface) 92%, transparent); box-shadow: 0 2rem 5rem -1rem rgba(0, 0, 0, 0.7); animation: pop 0.22s var(--ease); }
header { display: flex; align-items: center; justify-content: space-between; padding: 1.1rem 1.25rem 0; }
.body { padding: 1rem 1.25rem 1.25rem; display: flex; flex-direction: column; gap: 0.9rem; }
footer { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0.9rem 1.25rem; border-top: 1px solid var(--line); }
@keyframes pop { from { opacity: 0; transform: translateY(0.8rem) scale(0.97); } }
</style>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import { Newspaper, Pin, ArrowUpRight } from '@lucide/svelte';
import { abs } from '../lib/store.svelte';
import { niceDate } from '../lib/format';
import { openUrl } from '../lib/tauri';
import type { NewsItem } from '../lib/types';
let { news }: { news: NewsItem[] } = $props();
const sorted = $derived([...news].sort((a, b) => Number(b.pinned) - Number(a.pinned)));
</script>
<section class="news">
<h4 class="head"><Newspaper size={14} /> Latest news</h4>
<div class="list">
{#each sorted as n, i (n.id)}
<article class="card glass" style:animation-delay="{i * 60}ms" class:link={!!n.link}>
{#if n.image_url}<img src={abs(n.image_url)} alt="" loading="lazy" />{/if}
<div class="body">
<div class="meta">
{#if n.tag}<span class="tag">{n.tag}</span>{/if}
{#if n.pinned}<Pin size={11} />{/if}
<span class="spacer"></span>
<span class="tiny muted">{niceDate(n.date)}</span>
</div>
<h3>{n.title}</h3>
<p class="selectable">{n.body}</p>
{#if n.link}
<button class="more ghost sm" onclick={() => n.link && openUrl(n.link)}>Read more <ArrowUpRight size={13} /></button>
{/if}
</div>
</article>
{/each}
</div>
</section>
<style>
.news { display: flex; flex-direction: column; min-height: 0; gap: 0.7rem; }
.head { display: flex; align-items: center; gap: 0.45rem; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); font-weight: 750; padding-left: 0.2rem; }
.list { display: flex; flex-direction: column; gap: 0.7rem; overflow-y: auto; padding-right: 0.3rem; min-height: 0; mask-image: linear-gradient(180deg, black 85%, transparent); padding-bottom: 2rem; }
.card { overflow: hidden; flex-shrink: 0; animation: fade-up 0.35s var(--ease) both; transition: border-color 0.2s, transform 0.2s var(--ease); }
.card:hover { border-color: var(--line-strong); }
.card img { width: 100%; aspect-ratio: 16 / 7; object-fit: cover; display: block; }
.body { padding: 0.85rem 1rem 0.9rem; display: flex; flex-direction: column; gap: 0.35rem; }
.meta { display: flex; align-items: center; gap: 0.4rem; color: var(--accent); }
.tag { font-size: 0.66rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.1em; color: var(--accent); }
h3 { font-size: 1rem; font-weight: 680; line-height: 1.3; }
p { font-size: 0.84rem; line-height: 1.5; color: color-mix(in srgb, var(--text) 68%, transparent); display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.more { align-self: flex-start; padding-left: 0; color: var(--accent); margin-top: 0.1rem; }
.more:hover { background: transparent; color: var(--text); }
</style>
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import { Play, X, Square, LoaderCircle, TriangleAlert, RotateCcw } from '@lucide/svelte';
import { app, cancel, kill, play } from '../lib/store.svelte';
import { bytes } from '../lib/format';
import type { Instance } from '../lib/types';
let { inst }: { inst: Instance } = $props();
const l = $derived(app.launch);
const mine = $derived(app.launchingId === inst.id);
const busy = $derived(mine && l.state === 'preparing');
const running = $derived(app.running === inst.id);
const otherRunning = $derived(!!app.running && app.running !== inst.id);
const pct = $derived(l.total > 0 ? Math.min(100, (l.done / l.total) * 100) : 0);
const eta = $derived(l.speed > 0 && l.total > l.done ? Math.ceil((l.total - l.done) / l.speed) : 0);
const stageNames: Record<string, string> = { preparing: 'Preparing', java: 'Java', game: 'Game files', loader: 'Mod loader', files: 'Instance files', launching: 'Launching' };
const noAccount = $derived(!app.active);
</script>
<svelte:window onkeydown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !busy && !running && !otherRunning && !noAccount && app.view === 'home') play(inst.id);
}} />
<div class="wrap">
{#if busy}
<div class="progress glass">
<div class="top">
<span class="stage">
{#if l.stage === 'launching' || !l.total}<LoaderCircle class="spin" size={15} />{/if}
{l.label || 'Preparing…'}
</span>
<button class="ghost icon sm" onclick={cancel} aria-label="Cancel" title="Cancel"><X size={15} /></button>
</div>
<div class="bar" class:indeterminate={!l.total}><span style:width="{pct}%"></span></div>
<div class="meta tiny muted">
<span>{l.stage ? stageNames[l.stage] : ''}{l.filesTotal ? ` · ${l.filesDone.toLocaleString()} / ${l.filesTotal.toLocaleString()} files` : ''}</span>
<span>{#if l.total}{bytes(l.done)} / {bytes(l.total)}{#if l.speed > 0} · {bytes(l.speed)}/s{#if eta} · {eta < 60 ? `${eta}s` : `${Math.ceil(eta / 60)}m`} left{/if}{/if}{/if}</span>
</div>
</div>
{:else if running}
<button class="play running" onclick={kill} title="Force-close Minecraft">
<span class="pulse"></span>
<span class="lbl">PLAYING</span>
<span class="stop"><Square size={13} fill="currentColor" /> Stop</span>
</button>
{:else}
<button class="play" onclick={() => play(inst.id)} disabled={otherRunning || noAccount} title={noAccount ? 'Sign in first' : otherRunning ? 'Another instance is running' : 'Ctrl + Enter'}>
<Play size={22} fill="currentColor" />
<span class="lbl">PLAY</span>
</button>
{/if}
{#if mine && l.state === 'error'}
<div class="error glass selectable">
<TriangleAlert size={17} />
<span>{l.message}</span>
<button class="sm" onclick={() => play(inst.id)}><RotateCcw size={13} /> Retry</button>
</div>
{/if}
</div>
<style>
.wrap { display: flex; flex-direction: column; align-items: flex-start; gap: 0.7rem; }
.play {
position: relative; overflow: hidden; height: 4.2rem; min-width: 19rem; padding: 0 2.6rem; border: none; border-radius: var(--radius);
background: linear-gradient(120deg, var(--accent), var(--accent-2)); color: white; font-size: 1.35rem; font-weight: 800; letter-spacing: 0.12em;
box-shadow: 0 1rem 2.6rem -0.9rem var(--accent), inset 0 1px 0 rgba(255, 255, 255, 0.25); gap: 0.8rem;
transition: transform 0.2s var(--ease), box-shadow 0.25s, filter 0.2s;
}
.play::before { content: ''; position: absolute; inset: 0; background: linear-gradient(100deg, transparent 30%, rgba(255, 255, 255, 0.28) 50%, transparent 70%); transform: translateX(-100%); transition: transform 0.7s var(--ease); }
.play:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 1.4rem 3rem -0.9rem var(--accent), inset 0 1px 0 rgba(255, 255, 255, 0.3); filter: brightness(1.05); }
.play:hover:not(:disabled)::before { transform: translateX(100%); }
.play:active:not(:disabled) { transform: translateY(0) scale(0.985); }
.play:disabled { filter: grayscale(0.7) brightness(0.7); }
.lbl { position: relative; }
.running { background: linear-gradient(120deg, color-mix(in srgb, var(--success) 70%, var(--bg)), color-mix(in srgb, var(--success) 45%, var(--accent-2))); box-shadow: 0 1rem 2.6rem -1rem var(--success); padding: 0 1.6rem 0 2rem; }
.pulse { width: 0.7rem; height: 0.7rem; border-radius: 50%; background: white; box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); animation: ping 1.6s infinite; }
@keyframes ping { 70% { box-shadow: 0 0 0 0.7rem rgba(255, 255, 255, 0); } 100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); } }
.stop { font-size: 0.78rem; letter-spacing: 0.04em; font-weight: 650; display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.35rem 0.7rem; border-radius: 99rem; background: rgba(0, 0, 0, 0.2); opacity: 0.85; }
.running:hover .stop { background: rgba(0, 0, 0, 0.35); opacity: 1; }
.progress { width: 28rem; max-width: 100%; padding: 0.95rem 1.1rem 0.85rem; display: flex; flex-direction: column; gap: 0.6rem; animation: fade-up 0.25s var(--ease); }
.top { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.stage { display: flex; align-items: center; gap: 0.5rem; font-weight: 620; font-size: 0.95rem; }
.bar { height: 0.5rem; border-radius: 99rem; background: color-mix(in srgb, var(--text) 10%, transparent); overflow: hidden; position: relative; }
.bar span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent), var(--accent-2)); transition: width 0.2s linear; box-shadow: 0 0 1rem var(--accent); }
.bar.indeterminate span { width: 35% !important; animation: slide 1.2s ease-in-out infinite; }
@keyframes slide { from { transform: translateX(-100%); } to { transform: translateX(300%); } }
.meta { display: flex; justify-content: space-between; gap: 1rem; font-variant-numeric: tabular-nums; }
.error { display: flex; align-items: center; gap: 0.7rem; padding: 0.7rem 0.9rem; max-width: 34rem; font-size: 0.87rem; border-color: color-mix(in srgb, var(--danger) 35%, transparent); background: color-mix(in srgb, var(--danger) 14%, var(--panel)); line-height: 1.4; }
.error :global(svg) { color: var(--danger); flex-shrink: 0; }
.error span { flex: 1; }
</style>
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import { Settings, Plus, Check, LogOut, Home } from '@lucide/svelte';
import Avatar from './Avatar.svelte';
import { abs, activeAccount, app, instances, removeAccount, selectInstance, switchAccount } from '../lib/store.svelte';
let menu = $state(false);
const kindLabel = { panel: 'Account', microsoft: 'Microsoft', offline: 'Offline' };
const initials = (name: string) => name.split(/\s+/).map((w) => w[0]).join('').slice(0, 2).toUpperCase();
</script>
<svelte:window onclick={(e) => menu && !(e.target as HTMLElement).closest('.account') && (menu = false)} />
<nav class="rail glass">
<div class="list">
{#each instances() as inst (inst.id)}
<button
class="inst"
class:active={app.view === 'home' && app.selected === inst.id}
class:running={app.running === inst.id}
onclick={() => { selectInstance(inst.id); app.view = 'home'; }}
aria-label={inst.name}
>
{#if inst.icon_url}<img src={abs(inst.icon_url)} alt="" />{:else}<span>{initials(inst.name)}</span>{/if}
<em class="tip">{inst.name}</em>
</button>
{/each}
</div>
<div class="bottom">
{#if app.view === 'settings'}
<button class="ghost icon big" onclick={() => (app.view = 'home')} aria-label="Home"><Home size={20} /></button>
{/if}
<button class="ghost icon big" class:on={app.view === 'settings'} onclick={() => (app.view = 'settings')} aria-label="Settings">
<Settings size={20} />
{#if app.update}<span class="dot"></span>{/if}
</button>
<div class="account">
<button class="me" onclick={() => (menu = !menu)} aria-label="Accounts">
<Avatar account={activeAccount()} size={2.3} />
</button>
{#if menu}
<div class="menu glass">
<span class="tiny muted head">Accounts</span>
{#each app.accounts as a (a.id)}
<div class="acc" class:current={a.id === app.active}>
<button class="pick" onclick={() => { switchAccount(a.id); menu = false; }}>
<Avatar account={a} size={1.9} />
<span class="who"><strong>{a.username}</strong><span class="tiny muted">{kindLabel[a.kind]}</span></span>
{#if a.id === app.active}<Check size={16} />{/if}
</button>
<button class="ghost icon" title="Sign out" aria-label="Sign out {a.username}" onclick={() => removeAccount(a.id)}><LogOut size={14} /></button>
</div>
{/each}
<button class="add" onclick={() => { app.addAccount = true; menu = false; }}><Plus size={16} /> Add account</button>
</div>
{/if}
</div>
</div>
</nav>
<style>
.rail { width: 4.8rem; margin: 0 0 0.8rem 0.8rem; display: flex; flex-direction: column; align-items: center; padding: 0.8rem 0; gap: 0.5rem; border-radius: calc(var(--radius) + 4px); position: relative; z-index: 20; flex-shrink: 0; }
.list { display: flex; flex-direction: column; gap: 0.6rem; align-items: center; overflow-y: auto; overflow-x: visible; flex: 1; width: 100%; padding: 0.2rem 0; scrollbar-width: none; }
.inst {
width: 3.1rem; height: 3.1rem; padding: 0; border-radius: var(--radius); position: relative; flex-shrink: 0; overflow: visible;
background: color-mix(in srgb, var(--surface) 80%, var(--text) 6%); border-color: var(--line); font-weight: 750; font-size: 0.95rem;
transition: transform 0.2s var(--ease), box-shadow 0.2s, border-radius 0.2s;
}
.inst img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
.inst:hover { transform: translateY(-1px); border-radius: calc(var(--radius) * 0.75); }
.inst.active { background: linear-gradient(135deg, var(--accent), var(--accent-2)); border-color: transparent; box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent), 0 0.5rem 1.5rem -0.5rem var(--accent); color: white; }
.inst.running::after { content: ''; position: absolute; right: -0.2rem; bottom: -0.2rem; width: 0.8rem; height: 0.8rem; border-radius: 50%; background: var(--success); border: 2px solid var(--surface); box-shadow: 0 0 0.6rem var(--success); }
.tip { position: fixed; left: 6.4rem; transform: translateY(0); font-style: normal; font-size: 0.82rem; font-weight: 600; white-space: nowrap; padding: 0.35rem 0.7rem; border-radius: 0.5rem; background: var(--surface); border: 1px solid var(--line-strong); opacity: 0; pointer-events: none; transition: opacity 0.15s; box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.4); z-index: 50; color: var(--text); }
.inst:hover .tip { opacity: 1; }
.bottom { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; }
.big { width: 2.8rem; height: 2.8rem; position: relative; }
.big.on { color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); }
.dot { position: absolute; top: 0.55rem; right: 0.55rem; width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--accent-2); box-shadow: 0 0 0.5rem var(--accent-2); }
.account { position: relative; margin-top: 0.3rem; }
.me { padding: 0.15rem; border-radius: 30%; background: transparent; border-color: transparent; }
.me:hover { border-color: var(--accent); background: transparent; }
.menu { position: absolute; left: calc(100% + 0.9rem); bottom: 0; width: 17rem; padding: 0.6rem; display: flex; flex-direction: column; gap: 0.25rem; background: color-mix(in srgb, var(--surface) 99%, var(--text) 1%); backdrop-filter: none; box-shadow: 0 1.5rem 4rem -1rem rgba(0, 0, 0, 0.7); animation: fade-up 0.18s var(--ease); z-index: 60; }
.head { padding: 0.2rem 0.5rem 0.4rem; text-transform: uppercase; letter-spacing: 0.08em; font-weight: 700; }
.acc { display: flex; align-items: center; gap: 0.2rem; border-radius: var(--radius-sm); }
.acc.current { background: color-mix(in srgb, var(--accent) 12%, transparent); }
.pick { flex: 1; justify-content: flex-start; background: transparent; border-color: transparent; padding: 0.45rem 0.5rem; gap: 0.65rem; min-width: 0; }
.pick :global(svg) { color: var(--accent); }
.who { display: flex; flex-direction: column; align-items: flex-start; flex: 1; min-width: 0; }
.who strong { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.add { margin-top: 0.3rem; border-style: dashed; background: transparent; }
</style>
@@ -0,0 +1,88 @@
<script lang="ts">
import { Users, Signal, Copy, Check } from '@lucide/svelte';
import { parseMotd } from '../lib/motd';
import { invoke } from '../lib/tauri';
import type { ServerEntry, ServerStatus } from '../lib/types';
let { server }: { server: ServerEntry } = $props();
let status = $state<ServerStatus | null>(null);
let failed = $state(false);
let copied = $state(false);
$effect(() => {
const { address, port } = server;
status = null;
failed = false;
let alive = true;
const load = () =>
invoke<ServerStatus>('ping_server', { host: address, port })
.then((s) => alive && ((status = s), (failed = false)))
.catch(() => alive && (failed = true));
load();
const t = setInterval(() => !document.hidden && load(), 30_000);
return () => {
alive = false;
clearInterval(t);
};
});
const addr = $derived(server.port === 25565 ? server.address : `${server.address}:${server.port}`);
const bars = $derived(!status ? 0 : status.latency_ms < 60 ? 4 : status.latency_ms < 120 ? 3 : status.latency_ms < 250 ? 2 : 1);
function copy() {
navigator.clipboard.writeText(addr);
copied = true;
setTimeout(() => (copied = false), 1500);
}
</script>
<div class="server glass">
{#if status?.favicon}<img src={status.favicon} alt="" class="fav" />{:else}<span class="fav ph" class:on={status}></span>{/if}
<div class="info">
<div class="row name">
<strong>{server.name || server.address}</strong>
<span class="state" class:online={status} class:off={failed}>{status ? 'Online' : failed ? 'Offline' : 'Checking…'}</span>
</div>
{#if status}
<div class="motd">{#each parseMotd(status.motd.split('\n')[0]) as s}<span style:color={s.color} class:b={s.bold} class:i={s.italic}>{s.text}</span>{/each}</div>
{:else}
<button class="addr" onclick={copy}>{addr} {#if copied}<Check size={12} />{:else}<Copy size={12} />{/if}</button>
{/if}
</div>
{#if status}
<div class="stats">
<span class="players" title={status.sample.join(', ')}><Users size={14} /> <b>{status.players_online}</b><span class="muted">/{status.players_max}</span></span>
<span class="ping" title="{status.latency_ms} ms">
<span class="sig">{#each [1, 2, 3, 4] as i}<i class:lit={i <= bars} style:height="{i * 25}%"></i>{/each}</span>
{status.latency_ms}ms
</span>
</div>
{/if}
</div>
<style>
.server { display: flex; align-items: center; gap: 0.85rem; padding: 0.7rem 1rem 0.7rem 0.7rem; min-width: 20rem; max-width: 30rem; }
.fav { width: 2.6rem; height: 2.6rem; border-radius: calc(var(--radius-sm) - 2px); image-rendering: pixelated; flex-shrink: 0; }
.ph { background: color-mix(in srgb, var(--text) 8%, transparent); }
.ph.on { background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 60%, transparent), color-mix(in srgb, var(--accent-2) 60%, transparent)); }
.info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.2rem; }
.name { gap: 0.5rem; }
.name strong { font-size: 0.92rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.state { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); display: inline-flex; align-items: center; gap: 0.3rem; }
.state::before { content: ''; width: 0.45rem; height: 0.45rem; border-radius: 50%; background: currentColor; }
.state.online { color: var(--success); }
.state.online::before { box-shadow: 0 0 0.5rem var(--success); }
.state.off { color: var(--danger); }
.motd { font-size: 0.8rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: color-mix(in srgb, var(--text) 75%, transparent); }
.motd .b { font-weight: 700; }
.motd .i { font-style: italic; }
.addr { all: unset; cursor: pointer; font-size: 0.8rem; color: var(--muted); display: inline-flex; align-items: center; gap: 0.35rem; font-family: var(--mono); }
.addr:hover { color: var(--text); }
.stats { display: flex; flex-direction: column; align-items: flex-end; gap: 0.2rem; font-size: 0.8rem; font-variant-numeric: tabular-nums; }
.players { display: inline-flex; align-items: center; gap: 0.3rem; }
.players :global(svg) { color: var(--success); }
.ping { display: inline-flex; align-items: center; gap: 0.35rem; color: var(--muted); font-size: 0.72rem; }
.sig { display: inline-flex; align-items: flex-end; gap: 0.1rem; height: 0.7rem; }
.sig i { width: 0.18rem; background: color-mix(in srgb, var(--text) 20%, transparent); border-radius: 1px; }
.sig i.lit { background: var(--success); }
</style>
+160
View File
@@ -0,0 +1,160 @@
<script lang="ts">
import { UserRound, WifiOff, LoaderCircle, Copy, ExternalLink, Check, ArrowRight, UserPlus } from '@lucide/svelte';
import { accountAdded, app, toast } from '../lib/store.svelte';
import { errorText, invoke, openUrl } from '../lib/tauri';
import type { Account, DeviceCode } from '../lib/types';
const auth = $derived(app.manifest?.auth);
const methods = $derived(
[
auth?.panel_accounts !== false && { id: 'panel', label: 'Account' },
auth?.microsoft && { id: 'microsoft', label: 'Microsoft' },
auth?.offline_local !== false && { id: 'offline', label: 'Offline' },
].filter(Boolean) as { id: string; label: string }[],
);
let method = $state('');
$effect(() => {
if (!methods.some((m) => m.id === method)) method = methods[0]?.id ?? 'offline';
});
let username = $state('');
let password = $state('');
let email = $state('');
let registering = $state(false);
let busy = $state(false);
let error = $state('');
let pendingNotice = $state(false);
let code = $state<DeviceCode | null>(null);
let copied = $state(false);
const canRegister = $derived(auth?.registration && auth.registration !== 'closed');
async function submit(e: SubmitEvent) {
e.preventDefault();
error = '';
busy = true;
try {
if (method === 'offline') {
await accountAdded(await invoke<Account>('add_offline', { username }));
} else if (registering) {
const r = await invoke<{ account: Account | null; pending: boolean }>('register_panel', { username, password, email: email || null });
if (r.pending) pendingNotice = true;
else if (r.account) await accountAdded(r.account);
} else {
await accountAdded(await invoke<Account>('login_panel', { username, password }));
}
if (!pendingNotice) toast(`Signed in as ${username}`);
} catch (err) {
error = errorText(err);
} finally {
busy = false;
}
}
async function microsoft() {
error = '';
busy = true;
try {
code = await invoke<DeviceCode>('ms_start');
openUrl(code.verification_uri);
const account = await invoke<Account>('ms_finish');
code = null;
await accountAdded(account);
toast(`Signed in as ${account.username}`);
} catch (err) {
if (errorText(err) !== 'cancelled') error = errorText(err);
code = null;
} finally {
busy = false;
}
}
function copy() {
if (!code) return;
navigator.clipboard.writeText(code.user_code);
copied = true;
setTimeout(() => (copied = false), 1500);
}
</script>
<div class="signin">
{#if methods.length > 1}
<div class="segmented tabs">
{#each methods as m}
<button class:active={method === m.id} onclick={() => { method = m.id; error = ''; registering = false; }}>{m.label}</button>
{/each}
</div>
{/if}
{#if pendingNotice}
<div class="notice">
<Check size={22} />
<div><strong>Account created!</strong><p class="small muted">An admin needs to approve it before you can sign in. Check back soon.</p></div>
</div>
<button onclick={() => { pendingNotice = false; registering = false; }}>Back to sign in</button>
{:else if method === 'microsoft'}
{#if code}
<div class="code">
<p class="small muted">Go to <strong>{code.verification_uri.replace('https://', '')}</strong> and enter:</p>
<button class="usercode" onclick={copy} title="Copy">{code.user_code} {#if copied}<Check size={18} />{:else}<Copy size={16} />{/if}</button>
<div class="row">
<button class="sm" onclick={() => code && openUrl(code.verification_uri)}><ExternalLink size={14} /> Open page</button>
<button class="sm ghost" onclick={() => invoke('ms_cancel')}>Cancel</button>
</div>
<p class="tiny muted row wait"><LoaderCircle class="spin" size={14} /> Waiting for you to finish in the browser…</p>
</div>
{:else}
<p class="small muted">Own Minecraft? Sign in with Microsoft for your real skin and access to online-mode servers.</p>
<button class="primary big ms" onclick={microsoft} disabled={busy}>
<svg viewBox="0 0 21 21" width="18" height="18" aria-hidden="true"><rect x="1" y="1" width="9" height="9" fill="#f25022"/><rect x="11" y="1" width="9" height="9" fill="#7fba00"/><rect x="1" y="11" width="9" height="9" fill="#00a4ef"/><rect x="11" y="11" width="9" height="9" fill="#ffb900"/></svg>
Sign in with Microsoft
</button>
{/if}
{:else}
<form class="col" onsubmit={submit}>
<label class="field">
{method === 'offline' ? 'Username' : 'Username'}
<div class="inp"><span class="ic">{#if method === 'offline'}<WifiOff size={16} />{:else}<UserRound size={16} />{/if}</span>
<input bind:value={username} autocomplete="username" placeholder={method === 'offline' ? 'Steve' : ''} maxlength="16" required />
</div>
{#if method === 'offline'}<span class="help">Play without an account. You'll only see public instances.</span>{/if}
</label>
{#if method === 'panel'}
<label class="field">Password<input type="password" bind:value={password} autocomplete={registering ? 'new-password' : 'current-password'} required minlength={registering ? 8 : undefined} /></label>
{#if registering}
<label class="field">Email <span class="help">Optional</span><input type="email" bind:value={email} /></label>
{/if}
{/if}
<button class="primary big" disabled={busy || !username}>
{#if busy}<LoaderCircle class="spin" size={18} />{:else if registering}<UserPlus size={18} />{:else}<ArrowRight size={18} />{/if}
{method === 'offline' ? 'Play offline' : registering ? 'Create account' : 'Sign in'}
</button>
{#if method === 'panel' && canRegister}
<button type="button" class="ghost sm switch" onclick={() => { registering = !registering; error = ''; }}>
{registering ? 'Already have an account? Sign in' : "Don't have an account? Sign up"}
</button>
{/if}
</form>
{/if}
{#if error}<div class="error selectable">{error}</div>{/if}
</div>
<style>
.signin { display: flex; flex-direction: column; gap: 1rem; }
.tabs { width: 100%; }
.tabs button { flex: 1; }
.inp { position: relative; }
.inp .ic { position: absolute; left: 0.8rem; top: 50%; transform: translateY(-50%); color: var(--muted); display: flex; }
.inp input { padding-left: 2.4rem; }
.big { padding: 0.85rem; font-size: 1rem; }
.ms { background: #2f2f2f; box-shadow: none; }
.ms:hover { background: #3a3a3a; }
.switch { align-self: center; color: var(--muted); }
.code { display: flex; flex-direction: column; align-items: center; gap: 0.8rem; text-align: center; padding: 0.5rem 0; }
.usercode { font-family: var(--mono); font-size: 1.7rem; letter-spacing: 0.15em; padding: 0.7rem 1.4rem; font-weight: 700; border-color: color-mix(in srgb, var(--accent) 50%, transparent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
.wait { gap: 0.4rem; }
.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); border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); }
.notice { display: flex; gap: 0.8rem; align-items: flex-start; padding: 1rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--success) 12%, transparent); }
.notice :global(svg) { color: var(--success); flex-shrink: 0; }
</style>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import { Minus, Square, X, WifiOff } from '@lucide/svelte';
import { abs, app } from '../lib/store.svelte';
import { windowAction } from '../lib/tauri';
const b = $derived(app.manifest?.branding);
</script>
<header data-tauri-drag-region>
<div class="brand" data-tauri-drag-region>
{#if b?.logo_url}
<img src={abs(b.logo_url)} alt="" />
{:else}
<span class="mark"></span>
{/if}
<span data-tauri-drag-region>{b?.name ?? 'Launcher'}</span>
{#if app.offline}<span class="offline" title="Can't reach the server — using saved data"><WifiOff size={12} /> Offline</span>{/if}
</div>
<div class="controls">
<button class="ctl" onclick={() => windowAction('minimize')} aria-label="Minimise"><Minus size={15} /></button>
<button class="ctl" onclick={() => windowAction('maximize')} aria-label="Maximise"><Square size={12} /></button>
<button class="ctl close" onclick={() => windowAction('close')} aria-label="Close"><X size={16} /></button>
</div>
</header>
<style>
header { height: 2.6rem; display: flex; align-items: center; justify-content: space-between; padding-left: 1rem; position: relative; z-index: 30; flex-shrink: 0; }
.brand { display: flex; align-items: center; gap: 0.6rem; font-weight: 650; font-size: 0.9rem; pointer-events: none; }
.brand img { height: 1.3rem; border-radius: 0.3rem; }
.mark { width: 0.95rem; height: 0.95rem; border-radius: 0.3rem; background: linear-gradient(135deg, var(--accent), var(--accent-2)); box-shadow: 0 0 0.8rem var(--accent); }
.offline { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.72rem; color: var(--warn); background: color-mix(in srgb, var(--warn) 14%, transparent); padding: 0.15rem 0.5rem; border-radius: 99rem; pointer-events: auto; }
.controls { display: flex; height: 100%; }
.ctl { height: 100%; width: 3rem; border: none; border-radius: 0; background: transparent; color: color-mix(in srgb, var(--text) 70%, transparent); padding: 0; }
.ctl:hover { background: color-mix(in srgb, var(--text) 10%, transparent); color: var(--text); }
.ctl:active { transform: none; }
.close:hover { background: #e81123; color: white; }
</style>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import { CircleCheck, CircleAlert, Info } from '@lucide/svelte';
import { app } from '../lib/store.svelte';
</script>
<div class="toasts" aria-live="polite">
{#each app.toasts as t (t.id)}
<div class="toast glass {t.kind}">
{#if t.kind === 'ok'}<CircleCheck size={17} />{:else if t.kind === 'error'}<CircleAlert size={17} />{:else}<Info size={17} />{/if}
<span class="selectable">{t.text}</span>
</div>
{/each}
</div>
<style>
.toasts { position: fixed; bottom: 1.25rem; right: 1.25rem; z-index: 100; display: flex; flex-direction: column; gap: 0.6rem; max-width: 26rem; }
.toast { display: flex; gap: 0.6rem; align-items: flex-start; padding: 0.75rem 1rem; font-size: 0.88rem; line-height: 1.4; background: color-mix(in srgb, var(--surface) 90%, transparent); box-shadow: 0 1rem 3rem -1rem rgba(0, 0, 0, 0.6); animation: fade-up 0.25s var(--ease); }
.toast :global(svg) { flex-shrink: 0; margin-top: 0.1rem; }
.ok :global(svg) { color: var(--success); }
.error :global(svg) { color: var(--danger); }
.info :global(svg) { color: var(--accent-2); }
</style>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
let { checked = $bindable(false), label, help, disabled = false, onchange }: { checked: boolean; label: string; help?: string; disabled?: boolean; onchange?: () => void } = $props();
</script>
<label class="toggle" class:disabled>
<span class="text"><span class="label">{label}</span>{#if help}<span class="help">{help}</span>{/if}</span>
<input type="checkbox" role="switch" bind:checked {disabled} onchange={() => onchange?.()} />
<span class="track" aria-hidden="true"><span class="thumb"></span></span>
</label>
<style>
.toggle { display: flex; align-items: center; gap: 1rem; cursor: pointer; padding: 0.35rem 0; }
.disabled { opacity: 0.5; cursor: not-allowed; }
.text { flex: 1; display: flex; flex-direction: column; gap: 0.15rem; }
.label { font-weight: 560; font-size: 0.93rem; }
input { position: absolute; opacity: 0; pointer-events: none; }
.track { width: 2.75rem; height: 1.55rem; border-radius: 99rem; background: color-mix(in srgb, var(--text) 14%, transparent); position: relative; transition: background 0.2s; flex-shrink: 0; }
.thumb { position: absolute; top: 0.18rem; left: 0.18rem; width: 1.19rem; height: 1.19rem; border-radius: 50%; background: color-mix(in srgb, var(--text) 80%, transparent); transition: transform 0.25s cubic-bezier(0.3, 1.4, 0.6, 1), background 0.2s; }
input:checked + .track { background: var(--accent); }
input:checked + .track .thumb { transform: translateX(1.2rem); background: white; }
input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
</style>
+28
View File
@@ -0,0 +1,28 @@
export function bytes(n: number): string {
if (!n) return '0 B';
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
return `${(n / 1024 ** i).toFixed(i > 1 ? 1 : 0)} ${u[i]}`;
}
export function gb(mb: number): string {
return `${(mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 1)} GB`;
}
export const loaderName: Record<string, string> = {
vanilla: 'Vanilla',
fabric: 'Fabric',
quilt: 'Quilt',
forge: 'Forge',
neoforge: 'NeoForge',
};
export function shortLoaderVersion(mc: string, v: string | null): string {
return v ? v.replace(`${mc}-`, '') : '';
}
export function niceDate(iso: string | null): string {
if (!iso) return '';
const d = new Date(iso + (iso.length === 10 ? 'T00:00:00' : ''));
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: d.getFullYear() === new Date().getFullYear() ? undefined : 'numeric' });
}
+90
View File
@@ -0,0 +1,90 @@
// Minecraft (1.13+) key names used in options.txt, and friendly labels.
export interface Binding { id: string; label: string; default: string }
export const BIND_GROUPS: { title: string; binds: Binding[] }[] = [
{
title: 'Movement',
binds: [
{ id: 'key.forward', label: 'Walk forwards', default: 'key.keyboard.w' },
{ id: 'key.left', label: 'Strafe left', default: 'key.keyboard.a' },
{ id: 'key.back', label: 'Walk backwards', default: 'key.keyboard.s' },
{ id: 'key.right', label: 'Strafe right', default: 'key.keyboard.d' },
{ id: 'key.jump', label: 'Jump', default: 'key.keyboard.space' },
{ id: 'key.sneak', label: 'Sneak', default: 'key.keyboard.left.shift' },
{ id: 'key.sprint', label: 'Sprint', default: 'key.keyboard.left.control' },
],
},
{
title: 'Gameplay',
binds: [
{ id: 'key.attack', label: 'Attack / destroy', default: 'key.mouse.left' },
{ id: 'key.use', label: 'Use item / place block', default: 'key.mouse.right' },
{ id: 'key.pickItem', label: 'Pick block', default: 'key.mouse.middle' },
{ id: 'key.drop', label: 'Drop item', default: 'key.keyboard.q' },
{ id: 'key.swapOffhand', label: 'Swap to offhand', default: 'key.keyboard.f' },
],
},
{
title: 'Interface',
binds: [
{ id: 'key.inventory', label: 'Inventory', default: 'key.keyboard.e' },
{ id: 'key.chat', label: 'Open chat', default: 'key.keyboard.t' },
{ id: 'key.command', label: 'Open command', default: 'key.keyboard.slash' },
{ id: 'key.playerlist', label: 'Player list', default: 'key.keyboard.tab' },
{ id: 'key.advancements', label: 'Advancements', default: 'key.keyboard.l' },
{ id: 'key.socialInteractions', label: 'Social interactions', default: 'key.keyboard.p' },
{ id: 'key.screenshot', label: 'Screenshot', default: 'key.keyboard.f2' },
{ id: 'key.togglePerspective', label: 'Toggle perspective', default: 'key.keyboard.f5' },
{ id: 'key.fullscreen', label: 'Toggle fullscreen', default: 'key.keyboard.f11' },
],
},
{
title: 'Hotbar',
binds: Array.from({ length: 9 }, (_, i) => ({ id: `key.hotbar.${i + 1}`, label: `Hotbar slot ${i + 1}`, default: `key.keyboard.${i + 1}` })),
},
];
const SPECIAL: Record<string, string> = {
Space: 'space', ShiftLeft: 'left.shift', ShiftRight: 'right.shift', ControlLeft: 'left.control', ControlRight: 'right.control',
AltLeft: 'left.alt', AltRight: 'right.alt', Tab: 'tab', Enter: 'enter', Backspace: 'backspace', CapsLock: 'caps.lock',
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right', Backquote: 'grave.accent', Minus: 'minus',
Equal: 'equal', BracketLeft: 'left.bracket', BracketRight: 'right.bracket', Backslash: 'backslash', Semicolon: 'semicolon',
Quote: 'apostrophe', Comma: 'comma', Period: 'period', Slash: 'slash', Insert: 'insert', Delete: 'delete', Home: 'home',
End: 'end', PageUp: 'page.up', PageDown: 'page.down', NumpadAdd: 'keypad.add', NumpadSubtract: 'keypad.subtract',
NumpadMultiply: 'keypad.multiply', NumpadDivide: 'keypad.divide', NumpadEnter: 'keypad.enter', NumpadDecimal: 'keypad.decimal',
};
/** KeyboardEvent.code → Minecraft key name (null for unsupported keys). */
export function fromKeyboard(code: string): string | null {
if (/^Key[A-Z]$/.test(code)) return `key.keyboard.${code.slice(3).toLowerCase()}`;
if (/^Digit\d$/.test(code)) return `key.keyboard.${code.slice(5)}`;
if (/^F\d{1,2}$/.test(code)) return `key.keyboard.${code.toLowerCase()}`;
if (/^Numpad\d$/.test(code)) return `key.keyboard.keypad.${code.slice(6)}`;
return SPECIAL[code] ? `key.keyboard.${SPECIAL[code]}` : null;
}
export function fromMouse(button: number): string {
return ['key.mouse.left', 'key.mouse.middle', 'key.mouse.right', 'key.mouse.4', 'key.mouse.5'][button] ?? `key.mouse.${button + 1}`;
}
/** Human label for a Minecraft key name. */
export function keyLabel(key: string): string {
if (!key || key === 'key.keyboard.unknown') return 'Not bound';
const mouse = key.match(/^key\.mouse\.(.+)$/);
if (mouse) return { left: 'Left click', right: 'Right click', middle: 'Middle click' }[mouse[1]] ?? `Mouse ${mouse[1]}`;
const k = key.replace('key.keyboard.', '');
const names: Record<string, string> = {
'left.shift': 'Left Shift', 'right.shift': 'Right Shift', 'left.control': 'Left Ctrl', 'right.control': 'Right Ctrl',
'left.alt': 'Left Alt', 'right.alt': 'Right Alt', 'caps.lock': 'Caps Lock', 'page.up': 'Page Up', 'page.down': 'Page Down',
'grave.accent': '`', 'left.bracket': '[', 'right.bracket': ']', slash: '/', backslash: '\\', semicolon: ';', apostrophe: "'",
comma: ',', period: '.', minus: '-', equal: '=', up: '↑', down: '↓', left: '←', right: '→',
};
if (names[k]) return names[k];
if (k.startsWith('keypad.')) return `Num ${k.slice(7)}`;
return k.length === 1 ? k.toUpperCase() : k.charAt(0).toUpperCase() + k.slice(1);
}
export function defaults(): Record<string, string> {
return Object.fromEntries(BIND_GROUPS.flatMap((g) => g.binds.map((b) => [b.id, b.default])));
}
+167
View File
@@ -0,0 +1,167 @@
// In-browser stand-in for the Rust backend, used by `npm run dev` outside
// Tauri and for UI screenshots. Scenario via ?mock=setup|login|main.
import { mockEmit } from './tauri';
import type { Account, Bootstrap, Manifest, Settings } from './types';
const scenario = new URLSearchParams(location.search).get('mock') ?? 'main';
const settings: Settings = {
panel_url: scenario === 'setup' ? null : 'https://panel.scopenet.gg',
selected_instance: null,
memory_max_mb: 0,
memory_min_mb: 0,
custom_resolution: false,
width: 1280,
height: 720,
fullscreen: false,
after_launch: 'minimize',
show_console: false,
java_path: null,
gc: 'g1',
jvm_args: '',
theme_mode: 'server',
accent: null,
background_video: true,
reduce_motion: false,
glass: null,
ui_scale: 100,
concurrent_downloads: 12,
check_updates: true,
send_stats: true,
keybinds_enabled: false,
keybinds: {},
instance_overrides: {},
};
const manifest: Manifest = {
api_version: 1,
panel_version: '0.1.0',
user: null,
auth: { panel_accounts: true, registration: 'approval', microsoft: true, microsoft_client_id: 'demo', offline_local: true },
branding: {
name: 'ScopeNet',
tagline: 'Your worlds, one click away.',
logo_url: null,
icon_url: null,
background: { kind: 'gradient', url: null, blur: 0, dim: 45 },
colors: { accent: '#7c5cff', accent_2: '#22d3ee', background: '#0b0d17', surface: '#141828', text: '#eef0ff', muted: '#8b90b3', success: '#34d399', danger: '#f43f5e' },
font: 'Outfit',
radius: 14,
glass: true,
version_label: 'ScopeNet',
news: [
{ id: '1', title: 'Season 4 is live!', body: 'A brand new world with custom terrain, player shops and weekly events. Join now and claim your spot before the land rush.', image_url: null, link: 'https://example.com', date: '2026-09-26', pinned: true, tag: 'Update' },
{ id: '2', title: 'Build contest: Autumn Castles', body: 'Submit your best castle build on the Creative server by October 10th. Top three win VIP for a month.', image_url: null, link: null, date: '2026-09-21', pinned: false, tag: 'Event' },
{ id: '3', title: 'Performance boost', body: 'We switched the modpack to Sodium + Lithium. Expect way higher FPS on older machines.', image_url: null, link: null, date: '2026-09-14', pinned: false, tag: 'Changelog' },
],
links: [
{ label: 'Discord', url: 'https://discord.gg/example', icon: 'discord' },
{ label: 'Website', url: 'https://example.com', icon: 'website' },
{ label: 'Store', url: 'https://example.com/store', icon: 'store' },
],
features: { news: true, server_status: true, allow_user_theme: true, allow_advanced_java: true },
custom_css: '',
},
instances: [
{ id: 'survival-smp', name: 'Survival SMP', description: 'Our main survival world — season 4. Claims, shops & weekend events.', icon_url: null, banner_url: null, mc_version: '1.21.1', loader: 'fabric', loader_version: '0.16.9', revision: 3, server: { name: 'ScopeNet SMP', address: 'play.scopenet.gg', port: 25565, auto_join: true, inject: true }, memory: { min_mb: 2048, max_mb: 6144 }, jvm_args: '', featured: true, source_label: 'Modrinth · Fabulously Optimized 6.2', file_count: 48, total_size: 184_000_000 },
{ id: 'creative', name: 'Creative Build', description: 'Plots, WorldEdit and weekly build contests.', icon_url: null, banner_url: null, mc_version: '1.20.1', loader: 'vanilla', loader_version: null, revision: 1, server: { name: 'Creative', address: 'creative.scopenet.gg', port: 25565, auto_join: false, inject: true }, memory: { min_mb: 1024, max_mb: 4096 }, jvm_args: '', featured: false, source_label: 'Minecraft 1.20.1', file_count: 0, total_size: 0 },
{ id: 'modded', name: 'Create: Above & Beyond', description: 'Factory building with Create and friends.', icon_url: null, banner_url: null, mc_version: '1.20.1', loader: 'forge', loader_version: '1.20.1-47.3.0', revision: 2, server: null, memory: { min_mb: 4096, max_mb: 8192 }, jvm_args: '', featured: false, source_label: 'CurseForge · Create: Above & Beyond 1.4', file_count: 212, total_size: 612_000_000 },
],
};
const accounts: Account[] = scenario === 'main' || scenario === 'progress' ? [
{ id: 'a1', kind: 'panel', username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', panel_url: 'https://panel.scopenet.gg', role: 'admin' },
{ id: 'a2', kind: 'offline', username: 'Steve', uuid: '5627dd98-e6be-3c21-b8a8-e92344183641', panel_url: null, role: null },
] : [];
let active: string | null = accounts[0]?.id ?? null;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
let running = false;
async function fakeLaunch(id: string) {
const stages: [string, string, number, number][] = [
['java', 'Installing Java 21', 48_000_000, 212],
['game', 'Downloading Minecraft 1.21.1', 420_000_000, 3412],
['loader', 'Installing Fabric 0.16.9', 6_000_000, 14],
['files', 'Syncing instance files', 184_000_000, 48],
];
for (const [stage, label, total, files] of stages) {
mockEmit('launch://progress', { type: 'stage', stage, label });
for (let i = 1; i <= 20; i++) {
await sleep(scenario === 'progress' ? 400 : 60);
mockEmit('launch://progress', { type: 'progress', stage, done: (total * i) / 20, total, files_done: Math.round((files * i) / 20), files_total: files });
}
}
mockEmit('launch://progress', { type: 'stage', stage: 'launching', label: 'Starting Minecraft' });
await sleep(500);
running = true;
mockEmit('launch://state', { instance_id: id, state: 'running', message: null });
const lines = ['[main/INFO]: Loading Minecraft 1.21.1 with Fabric Loader 0.16.9', '[Render thread/INFO]: Setting user: Notch', '[Render thread/INFO]: Backend library: LWJGL version 3.3.3', '[Render thread/INFO]: Reloading ResourceManager: vanilla, fabric', '[Render thread/INFO]: Sound engine started', '[Render thread/INFO]: Connecting to play.scopenet.gg, 25565'];
for (const l of lines) {
await sleep(300);
mockEmit('game://log', [`[12:00:0${lines.indexOf(l)}] ${l}`]);
}
}
export async function mockInvoke(cmd: string, args: Record<string, any>): Promise<unknown> {
await sleep(40);
switch (cmd) {
case 'bootstrap': {
const b: Bootstrap = {
app: { version: '0.1.0', default_panel_url: null, panel_locked: false, repo: 'scopeddlol/SCOPENET-MC', os: 'windows', arch: 'x86_64', total_ram_mb: 16384, data_dir: 'C:\\Users\\you\\AppData\\Roaming\\net.scopenet.launcher' },
panel_url: settings.panel_url,
settings,
accounts,
active_account: active,
manifest: scenario === 'setup' ? null : manifest,
game_running: running ? 'survival-smp' : null,
};
return structuredClone(b);
}
case 'refresh_manifest':
case 'set_panel_url':
if (cmd === 'set_panel_url') settings.panel_url = args.url;
return structuredClone(manifest);
case 'save_settings':
Object.assign(settings, args.settings);
return null;
case 'login_panel':
case 'add_offline': {
const a: Account = { id: crypto.randomUUID(), kind: cmd === 'login_panel' ? 'panel' : 'offline', username: args.username, uuid: crypto.randomUUID(), panel_url: null, role: 'player' };
accounts.push(a);
active = a.id;
return a;
}
case 'register_panel':
return { account: null, pending: true };
case 'ms_start':
return { user_code: 'F7K2-QX9M', verification_uri: 'https://microsoft.com/link', expires_in: 900, interval: 5, message: '' };
case 'ms_finish':
await sleep(600_000);
return null;
case 'select_account':
active = args.id;
return null;
case 'launch':
fakeLaunch(args.instanceId);
return null;
case 'kill_game':
running = false;
mockEmit('game://exit', { instance_id: 'survival-smp', code: 0, crashed: false, tail: [], crash_report: null });
mockEmit('launch://state', { instance_id: 'survival-smp', state: 'idle', message: null });
return null;
case 'ping_server':
await sleep(200);
return { online: true, version: 'Paper 1.21.1', players_online: 42, players_max: 100, sample: ['Notch', 'jeb_', 'Dinnerbone'], motd: '§b§lScopeNet §r§7» §fSeason 4 is live!', favicon: null, latency_ms: 23 };
case 'instance_local':
return { installed: args.instanceId !== 'modded', revision: 3, size: 1_240_000_000 };
case 'storage_info':
return { shared: 2_400_000_000, runtimes: 410_000_000, instances: [['survival-smp', 1_240_000_000], ['creative', 320_000_000]] };
case 'check_update':
return null;
case 'detect_java':
return 21;
default:
return null;
}
}
+37
View File
@@ -0,0 +1,37 @@
// Render Minecraft § formatting codes as HTML-safe segments.
const COLORS: Record<string, string> = {
'0': '#000000', '1': '#0000AA', '2': '#00AA00', '3': '#00AAAA', '4': '#AA0000', '5': '#AA00AA', '6': '#FFAA00', '7': '#AAAAAA',
'8': '#555555', '9': '#5555FF', a: '#55FF55', b: '#55FFFF', c: '#FF5555', d: '#FF55FF', e: '#FFFF55', f: '#FFFFFF',
};
export interface Segment { text: string; color?: string; bold?: boolean; italic?: boolean; underline?: boolean; strike?: boolean }
export function parseMotd(input: string): Segment[] {
const out: Segment[] = [];
let cur: Segment = { text: '' };
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (ch === '§' && i + 1 < input.length) {
const code = input[i + 1].toLowerCase();
if (cur.text) out.push(cur);
const base: Segment = { ...cur, text: '' };
if (code === '#' && /^[0-9a-f]{6}$/i.test(input.slice(i + 2, i + 8))) {
cur = { text: '', color: '#' + input.slice(i + 2, i + 8) };
i += 7;
continue;
}
if (COLORS[code]) cur = { text: '', color: COLORS[code] };
else if (code === 'l') cur = { ...base, bold: true };
else if (code === 'o') cur = { ...base, italic: true };
else if (code === 'n') cur = { ...base, underline: true };
else if (code === 'm') cur = { ...base, strike: true };
else if (code === 'r') cur = { text: '' };
else cur = base;
i++;
continue;
}
cur.text += ch;
}
if (cur.text) out.push(cur);
return out;
}
+243
View File
@@ -0,0 +1,243 @@
import { errorText, invoke, listen } from './tauri';
import { applyTheme } from './theme';
import type { Account, Bootstrap, Instance, Manifest, ProgressEvent, Settings, Stage, UpdateInfo } from './types';
export interface LaunchState {
state: 'idle' | 'preparing' | 'running' | 'error';
stage: Stage | null;
label: string;
done: number;
total: number;
filesDone: number;
filesTotal: number;
speed: number;
message: string | null;
}
export interface ExitInfo { instance_id: string; code: number | null; crashed: boolean; tail: string[]; crash_report: string | null }
const idle = (): LaunchState => ({ state: 'idle', stage: null, label: '', done: 0, total: 0, filesDone: 0, filesTotal: 0, speed: 0, message: null });
export const app = $state({
ready: false,
boot: null as Bootstrap | null,
manifest: null as Manifest | null,
settings: null as Settings | null,
accounts: [] as Account[],
active: null as string | null,
panelUrl: null as string | null,
offline: false,
view: 'home' as 'home' | 'settings',
settingsTab: 'account',
selected: null as string | null,
launch: idle(),
launchingId: null as string | null,
running: null as string | null,
logs: [] as string[],
consoleOpen: false,
crash: null as ExitInfo | null,
update: null as UpdateInfo | null,
addAccount: false,
instanceSettings: null as string | null,
toasts: [] as { id: number; text: string; kind: 'ok' | 'error' | 'info' }[],
});
let toastId = 1;
export function toast(text: string, kind: 'ok' | 'error' | 'info' = 'ok') {
const id = toastId++;
app.toasts.push({ id, text, kind });
setTimeout(() => {
const i = app.toasts.findIndex((t) => t.id === id);
if (i >= 0) app.toasts.splice(i, 1);
}, kind === 'error' ? 7000 : 3500);
}
export const activeAccount = () => app.accounts.find((a) => a.id === app.active) ?? null;
export const instances = (): Instance[] => app.manifest?.instances ?? [];
export const selectedInstance = (): Instance | null => instances().find((i) => i.id === app.selected) ?? instances()[0] ?? null;
/** Resolve panel-relative media URLs ("/uploads/x.png"). */
export function abs(url: string | null | undefined): string | null {
if (!url) return null;
if (url.startsWith('/') && app.panelUrl) return app.panelUrl + url;
return url;
}
function theme() {
if (app.manifest) applyTheme(app.manifest.branding, app.settings);
}
function pickSelected() {
const list = instances();
const wanted = app.settings?.selected_instance;
app.selected = list.find((i) => i.id === wanted)?.id ?? list.find((i) => i.featured)?.id ?? list[0]?.id ?? null;
}
export async function init() {
const boot = await invoke<Bootstrap>('bootstrap');
app.boot = boot;
app.settings = boot.settings;
app.accounts = boot.accounts;
app.active = boot.active_account;
app.manifest = boot.manifest;
app.panelUrl = boot.panel_url;
app.running = boot.game_running;
theme();
pickSelected();
subscribe();
app.ready = true;
invoke('show_window').catch(() => {});
if (app.panelUrl) await refresh();
if (boot.settings.check_updates && boot.app.repo) {
invoke<UpdateInfo | null>('check_update').then((u) => (app.update = u)).catch(() => {});
}
}
export async function refresh() {
try {
app.manifest = await invoke<Manifest>('refresh_manifest');
app.offline = false;
theme();
pickSelected();
} catch (e) {
app.offline = true;
if (!app.manifest) throw e;
}
}
export async function connect(url: string) {
app.manifest = await invoke<Manifest>('set_panel_url', { url });
const boot = await invoke<Bootstrap>('bootstrap');
app.panelUrl = boot.panel_url;
app.offline = false;
theme();
pickSelected();
}
let saveTimer: ReturnType<typeof setTimeout> | undefined;
/** Persist settings (debounced) and re-apply the theme immediately. */
export function saveSettings() {
theme();
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
if (app.settings) invoke('save_settings', { settings: $state.snapshot(app.settings) }).catch((e) => toast(errorText(e), 'error'));
}, 300);
}
export function selectInstance(id: string) {
app.selected = id;
if (app.settings) {
app.settings.selected_instance = id;
saveSettings();
}
}
export async function accountAdded(account: Account) {
const i = app.accounts.findIndex((a) => a.id === account.id);
if (i >= 0) app.accounts[i] = account;
else app.accounts.push(account);
app.active = account.id;
app.addAccount = false;
await refresh().catch(() => {});
}
export async function switchAccount(id: string) {
await invoke('select_account', { id });
app.active = id;
await refresh().catch(() => {});
}
export async function removeAccount(id: string) {
await invoke('remove_account', { id });
app.accounts = app.accounts.filter((a) => a.id !== id);
if (app.active === id) app.active = app.accounts[0]?.id ?? null;
await refresh().catch(() => {});
}
export async function play(id: string) {
app.launch = { ...idle(), state: 'preparing', label: 'Getting ready…' };
app.launchingId = id;
app.logs = [];
try {
await invoke('launch', { instanceId: id });
} catch (e) {
app.launch = { ...idle(), state: 'error', message: errorText(e) };
}
}
export async function repair(id: string) {
app.launch = { ...idle(), state: 'preparing', label: 'Verifying files…' };
app.launchingId = id;
try {
await invoke('repair_instance', { instanceId: id });
} catch (e) {
app.launch = { ...idle(), state: 'error', message: errorText(e) };
}
}
export async function cancel() {
if (app.launchingId) await invoke('cancel_launch', { instanceId: app.launchingId });
}
export async function kill() {
await invoke('kill_game');
}
let subscribed = false;
let last = { t: 0, done: 0, stage: '' as string };
function subscribe() {
if (subscribed) return;
subscribed = true;
listen<ProgressEvent>('launch://progress', (ev) => {
const l = app.launch;
if (ev.type === 'stage') {
l.stage = ev.stage;
l.label = ev.label;
l.done = l.total = l.filesDone = l.filesTotal = 0;
l.speed = 0;
} else if (ev.type === 'progress') {
const now = performance.now();
if (last.stage !== ev.stage || ev.done < last.done) last = { t: now, done: ev.done, stage: ev.stage };
else if (now - last.t > 500) {
const inst = ((ev.done - last.done) / (now - last.t)) * 1000;
l.speed = l.speed ? l.speed * 0.6 + inst * 0.4 : inst;
last = { t: now, done: ev.done, stage: ev.stage };
}
l.stage = ev.stage;
l.done = ev.done;
l.total = ev.total;
l.filesDone = ev.files_done;
l.filesTotal = ev.files_total;
} else if (ev.type === 'log') {
pushLogs([ev.line]);
}
});
listen<{ instance_id: string; state: LaunchState['state']; message: string | null }>('launch://state', (ev) => {
if (ev.state === 'running') {
app.running = ev.instance_id;
app.launch = { ...idle(), state: 'running' };
if (app.settings?.show_console) app.consoleOpen = true;
} else if (ev.state === 'error') {
app.launch = { ...idle(), state: 'error', message: ev.message };
} else if (ev.state === 'idle') {
if (app.running === ev.instance_id) app.running = null;
if (app.launch.state !== 'error') app.launch = idle();
if (ev.message && ev.message !== 'Cancelled') toast(ev.message);
}
});
listen<string[]>('game://log', (lines) => pushLogs(lines));
listen<ExitInfo>('game://exit', (ev) => {
app.running = null;
if (ev.crashed) app.crash = ev;
});
}
function pushLogs(lines: string[]) {
app.logs.push(...lines);
if (app.logs.length > 3000) app.logs.splice(0, app.logs.length - 3000);
}
+57
View File
@@ -0,0 +1,57 @@
// Thin wrapper over Tauri IPC. When the UI runs in a normal browser
// (vite dev / screenshots), calls go to an in-memory mock instead.
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
import { listen as tauriListen, type UnlistenFn } from '@tauri-apps/api/event';
export const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
type Handler = (payload: any) => void;
const mockListeners = new Map<string, Set<Handler>>();
export function mockEmit(event: string, payload: unknown) {
mockListeners.get(event)?.forEach((h) => h(payload));
}
export async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
if (inTauri) return tauriInvoke<T>(cmd, args);
const { mockInvoke } = await import('./mock');
return mockInvoke(cmd, args ?? {}) as Promise<T>;
}
export async function listen<T>(event: string, handler: (payload: T) => void): Promise<UnlistenFn> {
if (inTauri) return tauriListen<T>(event, (e) => handler(e.payload));
const set = mockListeners.get(event) ?? new Set();
set.add(handler as Handler);
mockListeners.set(event, set);
return () => set.delete(handler as Handler);
}
export async function windowAction(action: 'minimize' | 'maximize' | 'close' | 'drag') {
if (!inTauri) return;
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const w = getCurrentWindow();
if (action === 'minimize') await w.minimize();
else if (action === 'maximize') await w.toggleMaximize();
else if (action === 'close') await w.close();
else await w.startDragging();
}
export async function openUrl(url: string) {
if (!inTauri) {
window.open(url, '_blank');
return;
}
const { openUrl } = await import('@tauri-apps/plugin-opener');
await openUrl(url);
}
export async function pickFile(title: string, filters?: { name: string; extensions: string[] }[]): Promise<string | null> {
if (!inTauri) return null;
const { open } = await import('@tauri-apps/plugin-dialog');
const r = await open({ title, multiple: false, directory: false, filters });
return typeof r === 'string' ? r : null;
}
export function errorText(e: unknown): string {
return typeof e === 'string' ? e : e instanceof Error ? e.message : JSON.stringify(e);
}
+54
View File
@@ -0,0 +1,54 @@
import type { Branding, Settings } from './types';
let styleEl: HTMLStyleElement | null = null;
/** Push the admin's branding (and the player's overrides) into CSS variables. */
export function applyTheme(b: Branding, s: Settings | null) {
const root = document.documentElement;
const custom = s?.theme_mode === 'custom' && b.features.allow_user_theme;
const accent = (custom && s?.accent) || b.colors.accent;
const vars: Record<string, string> = {
'--accent': accent,
'--accent-2': custom && s?.accent ? shift(accent) : b.colors.accent_2,
'--bg': b.colors.background,
'--surface': b.colors.surface,
'--text': b.colors.text,
'--muted': b.colors.muted,
'--success': b.colors.success,
'--danger': b.colors.danger,
'--radius': `${b.radius}px`,
'--radius-sm': `${Math.max(4, Math.round(b.radius * 0.65))}px`,
'--font': `'${b.font} Variable', '${b.font}', 'Outfit Variable', system-ui, sans-serif`,
};
for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
const glass = s?.glass ?? b.glass;
root.classList.toggle('no-glass', !glass);
root.classList.toggle('reduce-motion', !!s?.reduce_motion);
root.style.fontSize = `${(s?.ui_scale ?? 100) * 0.15}px`;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'admin-css';
document.head.appendChild(styleEl);
}
styleEl.textContent = b.custom_css ?? '';
}
/** A second accent derived from the first by rotating its hue. */
function shift(hex: string): string {
const m = hex.replace('#', '').match(/.{2}/g);
if (!m) return hex;
let [r, g, b] = m.map((x) => parseInt(x, 16) / 255);
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0;
const l = (max + min) / 2;
const d = max - min;
const sat = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
if (d) {
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
}
h = (h * 60 + 50 + 360) % 360;
return `hsl(${h} ${Math.round(sat * 100)}% ${Math.round(l * 100)}%)`;
}
+112
View File
@@ -0,0 +1,112 @@
export type Loader = 'vanilla' | 'fabric' | 'quilt' | 'forge' | 'neoforge';
export interface ServerEntry { name: string; address: string; port: number; auto_join: boolean; inject: boolean }
export interface Instance {
id: string;
name: string;
description: string;
icon_url: string | null;
banner_url: string | null;
mc_version: string;
loader: Loader;
loader_version: string | null;
revision: number;
server: ServerEntry | null;
memory: { min_mb: number; max_mb: number };
jvm_args: string;
featured: boolean;
source_label: string;
file_count: number;
total_size: number;
}
export interface NewsItem { id: string; title: string; body: string; image_url: string | null; link: string | null; date: string | null; pinned: boolean; tag: string | null }
export interface Branding {
name: string;
tagline: string;
logo_url: string | null;
icon_url: string | null;
background: { kind: 'gradient' | 'image' | 'video'; url: string | null; blur: number; dim: number };
colors: { accent: string; accent_2: string; background: string; surface: string; text: string; muted: string; success: string; danger: string };
font: string;
radius: number;
glass: boolean;
version_label: string;
news: NewsItem[];
links: { label: string; url: string; icon: string }[];
features: { news: boolean; server_status: boolean; allow_user_theme: boolean; allow_advanced_java: boolean };
custom_css: string;
}
export interface AuthConfig {
panel_accounts: boolean;
registration: 'closed' | 'open' | 'approval';
microsoft: boolean;
microsoft_client_id: string | null;
offline_local: boolean;
}
export interface Manifest {
api_version: number;
panel_version: string;
branding: Branding;
auth: AuthConfig;
instances: Instance[];
user: { id: number; username: string; uuid: string; role: string; groups: string[] } | null;
}
export interface Account { id: string; kind: 'panel' | 'microsoft' | 'offline'; username: string; uuid: string; panel_url: string | null; role: string | null }
export type Gc = 'default' | 'g1' | 'zgc';
export interface InstanceOverride { memory_max_mb: number; memory_min_mb: number; jvm_args: string; java_path: string | null }
export interface Settings {
panel_url: string | null;
selected_instance: string | null;
memory_max_mb: number;
memory_min_mb: number;
custom_resolution: boolean;
width: number;
height: number;
fullscreen: boolean;
after_launch: 'minimize' | 'hide' | 'keep' | 'close';
show_console: boolean;
java_path: string | null;
gc: Gc;
jvm_args: string;
theme_mode: 'server' | 'custom';
accent: string | null;
background_video: boolean;
reduce_motion: boolean;
glass: boolean | null;
ui_scale: number;
concurrent_downloads: number;
check_updates: boolean;
send_stats: boolean;
keybinds_enabled: boolean;
keybinds: Record<string, string>;
instance_overrides: Record<string, InstanceOverride>;
}
export interface Bootstrap {
app: { version: string; default_panel_url: string | null; panel_locked: boolean; repo: string | null; os: string; arch: string; total_ram_mb: number; data_dir: string };
panel_url: string | null;
settings: Settings;
accounts: Account[];
active_account: string | null;
manifest: Manifest | null;
game_running: string | null;
}
export type Stage = 'preparing' | 'java' | 'game' | 'loader' | 'files' | 'launching';
export type ProgressEvent =
| { type: 'stage'; stage: Stage; label: string }
| { type: 'progress'; stage: Stage; done: number; total: number; files_done: number; files_total: number }
| { type: 'log'; line: string };
export interface ServerStatus { online: boolean; version: string; players_online: number; players_max: number; sample: string[]; motd: string; favicon: string | null; latency_ms: number }
export interface DeviceCode { user_code: string; verification_uri: string; expires_in: number; interval: number; message: string }
export interface UpdateInfo { version: string; notes: string; url: string; size: number }
+15
View File
@@ -0,0 +1,15 @@
import '@fontsource-variable/outfit';
import '@fontsource-variable/inter';
import '@fontsource-variable/space-grotesk';
import '@fontsource-variable/plus-jakarta-sans';
import '@fontsource-variable/jetbrains-mono';
import './app.css';
import { mount } from 'svelte';
import App from './App.svelte';
// The launcher is a desktop app: no browser context menu or text-drag.
document.addEventListener('contextmenu', (e) => {
if (!(e.target as HTMLElement).closest('input, textarea, .selectable')) e.preventDefault();
});
mount(App, { target: document.getElementById('app')! });
+81
View File
@@ -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>
+33
View File
@@ -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>
+430
View File
@@ -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>
+45
View File
@@ -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>