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:
32 files changed
+4084
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user