Admin Discord studio: editor, live preview, live embeds
This commit is contained in:
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 (<@&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">
|
||||
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 },
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user