Files
SCOPENET-MC/panel/web/src/components/RewardBundleEditor.svelte
T

117 lines
7.6 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { Coins, Package, KeyRound, Users, Map as MapIcon, Award, MessageSquare, Terminal, Plus, X, Gift } from '@lucide/svelte';
import { get } from '../lib/api';
import type { RewardAction } from '../lib/rewards';
import type { GameServer } from '../lib/types';
// Everything a quest, achievement or rank milestone hands out besides its XP and title.
let { actions = $bindable([]) }: { actions: RewardAction[] } = $props();
let servers = $state<GameServer[]>([]);
$effect(() => { get<GameServer[]>('/api/admin/servers').then((s) => (servers = Array.isArray(s) ? s : [])).catch(() => {}); });
const kinds = [
{ id: 'money', label: 'Money', icon: Coins, blurb: 'Credit the player’s balance' },
{ id: 'item', label: 'Item', icon: Package, blurb: 'Hand over items in game' },
{ id: 'permission', label: 'Permission', icon: KeyRound, blurb: 'Grant an in-game permission (LuckPerms)' },
{ id: 'group', label: 'Group', icon: Users, blurb: 'Add them to a permission group' },
{ id: 'claim_chunks', label: 'Claim chunks', icon: MapIcon, blurb: 'Raise their guild’s land limit' },
{ id: 'badge', label: 'Badge', icon: Award, blurb: 'Show a badge on their profile' },
{ id: 'message', label: 'Message', icon: MessageSquare, blurb: 'Tell them something in game' },
{ id: 'command', label: 'Command', icon: Terminal, blurb: 'Run a console command (the server must allow it)' }
] as const;
const meta = (t: string) => kinds.find((k) => k.id === t)!;
const items = ['diamond', 'emerald', 'netherite_ingot', 'golden_apple', 'enchanted_golden_apple', 'elytra', 'totem_of_undying', 'experience_bottle',
'iron_ingot', 'gold_ingot', 'ender_pearl', 'name_tag', 'diamond_pickaxe', 'diamond_sword', 'shulker_box', 'cooked_beef', 'bread', 'torch'];
let adding = $state(false);
function add(type: RewardAction['type']) {
const base: Record<string, RewardAction> = {
money: { type: 'money', amount: 100 }, item: { type: 'item', item: 'diamond', amount: 1 },
permission: { type: 'permission', node: '', value: true, minutes: 0 }, group: { type: 'group', group: '' },
claim_chunks: { type: 'claim_chunks', amount: 4 }, badge: { type: 'badge', badge: '' },
message: { type: 'message', text: '' }, command: { type: 'command', command: '' }
};
actions = [...actions, base[type]];
adding = false;
}
const remove = (i: number) => (actions = actions.filter((_, n) => n !== i));
const days = (a: RewardAction) => ((a.minutes ?? 0) / 1440);
const setDays = (a: RewardAction, v: string) => { a.minutes = Math.max(0, Math.round((Number(v) || 0) * 1440)); };
</script>
<section class="bundle">
<header>
<span class="head"><Gift size={16} /> Extra rewards <small>{actions.length ? `${actions.length} attached` : 'none yet'}</small></span>
<button type="button" class="ghost sm" onclick={() => (adding = !adding)}><Plus size={14} /> Add reward</button>
</header>
{#if adding}
<div class="menu">
{#each kinds as k}
{@const Icon = k.icon}
<button type="button" class="kind" onclick={() => add(k.id)}><Icon size={16} /><strong>{k.label}</strong><small>{k.blurb}</small></button>
{/each}
</div>
{/if}
{#each actions as a, i}
{@const Icon = meta(a.type).icon}
<div class="row">
<span class="ic"><Icon size={16} /></span>
<div class="fields">
<strong class="kind-name">{meta(a.type).label}</strong>
{#if a.type === 'money'}
<label>Amount<input type="number" min="0.01" step="0.01" bind:value={a.amount} /></label>
{:else if a.type === 'item'}
<label class="wide">Item<input list="reward-items" bind:value={a.item} placeholder="diamond or modid:item" /></label>
<label>How many<input type="number" min="1" max="6400" bind:value={a.amount} /></label>
{:else if a.type === 'permission'}
<label class="wide">Permission node<input bind:value={a.node} placeholder="essentials.fly" /></label>
<label>For (days, 0 = forever)<input type="number" min="0" step="1" value={days(a)} oninput={(e) => setDays(a, e.currentTarget.value)} /></label>
{:else if a.type === 'group'}
<label class="wide">Group<input bind:value={a.group} placeholder="vip" /></label>
{:else if a.type === 'claim_chunks'}
<label>Extra chunks<input type="number" min="1" max="10000" bind:value={a.amount} /></label>
{:else if a.type === 'badge'}
<label class="wide">Badge name<input bind:value={a.badge} maxlength="40" placeholder="Pathfinder" /></label>
{:else if a.type === 'message'}
<label class="wide">Message<input bind:value={a.text} maxlength="200" placeholder="Thanks for playing!" /></label>
{:else if a.type === 'command'}
<label class="wide">Console command <small>({'{player}'} and {'{uuid}'} are filled in)</small><input bind:value={a.command} placeholder="give {'{player}'} minecraft:cake 1" /></label>
{/if}
{#if ['money', 'item', 'permission', 'group', 'command', 'message'].includes(a.type) && servers.length > 1}
<label>Where
<select value={a.server_id ?? ''} onchange={(e) => (a.server_id = e.currentTarget.value ? Number(e.currentTarget.value) : null)}>
<option value="">{a.type === 'money' ? 'Every economy' : 'Wherever they are online'}</option>
{#each servers as s}<option value={s.id}>{s.name}</option>{/each}
</select>
</label>
{/if}
</div>
<button type="button" class="ghost icon" onclick={() => remove(i)} aria-label="Remove reward"><X size={15} /></button>
</div>
{/each}
<datalist id="reward-items">{#each items as it}<option value={it}></option>{/each}</datalist>
{#if !actions.length && !adding}<p class="empty">Give money, items, permissions, claim chunks and more when this is earned.</p>{/if}
</section>
<style>
.bundle { display: flex; flex-direction: column; gap: 0.6rem; padding: 0.9rem; border: 1px solid var(--line, #ffffff1a); border-radius: 14px; background: color-mix(in srgb, var(--accent, #8b6cff) 5%, transparent); }
header { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; }
.head { display: inline-flex; align-items: center; gap: 0.45rem; font-weight: 600; }
.head small { color: var(--muted); font-weight: 400; margin-left: 0.3rem; }
.menu { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 0.5rem; }
.kind { display: grid; grid-template-columns: auto 1fr; gap: 0.1rem 0.6rem; align-items: center; text-align: left; padding: 0.6rem 0.7rem; border-radius: 12px; background: var(--surface, #ffffff0d); border: 1px solid var(--line, #ffffff1a); }
.kind:hover { border-color: var(--accent); }
.kind small { grid-column: 2; color: var(--muted); font-size: 0.72rem; line-height: 1.3; }
.row { display: flex; align-items: flex-start; gap: 0.6rem; padding: 0.65rem; border-radius: 12px; background: var(--surface, #ffffff0d); border: 1px solid var(--line, #ffffff14); }
.ic { display: grid; place-items: center; width: 2rem; height: 2rem; border-radius: 10px; background: color-mix(in srgb, var(--accent, #8b6cff) 20%, transparent); color: var(--accent, #8b6cff); flex-shrink: 0; }
.fields { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.5rem 0.7rem; flex: 1; min-width: 0; }
.kind-name { flex-basis: 100%; font-size: 0.85rem; }
label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.74rem; color: var(--muted); min-width: 7.5rem; }
label.wide { flex: 1; min-width: 12rem; }
.empty { margin: 0; color: var(--muted); font-size: 0.82rem; }
.sm { display: inline-flex; align-items: center; gap: 0.3rem; }
</style>