Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
838dfe2a6e | ||
|
|
3078906548 |
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.20');
|
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');
|
||||||
|
|||||||
+22
-3
@@ -2155,9 +2155,28 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 min-h-0 overflow-hidden">
|
<div class="flex flex-1 min-h-0 overflow-hidden">
|
||||||
<div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; overflow-y:scroll; scrollbar-width:none; -ms-overflow-style:none;"></div>
|
<div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; overflow-y:scroll; scrollbar-width:none; -ms-overflow-style:none;"></div>
|
||||||
<div id="editor-pane" class="relative min-h-full bg-t-bg overflow-hidden" style="flex:1 1 0%;min-width:0;">
|
<div id="editor-pane" class="relative min-h-full bg-t-bg overflow-hidden flex flex-col" style="flex:1 1 0%;min-width:0;">
|
||||||
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div>
|
<!-- Find / Replace bar (Ctrl+H) -->
|
||||||
<textarea id="editor-body" placeholder="> write markdown..." class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
|
<div id="editor-find-bar" class="hidden shrink-0 border-b border-t-border bg-t-surface px-3 py-2" style="z-index:5;">
|
||||||
|
<div class="flex items-center gap-2 mb-1.5">
|
||||||
|
<i class="ph ph-magnifying-glass text-xs text-t-dim"></i>
|
||||||
|
<input id="find-input" type="text" placeholder="find…" class="flex-1 bg-t-bg border border-t-border text-t-bright text-xs px-2 py-1 font-mono focus:border-t-accent focus:outline-none" style="min-width:0;">
|
||||||
|
<span id="find-count" class="font-pixel text-[9px] text-t-dim" style="min-width:50px;text-align:center;"></span>
|
||||||
|
<button id="find-prev" class="toolbar-btn" title="previous"><i class="ph ph-caret-up text-xs"></i></button>
|
||||||
|
<button id="find-next" class="toolbar-btn" title="next"><i class="ph ph-caret-down text-xs"></i></button>
|
||||||
|
<button id="find-close" class="toolbar-btn" title="close"><i class="ph ph-x text-xs"></i></button>
|
||||||
|
</div>
|
||||||
|
<div id="replace-row" class="flex items-center gap-2">
|
||||||
|
<i class="ph ph-swap text-xs text-t-dim"></i>
|
||||||
|
<input id="replace-input" type="text" placeholder="replace…" class="flex-1 bg-t-bg border border-t-border text-t-bright text-xs px-2 py-1 font-mono focus:border-t-accent focus:outline-none" style="min-width:0;">
|
||||||
|
<button id="replace-one" class="font-pixel text-[9px] px-2 py-1 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">Replace</button>
|
||||||
|
<button id="replace-all" class="font-pixel text-[9px] px-2 py-1 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex-1 min-h-0">
|
||||||
|
<div id="editor-line-hl" class="absolute pointer-events-none left-0 right-0" style="background:var(--t-border);opacity:0.18;display:none;z-index:1;"></div>
|
||||||
|
<textarea id="editor-body" placeholder="> write markdown..." class="absolute inset-0 w-full h-full text-t-text text-sm font-mono placeholder:text-t-dim border-none overflow-y-auto" style="padding:1rem; line-height:1.625; resize:none; z-index:2; background:transparent;"></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="editor-divider" class="hidden shrink-0" style="width:11px;cursor:col-resize;position:relative;z-index:3;margin:0 -5px;"><div style="position:absolute;left:5px;top:0;bottom:0;width:1px;background:var(--t-border);"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div>
|
<div id="editor-divider" class="hidden shrink-0" style="width:11px;cursor:col-resize;position:relative;z-index:3;margin:0 -5px;"><div style="position:absolute;left:5px;top:0;bottom:0;width:1px;background:var(--t-border);"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div>
|
||||||
<div id="editor-preview" class="bg-t-bg p-4 overflow-y-auto hidden" style="flex:1 1 0%;min-width:0;"><div id="preview-content" class="prose text-sm text-t-text"></div></div>
|
<div id="editor-preview" class="bg-t-bg p-4 overflow-y-auto hidden" style="flex:1 1 0%;min-width:0;"><div id="preview-content" class="prose text-sm text-t-text"></div></div>
|
||||||
|
|||||||
@@ -238,6 +238,9 @@ registerShortcut({ key: 'Enter', guard: () => false, section: 'editor', keys: '<
|
|||||||
registerShortcut({ key: '[', guard: () => false, section: 'editor', keys: '<kbd>[</kbd><kbd>[</kbd>', label: 'link autocomplete' });
|
registerShortcut({ key: '[', guard: () => false, section: 'editor', keys: '<kbd>[</kbd><kbd>[</kbd>', label: 'link autocomplete' });
|
||||||
registerShortcut({ key: '#', guard: () => false, section: 'editor', keys: '<kbd>#</kbd>', label: 'tag autocomplete' });
|
registerShortcut({ key: '#', guard: () => false, section: 'editor', keys: '<kbd>#</kbd>', label: 'tag autocomplete' });
|
||||||
registerShortcut({ key: '?', guard: () => false, section: 'editor', keys: '<kbd>?</kbd><em>snippet</em>', label: 'snippet expand' });
|
registerShortcut({ key: '?', guard: () => false, section: 'editor', keys: '<kbd>?</kbd><em>snippet</em>', label: 'snippet expand' });
|
||||||
|
registerShortcut({ key: 'h', guard: () => false, section: 'editor', keys: '<kbd>⌃</kbd> <kbd>H</kbd>', label: 'find + replace' });
|
||||||
|
registerShortcut({ key: 'f', guard: () => false, section: 'editor', keys: '<kbd>⌃</kbd> <kbd>F</kbd>', label: 'find' });
|
||||||
|
registerShortcut({ key: 'H', ctrl: true, shift: true, guard: () => !isDialogOpen() && !guards.isOpen('modal'), section: 'global', keys: '<kbd>⌃</kbd> <kbd>⇧</kbd> <kbd>H</kbd>', label: 'find + replace across cards', handler: () => openBoardFindReplace() });
|
||||||
|
|
||||||
// — Detail view —
|
// — Detail view —
|
||||||
registerShortcut({ match: (e) => (e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.ctrlKey && guards.isOpen('detail') && !!state.viewingCard, priority: 10, section: 'board + detail', keys: '<kbd>←</kbd> <kbd>→</kbd> <span class="mouse">in preview</span>', label: 'group prev / next', handler: (e) => {
|
registerShortcut({ match: (e) => (e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.ctrlKey && guards.isOpen('detail') && !!state.viewingCard, priority: 10, section: 'board + detail', keys: '<kbd>←</kbd> <kbd>→</kbd> <span class="mouse">in preview</span>', label: 'group prev / next', handler: (e) => {
|
||||||
@@ -506,6 +509,148 @@ function openTagQuickPick() {
|
|||||||
setTimeout(() => input.focus(), 0);
|
setTimeout(() => input.focus(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Board-level find/replace (Ctrl+Shift+H) ──
|
||||||
|
|
||||||
|
async function openBoardFindReplace() {
|
||||||
|
const existing = document.getElementById('board-find-replace');
|
||||||
|
if (existing) { existing.remove(); return; }
|
||||||
|
|
||||||
|
// Fetch all cards with bodies (state.cards may have body=null from lazy load)
|
||||||
|
const allCards = await api('api/cards') || [];
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'board-find-replace';
|
||||||
|
overlay.style.cssText = 'position:fixed;inset:0;z-index:120;background:var(--t-overlay,rgba(0,0,0,0.55));display:flex;align-items:flex-start;justify-content:center;padding:60px 16px 16px;backdrop-filter:blur(var(--ui-blur-overlay,0));';
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.style.cssText = 'background:var(--t-surface);border:1px solid var(--t-border);border-radius:var(--ui-radius-modal);box-shadow:var(--ui-shadow-modal);width:100%;max-width:560px;max-height:80vh;display:flex;flex-direction:column;overflow:hidden;';
|
||||||
|
box.innerHTML = `
|
||||||
|
<div style="padding:12px 14px;border-bottom:1px solid var(--t-border);display:flex;flex-direction:column;gap:8px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<i class="ph ph-magnifying-glass text-t-accent"></i>
|
||||||
|
<span class="font-pixel text-xs text-t-accent">find + replace across all cards</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<input id="bfr-find" type="text" placeholder="find…" style="flex:1;background:var(--t-bg);border:1px solid var(--t-border);border-radius:var(--ui-radius-input);color:var(--t-bright);padding:7px 10px;font-size:12px;font-family:var(--t-font-content,'JetBrains Mono',monospace);outline:none;">
|
||||||
|
<input id="bfr-replace" type="text" placeholder="replace with…" style="flex:1;background:var(--t-bg);border:1px solid var(--t-border);border-radius:var(--ui-radius-input);color:var(--t-bright);padding:7px 10px;font-size:12px;font-family:var(--t-font-content,'JetBrains Mono',monospace);outline:none;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="bfr-list" style="flex:1;overflow-y:auto;padding:6px 0;"></div>
|
||||||
|
<div id="bfr-footer" style="padding:10px 14px;border-top:1px solid var(--t-border);display:flex;align-items:center;gap:8px;">
|
||||||
|
<span id="bfr-status" class="font-pixel text-[9px] text-t-dim flex-1"></span>
|
||||||
|
<button id="bfr-apply" class="font-pixel text-[10px] px-3 py-1.5 border border-t-danger text-t-danger hover:bg-t-danger hover:text-t-bg transition-colors" disabled>Replace all</button>
|
||||||
|
<button id="bfr-close" class="font-pixel text-[10px] px-3 py-1.5 border border-t-border text-t-muted hover:text-t-bright transition-colors">Close</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
overlay.appendChild(box);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const findInp = box.querySelector('#bfr-find');
|
||||||
|
const replInp = box.querySelector('#bfr-replace');
|
||||||
|
const list = box.querySelector('#bfr-list');
|
||||||
|
const statusEl = box.querySelector('#bfr-status');
|
||||||
|
const applyBtn = box.querySelector('#bfr-apply');
|
||||||
|
|
||||||
|
function escHtml(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||||
|
|
||||||
|
let matches = [];
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
const needle = findInp.value;
|
||||||
|
matches = [];
|
||||||
|
if (!needle) {
|
||||||
|
list.innerHTML = '<div style="padding:24px 14px;text-align:center;font-size:11px;color:var(--t-dim);font-style:italic;">type to search across all card bodies</div>';
|
||||||
|
statusEl.textContent = '';
|
||||||
|
applyBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nLower = needle.toLowerCase();
|
||||||
|
for (const c of allCards) {
|
||||||
|
const body = c.body || '';
|
||||||
|
const idx = body.toLowerCase().indexOf(nLower);
|
||||||
|
if (idx === -1) continue;
|
||||||
|
// Extract context around the first match
|
||||||
|
const start = Math.max(0, idx - 30);
|
||||||
|
const end = Math.min(body.length, idx + needle.length + 30);
|
||||||
|
const before = body.substring(start, idx);
|
||||||
|
const match = body.substring(idx, idx + needle.length);
|
||||||
|
const after = body.substring(idx + needle.length, end);
|
||||||
|
// Count all occurrences in this card
|
||||||
|
let count = 0, pos = 0;
|
||||||
|
while (true) {
|
||||||
|
const i = body.toLowerCase().indexOf(nLower, pos);
|
||||||
|
if (i === -1) break;
|
||||||
|
count++;
|
||||||
|
pos = i + 1;
|
||||||
|
}
|
||||||
|
matches.push({ card: c, context: { before, match, after }, count });
|
||||||
|
}
|
||||||
|
const totalOccurrences = matches.reduce((s, m) => s + m.count, 0);
|
||||||
|
statusEl.textContent = matches.length
|
||||||
|
? `${totalOccurrences} occurrence${totalOccurrences !== 1 ? 's' : ''} in ${matches.length} card${matches.length !== 1 ? 's' : ''}`
|
||||||
|
: 'no matches';
|
||||||
|
applyBtn.disabled = !matches.length || !replInp.value.length;
|
||||||
|
|
||||||
|
if (!matches.length) {
|
||||||
|
list.innerHTML = '<div style="padding:24px 14px;text-align:center;font-size:11px;color:var(--t-dim);font-style:italic;">no matches found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = matches.map(m => `
|
||||||
|
<div style="padding:6px 14px;display:flex;align-items:flex-start;gap:10px;border-bottom:1px solid var(--t-border);">
|
||||||
|
<span style="width:6px;height:6px;border-radius:50%;background:${m.card.color || '#64748b'};flex-shrink:0;margin-top:5px;"></span>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div class="font-pixel text-[10px] text-t-bright" style="margin-bottom:2px;">${escHtml(m.card.title)} <span class="text-t-dim">(${m.count}×)</span></div>
|
||||||
|
<div style="font-size:11px;color:var(--t-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">…${escHtml(m.context.before)}<mark style="background:color-mix(in srgb, var(--t-accent) 30%, transparent);color:var(--t-bright);padding:0 2px;border-radius:2px;">${escHtml(m.context.match)}</mark>${escHtml(m.context.after)}…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
findInp.addEventListener('input', search);
|
||||||
|
replInp.addEventListener('input', () => { applyBtn.disabled = !matches.length || !replInp.value.length; });
|
||||||
|
|
||||||
|
applyBtn.addEventListener('click', async () => {
|
||||||
|
const needle = findInp.value;
|
||||||
|
const repl = replInp.value;
|
||||||
|
if (!needle || !matches.length) return;
|
||||||
|
const totalOccurrences = matches.reduce((s, m) => s + m.count, 0);
|
||||||
|
const ok = await showDialog({
|
||||||
|
title: 'replace_all',
|
||||||
|
message: `Replace ${totalOccurrences} occurrence${totalOccurrences !== 1 ? 's' : ''} of "${needle}" with "${repl}" across ${matches.length} card${matches.length !== 1 ? 's' : ''}?`,
|
||||||
|
danger: true,
|
||||||
|
okLabel: `Replace ${totalOccurrences}`,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const re = new RegExp(escaped, 'gi');
|
||||||
|
let patchCount = 0;
|
||||||
|
for (const m of matches) {
|
||||||
|
const newBody = (m.card.body || '').replace(re, repl);
|
||||||
|
if (newBody !== m.card.body) {
|
||||||
|
await api(`api/cards/${m.card.id}`, { method: 'PATCH', body: { body: newBody } });
|
||||||
|
patchCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statusEl.textContent = `done — ${patchCount} card${patchCount !== 1 ? 's' : ''} updated`;
|
||||||
|
statusEl.style.color = 'var(--t-accent)';
|
||||||
|
applyBtn.disabled = true;
|
||||||
|
matches = [];
|
||||||
|
list.innerHTML = '<div style="padding:24px 14px;text-align:center;font-size:11px;color:var(--t-accent);">✓ replaced</div>';
|
||||||
|
await window._reloadAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
overlay.remove();
|
||||||
|
document.removeEventListener('keydown', onKey, true);
|
||||||
|
}
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
|
||||||
|
}
|
||||||
|
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
|
||||||
|
box.querySelector('#bfr-close').addEventListener('click', close);
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
setTimeout(() => findInp.focus(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Boot ──
|
// ── Boot ──
|
||||||
|
|
||||||
renderShortcutHelp();
|
renderShortcutHelp();
|
||||||
|
|||||||
@@ -362,6 +362,7 @@ export function closeModal({ animate = true } = {}) {
|
|||||||
hideLinkAc();
|
hideLinkAc();
|
||||||
hideTagAc();
|
hideTagAc();
|
||||||
hideSnippetAc();
|
hideSnippetAc();
|
||||||
|
closeFindBar();
|
||||||
modal.classList.remove('open');
|
modal.classList.remove('open');
|
||||||
state.editingId = null;
|
state.editingId = null;
|
||||||
state.returnToGalleryAfterEdit = false;
|
state.returnToGalleryAfterEdit = false;
|
||||||
@@ -989,6 +990,123 @@ body.addEventListener('keydown', e => {
|
|||||||
|
|
||||||
body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hideTagAc, 150); setTimeout(hideSnippetAc, 150); });
|
body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hideTagAc, 150); setTimeout(hideSnippetAc, 150); });
|
||||||
|
|
||||||
|
// ── Find / Replace (Ctrl+H / Ctrl+F) ──
|
||||||
|
|
||||||
|
const findBar = $('editor-find-bar');
|
||||||
|
const findInput = $('find-input');
|
||||||
|
const replInput = $('replace-input');
|
||||||
|
const findCount = $('find-count');
|
||||||
|
const replRow = $('replace-row');
|
||||||
|
|
||||||
|
let findMatches = [];
|
||||||
|
let findCurrent = -1;
|
||||||
|
|
||||||
|
function openFindBar(withReplace = true) {
|
||||||
|
if (!findBar) return;
|
||||||
|
findBar.classList.remove('hidden');
|
||||||
|
replRow?.classList.toggle('hidden', !withReplace);
|
||||||
|
findInput?.focus();
|
||||||
|
if (findInput?.value) runFind();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFindBar() {
|
||||||
|
findBar?.classList.add('hidden');
|
||||||
|
findMatches = [];
|
||||||
|
findCurrent = -1;
|
||||||
|
if (findCount) findCount.textContent = '';
|
||||||
|
body.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFind() {
|
||||||
|
const needle = findInput?.value;
|
||||||
|
findMatches = [];
|
||||||
|
findCurrent = -1;
|
||||||
|
if (!needle) { if (findCount) findCount.textContent = ''; return; }
|
||||||
|
const text = body.value;
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
const nLower = needle.toLowerCase();
|
||||||
|
let pos = 0;
|
||||||
|
while (true) {
|
||||||
|
const idx = lower.indexOf(nLower, pos);
|
||||||
|
if (idx === -1) break;
|
||||||
|
findMatches.push(idx);
|
||||||
|
pos = idx + 1;
|
||||||
|
}
|
||||||
|
if (findCount) findCount.textContent = findMatches.length ? `${findMatches.length} found` : 'no match';
|
||||||
|
if (findMatches.length) jumpTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpTo(idx) {
|
||||||
|
findCurrent = idx;
|
||||||
|
const pos = findMatches[idx];
|
||||||
|
const needle = findInput?.value || '';
|
||||||
|
body.focus();
|
||||||
|
body.setSelectionRange(pos, pos + needle.length);
|
||||||
|
if (findCount) findCount.textContent = `${idx + 1} / ${findMatches.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNext() {
|
||||||
|
if (!findMatches.length) return;
|
||||||
|
jumpTo((findCurrent + 1) % findMatches.length);
|
||||||
|
}
|
||||||
|
function findPrev() {
|
||||||
|
if (!findMatches.length) return;
|
||||||
|
jumpTo((findCurrent - 1 + findMatches.length) % findMatches.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceOne() {
|
||||||
|
if (findCurrent < 0 || !findMatches.length) return;
|
||||||
|
const needle = findInput?.value || '';
|
||||||
|
const repl = replInput?.value ?? '';
|
||||||
|
const pos = findMatches[findCurrent];
|
||||||
|
body.setRangeText(repl, pos, pos + needle.length, 'end');
|
||||||
|
updatePreview();
|
||||||
|
runFind();
|
||||||
|
if (findMatches.length && findCurrent >= findMatches.length) findCurrent = 0;
|
||||||
|
if (findMatches.length) jumpTo(findCurrent < 0 ? 0 : findCurrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceAll() {
|
||||||
|
const needle = findInput?.value;
|
||||||
|
const repl = replInput?.value ?? '';
|
||||||
|
if (!needle) return;
|
||||||
|
// Simple case-insensitive global replace via regex
|
||||||
|
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
body.value = body.value.replace(new RegExp(escaped, 'gi'), repl);
|
||||||
|
updatePreview();
|
||||||
|
runFind();
|
||||||
|
}
|
||||||
|
|
||||||
|
findInput?.addEventListener('input', runFind);
|
||||||
|
findInput?.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); }
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); closeFindBar(); }
|
||||||
|
});
|
||||||
|
replInput?.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); replaceOne(); }
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); closeFindBar(); }
|
||||||
|
});
|
||||||
|
$('find-next')?.addEventListener('click', findNext);
|
||||||
|
$('find-prev')?.addEventListener('click', findPrev);
|
||||||
|
$('find-close')?.addEventListener('click', closeFindBar);
|
||||||
|
$('replace-one')?.addEventListener('click', replaceOne);
|
||||||
|
$('replace-all')?.addEventListener('click', replaceAll);
|
||||||
|
|
||||||
|
// Ctrl+H = find+replace, Ctrl+F = find only (within editor)
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (!modal.classList.contains('open')) return;
|
||||||
|
if ((e.key === 'h' || e.key === 'f') && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
// If already open and same mode, toggle off
|
||||||
|
if (!findBar?.classList.contains('hidden')) {
|
||||||
|
const wantReplace = e.key === 'h';
|
||||||
|
const hasReplace = !replRow?.classList.contains('hidden');
|
||||||
|
if (wantReplace === hasReplace) { closeFindBar(); return; }
|
||||||
|
}
|
||||||
|
openFindBar(e.key === 'h');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── File upload (paste + drag-drop) ──
|
// ── File upload (paste + drag-drop) ──
|
||||||
|
|
||||||
async function uploadFile(file) {
|
async function uploadFile(file) {
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||||
|
|
||||||
|
async function makeCard(title, body) {
|
||||||
|
return fetch('api/cards', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
|
body: JSON.stringify({ title, body, color: '#38bdf8' }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCard(id) {
|
||||||
|
const all = await fetch(`api/cards?ids=${id}`).then(r => r.json());
|
||||||
|
return all.find(c => c.id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBoardFR() {
|
||||||
|
// Ctrl+Shift+H opens the board find/replace overlay
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', {
|
||||||
|
key: 'H', ctrlKey: true, shiftKey: true, bubbles: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Board: Find/Replace', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
document.getElementById('board-find-replace')?.remove();
|
||||||
|
await closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+Shift+H opens the overlay', async () => {
|
||||||
|
openBoardFR();
|
||||||
|
// Wait for the overlay (it does an async api('api/cards') fetch)
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
if (document.getElementById('board-find-replace')) break;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
expect(document.getElementById('board-find-replace')).toExist();
|
||||||
|
expect(document.getElementById('bfr-find')).toExist();
|
||||||
|
expect(document.getElementById('bfr-replace')).toExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('typing in find shows matching cards', async () => {
|
||||||
|
const c1 = await makeCard('__bfr_match_a__', 'the quick brown fox');
|
||||||
|
const c2 = await makeCard('__bfr_match_b__', 'the quick red fox');
|
||||||
|
const c3 = await makeCard('__bfr_nomatch__', 'no match here');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
openBoardFR();
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
if (document.getElementById('bfr-find')) break;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
const findInp = document.getElementById('bfr-find');
|
||||||
|
if (!findInp) return;
|
||||||
|
findInp.value = 'quick';
|
||||||
|
findInp.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await sleep(100);
|
||||||
|
|
||||||
|
const status = document.getElementById('bfr-status');
|
||||||
|
// Should find 'quick' in at least 2 cards (c1 + c2)
|
||||||
|
expect(status.textContent).toContain('2');
|
||||||
|
// The list should mention both matching card titles
|
||||||
|
const list = document.getElementById('bfr-list');
|
||||||
|
expect(list.textContent).toContain('__bfr_match_a__');
|
||||||
|
expect(list.textContent).toContain('__bfr_match_b__');
|
||||||
|
// Non-matching card should NOT be in the list
|
||||||
|
expect(list.textContent.includes('__bfr_nomatch__')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replace all updates card bodies after confirmation', async () => {
|
||||||
|
const c1 = await makeCard('__bfr_repl_a__', 'hello world hello');
|
||||||
|
const c2 = await makeCard('__bfr_repl_b__', 'hello there');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
openBoardFR();
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
if (document.getElementById('bfr-find')) break;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
const findInp = document.getElementById('bfr-find');
|
||||||
|
const replInp = document.getElementById('bfr-replace');
|
||||||
|
if (!findInp || !replInp) return;
|
||||||
|
|
||||||
|
findInp.value = 'hello';
|
||||||
|
findInp.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await sleep(50);
|
||||||
|
replInp.value = 'goodbye';
|
||||||
|
replInp.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await sleep(50);
|
||||||
|
|
||||||
|
const applyBtn = document.getElementById('bfr-apply');
|
||||||
|
expect(applyBtn.disabled).toBe(false);
|
||||||
|
click(applyBtn);
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
// The confirmation dialog should appear
|
||||||
|
const dialog = document.getElementById('dialog');
|
||||||
|
if (dialog?.classList.contains('open')) {
|
||||||
|
const okBtn = document.getElementById('dialog-ok');
|
||||||
|
if (okBtn) click(okBtn);
|
||||||
|
await sleep(600);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify card bodies were updated
|
||||||
|
const updated1 = await getCard(c1.id);
|
||||||
|
const updated2 = await getCard(c2.id);
|
||||||
|
expect(updated1.body).toBe('goodbye world goodbye');
|
||||||
|
expect(updated2.body).toBe('goodbye there');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replace all button disabled without replace text', async () => {
|
||||||
|
await makeCard('__bfr_dis__', 'some text');
|
||||||
|
await window._reloadAll?.();
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
openBoardFR();
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
if (document.getElementById('bfr-find')) break;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
const findInp = document.getElementById('bfr-find');
|
||||||
|
if (!findInp) return;
|
||||||
|
findInp.value = 'some';
|
||||||
|
findInp.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await sleep(50);
|
||||||
|
|
||||||
|
const applyBtn = document.getElementById('bfr-apply');
|
||||||
|
// Replace input is empty → button should be disabled
|
||||||
|
expect(applyBtn.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -48,8 +48,12 @@ describe('Detail View', () => {
|
|||||||
if (!card) return;
|
if (!card) return;
|
||||||
click(card);
|
click(card);
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
|
if (!$('#detail')?.classList.contains('open')) return;
|
||||||
pressKey('Escape');
|
pressKey('Escape');
|
||||||
await sleep(150);
|
for (let i = 0; i < 20; i++) {
|
||||||
|
if (!$('#detail')?.classList.contains('open')) break;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
expect($('#detail')).toBeHidden();
|
expect($('#detail')).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const FILES = [
|
|||||||
'./context-menu.test.js',
|
'./context-menu.test.js',
|
||||||
'./card-interactions.test.js',
|
'./card-interactions.test.js',
|
||||||
'./card-join.test.js',
|
'./card-join.test.js',
|
||||||
|
'./board-find-replace.test.js',
|
||||||
'./filtering.test.js',
|
'./filtering.test.js',
|
||||||
'./sorting.test.js',
|
'./sorting.test.js',
|
||||||
'./state-functions.test.js',
|
'./state-functions.test.js',
|
||||||
|
|||||||
+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