feat: add global & server leveling, quests, achievements, guilds land-claiming, social & toggleable configs
This commit is contained in:
1 parent
4b79f69c20
commit
b4cef85b1f
40 files changed
+10261
-27
No files matched your search
@@ -0,0 +1,400 @@
|
||||
<script lang="ts">
|
||||
import { Search, Plus, Pencil, Trash2, CheckCircle2, XCircle, Award, Sparkles, Filter } from '@lucide/svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import { get, post, put, del } from '../lib/api';
|
||||
import { toast, toastError } from '../lib/toast.svelte';
|
||||
import type { Quest } from '../lib/types';
|
||||
|
||||
let quests = $state<Quest[]>([]);
|
||||
let loading = $state(true);
|
||||
let filterType = $state<'all' | 'daily' | 'weekly'>('all');
|
||||
let filterCategory = $state<string>('all');
|
||||
let search = $state('');
|
||||
|
||||
let draft = $state<Partial<Quest> | null>(null);
|
||||
let draftOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
let deleteConfirm = $state<Quest | null>(null);
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
const categories = ['mining', 'combat', 'building', 'farming', 'social', 'exploration'];
|
||||
const statTypes = [
|
||||
{ id: 'blocks_broken', label: 'Blocks Broken' },
|
||||
{ id: 'mob_kills', label: 'Mob Kills' },
|
||||
{ id: 'player_kills', label: 'Player Kills' },
|
||||
{ id: 'blocks_placed', label: 'Blocks Placed' },
|
||||
{ id: 'playtime_secs', label: 'Playtime (Seconds)' },
|
||||
{ id: 'messages', label: 'Chat Messages Sent' },
|
||||
{ id: 'joins', label: 'Server Joins' },
|
||||
{ id: 'friend_add', label: 'Add a Friend' },
|
||||
{ id: 'guild_create', label: 'Create a Guild' },
|
||||
];
|
||||
|
||||
async function loadQuests() {
|
||||
loading = true;
|
||||
try {
|
||||
quests = await get<Quest[]>('/api/admin/quests');
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadQuests();
|
||||
});
|
||||
|
||||
let filtered = $derived(
|
||||
quests.filter((q) => {
|
||||
if (filterType !== 'all' && q.quest_type !== filterType) return false;
|
||||
if (filterCategory !== 'all' && q.category !== filterCategory) return false;
|
||||
if (search.trim()) {
|
||||
const s = search.toLowerCase();
|
||||
return q.title.toLowerCase().includes(s) || q.description.toLowerCase().includes(s) || q.id.toLowerCase().includes(s);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
let dailyCount = $derived(quests.filter((q) => q.quest_type === 'daily').length);
|
||||
let weeklyCount = $derived(quests.filter((q) => q.quest_type === 'weekly').length);
|
||||
let activeCount = $derived(quests.filter((q) => q.active).length);
|
||||
|
||||
function openNew() {
|
||||
draft = {
|
||||
id: `quest_${Date.now()}`,
|
||||
title: '',
|
||||
description: '',
|
||||
quest_type: 'daily',
|
||||
category: 'mining',
|
||||
xp_reward: 150,
|
||||
stat_type: 'blocks_broken',
|
||||
target_count: 50,
|
||||
icon: '⛏️',
|
||||
active: true,
|
||||
};
|
||||
draftOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(q: Quest) {
|
||||
draft = { ...q };
|
||||
draftOpen = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!draft || !draft.title || !draft.description) {
|
||||
toast('Title and description are required', 'error');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
const exists = quests.some((q) => q.id === draft?.id);
|
||||
if (exists) {
|
||||
await put(`/api/admin/quests/${draft.id}`, draft);
|
||||
toast('Quest updated');
|
||||
} else {
|
||||
await post('/api/admin/quests', draft);
|
||||
toast('Quest created');
|
||||
}
|
||||
draftOpen = false;
|
||||
draft = null;
|
||||
await loadQuests();
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(q: Quest) {
|
||||
try {
|
||||
await del(`/api/admin/quests/${q.id}`);
|
||||
toast(`Quest "${q.title}" removed`);
|
||||
deleteConfirm = null;
|
||||
await loadQuests();
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<div>
|
||||
<h1>Quests Manager</h1>
|
||||
<p>Configure daily and weekly player objectives, XP rewards, and stat progression tracking.</p>
|
||||
</div>
|
||||
<button class="primary" onclick={openNew}>
|
||||
<Plus size={16} /> New Quest
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<span class="label">Total Quests</span>
|
||||
<span class="value">{quests.length}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="label">Active Quests</span>
|
||||
<span class="value text-success">{activeCount}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="label">Daily Quests</span>
|
||||
<span class="value text-accent">{dailyCount}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="label">Weekly Quests</span>
|
||||
<span class="value text-accent-2">{weeklyCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="search-box">
|
||||
<Search size={16} />
|
||||
<input type="text" placeholder="Search quests by title, description, or id..." bind:value={search} />
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<div class="segmented">
|
||||
<button class:active={filterType === 'all'} onclick={() => (filterType = 'all')}>All ({quests.length})</button>
|
||||
<button class:active={filterType === 'daily'} onclick={() => (filterType = 'daily')}>Daily ({dailyCount})</button>
|
||||
<button class:active={filterType === 'weekly'} onclick={() => (filterType = 'weekly')}>Weekly ({weeklyCount})</button>
|
||||
</div>
|
||||
|
||||
<select bind:value={filterCategory}>
|
||||
<option value="all">All Categories</option>
|
||||
{#each categories as cat}
|
||||
<option value={cat}>{cat.toUpperCase()}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="empty">Loading quests...</div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="empty">No quests match your filters.</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Icon</th>
|
||||
<th>Title & Description</th>
|
||||
<th>Type</th>
|
||||
<th>Category</th>
|
||||
<th>Target Objective</th>
|
||||
<th>XP Reward</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filtered as q}
|
||||
<tr>
|
||||
<td class="icon-cell">
|
||||
<span class="quest-icon">{q.icon || '🎯'}</span>
|
||||
</td>
|
||||
<td class="info-cell">
|
||||
<strong>{q.title}</strong>
|
||||
<p>{q.description}</p>
|
||||
<code class="id-tag">{q.id}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {q.quest_type}">
|
||||
{q.quest_type.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="cat-tag">{q.category}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="stat-target">
|
||||
<strong>{q.target_count.toLocaleString()}</strong>
|
||||
<code>{q.stat_type}</code>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="xp-badge">
|
||||
<Sparkles size={13} />
|
||||
+{q.xp_reward} XP
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{#if q.active}
|
||||
<span class="status-active"><CheckCircle2 size={14} /> Active</span>
|
||||
{:else}
|
||||
<span class="status-inactive"><XCircle size={14} /> Disabled</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button class="ghost icon" title="Edit quest" onclick={() => openEdit(q)}>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button class="ghost icon danger" title="Delete quest" onclick={() => { deleteConfirm = q; deleteOpen = true; }}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if draft}
|
||||
<Modal bind:open={draftOpen} title={draft.id && quests.some((q) => q.id === draft?.id) ? 'Edit Quest' : 'New Quest'}>
|
||||
<form onsubmit={(e) => { e.preventDefault(); save(); }} class="form">
|
||||
<div class="field">
|
||||
<label for="q-id">Quest Identifier</label>
|
||||
<input id="q-id" type="text" bind:value={draft.id} required placeholder="e.g. daily_mine_iron" />
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="q-title">Title</label>
|
||||
<input id="q-title" type="text" bind:value={draft.title} required placeholder="e.g. Iron Seeker" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="q-icon">Icon (Emoji / Symbol)</label>
|
||||
<input id="q-icon" type="text" bind:value={draft.icon} placeholder="⛏️" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="q-desc">Description</label>
|
||||
<textarea id="q-desc" rows="2" bind:value={draft.description} required placeholder="Describe the objective..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="q-type">Cadence Type</label>
|
||||
<select id="q-type" bind:value={draft.quest_type}>
|
||||
<option value="daily">Daily Quest</option>
|
||||
<option value="weekly">Weekly Quest</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="q-cat">Category</label>
|
||||
<select id="q-cat" bind:value={draft.category}>
|
||||
{#each categories as cat}
|
||||
<option value={cat}>{cat.charAt(0).toUpperCase() + cat.slice(1)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-3">
|
||||
<div class="field">
|
||||
<label for="q-stat">Tracked Stat</label>
|
||||
<select id="q-stat" bind:value={draft.stat_type}>
|
||||
{#each statTypes as st}
|
||||
<option value={st.id}>{st.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="q-target">Target Count</label>
|
||||
<input id="q-target" type="number" min="1" bind:value={draft.target_count} required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="q-xp">XP Reward</label>
|
||||
<input id="q-xp" type="number" min="10" step="10" bind:value={draft.xp_reward} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field checkbox">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={draft.active} />
|
||||
Active (players can receive and complete this quest)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="ghost" onclick={() => (draftOpen = false)}>Cancel</button>
|
||||
<button type="submit" class="primary" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Quest'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
{#if deleteConfirm}
|
||||
<Modal bind:open={deleteOpen} title="Delete Quest">
|
||||
<p>Are you sure you want to delete quest <strong>{deleteConfirm.title}</strong> (<code>{deleteConfirm.id}</code>)?</p>
|
||||
<div class="modal-actions">
|
||||
<button class="ghost" onclick={() => { deleteOpen = false; deleteConfirm = null; }}>Cancel</button>
|
||||
<button class="danger" onclick={() => { if (deleteConfirm) { remove(deleteConfirm); deleteOpen = false; } }}>Delete Quest</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page { padding: 32px; max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
|
||||
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
||||
h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 6px; }
|
||||
header p { color: var(--muted); margin: 0; font-size: 0.95rem; }
|
||||
|
||||
.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); }
|
||||
.stat-card .value { font-size: 1.8rem; font-weight: 700; }
|
||||
.text-success { color: var(--success, #22c55e); }
|
||||
.text-accent { color: var(--accent, #6366f1); }
|
||||
.text-accent-2 { color: #a855f7; }
|
||||
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.search-box { display: flex; align-items: center; gap: 10px; background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; padding: 8px 14px; flex: 1; min-width: 260px; max-width: 500px; }
|
||||
.search-box input { background: transparent; border: none; outline: none; color: var(--text); width: 100%; font-size: 0.9rem; }
|
||||
.filters { display: flex; align-items: center; gap: 12px; }
|
||||
.segmented { display: flex; background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; padding: 3px; gap: 2px; }
|
||||
.segmented button { padding: 6px 14px; border-radius: 7px; border: none; background: transparent; color: var(--muted); font-size: 0.85rem; font-weight: 500; cursor: pointer; transition: all 0.15s; }
|
||||
.segmented button.active { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
|
||||
select { background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; padding: 8px 14px; color: var(--text); font-size: 0.85rem; outline: none; }
|
||||
|
||||
.table-wrap { background: var(--bg-2); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
|
||||
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.9rem; }
|
||||
th { padding: 12px 16px; background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--line); color: var(--muted); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
td { padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,0.04); vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: rgba(255,255,255,0.015); }
|
||||
|
||||
.icon-cell { width: 48px; text-align: center; }
|
||||
.quest-icon { font-size: 1.6rem; display: inline-flex; align-items: center; justify-content: center; }
|
||||
|
||||
.info-cell strong { font-size: 0.95rem; display: block; margin-bottom: 2px; }
|
||||
.info-cell p { margin: 0 0 4px; color: var(--muted); font-size: 0.82rem; }
|
||||
.id-tag { font-size: 0.72rem; color: var(--muted); background: rgba(255,255,255,0.04); padding: 2px 6px; border-radius: 4px; }
|
||||
|
||||
.badge { display: inline-block; padding: 4px 10px; border-radius: 6px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.05em; }
|
||||
.badge.daily { background: rgba(99, 102, 241, 0.15); color: #818cf8; border: 1px solid rgba(99, 102, 241, 0.3); }
|
||||
.badge.weekly { background: rgba(168, 85, 247, 0.15); color: #c084fc; border: 1px solid rgba(168, 85, 247, 0.3); }
|
||||
|
||||
.cat-tag { display: inline-block; padding: 3px 8px; border-radius: 6px; background: rgba(255,255,255,0.05); font-size: 0.75rem; text-transform: capitalize; color: var(--text-2); }
|
||||
|
||||
.stat-target { display: flex; flex-direction: column; gap: 2px; }
|
||||
.stat-target strong { font-size: 0.95rem; }
|
||||
.stat-target code { font-size: 0.72rem; color: var(--muted); }
|
||||
|
||||
.xp-badge { display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border-radius: 6px; background: rgba(234, 179, 8, 0.12); color: #eab308; font-weight: 600; font-size: 0.82rem; border: 1px solid rgba(234, 179, 8, 0.25); }
|
||||
|
||||
.status-active { display: inline-flex; align-items: center; gap: 5px; color: var(--success, #22c55e); font-size: 0.82rem; font-weight: 500; }
|
||||
.status-inactive { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font-size: 0.82rem; }
|
||||
|
||||
.actions { display: flex; align-items: center; gap: 6px; }
|
||||
|
||||
.form { display: flex; flex-direction: column; gap: 16px; padding-top: 12px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field label { font-size: 0.82rem; font-weight: 500; color: var(--muted); }
|
||||
.field input, .field textarea, .field select { background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; color: var(--text); font-size: 0.9rem; }
|
||||
.field.checkbox label { display: flex; align-items: center; gap: 8px; cursor: pointer; color: var(--text); font-size: 0.88rem; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
|
||||
.empty { text-align: center; padding: 60px 20px; color: var(--muted); background: var(--bg-2); border-radius: 12px; border: 1px solid var(--line); }
|
||||
</style>
|
||||
Reference in new issue
Block a user