Admin Discord studio: editor, live preview, live embeds
This commit is contained in:
6 files changed
+363
-1
No files matched your search
@@ -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 (<@&roleid>)</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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
// 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(/<t:(\d+):[a-zA-Z]>/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>
|
||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 & update now{:else}<Send size={15} /> Save & 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>
|
||||||
Reference in new issue
Block a user