Admin Discord studio: editor, live preview, live embeds

This commit is contained in:
Claude committed 2026-10-01 01:44:59 +00:00
1 parent f9b0dcedff
commit 2cc009d66f
6 files changed
+363 -1

No files matched your search

+3
View File
@@ -18,6 +18,7 @@
import AchievementEditor from './pages/AchievementEditor.svelte'; import AchievementEditor from './pages/AchievementEditor.svelte';
import LevelingAdmin from './pages/LevelingAdmin.svelte'; import LevelingAdmin from './pages/LevelingAdmin.svelte';
import Progression from './pages/Progression.svelte'; import Progression from './pages/Progression.svelte';
import DiscordStudio from './pages/DiscordStudio.svelte';
import GuildsAdmin from './pages/GuildsAdmin.svelte'; import GuildsAdmin from './pages/GuildsAdmin.svelte';
import { get } from './lib/api'; import { get } from './lib/api';
import { route } from './lib/router.svelte'; import { route } from './lib/router.svelte';
@@ -99,6 +100,8 @@
<Users /> <Users />
{:else if route.name === 'branding'} {:else if route.name === 'branding'}
<Branding onsaved={(b) => (brand = b)} /> <Branding onsaved={(b) => (brand = b)} />
{:else if route.name === 'discord'}
<DiscordStudio />
{:else if route.name === 'landing-builder'} {:else if route.name === 'landing-builder'}
<LandingBuilder /> <LandingBuilder />
{:else if route.name === 'settings'} {:else if route.name === 'settings'}
@@ -0,0 +1,80 @@
<script lang="ts">
import { Plus, X } from '@lucide/svelte';
import ColorInput from './ColorInput.svelte';
import Toggle from './Toggle.svelte';
import type { EmbedStyle } from '../lib/types';
let { style = $bindable(), placeholders = [], toggleLabel = 'Send this message' }: { style: EmbedStyle; placeholders?: string[]; toggleLabel?: string } = $props();
let last: HTMLTextAreaElement | HTMLInputElement | null = null;
const remember = (e: Event) => { last = e.currentTarget as HTMLInputElement; };
// Clicking a placeholder inserts it at the cursor of the field you were typing in.
function insert(name: string) {
const token = `{${name}}`;
if (!last) { style.description = (style.description ?? '') + token; return; }
const el = last, start = el.selectionStart ?? el.value.length, end = el.selectionEnd ?? start;
el.value = el.value.slice(0, start) + token + el.value.slice(end);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.focus();
el.setSelectionRange(start + token.length, start + token.length);
}
const addField = () => (style.fields = [...(style.fields ?? []), { name: '', value: '', inline: true }]);
const removeField = (i: number) => (style.fields = style.fields.filter((_, n) => n !== i));
</script>
<div class="editor">
<Toggle bind:checked={style.enabled} label={toggleLabel} />
{#if placeholders.length}
<div class="ph" aria-label="Placeholders">
<span class="lbl">Placeholders <small>click to insert</small></span>
<div>{#each placeholders as p}<button type="button" class="chip" onclick={() => insert(p)}>{`{${p}}`}</button>{/each}</div>
</div>
{/if}
<div class="grid">
<label class="field wide">Message text <small>above the box, mentions work here (&lt;@&amp;roleid&gt;)</small><input bind:value={style.content} onfocus={remember} placeholder="Optional" /></label>
<label class="field wide">Title<input bind:value={style.title} onfocus={remember} maxlength="256" /></label>
<label class="field wide">Description<textarea rows="4" bind:value={style.description} onfocus={remember} maxlength="4000"></textarea></label>
<ColorInput bind:value={style.color} label="Colour" />
<label class="field">Title link<input bind:value={style.url} onfocus={remember} placeholder="https://…" /></label>
<label class="field">Small picture (right)<input bind:value={style.thumbnail} onfocus={remember} placeholder="https://… or {'{avatar}'}" /></label>
<label class="field">Big picture (bottom)<input bind:value={style.image} onfocus={remember} placeholder="https://…" /></label>
<label class="field">Author name<input bind:value={style.author_name} onfocus={remember} /></label>
<label class="field">Author icon<input bind:value={style.author_icon} onfocus={remember} placeholder="https://…" /></label>
<label class="field">Footer<input bind:value={style.footer} onfocus={remember} /></label>
<label class="field">Footer icon<input bind:value={style.footer_icon} onfocus={remember} placeholder="https://…" /></label>
<label class="field">Sender name<input bind:value={style.username} onfocus={remember} placeholder="SCOPENET" /></label>
<label class="field">Sender picture<input bind:value={style.avatar_url} onfocus={remember} placeholder="https://…" /></label>
</div>
<Toggle bind:checked={style.timestamp} label="Show the time" />
<div class="fields">
<div class="fhead"><span class="lbl">Fields</span><button type="button" class="ghost sm" onclick={addField}><Plus size={14} /> Add field</button></div>
{#each style.fields ?? [] as f, i}
<div class="frow">
<input bind:value={f.name} onfocus={remember} placeholder="Name" />
<input bind:value={f.value} onfocus={remember} placeholder="Value" />
<label class="inl"><input type="checkbox" bind:checked={f.inline} /> Side by side</label>
<button type="button" class="ghost icon" aria-label="Remove field" onclick={() => removeField(i)}><X size={14} /></button>
</div>
{/each}
</div>
</div>
<style>
.editor { display: flex; flex-direction: column; gap: 14px; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.field { display: flex; flex-direction: column; gap: 6px; font-size: 0.85rem; }
.field.wide { grid-column: 1 / -1; }
.field small, .ph small { color: var(--muted); font-weight: 400; }
.ph { display: flex; flex-direction: column; gap: 6px; }
.ph > div { display: flex; flex-wrap: wrap; gap: 6px; }
.lbl { font-size: 0.82rem; font-weight: 600; }
.chip { padding: 3px 9px; border-radius: 999px; font: 500 0.76rem ui-monospace, monospace; background: color-mix(in srgb, var(--accent) 14%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); }
.fhead { display: flex; align-items: center; justify-content: space-between; }
.fields { display: flex; flex-direction: column; gap: 8px; }
.frow { display: grid; grid-template-columns: 1fr 1.6fr auto auto; gap: 8px; align-items: center; }
.inl { display: flex; align-items: center; gap: 6px; font-size: 0.78rem; white-space: nowrap; }
@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } .frow { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,79 @@
<script lang="ts">
// A Discord-style rendering of a webhook message (the exact JSON the panel would send).
let { body }: { body: any } = $props();
const e = $derived(body?.embeds?.[0] ?? null);
const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Discord's light markdown, applied to already-escaped text.
function md(s: string = ''): string {
return esc(s)
.replace(/```([\s\S]*?)```/g, '<pre>$1</pre>')
.replace(/`([^`\n]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<i>$2</i>')
.replace(/__([^_\n]+)__/g, '<u>$1</u>')
.replace(/&lt;t:(\d+):[a-zA-Z]&gt;/g, (_m, t) => `<span class="time">${new Date(Number(t) * 1000).toLocaleString()}</span>`)
.replace(/\n/g, '<br>');
}
const color = $derived(e?.color != null ? '#' + Number(e.color).toString(16).padStart(6, '0') : '#202225');
</script>
<div class="msg">
<div class="avatar">{#if body?.avatar_url}<img src={body.avatar_url} alt="" />{:else}<span>S</span>{/if}</div>
<div class="main">
<div class="who"><strong>{body?.username ?? 'SCOPENET'}</strong><span class="bot">APP</span><span class="when">Today</span></div>
{#if body?.content}<div class="content">{@html md(body.content)}</div>{/if}
{#if e}
<div class="embed" style:border-left-color={color}>
<div class="grid" class:has-thumb={!!e.thumbnail}>
<div class="text">
{#if e.author}<div class="author">{#if e.author.icon_url}<img src={e.author.icon_url} alt="" />{/if}{e.author.name}</div>{/if}
{#if e.title}<div class="title">{e.title}</div>{/if}
{#if e.description}<div class="desc">{@html md(e.description)}</div>{/if}
{#if e.fields?.length}
<div class="fields">
{#each e.fields as f}<div class="field" class:inline={f.inline}><div class="fname">{f.name}</div><div class="fval">{@html md(f.value)}</div></div>{/each}
</div>
{/if}
</div>
{#if e.thumbnail}<img class="thumb" src={e.thumbnail.url} alt="" />{/if}
</div>
{#if e.image}<img class="image" src={e.image.url} alt="" />{/if}
{#if e.footer || e.timestamp}
<div class="footer">{#if e.footer?.icon_url}<img src={e.footer.icon_url} alt="" />{/if}{e.footer?.text ?? ''}{#if e.footer && e.timestamp} • {/if}{#if e.timestamp}{new Date(e.timestamp).toLocaleString()}{/if}</div>
{/if}
</div>
{/if}
</div>
</div>
<style>
.msg { display: flex; gap: 12px; padding: 14px; border-radius: 12px; background: #313338; color: #dbdee1; font-family: 'gg sans', 'Segoe UI', system-ui, sans-serif; font-size: 0.9rem; line-height: 1.4; }
.avatar { width: 40px; height: 40px; border-radius: 50%; background: #5865f2; overflow: hidden; flex-shrink: 0; display: grid; place-items: center; color: #fff; font-weight: 700; }
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.main { flex: 1; min-width: 0; }
.who { display: flex; align-items: center; gap: 6px; }
.who strong { color: #f2f3f5; }
.bot { background: #5865f2; color: #fff; font-size: 0.6rem; font-weight: 700; padding: 1px 5px; border-radius: 4px; }
.when { color: #949ba4; font-size: 0.72rem; }
.content { margin: 2px 0 4px; }
.embed { margin-top: 6px; max-width: 520px; padding: 10px 14px 12px 12px; border-left: 4px solid #202225; border-radius: 4px; background: #2b2d31; }
.grid { display: grid; gap: 12px; grid-template-columns: 1fr; }
.grid.has-thumb { grid-template-columns: 1fr 80px; }
.author { display: flex; align-items: center; gap: 8px; font-size: 0.82rem; font-weight: 600; margin-bottom: 4px; color: #f2f3f5; }
.author img, .footer img { width: 22px; height: 22px; border-radius: 50%; }
.title { font-weight: 700; color: #f2f3f5; font-size: 1rem; margin-bottom: 4px; }
.desc { font-size: 0.88rem; }
.fields { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px 12px; margin-top: 8px; }
.field { grid-column: 1 / -1; }
.field.inline { grid-column: auto; }
.fname { font-weight: 700; font-size: 0.82rem; color: #f2f3f5; margin-bottom: 2px; }
.fval { font-size: 0.85rem; }
.thumb { width: 80px; height: 80px; border-radius: 6px; object-fit: cover; justify-self: end; }
.image { margin-top: 10px; max-width: 100%; border-radius: 6px; display: block; }
.footer { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 0.72rem; color: #b5bac1; }
:global(.msg b) { color: #fff; }
:global(.msg code) { background: #1e1f22; padding: 1px 4px; border-radius: 4px; font-size: 0.82rem; }
:global(.msg pre) { background: #1e1f22; padding: 8px; border-radius: 6px; margin: 4px 0; white-space: pre-wrap; }
:global(.msg .time) { background: #4e5058; border-radius: 3px; padding: 0 4px; }
</style>
+2 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { LayoutDashboard, Boxes, Users, Palette, Settings, LogOut, ExternalLink, Server, Flag, Globe, Target, Trophy, Sparkles, Shield, SlidersHorizontal } from '@lucide/svelte'; import { LayoutDashboard, Boxes, Users, Palette, Settings, LogOut, ExternalLink, Server, Flag, Globe, Target, Trophy, Sparkles, Shield, SlidersHorizontal, MessageSquare } from '@lucide/svelte';
import { route } from '../lib/router.svelte'; import { route } from '../lib/router.svelte';
import { logout, session } from '../lib/session.svelte'; import { logout, session } from '../lib/session.svelte';
import Avatar from './Avatar.svelte'; import Avatar from './Avatar.svelte';
@@ -19,6 +19,7 @@
{ id: 'activity', label: 'Activity', icon: LayoutDashboard }, { id: 'activity', label: 'Activity', icon: LayoutDashboard },
{ id: 'capes', label: 'Capes', icon: Flag }, { id: 'capes', label: 'Capes', icon: Flag },
{ id: 'branding', label: 'Launcher design', icon: Palette }, { id: 'branding', label: 'Launcher design', icon: Palette },
{ id: 'discord', label: 'Discord', icon: MessageSquare },
{ id: 'landing-builder', label: 'Landing page', icon: Globe }, { id: 'landing-builder', label: 'Landing page', icon: Globe },
{ id: 'settings', label: 'Settings', icon: Settings }, { id: 'settings', label: 'Settings', icon: Settings },
]; ];
+10
View File
@@ -412,3 +412,13 @@ export interface PlayerProgress {
level_span: number; level_span: number;
progress_pct: number; progress_pct: number;
} }
export interface EmbedStyle {
enabled: boolean; username: string; avatar_url: string; content: string; title: string; url: string; description: string; color: string;
thumbnail: string; image: string; author_name: string; author_icon: string; footer: string; footer_icon: string; timestamp: boolean;
fields: { name: string; value: string; inline: boolean }[];
}
export interface LiveEmbed {
style: EmbedStyle; webhook_url: string; webhook_set: boolean; interval_secs: number; limit: number; sort: string; server_id: number; row: string; posted: boolean;
}
+189
View File
@@ -0,0 +1,189 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Send, RefreshCw, MessageSquare, Radio, Save, RotateCw } from '@lucide/svelte';
import EmbedEditor from '../components/EmbedEditor.svelte';
import EmbedPreview from '../components/EmbedPreview.svelte';
import { get, post, put } from '../lib/api';
import { toast, toastError } from '../lib/toast.svelte';
import type { EmbedStyle, GameServer, LiveEmbed } from '../lib/types';
type Studio = { templates: Record<string, EmbedStyle>; live: Record<string, LiveEmbed>; placeholders: Record<string, string[]>; live_placeholders: Record<string, string[]>; webhook_set: boolean };
const events = [
{ id: 'achievement', title: 'Achievement unlocked', blurb: 'When a player earns an achievement.' },
{ id: 'member', title: 'New player', blurb: 'When a new player account is approved.' },
{ id: 'guild', title: 'New guild', blurb: 'When a guild is founded.' }
];
const lives = [
{ id: 'status', title: 'Server status', blurb: 'Every server: online, players, TPS and version.' },
{ id: 'guilds', title: 'Active guilds', blurb: 'The top guilds with members, land and level.' },
{ id: 'leaderboard', title: 'Player leaderboard', blurb: 'Top players by playtime, level, kills or blocks.' },
{ id: 'baltop', title: 'Richest players', blurb: 'The top balances on a server economy.' }
];
let data = $state<Studio | null>(null);
let servers = $state<GameServer[]>([]);
let tab = $state<'events' | 'live'>('events');
let current = $state('achievement');
let preview = $state<any>(null);
let busy = $state('');
let timer: ReturnType<typeof setTimeout>;
async function load() {
try {
data = await get<Studio>('/api/admin/discord/studio');
servers = await get<GameServer[]>('/api/admin/servers').catch(() => []);
} catch (e) { toastError(e); }
}
onMount(load);
const isLive = $derived(lives.some((l) => l.id === current));
const style = $derived(data ? (isLive ? data.live[current]?.style : data.templates[current]) : null);
const placeholders = $derived(data ? (isLive ? [...data.live_placeholders[current], ...(data.live_placeholders[current + '_row'] ?? [])] : data.placeholders[current]) : []);
// The preview is built by the panel from real data, so what you see is what Discord gets.
async function refreshPreview() {
if (!data || !style) return;
try {
preview = await post('/api/admin/discord/studio/preview', isLive ? { kind: current, live: data.live[current] } : { kind: current, style: data.templates[current] });
} catch (e: any) { preview = null; }
}
$effect(() => {
void JSON.stringify(data?.templates[current] ?? data?.live[current]);
clearTimeout(timer);
timer = setTimeout(refreshPreview, 450);
return () => clearTimeout(timer);
});
function pick(id: string) { current = id; preview = null; }
function switchTab(t: 'events' | 'live') { tab = t; pick(t === 'events' ? 'achievement' : 'status'); }
async function save() {
if (!data) return;
busy = 'save';
try {
if (isLive) {
const live: Record<string, any> = {};
for (const [k, v] of Object.entries(data.live)) live[k] = { ...v, webhook_url: v.webhook_url };
await put('/api/admin/discord/studio/live', { live });
for (const v of Object.values(data.live)) v.webhook_url = '';
} else {
await put('/api/admin/discord/studio/templates', { templates: data.templates });
}
toast('Saved');
await load();
} catch (e) { toastError(e); } finally { busy = ''; }
}
async function sendNow() {
busy = 'send';
try { await save(); await post(`/api/admin/discord/studio/send/${current}`, {}); toast(isLive ? 'Embed updated in Discord' : 'Sample message sent to Discord'); await load(); }
catch (e) { toastError(e); } finally { busy = ''; }
}
async function repost() {
busy = 'repost';
try { await save(); await post(`/api/admin/discord/studio/repost/${current}`, {}); toast('Posted a new message'); await load(); }
catch (e) { toastError(e); } finally { busy = ''; }
}
</script>
<div class="page wide">
<header>
<div>
<h1>Discord</h1>
<p>Customise the messages the panel posts, and keep live status boards in your channels.{#if data && !data.webhook_set} <strong class="warn" style="margin-left:6px">Add a webhook under Settings → Discord first.</strong>{/if}</p>
</div>
</header>
<div class="tabs" role="tablist">
<button role="tab" aria-selected={tab === 'events'} class:active={tab === 'events'} onclick={() => switchTab('events')}><MessageSquare size={15} /> Announcements</button>
<button role="tab" aria-selected={tab === 'live'} class:active={tab === 'live'} onclick={() => switchTab('live')}><Radio size={15} /> Live embeds</button>
</div>
{#if data && style}
<div class="layout">
<aside class="list card">
{#each tab === 'events' ? events : lives as item}
<button class="item" class:on={current === item.id} onclick={() => pick(item.id)}>
<span class="dot" class:live={(isLive ? data.live[item.id]?.style.enabled : data.templates[item.id]?.enabled)}></span>
<span><strong>{item.title}</strong><small>{item.blurb}</small></span>
</button>
{/each}
</aside>
<section class="card form">
{#if isLive}
{#key current}<EmbedEditor bind:style={data.live[current].style} {placeholders} toggleLabel="Keep this embed updated" />{/key}
{:else}
{#key current}<EmbedEditor bind:style={data.templates[current]} {placeholders} toggleLabel="Post this message" />{/key}
{/if}
{#if isLive}
{@const c = data.live[current]}
<div class="live-opts">
<h3>Live settings</h3>
<div class="grid">
<label class="field wide">Line for each row <small>one line per server, guild or player. Fills {'{rows}'} in the description.</small><input bind:value={c.row} placeholder="Leave empty for the default" /></label>
<label class="field">Update every
<select bind:value={c.interval_secs}>
{#each [[60, '1 minute'], [120, '2 minutes'], [300, '5 minutes'], [900, '15 minutes'], [3600, '1 hour']] as [v, l]}<option value={v}>{l}</option>{/each}
</select>
</label>
{#if current !== 'status'}<label class="field">Rows shown<input type="number" min="1" max="25" bind:value={c.limit} /></label>{/if}
{#if current === 'leaderboard'}
<label class="field">Ranked by<select bind:value={c.sort}><option value="playtime">Playtime</option><option value="level">Level</option><option value="kills">Player kills</option><option value="blocks">Blocks broken</option></select></label>
{/if}
{#if current === 'baltop'}
<label class="field">Economy of<select bind:value={c.server_id}><option value={0}>The first server</option>{#each servers as s}<option value={s.id}>{s.name}</option>{/each}</select></label>
{/if}
<label class="field wide">Own webhook <small>{c.webhook_set ? 'one is saved. Type a new address to replace it, or - to remove it.' : 'optional. Leave empty to post in the announcement channel.'}</small><input bind:value={c.webhook_url} placeholder="https://discord.com/api/webhooks/…" autocomplete="off" /></label>
</div>
<p class="muted small">{c.posted ? 'The embed is posted and is edited in place on every update.' : 'Not posted yet. Save and press Update now.'}</p>
</div>
{/if}
<div class="actions">
<button class="primary" onclick={save} disabled={!!busy}><Save size={15} /> Save</button>
<button onclick={sendNow} disabled={!!busy || !data.webhook_set && !(isLive && data.live[current].webhook_set)}>{#if isLive}<RefreshCw size={15} /> Save &amp; update now{:else}<Send size={15} /> Save &amp; send a sample{/if}</button>
{#if isLive && data.live[current].posted}<button class="ghost" onclick={repost} disabled={!!busy} title="Post a new message instead of editing the old one"><RotateCw size={15} /> Post as new message</button>{/if}
</div>
</section>
<section class="preview">
<h3>Preview</h3>
<p class="muted small">Built from your real data{isLive ? '' : ' (sample names for announcements)'}.</p>
{#if preview}<EmbedPreview body={preview} />{:else}<p class="muted">Loading preview…</p>{/if}
</section>
</div>
{:else}
<p class="muted">Loading…</p>
{/if}
</div>
<style>
.page { display: flex; flex-direction: column; gap: 18px; }
header h1 { margin: 0; }
header p { margin: 4px 0 0; color: var(--muted); }
.warn { color: #fbbf24; }
.tabs { display: inline-flex; gap: 4px; padding: 4px; border-radius: 12px; background: var(--bg-2); border: 1px solid var(--line); align-self: flex-start; }
.tabs button { display: inline-flex; align-items: center; gap: 6px; border: 0; background: transparent; padding: 8px 14px; border-radius: 9px; color: var(--muted); }
.tabs button.active { background: var(--surface-2); color: var(--text); }
.layout { display: grid; grid-template-columns: 240px minmax(0, 1.4fr) minmax(0, 1fr); gap: 16px; align-items: start; }
.list { display: flex; flex-direction: column; gap: 6px; padding: 10px; position: sticky; top: 16px; }
.item { display: flex; align-items: flex-start; gap: 10px; text-align: left; padding: 10px; border-radius: 10px; border: 1px solid transparent; background: transparent; }
.item.on { background: color-mix(in srgb, var(--accent) 14%, transparent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
.item small { display: block; color: var(--muted); font-size: 0.74rem; font-weight: 400; margin-top: 2px; }
.dot { width: 9px; height: 9px; margin-top: 5px; border-radius: 50%; background: var(--muted); opacity: 0.5; flex-shrink: 0; }
.dot.live { background: #34d399; opacity: 1; box-shadow: 0 0 0 3px color-mix(in srgb, #34d399 25%, transparent); }
.form { display: flex; flex-direction: column; gap: 18px; }
.live-opts h3, .preview h3 { margin: 0 0 6px; font-size: 0.95rem; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.field { display: flex; flex-direction: column; gap: 6px; font-size: 0.85rem; }
.field.wide { grid-column: 1 / -1; }
.field small { color: var(--muted); font-weight: 400; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
.actions button { display: inline-flex; align-items: center; gap: 6px; }
.preview { position: sticky; top: 16px; display: flex; flex-direction: column; gap: 8px; }
@media (max-width: 1280px) { .layout { grid-template-columns: 220px minmax(0, 1fr); } .preview { grid-column: 1 / -1; position: static; } }
@media (max-width: 800px) { .layout { grid-template-columns: 1fr; } .list { position: static; flex-direction: row; flex-wrap: wrap; } }
</style>