Files
hyprcrd/public/plugins/support/support.js
T
thandClaude Opus 4.7 8cbec8d601 v2.0.13 — edge-to-edge fullscreen overlays, rounded-cards badge variant
- 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>
2026-04-19 12:34:50 +02:00

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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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' });