437 lines
18 KiB
Svelte
437 lines
18 KiB
Svelte
<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 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 iconChoices = [
|
|
{ id: 'pickaxe', label: 'Mining', component: Pickaxe },
|
|
{ id: 'swords', label: 'Combat', component: Swords },
|
|
{ id: 'blocks', label: 'Building', component: Blocks },
|
|
{ id: 'wheat', label: 'Farming', component: Wheat },
|
|
{ id: 'users', label: 'Social', component: Users },
|
|
{ id: 'compass', label: 'Exploration', component: Compass },
|
|
{ id: 'target', label: 'General', component: Target },
|
|
];
|
|
function iconFor(q: Partial<Quest>) {
|
|
const icon = (q.icon || '').toLowerCase();
|
|
return iconChoices.find((i) => i.id === icon)?.component || iconChoices.find((i) => i.label.toLowerCase() === q.category)?.component || Target;
|
|
}
|
|
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 {
|
|
const res = await get<any[]>('/api/admin/quests');
|
|
quests = (res || []).map((q: any) => ({
|
|
id: q.id || '',
|
|
title: q.title || '',
|
|
description: q.description || '',
|
|
quest_type: q.quest_type || q.period || 'daily',
|
|
category: q.category || 'mining',
|
|
xp_reward: q.xp_reward || 0,
|
|
stat_type: q.stat_type || q.target_stat || 'blocks_broken',
|
|
target_count: q.target_count || 1,
|
|
icon: q.icon || 'pickaxe',
|
|
active: q.active !== undefined ? q.active : (q.enabled !== undefined ? q.enabled : true),
|
|
}));
|
|
} catch (e) {
|
|
toastError(e);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
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: 'pickaxe',
|
|
active: true,
|
|
};
|
|
draftOpen = true;
|
|
}
|
|
|
|
function openEdit(q: Quest) {
|
|
draft = { ...q };
|
|
draftOpen = true;
|
|
}
|
|
|
|
async function save() {
|
|
if (!draft || !draft.id?.trim() || !draft.title?.trim() || !draft.description?.trim() || !draft.stat_type?.trim() || Number(draft.target_count) < 1 || Number(draft.xp_reward) < 0) {
|
|
toast('Enter an ID, title, description, tracked stat, target and reward', '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}
|
|
{@const Icon = iconFor(q)}
|
|
<tr>
|
|
<td class="icon-cell">
|
|
<span class="quest-icon" title={q.icon}><Icon size={21} /></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} width={720} 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 disabled={quests.some((q) => q.id === draft?.id)} 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</label>
|
|
<select id="q-icon" bind:value={draft.icon}>
|
|
{#each iconChoices as icon}<option value={icon.id}>{icon.label}</option>{/each}
|
|
</select>
|
|
</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>
|
|
<input id="q-stat" list="quest-stats" bind:value={draft.stat_type} required placeholder="blocks_broken or action:block_broken:DIAMOND_ORE" />
|
|
<datalist id="quest-stats">{#each statTypes as st}<option value={st.id}>{st.label}</option>{/each}</datalist>
|
|
<small>Use an action target such as <code>action:block_broken:DIAMOND_ORE</code> for a specific block.</small>
|
|
</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="0" step="1" 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: clamp(16px, 3vw, 32px); width: 100%; min-width: 0; 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; flex-wrap: wrap; min-width: 0; }
|
|
.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 { width: 100%; min-width: 0; background: var(--bg-2); border: 1px solid var(--line); border-radius: 14px; overflow-x: auto; }
|
|
table { width: 100%; min-width: 1040px; 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 { display: inline-flex; align-items: center; justify-content: center; color: var(--accent); }
|
|
|
|
.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); }
|
|
@media (max-width: 720px) {
|
|
header { flex-wrap: wrap; }
|
|
.stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
.search-box { min-width: 0; max-width: none; flex-basis: 100%; }
|
|
.filters, .segmented { width: 100%; }
|
|
.segmented button { flex: 1; padding-inline: 4px; }
|
|
.grid-2, .grid-3 { grid-template-columns: minmax(0, 1fr); }
|
|
.form .field { min-width: 0; }
|
|
}
|
|
</style>
|