- Overlays: drop the 24/40/54px inset frame on tasks, calendar,
gallery, search, detail, edit modal, bookmarks, email, gitea and
oumta-dashboard so they fill the viewport like the support
overlay does. Removed the shared border-radius + overflow:hidden
rule on their inner containers (only settings + dialog still use
it) so the card's top colour bar isn't clipped.
- Done badge: the corner-triangle variant gets chopped by rounded
card borders. applyDesign now toggles body.rounded-cards whenever
--ui-radius-card > 0, and the CSS swaps the triangle for a 18px
circle pill with the check centred. Triangle stays for square
cards.
- Support plugin: "Close ticket" button is a regular labelled
button now (was a 30×30 icon ghost that clipped the text). Empty
hero state ("select a ticket") matches the board's empty style —
centred in the column with a pixel-font blink-cursor label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
499 lines
23 KiB
JavaScript
499 lines
23 KiB
JavaScript
// ── Support plugin ──
|
|
//
|
|
// Talks to a self-hosted support-relay (see support-relay/ in the repo).
|
|
// Renders a full-screen overlay with the ticket inbox, the active
|
|
// ticket's thread, a reply composer with KB-fragment search + insert,
|
|
// and a "save as fragment" button. KB fragments are plain hyprcrd cards
|
|
// in the configured KB bucket, searched via the plugin backend.
|
|
|
|
const h = window.hyprcrd;
|
|
const $ = id => document.getElementById(id);
|
|
|
|
// ── State ───────────────────────────────────────────────────────
|
|
|
|
const state = {
|
|
tickets: [],
|
|
filter: '', // '', 'open', 'answered', 'closed'
|
|
activeId: null, // relay_id of the selected ticket
|
|
kbResults: [],
|
|
composerDirty: false,
|
|
};
|
|
|
|
// ── Overlay lifecycle ───────────────────────────────────────────
|
|
|
|
const overlay = document.getElementById('support-overlay');
|
|
function isOpen() { return overlay?.classList.contains('open'); }
|
|
function show() {
|
|
if (!overlay) return;
|
|
overlay.classList.add('open');
|
|
refreshTickets();
|
|
// Hopefully poll right away so the user sees fresh data
|
|
poll();
|
|
}
|
|
function hide() {
|
|
overlay?.classList.remove('open');
|
|
state.composerDirty = false;
|
|
}
|
|
window._supportToggle = () => (isOpen() ? hide() : show());
|
|
|
|
$('support-close')?.addEventListener('click', hide);
|
|
$('support-poll')?.addEventListener('click', () => poll({ manual: true }));
|
|
|
|
// Close with Escape (respects any open child dialogs)
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape' && isOpen()) {
|
|
e.preventDefault();
|
|
hide();
|
|
}
|
|
});
|
|
|
|
// Filter pills
|
|
overlay?.querySelectorAll('.support-pill').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
overlay.querySelectorAll('.support-pill').forEach(b => b.classList.toggle('active', b === btn));
|
|
state.filter = btn.dataset.filter || '';
|
|
renderList();
|
|
});
|
|
});
|
|
|
|
// ── API wrappers ────────────────────────────────────────────────
|
|
|
|
async function api(path, opts = {}) {
|
|
const res = await fetch(path, {
|
|
method: opts.method || 'GET',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': h?.util?.csrf?.() || '' },
|
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
});
|
|
const data = await res.json().catch(() => ({ error: 'bad response' }));
|
|
if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
|
|
return data;
|
|
}
|
|
|
|
async function poll({ manual = false } = {}) {
|
|
try {
|
|
const res = await api('api/support/poll', { method: 'POST' });
|
|
if (manual) toast(`Pulled ${res.pulled || 0} update(s)`);
|
|
await refreshTickets();
|
|
if (state.activeId) await refreshActive();
|
|
} catch (e) {
|
|
if (manual) toast('Poll failed: ' + e.message, 'err');
|
|
}
|
|
}
|
|
|
|
async function refreshTickets() {
|
|
try {
|
|
const res = await api('api/support/tickets');
|
|
state.tickets = res.tickets || [];
|
|
renderList();
|
|
} catch (e) {
|
|
$('support-list').innerHTML = `<div class="support-empty" style="color:var(--t-danger);">Couldn't load: ${escapeHtml(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
async function refreshActive() {
|
|
if (!state.activeId) return;
|
|
try {
|
|
const res = await api('api/support/tickets/' + encodeURIComponent(state.activeId));
|
|
renderDetail(res.ticket);
|
|
} catch (e) {
|
|
$('support-detail').innerHTML = `<div class="support-empty" style="color:var(--t-danger);">${escapeHtml(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
// ── Rendering ───────────────────────────────────────────────────
|
|
|
|
function renderList() {
|
|
const host = $('support-list');
|
|
if (!host) return;
|
|
const shown = state.filter ? state.tickets.filter(t => t.status === state.filter) : state.tickets;
|
|
if (!shown.length) {
|
|
host.innerHTML = `<div class="support-empty">No tickets${state.filter ? ' with status ' + state.filter : ''}.</div>`;
|
|
return;
|
|
}
|
|
host.innerHTML = shown.map(t => `
|
|
<div class="support-ticket ${t.relay_id == state.activeId ? 'active' : ''}" data-id="${t.relay_id}">
|
|
<div class="support-ticket-row">
|
|
<span class="support-ticket-issue">${escapeHtml(t.issue_id)}</span>
|
|
<span class="support-ticket-status" data-status="${escapeHtml(t.status)}">${escapeHtml(t.status)}</span>
|
|
</div>
|
|
<div class="support-ticket-title">${escapeHtml(t.subject || '(no subject)')}</div>
|
|
<div class="support-ticket-meta">
|
|
<span>${escapeHtml(t.email)}</span>
|
|
<span>${escapeHtml(formatRel(t.last_reply_at || t.updated_at))}</span>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
host.querySelectorAll('.support-ticket').forEach(el => {
|
|
el.addEventListener('click', () => openTicket(parseInt(el.dataset.id)));
|
|
});
|
|
}
|
|
|
|
function renderDetail(t) {
|
|
const host = $('support-detail');
|
|
if (!host) return;
|
|
if (!t) {
|
|
host.innerHTML = `<div class="support-empty-hero"><i class="ph ph-envelope-open"></i><p class="cursor-blink">select a ticket</p></div>`;
|
|
return;
|
|
}
|
|
const replies = (t.replies || []).map(r => `
|
|
<div class="support-msg" data-dir="${escapeHtml(r.direction)}">
|
|
<div class="support-msg-label" data-dir="${escapeHtml(r.direction)}">
|
|
<span class="tag">${r.direction === 'out' ? 'sent' : 'received'}</span>
|
|
<span>${escapeHtml(r.created_at)}</span>
|
|
</div>${escapeHtml(r.body)}
|
|
</div>
|
|
`).join('');
|
|
host.innerHTML = `
|
|
<div class="support-ticket-header">
|
|
<h2>${escapeHtml(t.subject || '(no subject)')}</h2>
|
|
<div class="meta">
|
|
<span class="support-ticket-issue">${escapeHtml(t.issue_id)}</span>
|
|
<span class="support-ticket-status" data-status="${escapeHtml(t.status)}">${escapeHtml(t.status)}</span>
|
|
<span class="email">${escapeHtml(t.email)}</span>
|
|
<span>opened ${escapeHtml(formatRel(t.created_at))}</span>
|
|
</div>
|
|
</div>
|
|
<div class="support-thread">
|
|
${replies || '<div class="support-empty" style="padding:12px;">No messages yet.</div>'}
|
|
</div>
|
|
<div class="support-composer">
|
|
<div class="support-composer-head">
|
|
<span class="font-pixel">Reply</span>
|
|
<input class="support-kb-search" placeholder="Search fragments…" id="support-kb-input">
|
|
<button class="support-btn support-btn-ghost" id="support-kb-toggle" title="Toggle fragment list"><i class="ph ph-books"></i></button>
|
|
</div>
|
|
<div id="support-kb-results" class="support-kb-results"></div>
|
|
<textarea id="support-reply-body" placeholder="Type your reply. Click a fragment to insert it at the cursor."></textarea>
|
|
<div class="support-composer-actions">
|
|
<span id="support-composer-status" class="support-composer-status"></span>
|
|
<button class="support-btn" id="support-save-fragment"><i class="ph ph-plus-circle"></i> Save as fragment</button>
|
|
<span class="spacer"></span>
|
|
<button class="support-btn" id="support-status-closed" title="Close without replying"><i class="ph ph-archive"></i> Close ticket</button>
|
|
<button class="support-btn support-btn-primary" id="support-send-btn"><i class="ph ph-paper-plane-tilt"></i> Send reply</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
bindComposer(t);
|
|
}
|
|
|
|
function bindComposer(t) {
|
|
const kbInput = $('support-kb-input');
|
|
const kbResults = $('support-kb-results');
|
|
const kbToggle = $('support-kb-toggle');
|
|
const body = $('support-reply-body');
|
|
const sendBtn = $('support-send-btn');
|
|
const saveFrag = $('support-save-fragment');
|
|
const closeBtn = $('support-status-closed');
|
|
const statusEl = $('support-composer-status');
|
|
|
|
// KB search: debounced
|
|
let kbTimer = null;
|
|
async function runKb(q) {
|
|
try {
|
|
const res = await api('api/support/kb?q=' + encodeURIComponent(q || ''));
|
|
state.kbResults = res.fragments || [];
|
|
kbResults.classList.toggle('open', !!state.kbResults.length);
|
|
kbResults.innerHTML = state.kbResults.map((f, i) => `
|
|
<div class="support-kb-result" data-idx="${i}">
|
|
<div class="support-kb-result-title">${escapeHtml(f.title)}</div>
|
|
<div class="support-kb-result-preview">${escapeHtml(stripMd(f.body).slice(0, 140))}</div>
|
|
</div>
|
|
`).join('') || `<div class="support-empty" style="padding:12px;">No fragments yet.</div>`;
|
|
kbResults.querySelectorAll('.support-kb-result').forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
const frag = state.kbResults[parseInt(el.dataset.idx)];
|
|
insertAtCursor(body, frag.body);
|
|
kbResults.classList.remove('open');
|
|
kbInput.value = '';
|
|
});
|
|
});
|
|
} catch (e) {
|
|
kbResults.innerHTML = `<div class="support-empty" style="padding:12px;color:var(--t-danger);">${escapeHtml(e.message)}</div>`;
|
|
kbResults.classList.add('open');
|
|
}
|
|
}
|
|
kbInput.addEventListener('input', () => {
|
|
clearTimeout(kbTimer);
|
|
kbTimer = setTimeout(() => runKb(kbInput.value.trim()), 120);
|
|
});
|
|
kbInput.addEventListener('focus', () => runKb(kbInput.value.trim()));
|
|
kbToggle.addEventListener('click', () => {
|
|
if (kbResults.classList.contains('open')) kbResults.classList.remove('open');
|
|
else runKb(kbInput.value.trim());
|
|
});
|
|
// Close KB results when clicking outside
|
|
document.addEventListener('click', e => {
|
|
if (!overlay.contains(e.target)) return;
|
|
if (kbResults.contains(e.target) || e.target === kbInput || e.target === kbToggle || kbToggle.contains(e.target)) return;
|
|
kbResults.classList.remove('open');
|
|
});
|
|
|
|
sendBtn.addEventListener('click', async () => {
|
|
const text = body.value.trim();
|
|
if (!text) { setStatus('Nothing to send.', 'err'); return; }
|
|
sendBtn.disabled = true; setStatus('Sending…');
|
|
try {
|
|
await api('api/support/tickets/' + t.relay_id + '/reply', { method: 'POST', body: { body: text } });
|
|
body.value = '';
|
|
setStatus('Sent.', 'ok');
|
|
await refreshTickets();
|
|
await refreshActive();
|
|
} catch (e) {
|
|
setStatus('Failed: ' + e.message, 'err');
|
|
} finally {
|
|
sendBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
saveFrag.addEventListener('click', async () => {
|
|
const text = body.value.trim();
|
|
if (!text) { setStatus('Type something first.', 'err'); return; }
|
|
const title = prompt('Fragment title?', text.split('\n')[0].slice(0, 60));
|
|
if (title === null) return;
|
|
try {
|
|
await api('api/support/kb', { method: 'POST', body: { title: title || '', body: text } });
|
|
setStatus('Saved to fragment library.', 'ok');
|
|
// Refresh KB list if open
|
|
if (kbResults.classList.contains('open')) runKb(kbInput.value.trim());
|
|
} catch (e) {
|
|
setStatus('Save failed: ' + e.message, 'err');
|
|
}
|
|
});
|
|
|
|
closeBtn.addEventListener('click', async () => {
|
|
try {
|
|
await api('api/support/tickets/' + t.relay_id + '/status', { method: 'PATCH', body: { status: 'closed' } });
|
|
await refreshTickets();
|
|
await refreshActive();
|
|
} catch (e) {
|
|
setStatus('Close failed: ' + e.message, 'err');
|
|
}
|
|
});
|
|
|
|
function setStatus(msg, kind = '') {
|
|
statusEl.textContent = msg;
|
|
statusEl.className = 'support-composer-status' + (kind ? ' ' + kind : '');
|
|
if (kind === 'ok') setTimeout(() => { statusEl.textContent = ''; statusEl.className = 'support-composer-status'; }, 3500);
|
|
}
|
|
}
|
|
|
|
async function openTicket(id) {
|
|
state.activeId = id;
|
|
renderList();
|
|
await refreshActive();
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────
|
|
|
|
function escapeHtml(s) {
|
|
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
}
|
|
|
|
/** Strip Markdown markers for preview purposes (headings, emphasis). */
|
|
function stripMd(s) {
|
|
return String(s || '')
|
|
.replace(/^#+\s+/gm, '')
|
|
.replace(/[*_`]/g, '')
|
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
/** Human-friendly timestamp: "just now", "14m ago", "2h ago", "Tue 14:02". */
|
|
function formatRel(iso) {
|
|
if (!iso) return '';
|
|
const d = new Date(iso.replace(' ', 'T') + (iso.includes('T') || iso.endsWith('Z') ? '' : 'Z'));
|
|
if (isNaN(d.getTime())) return iso;
|
|
const now = new Date();
|
|
const diff = (now - d) / 1000;
|
|
if (diff < 60) return 'just now';
|
|
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
|
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
|
if (diff < 86400 * 7) return d.toLocaleDateString(undefined, { weekday: 'short' }) + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
return d.toLocaleDateString();
|
|
}
|
|
|
|
function insertAtCursor(textarea, text) {
|
|
const start = textarea.selectionStart, end = textarea.selectionEnd;
|
|
const before = textarea.value.slice(0, start);
|
|
const after = textarea.value.slice(end);
|
|
const prefix = (before && !before.endsWith('\n')) ? '\n' : '';
|
|
textarea.value = before + prefix + text + after;
|
|
const caret = (before + prefix + text).length;
|
|
textarea.focus();
|
|
textarea.setSelectionRange(caret, caret);
|
|
}
|
|
|
|
function toast(msg, kind = 'info') {
|
|
// Piggy-back on the simple notification system from relay sync
|
|
const el = document.createElement('div');
|
|
el.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid ' + (kind === 'err' ? 'var(--t-danger)' : 'var(--t-accent)') + ';border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;font-family:var(--t-font-pixel);font-size:10px;color:' + (kind === 'err' ? 'var(--t-danger)' : 'var(--t-accent)') + ';';
|
|
el.textContent = msg;
|
|
document.body.appendChild(el);
|
|
setTimeout(() => { el.style.transition = 'opacity 0.3s'; el.style.opacity = '0'; setTimeout(() => el.remove(), 350); }, 3500);
|
|
}
|
|
|
|
// ── Header button ───────────────────────────────────────────────
|
|
|
|
function injectHeaderButton() {
|
|
const tasksBtn = document.getElementById('tasks-btn');
|
|
if (!tasksBtn || document.getElementById('support-btn')) return;
|
|
const btn = document.createElement('button');
|
|
btn.id = 'support-btn';
|
|
btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
|
|
btn.innerHTML = '<i class="ph ph-lifebuoy text-sm"></i><span class="font-pixel text-[10px]">Support</span>';
|
|
btn.addEventListener('click', () => window._supportToggle?.());
|
|
tasksBtn.parentNode.insertBefore(btn, tasksBtn);
|
|
}
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', injectHeaderButton);
|
|
} else {
|
|
injectHeaderButton();
|
|
}
|
|
|
|
// ── Settings tab in the main settings overlay ───────────────────
|
|
|
|
function renderSettingsPanel(panelEl) {
|
|
panelEl.innerHTML = `
|
|
<p class="support-settings-help" style="margin-bottom:14px;">
|
|
Connect hyprcrd to your support relay. The relay hosts the public form and
|
|
sends outgoing replies via SMTP; hyprcrd mirrors tickets and lets you reply
|
|
from any card.
|
|
</p>
|
|
<div class="support-settings-field">
|
|
<label for="support-set-url">Relay URL</label>
|
|
<input id="support-set-url" type="url" placeholder="https://support.example.com">
|
|
</div>
|
|
<div class="support-settings-field">
|
|
<label for="support-set-key">API key</label>
|
|
<input id="support-set-key" type="password" placeholder="paste the key shown by the relay setup" autocomplete="off">
|
|
<div class="support-settings-help">Leave empty to keep the currently stored key.</div>
|
|
</div>
|
|
<div class="support-settings-field">
|
|
<label for="support-set-inbox">Inbox bucket</label>
|
|
<select id="support-set-inbox"><option value="0">(none — keep cards unsorted)</option></select>
|
|
<div class="support-settings-help">Where newly-pulled ticket cards will land.</div>
|
|
</div>
|
|
<div class="support-settings-field">
|
|
<label for="support-set-kb">KB bucket</label>
|
|
<select id="support-set-kb"><option value="0">(none — disable fragment library)</option></select>
|
|
<div class="support-settings-help">Cards in this bucket become reusable reply fragments.</div>
|
|
</div>
|
|
<div class="support-settings-row">
|
|
<button id="support-set-save" class="support-btn"><i class="ph ph-floppy-disk"></i> Save</button>
|
|
<button id="support-set-test" class="support-btn"><i class="ph ph-pulse"></i> Test connection</button>
|
|
<span id="support-set-status" class="support-settings-help"></span>
|
|
</div>
|
|
|
|
<h2 style="margin-top:20px;font-family:var(--t-font-pixel);font-size:12px;color:var(--t-accent);text-transform:uppercase;letter-spacing:0.5px;">Relay config</h2>
|
|
<p class="support-settings-help" style="margin-bottom:14px;">
|
|
Editable post-setup values on the connected relay. Mail footer supports
|
|
<code>{brand}</code>, <code>{issue_id}</code>, <code>{public_url}</code>
|
|
placeholders — lines containing an empty placeholder are dropped.
|
|
</p>
|
|
<div class="support-settings-field">
|
|
<label for="support-rs-brand">Brand</label>
|
|
<input id="support-rs-brand" type="text">
|
|
</div>
|
|
<div class="support-settings-field">
|
|
<label for="support-rs-public">Public form URL</label>
|
|
<input id="support-rs-public" type="url" placeholder="https://support.oumta.de">
|
|
</div>
|
|
<div class="support-settings-field">
|
|
<label for="support-rs-footer">Mail footer template</label>
|
|
<textarea id="support-rs-footer" rows="4" style="background:var(--t-bg);border:1px solid var(--t-border);color:var(--t-bright);padding:8px 10px;border-radius:3px;font-family:var(--t-font-content,monospace);font-size:12px;outline:none;resize:vertical;"></textarea>
|
|
<div class="support-settings-help" id="support-rs-footer-default"></div>
|
|
</div>
|
|
<div class="support-settings-row">
|
|
<button id="support-rs-save" class="support-btn"><i class="ph ph-floppy-disk"></i> Save relay config</button>
|
|
<span id="support-rs-status" class="support-settings-help"></span>
|
|
</div>
|
|
`;
|
|
|
|
// Populate bucket selects from state.buckets
|
|
const opts = (window.state?.buckets || []).map(b => `<option value="${b.id}">${escapeHtml(b.name)}</option>`).join('');
|
|
panelEl.querySelector('#support-set-inbox').innerHTML += opts;
|
|
panelEl.querySelector('#support-set-kb').innerHTML += opts;
|
|
|
|
api('api/support/settings').then(cur => {
|
|
panelEl.querySelector('#support-set-url').value = cur.relay_url || '';
|
|
panelEl.querySelector('#support-set-key').placeholder = cur.has_key ? '•••••••• (saved — leave empty to keep)' : 'paste the key shown by the relay setup';
|
|
if (cur.bucket_inbox) panelEl.querySelector('#support-set-inbox').value = cur.bucket_inbox;
|
|
if (cur.bucket_kb) panelEl.querySelector('#support-set-kb').value = cur.bucket_kb;
|
|
}).catch(() => {});
|
|
|
|
const statusEl = panelEl.querySelector('#support-set-status');
|
|
function setStatus(msg, kind = '') {
|
|
statusEl.textContent = msg;
|
|
statusEl.style.color = kind === 'err' ? 'var(--t-danger)' : kind === 'ok' ? '#4ade80' : 'var(--t-dim)';
|
|
}
|
|
|
|
panelEl.querySelector('#support-set-save').addEventListener('click', async () => {
|
|
const payload = {
|
|
relay_url: panelEl.querySelector('#support-set-url').value.trim(),
|
|
bucket_inbox: parseInt(panelEl.querySelector('#support-set-inbox').value) || 0,
|
|
bucket_kb: parseInt(panelEl.querySelector('#support-set-kb').value) || 0,
|
|
};
|
|
const key = panelEl.querySelector('#support-set-key').value;
|
|
if (key) payload.relay_key = key;
|
|
try {
|
|
await api('api/support/settings', { method: 'POST', body: payload });
|
|
setStatus('Saved.', 'ok');
|
|
} catch (e) { setStatus(e.message, 'err'); }
|
|
});
|
|
|
|
panelEl.querySelector('#support-set-test').addEventListener('click', async () => {
|
|
setStatus('Testing…');
|
|
try {
|
|
const r = await api('api/support/settings/test', { method: 'POST' });
|
|
setStatus('OK — relay says "' + (r.brand || 'support') + '"', 'ok');
|
|
} catch (e) { setStatus(e.message, 'err'); }
|
|
});
|
|
|
|
// Relay config (brand / public url / footer). Loaded after a tick so
|
|
// the local-settings render happens first — keeps this lazy, and
|
|
// silently hides if the relay isn't reachable.
|
|
const rsStatus = panelEl.querySelector('#support-rs-status');
|
|
function setRsStatus(msg, kind = '') {
|
|
rsStatus.textContent = msg;
|
|
rsStatus.style.color = kind === 'err' ? 'var(--t-danger)' : kind === 'ok' ? '#4ade80' : 'var(--t-dim)';
|
|
}
|
|
(async () => {
|
|
try {
|
|
const rs = await api('api/support/relay-settings');
|
|
panelEl.querySelector('#support-rs-brand').value = rs.brand || '';
|
|
panelEl.querySelector('#support-rs-public').value = rs.public_url || '';
|
|
panelEl.querySelector('#support-rs-footer').value = rs.mail_footer || '';
|
|
const def = panelEl.querySelector('#support-rs-footer-default');
|
|
if (def && rs.default_footer) def.textContent = 'Default if left empty: ' + rs.default_footer.replace(/\n/g, ' ⏎ ');
|
|
} catch (e) {
|
|
setRsStatus('(relay unreachable — configure above first)', 'err');
|
|
}
|
|
})();
|
|
panelEl.querySelector('#support-rs-save').addEventListener('click', async () => {
|
|
setRsStatus('Saving…');
|
|
try {
|
|
await api('api/support/relay-settings', { method: 'POST', body: {
|
|
brand: panelEl.querySelector('#support-rs-brand').value.trim(),
|
|
public_url: panelEl.querySelector('#support-rs-public').value.trim(),
|
|
mail_footer: panelEl.querySelector('#support-rs-footer').value,
|
|
}});
|
|
setRsStatus('Saved.', 'ok');
|
|
} catch (e) {
|
|
setRsStatus(e.message, 'err');
|
|
}
|
|
});
|
|
}
|
|
|
|
h?.settings?.registerTab?.({
|
|
id: 'support',
|
|
label: 'support',
|
|
icon: 'ph-lifebuoy',
|
|
onShow: renderSettingsPanel,
|
|
});
|
|
|
|
// Hook the sync tick so each user-triggered sync also pulls support.
|
|
h?.hooks?.on?.('sync.tick', () => { poll().catch(() => {}); });
|
|
|
|
h?.registerPlugin?.({ name: 'support', version: '1.0.0' });
|