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:
1 parent
3078906548
commit
838dfe2a6e
4 files changed
+150
-38
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '0.9.21');
|
||||
define('APP_VERSION', '0.9.22');
|
||||
|
||||
// Database path — writable directory, outside webroot
|
||||
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
||||
|
||||
+116
-35
@@ -9,6 +9,28 @@ function $(id) { return document.getElementById(id); }
|
||||
* Multiple toasts stack vertically, oldest at the top, and each one
|
||||
* slides in from the right + fades out after a fixed timeout. The
|
||||
* 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') {
|
||||
if (kind === true) kind = 'err';
|
||||
if (kind === false) kind = 'info';
|
||||
@@ -266,31 +288,53 @@ async function loadTenants() {
|
||||
}
|
||||
|
||||
function populateRecipientPicker() {
|
||||
const sel = $('compose-recipient');
|
||||
const field = $('compose-recipient-field');
|
||||
sel.innerHTML = '';
|
||||
const hidden = $('compose-recipient');
|
||||
const btn = $('compose-recipient-btn');
|
||||
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
|
||||
// the form submits even when the picker is hidden)
|
||||
if (me) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = me.username;
|
||||
opt.textContent = `${me.username} (you)`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
// Build the options list: self first, then all other tenants
|
||||
const options = [];
|
||||
if (me) options.push({ value: me.username, text: `${me.username} (you)` });
|
||||
for (const t of allTenants) {
|
||||
if (me && t.username === me.username) continue;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.username;
|
||||
opt.textContent = t.username;
|
||||
sel.appendChild(opt);
|
||||
options.push({ value: t.username, text: t.username });
|
||||
}
|
||||
|
||||
// Solo tenant → no choice to make. Hide the picker entirely so
|
||||
// the compose form is just title + body.
|
||||
if (field) {
|
||||
field.classList.toggle('hidden', allTenants.length <= 1);
|
||||
// Default to self
|
||||
if (options.length && !hidden.value) {
|
||||
hidden.value = options[0].value;
|
||||
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() {
|
||||
@@ -337,6 +381,7 @@ async function loadInbox() {
|
||||
const res = await api('api/messages/inbox');
|
||||
const messages = res?.messages || [];
|
||||
$('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
|
||||
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${messages.length} in inbox</span>`;
|
||||
$('header-status').classList.toggle('has-unread', messages.length > 0);
|
||||
@@ -353,9 +398,8 @@ async function loadInbox() {
|
||||
list.innerHTML = messages.map(m => `
|
||||
<div class="inbox-item" data-id="${m.id}">
|
||||
<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>
|
||||
${m.title ? `<div class="title">${escapeHtml(m.title)}</div>` : ''}
|
||||
<div class="body">${escapeHtml(m.body)}</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>
|
||||
<div class="title">${m.title ? escapeHtml(m.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">No preview</span>'}</div>
|
||||
</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>
|
||||
@@ -364,19 +408,56 @@ async function loadInbox() {
|
||||
}
|
||||
|
||||
async function deleteMessage(id, btn) {
|
||||
if (!confirm('Delete this message?')) return;
|
||||
const res = await api('api/messages/' + id, { method: 'DELETE' });
|
||||
if (res?.ok) {
|
||||
btn.closest('.inbox-item')?.remove();
|
||||
loadInbox();
|
||||
refreshMe();
|
||||
toast('message deleted', 'ok');
|
||||
} else {
|
||||
toast(res?.error || 'delete failed', 'err');
|
||||
}
|
||||
// Inline confirmation: replace the trash button with yes/no.
|
||||
// Event listeners are wired via JS (not inline onclick) to avoid
|
||||
// passing escaped HTML through attributes.
|
||||
const row = btn.closest('.inbox-item');
|
||||
if (!row) return;
|
||||
const origHtml = btn.outerHTML;
|
||||
const wrap = document.createElement('span');
|
||||
wrap.className = 'del-confirm';
|
||||
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;
|
||||
|
||||
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
|
||||
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
|
||||
* or a full date for older messages. */
|
||||
@@ -466,7 +547,7 @@ async function resetUserPassword(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' });
|
||||
if (res.ok) { toast('user deleted'); loadUsers(); }
|
||||
else toast(res.error || 'failed', true);
|
||||
@@ -533,7 +614,7 @@ async function saveAccountSecret() {
|
||||
|
||||
async function generateAccountSecret() {
|
||||
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...');
|
||||
const res = await api('api/account/secret', { method: 'POST', body: { generate: true } });
|
||||
@@ -550,7 +631,7 @@ async function generateAccountSecret() {
|
||||
}
|
||||
|
||||
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' });
|
||||
if (res.ok) {
|
||||
setAccountStatus('cleared', 'ok');
|
||||
|
||||
+23
-2
@@ -74,7 +74,14 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
<h2><i class="ph ph-paper-plane-tilt"></i> compose</h2>
|
||||
<div class="field" id="compose-recipient-field">
|
||||
<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 class="field">
|
||||
<label>title (optional)</label>
|
||||
@@ -92,7 +99,10 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
@@ -185,6 +195,17 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
</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>
|
||||
|
||||
<script src="ui/app.js"></script>
|
||||
|
||||
@@ -326,6 +326,16 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
|
||||
text-transform:uppercase; letter-spacing:0.05em;
|
||||
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 {
|
||||
background:none; border:none; color:var(--dim); cursor:pointer;
|
||||
padding:6px; font-size:14px; flex-shrink:0;
|
||||
|
||||
Reference in new issue
Block a user