202 lines
12 KiB
Svelte
202 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { Mail, Plus, Trash2, Send, Save, Users, Copy } from '@lucide/svelte';
|
|
import Toggle from '../components/Toggle.svelte';
|
|
import Modal from '../components/Modal.svelte';
|
|
import { get, post, put, timeAgo } from '../lib/api';
|
|
import { toast, toastError } from '../lib/toast.svelte';
|
|
|
|
type Template = { id: string; name: string; subject: string; body: string };
|
|
type Data = { configured: boolean; templates: Template[]; placeholders: string[]; groups: { id: number; name: string }[]; log: any[] };
|
|
|
|
let data = $state<Data | null>(null);
|
|
let templates = $state<Template[]>([]);
|
|
let current = $state(0);
|
|
let important = $state(false);
|
|
let preview = $state<{ subject: string; html: string } | null>(null);
|
|
let testTo = $state('');
|
|
let kind = $state<'all' | 'group' | 'players'>('all');
|
|
let groupId = $state(0);
|
|
let names = $state('');
|
|
let count = $state<{ count: number; sample: string[] } | null>(null);
|
|
let busy = $state('');
|
|
let confirmOpen = $state(false);
|
|
let timer: ReturnType<typeof setTimeout>;
|
|
let body = $state<HTMLTextAreaElement>();
|
|
|
|
async function load() {
|
|
try {
|
|
data = await get<Data>('/api/admin/email');
|
|
templates = structuredClone(data.templates);
|
|
if (!groupId && data.groups[0]) groupId = data.groups[0].id;
|
|
} catch (e) { toastError(e); }
|
|
}
|
|
onMount(() => { load(); const i = setInterval(() => get<Data>('/api/admin/email').then((d) => data && (data.log = d.log)).catch(() => {}), 5000); return () => clearInterval(i); });
|
|
|
|
const t = $derived(templates[current]);
|
|
const audience = $derived({ kind, group_id: groupId, names: names.split(/[\n,]+/).map((n) => n.trim()).filter(Boolean) });
|
|
|
|
$effect(() => {
|
|
const subject = t?.subject, text = t?.body, imp = important;
|
|
clearTimeout(timer);
|
|
if (!t) return;
|
|
timer = setTimeout(async () => {
|
|
try { preview = await post('/api/admin/email/preview', { subject, body: text, important: imp }); } catch { preview = null; }
|
|
}, 400);
|
|
return () => clearTimeout(timer);
|
|
});
|
|
$effect(() => {
|
|
const a = audience, imp = important;
|
|
let alive = true;
|
|
post<{ count: number; sample: string[] }>('/api/admin/email/audience', { audience: a, important: imp }).then((r) => alive && (count = r)).catch(() => alive && (count = null));
|
|
return () => { alive = false; };
|
|
});
|
|
|
|
function add() {
|
|
const id = `mail-${Date.now().toString(36)}`;
|
|
templates = [...templates, { id, name: 'New email', subject: 'A message from {brand}', body: 'Hi {player},\n\n' }];
|
|
current = templates.length - 1;
|
|
}
|
|
function duplicate() { templates = [...templates, { ...t, id: `${t.id}-copy-${Date.now().toString(36).slice(-3)}`, name: `${t.name} copy` }]; current = templates.length - 1; }
|
|
function remove() { templates = templates.filter((_, i) => i !== current); current = Math.max(0, current - 1); }
|
|
function insert(p: string) {
|
|
const token = `{${p}}`;
|
|
const s = body?.selectionStart ?? t.body.length, e = body?.selectionEnd ?? s;
|
|
t.body = t.body.slice(0, s) + token + t.body.slice(e);
|
|
queueMicrotask(() => { body?.focus(); body?.setSelectionRange(s + token.length, s + token.length); });
|
|
}
|
|
|
|
async function save() {
|
|
busy = 'save';
|
|
try { await put('/api/admin/email/templates', { templates }); toast('Templates saved'); } catch (e) { toastError(e); } finally { busy = ''; }
|
|
}
|
|
async function sendTest() {
|
|
busy = 'test';
|
|
try { await post('/api/admin/email/send', { subject: t.subject, body: t.body, important, test_to: testTo }); toast(`Test sent to ${testTo}`); } catch (e) { toastError(e); } finally { busy = ''; }
|
|
}
|
|
async function sendAll() {
|
|
busy = 'send'; confirmOpen = false;
|
|
try { const r = await post<{ queued: number }>('/api/admin/email/send', { subject: t.subject, body: t.body, important, audience }); toast(`Sending to ${r.queued} players…`); await load(); } catch (e) { toastError(e); } finally { busy = ''; }
|
|
}
|
|
</script>
|
|
|
|
<div class="page wide">
|
|
<header>
|
|
<div>
|
|
<h1>Emails</h1>
|
|
<p>Write reusable emails and send them to everyone, a group, or chosen players. Messages use your launcher colours.{#if data && !data.configured} <strong class="warn">Set up email under Settings first (sender and Resend key).</strong>{/if}</p>
|
|
</div>
|
|
</header>
|
|
|
|
{#if data && t}
|
|
<div class="layout">
|
|
<aside class="card list">
|
|
{#each templates as tpl, i}
|
|
<button class="item" class:on={i === current} onclick={() => (current = i)}><Mail size={15} /><span><strong>{tpl.name || 'Untitled'}</strong><small>{tpl.subject}</small></span></button>
|
|
{/each}
|
|
<button class="ghost add" onclick={add}><Plus size={15} /> New email</button>
|
|
</aside>
|
|
|
|
<section class="card form">
|
|
<div class="grid">
|
|
<label class="field">Name <small>only you see this</small><input bind:value={t.name} maxlength="80" /></label>
|
|
<label class="field">Subject<input bind:value={t.subject} maxlength="200" /></label>
|
|
</div>
|
|
<div class="ph"><span class="lbl">Placeholders <small>click to insert</small></span>
|
|
<div>{#each data.placeholders as p}<button type="button" class="chip" onclick={() => insert(p)}>{`{${p}}`}</button>{/each}</div></div>
|
|
<label class="field">Message
|
|
<textarea rows="12" bind:value={t.body} bind:this={body} maxlength="20000"></textarea>
|
|
<small># Heading · **bold** · - list item · [link text](https://…) · [button: Label](https://…)</small>
|
|
</label>
|
|
<Toggle bind:checked={important} label="Account notice" help="Also reaches players who unsubscribed, and leaves out the unsubscribe link. Use only for things about their account." />
|
|
<div class="actions">
|
|
<button class="primary" onclick={save} disabled={!!busy}><Save size={15} /> Save templates</button>
|
|
<button class="ghost" onclick={duplicate}><Copy size={15} /> Duplicate</button>
|
|
<button class="ghost danger" onclick={remove} disabled={templates.length < 2}><Trash2 size={15} /> Delete</button>
|
|
</div>
|
|
|
|
<div class="send">
|
|
<h3>Send</h3>
|
|
<div class="testrow">
|
|
<input bind:value={testTo} type="email" placeholder="you@example.com" />
|
|
<button onclick={sendTest} disabled={!testTo || !!busy || !data.configured}><Send size={15} /> Send a test</button>
|
|
</div>
|
|
<div class="aud">
|
|
<label class="field">Send to
|
|
<select bind:value={kind}><option value="all">Everyone with an email</option><option value="group">A group</option><option value="players">Chosen players</option></select>
|
|
</label>
|
|
{#if kind === 'group'}<label class="field">Group<select bind:value={groupId}>{#each data.groups as g}<option value={g.id}>{g.name}</option>{/each}</select></label>{/if}
|
|
{#if kind === 'players'}<label class="field wide">Player names <small>separated by commas or new lines</small><textarea rows="2" bind:value={names}></textarea></label>{/if}
|
|
</div>
|
|
<p class="muted small"><Users size={13} /> {count ? `${count.count} recipient${count.count === 1 ? '' : 's'}${count.sample.length ? ` (${count.sample.join(', ')}${count.count > 5 ? '…' : ''})` : ''}` : '…'}</p>
|
|
<button class="primary" onclick={() => (confirmOpen = true)} disabled={!!busy || !count?.count || !data.configured}><Send size={15} /> Send to {count?.count ?? 0} players</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="preview">
|
|
<h3>Preview</h3>
|
|
{#if preview}
|
|
<div class="subject"><span>Subject</span>{preview.subject}</div>
|
|
<iframe title="Email preview" sandbox="" srcdoc={preview.html}></iframe>
|
|
{:else}<p class="muted">Loading…</p>{/if}
|
|
</section>
|
|
</div>
|
|
|
|
<section class="card history">
|
|
<h3>Sent emails</h3>
|
|
{#if data.log.length}
|
|
<table class="table">
|
|
<thead><tr><th>When</th><th>Subject</th><th>To</th><th>Sent</th><th>Failed</th></tr></thead>
|
|
<tbody>
|
|
{#each data.log as l}
|
|
<tr><td class="muted small">{timeAgo(l.started_at)}</td><td>{l.subject}</td><td>{l.audience}</td><td>{l.sent}/{l.total}{#if !l.finished_at} …{/if}</td><td class:bad={l.failed}>{l.failed}{#if l.last_error} <small class="muted" title={l.last_error}>{l.last_error}</small>{/if}</td></tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
{:else}<p class="muted">Nothing sent yet.</p>{/if}
|
|
</section>
|
|
{:else}<p class="muted">Loading…</p>{/if}
|
|
</div>
|
|
|
|
<Modal bind:open={confirmOpen} title="Send this email?">
|
|
<p>This sends <strong>{t?.subject}</strong> to <strong>{count?.count ?? 0}</strong> players. It can't be undone.</p>
|
|
<div class="actions end"><button class="ghost" onclick={() => (confirmOpen = false)}>Cancel</button><button class="primary" onclick={sendAll}><Send size={15} /> Send now</button></div>
|
|
</Modal>
|
|
|
|
<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; margin-left: 6px; }
|
|
.layout { display: grid; grid-template-columns: 240px minmax(0, 1.3fr) 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: 9px; 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-weight: 400; font-size: 0.74rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 150px; }
|
|
.add { justify-content: flex-start; }
|
|
.form { display: flex; flex-direction: column; gap: 14px; }
|
|
.grid, .aud { 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); }
|
|
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
.actions.end { justify-content: flex-end; margin-top: 14px; }
|
|
.actions button, .send button, .testrow button { display: inline-flex; align-items: center; gap: 6px; }
|
|
.send { display: flex; flex-direction: column; gap: 10px; padding-top: 14px; border-top: 1px solid var(--line); }
|
|
.send h3, .preview h3, .history h3 { margin: 0; font-size: 0.95rem; }
|
|
.testrow { display: flex; gap: 8px; }
|
|
.testrow input { flex: 1; }
|
|
.preview { position: sticky; top: 16px; display: flex; flex-direction: column; gap: 8px; }
|
|
.subject { padding: 8px 12px; border-radius: 10px; background: var(--bg-2); border: 1px solid var(--line); font-weight: 600; }
|
|
.subject span { display: block; font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; font-weight: 500; }
|
|
iframe { width: 100%; height: 520px; border: 1px solid var(--line); border-radius: 12px; background: #0f1220; }
|
|
.history { display: flex; flex-direction: column; gap: 10px; }
|
|
.bad { color: #fb7185; }
|
|
@media (max-width: 1300px) { .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; } .grid, .aud { grid-template-columns: 1fr; } }
|
|
</style>
|