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
@@ -14,6 +14,10 @@
|
||||
import Activity from './pages/Activity.svelte';
|
||||
import LandingBuilder from './pages/LandingBuilder.svelte';
|
||||
import PublicLanding from './pages/PublicLanding.svelte';
|
||||
import QuestEditor from './pages/QuestEditor.svelte';
|
||||
import AchievementEditor from './pages/AchievementEditor.svelte';
|
||||
import LevelingAdmin from './pages/LevelingAdmin.svelte';
|
||||
import GuildsAdmin from './pages/GuildsAdmin.svelte';
|
||||
import { get } from './lib/api';
|
||||
import { route } from './lib/router.svelte';
|
||||
import { logout, session, type Me } from './lib/session.svelte';
|
||||
@@ -73,6 +77,14 @@
|
||||
<ServerDetail id={route.params[0]} />
|
||||
{:else if route.name === 'servers'}
|
||||
<Servers />
|
||||
{:else if route.name === 'quests'}
|
||||
<QuestEditor />
|
||||
{:else if route.name === 'achievements'}
|
||||
<AchievementEditor />
|
||||
{:else if route.name === 'leveling'}
|
||||
<LevelingAdmin />
|
||||
{:else if route.name === 'guilds'}
|
||||
<GuildsAdmin />
|
||||
{:else if route.name === 'capes'}
|
||||
<Capes />
|
||||
{:else if route.name === 'activity'}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { LayoutDashboard, Boxes, Users, Palette, Settings, LogOut, ExternalLink, Server, Flag, Globe } from '@lucide/svelte';
|
||||
import { LayoutDashboard, Boxes, Users, Palette, Settings, LogOut, ExternalLink, Server, Flag, Globe, Target, Trophy, Sparkles, Shield } from '@lucide/svelte';
|
||||
import { route } from '../lib/router.svelte';
|
||||
import { logout, session } from '../lib/session.svelte';
|
||||
import Avatar from './Avatar.svelte';
|
||||
@@ -11,6 +11,10 @@
|
||||
{ id: 'instances', label: 'Instances', icon: Boxes },
|
||||
{ id: 'users', label: 'Players', icon: Users },
|
||||
{ id: 'servers', label: 'Servers', icon: Server },
|
||||
{ id: 'quests', label: 'Quests', icon: Target },
|
||||
{ id: 'achievements', label: 'Achievements', icon: Trophy },
|
||||
{ id: 'leveling', label: 'Leveling & XP', icon: Sparkles },
|
||||
{ id: 'guilds', label: 'Guilds & Claims', icon: Shield },
|
||||
{ id: 'activity', label: 'Activity', icon: LayoutDashboard },
|
||||
{ id: 'capes', label: 'Capes', icon: Flag },
|
||||
{ id: 'branding', label: 'Launcher design', icon: Palette },
|
||||
|
||||
@@ -183,3 +183,180 @@ export interface LandingConfig {
|
||||
faqs: FaqItem[];
|
||||
custom_css: string;
|
||||
}
|
||||
|
||||
export interface LevelReward {
|
||||
id: number;
|
||||
level: number;
|
||||
reward_type: 'title' | 'item' | 'cosmetic' | 'profile_badge';
|
||||
reward_value: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface UserLevelInfo {
|
||||
uuid: string;
|
||||
level: number;
|
||||
current_xp: number;
|
||||
next_level_xp: number;
|
||||
progress_pct: number;
|
||||
title: string | null;
|
||||
badges: string[];
|
||||
rewards: LevelReward[];
|
||||
}
|
||||
|
||||
export interface ServerLevelInfo {
|
||||
server_id: number;
|
||||
server_name: string;
|
||||
uuid: string;
|
||||
level: number;
|
||||
current_xp: number;
|
||||
next_level_xp: number;
|
||||
progress_pct: number;
|
||||
rank_title: string | null;
|
||||
}
|
||||
|
||||
export interface Quest {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
quest_type: 'daily' | 'weekly';
|
||||
category: 'mining' | 'combat' | 'building' | 'farming' | 'social' | 'exploration';
|
||||
xp_reward: number;
|
||||
stat_type: string;
|
||||
target_count: number;
|
||||
icon: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface UserQuest {
|
||||
quest: Quest;
|
||||
current_count: number;
|
||||
completed: boolean;
|
||||
claimed: boolean;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export interface Achievement {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
xp_reward: number;
|
||||
frame_type: 'task' | 'goal' | 'challenge';
|
||||
icon_item: string;
|
||||
icon_bg: string;
|
||||
icon_border: string;
|
||||
stat_type?: string | null;
|
||||
target_count?: number | null;
|
||||
event_type?: string | null;
|
||||
unlocked?: boolean;
|
||||
unlocked_at?: string | null;
|
||||
}
|
||||
|
||||
export interface Guild {
|
||||
id: string;
|
||||
instance_id: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
icon_url: string | null;
|
||||
banner_url: string | null;
|
||||
leader_uuid: string;
|
||||
member_count: number;
|
||||
max_members: number;
|
||||
claims_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GuildMember {
|
||||
guild_id: string;
|
||||
user_uuid: string;
|
||||
username: string;
|
||||
role: 'leader' | 'officer' | 'member';
|
||||
joined_at: string;
|
||||
is_online?: boolean;
|
||||
}
|
||||
|
||||
export interface GuildPost {
|
||||
id: string;
|
||||
guild_id: string;
|
||||
author_uuid: string;
|
||||
author_name: string;
|
||||
title: string;
|
||||
content: string;
|
||||
pinned: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GuildClaim {
|
||||
id: number;
|
||||
guild_id: string;
|
||||
instance_id: string;
|
||||
dimension: string;
|
||||
chunk_x: number;
|
||||
chunk_z: number;
|
||||
claimed_by_uuid: string;
|
||||
claimed_at: string;
|
||||
guild_name?: string;
|
||||
guild_tag?: string;
|
||||
}
|
||||
|
||||
export interface FriendInfo {
|
||||
uuid: string;
|
||||
username: string;
|
||||
status: 'pending_sent' | 'pending_received' | 'accepted';
|
||||
is_online: boolean;
|
||||
playing_on: string | null;
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
export interface DirectMessage {
|
||||
id: number;
|
||||
from_uuid: string;
|
||||
to_uuid: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
is_read: boolean;
|
||||
}
|
||||
|
||||
export interface GameInvite {
|
||||
id: string;
|
||||
from_uuid: string;
|
||||
from_username: string;
|
||||
to_uuid: string;
|
||||
instance_id: string;
|
||||
server_address: string;
|
||||
server_name: string;
|
||||
status: 'pending' | 'accepted' | 'declined' | 'expired';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UserPost {
|
||||
id: string;
|
||||
user_uuid: string;
|
||||
username: string;
|
||||
content: string;
|
||||
image_url: string | null;
|
||||
likes_count: number;
|
||||
liked_by_me: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UserProfileView {
|
||||
uuid: string;
|
||||
username: string;
|
||||
bio: string;
|
||||
banner_url: string | null;
|
||||
skin_url: string | null;
|
||||
skin_model: string;
|
||||
cape_url: string | null;
|
||||
global_level: UserLevelInfo;
|
||||
server_levels: ServerLevelInfo[];
|
||||
badges: string[];
|
||||
achievements: Achievement[];
|
||||
posts: UserPost[];
|
||||
friends_count: number;
|
||||
stats: PlayerStats | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
<script lang="ts">
|
||||
import { Plus, Pencil, Trash2, Trophy, Sparkles, Shield, Swords, Pickaxe, Compass, Users } 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 { Achievement } from '../lib/types';
|
||||
|
||||
let achievements = $state<Achievement[]>([]);
|
||||
let loading = $state(true);
|
||||
let filterCategory = $state<string>('all');
|
||||
let filterFrame = $state<string>('all');
|
||||
|
||||
let draft = $state<Partial<Achievement> | null>(null);
|
||||
let draftOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
let deleteConfirm = $state<Achievement | null>(null);
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
const categories = ['progression', 'mining', 'combat', 'building', 'social', 'guilds', 'exploration'];
|
||||
|
||||
const frameTypes = [
|
||||
{ id: 'task', label: 'Task (Normal)', bannerTitle: 'Advancement Made!', bannerColor: '#55ff55' },
|
||||
{ id: 'goal', label: 'Goal (Rounded)', bannerTitle: 'Goal Reached!', bannerColor: '#55ffff' },
|
||||
{ id: 'challenge', label: 'Challenge (Spiked & Glowing)', bannerTitle: 'Challenge Complete!', bannerColor: '#ff55ff' },
|
||||
] as const;
|
||||
|
||||
const bgTextures = [
|
||||
{ id: 'stone', label: 'Stone', color: '#52525b', pattern: 'radial-gradient(circle, #52525b 0%, #27272a 100%)' },
|
||||
{ id: 'deepslate', label: 'Deepslate', color: '#18181b', pattern: 'radial-gradient(circle, #27272a 0%, #09090b 100%)' },
|
||||
{ id: 'obsidian', label: 'Obsidian', color: '#1e1b4b', pattern: 'radial-gradient(circle, #2e1065 0%, #0f172a 100%)' },
|
||||
{ id: 'dirt', label: 'Dirt', color: '#78350f', pattern: 'radial-gradient(circle, #92400e 0%, #451a03 100%)' },
|
||||
{ id: 'bedrock', label: 'Bedrock', color: '#09090b', pattern: 'radial-gradient(circle, #3f3f46 0%, #09090b 100%)' },
|
||||
] as const;
|
||||
|
||||
const borderGlows = [
|
||||
{ id: 'bronze', label: 'Bronze', color: '#b45309', glow: '0 0 10px rgba(180, 83, 9, 0.6)' },
|
||||
{ id: 'silver', label: 'Silver', color: '#94a3b8', glow: '0 0 10px rgba(148, 163, 184, 0.6)' },
|
||||
{ id: 'gold', label: 'Gold', color: '#eab308', glow: '0 0 12px rgba(234, 179, 8, 0.7)' },
|
||||
{ id: 'diamond', label: 'Diamond', color: '#06b6d4', glow: '0 0 14px rgba(6, 182, 212, 0.8)' },
|
||||
{ id: 'purple', label: 'Amethyst / Purple', color: '#c084fc', glow: '0 0 14px rgba(192, 132, 252, 0.8)' },
|
||||
{ id: 'netherite', label: 'Netherite', color: '#475569', glow: '0 0 10px rgba(71, 85, 105, 0.7)' },
|
||||
] as const;
|
||||
|
||||
const popularItems = [
|
||||
{ id: 'diamond_pickaxe', label: 'Diamond Pickaxe', emoji: '⛏️' },
|
||||
{ id: 'netherite_sword', label: 'Netherite Sword', emoji: '⚔️' },
|
||||
{ id: 'beacon', label: 'Beacon', emoji: '✨' },
|
||||
{ id: 'golden_apple', label: 'Golden Apple', emoji: '🍏' },
|
||||
{ id: 'elytra', label: 'Elytra', emoji: '🪽' },
|
||||
{ id: 'ender_eye', label: 'Eye of Ender', emoji: '👁️' },
|
||||
{ id: 'crafting_table', label: 'Crafting Table', emoji: '🪚' },
|
||||
{ id: 'shield', label: 'Shield', emoji: '🛡️' },
|
||||
{ id: 'heart_of_the_sea', label: 'Heart of the Sea', emoji: '💙' },
|
||||
{ id: 'dragon_egg', label: 'Dragon Egg', emoji: '🥚' },
|
||||
{ id: 'totem_of_undying', label: 'Totem of Undying', emoji: '🗿' },
|
||||
{ id: 'nether_star', label: 'Nether Star', emoji: '⭐' },
|
||||
{ id: 'chest', label: 'Treasure Chest', emoji: '📦' },
|
||||
{ id: 'castle', label: 'Castle Flag', emoji: '🚩' },
|
||||
];
|
||||
|
||||
async function loadAchievements() {
|
||||
loading = true;
|
||||
try {
|
||||
achievements = await get<Achievement[]>('/api/admin/achievements');
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadAchievements();
|
||||
});
|
||||
|
||||
let filtered = $derived(
|
||||
achievements.filter((a) => {
|
||||
if (filterCategory !== 'all' && a.category !== filterCategory) return false;
|
||||
if (filterFrame !== 'all' && a.frame_type !== filterFrame) return false;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
function openNew() {
|
||||
draft = {
|
||||
id: `ach_${Date.now()}`,
|
||||
title: 'New Advancement',
|
||||
description: 'Describe the achievement requirements...',
|
||||
category: 'progression',
|
||||
xp_reward: 200,
|
||||
frame_type: 'task',
|
||||
icon_item: 'diamond_pickaxe',
|
||||
icon_bg: 'deepslate',
|
||||
icon_border: 'gold',
|
||||
stat_type: 'blocks_broken',
|
||||
target_count: 100,
|
||||
};
|
||||
draftOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(a: Achievement) {
|
||||
draft = { ...a };
|
||||
draftOpen = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!draft || !draft.title || !draft.description) {
|
||||
toast('Title and description are required', 'error');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
const exists = achievements.some((a) => a.id === draft?.id);
|
||||
if (exists) {
|
||||
await put(`/api/admin/achievements/${draft.id}`, draft);
|
||||
toast('Achievement updated');
|
||||
} else {
|
||||
await post('/api/admin/achievements', draft);
|
||||
toast('Achievement created');
|
||||
}
|
||||
draftOpen = false;
|
||||
draft = null;
|
||||
await loadAchievements();
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(a: Achievement) {
|
||||
try {
|
||||
await del(`/api/admin/achievements/${a.id}`);
|
||||
toast(`Achievement "${a.title}" deleted`);
|
||||
deleteConfirm = null;
|
||||
await loadAchievements();
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
|
||||
function getItemEmoji(id?: string | null): string {
|
||||
const item = popularItems.find((i) => i.id === id);
|
||||
return item ? item.emoji : '🏆';
|
||||
}
|
||||
|
||||
function getBorderColor(border?: string | null): string {
|
||||
const b = borderGlows.find((bg) => bg.id === border);
|
||||
return b ? b.color : '#eab308';
|
||||
}
|
||||
|
||||
function getBorderGlow(border?: string | null): string {
|
||||
const b = borderGlows.find((bg) => bg.id === border);
|
||||
return b ? b.glow : '0 0 10px rgba(234, 179, 8, 0.6)';
|
||||
}
|
||||
|
||||
function getBgPattern(bg?: string | null): string {
|
||||
const b = bgTextures.find((t) => t.id === bg);
|
||||
return b ? b.pattern : 'radial-gradient(circle, #27272a 0%, #09090b 100%)';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<div>
|
||||
<h1>Minecraft Achievements & Advancements</h1>
|
||||
<p>Create and customize in-game achievements with the Minecraft-themed icon and advancement creator.</p>
|
||||
</div>
|
||||
<button class="primary" onclick={openNew}>
|
||||
<Plus size={16} /> New Achievement
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="creator-spotlight">
|
||||
<div class="spotlight-header">
|
||||
<Trophy size={18} class="text-gold" />
|
||||
<span>Minecraft Advancement Toast Preview</span>
|
||||
</div>
|
||||
<div class="toast-preview-row">
|
||||
<!-- Task Preview -->
|
||||
<div class="mc-toast task">
|
||||
<div class="mc-frame-outer task border-gold">
|
||||
<div class="mc-frame-inner bg-stone">
|
||||
<span class="mc-icon">⛏️</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-toast-body">
|
||||
<div class="mc-toast-title text-task">Advancement Made!</div>
|
||||
<div class="mc-toast-desc">Stone Age</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Goal Preview -->
|
||||
<div class="mc-toast goal">
|
||||
<div class="mc-frame-outer goal border-diamond">
|
||||
<div class="mc-frame-inner bg-deepslate">
|
||||
<span class="mc-icon">✨</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-toast-body">
|
||||
<div class="mc-toast-title text-goal">Goal Reached!</div>
|
||||
<div class="mc-toast-desc">Beaconator</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Challenge Preview -->
|
||||
<div class="mc-toast challenge">
|
||||
<div class="mc-frame-outer challenge border-purple">
|
||||
<div class="mc-frame-inner bg-obsidian">
|
||||
<span class="mc-icon">🪽</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-toast-body">
|
||||
<div class="mc-toast-title text-challenge">Challenge Complete!</div>
|
||||
<div class="mc-toast-desc">Sky's the Limit</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<label for="filter-cat">Category:</label>
|
||||
<select id="filter-cat" bind:value={filterCategory}>
|
||||
<option value="all">All Categories</option>
|
||||
{#each categories as cat}
|
||||
<option value={cat}>{cat.toUpperCase()}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<label for="filter-frame">Frame Style:</label>
|
||||
<select id="filter-frame" bind:value={filterFrame}>
|
||||
<option value="all">All Frames</option>
|
||||
<option value="task">Task (Normal)</option>
|
||||
<option value="goal">Goal (Rounded)</option>
|
||||
<option value="challenge">Challenge (Spiked)</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="count-badge">{filtered.length} Achievements</span>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="empty">Loading achievements...</div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="empty">No achievements match your filters.</div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each filtered as a}
|
||||
<div class="ach-card">
|
||||
<div class="ach-icon-container">
|
||||
<div
|
||||
class="mc-frame-outer {a.frame_type}"
|
||||
style="border-color: {getBorderColor(a.icon_border)}; box-shadow: {getBorderGlow(a.icon_border)};"
|
||||
>
|
||||
<div class="mc-frame-inner" style="background: {getBgPattern(a.icon_bg)};">
|
||||
<span class="mc-icon">{getItemEmoji(a.icon_item)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ach-info">
|
||||
<div class="ach-header">
|
||||
<span class="ach-type {a.frame_type}">{a.frame_type.toUpperCase()}</span>
|
||||
<span class="ach-xp"><Sparkles size={12} /> +{a.xp_reward} XP</span>
|
||||
</div>
|
||||
<h3>{a.title}</h3>
|
||||
<p>{a.description}</p>
|
||||
<div class="ach-meta">
|
||||
<span class="meta-tag">{a.category}</span>
|
||||
{#if a.stat_type}
|
||||
<code class="meta-trigger">{a.stat_type} ≥ {a.target_count}</code>
|
||||
{:else if a.event_type}
|
||||
<code class="meta-trigger">event: {a.event_type}</code>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="ghost icon" title="Edit" onclick={() => openEdit(a)}>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button class="ghost icon danger" title="Delete" onclick={() => { deleteConfirm = a; deleteOpen = true; }}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if draft}
|
||||
<Modal bind:open={draftOpen} title={draft.id && achievements.some((a) => a.id === draft?.id) ? 'Edit Achievement' : 'Achievement Creator'}>
|
||||
<form onsubmit={(e) => { e.preventDefault(); save(); }} class="form">
|
||||
<!-- Live Achievement Preview Box -->
|
||||
<div class="preview-panel">
|
||||
<span class="preview-label">Live In-Game Toast Preview</span>
|
||||
<div class="mc-toast {draft.frame_type}">
|
||||
<div
|
||||
class="mc-frame-outer {draft.frame_type}"
|
||||
style="border-color: {getBorderColor(draft.icon_border)}; box-shadow: {getBorderGlow(draft.icon_border)};"
|
||||
>
|
||||
<div class="mc-frame-inner" style="background: {getBgPattern(draft.icon_bg)};">
|
||||
<span class="mc-icon">{getItemEmoji(draft.icon_item)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-toast-body">
|
||||
<div class="mc-toast-title text-{draft.frame_type}">
|
||||
{frameTypes.find((f) => f.id === draft?.frame_type)?.bannerTitle}
|
||||
</div>
|
||||
<div class="mc-toast-desc">{draft.title || 'Untitled Achievement'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="a-id">Achievement ID</label>
|
||||
<input id="a-id" type="text" bind:value={draft.id} required placeholder="ach_dragon_slayer" />
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="a-title">Title</label>
|
||||
<input id="a-title" type="text" bind:value={draft.title} required placeholder="Free the End" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="a-xp">XP Reward</label>
|
||||
<input id="a-xp" type="number" min="50" step="50" bind:value={draft.xp_reward} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="a-desc">Description</label>
|
||||
<textarea id="a-desc" rows="2" bind:value={draft.description} required placeholder="Kill the Ender Dragon..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Minecraft Icon Styling</div>
|
||||
<div class="grid-3">
|
||||
<div class="field">
|
||||
<label for="a-frame">Frame Type</label>
|
||||
<select id="a-frame" bind:value={draft.frame_type}>
|
||||
{#each frameTypes as ft}
|
||||
<option value={ft.id}>{ft.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="a-item">Icon Item</label>
|
||||
<select id="a-item" bind:value={draft.icon_item}>
|
||||
{#each popularItems as it}
|
||||
<option value={it.id}>{it.emoji} {it.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="a-bg">Background Texture</label>
|
||||
<select id="a-bg" bind:value={draft.icon_bg}>
|
||||
{#each bgTextures as bt}
|
||||
<option value={bt.id}>{bt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="a-border">Glowing Border Accent</label>
|
||||
<select id="a-border" bind:value={draft.icon_border}>
|
||||
{#each borderGlows as bg}
|
||||
<option value={bg.id}>{bg.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="a-cat">Category</label>
|
||||
<select id="a-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="section-title">Trigger Conditions</div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="a-stat">Tracked Stat (Optional)</label>
|
||||
<input id="a-stat" type="text" bind:value={draft.stat_type} placeholder="e.g. mob_kills or blocks_broken" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="a-target">Target Count (Optional)</label>
|
||||
<input id="a-target" type="number" min="1" bind:value={draft.target_count} placeholder="100" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="a-event">Game Event Trigger (Optional)</label>
|
||||
<input id="a-event" type="text" bind:value={draft.event_type} placeholder="e.g. kill_dragon, enter_nether, craft_beacon" />
|
||||
</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 Achievement'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
{#if deleteConfirm}
|
||||
<Modal bind:open={deleteOpen} title="Delete Achievement">
|
||||
<p>Are you sure you want to delete achievement <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 Achievement</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; }
|
||||
|
||||
.creator-spotlight { background: linear-gradient(135deg, rgba(24,24,27,0.8), rgba(9,9,11,0.9)); border: 1px solid var(--line); border-radius: 14px; padding: 20px 24px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.spotlight-header { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 0.95rem; }
|
||||
.text-gold { color: #eab308; }
|
||||
|
||||
.toast-preview-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
|
||||
|
||||
/* Authentic Minecraft Toast Styling */
|
||||
.mc-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
background: #212121;
|
||||
border: 2px solid #3f3f46;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
}
|
||||
.mc-toast.goal { border-color: #0284c7; }
|
||||
.mc-toast.challenge { border-color: #9333ea; }
|
||||
|
||||
.mc-toast-body { display: flex; flex-direction: column; gap: 2px; }
|
||||
.mc-toast-title { font-size: 0.8rem; font-weight: 700; letter-spacing: 0.05em; font-family: monospace, sans-serif; text-transform: uppercase; }
|
||||
.text-task { color: #ffff55; text-shadow: 1px 1px 0 #3f3f00; }
|
||||
.text-goal { color: #55ffff; text-shadow: 1px 1px 0 #003f3f; }
|
||||
.text-challenge { color: #ff55ff; text-shadow: 1px 1px 0 #3f003f; }
|
||||
|
||||
.mc-toast-desc { font-size: 0.9rem; font-weight: 600; color: #ffffff; text-shadow: 1px 1px 0 #181818; }
|
||||
|
||||
/* Minecraft Frame Creator Classes */
|
||||
.mc-frame-outer {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #eab308;
|
||||
background: #18181b;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.mc-frame-outer.task { border-radius: 6px; }
|
||||
.mc-frame-outer.goal { border-radius: 50%; }
|
||||
.mc-frame-outer.challenge {
|
||||
border-radius: 6px;
|
||||
clip-path: polygon(15% 0%, 85% 0%, 100% 15%, 100% 85%, 85% 100%, 15% 100%, 0% 85%, 0% 15%);
|
||||
}
|
||||
|
||||
.mc-frame-inner {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.mc-icon { font-size: 1.3rem; }
|
||||
|
||||
.border-gold { border-color: #eab308 !important; box-shadow: 0 0 10px rgba(234, 179, 8, 0.6) !important; }
|
||||
.border-diamond { border-color: #06b6d4 !important; box-shadow: 0 0 12px rgba(6, 182, 212, 0.8) !important; }
|
||||
.border-purple { border-color: #c084fc !important; box-shadow: 0 0 14px rgba(192, 132, 252, 0.8) !important; }
|
||||
|
||||
.bg-stone { background: radial-gradient(circle, #52525b 0%, #27272a 100%); }
|
||||
.bg-deepslate { background: radial-gradient(circle, #27272a 0%, #09090b 100%); }
|
||||
.bg-obsidian { background: radial-gradient(circle, #2e1065 0%, #0f172a 100%); }
|
||||
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.filters { display: flex; align-items: center; gap: 12px; }
|
||||
.filters label { font-size: 0.82rem; color: var(--muted); }
|
||||
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; }
|
||||
.count-badge { font-size: 0.85rem; color: var(--muted); }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 16px; }
|
||||
.ach-card {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
transition: transform 0.15s, border-color 0.15s;
|
||||
}
|
||||
.ach-card:hover { transform: translateY(-2px); border-color: rgba(255,255,255,0.15); }
|
||||
|
||||
.ach-icon-container { flex-shrink: 0; padding-top: 2px; }
|
||||
|
||||
.ach-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.ach-header { display: flex; align-items: center; gap: 8px; }
|
||||
.ach-type { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.05em; padding: 2px 6px; border-radius: 4px; }
|
||||
.ach-type.task { background: rgba(234, 179, 8, 0.15); color: #eab308; }
|
||||
.ach-type.goal { background: rgba(6, 182, 212, 0.15); color: #06b6d4; }
|
||||
.ach-type.challenge { background: rgba(192, 132, 252, 0.15); color: #c084fc; }
|
||||
|
||||
.ach-xp { display: inline-flex; align-items: center; gap: 4px; font-size: 0.75rem; font-weight: 600; color: #eab308; }
|
||||
.ach-info h3 { font-size: 1rem; font-weight: 600; margin: 2px 0 0; }
|
||||
.ach-info p { margin: 0; color: var(--muted); font-size: 0.84rem; line-height: 1.35; }
|
||||
.ach-meta { display: flex; align-items: center; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
|
||||
.meta-tag { font-size: 0.72rem; padding: 2px 6px; border-radius: 4px; background: rgba(255,255,255,0.06); color: var(--text-2); text-transform: capitalize; }
|
||||
.meta-trigger { font-size: 0.72rem; color: #a1a1aa; background: rgba(255,255,255,0.03); padding: 2px 6px; border-radius: 4px; }
|
||||
|
||||
.card-actions { display: flex; flex-direction: column; gap: 4px; }
|
||||
|
||||
.preview-panel { background: rgba(0,0,0,0.3); border: 1px dashed var(--line); border-radius: 10px; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.preview-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
|
||||
|
||||
.form { display: flex; flex-direction: column; gap: 14px; padding-top: 8px; }
|
||||
.section-title { font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin-top: 6px; border-top: 1px solid var(--line); 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; }
|
||||
.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>
|
||||
@@ -0,0 +1,222 @@
|
||||
<script lang="ts">
|
||||
import { Shield, Users, MapPin, Trash2, Search, ExternalLink, Flag } from '@lucide/svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import { get, del } from '../lib/api';
|
||||
import { toast, toastError } from '../lib/toast.svelte';
|
||||
import type { Guild, Instance } from '../lib/types';
|
||||
|
||||
let guilds = $state<Guild[]>([]);
|
||||
let instances = $state<Instance[]>([]);
|
||||
let loading = $state(true);
|
||||
let search = $state('');
|
||||
let selectedInstance = $state<string>('all');
|
||||
let deleteConfirm = $state<Guild | null>(null);
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
try {
|
||||
const [g, inst] = await Promise.all([
|
||||
get<Guild[]>('/api/admin/guilds'),
|
||||
get<Instance[]>('/api/v1/instances'),
|
||||
]);
|
||||
guilds = g;
|
||||
instances = inst;
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
let filtered = $derived(
|
||||
guilds.filter((g) => {
|
||||
if (selectedInstance !== 'all' && g.instance_id !== selectedInstance) return false;
|
||||
if (search.trim()) {
|
||||
const s = search.toLowerCase();
|
||||
return g.name.toLowerCase().includes(s) || g.tag.toLowerCase().includes(s) || g.id.toLowerCase().includes(s);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
async function remove(g: Guild) {
|
||||
try {
|
||||
await del(`/api/admin/guilds/${g.id}`);
|
||||
toast(`Guild [${g.tag}] ${g.name} deleted`);
|
||||
deleteConfirm = null;
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<div>
|
||||
<h1>Guilds & Land Claims Management</h1>
|
||||
<p>View instances' guilds, member counts, active chunk claims, and moderate guilds.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<span class="label">Total Guilds</span>
|
||||
<span class="value">{guilds.length}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="label">Total Members</span>
|
||||
<span class="value text-accent">{guilds.reduce((acc, g) => acc + g.member_count, 0)}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="label">Claimed Chunks</span>
|
||||
<span class="value text-success">{guilds.reduce((acc, g) => acc + g.claims_count, 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="search-box">
|
||||
<Search size={16} />
|
||||
<input type="text" placeholder="Search guilds by name, tag, or id..." bind:value={search} />
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<select bind:value={selectedInstance}>
|
||||
<option value="all">All Instances</option>
|
||||
{#each instances as inst}
|
||||
<option value={inst.id}>{inst.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="empty">Loading guilds...</div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="empty">No guilds found.</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Guild</th>
|
||||
<th>Tag</th>
|
||||
<th>Instance</th>
|
||||
<th>Leader</th>
|
||||
<th>Members</th>
|
||||
<th>Claimed Chunks</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filtered as g}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="guild-cell">
|
||||
{#if g.icon_url}
|
||||
<img src={g.icon_url} alt="" class="guild-avatar" />
|
||||
{:else}
|
||||
<div class="guild-avatar fallback">
|
||||
<Shield size={18} />
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<strong>{g.name}</strong>
|
||||
{#if g.description}
|
||||
<p class="guild-desc">{g.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="tag-badge">[{g.tag}]</span>
|
||||
</td>
|
||||
<td>
|
||||
<code class="instance-code">{g.instance_id}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code class="uuid-code">{g.leader_uuid.slice(0, 8)}...</code>
|
||||
</td>
|
||||
<td>
|
||||
<span class="count-pill">
|
||||
<Users size={13} /> {g.member_count} / {g.max_members}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="claims-pill">
|
||||
<MapPin size={13} /> {g.claims_count} chunks
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="date-text">{new Date(g.created_at).toLocaleDateString()}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="ghost icon danger" title="Disband guild" onclick={() => { deleteConfirm = g; deleteOpen = true; }}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if deleteConfirm}
|
||||
<Modal bind:open={deleteOpen} title="Disband Guild">
|
||||
<p>Are you sure you want to forcibly disband <strong>[{deleteConfirm.tag}] {deleteConfirm.name}</strong>?</p>
|
||||
<p class="warn-note">This will kick all {deleteConfirm.member_count} members and unclaim all {deleteConfirm.claims_count} chunks of land!</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; } }}>Disband Guild</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page { padding: 32px; max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
|
||||
header 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); }
|
||||
|
||||
.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; }
|
||||
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); }
|
||||
|
||||
.guild-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.guild-avatar { width: 36px; height: 36px; border-radius: 8px; object-fit: cover; }
|
||||
.guild-avatar.fallback { background: rgba(255,255,255,0.06); display: flex; align-items: center; justify-content: center; color: var(--muted); }
|
||||
.guild-desc { margin: 2px 0 0; font-size: 0.78rem; color: var(--muted); }
|
||||
|
||||
.tag-badge { font-family: monospace; font-weight: 700; color: #a5b4fc; background: rgba(99,102,241,0.12); padding: 2px 8px; border-radius: 4px; border: 1px solid rgba(99,102,241,0.25); }
|
||||
.instance-code, .uuid-code { font-size: 0.8rem; color: var(--muted); background: rgba(255,255,255,0.04); padding: 2px 6px; border-radius: 4px; }
|
||||
|
||||
.count-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 0.85rem; color: var(--text-2); }
|
||||
.claims-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 0.85rem; color: #4ade80; }
|
||||
.date-text { font-size: 0.8rem; color: var(--muted); }
|
||||
|
||||
.warn-note { color: #f87171; font-size: 0.88rem; }
|
||||
.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>
|
||||
@@ -0,0 +1,336 @@
|
||||
<script lang="ts">
|
||||
import { Sparkles, Trophy, Plus, Pencil, Trash2, Award, Shield, Crown } 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 { LevelReward, UserLevelInfo } from '../lib/types';
|
||||
|
||||
let rewards = $state<LevelReward[]>([]);
|
||||
let leaderboard = $state<UserLevelInfo[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
let draft = $state<Partial<LevelReward> | null>(null);
|
||||
let draftOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
let deleteConfirm = $state<LevelReward | null>(null);
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
const rewardTypes = [
|
||||
{ id: 'title', label: 'Player Title / Prefix' },
|
||||
{ id: 'profile_badge', label: 'Profile Badge' },
|
||||
{ id: 'cosmetic', label: 'Cosmetic / Particle Effect' },
|
||||
{ id: 'item', label: 'In-Game Kit / Item' },
|
||||
] as const;
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
try {
|
||||
const [r, lb] = await Promise.all([
|
||||
get<LevelReward[]>('/api/v1/levels/rewards'),
|
||||
get<UserLevelInfo[]>('/api/v1/levels/leaderboard?limit=10'),
|
||||
]);
|
||||
rewards = r.sort((a, b) => a.level - b.level);
|
||||
leaderboard = lb;
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
function openNew() {
|
||||
draft = {
|
||||
level: 10,
|
||||
reward_type: 'title',
|
||||
reward_value: 'Explorer',
|
||||
description: 'Grants the exclusive [Explorer] title across all servers and in launcher profile.',
|
||||
icon: '🧭',
|
||||
};
|
||||
draftOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(r: LevelReward) {
|
||||
draft = { ...r };
|
||||
draftOpen = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!draft || !draft.level || !draft.reward_value) {
|
||||
toast('Level and reward value are required', 'error');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
if (draft.id) {
|
||||
await put(`/api/admin/levels/rewards/${draft.id}`, draft);
|
||||
toast('Reward updated');
|
||||
} else {
|
||||
await post('/api/admin/levels/rewards', draft);
|
||||
toast('Reward created');
|
||||
}
|
||||
draftOpen = false;
|
||||
draft = null;
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(r: LevelReward) {
|
||||
try {
|
||||
await del(`/api/admin/levels/rewards/${r.id}`);
|
||||
toast('Reward deleted');
|
||||
deleteConfirm = null;
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
|
||||
function calcXp(lvl: number): number {
|
||||
if (lvl <= 1) return 0;
|
||||
return Math.round(Math.pow(lvl, 1.5) * 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<div>
|
||||
<h1>Global Leveling & Rewards</h1>
|
||||
<p>Configure account-wide global levels, unlockable titles, cosmetics, badges, and view global leaderboards.</p>
|
||||
</div>
|
||||
<button class="primary" onclick={openNew}>
|
||||
<Plus size={16} /> New Level Reward
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="grid-layout">
|
||||
<!-- Rewards Section -->
|
||||
<div class="main-col">
|
||||
<div class="card-header">
|
||||
<h2>Milestone Rewards ({rewards.length})</h2>
|
||||
<span class="sub">Granted automatically as players earn XP across all connected servers</span>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="empty">Loading rewards...</div>
|
||||
{:else}
|
||||
<div class="rewards-list">
|
||||
{#each rewards as r}
|
||||
<div class="reward-row">
|
||||
<div class="level-bubble">
|
||||
<span class="lvl-label">LVL</span>
|
||||
<span class="lvl-num">{r.level}</span>
|
||||
</div>
|
||||
|
||||
<div class="reward-content">
|
||||
<div class="reward-top">
|
||||
<span class="reward-icon">{r.icon || '🎁'}</span>
|
||||
<strong>{r.reward_value}</strong>
|
||||
<span class="type-pill {r.reward_type}">{r.reward_type.replace('_', ' ').toUpperCase()}</span>
|
||||
</div>
|
||||
<p>{r.description}</p>
|
||||
<div class="reward-xp-needed">
|
||||
Requires ~{calcXp(r.level).toLocaleString()} Total XP
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reward-actions">
|
||||
<button class="ghost icon" onclick={() => openEdit(r)} title="Edit"><Pencil size={15} /></button>
|
||||
<button class="ghost icon danger" onclick={() => { deleteConfirm = r; deleteOpen = true; }} title="Delete"><Trash2 size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Info / Leaderboard Preview -->
|
||||
<div class="side-col">
|
||||
<div class="side-card">
|
||||
<h3><Sparkles size={16} class="text-accent" /> Leveling Formula</h3>
|
||||
<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 × 100)</code>
|
||||
<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>
|
||||
<li><span>Level 25:</span> <strong>{calcXp(25).toLocaleString()} XP</strong></li>
|
||||
<li><span>Level 50:</span> <strong>{calcXp(50).toLocaleString()} XP</strong></li>
|
||||
<li><span>Level 100:</span> <strong>{calcXp(100).toLocaleString()} XP</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="side-card">
|
||||
<h3><Crown size={16} class="text-gold" /> Top Global Players</h3>
|
||||
{#if leaderboard.length === 0}
|
||||
<p class="muted-note">No player progression recorded yet.</p>
|
||||
{:else}
|
||||
<div class="lb-list">
|
||||
{#each leaderboard as p, i}
|
||||
<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>
|
||||
</div>
|
||||
<span class="lb-lvl">Lvl {p.level}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if draft}
|
||||
<Modal bind:open={draftOpen} title={draft.id ? 'Edit Level Reward' : 'New Level Reward'}>
|
||||
<form onsubmit={(e) => { e.preventDefault(); save(); }} class="form">
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="r-lvl">Target Global Level</label>
|
||||
<input id="r-lvl" type="number" min="2" max="500" bind:value={draft.level} required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="r-type">Reward Type</label>
|
||||
<select id="r-type" bind:value={draft.reward_type}>
|
||||
{#each rewardTypes as rt}
|
||||
<option value={rt.id}>{rt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="r-val">Reward Value / Name</label>
|
||||
<input id="r-val" type="text" bind:value={draft.reward_value} required placeholder="e.g. Master Builder or Diamond Cape" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="r-icon">Icon (Emoji / Symbol)</label>
|
||||
<input id="r-icon" type="text" bind:value={draft.icon} placeholder="👑" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="r-desc">Description</label>
|
||||
<textarea id="r-desc" rows="3" bind:value={draft.description} required placeholder="Describe what the player unlocks..."></textarea>
|
||||
</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 Reward'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
{#if deleteConfirm}
|
||||
<Modal bind:open={deleteOpen} title="Delete Reward">
|
||||
<p>Are you sure you want to delete the Level {deleteConfirm.level} reward (<strong>{deleteConfirm.reward_value}</strong>)?</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 Reward</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; }
|
||||
|
||||
.grid-layout { display: grid; grid-template-columns: 2fr 1fr; gap: 24px; }
|
||||
@media (max-width: 1000px) {
|
||||
.grid-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.main-col { background: var(--bg-2); border: 1px solid var(--line); border-radius: 14px; padding: 24px; display: flex; flex-direction: column; gap: 18px; }
|
||||
.card-header h2 { font-size: 1.2rem; font-weight: 600; margin: 0 0 4px; }
|
||||
.card-header .sub { font-size: 0.85rem; color: var(--muted); }
|
||||
|
||||
.rewards-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.reward-row {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.reward-row:hover { border-color: rgba(255,255,255,0.15); }
|
||||
|
||||
.level-bubble {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, rgba(99,102,241,0.2), rgba(168,85,247,0.2));
|
||||
border: 1px solid rgba(99,102,241,0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.lvl-label { font-size: 0.65rem; font-weight: 700; letter-spacing: 0.05em; color: #a5b4fc; }
|
||||
.lvl-num { font-size: 1.25rem; font-weight: 800; color: #ffffff; line-height: 1; }
|
||||
|
||||
.reward-content { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.reward-top { display: flex; align-items: center; gap: 10px; }
|
||||
.reward-icon { font-size: 1.2rem; }
|
||||
.reward-top strong { font-size: 1.05rem; }
|
||||
|
||||
.type-pill { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.05em; padding: 2px 7px; border-radius: 5px; }
|
||||
.type-pill.title { background: rgba(234,179,8,0.15); color: #eab308; }
|
||||
.type-pill.profile_badge { background: rgba(168,85,247,0.15); color: #c084fc; }
|
||||
.type-pill.cosmetic { background: rgba(236,72,153,0.15); color: #f472b6; }
|
||||
.type-pill.item { background: rgba(34,197,94,0.15); color: #4ade80; }
|
||||
|
||||
.reward-content p { margin: 0; color: var(--muted); font-size: 0.86rem; }
|
||||
.reward-xp-needed { font-size: 0.75rem; color: #94a3b8; font-family: monospace; }
|
||||
|
||||
.reward-actions { display: flex; align-items: center; gap: 6px; }
|
||||
|
||||
.side-col { display: flex; flex-direction: column; gap: 20px; }
|
||||
.side-card { background: var(--bg-2); border: 1px solid var(--line); border-radius: 14px; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.side-card h3 { display: flex; align-items: center; gap: 8px; font-size: 1rem; font-weight: 600; margin: 0; }
|
||||
.text-accent { color: var(--accent, #6366f1); }
|
||||
.text-gold { color: #eab308; }
|
||||
|
||||
.formula-desc { font-size: 0.85rem; color: var(--muted); margin: 0; }
|
||||
.formula-box { background: rgba(0,0,0,0.3); border: 1px solid var(--line); padding: 8px 12px; border-radius: 8px; font-family: monospace; font-size: 0.85rem; color: #a5b4fc; }
|
||||
|
||||
.curve-points { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.curve-points li { display: flex; justify-content: space-between; font-size: 0.85rem; border-bottom: 1px solid rgba(255,255,255,0.04); padding-bottom: 4px; }
|
||||
.curve-points li span { color: var(--muted); }
|
||||
|
||||
.muted-note { color: var(--muted); font-size: 0.85rem; margin: 0; }
|
||||
.lb-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.lb-item { display: flex; align-items: center; gap: 10px; padding: 8px; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; font-size: 0.85rem; }
|
||||
.lb-rank { font-weight: 700; width: 24px; color: var(--muted); }
|
||||
.lb-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.lb-info strong { font-size: 0.85rem; }
|
||||
.lb-info span { font-size: 0.72rem; color: var(--muted); }
|
||||
.lb-lvl { font-weight: 700; color: #eab308; }
|
||||
|
||||
.form { display: flex; flex-direction: column; gap: 14px; padding-top: 8px; }
|
||||
.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; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
|
||||
.empty { text-align: center; padding: 40px 20px; color: var(--muted); }
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
import {
|
||||
Download, Server, Users, Trophy, Sparkles, HelpCircle, Newspaper,
|
||||
Copy, Check, ExternalLink, ChevronDown, ChevronUp, Swords, Flame,
|
||||
Pickaxe, Clock, Box, Shield, Zap, ArrowRight, LogIn,
|
||||
Pickaxe, Clock, Box, Shield, Zap, ArrowRight, LogIn, Crown,
|
||||
} from '@lucide/svelte';
|
||||
import { get, formatBytes } from '../lib/api';
|
||||
import { session } from '../lib/session.svelte';
|
||||
@@ -11,6 +11,8 @@
|
||||
let config = $state<LandingConfig | null>(null);
|
||||
let publicServers = $state<Array<{ id: number; name: string; online: boolean; players_online: number; players_max: number; software?: string; mc_version?: string }>>([]);
|
||||
let leaderboard = $state<Array<{ uuid: string; name: string; playtime_secs: number; player_kills: number; mob_kills: number; blocks_broken: number; blocks_placed: number }>>([]);
|
||||
let globalLevels = $state<Array<{ rank: number; uuid: string; username: string; global_level: number; global_xp: number; title: string | null }>>([]);
|
||||
let publicGuilds = $state<Array<{ id: string; name: string; tag: string; description: string; member_count: number; claims_count: number; icon_url: string | null }>>([]);
|
||||
let instances = $state<Array<{ id: string; name: string; description: string; mc_version: string; loader: string; file_count: number; logo_url: string | null; icon_url: string | null }>>([]);
|
||||
let news = $state<Array<{ id: string; title: string; body: string; date: string | null; tag: string | null }>>([]);
|
||||
let totalPlaytime = $state(0);
|
||||
@@ -81,6 +83,15 @@
|
||||
news = res.branding?.news || [];
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// 5. Load Global Levels & Guilds
|
||||
get<any[]>('/api/v1/levels/leaderboard')
|
||||
.then((res) => (globalLevels = res || []))
|
||||
.catch(() => {});
|
||||
|
||||
get<any[]>('/api/v1/guilds')
|
||||
.then((res) => (publicGuilds = (res || []).slice(0, 6)))
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -294,6 +305,7 @@
|
||||
<div class="sort-chips-bar">
|
||||
{#each [
|
||||
{ id: 'playtime_secs', label: 'Playtime', icon: Clock },
|
||||
{ id: 'global_level', label: 'Global Level', icon: Crown },
|
||||
{ id: 'player_kills', label: 'Player Kills', icon: Swords },
|
||||
{ id: 'mob_kills', label: 'Mob Kills', icon: Flame },
|
||||
{ id: 'blocks_broken', label: 'Blocks Mined', icon: Pickaxe },
|
||||
@@ -305,7 +317,26 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if leaderboard.length}
|
||||
{#if lbSort === 'global_level' && globalLevels.length}
|
||||
<div class="lb-table-wrap">
|
||||
<div class="lb-table-head">
|
||||
<span>Rank</span>
|
||||
<span>Player</span>
|
||||
<span class="text-right">Global Level</span>
|
||||
<span class="text-right">Total XP</span>
|
||||
<span class="text-right">Title</span>
|
||||
</div>
|
||||
{#each globalLevels.slice(0, 10) as row (row.uuid)}
|
||||
<div class="lb-table-row">
|
||||
<span class="rank-num">#{row.rank}</span>
|
||||
<strong class="player-name">{row.username}</strong>
|
||||
<span class="score-highlight text-right">Lvl {row.global_level}</span>
|
||||
<span class="muted text-right">{row.global_xp.toLocaleString()} XP</span>
|
||||
<span class="muted text-right">{row.title || '—'}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if leaderboard.length}
|
||||
<!-- Podium -->
|
||||
{#if top3.length > 0}
|
||||
<div class="podium-row">
|
||||
@@ -366,6 +397,38 @@
|
||||
<p class="muted">No leaderboard data recorded yet.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if publicGuilds.length > 0}
|
||||
<div class="guilds-showcase" style="margin-top: 48px;">
|
||||
<div class="section-head" style="margin-bottom: 20px;">
|
||||
<h3>Top Guilds & Claimed Territories</h3>
|
||||
<p class="muted">Form parties, recruit allies, and protect your faction's chunks.</p>
|
||||
</div>
|
||||
<div class="guilds-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px;">
|
||||
{#each publicGuilds as g}
|
||||
<div class="guild-card" style="background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 16px; display: flex; align-items: center; gap: 14px;">
|
||||
{#if g.icon_url}
|
||||
<img src={g.icon_url} alt="" style="width: 44px; height: 44px; border-radius: 8px; object-fit: cover;" />
|
||||
{:else}
|
||||
<div style="width: 44px; height: 44px; border-radius: 8px; background: rgba(99,102,241,0.15); display: flex; align-items: center; justify-content: center; color: var(--accent);">
|
||||
<Shield size={22} />
|
||||
</div>
|
||||
{/if}
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="display: flex; align-items: center; gap: 6px;">
|
||||
<strong style="font-size: 0.95rem;">{g.name}</strong>
|
||||
<span style="font-size: 0.72rem; font-family: monospace; font-weight: 700; color: #a5b4fc; background: rgba(99,102,241,0.1); padding: 1px 5px; border-radius: 4px;">[{g.tag}]</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px; font-size: 0.78rem; color: var(--muted); margin-top: 4px;">
|
||||
<span><Users size={12} style="display: inline; vertical-align: -1px;" /> {g.member_count} members</span>
|
||||
<span style="color: #4ade80;"><Box size={12} style="display: inline; vertical-align: -1px;" /> {g.claims_count} chunks</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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