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

+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>