relay: custom confirm dialog, custom recipient dropdown, inbox polish

Replaces all native confirm() calls with styled relayConfirm() promise.
Replaces native <select> recipient picker with custom dropdown control.
Adds synced badge, title-only preview, delete-all, inline delete confirm.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-12 12:40:21 +02:00
1 parent 3078906548
commit 838dfe2a6e
4 files changed
+150 -38

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php <?php
define('APP_NAME', 'hyprcrd'); define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '0.9.21'); define('APP_VERSION', '0.9.22');
// Database path — writable directory, outside webroot // Database path — writable directory, outside webroot
define('DB_PATH', __DIR__ . '/data/hyprcrd.db'); define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
+116 -35
View File
@@ -9,6 +9,28 @@ function $(id) { return document.getElementById(id); }
* Multiple toasts stack vertically, oldest at the top, and each one * Multiple toasts stack vertically, oldest at the top, and each one
* slides in from the right + fades out after a fixed timeout. The * slides in from the right + fades out after a fixed timeout. The
* legacy `toast(msg, true)` second-arg style still works (true → err). */ * legacy `toast(msg, true)` second-arg style still works (true → err). */
/** Custom confirm dialog — returns a Promise<boolean>. Replaces native
* confirm() across the relay UI so all confirmations are styled. */
function relayConfirm(msg, { okLabel = 'Confirm', danger = true } = {}) {
return new Promise(resolve => {
const el = $('relay-confirm');
if (!el) { resolve(confirm(msg)); return; } // fallback
$('relay-confirm-msg').textContent = msg;
const yesBtn = $('relay-confirm-yes');
yesBtn.textContent = okLabel;
yesBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary';
el.classList.remove('hidden');
const cleanup = (result) => {
el.classList.add('hidden');
yesBtn.replaceWith(yesBtn.cloneNode(true)); // strip listeners
$('relay-confirm-no').replaceWith($('relay-confirm-no').cloneNode(true));
resolve(result);
};
$('relay-confirm-yes').addEventListener('click', () => cleanup(true), { once: true });
$('relay-confirm-no').addEventListener('click', () => cleanup(false), { once: true });
});
}
function toast(msg, kind = 'info') { function toast(msg, kind = 'info') {
if (kind === true) kind = 'err'; if (kind === true) kind = 'err';
if (kind === false) kind = 'info'; if (kind === false) kind = 'info';
@@ -266,31 +288,53 @@ async function loadTenants() {
} }
function populateRecipientPicker() { function populateRecipientPicker() {
const sel = $('compose-recipient'); const hidden = $('compose-recipient');
const field = $('compose-recipient-field'); const btn = $('compose-recipient-btn');
sel.innerHTML = ''; const label = $('compose-recipient-label');
const dd = $('compose-recipient-dropdown');
const field = $('compose-recipient-field');
if (!hidden || !btn || !dd) return;
// Always include "you" first as the default recipient (the value // Build the options list: self first, then all other tenants
// the form submits even when the picker is hidden) const options = [];
if (me) { if (me) options.push({ value: me.username, text: `${me.username} (you)` });
const opt = document.createElement('option');
opt.value = me.username;
opt.textContent = `${me.username} (you)`;
sel.appendChild(opt);
}
for (const t of allTenants) { for (const t of allTenants) {
if (me && t.username === me.username) continue; if (me && t.username === me.username) continue;
const opt = document.createElement('option'); options.push({ value: t.username, text: t.username });
opt.value = t.username;
opt.textContent = t.username;
sel.appendChild(opt);
} }
// Solo tenant → no choice to make. Hide the picker entirely so // Default to self
// the compose form is just title + body. if (options.length && !hidden.value) {
if (field) { hidden.value = options[0].value;
field.classList.toggle('hidden', allTenants.length <= 1); label.textContent = options[0].text;
} }
// Render dropdown items
dd.innerHTML = options.map(o => `
<button class="recipient-option" data-value="${escapeHtml(o.value)}" style="display:block;width:100%;text-align:left;padding:7px 12px;font-size:12px;font-family:var(--mono);color:var(--text);background:none;border:none;cursor:pointer;transition:background .1s;">
<i class="ph ph-user-circle" style="font-size:12px;color:var(--accent2);margin-right:6px;"></i>${escapeHtml(o.text)}
</button>
`).join('');
dd.querySelectorAll('.recipient-option').forEach(opt => {
opt.addEventListener('mouseenter', () => opt.style.background = 'var(--raised)');
opt.addEventListener('mouseleave', () => opt.style.background = 'none');
opt.addEventListener('click', () => {
hidden.value = opt.dataset.value;
label.textContent = opt.textContent.trim();
dd.classList.add('hidden');
});
});
// Toggle dropdown on button click
btn.onclick = () => dd.classList.toggle('hidden');
// Close on outside click
document.addEventListener('click', (e) => {
if (!btn.contains(e.target) && !dd.contains(e.target)) dd.classList.add('hidden');
});
// Solo tenant → hide the picker
if (field) field.classList.toggle('hidden', allTenants.length <= 1);
} }
function clearCompose() { function clearCompose() {
@@ -337,6 +381,7 @@ async function loadInbox() {
const res = await api('api/messages/inbox'); const res = await api('api/messages/inbox');
const messages = res?.messages || []; const messages = res?.messages || [];
$('inbox-count').textContent = messages.length; $('inbox-count').textContent = messages.length;
$('inbox-delete-all')?.classList.toggle('hidden', messages.length === 0);
// Mirror count into the header status so the unread dot stays in sync // Mirror count into the header status so the unread dot stays in sync
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${messages.length} in inbox</span>`; $('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${messages.length} in inbox</span>`;
$('header-status').classList.toggle('has-unread', messages.length > 0); $('header-status').classList.toggle('has-unread', messages.length > 0);
@@ -353,9 +398,8 @@ async function loadInbox() {
list.innerHTML = messages.map(m => ` list.innerHTML = messages.map(m => `
<div class="inbox-item" data-id="${m.id}"> <div class="inbox-item" data-id="${m.id}">
<div class="content"> <div class="content">
<div class="from"><i class="ph ph-user-circle"></i> ${escapeHtml(m.sender)}${m.fetched ? '' : ' <span class="badge-new">new</span>'}</div> <div class="from"><i class="ph ph-user-circle"></i> ${escapeHtml(m.sender)}${m.fetched ? ' <span class="badge-synced"><i class="ph ph-check"></i> synced</span>' : ' <span class="badge-new">new</span>'}</div>
${m.title ? `<div class="title">${escapeHtml(m.title)}</div>` : ''} <div class="title">${m.title ? escapeHtml(m.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">No preview</span>'}</div>
<div class="body">${escapeHtml(m.body)}</div>
</div> </div>
<div class="meta">${escapeHtml(formatRelTime(m.created_at))}</div> <div class="meta">${escapeHtml(formatRelTime(m.created_at))}</div>
<button class="del" onclick="deleteMessage(${m.id}, this)" aria-label="delete"><i class="ph ph-trash"></i></button> <button class="del" onclick="deleteMessage(${m.id}, this)" aria-label="delete"><i class="ph ph-trash"></i></button>
@@ -364,19 +408,56 @@ async function loadInbox() {
} }
async function deleteMessage(id, btn) { async function deleteMessage(id, btn) {
if (!confirm('Delete this message?')) return; // Inline confirmation: replace the trash button with yes/no.
const res = await api('api/messages/' + id, { method: 'DELETE' }); // Event listeners are wired via JS (not inline onclick) to avoid
if (res?.ok) { // passing escaped HTML through attributes.
btn.closest('.inbox-item')?.remove(); const row = btn.closest('.inbox-item');
loadInbox(); if (!row) return;
refreshMe(); const origHtml = btn.outerHTML;
toast('message deleted', 'ok'); const wrap = document.createElement('span');
} else { wrap.className = 'del-confirm';
toast(res?.error || 'delete failed', 'err'); wrap.style.cssText = 'display:inline-flex;align-items:center;gap:4px;font-family:var(--pixel);font-size:9px;';
} wrap.innerHTML = `
<span style="color:var(--danger);">delete?</span>
<button class="del-yes" style="background:var(--danger);color:var(--bg);border:none;border-radius:var(--radius);padding:2px 8px;cursor:pointer;font-family:var(--pixel);font-size:9px;">yes</button>
<button class="del-no" style="background:none;border:1px solid var(--border);color:var(--muted);border-radius:var(--radius);padding:2px 8px;cursor:pointer;font-family:var(--pixel);font-size:9px;">no</button>
`;
btn.replaceWith(wrap);
wrap.querySelector('.del-yes').addEventListener('click', async () => {
const res = await api('api/messages/' + id, { method: 'DELETE' });
if (res?.ok) {
if (row) { row.style.transition = 'opacity .2s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 220); }
loadInbox();
refreshMe();
toast('message deleted', 'ok');
} else {
toast(res?.error || 'delete failed', 'err');
}
});
wrap.querySelector('.del-no').addEventListener('click', () => {
const temp = document.createElement('div');
temp.innerHTML = origHtml;
wrap.replaceWith(temp.firstChild);
});
} }
window.deleteMessage = deleteMessage; window.deleteMessage = deleteMessage;
async function deleteAllMessages() {
const res = await api('api/messages/inbox');
const messages = res?.messages || [];
if (!messages.length) return;
if (!await relayConfirm(`Delete all ${messages.length} message${messages.length !== 1 ? 's' : ''}? This cannot be undone.`, { okLabel: 'Delete all' })) return;
let ok = 0;
for (const m of messages) {
const r = await api('api/messages/' + m.id, { method: 'DELETE' });
if (r?.ok) ok++;
}
toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok');
loadInbox();
refreshMe();
}
window.deleteAllMessages = deleteAllMessages;
/** Lightweight relative-time formatter for the inbox. SQLite gives us /** Lightweight relative-time formatter for the inbox. SQLite gives us
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32", * `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
* or a full date for older messages. */ * or a full date for older messages. */
@@ -466,7 +547,7 @@ async function resetUserPassword(id, username) {
} }
async function deleteUser(id, username) { async function deleteUser(id, username) {
if (!confirm(`Delete user "${username}"? All their messages and attachments will be removed.`)) return; if (!await relayConfirm(`Delete user "${username}"? All their messages and attachments will be removed.`, { okLabel: 'Delete user' })) return;
const res = await api('api/users/' + id, { method: 'DELETE' }); const res = await api('api/users/' + id, { method: 'DELETE' });
if (res.ok) { toast('user deleted'); loadUsers(); } if (res.ok) { toast('user deleted'); loadUsers(); }
else toast(res.error || 'failed', true); else toast(res.error || 'failed', true);
@@ -533,7 +614,7 @@ async function saveAccountSecret() {
async function generateAccountSecret() { async function generateAccountSecret() {
if (currentAccountSecret) { if (currentAccountSecret) {
if (!confirm('Replace your current secret? All your hyprcrd clients will need the new value to connect.')) return; if (!await relayConfirm('Replace your current secret? All your hyprcrd clients will need the new value to connect.', { okLabel: 'Replace', danger: false })) return;
} }
setAccountStatus('generating...'); setAccountStatus('generating...');
const res = await api('api/account/secret', { method: 'POST', body: { generate: true } }); const res = await api('api/account/secret', { method: 'POST', body: { generate: true } });
@@ -550,7 +631,7 @@ async function generateAccountSecret() {
} }
async function clearAccountSecret() { async function clearAccountSecret() {
if (!confirm('Clear your client secret? All your hyprcrd clients will be disconnected and will need a new secret to reconnect.')) return; if (!await relayConfirm('Clear your client secret? All your hyprcrd clients will be disconnected and will need a new secret to reconnect.', { okLabel: 'Clear secret' })) return;
const res = await api('api/account/secret', { method: 'DELETE' }); const res = await api('api/account/secret', { method: 'DELETE' });
if (res.ok) { if (res.ok) {
setAccountStatus('cleared', 'ok'); setAccountStatus('cleared', 'ok');
+23 -2
View File
@@ -74,7 +74,14 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
<h2><i class="ph ph-paper-plane-tilt"></i> compose</h2> <h2><i class="ph ph-paper-plane-tilt"></i> compose</h2>
<div class="field" id="compose-recipient-field"> <div class="field" id="compose-recipient-field">
<label>recipient</label> <label>recipient</label>
<select id="compose-recipient"></select> <div id="compose-recipient-wrap" style="position:relative;">
<button id="compose-recipient-btn" type="button" class="input" style="text-align:left;cursor:pointer;display:flex;align-items:center;justify-content:space-between;">
<span id="compose-recipient-label">select…</span>
<i class="ph ph-caret-down" style="font-size:10px;color:var(--dim);"></i>
</button>
<input type="hidden" id="compose-recipient" value="">
<div id="compose-recipient-dropdown" class="hidden" style="position:absolute;top:100%;left:0;right:0;margin-top:4px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:0 8px 24px rgba(0,0,0,0.4);z-index:50;max-height:200px;overflow-y:auto;padding:4px 0;"></div>
</div>
</div> </div>
<div class="field"> <div class="field">
<label>title (optional)</label> <label>title (optional)</label>
@@ -92,7 +99,10 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
</div> </div>
</div> </div>
<div id="inbox-section" class="section"> <div id="inbox-section" class="section">
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span></h2> <h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span>
<span style="flex:1"></span>
<button id="inbox-delete-all" class="btn btn-danger hidden" style="padding:3px 10px;font-size:9px;" onclick="deleteAllMessages()"><i class="ph ph-trash"></i> delete all</button>
</h2>
<div id="inbox-list"></div> <div id="inbox-list"></div>
</div> </div>
</div> </div>
@@ -185,6 +195,17 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
</main> </main>
<!-- Confirm dialog (replaces native confirm()) -->
<div id="relay-confirm" class="hidden" style="position:fixed;inset:0;z-index:300;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;padding:24px;">
<div style="background:var(--surface);border:1px solid var(--border);border-radius:6px;box-shadow:0 16px 48px rgba(0,0,0,0.5);padding:24px;max-width:380px;width:100%;text-align:center;">
<p id="relay-confirm-msg" style="font-size:12px;color:var(--text);line-height:1.5;margin-bottom:18px;"></p>
<div style="display:flex;gap:8px;justify-content:center;">
<button id="relay-confirm-no" class="btn btn-secondary">Cancel</button>
<button id="relay-confirm-yes" class="btn btn-danger">Confirm</button>
</div>
</div>
</div>
<div id="toast-stack"></div> <div id="toast-stack"></div>
<script src="ui/app.js"></script> <script src="ui/app.js"></script>
+10
View File
@@ -326,6 +326,16 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
text-transform:uppercase; letter-spacing:0.05em; text-transform:uppercase; letter-spacing:0.05em;
margin-left:4px; margin-left:4px;
} }
.badge-synced {
display:inline-flex; align-items:center; gap:3px;
color:var(--ok, #4ade80);
padding:1px 6px; border-radius:var(--radius);
border:1px solid color-mix(in srgb, var(--ok, #4ade80) 40%, var(--border));
font-family:var(--pixel); font-size:8px;
text-transform:uppercase; letter-spacing:0.05em;
margin-left:4px;
}
.badge-synced .ph { font-size:9px; }
.inbox-item .del { .inbox-item .del {
background:none; border:none; color:var(--dim); cursor:pointer; background:none; border:none; color:var(--dim); cursor:pointer;
padding:6px; font-size:14px; flex-shrink:0; padding:6px; font-size:14px; flex-shrink:0;