Files
Password-Manager/js/app.js
T
Zaki f40024cc67 Fix: mousedown clears selectedIds before batch callback runs
Document mousedown handler was clearing selectedIds when clicking 'Yes' (button element). Since mousedown fires before click, selectedIds was empty by the time the async callback read it, causing no-op deletes but still showing the toast. Added .batch-confirm-overlay to the mousedown exclusion list so clicking the confirm dialog won't trigger clearSelection().
2026-05-09 18:46:27 +01:00

1441 lines
78 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const API = '/password-manager/api.php';
let token = sessionStorage.getItem('authToken');
let csrfToken = sessionStorage.getItem('csrfToken') || '';
let curUser = sessionStorage.getItem('currentUsername');
function a2b64(arr) { return btoa(String.fromCharCode(...new Uint8Array(arr))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); }
function b642ab(s) { return Uint8Array.from(atob(s.replace(/-/g,'+').replace(/_/g,'/')), c=>c.charCodeAt(0)).buffer; }
let view = localStorage.getItem('vaultView') || 'grid';
let detailIndex = 0;
let showView = localStorage.getItem('showViewBtn') !== 'false';
let showMail = localStorage.getItem('showEmail') !== 'false';
let dark = localStorage.getItem('darkTheme') !== 'false';
let lockMin = parseInt(localStorage.getItem('autoLockMinutes') || '5');
let order = JSON.parse(localStorage.getItem('entryOrder') || '[]');
let selectedFolder = localStorage.getItem('selectedFolder') || 'All';
let entries = [];
let folders = ['All'];
let genPwdVal = '';
let cryptoKey = null;
let searchQuery = '';
let idleT, warnT, countT;
let draggedId = null;
let showTrash = false;
let selectedIds = new Set();
let lastSelectedId = null;
let rectState = { active: false, startX: 0, startY: 0, el: null, started: false };
// ==================== SOUND ====================
let soundEnabled = localStorage.getItem('soundEnabled') !== 'false';
let audioCtx = null;
function getAudioContext() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
return audioCtx;
}
function playTone(freq, duration, type = 'sine', volume = 0.08) {
if (!soundEnabled) return;
try {
const ctx = getAudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, ctx.currentTime);
gain.gain.setValueAtTime(volume, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + duration);
} catch (e) { /* ignore */ }
}
function playSound(type) {
if (!soundEnabled) return;
switch (type) {
case 'click': playTone(800, 0.08, 'sine', 0.06); break;
case 'success':
playTone(523, 0.1, 'sine', 0.1);
setTimeout(() => playTone(659, 0.1, 'sine', 0.1), 100);
setTimeout(() => playTone(784, 0.15, 'sine', 0.1), 200);
break;
case 'error':
playTone(200, 0.2, 'square', 0.06);
setTimeout(() => playTone(150, 0.3, 'square', 0.06), 150);
break;
case 'delete':
playTone(150, 0.15, 'triangle', 0.08);
break;
case 'copy':
playTone(1200, 0.05, 'sine', 0.07);
break;
case 'generate':
playTone(440, 0.05, 'sine', 0.05);
setTimeout(() => playTone(554, 0.05, 'sine', 0.05), 60);
setTimeout(() => playTone(659, 0.05, 'sine', 0.05), 120);
setTimeout(() => playTone(880, 0.1, 'sine', 0.07), 180);
break;
case 'open':
playTone(600, 0.12, 'sine', 0.06);
setTimeout(() => playTone(800, 0.1, 'sine', 0.06), 80);
break;
case 'close':
playTone(800, 0.08, 'sine', 0.05);
setTimeout(() => playTone(600, 0.1, 'sine', 0.05), 80);
break;
case 'login':
playTone(523, 0.1, 'sine', 0.08);
setTimeout(() => playTone(659, 0.1, 'sine', 0.08), 100);
setTimeout(() => playTone(784, 0.2, 'sine', 0.1), 200);
break;
case 'register':
playTone(440, 0.1, 'sine', 0.08);
setTimeout(() => playTone(554, 0.1, 'sine', 0.08), 100);
setTimeout(() => playTone(659, 0.15, 'sine', 0.1), 200);
break;
}
}
function toggleSound() {
soundEnabled = !soundEnabled;
localStorage.setItem('soundEnabled', soundEnabled);
if (soundEnabled) playTone(440, 0.05);
syncSettingsUI();
}
// ==================== TOAST ====================
function toast(m, t) { t = t || 'success'; const c = document.getElementById('toastContainer'); const d = document.createElement('div'); d.className = 'toast ' + t; d.textContent = m; c.appendChild(d); setTimeout(() => d.remove(), 3000); }
function showZigzagToast(elem, msg, type) {
const t = document.createElement('div');
t.className = 'toast-zigzag ' + (type || 'success');
t.textContent = msg;
document.body.appendChild(t);
const r = elem.getBoundingClientRect();
t.style.left = r.left + 'px';
t.style.top = r.top + 'px';
setTimeout(() => t.remove(), 1500);
}
// ==================== THEME ====================
function applyTheme() { document.body.classList.toggle('light', !dark); }
function toggleTheme() { dark = !dark; localStorage.setItem('darkTheme', dark); applyTheme(); syncSettingsUI(); playSound('click'); }
// ==================== CRYPTO ====================
function checkStrength() { const p = document.getElementById('passwordInput').value; const b = document.getElementById('strengthBar'); let s = 0; if (p.length >= 8) s++; if (p.length >= 12) s++; if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++; if (/\d/.test(p)) s++; if (/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(p)) s++; b.className = 'strength-bar s' + Math.min(4, s); }
async function deriveKey(pwd, salt) { const enc = new TextEncoder(); const km = await crypto.subtle.importKey('raw', enc.encode(pwd), 'PBKDF2', false, ['deriveKey']); const sb = Uint8Array.from(atob(salt), c => c.charCodeAt(0)); return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: sb, iterations: 100000, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); }
async function encryptPwd(plain) { const iv = crypto.getRandomValues(new Uint8Array(12)); const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, new TextEncoder().encode(plain)); return { encrypted: btoa(String.fromCharCode(...new Uint8Array(enc))), iv: btoa(String.fromCharCode(...iv)) }; }
async function decryptPwd(encB64, ivB64) { try { const enc = Uint8Array.from(atob(encB64), c => c.charCodeAt(0)); const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0)); const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, cryptoKey, enc); return new TextDecoder().decode(dec); } catch (e) { return '[ERROR]'; } }
async function persistCryptoKey() { const raw = await crypto.subtle.exportKey('raw', cryptoKey); sessionStorage.setItem('cryptoKey', btoa(String.fromCharCode(...new Uint8Array(raw)))); }
async function restoreCryptoKey() { const saved = sessionStorage.getItem('cryptoKey'); if (!saved) return false; try { const raw = Uint8Array.from(atob(saved), c => c.charCodeAt(0)); cryptoKey = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); return true; } catch (e) { return false; } }
// ==================== AUTO-LOCK ====================
function setAutoLock() { lockMin = parseInt(document.getElementById('autoLockTimer').value); localStorage.setItem('autoLockMinutes', lockMin); resetIdle(); }
function resetIdle() { clearTimeout(idleT); clearTimeout(warnT); clearInterval(countT); document.getElementById('idleWarning').classList.remove('show'); if (lockMin > 0 && token) { const lm = lockMin * 60000; warnT = setTimeout(() => { document.getElementById('idleWarning').classList.add('show'); let cd = 30; document.getElementById('idleCountdown').textContent = cd; countT = setInterval(() => { cd--; document.getElementById('idleCountdown').textContent = cd; if (cd <= 0) { clearInterval(countT); doLogout(); } }, 1000); }, Math.max(0, lm - 30000)); idleT = setTimeout(() => doLogout(), lm); } }
// ==================== USERNAME ====================
function saveUsername() { const f = document.getElementById('addUsername'); if (f && f.value.trim()) localStorage.setItem('savedUsername', f.value.trim()); }
function loadUsername() { const s = localStorage.getItem('savedUsername'); const f = document.getElementById('addUsername'); if (s && f) f.value = s; }
// ==================== FOLDERS ====================
async function loadFolders() {
if (!token) return;
try {
const r = await fetch(API + '/folders', { headers: { 'Authorization': 'Bearer ' + token } });
if (r.ok) {
const data = await r.json();
if (Array.isArray(data)) {
folders = data.filter(f => typeof f === 'string');
if (!folders.includes('All')) folders.unshift('All');
} else {
folders = ['All'];
}
} else {
folders = ['All'];
}
} catch (e) {
folders = ['All'];
}
}
async function addFolderToServer(name) {
try {
const r = await fetch(API + '/folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ name })
});
if (r.ok) { await loadFolders(); return true; }
const d = await r.json();
toast('❌ ' + (d.error || 'Error'), 'error');
return false;
} catch (e) { toast('⚠️ Connection error', 'error'); return false; }
}
async function deleteFolderFromServer(name) {
try {
const r = await fetch(API + '/folders/' + encodeURIComponent(name), {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }
});
if (r.ok) {
await loadFolders();
if (selectedFolder === name) { selectedFolder = 'All'; localStorage.setItem('selectedFolder', 'All'); }
return true;
}
const d = await r.json();
toast('❌ ' + (d.error || 'Error'), 'error');
return false;
} catch (e) { toast('⚠️ Connection error', 'error'); return false; }
}
function folderColor(name) {
if (name === 'All') return '';
let hash = 0;
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
const hue = ((hash % 360) + 360) % 360;
return `style="--chip-color:hsl(${hue},60%,55%)"`;
}
function renderFolders() {
const bar = document.getElementById('foldersBar');
if (!bar) return;
entries.forEach(e => { if (!e.folder || typeof e.folder !== 'string') e.folder = 'All'; });
const counts = {};
entries.forEach(e => { const f = e.folder; counts[f] = (counts[f] || 0) + 1; });
let html = '';
folders.forEach(f => {
if (!f) return;
const count = counts[f] || 0;
const color = folderColor(f);
html += `<span class="folder-chip${selectedFolder === f ? ' active' : ''}" ${color} data-folder="${esc(f)}" onclick="selectFolder('${esc(f)}')">📁 ${esc(f)}<span class="folder-count">${count}</span>${f !== 'All' ? `<button class="folder-delete-btn" onclick="event.stopPropagation();showDeleteFolderConfirm('${esc(f)}')">✕</button>` : ''}</span>`;
});
html += `<button class="folder-add-btn" onclick="showAddFolderModal()">+ New</button>`;
bar.innerHTML = html;
// Make folders drop targets for moving entries
bar.querySelectorAll('.folder-chip[data-folder]').forEach(chip => {
chip.addEventListener('dragover', e => { e.preventDefault(); chip.classList.add('drag-over'); });
chip.addEventListener('dragleave', () => chip.classList.remove('drag-over'));
chip.addEventListener('drop', async function(e) {
e.preventDefault();
this.classList.remove('drag-over');
const id = parseInt(e.dataTransfer.getData('text/plain'));
if (!id) return;
const entry = entries.find(x => x.id === id);
if (!entry) return;
const folder = this.dataset.folder;
const ids = selectedIds.has(id) && selectedIds.size > 1 ? [...selectedIds] : [id];
let moved = 0;
for (const sid of ids) {
const e2 = entries.find(x => x.id === sid);
if (!e2 || e2.folder === folder) continue;
const enc = await encryptPwd(e2.password);
const r = await fetch(API + '/entries/' + sid, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ site: e2.site, username: e2.username, encrypted_password: enc.encrypted, iv: enc.iv, folder })
});
if (r.ok) { e2.folder = folder; moved++; }
}
renderFolders();
render();
if (moved) playSound('success');
});
});
}
function selectFolder(f) {
selectedFolder = f;
localStorage.setItem('selectedFolder', f);
renderFolders();
populateFolderSelects();
render();
playSound('click');
}
function showAddFolderModal() {
const overlay = document.createElement('div');
overlay.className = 'custom-modal-overlay show';
overlay.innerHTML = `<div class="custom-modal"><h3>📁 New Folder</h3><input type="text" id="newFolderName" placeholder="Folder name"><div class="modal-actions"><button class="btn btn-outline btn-sm" id="cancelAddFolder">Cancel</button><button class="btn btn-sm" id="confirmAddFolder">Create</button></div></div>`;
document.body.appendChild(overlay);
document.getElementById('cancelAddFolder').onclick = () => overlay.remove();
document.getElementById('confirmAddFolder').onclick = async () => {
const name = document.getElementById('newFolderName').value.trim();
if (!name) { toast('Enter a name', 'error'); return; }
const ok = await addFolderToServer(name);
if (ok) { renderFolders(); populateFolderSelects(); overlay.remove(); toast('📁 Folder created!'); playSound('success'); }
};
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
playSound('open');
}
function showDeleteFolderConfirm(folderName) {
const overlay = document.createElement('div');
overlay.className = 'custom-modal-overlay show';
overlay.innerHTML = `<div class="custom-modal"><h3>🗑️ Delete Folder</h3><p style="color:var(--text2);margin-bottom:1rem;">Delete "${folderName}"? Entries move to "All".</p><div class="modal-actions"><button class="btn btn-outline btn-sm" id="cancelDeleteFolder">Cancel</button><button class="btn btn-sm btn-danger" id="confirmDeleteFolder">Delete</button></div></div>`;
document.body.appendChild(overlay);
document.getElementById('cancelDeleteFolder').onclick = () => overlay.remove();
document.getElementById('confirmDeleteFolder').onclick = async () => {
const ok = await deleteFolderFromServer(folderName);
if (ok) { renderFolders(); populateFolderSelects(); render(); overlay.remove(); toast('📁 Folder deleted'); playSound('delete'); }
};
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
}
// ==================== TRASH ====================
function toggleTrash() {
showTrash = !showTrash;
const btn = document.getElementById('trashBtn');
const actions = document.getElementById('trashActions');
if (btn) { btn.textContent = showTrash ? '📋 Active' : '🗑️ Trash'; btn.classList.toggle('btn-danger', showTrash); }
if (actions) actions.classList.toggle('hidden', !showTrash);
loadEntries();
playSound('click');
}
async function restoreEntry(id, noToast) {
try { const r = await fetch(API + '/entries/' + id + '/restore', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { if (!noToast) { toast('✅ Restored!'); await loadEntries(); playSound('success'); } } } catch (e) { if (!noToast) toast('⚠️ Error', 'error'); }
}
async function toggleFavorite(id) {
try { await fetch(API + '/entries/' + id + '/favorite', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); const e = entries.find(x => x.id == id); if (e) e.favorite = e.favorite ? 0 : 1; renderFolders(); render(); playSound('click'); } catch (e) {}
}
async function permanentDelete(id, silent) {
const doDelete = async () => {
try { const r = await fetch(API + '/entries/' + id + '?permanent=1', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { order = order.filter(x => x != id); localStorage.setItem('entryOrder', JSON.stringify(order)); if (!silent) { toast('🗑️ Permanently deleted'); await loadEntries(); playSound('error'); } } } catch (e) { if (!silent) toast('⚠️ Error', 'error'); }
};
if (silent) { await doDelete(); return; }
const btn = document.querySelector('.delete-btn[data-id="' + id + '"]');
if (btn) showBatchConfirm(btn, 'Permanently delete?', doDelete);
else showBatchConfirm(document.body, 'Permanently delete?', doDelete);
}
async function emptyTrash() {
const btn = document.querySelector('.empty-trash-btn');
showBatchConfirm(btn || document.body, 'Delete ALL trashed entries?', async () => {
try { const r = await fetch(API + '/entries/trash/empty', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { toast('🗑️ Trash emptied'); await loadEntries(); playSound('error'); } } catch (e) { toast('⚠️ Error', 'error'); }
});
}
function timeAgo(dateStr) {
if (!dateStr) return '';
const now = new Date(); const d = new Date(dateStr + 'Z');
const days = 30 - Math.floor((now - d) / (1000 * 60 * 60 * 24));
return days <= 0 ? 'Expiring' : days + 'd left';
}
// ==================== SETTINGS ====================
function toggleSettings() {
document.getElementById('settingsMenu').classList.toggle('hidden');
}
function syncSettingsUI() {
document.getElementById('soundToggleSwitch').classList.toggle('active', soundEnabled);
document.getElementById('themeToggleSwitch').classList.toggle('active', !dark);
document.getElementById('showViewBtnToggle').classList.toggle('active', showView);
document.getElementById('showEmailToggle').classList.toggle('active', showMail);
document.getElementById('autoLockTimer').value = lockMin;
}
async function registerPasskey() {
try {
const r = await fetch(API + '/passkey/register/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }
});
if (!r.ok) { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); return; }
const opts = await r.json();
opts.challenge = b642ab(opts.challenge);
opts.user.id = b642ab(opts.user.id);
if (!window.PublicKeyCredential) { toast('❌ Passkeys not supported', 'error'); return; }
const cred = await navigator.credentials.create({ publicKey: opts });
const result = {
id: cred.id,
response: {
clientDataJSON: a2b64(cred.response.clientDataJSON),
attestationObject: a2b64(cred.response.attestationObject)
}
};
const r2 = await fetch(API + '/passkey/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken },
body: JSON.stringify(result)
});
if (r2.ok) { toast('✅ Passkey registered!'); playSound('success'); }
else { const d = await r2.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); }
} catch (e) { toast('⚠️ Passkey setup failed: ' + e.message, 'error'); }
}
// ==================== INIT ====================
function init() {
document.getElementById('autoLockTimer').value = lockMin;
applyTheme();
// document.getElementById('usernameInput').style.display = showMail ? '' : 'none';
loadUsername();
const sl = localStorage.getItem('savedLoginUser');
if (sl) document.getElementById('loginUsername').value = sl;
syncSettingsUI();
// View dropdown
const vdd = document.getElementById('viewDropdown');
const vBtn = document.getElementById('viewDropdownBtn');
const vMenu = document.getElementById('viewDropdownMenu');
const vIcons = {grid:'🟫',compact:'📝',list:'📋',table:'📊',card:'🃏',grouped:'📂',detail:'🔍'};
const updateViewBtn = () => { vBtn.textContent = (vIcons[view] || '🟫') + ' ' + view.charAt(0).toUpperCase() + view.slice(1) + ' ▾'; };
updateViewBtn();
vMenu.querySelectorAll('.view-opt').forEach(o => o.classList.toggle('active', o.dataset.view === view));
vBtn.onclick = (e) => { e.stopPropagation(); vMenu.classList.toggle('hidden'); };
vMenu.onclick = (e) => {
const opt = e.target.closest('.view-opt');
if (!opt) return;
vMenu.querySelectorAll('.view-opt').forEach(o => o.classList.remove('active'));
opt.classList.add('active');
view = opt.dataset.view;
detailIndex = 0;
localStorage.setItem('vaultView', view);
updateViewBtn();
render();
playSound('click');
};
document.addEventListener('click', () => vMenu.classList.add('hidden'));
const clearBtn = document.getElementById('clearSearchBtn');
if (clearBtn) clearBtn.style.display = 'none';
document.getElementById('addModal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeAdd();
});
// Close settings when clicking outside
document.addEventListener('click', (e) => {
const menu = document.getElementById('settingsMenu');
const btn = document.getElementById('settingsBtn');
if (menu && !menu.classList.contains('hidden') && !menu.contains(e.target) && e.target !== btn) {
menu.classList.add('hidden');
}
});
}
function toggleViewBtn() { showView = !showView; localStorage.setItem('showViewBtn', showView); syncSettingsUI(); render(); playSound('click'); }
function toggleShowEmail() { showMail = !showMail; localStorage.setItem('showEmail', showMail); syncSettingsUI(); render(); playSound('click'); }
// ==================== GENERATOR ====================
function openGen() { document.getElementById('genModal').style.display = 'flex'; genPwd(); playSound('open'); }
function closeGen() { document.getElementById('genModal').style.display = 'none'; playSound('close'); }
function onLenChange() { document.getElementById('lenVal').textContent = document.getElementById('pwdLen').value; genPwd(); }
function genPreset(len, chars) {
document.getElementById('pwdLen').value = len;
document.getElementById('lenVal').textContent = len;
document.getElementById('useUpper').checked = chars.includes('upper');
document.getElementById('useLower').checked = chars.includes('lower');
document.getElementById('useNum').checked = chars.includes('num');
document.getElementById('useSym').checked = chars.includes('sym');
genPwd();
playSound('click');
}
function genPwd() { const l = parseInt(document.getElementById('pwdLen').value); let c = ''; if (document.getElementById('useUpper').checked) c += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; if (document.getElementById('useLower').checked) c += 'abcdefghijklmnopqrstuvwxyz'; if (document.getElementById('useNum').checked) c += '0123456789'; if (document.getElementById('useSym').checked) c += '!@#$%^&*()_+-=[]{}|;:,.<>?'; if (!c) { document.getElementById('genPreview').textContent = 'Select option'; return; } let p = ''; const arr = new Uint32Array(l); crypto.getRandomValues(arr); for (let i = 0; i < l; i++) p += c.charAt(arr[i] % c.length); genPwdVal = p; document.getElementById('genPreview').textContent = p; }
function useGen() {
if (!genPwdVal) genPwd();
// Put the generated password into the addmodals password field
const pwdField = document.getElementById('addPassword');
if (pwdField) {
pwdField.value = genPwdVal;
checkAddStrength(); // update the strength bar
}
navigator.clipboard.writeText(genPwdVal);
toast('🎲 Copied!');
closeGen(); // closes the generator modal, not the add modal
}
function populateFolderSelects() {
['addFolder', 'editFolder'].forEach(id => {
const select = document.getElementById(id);
if (!select) return;
select.innerHTML = '';
folders.forEach(f => {
if (!f) return;
const option = document.createElement('option');
option.value = f;
option.textContent = '📁 ' + f;
if (f === selectedFolder) option.selected = true;
select.appendChild(option);
});
});
}
function openAdd() {
document.getElementById('addSite').value = '';
document.getElementById('addPassword').value = '';
document.getElementById('addStrengthBar').className = 'strength-bar s0';
loadUsername();
populateFolderSelects();
document.getElementById('addModal').classList.add('show');
playSound('open');
}
function closeAdd() {
document.getElementById('addModal').classList.remove('show');
playSound('close');
}
function checkRegStrength() {
const p = document.getElementById('regPassword').value;
const bar = document.getElementById('regStrengthBar');
let s = 0;
if (p.length >= 8) s++;
if (p.length >= 12) s++;
if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++;
if (/\d/.test(p)) s++;
if (/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(p)) s++;
bar.className = 'strength-bar s' + Math.min(4, s);
}
function checkAddStrength() {
const p = document.getElementById('addPassword').value;
const bar = document.getElementById('addStrengthBar');
let s = 0;
if (p.length >= 8) s++;
if (p.length >= 12) s++;
if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++;
if (/\d/.test(p)) s++;
if (/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(p)) s++;
bar.className = 'strength-bar s' + Math.min(4, s);
}
// ==================== AUTH ====================
function switchTab(t) { document.querySelectorAll('.auth-tab').forEach(x => x.classList.remove('active')); event.target.classList.add('active'); document.getElementById('loginForm').classList.toggle('hidden', t !== 'login'); document.getElementById('registerForm').classList.toggle('hidden', t !== 'register'); }
async function loginWithPasskey() {
if (!window.PublicKeyCredential) { toast('❌ Passkeys not supported', 'error'); return; }
const u = document.getElementById('loginUsername').value.trim();
if (!u) { toast('Enter username first', 'error'); return; }
document.getElementById('loginBtn').disabled = true;
try {
const r = await fetch(API + '/passkey/login/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u })
});
if (!r.ok) { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); document.getElementById('loginBtn').disabled = false; return; }
const opts = await r.json();
opts.challenge = b642ab(opts.challenge);
opts.allowCredentials.forEach(c => { c.id = b642ab(c.id); });
const cred = await navigator.credentials.get({ publicKey: opts });
const result = {
id: cred.id,
response: {
clientDataJSON: a2b64(cred.response.clientDataJSON),
authenticatorData: a2b64(cred.response.authenticatorData),
signature: a2b64(cred.response.signature),
userHandle: cred.response.userHandle ? a2b64(cred.response.userHandle) : null
}
};
const r2 = await fetch(API + '/passkey/login/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result)
});
const d = await r2.json();
if (r2.ok) {
token = d.token; csrfToken = d.csrfToken || ''; curUser = d.username || u;
sessionStorage.setItem('authToken', token);
sessionStorage.setItem('csrfToken', csrfToken);
sessionStorage.setItem('currentUsername', curUser);
// Try to restore crypto key from sessionStorage
const restored = await restoreCryptoKey();
if (!restored) {
// Need master password once to derive crypto key
const mp = await new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'custom-modal-overlay show';
overlay.innerHTML = `<div class="custom-modal"><h3>🔑 One more step</h3><p style="color:var(--text2);margin-bottom:1rem">Enter your master password to unlock the vault</p><input type="password" id="passkeyTempPwd" placeholder="Master password" style="width:100%"><div class="modal-actions" style="margin-top:1rem"><button class="btn btn-sm" id="passkeyTempBtn">Unlock</button></div></div>`;
document.body.appendChild(overlay);
document.getElementById('passkeyTempBtn').onclick = () => resolve(document.getElementById('passkeyTempPwd').value);
overlay.addEventListener('keydown', function handler(e) { if (e.key === 'Enter') { resolve(document.getElementById('passkeyTempPwd').value); overlay.remove(); document.removeEventListener('keydown', handler); } });
});
const m = document.querySelector('.custom-modal-overlay.show');
if (m) m.remove();
cryptoKey = await deriveKey(mp, d.salt);
persistCryptoKey();
}
await loadFolders();
toast('✅ Biometric login!');
playSound('login');
showVault();
loadEntries();
} else { toast('❌ ' + (d.error || 'Failed'), 'error'); }
} catch (e) { toast('⚠️ Passkey login failed: ' + e.message, 'error'); }
finally { document.getElementById('loginBtn').disabled = false; }
}
async function login() {
const u = document.getElementById('loginUsername').value.trim();
const p = document.getElementById('loginPassword').value;
if (!u || !p) { toast('Fill all fields', 'error'); return; }
document.getElementById('loginBtn').disabled = true;
localStorage.setItem('savedLoginUser', u);
try {
const r = await fetch(API + '/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, masterPassword: p }) });
const d = await r.json();
if (r.ok) {
token = d.token; csrfToken = d.csrfToken || ''; curUser = u;
cryptoKey = await deriveKey(p, d.salt);
persistCryptoKey();
sessionStorage.setItem('authToken', token);
sessionStorage.setItem('csrfToken', csrfToken);
sessionStorage.setItem('currentUsername', u);
await loadFolders();
toast('✅ Login!');
playSound('login');
showVault();
loadEntries();
} else { toast('❌ ' + (d.error || 'Invalid'), 'error'); document.getElementById('loginPassword').value = ''; }
} catch (e) { toast('⚠️ Connection error', 'error'); }
finally { document.getElementById('loginBtn').disabled = false; }
}
async function register() {
const u = document.getElementById('regUsername').value.trim();
const p = document.getElementById('regPassword').value;
if (u.length < 3) { toast('Username min 3', 'error'); return; }
if (p.length < 8) { toast('Password min 8', 'error'); return; }
document.getElementById('registerBtn').disabled = true;
try {
const r = await fetch(API + '/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, masterPassword: p }) });
const d = await r.json();
if (r.ok) {
token = d.token; csrfToken = d.csrfToken || ''; curUser = u;
cryptoKey = await deriveKey(p, d.salt);
persistCryptoKey();
sessionStorage.setItem('authToken', token);
sessionStorage.setItem('csrfToken', csrfToken);
sessionStorage.setItem('currentUsername', u);
await loadFolders();
toast('✅ Created!');
playSound('register');
showVault();
loadEntries();
} else { toast('❌ ' + (d.error || 'Failed'), 'error'); }
} catch (e) { toast('⚠️ Connection error', 'error'); }
finally { document.getElementById('registerBtn').disabled = false; }
}
async function doLogout() {
saveUsername();
if (token) {
try { await fetch(API + '/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); } catch (e) {}
}
clearTimeout(idleT); clearTimeout(warnT); clearInterval(countT);
document.getElementById('idleWarning').classList.remove('show');
token = null; csrfToken = ''; curUser = null; entries = []; cryptoKey = null; folders = ['All']; showTrash = false;
sessionStorage.clear();
document.getElementById('authSection').classList.remove('hidden');
document.getElementById('vaultSection').classList.add('hidden');
document.getElementById('loginPassword').value = '';
document.getElementById('loginUsername').value = localStorage.getItem('savedLoginUser') || '';
}
function showVault() {
document.getElementById('authSection').classList.add('hidden');
document.getElementById('vaultSection').classList.remove('hidden');
document.getElementById('currentUser').textContent = '👤 ' + curUser;
//document.getElementById('usernameInput').style.display = showMail ? '' : 'none';
document.getElementById('autoLockTimer').value = lockMin;
loadUsername();
renderFolders();
populateFolderSelects();
syncSettingsUI();
resetIdle();
}
// ==================== ENTRIES ====================
function applyOrder(list) { if (!list || !list.length) return []; if (!order || !order.length) return list; const map = new Map(list.filter(e => e && e.id).map(e => [e.id, e])); const ord = []; order.forEach(id => { if (map.has(id)) { ord.push(map.get(id)); map.delete(id); } }); map.forEach(e => ord.push(e)); return ord; }
async function loadEntries(q) {
try {
let url = API + '/entries?deleted=' + (showTrash ? '1' : '0');
if (q) url += '&search=' + encodeURIComponent(q);
const r = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token } });
if (r.ok) {
const raw = await r.json();
entries = [];
for (const e of raw) {
if (e.encryption_method === 'client') {
const pw = await decryptPwd(e.encrypted_password, e.iv);
entries.push({ id: e.id, site: e.site, username: e.username, password: pw, folder: e.folder || 'All', deleted_at: e.deleted_at, favorite: e.favorite || 0 });
} else {
entries.push({ id: e.id, site: e.site, username: e.username, password: e.password || '', folder: e.folder || 'All', deleted_at: e.deleted_at, favorite: e.favorite || 0 });
}
}
entries = applyOrder(entries);
entries.sort((a, b) => (b.favorite || 0) - (a.favorite || 0));
document.getElementById('connectionStatus').textContent = '🟢 Connected';
document.getElementById('entryCount').textContent = '(' + entries.length + ' entries)';
renderFolders();
populateFolderSelects();
render();
} else if (r.status === 401) { toast('Session expired', 'error'); doLogout(); }
} catch (e) { document.getElementById('connectionStatus').textContent = '🔴 Error'; toast('Connection error', 'error'); }
}
function getFilteredEntries(noFolder) {
if (showTrash) return entries;
if (noFolder || selectedFolder === 'All') return entries;
return entries.filter(e => (e.folder || 'All') === selectedFolder);
}
// ==================== RENDER ====================
function render() {
const c = document.getElementById('entriesContainer');
c.className = '';
if (showTrash) c.classList.add('trash-view');
c.classList.add(view + '-view');
const filtered = getFilteredEntries();
if (!filtered.length) { c.innerHTML = '<div style="text-align:center;color:var(--text2);padding:2rem;grid-column:1/-1">' + (showTrash ? '📭 Trash empty' : '📭 No entries') + '</div>'; return; }
if (view === 'table') {
let h = '<table><thead><tr><th></th><th>Site</th>' + (showMail ? '<th>User</th>' : '') + '<th>Password</th>' + (!showTrash ? '<th>Folder</th>' : '<th>Deleted</th>') + '<th>Actions</th></tr></thead><tbody>';
filtered.forEach(e => {
const sel = selectedIds.has(e.id);
h += '<tr class="table-row-drag' + (e.favorite ? ' favorite' : '') + (sel ? ' selected' : '') + '" draggable="true" data-id="' + e.id + '" onclick="toggleSelectEntry(' + e.id + ', event)">' +
'<td>' + (e.favorite ? '⭐' : '') + '</td>' +
'<td>🌐 ' + highlightText(e.site, searchQuery) + '</td>' +
(showMail ? '<td>👤 ' + highlightText(e.username, searchQuery) + '</td>' : '') +
'<td class="password-cell"><span id="p-' + e.id + '" class="pw-display' + (showView ? ' pw-hover' : '') + '">••••••••</span></td>';
if (!showTrash) {
h += '<td><span class="entry-folder">📁 ' + esc(e.folder || 'All') + '</span></td>' +
'<td class="actions-cell">' +
'<button class="star-btn" data-id="' + e.id + '">' + (e.favorite ? '⭐' : '☆') + '</button> ' +
'<button class="icon-btn copy-p" data-id="' + e.id + '">📋</button> ' +
'<button class="edit-btn" data-id="' + e.id + '" style="position:static;display:inline-flex;vertical-align:middle;">✏️</button> ' +
'<button class="delete-btn" data-id="' + e.id + '" style="position:static;display:inline-flex;vertical-align:middle;">✕</button>' +
'</td>';
} else {
h += '<td><span class="trash-badge">🗑️ ' + timeAgo(e.deleted_at) + '</span></td>' +
'<td class="actions-cell">' +
'<button class="restore-btn" onclick="restoreEntry(' + e.id + ')">♻️</button> ' +
'<button class="delete-btn" data-id="' + e.id + '" style="position:static;display:inline-flex;vertical-align:middle;">✕</button>' +
'</td>';
}
h += '</tr>';
});
h += '</tbody></table>';
c.innerHTML = h;
} else if (view === 'grouped') {
c.innerHTML = groupedC(getFilteredEntries(true));
} else if (view === 'detail') {
c.innerHTML = detailC(getFilteredEntries(true));
} else {
c.innerHTML = filtered.map(e => {
if (view === 'grid' || view === 'card') return gridC(e);
if (view === 'compact') return compC(e);
return listC(e);
}).join('');
}
attachEvents();
setupDrag();
}
function gridC(e) {
const sel = selectedIds.has(e.id);
let html = '<div class="entry-card' + (e.favorite ? ' favorite' : '') + (sel ? ' selected' : '') + '" draggable="true" data-id="' + e.id + '" onclick="toggleSelectEntry(' + e.id + ', event)">';
html += '<div class="action-btns">';
if (showTrash) {
html += '<button class="restore-btn" onclick="restoreEntry(' + e.id + ')">♻️</button>';
html += '<button class="delete-btn" data-id="' + e.id + '">✕</button>';
} else {
html += '<button class="star-btn" data-id="' + e.id + '">' + (e.favorite ? '⭐' : '☆') + '</button>';
html += '<button class="edit-btn" data-id="' + e.id + '">✏️</button>';
html += '<button class="delete-btn" data-id="' + e.id + '">✕</button>';
}
html += '</div>';
html += '<div class="card-site">🌐 ' + highlightText(e.site, searchQuery) + '</div>';
if (showMail) html += '<div class="card-user">👤 ' + highlightText(e.username, searchQuery) + '</div>';
html += '<div class="card-folder">📁 ' + esc(e.folder || 'All') + '</div>';
if (!showTrash) {
html += '<div class="card-password"><span id="p-' + e.id + '" class="pw-display' + (showView ? ' pw-hover' : '') + '">••••••••</span><div>' +
'<button class="icon-btn copy-p" data-id="' + e.id + '">📋</button></div></div>';
} else {
html += '<div class="trash-info">🗑️ ' + timeAgo(e.deleted_at) + '</div>';
}
html += '</div>';
return html;
}
function listC(e) {
const sel = selectedIds.has(e.id);
let html = '<div class="entry-row' + (e.favorite ? ' favorite' : '') + (sel ? ' selected' : '') + '" draggable="true" data-id="' + e.id + '" onclick="toggleSelectEntry(' + e.id + ', event)">';
html += '<div class="action-btns">';
if (showTrash) {
html += '<button class="restore-btn" onclick="restoreEntry(' + e.id + ')">♻️</button>';
html += '<button class="delete-btn" data-id="' + e.id + '">✕</button>';
} else {
html += '<button class="star-btn" data-id="' + e.id + '">' + (e.favorite ? '⭐' : '☆') + '</button>';
html += '<button class="edit-btn" data-id="' + e.id + '">✏️</button>';
html += '<button class="delete-btn" data-id="' + e.id + '">✕</button>';
}
html += '</div>';
html += '<div class="entry-info"><span class="entry-site">🌐 ' + highlightText(e.site, searchQuery) + '</span>';
if (showMail) html += '<span class="entry-user">👤 ' + highlightText(e.username, searchQuery) + '</span>';
if (!showTrash) {
html += '<span class="entry-folder">📁 ' + esc(e.folder || 'All') + '</span>';
html += '<div class="password-field"><span class="password-text pw-display' + (showView ? ' pw-hover' : '') + '" id="p-' + e.id + '">••••••••</span>' +
'<button class="icon-btn copy-p" data-id="' + e.id + '">📋</button></div>';
} else {
html += '<span class="trash-badge">🗑️ ' + timeAgo(e.deleted_at) + '</span>';
}
html += '</div></div>';
return html;
}
function compC(e) {
const sel = selectedIds.has(e.id);
let html = '<div class="entry-compact' + (e.favorite ? ' favorite' : '') + (sel ? ' selected' : '') + '" draggable="true" data-id="' + e.id + '" onclick="toggleSelectEntry(' + e.id + ', event)">';
html += '<div class="action-btns">';
if (showTrash) {
html += '<button class="restore-btn" onclick="restoreEntry(' + e.id + ')">♻️</button>';
html += '<button class="delete-btn" data-id="' + e.id + '">✕</button>';
} else {
html += '<button class="star-btn" data-id="' + e.id + '">' + (e.favorite ? '⭐' : '☆') + '</button>';
html += '<button class="edit-btn" data-id="' + e.id + '">✏️</button>';
html += '<button class="delete-btn" data-id="' + e.id + '">✕</button>';
}
html += '</div>';
html += '<span>🌐 ' + highlightText(e.site, searchQuery) + '</span>';
if (showMail) html += '<span>👤 ' + highlightText(e.username, searchQuery) + '</span>';
if (!showTrash) {
html += '<span class="entry-folder">📁 ' + esc(e.folder || 'All') + '</span>';
html += '<span id="p-' + e.id + '" class="pw-display' + (showView ? ' pw-hover' : '') + '">••••••••</span>';
html += '<button class="icon-btn copy-p" data-id="' + e.id + '">📋</button>';
} else {
html += '<span class="trash-badge">🗑️ ' + timeAgo(e.deleted_at) + '</span>';
}
html += '</div>';
return html;
}
function groupedC(list) {
const groups = {};
list.forEach(e => {
const f = e.folder || 'All';
if (!groups[f]) groups[f] = [];
groups[f].push(e);
});
let html = '';
for (const [folder, items] of Object.entries(groups)) {
html += '<div class="grouped-header">📁 ' + esc(folder) + ' <span class="folder-count">' + items.length + '</span></div>';
items.forEach(e => {
const sel = selectedIds.has(e.id);
html += '<div class="entry-row' + (e.favorite ? ' favorite' : '') + (sel ? ' selected' : '') + '" draggable="true" data-id="' + e.id + '" onclick="toggleSelectEntry(' + e.id + ', event)">';
html += '<div class="action-btns">';
if (showTrash) {
html += '<button class="restore-btn" onclick="restoreEntry(' + e.id + ')">♻️</button>';
html += '<button class="delete-btn" data-id="' + e.id + '">✕</button>';
} else {
html += '<button class="star-btn" data-id="' + e.id + '">' + (e.favorite ? '⭐' : '☆') + '</button>';
html += '<button class="edit-btn" data-id="' + e.id + '">✏️</button>';
html += '<button class="delete-btn" data-id="' + e.id + '">✕</button>';
}
html += '</div>';
html += '<div class="entry-info"><span class="entry-site">🌐 ' + highlightText(e.site, searchQuery) + '</span>';
if (showMail) html += '<span class="entry-user">👤 ' + highlightText(e.username, searchQuery) + '</span>';
if (!showTrash) {
html += '<div class="password-field"><span class="password-text pw-display' + (showView ? ' pw-hover' : '') + '" id="p-' + e.id + '">••••••••</span>' +
'<button class="icon-btn copy-p" data-id="' + e.id + '">📋</button></div>';
} else {
html += '<span class="trash-badge">🗑️ ' + timeAgo(e.deleted_at) + '</span>';
}
html += '</div></div>';
});
}
return html;
}
function detailC(list) {
if (detailIndex >= list.length) detailIndex = 0;
if (detailIndex < 0) detailIndex = list.length - 1;
const e = list[detailIndex];
const hasPrev = detailIndex > 0, hasNext = detailIndex < list.length - 1;
const sel = selectedIds.has(e.id);
let html = '<div class="detail-nav">';
html += '<button onclick="goDetail(-1)"' + (hasPrev ? '' : ' disabled') + '>◀ Prev</button>';
html += '<span class="detail-counter">' + (detailIndex + 1) + ' of ' + list.length + '</span>';
html += '<button onclick="goDetail(1)"' + (hasNext ? '' : ' disabled') + '>Next ▶</button>';
html += '</div>';
html += '<div class="detail-card' + (e.favorite ? ' favorite' : '') + '">';
html += '<div class="detail-field"><span class="detail-label">Site</span><span class="detail-value">🌐 ' + highlightText(e.site, searchQuery) + '</span></div>';
if (showMail) html += '<div class="detail-field"><span class="detail-label">Username</span><span class="detail-value">👤 ' + highlightText(e.username, searchQuery) + '</span></div>';
if (!showTrash) {
html += '<div class="detail-field"><span class="detail-label">Password</span><span class="detail-value pw-display' + (showView ? ' pw-hover' : '') + '" id="p-' + e.id + '">••••••••</span></div>';
html += '<div class="detail-folder"><span class="detail-label">Folder</span><span class="entry-folder">📁 ' + esc(e.folder || 'All') + '</span></div>';
html += '<div class="detail-actions">' +
'<button class="star-btn" data-id="' + e.id + '">' + (e.favorite ? '⭐' : '☆') + '</button>' +
'<button class="icon-btn copy-p" data-id="' + e.id + '">📋</button>' +
'<button class="edit-btn" data-id="' + e.id + '">✏️</button>' +
'<button class="delete-btn" data-id="' + e.id + '">✕</button>' +
'</div>';
} else {
html += '<div class="detail-field"><span class="detail-label">Deleted</span><span class="trash-badge">🗑️ ' + timeAgo(e.deleted_at) + '</span></div>';
html += '<div class="detail-actions">' +
'<button class="restore-btn" onclick="restoreEntry(' + e.id + ')">♻️ Restore</button>' +
'<button class="delete-btn" data-id="' + e.id + '">✕ Delete</button>' +
'</div>';
}
html += '</div>';
return html;
}
function goDetail(dir) {
const list = getFilteredEntries(true);
detailIndex += dir;
if (detailIndex < 0) detailIndex = list.length - 1;
if (detailIndex >= list.length) detailIndex = 0;
render();
}
// ==================== EVENTS ====================
function showConfirm(btn, message, callback) {
const id = btn.dataset.id;
const existing = document.querySelector('.custom-confirm');
if (existing) existing.remove();
const confirm = document.createElement('div');
confirm.className = 'custom-confirm show';
confirm.innerHTML =
'<div class="confirm-text">' + message + '</div>' +
'<div class="confirm-btns">' +
'<button class="confirm-yes">Yes</button>' +
'<button class="confirm-no">No</button>' +
'</div>';
document.body.appendChild(confirm);
const rect = btn.getBoundingClientRect();
confirm.style.top = (rect.top - 60) + 'px';
let leftPos = rect.left - confirm.offsetWidth + rect.width;
if (leftPos < 10) leftPos = 10;
confirm.style.left = leftPos + 'px';
const yesBtn = confirm.querySelector('.confirm-yes');
const noBtn = confirm.querySelector('.confirm-no');
const cleanup = () => {
confirm.remove();
document.removeEventListener('keydown', keyHandler);
};
const keyHandler = (e) => {
if (e.key === 'Enter' || e.key === 'y' || e.key === 'Y') {
e.preventDefault();
cleanup();
callback(id);
showZigzagToast(btn, '🗑️ Deleted!', 'error');
playSound('delete');
} else if (e.key === 'Escape' || e.key === 'n' || e.key === 'N') {
e.preventDefault();
cleanup();
}
};
yesBtn.onclick = () => {
cleanup();
callback(id);
showZigzagToast(btn, '🗑️ Deleted!', 'error');
playSound('delete');
};
noBtn.onclick = () => cleanup();
// Focus the confirm box so keyboard events are captured
confirm.tabIndex = 0;
confirm.focus();
document.addEventListener('keydown', keyHandler);
// Close if clicking outside
setTimeout(() => {
document.addEventListener('click', function closeConfirm(e) {
if (!confirm.contains(e.target) && e.target !== btn) {
cleanup();
document.removeEventListener('click', closeConfirm);
}
});
}, 10);
}
function entryPw(id) { const e = entries.find(x => x.id == id); return e ? e.password : ''; }
function showBatchConfirm(btn, message, callback) {
const existing = document.querySelector('.batch-confirm-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'batch-confirm-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:9999;background:transparent;';
const confirm = document.createElement('div');
confirm.className = 'custom-confirm show';
confirm.style.cssText = 'position:fixed;background:var(--bg2);border:1px solid var(--accent);border-radius:0.8rem;padding:0.7rem 1rem;z-index:10000;box-shadow:0 10px 30px rgba(0,0,0,0.5);font-size:0.8rem;color:var(--text);white-space:nowrap;';
confirm.innerHTML =
'<div class="confirm-text">' + message + '</div>' +
'<div class="confirm-btns">' +
'<button class="confirm-yes">Yes</button>' +
'<button class="confirm-no">No</button>' +
'</div>';
const rect = btn.getBoundingClientRect();
confirm.style.top = (rect.top - 60) + 'px';
let leftPos = rect.left - 20;
if (leftPos < 10) leftPos = 10;
confirm.style.left = leftPos + 'px';
overlay.appendChild(confirm);
document.body.appendChild(overlay);
const yesBtn = confirm.querySelector('.confirm-yes');
const noBtn = confirm.querySelector('.confirm-no');
const cleanup = () => { overlay.remove(); document.removeEventListener('keydown', keyHandler); };
const keyHandler = (e) => {
if (e.key === 'Enter' || e.key === 'y' || e.key === 'Y') { e.preventDefault(); cleanup(); callback(); playSound('delete'); }
else if (e.key === 'Escape' || e.key === 'n' || e.key === 'N') { e.preventDefault(); cleanup(); }
};
yesBtn.onclick = () => { cleanup(); callback(); playSound('delete'); };
noBtn.onclick = () => cleanup();
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
confirm.tabIndex = 0; confirm.focus();
document.addEventListener('keydown', keyHandler);
}
function attachEvents() {
document.querySelectorAll('.delete-btn').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); if (showTrash) { permanentDelete(this.dataset.id); } else { showConfirm(this, 'Delete this entry?', id => delEntry(id)); } });
document.querySelectorAll('.edit-btn').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); openEdit(this.dataset.id); });
document.querySelectorAll('.star-btn').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); toggleFavorite(this.dataset.id); });
document.querySelectorAll('.copy-p').forEach(b => b.onclick = async function(ev) { ev.stopPropagation(); const pw = entryPw(this.dataset.id); try { await navigator.clipboard.writeText(pw); this.textContent = '✓'; const btn = this; setTimeout(() => { btn.textContent = '📋'; }, 1000); showZigzagToast(this, '📋 Copied!', 'success'); playSound('copy'); } catch (e) { showZigzagToast(this, 'Failed', 'error'); } });
// Password reveal on hover
if (showView) {
document.querySelectorAll('.pw-display.pw-hover').forEach(el => {
el.addEventListener('mouseenter', function() {
const pw = entryPw(this.id.replace('p-', ''));
this.textContent = pw;
});
el.addEventListener('mouseleave', function() {
this.textContent = '••••••••';
});
});
}
}
function setupDrag() {
const c = document.getElementById('entriesContainer'); if (!c) return;
c.querySelectorAll('[draggable="true"]').forEach(el => {
el.ondragstart = function(e) { draggedId = this.dataset.id; const dragIds = selectedIds.has(parseInt(draggedId)) && selectedIds.size > 1 ? [...selectedIds] : [parseInt(draggedId)]; c.querySelectorAll('[draggable="true"]').forEach(card => { card.classList.toggle('drag-dim', dragIds.includes(parseInt(card.dataset.id))); }); e.dataTransfer.setData('text/plain', this.dataset.id); e.dataTransfer.effectAllowed = 'move'; if (dragIds.length > 1) { const cv = document.createElement('canvas'); cv.width = 100; cv.height = 50; const g = cv.getContext('2d'); for (let i = dragIds.length - 1; i >= 0; i--) { const ox = i * 4, oy = i * 4; g.fillStyle = i === 0 ? 'rgba(30,40,55,0.9)' : 'rgba(59,130,246,0.15)'; g.fillRect(ox, oy, 80, 36); g.strokeStyle = 'rgba(255,255,255,0.15)'; g.strokeRect(ox, oy, 80, 36); } g.fillStyle = 'rgba(0,0,0,0.7)'; g.fillRect(0, 34, 100, 16); g.fillStyle = '#fff'; g.font = '11px sans-serif'; g.textAlign = 'center'; g.fillText(dragIds.length + ' items', 50, 46); cv.style.position = 'fixed'; cv.style.top = '-1000px'; document.body.appendChild(cv); e.dataTransfer.setDragImage(cv, 6, 10); setTimeout(() => cv.remove(), 50); } };
el.ondragend = function(e) { c.querySelectorAll('.drag-dim').forEach(card => card.classList.remove('drag-dim')); draggedId = null; c.querySelectorAll('.drag-over').forEach(x => x.classList.remove('drag-over')); document.getElementById('trashBtn')?.classList.remove('drag-over'); };
el.ondragover = function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (this.dataset.id !== draggedId) this.classList.add('drag-over'); };
el.ondragleave = function(e) { this.classList.remove('drag-over'); };
el.ondrop = function(e) { e.preventDefault(); e.stopPropagation(); this.classList.remove('drag-over'); const fromId = parseInt(e.dataTransfer.getData('text/plain')); const toId = parseInt(this.dataset.id); if (!fromId || !toId) return; const ids = selectedIds.has(fromId) && selectedIds.size > 1 ? [...selectedIds] : [fromId]; if (ids.length === 1 && ids[0] === toId) return; const base = order.length > 0 ? order : entries.filter(e => e).map(e => e.id); const filtered = base.filter(id => !ids.includes(id)); const idx = filtered.indexOf(toId); idx > -1 ? filtered.splice(idx, 0, ...ids) : filtered.push(...ids); order = filtered; // Ensure no entries are lost from order
entries.forEach(e => { if (!order.includes(e.id)) order.push(e.id); }); localStorage.setItem('entryOrder', JSON.stringify(order)); const map = new Map(entries.filter(e => e).map(e => [e.id, e])); entries = order.map(id => map.get(id)).filter(e => e); entries.sort((a, b) => (b.favorite || 0) - (a.favorite || 0)); render(); };
});
}
// ==================== EDIT ====================
function openEdit(id) {
let e = null;
for (let i = 0; i < entries.length; i++) { if (entries[i] && entries[i].id == id) { e = entries[i]; break; } }
if (!e) return;
const folderSelect = document.getElementById('editFolder');
folderSelect.innerHTML = '';
folders.forEach(f => {
if (!f) return;
const option = document.createElement('option');
option.value = f;
option.textContent = '📁 ' + f;
if (f === (e.folder || 'All')) option.selected = true;
folderSelect.appendChild(option);
});
document.getElementById('editId').value = id;
document.getElementById('editSite').value = e.site;
document.getElementById('editUsername').value = e.username;
document.getElementById('editPassword').value = e.password;
document.getElementById('editPassword').type = 'password';
document.getElementById('editModal').classList.add('show');
playSound('open');
}
function closeEdit() { document.getElementById('editModal').classList.remove('show'); playSound('close'); }
function toggleEditPassword() { const f = document.getElementById('editPassword'); f.type = f.type === 'password' ? 'text' : 'password'; }
async function saveEdit() {
const id = document.getElementById('editId').value;
const site = document.getElementById('editSite').value.trim();
const username = document.getElementById('editUsername').value.trim();
const password = document.getElementById('editPassword').value;
const folder = document.getElementById('editFolder').value;
if (!site || !password) { toast('Site and password required', 'error'); return; }
try {
const enc = await encryptPwd(password);
const r = await fetch(API + '/entries/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, body: JSON.stringify({ site, username, encrypted_password: enc.encrypted, iv: enc.iv, folder }) });
if (r.ok) { toast('✅ Updated!'); closeEdit(); loadEntries(); playSound('success'); }
else { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); }
} catch (e) { toast('⚠️ Error', 'error'); }
}
//======================== batch selection ==========================
function toggleSelectEntry(id, e) {
if (e?.shiftKey && lastSelectedId !== null) {
const ids = getFilteredEntries().map(x => x.id);
const i1 = ids.indexOf(lastSelectedId);
const i2 = ids.indexOf(id);
if (i1 > -1 && i2 > -1) {
const start = Math.min(i1, i2), end = Math.max(i1, i2);
for (let i = start; i <= end; i++) selectedIds.add(ids[i]);
}
} else if (e?.ctrlKey || e?.metaKey) {
if (selectedIds.has(id)) selectedIds.delete(id); else selectedIds.add(id);
} else {
if (selectedIds.size === 1 && selectedIds.has(id)) { selectedIds.clear(); }
else { selectedIds.clear(); selectedIds.add(id); }
}
lastSelectedId = id;
updateBatchBar();
render();
}
function clearSelection() {
selectedIds.clear();
lastSelectedId = null;
hideBatchBar();
render();
}
function updateBatchBar() {
const existing = document.getElementById('batchBar');
if (existing) existing.remove();
if (selectedIds.size === 0) return;
const bar = document.createElement('div');
bar.id = 'batchBar';
bar.className = 'batch-actions';
bar.innerHTML = `<span class="selected-count">${selectedIds.size} selected</span>`;
if (showTrash) {
bar.innerHTML += `
<button class="btn btn-sm restore-btn" onclick="batchRestore()">♻️ Restore All</button>
<button class="btn btn-sm btn-danger" onclick="batchPermanentDelete()">🗑️ Delete</button>
`;
} else {
bar.innerHTML += `
<select id="batchFolder" style="font-size:0.75rem;padding:0.2rem 0.5rem;">
${folders.map(f => `<option value="${f}">📁 ${f}</option>`).join('')}
</select>
<button class="btn btn-sm" onclick="batchMove()">Move</button>
<button class="btn btn-sm btn-danger" onclick="batchDelete()">Delete</button>
`;
}
bar.innerHTML += `<button class="btn btn-sm btn-outline" onclick="clearSelection()">Clear</button>`;
document.body.appendChild(bar);
}
function hideBatchBar() {
const bar = document.getElementById('batchBar');
if (bar) bar.remove();
}
async function batchDelete() {
const count = selectedIds.size;
const btn = document.querySelector('#batchBar .btn-danger');
showBatchConfirm(btn || document.body, 'Move ' + count + ' entries to trash?', async () => {
for (const id of selectedIds) await delEntry(id, true);
toast('📦 Moved ' + count + ' entries to trash');
playSound('delete');
clearSelection();
await loadEntries();
});
}
async function batchPermanentDelete() {
const count = selectedIds.size;
const btn = document.querySelector('#batchBar .btn-danger');
showBatchConfirm(btn || document.body, 'Permanently delete ' + count + ' entries?', async () => {
for (const id of selectedIds) await permanentDelete(id, true);
toast('🗑️ Permanently deleted ' + count + ' entries');
playSound('error');
clearSelection();
await loadEntries();
});
}
async function batchRestore() {
const count = selectedIds.size;
for (const id of selectedIds) await restoreEntry(id, true);
toast('✅ Restored ' + count + ' entries');
playSound('success');
clearSelection();
await loadEntries();
}
async function batchMove() {
const folder = document.getElementById('batchFolder')?.value || 'All';
for (const id of selectedIds) {
const e = entries.find(x => x.id == id);
if (e) {
e.folder = folder;
const enc = await encryptPwd(e.password);
await fetch(API + '/entries/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ site: e.site, username: e.username, encrypted_password: enc.encrypted, iv: enc.iv, folder })
});
}
}
clearSelection();
loadEntries();
}
// ==================== ADD / DELETE ====================
async function addEntry() {
const site = document.getElementById('addSite').value.trim();
const user = document.getElementById('addUsername').value.trim();
const pass = document.getElementById('addPassword').value;
if (!site || !pass) { toast('❌ Site and password required', 'error'); return; }
if (user) localStorage.setItem('savedUsername', user);
const btn = document.getElementById('addEntryBtn');
btn.disabled = true;
try {
const enc = await encryptPwd(pass);
const folder = document.getElementById('addFolder').value;
const r = await fetch(API + '/entries', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ site, username: user, encrypted_password: enc.encrypted, iv: enc.iv, encryption_method: 'client', folder })
});
if (r.ok) {
closeAdd();
toast('✅ Saved!');
loadEntries();
playSound('success');
} else {
const d = await r.json();
toast('❌ ' + (d.error || 'Failed'), 'error');
}
} catch (e) { toast('⚠️ Error', 'error'); }
finally { btn.disabled = false; }
}
async function delEntry(id, noToast) {
try {
const r = await fetch(API + '/entries/' + id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } });
if (r.ok) { selectedIds.delete(id); order = order.filter(x => x != id); localStorage.setItem('entryOrder', JSON.stringify(order)); if (!noToast) { toast('📦 Moved to trash'); await loadEntries(); playSound('delete'); } }
} catch (e) { if (!noToast) toast('Error', 'error'); }
}
// ==================== UTILS ====================
function searchEntries() {
const input = document.getElementById('searchInput');
searchQuery = input.value.trim();
const btn = document.getElementById('clearSearchBtn');
if (btn) btn.style.display = searchQuery ? 'block' : 'none';
loadEntries(searchQuery);
}
function showExportModal() {
const overlay = document.createElement('div');
overlay.className = 'custom-modal-overlay show';
overlay.innerHTML = `<div class="custom-modal"><h3>📤 Export Passwords</h3><p style="color:var(--text2);margin-bottom:1rem;">Re-enter master password to export plaintext passwords</p><input type="password" id="exportPassword" placeholder="Master password" style="width:100%"><div class="modal-actions" style="margin-top:1rem"><button class="btn btn-outline btn-sm" id="cancelExport">Cancel</button><button class="btn btn-sm" id="confirmExport">Export</button></div></div>`;
document.body.appendChild(overlay);
document.getElementById('cancelExport').onclick = () => overlay.remove();
document.getElementById('confirmExport').onclick = async () => {
const pwd = document.getElementById('exportPassword').value;
if (!pwd) { toast('Enter your master password', 'error'); return; }
try {
const r = await fetch(API + '/reauth', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ masterPassword: pwd })
});
if (r.ok) {
overlay.remove();
const b = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = 'vault-' + new Date().toISOString().slice(0, 10) + '.json';
a.click();
URL.revokeObjectURL(a.href);
toast('Exported!');
playSound('success');
} else {
toast('❌ Invalid password', 'error');
}
} catch (e) { toast('⚠️ Error', 'error'); }
};
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
playSound('open');
}
function esc(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
function escRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function highlightText(text, query) {
if (!query || !query.trim()) return esc(text);
const re = new RegExp('(' + escRegex(query.trim()) + ')', 'gi');
return esc(text).replace(re, '<mark style="background:var(--accent);color:#fff;border-radius:3px;padding:0 2px">$1</mark>');
}
function showShortcutsHelp() {
const overlay = document.createElement('div');
overlay.className = 'custom-modal-overlay show';
overlay.innerHTML = `<div class="custom-modal" style="min-width:380px"><h3>⌨️ Keyboard Shortcuts</h3><div style="display:grid;grid-template-columns:auto 1fr;gap:0.5rem 1.2rem;font-size:0.85rem;margin-bottom:1rem"><span style="background:var(--input);padding:0.15rem 0.6rem;border-radius:0.4rem;font-family:monospace;text-align:center">Ctrl+N</span><span>New entry</span><span style="background:var(--input);padding:0.15rem 0.6rem;border-radius:0.4rem;font-family:monospace;text-align:center">Ctrl+A</span><span>Select all</span><span style="background:var(--input);padding:0.15rem 0.6rem;border-radius:0.4rem;font-family:monospace;text-align:center">Ctrl+F</span><span>Search</span><span style="background:var(--input);padding:0.15rem 0.6rem;border-radius:0.4rem;font-family:monospace;text-align:center">Ctrl+T</span><span>Toggle trash</span><span style="background:var(--input);padding:0.15rem 0.6rem;border-radius:0.4rem;font-family:monospace;text-align:center">Ctrl+L</span><span>Lock vault</span><span style="background:var(--input);padding:0.15rem 0.6rem;border-radius:0.4rem;font-family:monospace;text-align:center">Ctrl+S</span><span>Save entry</span><span style="background:var(--input);padding:0.15rem 0.6rem;border-radius:0.4rem;font-family:monospace;text-align:center">Esc</span><span>Close modal / deselect</span><span style="background:var(--input);padding:0.15rem 0.6rem;border-radius:0.4rem;font-family:monospace;text-align:center">◀ ▶</span><span>Detail view nav</span><span style="background:var(--input);padding:0.15rem 0.6rem;border-radius:0.4rem;font-family:monospace;text-align:center">?</span><span>Show this help</span></div><p style="color:var(--text2);font-size:0.8rem;margin-bottom:0.8rem">💡 Click any entry to select, Shift+click for range, Ctrl+click to toggle</p><div class="modal-actions"><button class="btn btn-sm" onclick="this.closest('.custom-modal-overlay').remove()">Got it</button></div></div>`;
document.body.appendChild(overlay);
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
}
function clearSearch() {
const input = document.getElementById('searchInput');
input.value = '';
searchQuery = '';
const btn = document.getElementById('clearSearchBtn');
if (btn) btn.style.display = 'none';
loadEntries();
input.focus();
}
// ==================== STARTUP session persistence ====================
init();
applyTheme();
if (token && curUser) {
(async () => {
if (await restoreCryptoKey()) {
await loadFolders();
showVault();
loadEntries();
} else {
sessionStorage.clear();
token = null;
curUser = null;
document.getElementById('loginUsername').value = localStorage.getItem('savedLoginUser') || '';
}
})();
}
['click', 'keypress', 'scroll', 'mousemove'].forEach(e => document.addEventListener(e, () => { if (token) resetIdle(); }));
// Trash button as drop target (set up once, outside setupDrag to avoid duplicates)
(function() {
const trashBtn = document.getElementById('trashBtn');
if (trashBtn) {
trashBtn.addEventListener('dragover', e => { if (!showTrash) { e.preventDefault(); trashBtn.classList.add('drag-over'); } });
trashBtn.addEventListener('dragleave', () => trashBtn.classList.remove('drag-over'));
trashBtn.addEventListener('drop', async function(e) {
e.preventDefault();
this.classList.remove('drag-over');
const id = parseInt(e.dataTransfer.getData('text/plain'));
if (!id) return;
const ids = selectedIds.has(id) && selectedIds.size > 1 ? [...selectedIds] : [id];
for (const sid of ids) await delEntry(sid, true);
toast('📦 Moved ' + ids.length + ' entries to trash');
playSound('delete');
clearSelection();
await loadEntries();
});
}
})();
// Prevent native drag on non-card elements (table headers, text, etc.)
document.getElementById('entriesContainer').addEventListener('dragstart', function(e) {
if (!e.target?.closest?.('[draggable="true"]')) e.preventDefault();
});
// Document mousedown: rect selection on vault background, clear outside vault
document.addEventListener('mousedown', function(e) {
if (e.button !== 0 || rectState.active) return;
if (e.target?.closest?.('.entry-card,.entry-row,.entry-compact,.table-row-drag,.detail-card,.detail-nav,#batchBar,.custom-modal-overlay.show,.edit-modal.show,.modal-overlay.show,#genModal,#settingsMenu,.batch-confirm-overlay')) return;
if (e.target?.closest?.('button,input,select,.folders-bar,.toolbar,#trashActions,.settings-dropdown,.fab,.auth-section')) {
if (selectedIds.size > 0) clearSelection();
return;
}
if (e.target?.closest?.('.vault')) {
rectState.active = true;
rectState.startX = e.clientX;
rectState.startY = e.clientY;
rectState.started = false;
rectState.el = null;
selectedIds.clear();
lastSelectedId = null;
hideBatchBar();
document.getElementById('entriesContainer')?.querySelectorAll('.selected').forEach(el => el.classList.remove('selected'));
} else if (selectedIds.size > 0) {
clearSelection();
}
});
document.addEventListener('mousemove', function(e) {
if (!rectState.active) return;
const dx = e.clientX - rectState.startX;
const dy = e.clientY - rectState.startY;
if (!rectState.started && (dx > 5 || dx < -5 || dy > 5 || dy < -5)) {
rectState.started = true;
rectState.el = document.createElement('div');
rectState.el.id = 'rectSelect';
document.body.appendChild(rectState.el);
}
if (rectState.el) {
const x = Math.min(rectState.startX, e.clientX);
const y = Math.min(rectState.startY, e.clientY);
const w = Math.abs(dx);
const h = Math.abs(dy);
rectState.el.style.cssText = `left:${x}px;top:${y}px;width:${w}px;height:${h}px;display:block;position:fixed;pointer-events:none;z-index:999;border:1px solid var(--accent);background:rgba(59,130,246,0.1);`;
}
});
document.addEventListener('mouseup', function(e) {
if (!rectState.active) return;
rectState.active = false;
if (rectState.el) {
rectState.el.remove();
rectState.el = null;
if (rectState.started) {
const r = {
left: Math.min(rectState.startX, e.clientX),
top: Math.min(rectState.startY, e.clientY),
right: Math.max(rectState.startX, e.clientX),
bottom: Math.max(rectState.startY, e.clientY)
};
const container = document.getElementById('entriesContainer');
if (container) {
container.querySelectorAll('.entry-card,.entry-row,.entry-compact,.table-row-drag').forEach(el => {
const er = el.getBoundingClientRect();
if (er.left < r.right && er.right > r.left && er.top < r.bottom && er.bottom > r.top) {
const id = parseInt(el.dataset.id);
if (id) selectedIds.add(id);
}
});
}
if (selectedIds.size > 0) { updateBatchBar(); render(); }
}
}
});
document.addEventListener('keypress', e => { if (e.key === 'Enter') { if (document.getElementById('passwordInput') === document.activeElement) addEntry(); else if (document.getElementById('loginPassword') === document.activeElement) login(); else if (document.getElementById('regPassword') === document.activeElement) register(); } });
document.getElementById('genModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeGen(); });
document.getElementById('editModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEdit(); });
// ==================== KEYBOARD SHORTCUTS ====================
document.addEventListener('keydown', function(e) {
const tag = document.activeElement?.tagName;
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
// Escape close any open modal or settings
if (e.key === 'Escape') {
if (rectState.active || rectState.el) { rectState.active = false; if (rectState.el) { rectState.el.remove(); rectState.el = null; } clearSelection(); return; }
if (selectedIds.size > 0) { clearSelection(); return; }
if (!document.getElementById('settingsMenu').classList.contains('hidden')) {
document.getElementById('settingsMenu').classList.add('hidden');
return;
}
if (document.getElementById('addModal').classList.contains('show')) { closeAdd(); return; }
if (document.getElementById('editModal').classList.contains('show')) { closeEdit(); return; }
if (document.getElementById('genModal').style.display === 'flex') { closeGen(); return; }
const confirm = document.querySelector('.custom-confirm.show');
if (confirm) confirm.remove();
return;
}
// Arrow keys for detail view navigation
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !isInput && view === 'detail' && !document.getElementById('authSection').classList.contains('hidden')) {
e.preventDefault();
goDetail(e.key === 'ArrowLeft' ? -1 : 1);
return;
}
// ? or / to show shortcuts help (only in vault)
if ((e.key === '?' || e.key === '/') && !isInput) {
if (!document.getElementById('authSection').classList.contains('hidden')) return;
e.preventDefault();
showShortcutsHelp();
return;
}
// Only handle Ctrl+[key], no Shift/Alt/Meta
if (!e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return;
// Prevent browser defaults for ALL our shortcuts BEFORE dispatching
const code = e.code;
if (code === 'KeyN' || code === 'KeyF' || code === 'KeyT' || code === 'KeyL' || code === 'KeyS' || code === 'KeyA') {
e.preventDefault();
}
if (code === 'KeyN') {
if (!isInput && !document.getElementById('addModal').classList.contains('show')) openAdd();
} else if (code === 'KeyF') {
const el = document.getElementById('searchInput');
if (el) { el.focus(); el.select(); }
} else if (code === 'KeyT') {
if (!isInput) toggleTrash();
} else if (code === 'KeyL') {
if (!isInput) doLogout();
} else if (code === 'KeyS') {
if (document.getElementById('addModal').classList.contains('show')) addEntry();
else if (document.getElementById('editModal').classList.contains('show')) saveEdit();
} else if (code === 'KeyA') {
if (!isInput && !document.getElementById('authSection').classList.contains('hidden')) {
getFilteredEntries(view === 'grouped' || view === 'detail').forEach(e => selectedIds.add(e.id));
updateBatchBar();
render();
playSound('click');
}
}
});