v2.0.12 — support channel: relay + hyprcrd plugin

Central support desk for the Oumta project. Tickets arrive through a
self-hostable support-relay (public form, SMTP out), hyprcrd pulls
them in as cards, replies go back out through the relay, and KB
fragments are plain hyprcrd cards so the board + search + tags
machinery handles them for free.

support-relay/ (standalone PHP/SQLite service)
- Public form + submit endpoint with IP rate-limit, honeypot, and an
  email-matched reply path so users can follow up by typing their
  OUMTA-n issue id — mismatched email falls back to a fresh ticket
  so a hostile submitter can't hijack someone else's thread.
- One-shot setup wizard configures branding, issue-id prefix, public
  URL, mail footer template, SMTP creds (stored AES-256-GCM at rest),
  and generates the X-Support-Key API token shown exactly once.
- Hyprcrd-facing API: GET /api/hyprcrd/tickets?since=<iso>, POST
  …/reply (sends via SMTP, flips status to answered), PATCH
  …/status, DELETE, GET /ping, plus GET/POST /api/hyprcrd/settings
  exposing brand/public_url/mail_footer for post-setup edits.
- Minimal dependency-free SMTP client covering STARTTLS/TLS/none,
  AUTH LOGIN, RFC 2047 subject encoding, dot-stuffing. Extended to
  emit multipart/alternative (plain+HTML) + multipart/related so
  outbound mails carry the branded logo via Content-ID embedding;
  plain-text fallback stays intact for older clients.
- Mail footer template supports {brand}, {issue_id}, {public_url}
  placeholders. Lines with an empty placeholder are dropped, so the
  "Reply via …" line vanishes cleanly when no public URL is set.
- Favicon on all public pages. Logo (SVG by default, PNG preferred
  when both exist) picked up from logo2026col.* at send time.
- Docker: Dockerfile + docker-compose.yml for a stand-alone test
  container, plus a support-relay service in the main compose
  (profile "support") so hyprcrd can reach it at http://support-relay
  without host-networking shenanigans.

public/plugins/support (hyprcrd client)
- Full-screen Support overlay: ticket list (status-filterable),
  active ticket thread, reply composer with KB-fragment search +
  click-to-insert, "Save as fragment" button, "Close ticket" shortcut.
- KB fragments are regular cards in a configurable bucket. The
  composer just adds a fast search-and-insert path on top.
- Sync-tick hook auto-polls the relay alongside the existing CalDAV
  + relay-mail cycle.
- Settings tab in the main overlay: relay URL, API key, inbox / KB
  bucket pickers, connection test, and a "Relay config" subsection
  that round-trips to /api/hyprcrd/settings so the mail footer and
  branding can be edited without touching the relay's DB.
- Local cache tables support_tickets + support_replies mirror the
  relay's authoritative data keyed by relay_id.

Tooling
- dev.sh now opts into both relay + support profiles.
- dev-down.sh wipes support-relay volumes and bind-mount data for a
  clean re-setup. Hyprcrd + relay data is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-19 10:41:19 +02:00
1 parent 2d58cd993e
commit 7bce6a3a36
33 files changed
+2915 -10

No files matched your search

