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

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