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

@@ -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">
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 { logout, session } from '../lib/session.svelte';
import Avatar from './Avatar.svelte';
@@ -19,6 +19,7 @@
{ id: 'activity', label: 'Activity', icon: LayoutDashboard },
{ id: 'capes', label: 'Capes', icon: Flag },
{ id: 'branding', label: 'Launcher design', icon: Palette },
{ id: 'discord', label: 'Discord', icon: MessageSquare },
{ id: 'landing-builder', label: 'Landing page', icon: Globe },
{ id: 'settings', label: 'Settings', icon: Settings },
];