+398
View File
@@ -0,0 +1,398 @@
<?php
/**
* Support Plugin: backend (hyprcrd side).
*
* Talks to a standalone support-relay via HTTP. Tickets live on the
* relay; this plugin keeps a local cache + links each ticket to a
* hyprcrd card so the user can answer from the card detail view.
* KB fragments stay purely in hyprcrd (cards in a configured bucket).
*
* Routes (auth-protected, registered via the plugin dispatch):
* GET /api/support/tickets list from local cache
* GET /api/support/tickets/{id} single ticket w/ replies
* POST /api/support/tickets/{id}/reply forward reply to the relay
* PATCH /api/support/tickets/{id}/status forward status change
* POST /api/support/poll pull tickets from the relay
* GET /api/support/kb?q=... search KB fragments (local cards)
* POST /api/support/kb save a new KB fragment
* GET /api/support/settings read plugin config (no secret)
* POST /api/support/settings write plugin config
* POST /api/support/settings/test ping the configured relay
*/
// ── Schema ────────────────────────────────────────────────────────
(function () {
$db = Database::connect();
// Local cache of tickets that live on the relay. We map relay_id
// (authoritative) to hyprcrd card_id so the card detail view can
// surface the ticket + its reply history.
$db->exec('
CREATE TABLE IF NOT EXISTS support_tickets (
relay_id INTEGER PRIMARY KEY,
card_id INTEGER,
issue_id TEXT NOT NULL,
email TEXT NOT NULL,
subject TEXT NOT NULL DEFAULT "",
status TEXT NOT NULL DEFAULT "open",
message_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_reply_at TEXT,
polled_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
');
$db->exec('CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status, updated_at DESC)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_support_tickets_card ON support_tickets(card_id)');
$db->exec('
CREATE TABLE IF NOT EXISTS support_replies (
relay_id INTEGER PRIMARY KEY,
ticket_id INTEGER NOT NULL,
direction TEXT NOT NULL,
body TEXT NOT NULL,
sent_at TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (ticket_id) REFERENCES support_tickets(relay_id) ON DELETE CASCADE
)
');
$db->exec('CREATE INDEX IF NOT EXISTS idx_support_replies_ticket ON support_replies(ticket_id, created_at)');
})();
// ── Encryption (API key at rest) ──────────────────────────────────
function supportEncKey(): string {
$db = Database::connect();
$vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
if ($vk) return substr(hash('sha256', $vk), 0, 32);
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
}
function supportEncrypt(string $plain): string {
$iv = random_bytes(16);
$cipher = openssl_encrypt($plain, 'aes-256-cbc', supportEncKey(), OPENSSL_RAW_DATA, $iv);
return base64_encode($iv . $cipher);
}
function supportDecrypt(string $encoded): string {
$raw = base64_decode($encoded);
if (!$raw || strlen($raw) < 17) return '';
return openssl_decrypt(substr($raw, 16), 'aes-256-cbc', supportEncKey(), OPENSSL_RAW_DATA, substr($raw, 0, 16)) ?: '';
}
function supportSetting(string $key, ?string $default = null): ?string {
$db = Database::connect();
$stmt = $db->prepare('SELECT value FROM settings WHERE key = :k');
$stmt->execute(['k' => 'support_' . $key]);
$v = $stmt->fetchColumn();
return $v === false ? $default : $v;
}
function supportSetSetting(string $key, ?string $value): void {
$db = Database::connect();
if ($value === null) {
$db->prepare('DELETE FROM settings WHERE key = :k')->execute(['k' => 'support_' . $key]);
} else {
$db->prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (:k, :v)')
->execute(['k' => 'support_' . $key, 'v' => $value]);
}
}
function supportRelayUrl(): string {
return rtrim((string)supportSetting('relay_url', ''), '/');
}
function supportRelayKey(): string {
$enc = supportSetting('relay_key_enc', '');
return $enc ? supportDecrypt($enc) : '';
}
function supportJson($data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
/** cURL helper for the support-relay. Short timeouts — this runs inline
* from user-triggered flows. Responses are returned as the decoded body
* plus the HTTP status so callers can surface errors. */
function supportCurl(string $path, string $method = 'GET', ?array $body = null): array {
$url = supportRelayUrl() . $path;
$key = supportRelayKey();
if ($url === '' || $key === '') return ['status' => 0, 'error' => 'support relay not configured', 'body' => null];
$ch = curl_init($url);
$headers = ['X-Support-Key: ' . $key, 'Accept: application/json'];
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 10,
CURLOPT_CUSTOMREQUEST => $method,
];
if ($body !== null) {
$opts[CURLOPT_POSTFIELDS] = json_encode($body);
$opts[CURLOPT_HTTPHEADER][] = 'Content-Type: application/json';
}
curl_setopt_array($ch, $opts);
if (function_exists('hyprcrdApplySafeCurlOpts')) hyprcrdApplySafeCurlOpts($ch);
$resp = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($resp === false) return ['status' => 0, 'error' => $err ?: 'request failed', 'body' => null];
$decoded = json_decode($resp, true);
return ['status' => $status, 'body' => $decoded, 'raw' => $resp, 'error' => null];
}
/** Apply a relay-side ticket (+ replies) into the local cache, creating
* or updating the linked hyprcrd card. */
function supportApplyTicket(array $t): void {
$db = Database::connect();
$relayId = (int)$t['id'];
$issueId = (string)($t['issue_id'] ?? '');
$email = strtolower((string)($t['email'] ?? ''));
$subject = (string)($t['subject'] ?? '');
$status = (string)($t['status'] ?? 'open');
$body = (string)($t['body'] ?? '');
// Look up existing mapping
$ex = $db->prepare('SELECT card_id FROM support_tickets WHERE relay_id = :r');
$ex->execute(['r' => $relayId]);
$cardId = (int)($ex->fetchColumn() ?: 0);
// Compose the card body from the ticket + reply log (relay is
// authoritative on replies too).
$replies = $t['replies'] ?? [];
$rendered = "**From:** " . $email . "\n"
. "**Issue:** " . $issueId . "\n"
. "**Status:** " . $status . "\n\n---\n\n"
. $body;
foreach ($replies as $r) {
if (($r['direction'] ?? '') === 'in' && ($r['body'] ?? '') === $body) continue; // skip initial dupe
$label = ($r['direction'] ?? '') === 'out' ? 'Reply sent' : 'User reply';
$when = (string)($r['created_at'] ?? '');
$rendered .= "\n\n---\n\n**{$label} — {$when}:**\n\n" . ($r['body'] ?? '');
}
$title = $subject !== '' ? $subject : '(no subject)';
$tagSet = 'support,' . $status;
$buckets = ['inbox' => (int)(supportSetting('bucket_inbox', '0') ?: 0)];
$bucketId = $buckets['inbox'] ?: null;
if ($cardId) {
$db->prepare('UPDATE cards SET title = :t, body = :b, tags = :tg, updated_at = datetime("now") WHERE id = :id')
->execute(['t' => $title, 'b' => $rendered, 'tg' => $tagSet, 'id' => $cardId]);
} else {
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, position, tags) VALUES (:t, :b, :c, :bi, -1, :tg)')
->execute(['t' => $title, 'b' => $rendered, 'c' => '#38bdf8', 'bi' => $bucketId, 'tg' => $tagSet]);
$cardId = (int)$db->lastInsertId();
}
$db->prepare('
INSERT INTO support_tickets (relay_id, card_id, issue_id, email, subject, status, message_id, created_at, updated_at, last_reply_at, polled_at)
VALUES (:r, :c, :ii, :e, :s, :st, :mid, :ca, :ua, :lr, datetime("now"))
ON CONFLICT(relay_id) DO UPDATE SET
card_id = excluded.card_id,
issue_id = excluded.issue_id,
email = excluded.email,
subject = excluded.subject,
status = excluded.status,
message_id = excluded.message_id,
updated_at = excluded.updated_at,
last_reply_at = excluded.last_reply_at,
polled_at = datetime("now")
')->execute([
'r' => $relayId,
'c' => $cardId,
'ii' => $issueId,
'e' => $email,
's' => $subject,
'st' => $status,
'mid' => $t['message_id'] ?? null,
'ca' => (string)($t['created_at'] ?? date('Y-m-d H:i:s')),
'ua' => (string)($t['updated_at'] ?? date('Y-m-d H:i:s')),
'lr' => $t['last_reply_at'] ?? null,
]);
// Replies: straight upsert by relay reply id. Lets the local card
// body stay in sync no matter which side added the reply.
$db->prepare('DELETE FROM support_replies WHERE ticket_id = :t')->execute(['t' => $relayId]);
$ins = $db->prepare('INSERT INTO support_replies (relay_id, ticket_id, direction, body, sent_at, created_at) VALUES (:r, :t, :d, :b, :sa, :ca)');
foreach ($replies as $r) {
$ins->execute([
'r' => (int)($r['id'] ?? 0) ?: null,
't' => $relayId,
'd' => (string)($r['direction'] ?? 'in'),
'b' => (string)($r['body'] ?? ''),
'sa' => $r['sent_at'] ?? null,
'ca' => (string)($r['created_at'] ?? date('Y-m-d H:i:s')),
]);
}
}
// ── Routes ────────────────────────────────────────────────────────
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
$db = Database::connect();
if ($route === '/api/support/tickets' && $method === 'GET') {
$status = trim((string)($_GET['status'] ?? ''));
$sql = 'SELECT t.*, c.title AS card_title FROM support_tickets t LEFT JOIN cards c ON c.id = t.card_id';
$params = [];
if ($status !== '') { $sql .= ' WHERE t.status = :s'; $params['s'] = $status; }
$sql .= ' ORDER BY COALESCE(t.last_reply_at, t.updated_at) DESC LIMIT 500';
$stmt = $db->prepare($sql);
$stmt->execute($params);
supportJson(['tickets' => $stmt->fetchAll()]);
}
if (preg_match('#^/api/support/tickets/(\d+)$#', $route, $m) && $method === 'GET') {
$relayId = (int)$m[1];
$stmt = $db->prepare('SELECT t.*, c.title AS card_title, c.body AS card_body FROM support_tickets t LEFT JOIN cards c ON c.id = t.card_id WHERE t.relay_id = :r');
$stmt->execute(['r' => $relayId]);
$t = $stmt->fetch();
if (!$t) supportJson(['error' => 'not found'], 404);
$reps = $db->prepare('SELECT direction, body, sent_at, created_at FROM support_replies WHERE ticket_id = :t ORDER BY created_at ASC');
$reps->execute(['t' => $relayId]);
$t['replies'] = $reps->fetchAll();
supportJson(['ticket' => $t]);
}
if (preg_match('#^/api/support/tickets/(\d+)/reply$#', $route, $m) && $method === 'POST') {
$relayId = (int)$m[1];
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$body = trim((string)($input['body'] ?? ''));
if ($body === '') supportJson(['error' => 'body required'], 400);
$res = supportCurl('/api/hyprcrd/tickets/' . $relayId . '/reply', 'POST', ['body' => $body]);
if ($res['status'] !== 200) {
supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'relay error') . ' (HTTP ' . $res['status'] . ')'], 502);
}
// Re-fetch the updated ticket so the local cache immediately reflects
// the new reply + answered status.
$fresh = supportCurl('/api/hyprcrd/tickets?since=', 'GET');
if ($fresh['status'] === 200 && is_array($fresh['body']['tickets'] ?? null)) {
foreach ($fresh['body']['tickets'] as $t) {
if ((int)$t['id'] === $relayId) { supportApplyTicket($t); break; }
}
}
supportJson(['ok' => true]);
}
if (preg_match('#^/api/support/tickets/(\d+)/status$#', $route, $m) && $method === 'PATCH') {
$relayId = (int)$m[1];
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$status = trim((string)($input['status'] ?? ''));
if (!in_array($status, ['open', 'answered', 'closed'], true)) supportJson(['error' => 'invalid status'], 400);
$res = supportCurl('/api/hyprcrd/tickets/' . $relayId . '/status', 'PATCH', ['status' => $status]);
if ($res['status'] !== 200) supportJson(['error' => 'relay error ' . $res['status']], 502);
$db->prepare('UPDATE support_tickets SET status = :s WHERE relay_id = :r')->execute(['s' => $status, 'r' => $relayId]);
// Sync the card's support tag set
$db->prepare("
UPDATE cards SET tags = (
SELECT 'support,' || :s FROM support_tickets WHERE card_id = cards.id
) WHERE id = (SELECT card_id FROM support_tickets WHERE relay_id = :r)
")->execute(['s' => $status, 'r' => $relayId]);
supportJson(['ok' => true]);
}
if ($route === '/api/support/poll' && $method === 'POST') {
$last = supportSetting('last_polled_at', '') ?: '';
$qs = $last !== '' ? '?since=' . urlencode($last) : '';
$res = supportCurl('/api/hyprcrd/tickets' . $qs, 'GET');
if ($res['status'] !== 200) supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'relay error'), 'status' => $res['status']], 502);
$tickets = $res['body']['tickets'] ?? [];
$maxUpd = $last;
foreach ($tickets as $t) {
supportApplyTicket($t);
if (!empty($t['updated_at']) && $t['updated_at'] > $maxUpd) $maxUpd = $t['updated_at'];
}
if ($maxUpd !== $last) supportSetSetting('last_polled_at', $maxUpd);
supportJson(['ok' => true, 'pulled' => count($tickets)]);
}
// KB search (local cards in the configured KB bucket)
if ($route === '/api/support/kb' && $method === 'GET') {
$q = trim((string)($_GET['q'] ?? ''));
$bucket = (int)(supportSetting('bucket_kb', '0') ?: 0);
if (!$bucket) supportJson(['fragments' => []]);
$sql = 'SELECT id, title, body, tags, updated_at FROM cards WHERE bucket_id = :b';
$params = ['b' => $bucket];
if ($q !== '') {
$sql .= ' AND (title LIKE :q OR body LIKE :q2 OR tags LIKE :q3)';
$like = '%' . $q . '%';
$params['q'] = $like; $params['q2'] = $like; $params['q3'] = $like;
}
$sql .= ' ORDER BY updated_at DESC LIMIT 50';
$stmt = $db->prepare($sql);
$stmt->execute($params);
supportJson(['fragments' => $stmt->fetchAll()]);
}
if ($route === '/api/support/kb' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$title = trim((string)($input['title'] ?? ''));
$body = trim((string)($input['body'] ?? ''));
$tags = trim((string)($input['tags'] ?? 'faq'));
if ($body === '') supportJson(['error' => 'body required'], 400);
if ($title === '') $title = mb_substr(str_replace("\n", ' ', $body), 0, 50);
$bucket = (int)(supportSetting('bucket_kb', '0') ?: 0);
if (!$bucket) supportJson(['error' => 'KB bucket not configured'], 400);
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, position, tags) VALUES (:t, :b, :c, :bi, -1, :tg)')
->execute(['t' => $title, 'b' => $body, 'c' => '#4ade80', 'bi' => $bucket, 'tg' => $tags]);
supportJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
}
if ($route === '/api/support/settings' && $method === 'GET') {
supportJson([
'relay_url' => supportRelayUrl(),
'has_key' => supportRelayKey() !== '',
'bucket_inbox' => (int)(supportSetting('bucket_inbox', '0') ?: 0),
'bucket_kb' => (int)(supportSetting('bucket_kb', '0') ?: 0),
'last_polled_at' => supportSetting('last_polled_at', ''),
]);
}
if ($route === '/api/support/settings' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
if (array_key_exists('relay_url', $input)) supportSetSetting('relay_url', rtrim((string)$input['relay_url'], '/'));
if (array_key_exists('relay_key', $input)) {
$k = (string)$input['relay_key'];
if ($k === '') supportSetSetting('relay_key_enc', null);
else supportSetSetting('relay_key_enc', supportEncrypt($k));
}
if (array_key_exists('bucket_inbox', $input)) supportSetSetting('bucket_inbox', (int)$input['bucket_inbox'] ?: null);
if (array_key_exists('bucket_kb', $input)) supportSetSetting('bucket_kb', (int)$input['bucket_kb'] ?: null);
supportJson(['ok' => true]);
}
if ($route === '/api/support/settings/test' && $method === 'POST') {
$res = supportCurl('/api/hyprcrd/ping', 'GET');
if ($res['status'] !== 200) supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'ping failed'), 'status' => $res['status']], 502);
supportJson(['ok' => true, 'brand' => $res['body']['brand'] ?? '']);
}
// Proxy to the relay's editable-settings endpoint. The plugin UI
// uses this to let the admin tweak the mail footer / public URL /
// brand post-setup without touching the relay's data directly.
if ($route === '/api/support/relay-settings' && $method === 'GET') {
$res = supportCurl('/api/hyprcrd/settings', 'GET');
if ($res['status'] !== 200) supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'relay unreachable'), 'status' => $res['status']], 502);
supportJson($res['body']);
}
if ($route === '/api/support/relay-settings' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$res = supportCurl('/api/hyprcrd/settings', 'POST', $input);
if ($res['status'] !== 200) supportJson(['error' => ($res['body']['error'] ?? $res['error'] ?? 'relay update failed'), 'status' => $res['status']], 502);
supportJson(['ok' => true]);
}
});
// Hook into the central sync.tick so support polls happen alongside the
// CalDAV + relay-mail cycle. Runs server-side triggered from the client
// — no separate cron needed. If the relay isn't configured the call
// silently no-ops.
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
// Tie the poll to the main relay poll: when hyprcrd fires /api/relay/poll
// the client also fires /api/support/poll. No server-side hook runs
// here, we just expose the endpoint. See support.js.
});
+11
View File
@@ -0,0 +1,11 @@
{
"name": "support",
"version": "1.0.0",
"description": "Central support channel: public form for incoming tickets (OUMTA-nnn issue IDs, email-matched replies), plugin inbox with a reply composer, KB-fragment library. Outbound replies via configured SMTP.",
"frontend": {
"js": ["support.js"],
"css": ["support.css"],
"html": ["support.html"]
},
"backend": "backend.php"
}
+265
View File
@@ -0,0 +1,265 @@
/* Support overlay. Full-screen like chat / email / canvas. Dark chrome,
* two-column layout on desktop, stacked on narrow. Inbox left, ticket
* detail + reply composer + KB picker on the right. */
.support-overlay {
position: fixed;
inset: 0;
z-index: 90;
background: var(--t-bg);
display: none;
flex-direction: column;
}
.support-overlay.open { display: flex; }
.support-header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 10px 16px;
border-bottom: 1px solid var(--t-border);
background: var(--t-surface);
}
.support-header-left, .support-header-right { display: flex; align-items: center; gap: 10px; }
.support-header i { font-size: 16px; color: var(--t-accent); }
.support-btn {
background: transparent;
border: 1px solid var(--t-border);
color: var(--t-muted);
cursor: pointer;
border-radius: 3px;
padding: 6px 10px;
font-size: 12px;
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
transition: all 0.12s;
}
.support-btn:hover { border-color: var(--t-accent); color: var(--t-accent); }
.support-btn-primary {
background: var(--t-accent);
color: var(--t-bg);
border-color: var(--t-accent);
}
.support-btn-primary:hover { filter: brightness(1.08); color: var(--t-bg); }
.support-btn-danger:hover { border-color: var(--t-danger); color: var(--t-danger); }
.support-btn-ghost {
width: 30px; height: 30px; padding: 0;
display: inline-flex; align-items: center; justify-content: center;
font-size: 14px;
}
.support-filter { display: flex; gap: 4px; }
.support-pill {
background: transparent;
border: 1px solid var(--t-border);
color: var(--t-muted);
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 4px 10px;
cursor: pointer;
border-radius: 3px;
transition: all 0.12s;
}
.support-pill:hover { color: var(--t-bright); }
.support-pill.active { background: var(--t-accent); border-color: var(--t-accent); color: var(--t-bg); }
.support-body {
flex: 1;
display: grid;
grid-template-columns: 320px 1fr;
min-height: 0;
}
@media (max-width: 800px) {
.support-body { grid-template-columns: 1fr; }
.support-list { max-height: 40vh; }
.support-list.hidden-mobile { display: none; }
}
.support-list {
border-right: 1px solid var(--t-border);
overflow-y: auto;
background: var(--t-bg);
}
.support-ticket {
padding: 12px 14px;
border-bottom: 1px solid var(--t-border);
cursor: pointer;
transition: background 0.1s;
display: flex;
flex-direction: column;
gap: 4px;
}
.support-ticket:hover { background: var(--t-surface); }
.support-ticket.active { background: color-mix(in srgb, var(--t-accent) 12%, transparent); border-left: 3px solid var(--t-accent); padding-left: 11px; }
.support-ticket-row { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.support-ticket-issue {
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
font-size: 9px;
color: var(--t-accent);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.support-ticket-status {
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
font-size: 8px;
padding: 1px 5px;
border-radius: 2px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.support-ticket-status[data-status="open"] { background: rgba(251, 146, 60, 0.2); color: #fb923c; }
.support-ticket-status[data-status="answered"] { background: rgba(74, 222, 128, 0.15); color: #4ade80; }
.support-ticket-status[data-status="closed"] { background: rgba(100, 116, 139, 0.2); color: var(--t-dim); }
.support-ticket-title { color: var(--t-bright); font-size: 13px; line-height: 1.3; }
.support-ticket-meta { color: var(--t-dim); font-size: 11px; display: flex; gap: 8px; }
.support-detail {
overflow-y: auto;
padding: 20px 24px 40px;
display: flex;
flex-direction: column;
gap: 18px;
}
.support-empty, .support-empty-hero {
color: var(--t-dim);
text-align: center;
padding: 40px 20px;
font-size: 13px;
}
.support-empty-hero i { font-size: 48px; display: block; margin: 0 auto 12px; color: var(--t-muted); }
.support-ticket-header h2 { font-family: var(--t-font-pixel); color: var(--t-bright); font-size: 14px; margin: 0 0 4px; }
.support-ticket-header .meta { color: var(--t-dim); font-size: 11px; display: flex; gap: 12px; flex-wrap: wrap; }
.support-ticket-header .email { color: var(--t-text); }
.support-thread { display: flex; flex-direction: column; gap: 10px; }
.support-msg {
background: var(--t-surface);
border: 1px solid var(--t-border);
border-radius: 4px;
padding: 12px 14px;
white-space: pre-wrap;
font-size: 13px;
line-height: 1.5;
}
.support-msg[data-dir="out"] {
border-left: 3px solid var(--t-accent);
padding-left: 11px;
}
.support-msg[data-dir="in"] {
border-left: 3px solid #fb923c;
padding-left: 11px;
}
.support-msg-label {
font-family: var(--t-font-pixel);
font-size: 9px;
color: var(--t-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
display: flex;
gap: 8px;
}
.support-msg-label .tag { color: var(--t-accent); }
.support-msg-label[data-dir="in"] .tag { color: #fb923c; }
/* Composer */
.support-composer {
background: var(--t-surface);
border: 1px solid var(--t-border);
border-radius: 4px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.support-composer-head {
display: flex;
gap: 8px;
align-items: center;
}
.support-composer-head .font-pixel { font-family: var(--t-font-pixel); font-size: 10px; color: var(--t-accent); text-transform: uppercase; letter-spacing: 0.5px; }
.support-kb-search {
flex: 1;
background: var(--t-bg);
border: 1px solid var(--t-border);
color: var(--t-bright);
padding: 6px 10px;
border-radius: 3px;
font-size: 12px;
font-family: var(--t-font-content, monospace);
outline: none;
}
.support-kb-search:focus { border-color: var(--t-accent); }
.support-kb-results {
display: none;
max-height: 200px;
overflow-y: auto;
background: var(--t-bg);
border: 1px solid var(--t-border);
border-radius: 3px;
}
.support-kb-results.open { display: block; }
.support-kb-result {
padding: 8px 12px;
border-bottom: 1px solid var(--t-border);
cursor: pointer;
transition: background 0.1s;
}
.support-kb-result:hover { background: var(--t-surface); }
.support-kb-result:last-child { border-bottom: none; }
.support-kb-result-title { color: var(--t-bright); font-size: 12px; font-weight: 600; }
.support-kb-result-preview { color: var(--t-dim); font-size: 11px; margin-top: 2px; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.support-composer textarea {
background: var(--t-bg);
border: 1px solid var(--t-border);
color: var(--t-bright);
padding: 10px 12px;
border-radius: 3px;
font-family: var(--t-font-content, ui-monospace, monospace);
font-size: 13px;
min-height: 140px;
resize: vertical;
outline: none;
}
.support-composer textarea:focus { border-color: var(--t-accent); }
.support-composer-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
flex-wrap: wrap;
}
.support-composer-actions .spacer { flex: 1; }
.support-composer-status { font-size: 11px; color: var(--t-dim); align-self: center; margin-right: auto; }
.support-composer-status.err { color: var(--t-danger); }
.support-composer-status.ok { color: #4ade80; }
/* Settings tab (injected into the main settings overlay) */
.support-settings-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.support-settings-field label {
font-family: var(--t-font-pixel, 'Silkscreen', cursive);
font-size: 9px;
color: var(--t-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.support-settings-field input, .support-settings-field select {
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;
}
.support-settings-field input:focus { border-color: var(--t-accent); }
.support-settings-help { font-size: 10px; color: var(--t-dim); line-height: 1.5; }
.support-settings-row { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
+35
View File
@@ -0,0 +1,35 @@
<!-- Support plugin overlay: ticket inbox + reply composer + KB panel.
Rendered into the document root; shown/hidden via the .open class. -->
<div id="support-overlay" class="support-overlay">
<header class="support-header">
<div class="support-header-left">
<i class="ph ph-lifebuoy"></i>
<span class="font-pixel text-xs text-t-bright">Support inbox</span>
<button id="support-poll" class="support-btn support-btn-ghost" title="Poll the relay for new tickets"><i class="ph ph-arrow-clockwise"></i></button>
</div>
<div class="support-header-right">
<div class="support-filter">
<button class="support-pill active" data-filter="">all</button>
<button class="support-pill" data-filter="open">open</button>
<button class="support-pill" data-filter="answered">answered</button>
<button class="support-pill" data-filter="closed">closed</button>
</div>
<button id="support-close" class="support-btn support-btn-ghost" title="Close (Esc)"><i class="ph ph-x"></i></button>
</div>
</header>
<div class="support-body">
<!-- Left: ticket list -->
<aside id="support-list" class="support-list">
<div class="support-empty">Loading…</div>
</aside>
<!-- Right: active ticket detail + reply composer -->
<section id="support-detail" class="support-detail">
<div class="support-empty-hero">
<i class="ph ph-envelope-open"></i>
<p>Pick a ticket on the left.</p>
</div>
</section>
</div>
</div>
+498
View File
@@ -0,0 +1,498 @@
// ── 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>Pick a ticket on the left.</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 support-btn-ghost" id="support-status-closed" title="Close without replying">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' });