Add Tauri launcher with Svelte UI
- Accounts: panel login/sign-up, Microsoft device-code sign-in, offline play; tokens encrypted with a key kept in the OS credential store - Home: instance rail, live server status, news feed, social links, Play button with download progress, speed and ETA - Full settings: accounts, memory/resolution/after-launch behaviour, Java and GC presets, Minecraft keybind editor applied to options.txt, theme overrides, storage manager, self-update via GitHub releases - Game console, crash dialog with hints, per-instance overrides - Live admin branding (colours, fonts, backgrounds, custom CSS) - Online install tests for vanilla/Fabric/Quilt/Forge/NeoForge (CI) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
60 files changed
+9424
-47
No files matched your search
@@ -0,0 +1,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>
|
||||
@@ -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("{image}")" 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user