Admin Progression page: quest limits, XP curve and rates, per-player XP adjustments

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-09-30 17:28:26 +00:00
1 parent e3e25da616
commit 3ceed4382f
6 files changed
+481 -8

No files matched your search

+3
View File
@@ -17,6 +17,7 @@
import QuestEditor from './pages/QuestEditor.svelte';
import AchievementEditor from './pages/AchievementEditor.svelte';
import LevelingAdmin from './pages/LevelingAdmin.svelte';
import Progression from './pages/Progression.svelte';
import GuildsAdmin from './pages/GuildsAdmin.svelte';
import { get } from './lib/api';
import { route } from './lib/router.svelte';
@@ -86,6 +87,8 @@
<AchievementEditor />
{:else if route.name === 'leveling'}
<LevelingAdmin />
{:else if route.name === 'progression'}
<Progression />
{:else if route.name === 'guilds'}
<GuildsAdmin />
{:else if route.name === 'capes'}
+2 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { LayoutDashboard, Boxes, Users, Palette, Settings, LogOut, ExternalLink, Server, Flag, Globe, Target, Trophy, Sparkles, Shield } from '@lucide/svelte';
import { LayoutDashboard, Boxes, Users, Palette, Settings, LogOut, ExternalLink, Server, Flag, Globe, Target, Trophy, Sparkles, Shield, SlidersHorizontal } from '@lucide/svelte';
import { route } from '../lib/router.svelte';
import { logout, session } from '../lib/session.svelte';
import Avatar from './Avatar.svelte';
@@ -14,6 +14,7 @@
{ id: 'quests', label: 'Quests', icon: Target },
{ id: 'achievements', label: 'Achievements', icon: Trophy },
{ id: 'leveling', label: 'Leveling & XP', icon: Sparkles },
{ id: 'progression', label: 'Progression', icon: SlidersHorizontal },
{ id: 'guilds', label: 'Guilds & Claims', icon: Shield },
{ id: 'activity', label: 'Activity', icon: LayoutDashboard },
{ id: 'capes', label: 'Capes', icon: Flag },
+30
View File
@@ -249,6 +249,7 @@ export interface Quest {
target_count: number;
icon: string;
active: boolean;
pinned?: boolean;
}
export interface UserQuest {
@@ -383,3 +384,32 @@ export interface UserProfileView {
stats: PlayerStats | null;
created_at: string;
}
export interface ProgressionSettings {
daily_quest_limit: number;
weekly_quest_limit: number;
quest_rotation: 'per_player' | 'shared';
level_base: number;
level_exponent: number;
max_level: number;
global_xp_multiplier: number;
server_xp_multiplier: number;
xp_rates: { playtime_per_hour: number; player_kill: number; mob_kill: number; block_broken: number; block_placed: number; message: number };
}
export interface ProgressionView {
settings: ProgressionSettings;
quest_pool: { daily: { enabled: number; pinned: number }; weekly: { enabled: number; pinned: number } };
curve: { level: number; xp: number }[];
}
export interface PlayerProgress {
uuid: string;
name: string;
title: string | null;
global_xp: number;
global_level: number;
level_xp: number;
level_span: number;
progress_pct: number;
}
+9 -5
View File
@@ -9,6 +9,7 @@
let rewards = $state<LevelReward[]>([]);
let leaderboard = $state<UserLevelInfo[]>([]);
let loading = $state(true);
let curve = $state({ base: 100, exponent: 1.5, max: 0 });
let draft = $state<Partial<LevelReward> | null>(null);
let draftOpen = $state(false);
@@ -39,6 +40,8 @@
icon: x.icon ?? '🎁',
})).sort((a, b) => a.level - b.level);
leaderboard = lb || [];
const p = await get<{ settings: { level_base: number; level_exponent: number; max_level: number } }>('/api/admin/progression');
curve = { base: p.settings.level_base, exponent: p.settings.level_exponent, max: p.settings.max_level };
} catch (e) {
toastError(e);
} finally {
@@ -103,7 +106,7 @@
function calcXp(lvl: number): number {
if (lvl <= 1) return 0;
return Math.round(Math.pow(lvl, 1.5) * 100);
return Math.round(Math.pow(lvl, curve.exponent) * curve.base);
}
</script>
@@ -166,7 +169,8 @@
<p class="formula-desc">
Total XP required for level <em>L</em> is given by:
</p>
<code class="formula-box">XP = round(L ^ 1.5 &times; 100)</code>
<code class="formula-box">XP = round(L ^ {curve.exponent} &times; {curve.base})</code>
<p class="formula-desc">{curve.max ? `Level cap: ${curve.max}.` : 'No level cap.'} <a href="#/progression">Tune the curve →</a></p>
<ul class="curve-points">
<li><span>Level 5:</span> <strong>{calcXp(5).toLocaleString()} XP</strong></li>
<li><span>Level 10:</span> <strong>{calcXp(10).toLocaleString()} XP</strong></li>
@@ -186,10 +190,10 @@
<div class="lb-item">
<span class="lb-rank">#{i + 1}</span>
<div class="lb-info">
<strong>UUID: {p.uuid.slice(0, 8)}...</strong>
<span>{p.current_xp.toLocaleString()} XP</span>
<strong>{(p as any).username ?? `UUID: ${p.uuid.slice(0, 8)}…`}</strong>
<span>{Number((p as any).global_xp ?? p.current_xp ?? 0).toLocaleString()} XP</span>
</div>
<span class="lb-lvl">Lvl {p.level}</span>
<span class="lb-lvl">Lvl {(p as any).global_level ?? p.level}</span>
</div>
{/each}
</div>
+409
View File
@@ -0,0 +1,409 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Target, Sparkles, Users, Save, RotateCcw, Search, Shuffle, Shield, Pin, TrendingUp, Plus, Minus, Equal, ArrowUpToLine, Eraser, Info } from '@lucide/svelte';
import Avatar from '../components/Avatar.svelte';
import Modal from '../components/Modal.svelte';
import { get, post, put } from '../lib/api';
import { toast, toastError } from '../lib/toast.svelte';
import type { PlayerProgress, ProgressionSettings, ProgressionView } from '../lib/types';
const DEFAULTS: ProgressionSettings = {
daily_quest_limit: 5,
weekly_quest_limit: 3,
quest_rotation: 'per_player',
level_base: 100,
level_exponent: 1.5,
max_level: 0,
global_xp_multiplier: 1,
server_xp_multiplier: 1,
xp_rates: { playtime_per_hour: 600, player_kill: 50, mob_kill: 15, block_broken: 0.2, block_placed: 0.2, message: 2 },
};
let tab = $state<'quests' | 'xp' | 'players'>('quests');
let saved = $state<ProgressionSettings | null>(null);
let s = $state<ProgressionSettings>(structuredClone(DEFAULTS));
let pool = $state({ daily: { enabled: 0, pinned: 0 }, weekly: { enabled: 0, pinned: 0 } });
let saving = $state(false);
const dirty = $derived(saved != null && JSON.stringify(saved) !== JSON.stringify(s));
const curveChanged = $derived(saved != null && (saved.level_base !== s.level_base || saved.level_exponent !== s.level_exponent || saved.max_level !== s.max_level));
async function load() {
try {
const v = await get<ProgressionView>('/api/admin/progression');
saved = structuredClone(v.settings);
s = structuredClone(v.settings);
pool = v.quest_pool;
} catch (e) {
toastError(e);
}
}
async function save() {
saving = true;
try {
const v = await put<ProgressionView & { recalculated: number }>('/api/admin/progression', s);
saved = structuredClone(v.settings);
s = structuredClone(v.settings);
pool = v.quest_pool;
toast(v.recalculated ? `Saved — ${v.recalculated} player levels recalculated` : 'Progression settings saved');
} catch (e) {
toastError(e);
} finally {
saving = false;
}
}
const revert = () => saved && (s = structuredClone(saved));
// ---- curve helpers (mirror the server's maths for live previews) ----
const xpFor = (lvl: number, base = s.level_base, exp = s.level_exponent) => (lvl <= 1 ? 0 : Math.round(base * Math.pow(lvl, exp)));
function levelFrom(xp: number): number {
const cap = s.max_level > 0 ? s.max_level : 10000;
let l = 1;
while (l < cap && xp >= xpFor(l + 1)) l++;
return l;
}
const sampleLevels = $derived([5, 10, 25, 50, 100].filter((l) => !s.max_level || l <= s.max_level));
const chart = $derived.by(() => {
const top = Math.min(s.max_level > 0 ? s.max_level : 60, 60);
const pts = Array.from({ length: top - 1 }, (_, i) => ({ l: i + 2, xp: xpFor(i + 2) }));
const maxXp = Math.max(1, ...pts.map((p) => p.xp));
const path = pts.map((p, i) => `${i ? 'L' : 'M'}${(((p.l - 2) / Math.max(1, top - 2)) * 280 + 10).toFixed(1)},${(100 - (p.xp / maxXp) * 88).toFixed(1)}`).join(' ');
return { path, top, maxXp };
});
const n = (v: number) => Math.round(v).toLocaleString();
const per = (r: number) => (r > 0 && r < 1 ? `1 XP per ${Math.round(1 / r)}` : `${r} XP each`);
const quests = $derived([
{ key: 'daily_quest_limit' as const, label: 'Daily quests', hint: 'Reset every day at 00:00 UTC', pool: pool.daily, icon: Target },
{ key: 'weekly_quest_limit' as const, label: 'Weekly quests', hint: 'Reset every Monday at 00:00 UTC', pool: pool.weekly, icon: TrendingUp },
]);
// ---- players ----
let q = $state('');
let players = $state<PlayerProgress[]>([]);
let total = $state(0);
let loadingPlayers = $state(false);
let timer: ReturnType<typeof setTimeout>;
async function loadPlayers() {
loadingPlayers = true;
try {
const r = await get<{ players: PlayerProgress[]; total: number }>(`/api/admin/progression/players?q=${encodeURIComponent(q)}&limit=40`);
players = r.players;
total = r.total;
} catch (e) {
toastError(e);
} finally {
loadingPlayers = false;
}
}
$effect(() => {
void q;
clearTimeout(timer);
timer = setTimeout(() => tab === 'players' && loadPlayers(), 250);
return () => clearTimeout(timer);
});
$effect(() => { if (tab === 'players' && !players.length) loadPlayers(); });
type Detail = {
uuid: string; name: string; title: string | null; max_level: number;
global: { xp: number; level: number; level_xp: number; level_span: number; progress_pct: number };
servers: { server_id: number; server_name: string; xp: number; level: number; progress_pct: number; rank_name: string | null }[];
};
let detail = $state<Detail | null>(null);
let open = $state(false);
let scope = $state('global');
let mode = $state<'add' | 'remove' | 'set_xp' | 'set_level' | 'reset'>('add');
let amount = $state(500);
let reason = $state('');
let applying = $state(false);
async function inspect(p: PlayerProgress) {
try {
detail = await get<Detail>(`/api/admin/progression/players/${p.uuid}`);
scope = 'global';
mode = 'add';
amount = 500;
reason = '';
open = true;
} catch (e) {
toastError(e);
}
}
const currentXp = $derived(!detail ? 0 : scope === 'global' ? detail.global.xp : detail.servers.find((x) => String(x.server_id) === scope)?.xp ?? 0);
const nextXp = $derived(
mode === 'add' ? currentXp + amount
: mode === 'remove' ? Math.max(0, currentXp - amount)
: mode === 'set_xp' ? amount
: mode === 'set_level' ? xpFor(amount)
: 0
);
async function apply() {
if (!detail) return;
applying = true;
try {
const body: Record<string, unknown> = { scope: scope === 'global' ? 'global' : 'server', mode, amount: mode === 'reset' ? 0 : Number(amount), reason };
if (scope !== 'global') body.server_id = Number(scope);
detail = await post<Detail>(`/api/admin/progression/players/${detail.uuid}/adjust`, body);
toast('XP updated');
loadPlayers();
} catch (e) {
toastError(e);
} finally {
applying = false;
}
}
const modes = [
{ id: 'add', label: 'Give XP', icon: Plus },
{ id: 'remove', label: 'Take XP', icon: Minus },
{ id: 'set_xp', label: 'Set XP', icon: Equal },
{ id: 'set_level', label: 'Set level', icon: ArrowUpToLine },
{ id: 'reset', label: 'Reset', icon: Eraser },
] as const;
onMount(load);
</script>
<div class="page">
<div class="page-head">
<div>
<h1>Progression</h1>
<p>Decide how many quests players get, how fast they earn XP, how steep levels are — and fix any player's XP by hand.</p>
</div>
</div>
<div class="tabs" role="tablist">
<button role="tab" aria-selected={tab === 'quests'} class:on={tab === 'quests'} onclick={() => (tab = 'quests')}><Target size={15} /> Quest limits</button>
<button role="tab" aria-selected={tab === 'xp'} class:on={tab === 'xp'} onclick={() => (tab = 'xp')}><Sparkles size={15} /> XP &amp; levels</button>
<button role="tab" aria-selected={tab === 'players'} class:on={tab === 'players'} onclick={() => (tab = 'players')}><Users size={15} /> Players</button>
</div>
{#if tab === 'quests'}
<div class="cols">
{#each quests as c (c.key)}
<section class="card quota">
<div class="q-head">
<span class="ic"><c.icon size={18} /></span>
<div><h2>{c.label}</h2><p class="muted small">{c.hint}</p></div>
</div>
<div class="big">
<button class="step" aria-label="Fewer" onclick={() => (s[c.key] = Math.max(0, s[c.key] - 1))}><Minus size={16} /></button>
<div class="num">
<input type="number" min="0" max="100" bind:value={s[c.key]} aria-label={c.label + ' per player'} />
<span class="muted small">{s[c.key] === 0 ? 'every quest' : s[c.key] === 1 ? 'quest per player' : 'quests per player'}</span>
</div>
<button class="step" aria-label="More" onclick={() => (s[c.key] = Math.min(100, s[c.key] + 1))}><Plus size={16} /></button>
</div>
<input class="range" type="range" min="0" max={Math.max(20, Math.min(100, c.pool.enabled))} bind:value={s[c.key]} aria-label="Limit slider" />
<div class="pool">
<span><strong>{c.pool.enabled}</strong> enabled in the pool</span>
<span class="pin"><Pin size={12} /> {c.pool.pinned} pinned (always included)</span>
</div>
{#if s[c.key] > 0 && s[c.key] < c.pool.pinned}
<p class="warn small"><Info size={13} /> Only {s[c.key]} of the {c.pool.pinned} pinned quests fit — unpin some or raise the limit.</p>
{/if}
</section>
{/each}
</div>
<section class="card">
<div class="section-title"><h2><Shuffle size={16} /> Rotation</h2></div>
<div class="choice">
<button class="opt" class:on={s.quest_rotation === 'per_player'} onclick={() => (s.quest_rotation = 'per_player')}>
<Users size={17} /><span><strong>Per player</strong><small>Everyone draws their own random set each day/week.</small></span>
</button>
<button class="opt" class:on={s.quest_rotation === 'shared'} onclick={() => (s.quest_rotation = 'shared')}>
<Shield size={17} /><span><strong>Shared</strong><small>Everyone gets the same set — good for community goals.</small></span>
</button>
</div>
<p class="muted small note">
A player's set is fixed the first time they open their quests in a period, so edits here never swap out quests they've started.
Raising a limit adds quests, lowering it trims the list. Pin quests on the <a href="#/quests">Quests</a> page to always include them.
</p>
</section>
{:else if tab === 'xp'}
<div class="cols">
<section class="card">
<div class="section-title"><h2><TrendingUp size={16} /> Level curve</h2></div>
<p class="muted small">XP needed for level <em>L</em> is <code>round(base × L<sup>exponent</sup>)</code>.</p>
<div class="fields3">
<label class="field">Base<input type="number" min="10" max="100000" step="10" bind:value={s.level_base} /></label>
<label class="field">Exponent<input type="number" min="1" max="3" step="0.05" bind:value={s.level_exponent} /></label>
<label class="field">Max level<input type="number" min="0" max="10000" bind:value={s.max_level} /><span class="tiny muted">0 = no cap</span></label>
</div>
<svg class="chart" viewBox="0 0 300 110" role="img" aria-label="XP required per level">
<path d="M10,100 H290" class="axis" />
<path d={chart.path} class="line" />
</svg>
<div class="chart-legend tiny muted"><span>Level 2</span><span>Level {chart.top} · {n(chart.maxXp)} XP</span></div>
<ul class="samples">
{#each sampleLevels as l}<li><span class="muted">Level {l}</span><strong>{n(xpFor(l))} XP</strong></li>{/each}
</ul>
{#if curveChanged}<p class="warn small"><Info size={13} /> Saving recalculates every player's stored level from their XP. Nobody loses XP.</p>{/if}
</section>
<section class="card">
<div class="section-title"><h2><Sparkles size={16} /> XP earned from play</h2></div>
<div class="rates">
<label class="field"><span class="lab">Online time <span class="unit">XP / hour</span></span><input type="number" min="0" step="10" bind:value={s.xp_rates.playtime_per_hour} /></label>
<label class="field"><span class="lab">Player kill <span class="unit">XP</span></span><input type="number" min="0" step="1" bind:value={s.xp_rates.player_kill} /></label>
<label class="field"><span class="lab">Mob kill <span class="unit">XP</span></span><input type="number" min="0" step="1" bind:value={s.xp_rates.mob_kill} /></label>
<label class="field"><span class="lab">Block broken <span class="unit">{per(s.xp_rates.block_broken)}</span></span><input type="number" min="0" step="0.05" bind:value={s.xp_rates.block_broken} /></label>
<label class="field"><span class="lab">Block placed <span class="unit">{per(s.xp_rates.block_placed)}</span></span><input type="number" min="0" step="0.05" bind:value={s.xp_rates.block_placed} /></label>
<label class="field"><span class="lab">Chat message <span class="unit">XP</span></span><input type="number" min="0" step="1" bind:value={s.xp_rates.message} /></label>
</div>
<div class="section-title mult"><h2>Multipliers</h2></div>
<div class="fields2">
<label class="field">Global XP ×<input type="number" min="0" max="100" step="0.1" bind:value={s.global_xp_multiplier} /></label>
<label class="field">Server XP ×<input type="number" min="0" max="100" step="0.1" bind:value={s.server_xp_multiplier} /></label>
</div>
<p class="muted small">These stack with the multipliers in each server's plugin/mod config (server levels default to 1.5× there). Quest and achievement XP is set per quest/achievement.</p>
</section>
</div>
{:else}
<section class="card">
<div class="search"><Search size={16} /><input type="search" placeholder="Search players by name…" bind:value={q} /><span class="muted small">{total.toLocaleString()} players</span></div>
{#if loadingPlayers && !players.length}
<div class="skeleton"></div>
{:else if !players.length}
<p class="muted">No players match.</p>
{:else}
<div class="plist">
{#each players as p (p.uuid)}
<button class="prow" onclick={() => inspect(p)}>
<Avatar name={p.name} uuid={p.uuid} size={34} />
<span class="pname"><strong>{p.name}</strong>{#if p.title}<small class="muted">{p.title}</small>{/if}</span>
<span class="pbar"><i style:width="{p.progress_pct}%"></i></span>
<span class="plvl">Lv {p.global_level}</span>
<span class="pxp muted">{n(p.global_xp)} XP</span>
</button>
{/each}
</div>
{/if}
</section>
{/if}
</div>
{#if dirty}
<div class="savebar" role="status">
<span>You have unsaved progression changes.</span>
<button class="ghost" onclick={revert}><RotateCcw size={15} /> Discard</button>
<button class="primary" disabled={saving} onclick={save}><Save size={15} /> {saving ? 'Saving…' : 'Save changes'}</button>
</div>
{/if}
<Modal bind:open title={detail ? `Adjust ${detail.name}` : 'Adjust XP'} width={560}>
{#if detail}
<div class="who">
<Avatar name={detail.name} uuid={detail.uuid} size={44} />
<div>
<strong>{detail.name}</strong>
<span class="muted small">Global level {detail.global.level} · {n(detail.global.xp)} XP</span>
<div class="pbar wide"><i style:width="{detail.global.progress_pct}%"></i></div>
</div>
</div>
<label class="field">Level track
<select bind:value={scope}>
<option value="global">Global level (all servers)</option>
{#each detail.servers as sv}<option value={String(sv.server_id)}>{sv.server_name} — level {sv.level} · {n(sv.xp)} XP</option>{/each}
</select>
</label>
<div class="modes" role="tablist">
{#each modes as m}
<button role="tab" aria-selected={mode === m.id} class="mode" class:on={mode === m.id} onclick={() => (mode = m.id)}><m.icon size={14} /> {m.label}</button>
{/each}
</div>
{#if mode !== 'reset'}
<label class="field">{mode === 'set_level' ? 'Level' : 'XP amount'}
<input type="number" min={mode === 'set_level' ? 1 : 0} bind:value={amount} />
</label>
{/if}
<label class="field">Reason <span class="tiny muted">(kept in the activity log)</span><input maxlength="120" bind:value={reason} placeholder="e.g. event prize, rollback after bug" /></label>
<div class="preview">
<span>{n(currentXp)} XP <small class="muted">(level {levelFrom(currentXp)})</small></span>
<span class="arrow">→</span>
<strong>{n(nextXp)} XP <small>(level {levelFrom(nextXp)})</small></strong>
</div>
{/if}
{#snippet footer()}
<button class="ghost" onclick={() => (open = false)}>Close</button>
<button class={mode === 'reset' || mode === 'remove' ? 'danger' : 'primary'} disabled={applying || (mode !== 'reset' && !(amount >= (mode === 'set_level' ? 1 : 0)))} onclick={apply}>
{applying ? 'Applying…' : 'Apply'}
</button>
{/snippet}
</Modal>
<style>
.page { padding-bottom: 90px; }
.tabs { display: inline-flex; gap: 4px; padding: 4px; border-radius: 12px; background: var(--bg-2); margin-bottom: 18px; }
.tabs button { border: 0; background: transparent; padding: 8px 16px; border-radius: 9px; color: var(--muted); gap: 8px; }
.tabs button.on { background: var(--accent-soft); color: var(--text); }
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 16px; margin-bottom: 16px; align-items: start; }
.quota { display: flex; flex-direction: column; gap: 14px; }
.q-head { display: flex; gap: 12px; align-items: center; }
.q-head h2 { margin: 0; font-size: 1.05rem; }
.q-head p { margin: 2px 0 0; }
.ic { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent-2); }
.big { display: flex; align-items: center; justify-content: center; gap: 18px; padding: 6px 0; }
.step { width: 40px; height: 40px; padding: 0; border-radius: 50%; }
.num { display: flex; flex-direction: column; align-items: center; min-width: 120px; }
.num input { width: 100px; text-align: center; font-size: 2.6rem; font-weight: 700; letter-spacing: -0.03em; background: transparent; border: 0; padding: 0; font-variant-numeric: tabular-nums; -moz-appearance: textfield; appearance: textfield; }
.num input::-webkit-outer-spin-button, .num input::-webkit-inner-spin-button { -webkit-appearance: none; }
.range { width: 100%; accent-color: var(--accent); }
.pool { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; font-size: 0.85rem; color: var(--text-2); padding-top: 10px; border-top: 1px solid var(--line); }
.pin { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); }
.warn { display: flex; gap: 7px; align-items: center; color: #fcd34d; margin: 0; }
.choice { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; margin: 12px 0; }
.opt { justify-content: flex-start; gap: 12px; text-align: left; white-space: normal; padding: 12px 14px; background: var(--bg-2); }
.opt span { display: flex; flex-direction: column; gap: 2px; }
.opt small { color: var(--muted); font-weight: 400; }
.opt.on { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: var(--accent-soft); }
.note { line-height: 1.55; margin: 0; }
.fields3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 12px 0; }
.fields2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin: 10px 0; }
.rates { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-top: 12px; }
.field { display: flex; flex-direction: column; gap: 6px; font-size: 0.85rem; color: var(--text-2); font-weight: 500; }
.lab { display: flex; justify-content: space-between; gap: 8px; }
.unit { font-weight: 400; color: var(--muted); font-size: 0.75rem; }
.mult { margin-top: 18px; }
.chart { width: 100%; height: 110px; margin-top: 6px; }
.axis { stroke: var(--line); stroke-width: 1; fill: none; }
.line { stroke: var(--accent-2); stroke-width: 2.2; fill: none; stroke-linejoin: round; stroke-linecap: round; }
.chart-legend { display: flex; justify-content: space-between; }
.samples { list-style: none; margin: 12px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 8px; }
.samples li { display: flex; flex-direction: column; padding: 9px 12px; border-radius: 10px; background: var(--bg-2); font-size: 0.85rem; }
.search { display: flex; align-items: center; gap: 10px; padding: 0 12px; border-radius: 12px; background: var(--bg-2); margin-bottom: 14px; }
.search input { border: 0; background: transparent; }
.search span { white-space: nowrap; }
.plist { display: flex; flex-direction: column; gap: 4px; }
.prow { display: grid; grid-template-columns: 34px minmax(120px, 1.3fr) minmax(80px, 2fr) 56px 100px; align-items: center; gap: 14px; padding: 9px 12px; border-radius: 12px; background: transparent; border-color: transparent; text-align: left; }
.prow:hover { background: var(--bg-2); }
.pname { display: flex; flex-direction: column; min-width: 0; }
.pname small { font-size: 0.75rem; }
.pbar { height: 6px; border-radius: 99px; background: var(--bg-2); overflow: hidden; }
.pbar.wide { margin-top: 6px; width: 260px; max-width: 100%; }
.pbar i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent), var(--accent-2)); }
.plvl { font-weight: 700; color: #fcd34d; }
.pxp { text-align: right; font-variant-numeric: tabular-nums; font-size: 0.85rem; }
.skeleton { height: 220px; border-radius: 12px; background: var(--bg-2); }
.who { display: flex; gap: 14px; align-items: center; }
.who div { display: flex; flex-direction: column; }
.modes { display: flex; flex-wrap: wrap; gap: 6px; }
.mode { padding: 7px 12px; border-radius: 99px; font-size: 0.83rem; background: var(--bg-2); gap: 6px; }
.mode.on { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: var(--accent-soft); }
.preview { display: flex; align-items: center; justify-content: center; gap: 14px; padding: 14px; border-radius: 12px; background: var(--bg-2); font-variant-numeric: tabular-nums; }
.preview strong { color: var(--accent-2); }
.arrow { color: var(--muted); }
.savebar { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); z-index: 40; display: flex; align-items: center; gap: 12px; padding: 10px 12px 10px 18px; border-radius: 14px; background: var(--surface); border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); box-shadow: 0 14px 40px -12px rgba(0, 0, 0, 0.6); animation: rise 0.2s ease; }
@keyframes rise { from { opacity: 0; transform: translate(-50%, 10px); } }
@media (max-width: 720px) { .fields3, .rates { grid-template-columns: 1fr; } .prow { grid-template-columns: 34px 1fr 56px; } .pbar, .pxp { display: none; } .savebar { left: 12px; right: 12px; transform: none; flex-wrap: wrap; } }
</style>
+28 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Search, Plus, Pencil, Trash2, CheckCircle2, XCircle, Sparkles, Pickaxe, Swords, Blocks, Wheat, Users, Compass, Target } from '@lucide/svelte';
import { Search, Plus, Pencil, Trash2, CheckCircle2, XCircle, Sparkles, Pin, SlidersHorizontal, Pickaxe, Swords, Blocks, Wheat, Users, Compass, Target } from '@lucide/svelte';
import Modal from '../components/Modal.svelte';
import { get, post, put, del } from '../lib/api';
import { toast, toastError } from '../lib/toast.svelte';
@@ -59,6 +59,7 @@
target_count: q.target_count || 1,
icon: q.icon || 'pickaxe',
active: q.active !== undefined ? q.active : (q.enabled !== undefined ? q.enabled : true),
pinned: !!q.pinned,
}));
} catch (e) {
toastError(e);
@@ -67,8 +68,10 @@
}
}
let limits = $state<{ daily_quest_limit: number; weekly_quest_limit: number; quest_rotation: string } | null>(null);
onMount(() => {
loadQuests();
get<{ settings: NonNullable<typeof limits> }>('/api/admin/progression').then((v) => (limits = v.settings)).catch(() => {});
});
let filtered = $derived(
@@ -99,6 +102,7 @@
target_count: 50,
icon: 'pickaxe',
active: true,
pinned: false,
};
draftOpen = true;
}
@@ -156,6 +160,18 @@
</button>
</header>
{#if limits}
<a class="limit-banner" href="#/progression">
<SlidersHorizontal size={16} />
<span>
Each player gets <strong>{limits.daily_quest_limit || 'every'}</strong> of {dailyCount} daily and
<strong>{limits.weekly_quest_limit || 'every'}</strong> of {weeklyCount} weekly quests
({limits.quest_rotation === 'shared' ? 'same set for everyone' : 'random per player'}).
</span>
<span class="edit">Change limits →</span>
</a>
{/if}
<div class="stats-row">
<div class="stat-card">
<span class="label">Total Quests</span>
@@ -224,7 +240,7 @@
<span class="quest-icon" title={q.icon}><Icon size={21} /></span>
</td>
<td class="info-cell">
<strong>{q.title}</strong>
<strong>{q.title}{#if q.pinned}<span class="pin-tag" title="Always handed out first"><Pin size={11} /> Pinned</span>{/if}</strong>
<p>{q.description}</p>
<code class="id-tag">{q.id}</code>
</td>
@@ -340,6 +356,12 @@
Active (players can receive and complete this quest)
</label>
</div>
<div class="field checkbox">
<label>
<input type="checkbox" bind:checked={draft.pinned} />
Pinned — always included when a player's quests are limited
</label>
</div>
<div class="modal-actions">
<button type="button" class="ghost" onclick={() => (draftOpen = false)}>Cancel</button>
@@ -367,6 +389,10 @@
h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 6px; }
header p { color: var(--muted); margin: 0; font-size: 0.95rem; }
.limit-banner { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-radius: 12px; background: color-mix(in srgb, var(--accent) 9%, var(--bg-2)); border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); color: var(--text); font-size: 0.9rem; }
.limit-banner:hover { border-color: color-mix(in srgb, var(--accent) 60%, transparent); }
.limit-banner .edit { margin-left: auto; color: var(--accent-2); white-space: nowrap; }
.pin-tag { display: inline-flex; align-items: center; gap: 3px; margin-left: 8px; padding: 1px 7px; border-radius: 99px; font-size: 0.68rem; font-weight: 600; background: color-mix(in srgb, #fcd34d 16%, transparent); color: #fcd34d; vertical-align: middle; }
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
.stat-card { background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; display: flex; flex-direction: column; gap: 4px; }
.stat-card .label { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }