Add admin panel web UI (Svelte 5)

Dashboard with launch stats, instance manager with Modrinth/CurseForge
search and zip import, player and group management, launcher design
editor with live preview, and sign-in settings.

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 04:32:15 +00:00
1 parent 8cdf616a94
commit af9147ec75
32 files changed
+4084

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
let { name, size = 32 }: { name: string; size?: number } = $props();
let failed = $state(false);
const hue = $derived([...name].reduce((a, c) => a + c.charCodeAt(0), 0) % 360);
</script>
{#if failed}
<span class="fallback" style:width="{size}px" style:height="{size}px" style:background="hsl({hue} 60% 45%)">{name.slice(0, 1).toUpperCase()}</span>
{:else}
<img src="https://mc-heads.net/avatar/{encodeURIComponent(name)}/{size * 2}" alt="" width={size} height={size} onerror={() => (failed = true)} />
{/if}
<style>
img, .fallback { border-radius: 8px; image-rendering: pixelated; flex-shrink: 0; }
.fallback { display: inline-grid; place-items: center; font-weight: 700; font-size: 0.85rem; color: white; }
</style>
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
// Single-series column chart: ≤24px columns with 4px rounded caps,
// hairline grid, per-column hover tooltip and a screen-reader table.
let { data, label }: { data: { day: string; launches: number }[]; label: string } = $props();
const W = 640, H = 200, PAD_L = 34, PAD_B = 26, PAD_T = 12;
let hover = $state<number | null>(null);
const max = $derived(Math.max(4, ...data.map((d) => d.launches)));
const step = $derived(niceStep(max));
const top = $derived(Math.ceil(max / step) * step);
const ticks = $derived(Array.from({ length: Math.round(top / step) + 1 }, (_, i) => i * step));
const band = $derived((W - PAD_L) / Math.max(1, data.length));
const barW = $derived(Math.min(24, band - 6));
const y = (v: number) => PAD_T + (H - PAD_T - PAD_B) * (1 - v / top);
function niceStep(m: number) {
const raw = m / 4;
const pow = 10 ** Math.floor(Math.log10(raw));
return [1, 2, 5, 10].map((s) => s * pow).find((s) => s >= raw) ?? raw;
}
function colPath(x: number, v: number) {
const yb = H - PAD_B, yt = y(v), h = yb - yt;
if (h <= 0) return '';
const r = Math.min(4, h, barW / 2);
return `M${x},${yb} V${yt + r} Q${x},${yt} ${x + r},${yt} H${x + barW - r} Q${x + barW},${yt} ${x + barW},${yt + r} V${yb} Z`;
}
const fmtDay = (d: string) => new Date(d + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
</script>
<div class="chart">
<svg viewBox="0 0 {W} {H}" role="img" aria-label={label}>
{#each ticks as t}
<line x1={PAD_L} x2={W} y1={y(t)} y2={y(t)} class="grid" />
<text x={PAD_L - 8} y={y(t) + 4} class="tick" text-anchor="end">{t.toLocaleString()}</text>
{/each}
{#each data as d, i}
{@const x = PAD_L + i * band + (band - barW) / 2}
<path d={colPath(x, d.launches)} class="bar" class:dim={hover !== null && hover !== i} />
{#if i % 2 === data.length % 2 || data.length < 8}
<text x={x + barW / 2} y={H - 8} class="tick" text-anchor="middle">{fmtDay(d.day)}</text>
{/if}
<rect
x={PAD_L + i * band} y={PAD_T} width={band} height={H - PAD_T - PAD_B} fill="transparent"
role="presentation" onmouseenter={() => (hover = i)} onmouseleave={() => (hover = null)}
/>
{/each}
</svg>
{#if hover !== null}
{@const d = data[hover]}
<div class="tip" style:left="{((PAD_L + hover * band + band / 2) / W) * 100}%" style:top="{(y(d.launches) / H) * 100}%">
<strong>{d.launches.toLocaleString()}</strong> launch{d.launches === 1 ? '' : 'es'}
<span>{fmtDay(d.day)}</span>
</div>
{/if}
<table class="sr-only">
<caption>{label}</caption>
<tbody>{#each data as d}<tr><th>{d.day}</th><td>{d.launches}</td></tr>{/each}</tbody>
</table>
</div>
<style>
.chart { position: relative; }
svg { width: 100%; height: auto; display: block; overflow: visible; }
.grid { stroke: rgba(255, 255, 255, 0.06); stroke-width: 1; }
.tick { fill: var(--muted); font-size: 11px; font-family: var(--font); }
.bar { fill: #8b6cff; transition: opacity 0.15s; }
.bar.dim { opacity: 0.4; }
.tip {
position: absolute; transform: translate(-50%, calc(-100% - 10px)); pointer-events: none; background: var(--surface-3);
border: 1px solid var(--line-strong); border-radius: 8px; padding: 6px 10px; font-size: 0.8rem; white-space: nowrap;
display: flex; gap: 6px; align-items: baseline; box-shadow: var(--shadow);
}
.tip span { color: var(--muted); }
</style>
@@ -0,0 +1,19 @@
<script lang="ts">
let { value = $bindable('#000000'), label }: { value: string; label: string } = $props();
</script>
<label class="color-input">
<input type="color" bind:value />
<span class="col">
<span class="lbl">{label}</span>
<input class="hex mono" type="text" bind:value maxlength="9" spellcheck="false" />
</span>
</label>
<style>
.color-input { display: flex; gap: 10px; align-items: center; background: var(--bg-2); border: 1px solid var(--line); padding: 8px; border-radius: 12px; }
.col { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.lbl { font-size: 0.78rem; color: var(--muted); }
.hex { border: none; background: none; padding: 0; font-size: 0.85rem; width: 90px; }
.hex:focus { background: none; }
</style>
@@ -0,0 +1,66 @@
<script lang="ts">
import { ImageUp, Trash2, LoaderCircle } from '@lucide/svelte';
import { uploadMedia } from '../lib/api';
import { toastError } from '../lib/toast.svelte';
let { value = $bindable(null), label, accept = 'image/*', help, video = false }: {
value: string | null; label: string; accept?: string; help?: string; video?: boolean;
} = $props();
let busy = $state(false);
let input: HTMLInputElement;
const isVideo = $derived(!!value && /\.(mp4|webm)$/i.test(value));
async function pick(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
busy = true;
try {
value = await uploadMedia(file);
} catch (err) {
toastError(err);
} finally {
busy = false;
input.value = '';
}
}
</script>
<div class="field image-input">
<span class="lbl">{label}</span>
<div class="row">
<button type="button" class="preview" onclick={() => input.click()} aria-label="Upload {label}">
{#if busy}
<LoaderCircle class="spin" size={20} />
{:else if value && isVideo}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={value} muted autoplay loop playsinline></video>
{:else if value}
<img src={value} alt="" />
{:else}
<ImageUp size={20} />
{/if}
</button>
<div class="col grow">
<input type="text" placeholder="https://… or upload" bind:value />
{#if help}<span class="help">{help}</span>{/if}
</div>
{#if value}
<button type="button" class="ghost icon" onclick={() => (value = null)} aria-label="Remove"><Trash2 size={16} /></button>
{/if}
</div>
<input bind:this={input} type="file" accept={video ? 'image/*,video/mp4,video/webm' : accept} hidden onchange={pick} />
</div>
<style>
.image-input { display: flex; flex-direction: column; gap: 7px; }
.lbl { font-size: 0.85rem; color: var(--text-2); font-weight: 500; }
.help { color: var(--muted); font-size: 0.78rem; }
.grow { flex: 1; gap: 4px; }
.preview {
width: 56px; height: 56px; padding: 0; border-radius: 12px; overflow: hidden; flex-shrink: 0;
background: repeating-conic-gradient(#1b1e2e 0 25%, #151826 0 50%) 50% / 12px 12px; border: 1px dashed var(--line-strong);
color: var(--muted);
}
.preview img, .preview video { width: 100%; height: 100%; object-fit: cover; }
</style>
@@ -0,0 +1,136 @@
<script lang="ts">
// A scaled-down replica of the launcher's home screen, driven by the
// branding being edited, so admins see changes instantly.
import { Play, Settings, Newspaper, Wifi } from '@lucide/svelte';
import type { Branding } from '../lib/types';
let { b, instanceName = 'Survival SMP' }: { b: Branding; instanceName?: string } = $props();
const fontFamily = $derived(`'${b.font}', 'Outfit Variable', sans-serif`);
const bgStyle = $derived(
b.background.kind === 'image' && b.background.url
? `url("${b.background.url}") center/cover`
: `radial-gradient(120% 80% at 80% 0%, ${b.colors.accent}55, transparent 60%), radial-gradient(90% 70% at 0% 100%, ${b.colors.accent_2}33, transparent 60%), ${b.colors.background}`,
);
const news = $derived(b.news.slice(0, 2));
</script>
<div
class="frame"
style:--acc={b.colors.accent}
style:--acc2={b.colors.accent_2}
style:--bg={b.colors.background}
style:--surf={b.colors.surface}
style:--txt={b.colors.text}
style:--mut={b.colors.muted}
style:--ok={b.colors.success}
style:--r="{Math.max(2, b.radius * 0.55)}px"
style:font-family={fontFamily}
>
<div class="inner">
<div class="bg" style:background={bgStyle} style:filter="blur({b.background.blur * 0.5}px)">
{#if b.background.kind === 'video' && b.background.url}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={b.background.url} autoplay muted loop playsinline></video>
{/if}
</div>
<div class="dim" style:opacity={b.background.dim / 100}></div>
<div class="titlebar">
{#if b.logo_url}<img src={b.logo_url} alt="" />{:else}<span class="dot"></span>{/if}
<span>{b.name}</span>
<span class="spacer"></span>
<i></i><i></i><i class="x"></i>
</div>
<div class="rail" class:glass={b.glass}>
<span class="inst active">{instanceName.slice(0, 1)}</span>
<span class="inst">C</span>
<span class="inst">V</span>
<span class="spacer"></span>
<Settings size={11} />
<span class="avatar"></span>
</div>
<div class="main">
<div class="hero">
<div class="chips"><span>1.21.1</span><span>Fabric</span></div>
<h1>{instanceName}</h1>
<p>{b.tagline}</p>
</div>
<div class="bottom">
<button class="play"><Play size={11} fill="currentColor" /> PLAY</button>
{#if b.features.server_status}
<span class="status" class:glass={b.glass}><Wifi size={9} /> <b>42</b>/100 online</span>
{/if}
</div>
{#if b.features.news && news.length}
<div class="news">
<span class="news-h"><Newspaper size={9} /> News</span>
{#each news as n}
<div class="card" class:glass={b.glass}>
{#if n.image_url}<img src={n.image_url} alt="" />{/if}
<div>
{#if n.tag}<em>{n.tag}</em>{/if}
<strong>{n.title || 'Untitled'}</strong>
<small>{n.body}</small>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<style>
.frame {
position: relative; aspect-ratio: 1100 / 680; width: 100%; border-radius: 14px; overflow: hidden; color: var(--txt);
background: var(--bg); border: 1px solid var(--line-strong); box-shadow: 0 30px 80px -30px rgba(0, 0, 0, 0.9);
container-type: inline-size;
}
/* cqw must be used inside the container, not on it */
.inner { position: absolute; inset: 0; font-size: 1.85cqw; }
.bg { position: absolute; inset: -10px; }
.bg video { width: 100%; height: 100%; object-fit: cover; }
.dim { position: absolute; inset: 0; background: linear-gradient(90deg, var(--bg) 0%, transparent 70%), linear-gradient(0deg, var(--bg), transparent 50%); }
.titlebar { position: absolute; top: 0; left: 0; right: 0; height: 6%; display: flex; align-items: center; gap: 1.2%; padding: 0 1.5%; font-size: 0.75em; font-weight: 600; z-index: 2; }
.titlebar img { height: 60%; border-radius: 3px; }
.dot { width: 1.4%; aspect-ratio: 1; border-radius: 30%; background: linear-gradient(135deg, var(--acc), var(--acc2)); }
.titlebar i { width: 1%; aspect-ratio: 1; border-radius: 50%; background: var(--mut); opacity: 0.5; }
.titlebar i.x { background: #f43f5e; opacity: 0.8; }
.spacer { flex: 1; }
.rail {
position: absolute; left: 0; top: 6%; bottom: 0; width: 7%; display: flex; flex-direction: column; align-items: center;
gap: 2.2%; padding: 1.5% 0; background: color-mix(in srgb, var(--surf) 85%, transparent); z-index: 2; color: var(--mut);
border-right: 1px solid rgba(255, 255, 255, 0.06);
}
.rail.glass { background: color-mix(in srgb, var(--surf) 55%, transparent); backdrop-filter: blur(8px); }
.inst { width: 62%; aspect-ratio: 1; border-radius: var(--r); background: var(--surf); display: grid; place-items: center; font-weight: 700; font-size: 1.1em; color: var(--txt); border: 1px solid rgba(255, 255, 255, 0.08); }
.inst.active { background: linear-gradient(135deg, var(--acc), var(--acc2)); box-shadow: 0 0 0 2px var(--bg), 0 0 0 3.5px var(--acc); }
.avatar { width: 50%; aspect-ratio: 1; border-radius: 30%; background: linear-gradient(#6b4f2a 40%, #c9956b 40%); margin-top: 6%; }
.main { position: absolute; left: 7%; top: 6%; right: 0; bottom: 0; padding: 4% 4.5%; z-index: 1; }
.hero h1 { font-size: 3.6em; line-height: 1; margin: 0.25em 0 0.2em; letter-spacing: -0.02em; font-weight: 800; }
.hero p { color: var(--mut); font-size: 1.1em; max-width: 50%; }
.chips { display: flex; gap: 0.5em; }
.chips span { font-size: 0.7em; padding: 0.25em 0.8em; border-radius: 99px; background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.12); font-weight: 600; }
.bottom { position: absolute; left: 4.5%; bottom: 7%; display: flex; align-items: center; gap: 1.2em; }
.play {
all: unset; display: flex; align-items: center; gap: 0.6em; padding: 0.9em 2.6em; border-radius: var(--r); font-weight: 800;
font-size: 1.25em; letter-spacing: 0.08em; color: white; background: linear-gradient(135deg, var(--acc), var(--acc2));
box-shadow: 0 0.6em 2em -0.5em var(--acc);
}
.status { display: flex; align-items: center; gap: 0.4em; font-size: 0.8em; padding: 0.5em 0.9em; border-radius: 99px; background: color-mix(in srgb, var(--surf) 85%, transparent); color: var(--mut); }
.status.glass { background: color-mix(in srgb, var(--surf) 50%, transparent); backdrop-filter: blur(8px); }
.status :global(svg) { color: var(--ok); }
.status b { color: var(--txt); }
.news { position: absolute; right: 3.5%; top: 6%; width: 30%; display: flex; flex-direction: column; gap: 0.7em; }
.news-h { font-size: 0.75em; color: var(--mut); display: flex; align-items: center; gap: 0.4em; text-transform: uppercase; letter-spacing: 0.08em; font-weight: 700; }
.card { display: flex; flex-direction: column; border-radius: var(--r); overflow: hidden; background: color-mix(in srgb, var(--surf) 90%, transparent); border: 1px solid rgba(255, 255, 255, 0.07); }
.card.glass { background: color-mix(in srgb, var(--surf) 55%, transparent); backdrop-filter: blur(8px); }
.card img { width: 100%; aspect-ratio: 16/7; object-fit: cover; }
.card div { padding: 0.7em 0.9em; display: flex; flex-direction: column; gap: 0.2em; }
.card em { font-style: normal; font-size: 0.6em; color: var(--acc); font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; }
.card strong { font-size: 0.9em; }
.card small { font-size: 0.7em; color: var(--mut); display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
</style>
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import type { Snippet } from 'svelte';
let { open = $bindable(false), title, width = 520, children, footer }: {
open: boolean; title: string; width?: number; children: Snippet; footer?: Snippet;
} = $props();
function onkey(e: KeyboardEvent) {
if (e.key === 'Escape') open = false;
}
</script>
<svelte:window onkeydown={open ? onkey : undefined} />
{#if open}
<div class="backdrop" role="presentation" onclick={(e) => e.target === e.currentTarget && (open = false)}>
<div class="modal" style:max-width="{width}px" role="dialog" aria-modal="true" aria-label={title}>
<header>
<h2>{title}</h2>
<button class="ghost icon" onclick={() => (open = false)} aria-label="Close"><X size={18} /></button>
</header>
<div class="body">{@render children()}</div>
{#if footer}<footer>{@render footer()}</footer>{/if}
</div>
</div>
{/if}
<style>
.backdrop {
position: fixed; inset: 0; background: rgba(4, 5, 10, 0.7); backdrop-filter: blur(6px);
display: grid; place-items: center; z-index: 50; padding: 20px; animation: fade 0.15s ease;
}
.modal {
width: 100%; max-height: calc(100vh - 40px); overflow: auto; background: var(--surface);
border: 1px solid var(--line-strong); border-radius: 18px; box-shadow: 0 40px 100px -20px rgba(0, 0, 0, 0.8);
animation: rise 0.2s ease;
}
header { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 0; }
.body { padding: 18px 20px 20px; display: flex; flex-direction: column; gap: 14px; }
footer { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 20px; border-top: 1px solid var(--line); }
@keyframes fade { from { opacity: 0; } }
@keyframes rise { from { opacity: 0; transform: translateY(12px) scale(0.98); } }
</style>
+90
View File
@@ -0,0 +1,90 @@
<script lang="ts">
import { LayoutDashboard, Boxes, Users, Palette, Settings, LogOut, ExternalLink } from '@lucide/svelte';
import { route } from '../lib/router.svelte';
import { logout, session } from '../lib/session.svelte';
import Avatar from './Avatar.svelte';
let { brandName = 'ScopeNet', logo = null }: { brandName?: string; logo?: string | null } = $props();
const items = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ id: 'instances', label: 'Instances', icon: Boxes },
{ id: 'users', label: 'Players', icon: Users },
{ id: 'branding', label: 'Launcher design', icon: Palette },
{ id: 'settings', label: 'Settings', icon: Settings },
];
</script>
<aside>
<div class="brand">
{#if logo}<img src={logo} alt="" />{:else}<img src="/favicon.svg" alt="" />{/if}
<div>
<strong>{brandName}</strong>
<span>Admin panel</span>
</div>
</div>
<nav>
{#each items as item}
<a href="#/{item.id}" class:active={route.name === item.id}>
<item.icon size={18} />
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="spacer"></div>
<a class="docs" href="https://github.com/scopeddlol/SCOPENET-MC#readme" target="_blank" rel="noreferrer">
<ExternalLink size={14} /> Documentation
</a>
{#if session.user}
<div class="me">
<Avatar name={session.user.username} size={34} />
<div class="who">
<strong>{session.user.username}</strong>
<span>Administrator</span>
</div>
<button class="ghost icon" onclick={logout} title="Sign out" aria-label="Sign out"><LogOut size={16} /></button>
</div>
{/if}
</aside>
<style>
aside {
position: sticky; top: 0; height: 100vh; width: 248px; flex-shrink: 0; display: flex; flex-direction: column;
padding: 20px 14px; border-right: 1px solid var(--line); background: rgba(9, 10, 18, 0.6); backdrop-filter: blur(20px);
}
.brand { display: flex; align-items: center; gap: 12px; padding: 4px 8px 24px; }
.brand img { width: 38px; height: 38px; border-radius: 11px; object-fit: cover; }
.brand div { display: flex; flex-direction: column; min-width: 0; }
.brand strong { font-size: 1.05rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.brand span { font-size: 0.75rem; color: var(--muted); }
nav { display: flex; flex-direction: column; gap: 2px; }
nav a {
display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 10px; color: var(--text-2);
font-weight: 500; transition: background 0.15s, color 0.15s; position: relative;
}
nav a:hover { background: rgba(255, 255, 255, 0.04); color: var(--text); }
nav a.active { background: var(--accent-soft); color: var(--text); }
nav a.active::before {
content: ''; position: absolute; left: -14px; top: 10px; bottom: 10px; width: 3px; border-radius: 0 3px 3px 0;
background: linear-gradient(var(--accent), var(--accent-2));
}
nav a.active :global(svg) { color: var(--accent); }
.docs { display: flex; align-items: center; gap: 8px; font-size: 0.8rem; color: var(--muted); padding: 10px 12px; }
.docs:hover { color: var(--text); }
.me { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 12px; background: var(--surface); border: 1px solid var(--line); }
.who { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.who strong { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; }
.who span { font-size: 0.72rem; color: var(--muted); }
@media (max-width: 900px) {
aside { width: 72px; padding: 16px 10px; }
.brand div, nav a span, .docs, .who { display: none; }
.brand { padding: 4px 6px 20px; }
nav a { justify-content: center; }
.me { justify-content: center; padding: 6px; }
.me :global(img), .me :global(.fallback) { display: none; }
}
</style>
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
import { CircleCheck, CircleAlert, Info, X } from '@lucide/svelte';
import { dismiss, toasts } from '../lib/toast.svelte';
</script>
<div class="toasts" aria-live="polite">
{#each toasts as t (t.id)}
<div class="toast {t.kind}">
{#if t.kind === 'ok'}<CircleCheck size={18} />{:else if t.kind === 'error'}<CircleAlert size={18} />{:else}<Info size={18} />{/if}
<span>{t.text}</span>
<button class="ghost icon" onclick={() => dismiss(t.id)} aria-label="Dismiss"><X size={14} /></button>
</div>
{/each}
</div>
<style>
.toasts { position: fixed; right: 20px; bottom: 20px; display: flex; flex-direction: column; gap: 10px; z-index: 100; max-width: 420px; }
.toast {
display: flex; align-items: center; gap: 10px; padding: 10px 10px 10px 14px; border-radius: 12px;
background: rgba(24, 27, 41, 0.92); backdrop-filter: blur(12px); border: 1px solid var(--line-strong);
box-shadow: var(--shadow); animation: pop 0.2s ease; font-size: 0.9rem;
}
.toast span { flex: 1; }
.ok :global(svg:first-child) { color: var(--good); }
.error :global(svg:first-child) { color: var(--bad); }
.info :global(svg:first-child) { color: var(--accent-2); }
@keyframes pop { from { opacity: 0; transform: translateY(8px) scale(0.98); } }
</style>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
let { checked = $bindable(false), label, help, disabled = false }: { checked: boolean; label: string; help?: string; disabled?: boolean } = $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} />
<span class="track" aria-hidden="true"><span class="thumb"></span></span>
</label>
<style>
.toggle { display: flex; align-items: center; gap: 16px; cursor: pointer; padding: 4px 0; }
.toggle.disabled { opacity: 0.5; cursor: not-allowed; }
.text { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.label { font-weight: 550; font-size: 0.92rem; }
.help { color: var(--muted); font-size: 0.8rem; }
input { position: absolute; opacity: 0; pointer-events: none; }
.track { width: 42px; height: 24px; border-radius: 99px; background: var(--surface-3); position: relative; transition: background 0.2s; flex-shrink: 0; border: 1px solid var(--line-strong); }
.thumb { position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #cfd3f5; transition: transform 0.2s cubic-bezier(0.3, 1.4, 0.6, 1), background 0.2s; }
input:checked + .track { background: var(--accent); border-color: transparent; }
input:checked + .track .thumb { transform: translateX(18px); background: white; }
input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
</style>
@@ -0,0 +1,96 @@
<script lang="ts">
import { get, LOADERS } from '../lib/api';
let { mc = $bindable(''), loader = $bindable('vanilla'), loaderVersion = $bindable<string | null>(null), showLoader = true }: {
mc: string; loader: string; loaderVersion: string | null; showLoader?: boolean;
} = $props();
type V = { id: string; kind: string };
let versions = $state<V[]>([]);
let snapshots = $state(false);
let metaError = $state(false);
let loaderVersions = $state<{ version: string; stable: boolean }[]>([]);
let loadingLoaders = $state(false);
let cache: { versions?: V[] } = (globalThis as any).__mcCache ??= {};
$effect(() => {
if (cache.versions) {
versions = cache.versions;
return;
}
get<{ versions: V[]; latest: { release: string } }>('/api/admin/meta/minecraft')
.then((r) => {
versions = cache.versions = r.versions;
if (!mc) mc = r.latest.release;
})
.catch(() => (metaError = true));
});
$effect(() => {
const l = loader, v = mc;
loaderVersions = [];
if (l === 'vanilla' || !v || !showLoader) return;
loadingLoaders = true;
get<{ version: string; stable: boolean }[]>(`/api/admin/meta/loaders/${l}?mc=${encodeURIComponent(v)}`)
.then((r) => (loaderVersions = r))
.catch(() => (loaderVersions = []))
.finally(() => (loadingLoaders = false));
});
const shown = $derived(versions.filter((v) => snapshots || v.kind === 'release'));
</script>
<div class="picker">
<label class="field">
Minecraft version
<div class="row">
{#if metaError || versions.length === 0}
<input bind:value={mc} placeholder="1.21.1" list="mc-versions" />
{:else}
<select bind:value={mc}>
{#if mc && !shown.some((v) => v.id === mc)}<option value={mc}>{mc}</option>{/if}
{#each shown as v}<option value={v.id}>{v.id}{v.kind !== 'release' ? ` (${v.kind})` : ''}</option>{/each}
</select>
{/if}
<label class="snap" title="Show snapshots and old versions"><input type="checkbox" bind:checked={snapshots} /> All</label>
</div>
{#if metaError}<span class="help">Couldn't reach Mojang — type the version manually.</span>{/if}
</label>
{#if showLoader}
<div class="field">
<span class="lbl">Mod loader</span>
<div class="segmented loaders">
{#each LOADERS as l}
<button type="button" class:active={loader === l.id} onclick={() => { loader = l.id; loaderVersion = null; }}>{l.label}</button>
{/each}
</div>
</div>
{#if loader !== 'vanilla'}
<label class="field">
Loader version
{#if loaderVersions.length}
<select value={loaderVersion ?? ''} onchange={(e) => (loaderVersion = (e.target as HTMLSelectElement).value || null)}>
<option value="">Latest recommended</option>
{#if loaderVersion && !loaderVersions.some((v) => v.version === loaderVersion)}<option value={loaderVersion}>{loaderVersion}</option>{/if}
{#each loaderVersions as v}<option value={v.version}>{v.version}{v.stable ? ' ★' : ''}</option>{/each}
</select>
{:else}
<input value={loaderVersion ?? ''} oninput={(e) => (loaderVersion = (e.target as HTMLInputElement).value || null)} placeholder={loadingLoaders ? 'Loading…' : 'Latest (leave empty)'} />
{#if !loadingLoaders}<span class="help">No version list available — leave empty to use the latest.</span>{/if}
{/if}
</label>
{/if}
{/if}
</div>
<style>
.picker { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 7px; }
.lbl { font-size: 0.85rem; color: var(--text-2); font-weight: 500; }
.snap { display: flex; align-items: center; gap: 6px; font-size: 0.8rem; color: var(--muted); white-space: nowrap; cursor: pointer; }
.loaders { flex-wrap: wrap; }
.help { color: var(--muted); font-size: 0.78rem; }
</style>