1505 lines
81 KiB
JavaScript
1505 lines
81 KiB
JavaScript
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 max = 256 - (256 % c.length); const buf = new Uint8Array(1); for (let i = 0; i < l; i++) { do { crypto.getRandomValues(buf); } while (buf[0] >= max); p += c.charAt(buf[0] % c.length); } genPwdVal = p; document.getElementById('genPreview').textContent = p; }
|
||
function useGen() {
|
||
if (!genPwdVal) genPwd();
|
||
// Put the generated password into the add‑modal’s 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');
|
||
document.getElementById('addSite').focus();
|
||
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' : '') + '" data-id="' + e.id + '">';
|
||
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'); } });
|
||
// Double-click entry to edit
|
||
document.querySelectorAll('[draggable="true"], .detail-card').forEach(el => {
|
||
el.addEventListener('dblclick', function(ev) {
|
||
const id = this.dataset.id;
|
||
if (id && !showTrash) { ev.stopPropagation(); openEdit(id); }
|
||
});
|
||
});
|
||
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');
|
||
document.getElementById('editSite').focus();
|
||
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">Alt+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">Alt+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">Del</span><span>Delete selected</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('keydown', e => { if (e.key === 'Enter' && !e.ctrlKey && !e.altKey && !e.metaKey) { const a = document.activeElement; if (!a || a.tagName === 'BUTTON') return; e.preventDefault(); if (document.getElementById('editModal').classList.contains('show') && a.closest('.edit-box')) saveEdit(); else if (document.getElementById('addModal').classList.contains('show') && a.closest('.modal-box')) addEntry(); else if (!document.getElementById('authSection').classList.contains('hidden')) { if (a.id === 'loginUsername' || a.id === 'loginPassword') login(); else if (a.id === 'regPassword') 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;
|
||
}
|
||
|
||
// Arrow keys — navigate entries in vault
|
||
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !isInput && document.getElementById('authSection').classList.contains('hidden')) {
|
||
e.preventDefault();
|
||
const filtered = getFilteredEntries();
|
||
if (!filtered.length) return;
|
||
const isNext = e.key === 'ArrowDown' || e.key === 'ArrowRight';
|
||
let idx = -1;
|
||
if (selectedIds.size > 0) {
|
||
const firstId = [...selectedIds][0];
|
||
idx = filtered.findIndex(e => e.id == firstId);
|
||
}
|
||
if (isNext) idx = idx < filtered.length - 1 ? idx + 1 : 0;
|
||
else idx = idx > 0 ? idx - 1 : filtered.length - 1;
|
||
if (e.shiftKey) {
|
||
const anchorId = lastSelectedId !== null ? lastSelectedId : filtered[idx].id;
|
||
const anchor = filtered.findIndex(e => e.id == anchorId);
|
||
const start = Math.min(anchor, idx), end = Math.max(anchor, idx);
|
||
selectedIds.clear();
|
||
for (let i = start; i <= end; i++) selectedIds.add(filtered[i].id);
|
||
} else {
|
||
selectedIds.clear();
|
||
selectedIds.add(filtered[idx].id);
|
||
lastSelectedId = filtered[idx].id;
|
||
}
|
||
updateBatchBar(); render(); playSound('click');
|
||
return;
|
||
}
|
||
|
||
// Enter — open edit for single selected entry
|
||
if (e.key === 'Enter' && !isInput && selectedIds.size === 1 && document.getElementById('authSection').classList.contains('hidden') && !document.getElementById('editModal').classList.contains('show') && !document.getElementById('addModal').classList.contains('show')) {
|
||
e.preventDefault();
|
||
openEdit([...selectedIds][0]);
|
||
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;
|
||
}
|
||
|
||
// Delete key — move to trash or permanently delete selected entries
|
||
if (e.key === 'Delete' && !isInput && selectedIds.size > 0 && document.getElementById('authSection').classList.contains('hidden')) {
|
||
e.preventDefault();
|
||
if (showTrash) batchPermanentDelete();
|
||
else batchDelete();
|
||
return;
|
||
}
|
||
|
||
// Alt+N — New entry (Ctrl+N intercepted by browser)
|
||
if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 'n' || e.key === 'N') && !isInput && !document.getElementById('addModal').classList.contains('show') && document.getElementById('authSection').classList.contains('hidden')) {
|
||
e.preventDefault();
|
||
openAdd();
|
||
return;
|
||
}
|
||
|
||
// Alt+T — Toggle trash (Ctrl+T intercepted by browser)
|
||
if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 't' || e.key === 'T') && !isInput && document.getElementById('authSection').classList.contains('hidden')) {
|
||
e.preventDefault();
|
||
toggleTrash();
|
||
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 === 'KeyF' || code === 'KeyL' || code === 'KeyS') {
|
||
e.preventDefault();
|
||
}
|
||
|
||
if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && (e.key === 'a' || e.key === 'A') && !isInput && document.getElementById('authSection').classList.contains('hidden')) {
|
||
e.preventDefault();
|
||
getFilteredEntries(view === 'grouped' || view === 'detail').forEach(e => selectedIds.add(e.id));
|
||
updateBatchBar();
|
||
render();
|
||
playSound('click');
|
||
return;
|
||
}
|
||
|
||
if (code === 'KeyF') {
|
||
const el = document.getElementById('searchInput');
|
||
if (el) { el.focus(); el.select(); }
|
||
} 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();
|
||
}
|
||
});
|
||
document.getElementById('loginUsername').focus(); |