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

+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>ScopeNet Panel</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1255
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "scopenet-panel-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource-variable/outfit": "^5.3.0",
"@fontsource-variable/plus-jakarta-sans": "^5.3.0",
"@fontsource-variable/space-grotesk": "^5.3.0",
"@lucide/svelte": "^1.48.0",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tsconfig/svelte": "^5.0.8",
"svelte": "^5.57.1",
"svelte-check": "^4.7.6",
"typescript": "~5.9.0",
"vite": "^8.3.1"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7c5cff"/><stop offset="1" stop-color="#22d3ee"/></linearGradient></defs><rect width="64" height="64" rx="16" fill="#0b0d17"/><path d="M32 10 52 21v22L32 54 12 43V21z" fill="none" stroke="url(#g)" stroke-width="5" stroke-linejoin="round"/><circle cx="32" cy="32" r="7" fill="url(#g)"/></svg>

After

Width:  |  Height:  |  Size: 438 B

+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
import Sidebar from './components/Sidebar.svelte';
import Toasts from './components/Toasts.svelte';
import Login from './pages/Login.svelte';
import Dashboard from './pages/Dashboard.svelte';
import Instances from './pages/Instances.svelte';
import InstanceEditor from './pages/InstanceEditor.svelte';
import Users from './pages/Users.svelte';
import Branding from './pages/Branding.svelte';
import Settings from './pages/Settings.svelte';
import { get } from './lib/api';
import { route } from './lib/router.svelte';
import { logout, session, type Me } from './lib/session.svelte';
import { toast } from './lib/toast.svelte';
let brand = $state<{ name: string; logo_url: string | null }>({ name: 'ScopeNet', logo_url: null });
let checking = $state(!!session.token);
$effect(() => {
if (!session.token) {
checking = false;
return;
}
get<Me>('/api/v1/auth/me')
.then((me) => {
if (me.role !== 'admin') {
toast('That account is not an admin.', 'error');
logout();
} else session.user = me;
})
.catch(() => logout())
.finally(() => (checking = false));
});
$effect(() => {
get('/api/v1/launcher/manifest')
.then((m) => {
brand = m.branding;
document.title = `${m.branding.name} · Admin`;
})
.catch(() => {});
});
</script>
{#if checking}
<div class="boot"><span class="pulse"></span></div>
{:else if !session.user}
<Login brandName={brand.name} logo={brand.logo_url} />
{:else}
<div class="shell">
<Sidebar brandName={brand.name} logo={brand.logo_url} />
<main>
{#key route.name + route.params.join('/')}
{#if route.name === 'instances' && route.params[0]}
<InstanceEditor id={route.params[0]} />
{:else if route.name === 'instances'}
<Instances />
{:else if route.name === 'users'}
<Users />
{:else if route.name === 'branding'}
<Branding onsaved={(b) => (brand = b)} />
{:else if route.name === 'settings'}
<Settings />
{:else}
<Dashboard />
{/if}
{/key}
</main>
</div>
{/if}
<Toasts />
<style>
.shell { display: flex; min-height: 100vh; }
main { flex: 1; min-width: 0; }
.boot { height: 100vh; display: grid; place-items: center; }
.pulse { width: 42px; height: 42px; border-radius: 12px; background: linear-gradient(135deg, var(--accent), var(--accent-2)); animation: pulse 1s ease-in-out infinite alternate; }
@keyframes pulse { from { transform: scale(0.85); opacity: 0.6; } to { transform: scale(1); opacity: 1; } }
</style>
+311
View File
@@ -0,0 +1,311 @@
:root {
--bg: #090a12;
--bg-2: #0e1019;
--surface: #12141f;
--surface-2: #181b29;
--surface-3: #20243a;
--line: rgba(255, 255, 255, 0.07);
--line-strong: rgba(255, 255, 255, 0.13);
--text: #eef0ff;
--text-2: #b7bbdb;
--muted: #7d82a8;
--accent: #8b6cff;
--accent-2: #22d3ee;
--accent-soft: rgba(139, 108, 255, 0.14);
--good: #34d399;
--warn: #fbbf24;
--bad: #f43f5e;
--radius: 14px;
--radius-sm: 9px;
--shadow: 0 20px 60px -20px rgba(0, 0, 0, 0.6);
--font: 'Outfit Variable', system-ui, sans-serif;
--mono: 'JetBrains Mono Variable', ui-monospace, monospace;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 15px;
-webkit-font-smoothing: antialiased;
}
body {
background:
radial-gradient(1200px 600px at 110% -10%, rgba(139, 108, 255, 0.12), transparent 60%),
radial-gradient(900px 500px at -10% 110%, rgba(34, 211, 238, 0.07), transparent 60%),
var(--bg);
background-attachment: fixed;
}
a {
color: var(--accent-2);
text-decoration: none;
}
h1, h2, h3, h4 {
margin: 0;
font-weight: 650;
letter-spacing: -0.01em;
}
h1 { font-size: 1.75rem; }
h2 { font-size: 1.2rem; }
h3 { font-size: 1rem; }
p { margin: 0; }
code, .mono {
font-family: var(--mono);
font-size: 0.85em;
}
::selection {
background: rgba(139, 108, 255, 0.4);
}
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 10px; border: 2px solid var(--bg); }
::-webkit-scrollbar-track { background: transparent; }
/* ---------- layout primitives ---------- */
.page {
padding: 32px 40px 64px;
max-width: 1320px;
margin: 0 auto;
animation: fade-in 0.25s ease;
}
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
flex-wrap: wrap;
}
.page-head p {
color: var(--muted);
margin-top: 6px;
}
.row { display: flex; gap: 12px; align-items: center; }
.row.wrap { flex-wrap: wrap; }
.col { display: flex; flex-direction: column; gap: 12px; }
.spacer { flex: 1; }
.grid { display: grid; gap: 16px; }
.muted { color: var(--muted); }
.small { font-size: 0.85rem; }
.tiny { font-size: 0.75rem; }
.card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.01)), var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 20px;
}
.card.hover {
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
}
.card.hover:hover {
border-color: var(--line-strong);
transform: translateY(-2px);
box-shadow: var(--shadow);
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.section-title .hint {
margin-left: auto;
font-size: 0.8rem;
color: var(--muted);
}
/* ---------- controls ---------- */
button, .btn {
font: inherit;
cursor: pointer;
border: 1px solid var(--line-strong);
background: var(--surface-2);
color: var(--text);
border-radius: var(--radius-sm);
padding: 9px 16px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-weight: 550;
transition: background 0.15s, border-color 0.15s, transform 0.08s, box-shadow 0.15s, opacity 0.15s;
white-space: nowrap;
}
button:hover { background: var(--surface-3); }
button:active { transform: translateY(1px); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
button.primary {
background: linear-gradient(135deg, var(--accent), #6a4cf0);
border-color: transparent;
box-shadow: 0 8px 24px -10px rgba(139, 108, 255, 0.8);
}
button.primary:hover { filter: brightness(1.1); }
button.ghost { background: transparent; border-color: transparent; color: var(--text-2); }
button.ghost:hover { background: rgba(255, 255, 255, 0.05); color: var(--text); }
button.danger { color: #ff8a9e; border-color: rgba(244, 63, 94, 0.3); background: rgba(244, 63, 94, 0.08); }
button.danger:hover { background: rgba(244, 63, 94, 0.16); }
button.icon { padding: 8px; }
button.sm { padding: 6px 11px; font-size: 0.85rem; }
input, select, textarea {
font: inherit;
width: 100%;
background: var(--bg-2);
color: var(--text);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
padding: 10px 12px;
transition: border-color 0.15s, background 0.15s;
}
input:hover, select:hover, textarea:hover { border-color: rgba(255, 255, 255, 0.2); }
input:focus, select:focus, textarea:focus { border-color: var(--accent); background: #0c0e18; outline: none; }
textarea { resize: vertical; min-height: 80px; line-height: 1.5; }
select { appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%); background-position: calc(100% - 18px) 55%, calc(100% - 13px) 55%; background-size: 5px 5px; background-repeat: no-repeat; padding-right: 34px; }
input[type='color'] { padding: 0; width: 42px; height: 42px; border-radius: 10px; cursor: pointer; overflow: hidden; }
input[type='color']::-webkit-color-swatch-wrapper { padding: 0; }
input[type='color']::-webkit-color-swatch { border: none; }
input[type='range'] { padding: 0; accent-color: var(--accent); border: none; background: transparent; }
input[type='checkbox'] { width: auto; accent-color: var(--accent); }
label.field {
display: flex;
flex-direction: column;
gap: 7px;
font-size: 0.85rem;
color: var(--text-2);
font-weight: 500;
}
label.field .help { color: var(--muted); font-weight: 400; font-size: 0.78rem; }
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 9px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
background: rgba(255, 255, 255, 0.06);
color: var(--text-2);
border: 1px solid var(--line);
white-space: nowrap;
}
.badge.accent { background: var(--accent-soft); color: #c4b5ff; border-color: rgba(139, 108, 255, 0.3); }
.badge.good { background: rgba(52, 211, 153, 0.12); color: #6ee7b7; border-color: rgba(52, 211, 153, 0.25); }
.badge.warn { background: rgba(251, 191, 36, 0.12); color: #fcd34d; border-color: rgba(251, 191, 36, 0.25); }
.badge.bad { background: rgba(244, 63, 94, 0.12); color: #fda4af; border-color: rgba(244, 63, 94, 0.25); }
.segmented {
display: inline-flex;
background: var(--bg-2);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
padding: 3px;
gap: 2px;
}
.segmented button {
border: none;
background: transparent;
padding: 7px 14px;
border-radius: 7px;
color: var(--muted);
font-size: 0.88rem;
}
.segmented button.active { background: var(--surface-3); color: var(--text); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); }
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--line);
margin-bottom: 24px;
overflow-x: auto;
}
.tabs button {
background: none;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
color: var(--muted);
padding: 12px 16px;
}
.tabs button:hover { color: var(--text); background: none; }
.tabs button.active { color: var(--text); border-bottom-color: var(--accent); }
table.table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.table th {
text-align: left;
color: var(--muted);
font-weight: 500;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 10px 14px;
border-bottom: 1px solid var(--line);
}
.table td {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
.table tr:last-child td { border-bottom: none; }
.table tbody tr { transition: background 0.1s; }
.table tbody tr:hover { background: rgba(255, 255, 255, 0.02); }
.empty {
text-align: center;
padding: 56px 20px;
color: var(--muted);
}
.empty h3 { color: var(--text); margin: 12px 0 6px; }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.spin { animation: spin 0.9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fade-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@media (max-width: 900px) {
.page { padding: 20px 16px 48px; }
}
+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>
+73
View File
@@ -0,0 +1,73 @@
import { logout, session } from './session.svelte';
export class ApiError extends Error {
constructor(message: string, public status: number) {
super(message);
}
}
type Options = { method?: string; body?: unknown; form?: FormData };
export async function api<T = any>(path: string, opts: Options = {}): Promise<T> {
const headers: Record<string, string> = {};
if (session.token) headers.Authorization = `Bearer ${session.token}`;
let body: BodyInit | undefined;
if (opts.form) body = opts.form;
else if (opts.body !== undefined) {
headers['Content-Type'] = 'application/json';
body = JSON.stringify(opts.body);
}
const res = await fetch(path, { method: opts.method ?? (body ? 'POST' : 'GET'), headers, body });
const text = await res.text();
let data: any = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
if (!res.ok) {
if (res.status === 401 && session.token) logout();
throw new ApiError(data?.error ?? `Request failed (${res.status})`, res.status);
}
return data as T;
}
export const get = <T = any>(p: string) => api<T>(p);
export const post = <T = any>(p: string, body?: unknown) => api<T>(p, { method: 'POST', body: body ?? {} });
export const put = <T = any>(p: string, body: unknown) => api<T>(p, { method: 'PUT', body });
export const patch = <T = any>(p: string, body: unknown) => api<T>(p, { method: 'PATCH', body });
export const del = <T = any>(p: string) => api<T>(p, { method: 'DELETE' });
export async function uploadMedia(file: File): Promise<string> {
const form = new FormData();
form.append('file', file);
const res = await api<{ url: string }>('/api/admin/uploads', { method: 'POST', form });
return res.url;
}
export function formatBytes(n: number): string {
if (!n) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
return `${(n / 1024 ** i).toFixed(i ? 1 : 0)} ${units[i]}`;
}
export function timeAgo(iso?: string | null): string {
if (!iso) return 'never';
const s = (Date.now() - new Date(iso).getTime()) / 1000;
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`;
return new Date(iso).toLocaleDateString();
}
export const LOADERS = [
{ id: 'vanilla', label: 'Vanilla' },
{ id: 'fabric', label: 'Fabric' },
{ id: 'quilt', label: 'Quilt' },
{ id: 'forge', label: 'Forge' },
{ id: 'neoforge', label: 'NeoForge' },
] as const;
export const loaderLabel = (id: string) => LOADERS.find((l) => l.id === id)?.label ?? id;
+18
View File
@@ -0,0 +1,18 @@
// Tiny hash router: #/instances/abc → { name: 'instances', params: ['abc'] }
function parse() {
const parts = location.hash.replace(/^#\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);
return { name: parts[0] ?? 'dashboard', params: parts.slice(1) };
}
export const route = $state(parse());
window.addEventListener('hashchange', () => {
const r = parse();
route.name = r.name;
route.params = r.params;
window.scrollTo({ top: 0 });
});
export function go(path: string) {
location.hash = '#/' + path.replace(/^\//, '');
}
+34
View File
@@ -0,0 +1,34 @@
const KEY = 'scopenet.panel.token';
function read(): string {
try {
return localStorage.getItem(KEY) ?? '';
} catch {
return '';
}
}
export interface Me {
id: number;
username: string;
uuid: string;
role: string;
groups: string[];
}
export const session = $state<{ token: string; user: Me | null }>({ token: read(), user: null });
export function setToken(token: string) {
session.token = token;
try {
if (token) localStorage.setItem(KEY, token);
else localStorage.removeItem(KEY);
} catch {
/* storage unavailable: session lasts until reload */
}
}
export function logout() {
setToken('');
session.user = null;
}
+19
View File
@@ -0,0 +1,19 @@
export type Toast = { id: number; kind: 'ok' | 'error' | 'info'; text: string };
export const toasts = $state<Toast[]>([]);
let next = 1;
export function toast(text: string, kind: Toast['kind'] = 'ok') {
const id = next++;
toasts.push({ id, kind, text });
setTimeout(() => dismiss(id), kind === 'error' ? 6000 : 3200);
}
export function dismiss(id: number) {
const i = toasts.findIndex((t) => t.id === id);
if (i >= 0) toasts.splice(i, 1);
}
export function toastError(e: unknown) {
toast(e instanceof Error ? e.message : String(e), 'error');
}
+80
View File
@@ -0,0 +1,80 @@
export interface MemoryDefaults { min_mb: number; max_mb: number }
export interface ServerEntry { name: string; address: string; port: number; auto_join: boolean; inject: boolean }
export interface Instance {
id: string;
name: string;
description: string;
icon_url: string | null;
banner_url: string | null;
mc_version: string;
loader: string;
loader_version: string | null;
source_kind: string;
source_label: string;
source_ref: any;
visibility: 'public' | 'members' | 'groups';
allowed_groups: string[];
memory: MemoryDefaults;
jvm_args: string;
server: ServerEntry | null;
featured: boolean;
enabled: boolean;
sort: number;
revision: number;
created_at: string;
updated_at: string;
file_count: number;
total_size: number;
missing_count: number;
}
export interface FileRow { path: string; url: string; sha1: string; size: number; origin: string; note: string | null }
export interface NewsItem { id: string; title: string; body: string; image_url: string | null; link: string | null; date: string | null; pinned: boolean; tag: string | null }
export interface SocialLink { label: string; url: string; icon: string }
export interface Branding {
name: string;
tagline: string;
logo_url: string | null;
icon_url: string | null;
background: { kind: 'gradient' | 'image' | 'video'; url: string | null; blur: number; dim: number };
colors: { accent: string; accent_2: string; background: string; surface: string; text: string; muted: string; success: string; danger: string };
font: string;
radius: number;
glass: boolean;
version_label: string;
news: NewsItem[];
links: SocialLink[];
features: { news: boolean; server_status: boolean; allow_user_theme: boolean; allow_advanced_java: boolean };
custom_css: string;
}
export interface User {
id: number;
username: string;
email: string | null;
role: 'admin' | 'player';
status: 'active' | 'pending' | 'disabled';
created_at: string;
last_login: string | null;
uuid: string;
groups: string[];
}
export interface Group { id: number; name: string; color: string; members: number }
export interface Settings {
auth: {
panel_accounts: boolean;
registration: 'closed' | 'open' | 'approval';
microsoft: boolean;
microsoft_client_id: string | null;
offline_local: boolean;
};
curseforge_api_key?: string | null;
launcher_download_url: string | null;
curseforge_key_set: boolean;
curseforge_key_from_env: boolean;
}
+11
View File
@@ -0,0 +1,11 @@
import '@fontsource-variable/outfit';
import '@fontsource-variable/jetbrains-mono';
// Launcher fonts, so the design preview matches exactly.
import '@fontsource-variable/inter';
import '@fontsource-variable/space-grotesk';
import '@fontsource-variable/plus-jakarta-sans';
import './app.css';
import { mount } from 'svelte';
import App from './App.svelte';
mount(App, { target: document.getElementById('app')! });
+252
View File
@@ -0,0 +1,252 @@
<script lang="ts">
import { Save, LoaderCircle, Plus, Trash2, GripVertical, Pin, Sparkles, RotateCcw } from '@lucide/svelte';
import ColorInput from '../components/ColorInput.svelte';
import ImageInput from '../components/ImageInput.svelte';
import LauncherPreview from '../components/LauncherPreview.svelte';
import Toggle from '../components/Toggle.svelte';
import { get, put } from '../lib/api';
import { toast, toastError } from '../lib/toast.svelte';
import type { Branding } from '../lib/types';
let { onsaved }: { onsaved?: (b: Branding) => void } = $props();
let b = $state<Branding | null>(null);
let saved = $state('');
let saving = $state(false);
let section = $state('identity');
$effect(() => {
get<Branding>('/api/admin/branding').then((x) => {
b = x;
saved = JSON.stringify(x);
});
});
const dirty = $derived(!!b && JSON.stringify(b) !== saved);
async function save() {
if (!b) return;
saving = true;
try {
const r = await put<Branding>('/api/admin/branding', $state.snapshot(b));
saved = JSON.stringify(r);
onsaved?.(r);
toast('Published — launchers pick it up on their next refresh');
} catch (e) {
toastError(e);
} finally {
saving = false;
}
}
const presets = [
{ name: 'Nebula', c: { accent: '#7c5cff', accent_2: '#22d3ee', background: '#0b0d17', surface: '#141828', text: '#eef0ff', muted: '#8b90b3' } },
{ name: 'Creeper', c: { accent: '#22c55e', accent_2: '#a3e635', background: '#07110b', surface: '#0f1d15', text: '#ecfdf3', muted: '#86a893' } },
{ name: 'Nether', c: { accent: '#f97316', accent_2: '#ef4444', background: '#130806', surface: '#22100c', text: '#fff1eb', muted: '#b48a7e' } },
{ name: 'Ocean', c: { accent: '#0ea5e9', accent_2: '#2dd4bf', background: '#06111a', surface: '#0c1d2b', text: '#e8f6ff', muted: '#7ea1b8' } },
{ name: 'Sakura', c: { accent: '#ec4899', accent_2: '#f9a8d4', background: '#140a12', surface: '#22121e', text: '#fff0f8', muted: '#b88aa6' } },
{ name: 'End', c: { accent: '#a855f7', accent_2: '#facc15', background: '#0d0a14', surface: '#18122a', text: '#f5f0ff', muted: '#9d8fbd' } },
{ name: 'Mono', c: { accent: '#e5e7eb', accent_2: '#9ca3af', background: '#0a0a0a', surface: '#161616', text: '#fafafa', muted: '#8a8a8a' } },
];
function applyPreset(p: (typeof presets)[number]) {
if (b) b.colors = { ...b.colors, ...p.c };
}
const fonts = ['Outfit', 'Inter', 'Space Grotesk', 'Plus Jakarta Sans'];
const linkIcons = ['discord', 'website', 'store', 'youtube', 'twitter', 'twitch', 'github'];
function addNews() {
b?.news.unshift({ id: crypto.randomUUID().slice(0, 8), title: '', body: '', image_url: null, link: null, date: new Date().toISOString().slice(0, 10), pinned: false, tag: 'News' });
}
let dragIndex = $state<number | null>(null);
function dropNews(i: number) {
if (!b || dragIndex === null || dragIndex === i) return;
const [item] = b.news.splice(dragIndex, 1);
b.news.splice(i, 0, item);
dragIndex = null;
}
const sections = [
{ id: 'identity', label: 'Identity' },
{ id: 'colors', label: 'Colours' },
{ id: 'background', label: 'Background' },
{ id: 'style', label: 'Style' },
{ id: 'news', label: 'News' },
{ id: 'links', label: 'Links' },
{ id: 'features', label: 'Features' },
{ id: 'css', label: 'Custom CSS' },
];
</script>
<div class="page wide">
<div class="page-head">
<div>
<h1>Launcher design</h1>
<p>Everything here updates every player's launcher live — no rebuild or reinstall.</p>
</div>
<div class="row">
{#if dirty}<button class="ghost" onclick={() => (b = JSON.parse(saved))}><RotateCcw size={16} /> Discard</button>{/if}
<button class="primary" disabled={!dirty || saving} onclick={save}>
{#if saving}<LoaderCircle class="spin" size={16} />{:else}<Save size={16} />{/if} Publish
</button>
</div>
</div>
{#if b}
<div class="layout">
<div class="editor">
<div class="tabs">
{#each sections as s}<button class:active={section === s.id} onclick={() => (section = s.id)}>{s.label}</button>{/each}
</div>
{#if section === 'identity'}
<div class="card col">
<label class="field">Launcher name<input bind:value={b.name} maxlength="40" /></label>
<label class="field">Tagline<input bind:value={b.tagline} maxlength="120" /></label>
<ImageInput bind:value={b.logo_url} label="Logo" help="Shown in the title bar and sign-in screen. Transparent PNG works best." />
<ImageInput bind:value={b.icon_url} label="Avatar / small icon" />
<label class="field">In-game version label <span class="help">Appears on the Minecraft title screen and F3 menu.</span>
<input bind:value={b.version_label} maxlength="32" />
</label>
</div>
{:else if section === 'colors'}
<div class="card col">
<div class="presets">
{#each presets as p}
<button class="preset" onclick={() => applyPreset(p)} title={p.name}>
<span class="sw" style:background="linear-gradient(135deg, {p.c.accent}, {p.c.accent_2})"></span>
<span class="sw2" style:background={p.c.background}></span>
<span>{p.name}</span>
</button>
{/each}
</div>
<div class="colors">
<ColorInput bind:value={b.colors.accent} label="Accent" />
<ColorInput bind:value={b.colors.accent_2} label="Accent 2 (gradient)" />
<ColorInput bind:value={b.colors.background} label="Background" />
<ColorInput bind:value={b.colors.surface} label="Surface" />
<ColorInput bind:value={b.colors.text} label="Text" />
<ColorInput bind:value={b.colors.muted} label="Muted text" />
<ColorInput bind:value={b.colors.success} label="Success" />
<ColorInput bind:value={b.colors.danger} label="Danger" />
</div>
</div>
{:else if section === 'background'}
<div class="card col">
<div class="segmented">
{#each ['gradient', 'image', 'video'] as k}
<button class:active={b.background.kind === k} onclick={() => b && (b.background.kind = k as Branding['background']['kind'])}>{k[0].toUpperCase() + k.slice(1)}</button>
{/each}
</div>
{#if b.background.kind !== 'gradient'}
<ImageInput bind:value={b.background.url} label={b.background.kind === 'video' ? 'Video (mp4/webm)' : 'Image'} video={b.background.kind === 'video'}
help={b.background.kind === 'video' ? 'Keep it short and under ~10 MB. Players can turn video off to save resources.' : 'At least 1600×900 looks sharp.'} />
{:else}
<p class="muted small">An animated gradient built from your accent colours. The lightest option on player PCs.</p>
{/if}
<label class="field"><span class="row">Blur <span class="spacer"></span>{b.background.blur}px</span><input type="range" min="0" max="30" bind:value={b.background.blur} /></label>
<label class="field"><span class="row">Darken <span class="spacer"></span>{b.background.dim}%</span><input type="range" min="0" max="90" bind:value={b.background.dim} /></label>
</div>
{:else if section === 'style'}
<div class="card col">
<div class="field">
<span class="lbl">Font</span>
<div class="fonts">
{#each fonts as f}
<button class="font" class:active={b.font === f} style:font-family="'{f} Variable', '{f}', sans-serif" onclick={() => b && (b.font = f)}>
<span class="aa">Aa</span><span>{f}</span>
</button>
{/each}
</div>
</div>
<label class="field"><span class="row">Corner radius <span class="spacer"></span>{b.radius}px</span><input type="range" min="0" max="28" bind:value={b.radius} /></label>
<Toggle bind:checked={b.glass} label="Frosted glass" help="Blurred translucent panels. Turn off for very old graphics cards." />
</div>
{:else if section === 'news'}
<div class="col">
<button class="add" onclick={addNews}><Plus size={16} /> Add a post</button>
{#each b.news as n, i (n.id)}
<div class="card col news" draggable="true" role="listitem"
ondragstart={() => (dragIndex = i)} ondragover={(e) => e.preventDefault()} ondrop={() => dropNews(i)}>
<div class="row">
<span class="grip" title="Drag to reorder"><GripVertical size={16} /></span>
<input class="title" bind:value={n.title} placeholder="Headline" />
<button class="ghost icon" class:pinned={n.pinned} title="Pin to top" aria-label="Pin" onclick={() => (n.pinned = !n.pinned)}><Pin size={15} /></button>
<button class="ghost icon" aria-label="Delete post" onclick={() => b?.news.splice(i, 1)}><Trash2 size={15} /></button>
</div>
<textarea bind:value={n.body} rows="3" placeholder="What's new?"></textarea>
<div class="grid three">
<label class="field">Tag<input bind:value={n.tag} placeholder="Update" /></label>
<label class="field">Date<input type="date" bind:value={n.date} /></label>
<label class="field">Link<input bind:value={n.link} placeholder="https://…" /></label>
</div>
<ImageInput bind:value={n.image_url} label="Image" />
</div>
{:else}
<div class="card empty"><Sparkles size={28} /><h3>No news yet</h3><p>Posts show up on the launcher home screen.</p></div>
{/each}
</div>
{:else if section === 'links'}
<div class="card col">
{#each b.links as l, i}
<div class="row link">
<select bind:value={l.icon} style="width: 130px">{#each linkIcons as ic}<option value={ic}>{ic}</option>{/each}</select>
<input bind:value={l.label} placeholder="Label" style="width: 150px" />
<input bind:value={l.url} placeholder="https://discord.gg/…" />
<button class="ghost icon" aria-label="Remove link" onclick={() => b?.links.splice(i, 1)}><Trash2 size={15} /></button>
</div>
{/each}
<button class="add" onclick={() => b?.links.push({ icon: 'discord', label: 'Discord', url: '' })}><Plus size={16} /> Add link</button>
</div>
{:else if section === 'features'}
<div class="card col">
<Toggle bind:checked={b.features.news} label="News feed" help="Show your posts on the home screen." />
<Toggle bind:checked={b.features.server_status} label="Live server status" help="Player count, MOTD and ping for each instance's server." />
<Toggle bind:checked={b.features.allow_user_theme} label="Let players change the theme" help="Players can pick their own accent colour and turn off effects." />
<Toggle bind:checked={b.features.allow_advanced_java} label="Advanced Java settings" help="Let players set custom JVM arguments and Java paths." />
</div>
{:else if section === 'css'}
<div class="card col">
<p class="muted small">Injected into the launcher after the built-in styles. Useful variables: <code>--accent</code>, <code>--surface</code>, <code>--radius</code>.</p>
<textarea class="mono css" bind:value={b.custom_css} rows="16" spellcheck="false" placeholder={'.play-button {\n letter-spacing: 0.2em;\n}'}></textarea>
</div>
{/if}
</div>
<div class="preview">
<div class="sticky">
<span class="tiny muted label">Live preview</span>
<LauncherPreview {b} />
</div>
</div>
</div>
{/if}
</div>
<style>
.wide { max-width: 1500px; }
.layout { display: grid; grid-template-columns: minmax(380px, 520px) 1fr; gap: 28px; align-items: start; }
@media (max-width: 1150px) { .layout { grid-template-columns: 1fr; } .preview { order: -1; } }
.editor .tabs { margin-bottom: 16px; }
.sticky { position: sticky; top: 24px; display: flex; flex-direction: column; gap: 10px; }
.label { text-transform: uppercase; letter-spacing: 0.1em; font-weight: 600; }
.field { display: flex; flex-direction: column; gap: 7px; }
.lbl { font-size: 0.85rem; color: var(--text-2); font-weight: 500; }
.presets { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; }
.preset { flex-direction: column; gap: 6px; padding: 10px; background: var(--bg-2); font-size: 0.8rem; position: relative; }
.sw { width: 100%; height: 28px; border-radius: 8px; }
.sw2 { position: absolute; right: 16px; top: 16px; width: 14px; height: 14px; border-radius: 50%; border: 2px solid rgba(255, 255, 255, 0.3); }
.colors { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.fonts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.font { flex-direction: column; align-items: flex-start; gap: 2px; padding: 12px 14px; background: var(--bg-2); font-weight: 400; }
.font .aa { font-size: 1.6rem; font-weight: 700; }
.font.active { border-color: var(--accent); background: var(--accent-soft); }
.add { border-style: dashed; background: transparent; color: var(--text-2); }
.news .title { font-weight: 600; }
.grip { cursor: grab; color: var(--muted); display: flex; }
.pinned { color: var(--accent) !important; }
.three { grid-template-columns: 1fr 1fr 1.4fr; }
.css { min-height: 300px; font-size: 0.85rem; }
</style>
+150
View File
@@ -0,0 +1,150 @@
<script lang="ts">
import { Users, UserCheck, Boxes, Activity, Download, ArrowRight, Palette, Rocket, CircleCheck, Circle } from '@lucide/svelte';
import BarChart from '../components/BarChart.svelte';
import Avatar from '../components/Avatar.svelte';
import { get, timeAgo } from '../lib/api';
import { session } from '../lib/session.svelte';
let stats = $state<any>(null);
let instances = $state<any[]>([]);
let branding = $state<any>(null);
$effect(() => {
get('/api/admin/stats').then((s) => (stats = s));
get('/api/admin/instances').then((i) => (instances = i));
get('/api/admin/branding').then((b) => (branding = b));
});
const nameOf = (id: string | null) => instances.find((i) => i.id === id)?.name ?? id ?? 'Unknown';
const hour = new Date().getHours();
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
const total = $derived(stats ? stats.launches.reduce((a: number, d: any) => a + d.launches, 0) : 0);
const steps = $derived([
{ done: !!branding && (branding.name !== 'ScopeNet' || branding.logo_url), label: 'Make the launcher yours', href: '#/branding', icon: Palette },
{ done: instances.length > 0, label: 'Create your first instance', href: '#/instances', icon: Boxes },
{ done: !!stats?.launcher_download_url, label: 'Share the launcher download link', href: '#/settings', icon: Download },
{ done: total > 0, label: 'Launch the game once', href: '#/instances', icon: Rocket },
]);
</script>
<div class="page">
<div class="page-head">
<div>
<h1>{greeting}, {session.user?.username}</h1>
<p>Here's what's happening with your launcher.</p>
</div>
{#if stats?.launcher_download_url}
<a class="btn primary" href={stats.launcher_download_url} target="_blank" rel="noreferrer"><Download size={16} /> Launcher download</a>
{/if}
</div>
<div class="grid stats">
{#each [
{ label: 'Active players (7d)', value: stats?.players_7d, icon: Activity, tone: 'accent' },
{ label: 'Accounts', value: stats?.users, icon: Users, tone: 'cyan' },
{ label: 'Awaiting approval', value: stats?.pending, icon: UserCheck, tone: 'warn', href: '#/users' },
{ label: 'Instances', value: stats?.instances, icon: Boxes, tone: 'green', href: '#/instances' },
] as s}
<a class="card stat hover {s.tone}" href={s.href ?? '#/dashboard'}>
<span class="icon"><s.icon size={18} /></span>
<span class="value">{s.value ?? '—'}</span>
<span class="label">{s.label}</span>
</a>
{/each}
</div>
<div class="grid main">
<section class="card">
<div class="section-title">
<h2>Game launches</h2>
<span class="hint">{total.toLocaleString()} in the last 14 days</span>
</div>
{#if stats}
<BarChart data={stats.launches} label="Game launches per day, last 14 days" />
{:else}
<div class="skeleton" style="height: 200px"></div>
{/if}
</section>
<section class="card">
<div class="section-title"><h2>Getting started</h2></div>
<div class="steps">
{#each steps as s}
<a class="step" class:done={s.done} href={s.href}>
{#if s.done}<CircleCheck size={18} />{:else}<Circle size={18} />{/if}
<span>{s.label}</span>
<ArrowRight size={14} />
</a>
{/each}
</div>
</section>
<section class="card">
<div class="section-title"><h2>Popular instances</h2></div>
{#if stats?.top_instances?.length}
<div class="list">
{#each stats.top_instances as t}
{@const pct = (t.launches / stats.top_instances[0].launches) * 100}
<div class="top">
<span class="name">{nameOf(t.instance_id)}</span>
<span class="meter"><span style:width="{pct}%"></span></span>
<span class="num">{t.launches}</span>
</div>
{/each}
</div>
{:else}
<p class="muted small">No launches yet. Stats appear once players start the game.</p>
{/if}
</section>
<section class="card">
<div class="section-title"><h2>Recent activity</h2></div>
{#if stats?.recent?.length}
<div class="list">
{#each stats.recent as r}
<div class="activity">
<Avatar name={r.username ?? 'Player'} size={28} />
<span><strong>{r.username ?? 'Someone'}</strong> <span class="muted">launched</span> {nameOf(r.instance_id)}</span>
<span class="muted tiny">{timeAgo(r.at)}</span>
</div>
{/each}
</div>
{:else}
<p class="muted small">Nothing yet.</p>
{/if}
</section>
</div>
</div>
<style>
.stats { grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); margin-bottom: 16px; }
.stat { display: flex; flex-direction: column; gap: 4px; color: var(--text); position: relative; overflow: hidden; }
.stat .icon { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; margin-bottom: 10px; }
.stat .value { font-size: 2rem; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.stat .label { color: var(--muted); font-size: 0.85rem; }
.accent .icon { background: var(--accent-soft); color: #b8a6ff; }
.cyan .icon { background: rgba(34, 211, 238, 0.12); color: #67e8f9; }
.warn .icon { background: rgba(251, 191, 36, 0.12); color: #fcd34d; }
.green .icon { background: rgba(52, 211, 153, 0.12); color: #6ee7b7; }
.main { grid-template-columns: 1.6fr 1fr; }
@media (max-width: 1100px) { .main { grid-template-columns: 1fr; } }
.steps { display: flex; flex-direction: column; gap: 6px; }
.step { display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 10px; color: var(--text); background: var(--bg-2); border: 1px solid var(--line); transition: border-color 0.15s; }
.step:hover { border-color: var(--line-strong); }
.step span { flex: 1; }
.step :global(svg:first-child) { color: var(--muted); }
.step.done { color: var(--muted); }
.step.done span { text-decoration: line-through; }
.step.done :global(svg:first-child) { color: var(--good); }
.list { display: flex; flex-direction: column; gap: 12px; }
.top { display: grid; grid-template-columns: 140px 1fr 40px; gap: 12px; align-items: center; font-size: 0.9rem; }
.top .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.meter { height: 8px; background: var(--bg-2); border-radius: 99px; overflow: hidden; }
.meter span { display: block; height: 100%; background: #8b6cff; border-radius: 99px; }
.num { text-align: right; font-variant-numeric: tabular-nums; color: var(--text-2); }
.activity { display: flex; align-items: center; gap: 10px; font-size: 0.9rem; }
.activity > span:nth-child(2) { flex: 1; }
.skeleton { border-radius: 10px; background: linear-gradient(90deg, var(--surface-2), var(--surface-3), var(--surface-2)); background-size: 200% 100%; animation: shimmer 1.2s linear infinite; }
@keyframes shimmer { to { background-position: -200% 0; } }
</style>
+560
View File
@@ -0,0 +1,560 @@
<script lang="ts">
import {
ArrowLeft, Save, Trash2, Search, Download, Upload, LoaderCircle, TriangleAlert, FileArchive, Package, Globe, Lock,
Users as UsersIcon, Server, RotateCcw, ExternalLink, FolderPlus, X,
} from '@lucide/svelte';
import ImageInput from '../components/ImageInput.svelte';
import Modal from '../components/Modal.svelte';
import Toggle from '../components/Toggle.svelte';
import VersionPicker from '../components/VersionPicker.svelte';
import { api, del, formatBytes, get, loaderLabel, post, put } from '../lib/api';
import { go, route } from '../lib/router.svelte';
import { toast, toastError } from '../lib/toast.svelte';
import type { FileRow, Group, Instance } from '../lib/types';
let { id }: { id: string } = $props();
type Tab = 'general' | 'source' | 'files' | 'server' | 'access';
const tabs: { id: Tab; label: string }[] = [
{ id: 'general', label: 'General' },
{ id: 'source', label: 'Version & modpack' },
{ id: 'files', label: 'Files' },
{ id: 'server', label: 'Server' },
{ id: 'access', label: 'Access' },
];
let tab = $state<Tab>((route.params[1] as Tab) ?? 'general');
let inst = $state<Instance | null>(null);
let form = $state<Instance | null>(null);
let files = $state<FileRow[]>([]);
let groups = $state<Group[]>([]);
let saving = $state(false);
let importing = $state<string | null>(null);
let confirmDelete = $state(false);
function load(detail: { instance: Instance; files: FileRow[] }) {
inst = detail.instance;
form = structuredClone($state.snapshot(detail.instance)) as Instance;
form.server ??= { name: '', address: '', port: 25565, auto_join: false, inject: true };
files = detail.files;
}
$effect(() => {
get(`/api/admin/instances/${encodeURIComponent(id)}`).then(load).catch((e) => {
toastError(e);
go('instances');
});
get<Group[]>('/api/admin/groups').then((g) => (groups = g)).catch(() => {});
});
const dirty = $derived(!!inst && !!form && JSON.stringify(normalize(form)) !== JSON.stringify(normalize(inst)));
function normalize(i: Instance) {
const s = i.server && i.server.address.trim() ? i.server : null;
return { ...i, server: s, revision: 0, updated_at: '' };
}
async function save() {
if (!form) return;
saving = true;
try {
const body = { ...$state.snapshot(form), server: form.server?.address.trim() ? form.server : null };
load(await put(`/api/admin/instances/${encodeURIComponent(id)}`, body));
toast('Saved — players get the update on their next launch');
} catch (e) {
toastError(e);
} finally {
saving = false;
}
}
async function remove() {
try {
await del(`/api/admin/instances/${encodeURIComponent(id)}`);
toast('Instance deleted');
go('instances');
} catch (e) {
toastError(e);
}
}
async function runImport(label: string, fn: () => Promise<any>) {
importing = label;
try {
load(await fn());
toast('Modpack imported');
tab = 'files';
} catch (e) {
toastError(e);
} finally {
importing = null;
}
}
// ---- source pickers ----
let sourceMode = $state<'version' | 'modrinth' | 'curseforge' | 'upload'>('version');
let query = $state('');
let results = $state<any[]>([]);
let searching = $state(false);
let selected = $state<any>(null);
let versions = $state<any[]>([]);
let loadingVersions = $state(false);
$effect(() => {
if (inst && (inst.source_kind === 'modrinth' || inst.source_kind === 'curseforge')) sourceMode = inst.source_kind;
});
async function search() {
searching = true;
selected = null;
try {
if (sourceMode === 'modrinth') {
const r = await get(`/api/admin/modrinth/search?q=${encodeURIComponent(query)}`);
results = r.hits.map((h: any) => ({ id: h.project_id, title: h.title, text: h.description, icon: h.icon_url, downloads: h.downloads, author: h.author }));
} else {
const r = await get<any[]>(`/api/admin/curseforge/search?q=${encodeURIComponent(query)}`);
results = r.map((m: any) => ({ id: m.id, title: m.name, text: m.summary, icon: m.logo?.thumbnailUrl, downloads: m.downloadCount, author: m.authors?.[0]?.name }));
}
} catch (e) {
toastError(e);
} finally {
searching = false;
}
}
async function pick(p: any) {
selected = p;
versions = [];
loadingVersions = true;
try {
if (sourceMode === 'modrinth') {
const v = await get<any[]>(`/api/admin/modrinth/project/${p.id}/versions`);
versions = v.map((x) => ({ id: x.id, name: x.name || x.version_number, meta: [...x.game_versions.slice(-2), ...x.loaders].join(' · '), date: x.date_published }));
} else {
const v = await get<any[]>(`/api/admin/curseforge/mod/${p.id}/files`);
versions = v.map((x) => ({ id: x.id, name: x.displayName, meta: (x.gameVersions ?? []).join(' · '), date: x.fileDate }));
}
} catch (e) {
toastError(e);
} finally {
loadingVersions = false;
}
}
function importVersion(v: any) {
const path = `/api/admin/instances/${encodeURIComponent(id)}/import/`;
if (sourceMode === 'modrinth') runImport(`${selected.title} ${v.name}`, () => post(path + 'modrinth', { version_id: v.id, project_id: selected.id }));
else runImport(`${selected.title} ${v.name}`, () => post(path + 'curseforge', { mod_id: selected.id, file_id: v.id }));
}
// ---- upload ----
let dragging = $state(false);
let uploadFile = $state<File | null>(null);
let fallback = $state({ mc: '', loader: 'vanilla', lv: null as string | null });
function onDrop(e: DragEvent) {
e.preventDefault();
dragging = false;
const f = e.dataTransfer?.files?.[0];
if (f) uploadFile = f;
}
function doUpload() {
if (!uploadFile) return;
const form = new FormData();
form.append('mc_version', fallback.mc);
form.append('loader', fallback.loader);
if (fallback.lv) form.append('loader_version', fallback.lv);
form.append('file', uploadFile);
runImport(uploadFile.name, () => api(`/api/admin/instances/${encodeURIComponent(id)}/import/upload`, { method: 'POST', form }));
uploadFile = null;
}
async function resetSource() {
try {
load(await post(`/api/admin/instances/${encodeURIComponent(id)}/reset`));
toast('Modpack removed — the instance is now a plain version');
} catch (e) {
toastError(e);
}
}
// ---- files ----
let folder = $state('mods');
let customFolder = $state('');
let fileInput = $state<HTMLInputElement>();
let uploadingFiles = $state(false);
const missing = $derived(files.filter((f) => !f.url));
async function addFiles(e: Event) {
const list = (e.target as HTMLInputElement).files;
if (!list?.length) return;
uploadingFiles = true;
const form = new FormData();
form.append('folder', folder === 'custom' ? customFolder : folder);
for (const f of list) form.append('file', f);
try {
load(await api(`/api/admin/instances/${encodeURIComponent(id)}/files`, { method: 'POST', form }));
toast(`Uploaded ${list.length} file${list.length > 1 ? 's' : ''}`);
} catch (err) {
toastError(err);
} finally {
uploadingFiles = false;
if (fileInput) fileInput.value = "";
}
}
async function deleteFile(path: string) {
try {
load(await del(`/api/admin/instances/${encodeURIComponent(id)}/files?path=${encodeURIComponent(path)}`));
} catch (e) {
toastError(e);
}
}
const originBadge: Record<string, string> = { pack: 'accent', override: '', upload: 'good' };
const noteLink = (n: string | null) => n?.match(/https?:\/\/\S+/)?.[0]?.replace(/\.$/, '');
</script>
{#if form && inst}
<div class="page">
<a href="#/instances" class="back"><ArrowLeft size={16} /> All instances</a>
<div class="page-head">
<div class="row head">
<div class="icon">{#if form.icon_url}<img src={form.icon_url} alt="" />{:else}<Package size={26} />{/if}</div>
<div>
<h1>{inst.name}</h1>
<p class="row wrap">
<span class="badge">{inst.mc_version}</span>
<span class="badge">{loaderLabel(inst.loader)} {inst.loader_version?.replace(`${inst.mc_version}-`, '') ?? ''}</span>
<span class="badge accent">{inst.source_label || 'Vanilla'}</span>
<span class="muted tiny">revision {inst.revision}</span>
</p>
</div>
</div>
<button class="danger" onclick={() => (confirmDelete = true)}><Trash2 size={16} /> Delete</button>
</div>
<div class="tabs">
{#each tabs as t}
<button class:active={tab === t.id} onclick={() => (tab = t.id)}>
{t.label}
{#if t.id === 'files' && missing.length}<span class="badge warn">{missing.length}</span>{/if}
</button>
{/each}
</div>
{#if tab === 'general'}
<div class="grid two">
<section class="card col">
<h3>Details</h3>
<label class="field">Name<input bind:value={form.name} /></label>
<label class="field">Description<textarea bind:value={form.description} rows="3" placeholder="What makes this instance special?"></textarea></label>
<ImageInput bind:value={form.icon_url} label="Icon" help="Square image, shown in the launcher sidebar." />
<ImageInput bind:value={form.banner_url} label="Banner / background" help="Wide image shown behind this instance. Overrides the launcher background." />
</section>
<section class="card col">
<h3>Performance defaults</h3>
<p class="muted small">Players can change these in their settings; these are the starting values.</p>
<label class="field">
<span class="row">Maximum memory <span class="spacer"></span><strong>{(form.memory.max_mb / 1024).toFixed(1)} GB</strong></span>
<input type="range" min="1024" max="16384" step="256" bind:value={form.memory.max_mb} />
</label>
<label class="field">
<span class="row">Minimum memory <span class="spacer"></span><strong>{(form.memory.min_mb / 1024).toFixed(1)} GB</strong></span>
<input type="range" min="512" max={form.memory.max_mb} step="256" bind:value={form.memory.min_mb} />
</label>
<label class="field">Extra JVM arguments <span class="help">Added before the player's own arguments.</span>
<input class="mono" bind:value={form.jvm_args} placeholder="-Dfml.readTimeout=180" />
</label>
</section>
</div>
{:else if tab === 'source'}
<section class="card current">
<FileArchive size={22} />
<div class="grow">
<strong>{inst.source_label || 'Vanilla'}</strong>
<span class="muted small">Minecraft {inst.mc_version} · {loaderLabel(inst.loader)} {inst.loader_version ?? ''} · {inst.file_count} files ({formatBytes(inst.total_size)})</span>
</div>
{#if inst.source_kind !== 'vanilla'}
<button class="sm" onclick={resetSource}><RotateCcw size={14} /> Remove modpack</button>
{/if}
</section>
<div class="segmented modes">
<button class:active={sourceMode === 'version'} onclick={() => (sourceMode = 'version')}><Package size={15} /> Version</button>
<button class:active={sourceMode === 'modrinth'} onclick={() => { sourceMode = 'modrinth'; results = []; selected = null; }}>Modrinth</button>
<button class:active={sourceMode === 'curseforge'} onclick={() => { sourceMode = 'curseforge'; results = []; selected = null; }}>CurseForge</button>
<button class:active={sourceMode === 'upload'} onclick={() => (sourceMode = 'upload')}><Upload size={15} /> Upload .zip</button>
</div>
{#if sourceMode === 'version'}
<section class="card col narrow">
<p class="muted small">
{#if inst.source_kind !== 'vanilla'}Changing versions here overrides what the modpack declared — only do this if you know the pack supports it.{:else}Pick the game version and (optionally) a mod loader. Add mods in the Files tab.{/if}
</p>
<VersionPicker bind:mc={form.mc_version} bind:loader={form.loader} bind:loaderVersion={form.loader_version} />
</section>
{:else if sourceMode === 'upload'}
<section class="card col narrow">
<div
class="drop" class:dragging role="button" tabindex="0"
ondragover={(e) => { e.preventDefault(); dragging = true; }}
ondragleave={() => (dragging = false)}
ondrop={onDrop}
onclick={() => document.getElementById('pack-file')?.click()}
onkeydown={(e) => e.key === 'Enter' && document.getElementById('pack-file')?.click()}
>
<Upload size={28} />
{#if uploadFile}
<strong>{uploadFile.name}</strong><span class="muted small">{formatBytes(uploadFile.size)}</span>
{:else}
<strong>Drop a modpack here</strong>
<span class="muted small">.mrpack, CurseForge .zip, or any instance folder zipped up</span>
{/if}
</div>
<input id="pack-file" type="file" accept=".zip,.mrpack" hidden onchange={(e) => (uploadFile = (e.target as HTMLInputElement).files?.[0] ?? null)} />
<details>
<summary class="small muted">Plain zip? Choose its Minecraft version and loader</summary>
<div style="margin-top: 12px"><VersionPicker bind:mc={fallback.mc} bind:loader={fallback.loader} bind:loaderVersion={fallback.lv} /></div>
</details>
<button class="primary" disabled={!uploadFile} onclick={doUpload}><Upload size={16} /> Import</button>
</section>
{:else}
<section class="card col">
<form class="row" onsubmit={(e) => { e.preventDefault(); search(); }}>
<input bind:value={query} placeholder="Search {sourceMode === 'modrinth' ? 'Modrinth' : 'CurseForge'} modpacks…" />
<button class="primary" disabled={searching}>{#if searching}<LoaderCircle class="spin" size={16} />{:else}<Search size={16} />{/if} Search</button>
</form>
{#if selected}
<div class="picked">
<button class="ghost sm" onclick={() => (selected = null)}><ArrowLeft size={14} /> Results</button>
<div class="row">
{#if selected.icon}<img src={selected.icon} alt="" class="pi" />{/if}
<div><h3>{selected.title}</h3><p class="muted small">{selected.text}</p></div>
</div>
{#if loadingVersions}
<p class="muted row"><LoaderCircle class="spin" size={16} /> Loading versions…</p>
{:else}
<div class="versions">
{#each versions as v}
<div class="version">
<div class="grow"><strong>{v.name}</strong><span class="muted tiny">{v.meta}</span></div>
<span class="muted tiny">{v.date ? new Date(v.date).toLocaleDateString() : ''}</span>
<button class="sm primary" onclick={() => importVersion(v)}><Download size={14} /> Use</button>
</div>
{/each}
</div>
{/if}
</div>
{:else if results.length}
<div class="results">
{#each results as r}
<button class="result" onclick={() => pick(r)}>
{#if r.icon}<img src={r.icon} alt="" />{:else}<span class="ph"><Package size={20} /></span>{/if}
<span class="grow">
<strong>{r.title}</strong>
<span class="muted tiny">{r.author ? `by ${r.author} · ` : ''}{Intl.NumberFormat(undefined, { notation: 'compact' }).format(r.downloads ?? 0)} downloads</span>
<span class="muted small clamp">{r.text}</span>
</span>
</button>
{/each}
</div>
{:else}
<p class="muted small">
{sourceMode === 'curseforge' ? 'CurseForge needs an API key (Settings → Integrations). ' : ''}Search for a modpack, then pick a version. Mods download straight from the CDN; configs are hosted by this panel.
</p>
{/if}
</section>
{/if}
{:else if tab === 'files'}
{#if missing.length}
<section class="card warnbox">
<div class="row"><TriangleAlert size={18} /><strong>{missing.length} file{missing.length > 1 ? 's' : ''} need a manual upload</strong></div>
<p class="small">These CurseForge authors block third-party downloads. Download each file and upload it below into the same folder — it replaces the placeholder automatically.</p>
{#each missing as m}
<div class="row small miss">
<code>{m.path}</code>
<span class="spacer"></span>
{#if noteLink(m.note)}<a href={noteLink(m.note)} target="_blank" rel="noreferrer" class="row"><ExternalLink size={13} /> Download page</a>{/if}
</div>
{/each}
</section>
{/if}
<section class="card">
<div class="section-title">
<h3>{files.length} files</h3>
<span class="spacer"></span>
<select bind:value={folder} style="width: auto">
<option value="mods">mods/</option>
<option value="config">config/</option>
<option value="resourcepacks">resourcepacks/</option>
<option value="shaderpacks">shaderpacks/</option>
<option value="">(game folder)</option>
<option value="custom">custom…</option>
</select>
{#if folder === 'custom'}<input bind:value={customFolder} placeholder="kubejs/scripts" style="width: 180px" />{/if}
<button class="primary sm" disabled={uploadingFiles} onclick={() => fileInput?.click()}>
{#if uploadingFiles}<LoaderCircle class="spin" size={14} />{:else}<FolderPlus size={14} />{/if} Add files
</button>
<input bind:this={fileInput} type="file" multiple hidden onchange={addFiles} />
</div>
{#if files.length}
<div class="table-wrap">
<table class="table">
<thead><tr><th>Path</th><th>Source</th><th>Size</th><th></th></tr></thead>
<tbody>
{#each files as f (f.path)}
<tr class:missing={!f.url}>
<td class="mono path">{f.path}</td>
<td>{#if !f.url}<span class="badge warn">missing</span>{:else}<span class="badge {originBadge[f.origin] ?? ''}">{f.origin === 'pack' ? 'CDN' : f.origin === 'upload' ? 'uploaded' : 'config'}</span>{/if}</td>
<td class="muted">{formatBytes(f.size)}</td>
<td class="actions"><button class="ghost icon" title="Remove" aria-label="Remove {f.path}" onclick={() => deleteFile(f.path)}><X size={15} /></button></td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="muted small">No extra files. Vanilla instances don't need any — add mods, configs or resource packs here.</p>
{/if}
</section>
{:else if tab === 'server' && form.server}
<section class="card col narrow">
<div class="row"><Server size={20} /><h3>Built-in server</h3></div>
<p class="muted small">Adds your server to the in-game multiplayer list and shows its live status in the launcher.</p>
<div class="grid srv">
<label class="field">Display name<input bind:value={form.server.name} placeholder="ScopeNet SMP" /></label>
<label class="field">Address<input bind:value={form.server.address} placeholder="play.example.net" /></label>
<label class="field">Port<input type="number" min="1" max="65535" bind:value={form.server.port} /></label>
</div>
<Toggle bind:checked={form.server.inject} label="Add to multiplayer list" help="Written into servers.dat without touching servers players added themselves." />
<Toggle bind:checked={form.server.auto_join} label="Join automatically" help="The game connects straight to the server after loading (Quick Play on 1.20+)." />
{#if form.server.address}
<button class="ghost sm" style="align-self: flex-start" onclick={() => form && (form.server = { name: '', address: '', port: 25565, auto_join: false, inject: true })}><X size={14} /> Remove server</button>
{/if}
</section>
{:else if tab === 'access'}
<div class="grid two">
<section class="card col">
<h3>Who can see this instance?</h3>
<div class="vis">
{#each [
{ id: 'public', icon: Globe, title: 'Everyone', text: 'Including offline and Microsoft accounts' },
{ id: 'members', icon: UsersIcon, title: 'Signed-in players', text: 'Anyone with a panel account' },
{ id: 'groups', icon: Lock, title: 'Specific groups', text: 'Only members of the groups below' },
] as v}
<button type="button" class="vopt" class:active={form.visibility === v.id} onclick={() => form && (form.visibility = v.id as Instance['visibility'])}>
<v.icon size={18} /><span><strong>{v.title}</strong><span class="muted tiny">{v.text}</span></span>
</button>
{/each}
</div>
{#if form.visibility === 'groups'}
<div class="row wrap">
{#each groups as g}
{@const on = form.allowed_groups.includes(g.name)}
<button type="button" class="chip" class:on style:--c={g.color}
onclick={() => form && (form.allowed_groups = on ? form.allowed_groups.filter((x) => x !== g.name) : [...form.allowed_groups, g.name])}>
<span class="dot"></span>{g.name}
</button>
{:else}
<p class="muted small">No groups yet — create them on the <a href="#/users">Players</a> page.</p>
{/each}
</div>
{/if}
</section>
<section class="card col">
<h3>Listing</h3>
<Toggle bind:checked={form.enabled} label="Enabled" help="Hidden instances disappear from every launcher." />
<Toggle bind:checked={form.featured} label="Featured" help="Shown first and selected by default." />
<label class="field">Sort order <span class="help">Lower numbers come first.</span><input type="number" bind:value={form.sort} /></label>
</section>
</div>
{/if}
</div>
{#if dirty}
<div class="savebar">
<span>You have unsaved changes</span>
<button class="ghost" onclick={() => inst && (form = { ...structuredClone($state.snapshot(inst)), server: inst.server ?? { name: '', address: '', port: 25565, auto_join: false, inject: true } } as Instance)}>Discard</button>
<button class="primary" disabled={saving} onclick={save}>{#if saving}<LoaderCircle class="spin" size={16} />{:else}<Save size={16} />{/if} Save changes</button>
</div>
{/if}
{#if importing}
<div class="importing">
<div class="card col">
<LoaderCircle class="spin" size={32} />
<h3>Importing {importing}</h3>
<p class="muted small">Downloading and unpacking the pack. Large packs can take a minute.</p>
</div>
</div>
{/if}
<Modal bind:open={confirmDelete} title="Delete {inst.name}?">
<p class="muted">The instance disappears from every launcher and its hosted files are deleted. Players keep their local worlds.</p>
{#snippet footer()}
<button class="ghost" onclick={() => (confirmDelete = false)}>Cancel</button>
<button class="danger" onclick={remove}><Trash2 size={16} /> Delete instance</button>
{/snippet}
</Modal>
{:else}
<div class="page"><p class="muted row"><LoaderCircle class="spin" size={16} /> Loading…</p></div>
{/if}
<style>
.back { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 0.88rem; margin-bottom: 16px; }
.back:hover { color: var(--text); }
.head { gap: 16px; }
.head p { gap: 6px; margin-top: 8px; }
.icon { width: 64px; height: 64px; border-radius: 16px; background: var(--surface-2); border: 1px solid var(--line-strong); display: grid; place-items: center; overflow: hidden; color: var(--muted); flex-shrink: 0; }
.icon img { width: 100%; height: 100%; object-fit: cover; }
.two { grid-template-columns: 1fr 1fr; align-items: start; }
@media (max-width: 1000px) { .two { grid-template-columns: 1fr; } }
.narrow { max-width: 680px; }
.grow { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.current { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }
.current :global(svg:first-child) { color: var(--accent); }
.modes { margin-bottom: 18px; flex-wrap: wrap; }
.drop { border: 2px dashed var(--line-strong); border-radius: 14px; padding: 36px; display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; transition: border-color 0.15s, background 0.15s; color: var(--muted); }
.drop strong { color: var(--text); }
.drop:hover, .drop.dragging { border-color: var(--accent); background: var(--accent-soft); }
details summary { cursor: pointer; }
.results { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 10px; }
.result { justify-content: flex-start; align-items: flex-start; gap: 12px; padding: 12px; text-align: left; white-space: normal; background: var(--bg-2); font-weight: 400; }
.result img, .ph { width: 48px; height: 48px; border-radius: 10px; object-fit: cover; flex-shrink: 0; background: var(--surface-3); display: grid; place-items: center; }
.clamp { display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.picked { display: flex; flex-direction: column; gap: 14px; }
.picked > button { align-self: flex-start; }
.pi { width: 56px; height: 56px; border-radius: 12px; }
.versions { display: flex; flex-direction: column; gap: 6px; max-height: 460px; overflow: auto; }
.version { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 10px; background: var(--bg-2); border: 1px solid var(--line); }
.warnbox { border-color: rgba(251, 191, 36, 0.3); background: rgba(251, 191, 36, 0.06); display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px; }
.warnbox :global(svg) { color: var(--warn); }
.miss { padding: 6px 0; border-top: 1px solid rgba(251, 191, 36, 0.15); }
.table-wrap { overflow-x: auto; }
.path { word-break: break-all; }
tr.missing td { background: rgba(251, 191, 36, 0.04); }
.actions { width: 40px; }
.srv { grid-template-columns: 1fr 1.4fr 110px; }
.vis { display: flex; flex-direction: column; gap: 8px; }
.vopt { justify-content: flex-start; gap: 14px; padding: 14px; background: var(--bg-2); text-align: left; }
.vopt span { display: flex; flex-direction: column; gap: 2px; font-weight: 400; }
.vopt.active { border-color: var(--accent); background: var(--accent-soft); }
.vopt.active :global(svg) { color: var(--accent); }
.chip { padding: 6px 12px; border-radius: 99px; font-size: 0.85rem; background: var(--bg-2); }
.chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--c); }
.chip.on { border-color: var(--c); background: color-mix(in srgb, var(--c) 18%, transparent); }
.savebar {
position: fixed; bottom: 20px; left: 50%; transform: translateX(calc(-50% + 124px)); display: flex; align-items: center; gap: 12px;
padding: 10px 10px 10px 20px; border-radius: 14px; background: rgba(24, 27, 41, 0.92); backdrop-filter: blur(16px);
border: 1px solid var(--line-strong); box-shadow: var(--shadow); z-index: 20; animation: up 0.2s ease;
}
@media (max-width: 900px) { .savebar { transform: translateX(-50%); } }
@keyframes up { from { opacity: 0; transform: translate(calc(-50% + 124px), 10px); } }
.importing { position: fixed; inset: 0; background: rgba(4, 5, 10, 0.75); backdrop-filter: blur(6px); display: grid; place-items: center; z-index: 60; }
.importing .card { align-items: center; text-align: center; max-width: 380px; padding: 32px; }
.importing :global(svg) { color: var(--accent); }
</style>
+147
View File
@@ -0,0 +1,147 @@
<script lang="ts">
import { Plus, Boxes, Star, Lock, Users as UsersIcon, Globe, Server, TriangleAlert, EyeOff, Package } from '@lucide/svelte';
import Modal from '../components/Modal.svelte';
import VersionPicker from '../components/VersionPicker.svelte';
import { formatBytes, get, loaderLabel, post } from '../lib/api';
import { go } from '../lib/router.svelte';
import { toast, toastError } from '../lib/toast.svelte';
import type { Instance } from '../lib/types';
let instances = $state<Instance[] | null>(null);
let creating = $state(false);
let busy = $state(false);
let draft = $state({ name: '', description: '', mc_version: '', loader: 'vanilla', loader_version: null as string | null, source: 'vanilla' });
$effect(() => {
get<Instance[]>('/api/admin/instances').then((i) => (instances = i)).catch(toastError);
});
async function create() {
busy = true;
try {
const inst = await post<Instance>('/api/admin/instances', {
name: draft.name,
description: draft.description,
mc_version: draft.mc_version || '1.21.1',
loader: draft.source === 'vanilla' ? draft.loader : 'vanilla',
loader_version: draft.loader_version,
});
toast(`Created ${inst.name}`);
go(`instances/${inst.id}${draft.source === 'vanilla' ? '' : '/source'}`);
} catch (e) {
toastError(e);
} finally {
busy = false;
}
}
const visIcon = { public: Globe, members: UsersIcon, groups: Lock };
const visLabel = { public: 'Everyone', members: 'Signed-in players', groups: 'Specific groups' };
</script>
<div class="page">
<div class="page-head">
<div>
<h1>Instances</h1>
<p>Each instance is a playable profile in the launcher: a Minecraft version or a modpack, plus an optional server.</p>
</div>
<button class="primary" onclick={() => (creating = true)}><Plus size={18} /> New instance</button>
</div>
{#if instances === null}
<div class="grid cards">{#each [1, 2, 3] as _}<div class="card sk"></div>{/each}</div>
{:else if instances.length === 0}
<div class="card empty">
<Boxes size={40} />
<h3>No instances yet</h3>
<p>Create one to give players something to launch.</p>
<button class="primary" style="margin-top: 16px" onclick={() => (creating = true)}><Plus size={18} /> New instance</button>
</div>
{:else}
<div class="grid cards">
{#each instances as inst (inst.id)}
{@const Vis = visIcon[inst.visibility]}
<a class="card hover inst" href="#/instances/{inst.id}" class:disabled={!inst.enabled}>
<div class="banner" style:background-image={inst.banner_url ? `url("${inst.banner_url}")` : undefined}>
<div class="badges">
{#if inst.featured}<span class="badge accent"><Star size={11} /> Featured</span>{/if}
{#if !inst.enabled}<span class="badge"><EyeOff size={11} /> Hidden</span>{/if}
{#if inst.missing_count}<span class="badge warn"><TriangleAlert size={11} /> {inst.missing_count} missing</span>{/if}
</div>
</div>
<div class="body">
<div class="icon">
{#if inst.icon_url}<img src={inst.icon_url} alt="" />{:else}<Package size={22} />{/if}
</div>
<h3>{inst.name}</h3>
<p class="muted small desc">{inst.description || inst.source_label}</p>
<div class="row wrap chips">
<span class="badge">{inst.mc_version}</span>
<span class="badge">{loaderLabel(inst.loader)}{inst.loader_version ? ` ${inst.loader_version.replace(`${inst.mc_version}-`, '')}` : ''}</span>
{#if inst.file_count}<span class="badge">{inst.file_count} files · {formatBytes(inst.total_size)}</span>{/if}
</div>
<div class="foot">
<span class="row small muted"><Vis size={14} /> {visLabel[inst.visibility]}</span>
{#if inst.server?.address}<span class="row small muted"><Server size={14} /> {inst.server.address}</span>{/if}
</div>
</div>
</a>
{/each}
</div>
{/if}
</div>
<Modal bind:open={creating} title="New instance" width={560}>
<label class="field">Name<input bind:value={draft.name} placeholder="Survival SMP" /></label>
<label class="field">Description <span class="help">Shown under the name in the launcher.</span>
<input bind:value={draft.description} placeholder="Our main survival world — season 4" />
</label>
<div class="field">
<span class="lbl">Start from</span>
<div class="sources">
{#each [
{ id: 'vanilla', title: 'Minecraft version', text: 'Vanilla or a bare mod loader' },
{ id: 'modpack', title: 'Modpack', text: 'Modrinth, CurseForge or a .zip' },
] as s}
<button type="button" class="source" class:active={draft.source === s.id} onclick={() => (draft.source = s.id)}>
<strong>{s.title}</strong><span>{s.text}</span>
</button>
{/each}
</div>
</div>
{#if draft.source === 'vanilla'}
<VersionPicker bind:mc={draft.mc_version} bind:loader={draft.loader} bind:loaderVersion={draft.loader_version} />
{:else}
<p class="muted small">You'll pick the modpack on the next screen. Its Minecraft version and loader are set automatically.</p>
{/if}
{#snippet footer()}
<button class="ghost" onclick={() => (creating = false)}>Cancel</button>
<button class="primary" disabled={!draft.name.trim() || busy} onclick={create}>Create instance</button>
{/snippet}
</Modal>
<style>
.cards { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
.sk { height: 280px; opacity: 0.5; }
.inst { padding: 0; overflow: hidden; color: var(--text); display: flex; flex-direction: column; }
.inst.disabled { opacity: 0.6; }
.banner {
height: 110px; background: radial-gradient(120% 120% at 100% 0%, rgba(139, 108, 255, 0.45), transparent 60%), radial-gradient(100% 100% at 0% 100%, rgba(34, 211, 238, 0.25), transparent 60%), var(--surface-2);
background-size: cover; background-position: center; position: relative;
}
.badges { position: absolute; top: 10px; right: 10px; display: flex; gap: 6px; }
.badges .badge { backdrop-filter: blur(8px); background: rgba(10, 11, 20, 0.6); }
.body { padding: 0 18px 18px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.icon { width: 54px; height: 54px; border-radius: 14px; margin-top: -28px; background: var(--surface-3); border: 3px solid var(--surface); display: grid; place-items: center; overflow: hidden; color: var(--muted); position: relative; }
.icon img { width: 100%; height: 100%; object-fit: cover; }
.desc { min-height: 2.6em; display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.chips { gap: 6px; }
.foot { display: flex; justify-content: space-between; gap: 10px; margin-top: auto; padding-top: 12px; border-top: 1px solid var(--line); }
.foot .row { gap: 6px; min-width: 0; overflow: hidden; white-space: nowrap; }
.field { display: flex; flex-direction: column; gap: 7px; }
.lbl { font-size: 0.85rem; color: var(--text-2); font-weight: 500; }
.sources { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.source { flex-direction: column; align-items: flex-start; gap: 4px; padding: 14px; background: var(--bg-2); text-align: left; white-space: normal; }
.source span { font-size: 0.8rem; color: var(--muted); font-weight: 400; }
.source.active { border-color: var(--accent); background: var(--accent-soft); }
</style>
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
import { LogIn, LoaderCircle } from '@lucide/svelte';
import { post } from '../lib/api';
import { setToken } from '../lib/session.svelte';
let { brandName, logo }: { brandName: string; logo: string | null } = $props();
let username = $state('');
let password = $state('');
let error = $state('');
let busy = $state(false);
async function submit(e: SubmitEvent) {
e.preventDefault();
busy = true;
error = '';
try {
const r = await post<{ token: string; user: { role: string } }>('/api/v1/auth/login', { username, password });
if (r.user.role !== 'admin') throw new Error('This account is not an administrator.');
setToken(r.token);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
busy = false;
}
}
</script>
<div class="wrap">
<div class="orb a"></div>
<div class="orb b"></div>
<form class="card login" onsubmit={submit}>
<img src={logo ?? '/favicon.svg'} alt="" class="logo" />
<h1>{brandName}</h1>
<p class="muted">Sign in to manage your launcher</p>
<label class="field">Username<input bind:value={username} autocomplete="username" required /></label>
<label class="field">Password<input type="password" bind:value={password} autocomplete="current-password" required /></label>
{#if error}<div class="error">{error}</div>{/if}
<button class="primary big" disabled={busy}>
{#if busy}<LoaderCircle class="spin" size={18} />{:else}<LogIn size={18} />{/if}
Sign in
</button>
<p class="tiny muted hint">First start? The admin password is in the container logs (or set <code>ADMIN_PASSWORD</code>).</p>
</form>
</div>
<style>
.wrap { min-height: 100vh; display: grid; place-items: center; padding: 20px; position: relative; overflow: hidden; }
.orb { position: absolute; width: 520px; height: 520px; border-radius: 50%; filter: blur(90px); opacity: 0.35; animation: drift 14s ease-in-out infinite alternate; }
.orb.a { background: var(--accent); top: -160px; right: -120px; }
.orb.b { background: var(--accent-2); bottom: -200px; left: -160px; opacity: 0.2; animation-delay: -7s; }
@keyframes drift { to { transform: translate(-60px, 40px) scale(1.1); } }
.login { width: 100%; max-width: 400px; display: flex; flex-direction: column; gap: 16px; padding: 36px; position: relative; backdrop-filter: blur(20px); background: rgba(18, 20, 31, 0.8); box-shadow: var(--shadow); animation: fade-in 0.4s ease; }
.logo { width: 56px; height: 56px; border-radius: 16px; margin-bottom: 4px; object-fit: cover; }
h1 { font-size: 1.6rem; }
.muted { margin-top: -10px; }
.big { padding: 12px; margin-top: 6px; }
.error { background: rgba(244, 63, 94, 0.1); border: 1px solid rgba(244, 63, 94, 0.3); color: #fda4af; padding: 10px 12px; border-radius: 10px; font-size: 0.88rem; }
.hint { text-align: center; margin-top: 0; line-height: 1.5; }
</style>
+109
View File
@@ -0,0 +1,109 @@
<script lang="ts">
import { Save, LoaderCircle, KeyRound, UserRound, WifiOff, Download, ExternalLink, CircleCheck } from '@lucide/svelte';
import Toggle from '../components/Toggle.svelte';
import { get, put } from '../lib/api';
import { toast, toastError } from '../lib/toast.svelte';
import type { Settings } from '../lib/types';
let s = $state<Settings | null>(null);
let cfKey = $state('');
let saving = $state(false);
$effect(() => {
get<Settings>('/api/admin/settings').then((x) => (s = x)).catch(toastError);
});
async function save() {
if (!s) return;
saving = true;
try {
s = await put<Settings>('/api/admin/settings', { ...$state.snapshot(s), curseforge_api_key: cfKey });
cfKey = '';
toast('Settings saved');
} catch (e) {
toastError(e);
} finally {
saving = false;
}
}
</script>
<div class="page narrow">
<div class="page-head">
<div>
<h1>Settings</h1>
<p>How players sign in, and connections to other services.</p>
</div>
<button class="primary" disabled={!s || saving} onclick={save}>
{#if saving}<LoaderCircle class="spin" size={16} />{:else}<Save size={16} />{/if} Save
</button>
</div>
{#if s}
<section class="card col">
<div class="section-title"><UserRound size={18} /><h2>Panel accounts</h2></div>
<Toggle bind:checked={s.auth.panel_accounts} label="Allow panel accounts" help="Players sign in with a username and password you manage here, then play in offline mode under that name." />
{#if s.auth.panel_accounts}
<div class="field">
<span class="lbl">Sign-ups from the launcher</span>
<div class="segmented">
<button class:active={s.auth.registration === 'closed'} onclick={() => s && (s.auth.registration = 'closed')}>Closed</button>
<button class:active={s.auth.registration === 'approval'} onclick={() => s && (s.auth.registration = 'approval')}>Needs approval</button>
<button class:active={s.auth.registration === 'open'} onclick={() => s && (s.auth.registration = 'open')}>Open</button>
</div>
<span class="help">
{s.auth.registration === 'closed' ? 'Only admins create accounts.' : s.auth.registration === 'approval' ? 'Anyone can sign up; you approve them on the Players page.' : 'Anyone can create an account and play immediately.'}
</span>
</div>
{/if}
</section>
<section class="card col">
<div class="section-title"><WifiOff size={18} /><h2>Offline play</h2></div>
<Toggle bind:checked={s.auth.offline_local} label="Allow local offline accounts" help="Players can type any username and play without an account. They only see public instances." />
</section>
<section class="card col">
<div class="section-title"><KeyRound size={18} /><h2>Microsoft accounts</h2></div>
<Toggle bind:checked={s.auth.microsoft} label="Sign in with Microsoft" help="For players who own Minecraft — real skins and online-mode servers." />
{#if s.auth.microsoft}
<label class="field">Azure application (client) ID
<input class="mono" bind:value={s.auth.microsoft_client_id} placeholder="00000000-0000-0000-0000-000000000000" />
<span class="help">
Register a public client app in Azure (Personal Microsoft accounts, device-code flow enabled), then
<a href="https://aka.ms/mce-reviewappid" target="_blank" rel="noreferrer">request Minecraft API access <ExternalLink size={11} /></a>.
See the docs for the step-by-step guide.
</span>
</label>
{/if}
</section>
<section class="card col">
<div class="section-title"><Download size={18} /><h2>Integrations</h2></div>
<label class="field">CurseForge API key
{#if s.curseforge_key_from_env}
<span class="set"><CircleCheck size={15} /> Set via the <code>CURSEFORGE_API_KEY</code> environment variable</span>
{:else}
<input type="password" bind:value={cfKey} placeholder={s.curseforge_key_set ? '•••••••• (saved — type to replace, "-" to remove)' : 'Paste your key'} autocomplete="off" />
<span class="help">Needed to search and import CurseForge modpacks. Get a free key at <a href="https://console.curseforge.com" target="_blank" rel="noreferrer">console.curseforge.com</a>.</span>
{/if}
</label>
<label class="field">Launcher download link
<input bind:value={s.launcher_download_url} placeholder="https://github.com/you/repo/releases/latest" />
<span class="help">Shown on the dashboard so you can share it easily.</span>
</label>
</section>
{/if}
</div>
<style>
.narrow { max-width: 820px; }
section { margin-bottom: 16px; }
.section-title { margin-bottom: 4px; }
.section-title :global(svg) { color: var(--accent); }
.field { display: flex; flex-direction: column; gap: 7px; font-size: 0.85rem; color: var(--text-2); font-weight: 500; }
.lbl { font-size: 0.85rem; color: var(--text-2); font-weight: 500; }
.help { color: var(--muted); font-weight: 400; font-size: 0.8rem; line-height: 1.5; }
.segmented { align-self: flex-start; }
.set { display: flex; align-items: center; gap: 8px; color: var(--good); font-weight: 400; }
</style>
+247
View File
@@ -0,0 +1,247 @@
<script lang="ts">
import { UserPlus, Check, Pencil, Trash2, Shield, Plus, X, Search, Tag } from '@lucide/svelte';
import Avatar from '../components/Avatar.svelte';
import Modal from '../components/Modal.svelte';
import { del, get, patch, post, timeAgo } from '../lib/api';
import { session } from '../lib/session.svelte';
import { toast, toastError } from '../lib/toast.svelte';
import type { Group, User } from '../lib/types';
let users = $state<User[]>([]);
let groups = $state<Group[]>([]);
let filter = $state('');
let editing = $state<Partial<User> & { password?: string } | null>(null);
let editOpen = $state(false);
let confirm = $state<User | null>(null);
let confirmOpen = $state(false);
let newGroup = $state({ name: '', color: '#22d3ee' });
const refresh = () => {
get<User[]>('/api/admin/users').then((u) => (users = u)).catch(toastError);
get<Group[]>('/api/admin/groups').then((g) => (groups = g)).catch(toastError);
};
$effect(refresh);
const pending = $derived(users.filter((u) => u.status === 'pending'));
const shown = $derived(users.filter((u) => !filter || u.username.toLowerCase().includes(filter.toLowerCase()) || u.groups.some((g) => g.toLowerCase().includes(filter.toLowerCase()))));
const colorOf = (name: string) => groups.find((g) => g.name === name)?.color ?? '#8b6cff';
function openNew() {
editing = { username: '', password: '', role: 'player', status: 'active', groups: [], email: '' };
editOpen = true;
}
function openEdit(u: User) {
editing = { ...u, password: '' };
editOpen = true;
}
async function saveUser() {
if (!editing) return;
try {
const body = { username: editing.username, password: editing.password || undefined, role: editing.role, status: editing.status, groups: editing.groups, email: editing.email ?? '' };
if (editing.id) await patch(`/api/admin/users/${editing.id}`, body);
else await post('/api/admin/users', body);
toast(editing.id ? 'Player updated' : `Created ${editing.username}`);
editOpen = false;
refresh();
} catch (e) {
toastError(e);
}
}
async function approve(u: User) {
try {
await patch(`/api/admin/users/${u.id}`, { status: 'active' });
toast(`${u.username} approved`);
refresh();
} catch (e) {
toastError(e);
}
}
async function removeUser() {
if (!confirm) return;
try {
await del(`/api/admin/users/${confirm.id}`);
toast('Player deleted');
confirmOpen = false;
refresh();
} catch (e) {
toastError(e);
}
}
async function addGroup(e: SubmitEvent) {
e.preventDefault();
try {
await post('/api/admin/groups', newGroup);
newGroup = { name: '', color: newGroup.color };
refresh();
} catch (err) {
toastError(err);
}
}
async function removeGroup(g: Group) {
try {
await del(`/api/admin/groups/${g.id}`);
refresh();
} catch (e) {
toastError(e);
}
}
</script>
<div class="page">
<div class="page-head">
<div>
<h1>Players</h1>
<p>Accounts sign in from the launcher and play with their username in offline mode.</p>
</div>
<button class="primary" onclick={openNew}><UserPlus size={18} /> Add player</button>
</div>
{#if pending.length}
<section class="card pending">
<strong>{pending.length} player{pending.length > 1 ? 's' : ''} waiting for approval</strong>
<div class="row wrap">
{#each pending as u}
<span class="pend"><Avatar name={u.username} size={22} /> {u.username}
<button class="sm primary" onclick={() => approve(u)}><Check size={14} /> Approve</button>
</span>
{/each}
</div>
</section>
{/if}
<div class="grid layout">
<section class="card">
<div class="section-title">
<div class="search"><Search size={16} /><input bind:value={filter} placeholder="Search players or groups" /></div>
<span class="hint">{users.length} accounts</span>
</div>
<div class="table-wrap">
<table class="table">
<thead><tr><th>Player</th><th>Role</th><th>Groups</th><th>Last seen</th><th></th></tr></thead>
<tbody>
{#each shown as u (u.id)}
<tr>
<td>
<div class="row who">
<Avatar name={u.username} size={34} />
<div class="col tight">
<strong>{u.username}{#if u.id === session.user?.id} <span class="muted tiny">(you)</span>{/if}</strong>
<span class="mono tiny muted">{u.uuid}</span>
</div>
</div>
</td>
<td>
{#if u.role === 'admin'}<span class="badge accent"><Shield size={11} /> Admin</span>{:else}<span class="badge">Player</span>{/if}
{#if u.status === 'pending'}<span class="badge warn">Pending</span>{:else if u.status === 'disabled'}<span class="badge bad">Disabled</span>{/if}
</td>
<td><div class="row wrap chips">{#each u.groups as g}<span class="gchip" style:--c={colorOf(g)}>{g}</span>{/each}</div></td>
<td class="muted small">{timeAgo(u.last_login)}</td>
<td class="actions">
<button class="ghost icon" aria-label="Edit {u.username}" onclick={() => openEdit(u)}><Pencil size={15} /></button>
{#if u.id !== session.user?.id}
<button class="ghost icon" aria-label="Delete {u.username}" onclick={() => { confirm = u; confirmOpen = true; }}><Trash2 size={15} /></button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
<section class="card col">
<div class="row"><Tag size={18} /><h3>Groups</h3></div>
<p class="muted small">Use groups to give VIPs, staff or testers access to private instances.</p>
<div class="col tight">
{#each groups as g (g.id)}
<div class="group">
<span class="dot" style:background={g.color}></span>
<strong>{g.name}</strong>
<span class="muted tiny">{g.members} member{g.members === 1 ? '' : 's'}</span>
<span class="spacer"></span>
<button class="ghost icon" aria-label="Delete group {g.name}" onclick={() => removeGroup(g)}><X size={14} /></button>
</div>
{/each}
</div>
<form class="row" onsubmit={addGroup}>
<input type="color" bind:value={newGroup.color} aria-label="Group colour" />
<input bind:value={newGroup.name} placeholder="New group" maxlength="32" />
<button class="icon" disabled={!newGroup.name.trim()} aria-label="Add group"><Plus size={16} /></button>
</form>
</section>
</div>
</div>
<Modal bind:open={editOpen} title={editing?.id ? `Edit ${editing.username}` : 'Add player'}>
{#if editing}
{#if !editing.id}
<label class="field">Username <span class="help">3–16 letters, numbers or _. This is their in-game name.</span><input bind:value={editing.username} /></label>
{/if}
<label class="field">{editing.id ? 'New password' : 'Password'} <span class="help">{editing.id ? 'Leave empty to keep the current password.' : 'At least 8 characters.'}</span>
<input type="password" bind:value={editing.password} autocomplete="new-password" />
</label>
<label class="field">Email (optional)<input type="email" bind:value={editing.email} /></label>
<div class="grid two">
<label class="field">Role
<select bind:value={editing.role}><option value="player">Player</option><option value="admin">Admin</option></select>
</label>
<label class="field">Status
<select bind:value={editing.status}><option value="active">Active</option><option value="pending">Pending approval</option><option value="disabled">Disabled</option></select>
</label>
</div>
{#if groups.length}
<div class="field">
<span class="lbl">Groups</span>
<div class="row wrap">
{#each groups as g}
{@const on = editing.groups?.includes(g.name)}
<button type="button" class="gpick" class:on style:--c={g.color}
onclick={() => editing && (editing.groups = on ? editing.groups!.filter((x) => x !== g.name) : [...(editing.groups ?? []), g.name])}>
<span class="dot" style:background={g.color}></span>{g.name}
</button>
{/each}
</div>
</div>
{/if}
{/if}
{#snippet footer()}
<button class="ghost" onclick={() => (editOpen = false)}>Cancel</button>
<button class="primary" onclick={saveUser}>{editing?.id ? 'Save' : 'Create player'}</button>
{/snippet}
</Modal>
<Modal bind:open={confirmOpen} title="Delete {confirm?.username}?">
<p class="muted">They won't be able to sign in any more. This can't be undone.</p>
{#snippet footer()}
<button class="ghost" onclick={() => (confirmOpen = false)}>Cancel</button>
<button class="danger" onclick={removeUser}><Trash2 size={16} /> Delete</button>
{/snippet}
</Modal>
<style>
.layout { grid-template-columns: 1fr 300px; align-items: start; }
@media (max-width: 1100px) { .layout { grid-template-columns: 1fr; } }
.pending { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px; border-color: rgba(251, 191, 36, 0.3); background: rgba(251, 191, 36, 0.05); }
.pend { display: inline-flex; align-items: center; gap: 8px; padding: 5px 5px 5px 8px; border-radius: 12px; background: var(--bg-2); border: 1px solid var(--line); }
.search { display: flex; align-items: center; gap: 8px; flex: 1; max-width: 360px; background: var(--bg-2); border: 1px solid var(--line-strong); border-radius: var(--radius-sm); padding: 0 12px; color: var(--muted); }
.search input { border: none; background: none; padding: 9px 0; }
.table-wrap { overflow-x: auto; }
.who { gap: 12px; }
.tight { gap: 2px; }
.chips { gap: 4px; }
.gchip { font-size: 0.75rem; padding: 2px 8px; border-radius: 99px; background: color-mix(in srgb, var(--c) 16%, transparent); color: color-mix(in srgb, var(--c) 70%, white); border: 1px solid color-mix(in srgb, var(--c) 35%, transparent); }
.actions { white-space: nowrap; text-align: right; }
.group { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 10px; background: var(--bg-2); }
.dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
form.row input[type='color'] { width: 42px; flex-shrink: 0; }
.two { grid-template-columns: 1fr 1fr; }
.field { display: flex; flex-direction: column; gap: 7px; }
.lbl { font-size: 0.85rem; color: var(--text-2); font-weight: 500; }
.gpick { padding: 6px 12px; border-radius: 99px; font-size: 0.85rem; background: var(--bg-2); }
.gpick.on { border-color: var(--c); background: color-mix(in srgb, var(--c) 18%, transparent); }
</style>
+3
View File
@@ -0,0 +1,3 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default { preprocess: vitePreprocess() };
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"types": ["svelte", "vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
// In dev, API calls are proxied to a locally running panel (cargo run -p scopenet-panel).
const target = process.env.PANEL_URL ?? 'http://localhost:8080';
export default defineConfig({
plugins: [svelte()],
server: {
port: 5173,
proxy: {
'/api': target,
'/files': target,
'/uploads': target,
},
},
build: {
target: 'es2022',
cssCodeSplit: false,
chunkSizeWarningLimit: 800,
},
});