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
|
<?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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user