From 8abb1c5ad080a42a20012cf2ab1e4f72721c0eed Mon Sep 17 00:00:00 2001 From: Zaki <18zaki18@gmail.com> Date: Fri, 8 May 2026 23:53:47 +0100 Subject: [PATCH] Add hover reveal, drag-to-folder, generator presets, favorites - Inline password reveal on hover (controlled by showView setting, replaces eye button) - Drag an entry card onto a folder chip to move it (no modal needed) - Generator presets: Strong 16, Strong 20, Paranoid 32 buttons - Favorites: star toggle button per entry, entries sort to top - Add favorite column to vault_entries, toggle endpoint, star UI in all views - Gold border/background for favorited entries --- api.php | 14 +++++++++ css/style.css | 8 +++++ index.html | 7 ++++- js/app.js | 86 +++++++++++++++++++++++++++++++++++++++++---------- 4 files changed, 97 insertions(+), 18 deletions(-) diff --git a/api.php b/api.php index 8a097b9..7bef32e 100644 --- a/api.php +++ b/api.php @@ -88,6 +88,7 @@ try { $db->exec("ALTER TABLE vault_entries ADD COLUMN deleted_at DATETIME"); } c try { $db->exec("ALTER TABLE users DROP COLUMN encryption_key"); } catch (Exception $e) {} try { $db->exec("ALTER TABLE users ADD COLUMN hash_algo TEXT DEFAULT 'pbkdf2'"); } catch (Exception $e) {} try { $db->exec("ALTER TABLE sessions ADD COLUMN csrf_token TEXT"); } catch (Exception $e) {} +try { $db->exec("ALTER TABLE vault_entries ADD COLUMN favorite INTEGER DEFAULT 0"); } catch (Exception $e) {} $db->exec("DELETE FROM sessions WHERE expires_at < datetime('now')"); $db->exec("DELETE FROM login_attempts WHERE attempted_at < datetime('now', '-15 minutes')"); @@ -321,6 +322,7 @@ try { 'folder' => $r['folder'] ?? 'All', 'deleted' => $r['deleted'], 'deleted_at' => $r['deleted_at'], + 'favorite' => (int)($r['favorite'] ?? 0), 'created_at' => $r['created_at'], 'updated_at' => $r['updated_at'] ]; @@ -405,6 +407,18 @@ try { echo json_encode(['message'=>'Restored']); break; + // Favorite toggle + case (preg_match('/^\/entries\/(\d+)\/favorite$/', $path, $m) && $method === 'POST'): + $auth = authenticate($db); + requireCSRF($db, $auth['userId']); + $st = $db->prepare('UPDATE vault_entries SET favorite = CASE WHEN favorite=1 THEN 0 ELSE 1 END WHERE id=:id AND user_id=:uid'); + $st->bindValue(':id', $m[1], SQLITE3_INTEGER); + $st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER); + $st->execute(); + logAudit($db, $auth['userId'], 'toggle_favorite'); + echo json_encode(['message'=>'Toggled']); + break; + case ($path === '/logout' && $method === 'POST'): $auth = authenticate($db); requireCSRF($db, $auth['userId']); diff --git a/css/style.css b/css/style.css index 64aa8df..5b30a37 100644 --- a/css/style.css +++ b/css/style.css @@ -117,6 +117,8 @@ input:focus, select:focus { border-color:var(--accent); } .folder-chip { background:var(--bg2); border:1px solid var(--border); color:var(--text2); padding:0.3rem 0.8rem; border-radius:2rem; cursor:pointer; font-size:0.78rem; transition:0.2s; white-space:nowrap; } .folder-chip:hover { background:var(--chip-color, var(--accent)); color:#fff; border-color:var(--chip-color, var(--accent)); } .folder-chip.active { background:var(--chip-color, var(--accent)); color:#fff; border-color:var(--chip-color, var(--accent)); } +.folder-chip.drag-over { border-color:var(--accent)!important; box-shadow:0 0 12px rgba(59,130,246,0.5); transform:scale(1.05); } +.pw-display.pw-hover { cursor:pointer; } .folder-count { background:rgba(0,0,0,0.3); padding:0.1rem 0.4rem; border-radius:1rem; margin-left:0.3rem; font-size:0.7rem; } .folder-delete-btn { background:transparent; border:none; color:var(--danger); cursor:pointer; font-size:0.7rem; margin-left:0.2rem; opacity:0.7; } .folder-delete-btn:hover { opacity:1; } @@ -151,6 +153,12 @@ input:focus, select:focus { border-color:var(--accent); } .edit-btn { width:20px; height:20px; background:transparent; color:var(--accent); border:none; cursor:pointer; font-size:0.75rem; display:flex; align-items:center; justify-content:center; } .edit-btn:hover { color:#fff; transform:scale(1.2); } .light .edit-btn:hover { color:#000!important; } +.star-btn { width:20px; height:20px; background:transparent; border:none; cursor:pointer; font-size:0.75rem; display:flex; align-items:center; justify-content:center; } +.star-btn:hover { transform:scale(1.3); } +.entry-card.favorite { border-color:rgba(255,200,0,0.3); background:rgba(255,200,0,0.05); } +.entry-row.favorite { border-color:rgba(255,200,0,0.3); background:rgba(255,200,0,0.05); } +.entry-compact.favorite { border-color:rgba(255,200,0,0.3); background:rgba(255,200,0,0.05); } +.table-row-drag.favorite td { background:rgba(255,200,0,0.05); } .entry-info { display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap; flex:1; } .entry-site { font-weight:700; color:var(--text); } .entry-user { color:var(--text2); } diff --git a/index.html b/index.html index 78555f7..baa2131 100644 --- a/index.html +++ b/index.html @@ -189,7 +189,12 @@ -
+
+ + + +
+
diff --git a/js/app.js b/js/app.js index 1a110f2..335d67d 100644 --- a/js/app.js +++ b/js/app.js @@ -204,10 +204,35 @@ function renderFolders() { if (!f) return; const count = counts[f] || 0; const color = folderColor(f); - html += `📁 ${esc(f)}${count}${f !== 'All' ? `` : ''}`; + html += `📁 ${esc(f)}${count}${f !== 'All' ? `` : ''}`; }); html += ``; 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; + if (entry.folder === folder) return; + const enc = await encryptPwd(entry.password); + await fetch(API + '/entries/' + id, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, + body: JSON.stringify({ site: entry.site, username: entry.username, encrypted_password: enc.encrypted, iv: enc.iv, folder }) + }); + entry.folder = folder; + renderFolders(); + render(); + playSound('success'); + }); + }); } @@ -263,6 +288,9 @@ function toggleTrash() { async function restoreEntry(id) { try { const r = await fetch(API + '/entries/' + id + '/restore', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { toast('✅ Restored!'); await loadEntries(); playSound('success'); } } catch (e) { 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) { if (!confirm('Permanently delete?')) return; try { const r = await fetch(API + '/entries/' + id + '?permanent=1', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { toast('🗑️ Permanently deleted'); await loadEntries(); playSound('error'); } } catch (e) { toast('⚠️ Error', 'error'); } @@ -332,6 +360,16 @@ function toggleShowEmail() { showMail = !showMail; localStorage.setItem('showEma 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(); @@ -494,12 +532,13 @@ async function loadEntries(q) { 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 }); + 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 }); + 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(); @@ -524,17 +563,18 @@ function render() { const filtered = getFilteredEntries(); if (!filtered.length) { c.innerHTML = '
' + (showTrash ? '📭 Trash empty' : '📭 No entries') + '
'; return; } if (view === 'table') { - let h = '' + (showMail ? '' : '') + '' + (!showTrash ? '' : '') + ''; + let h = '
SiteUserPasswordFolderDeletedActions
' + (showMail ? '' : '') + '' + (!showTrash ? '' : '') + ''; filtered.forEach(e => { - h += '' + + h += '' + + '' + '' + (showMail ? '' : '') + - ''; + ''; if (!showTrash) { h += '' + '
SiteUserPasswordFolderDeletedActions
' + (e.favorite ? '⭐' : '') + '🌐 ' + highlightText(e.site, searchQuery) + '👤 ' + highlightText(e.username, searchQuery) + '••••••••••••••••📁 ' + esc(e.folder || 'All') + '' + (selectMode ? `` : '') + - (showView ? ' ' : '') + + ' ' + ' ' + ' ' + '' + @@ -563,7 +603,7 @@ function render() { } function gridC(e) { - let html = '
'; + let html = '
'; if (selectMode) { html += ``; } @@ -572,6 +612,7 @@ function gridC(e) { html += ''; html += ''; } else { + html += ''; html += ''; html += ''; } @@ -580,8 +621,7 @@ function gridC(e) { if (showMail) html += '
👤 ' + highlightText(e.username, searchQuery) + '
'; html += '
📁 ' + esc(e.folder || 'All') + '
'; if (!showTrash) { - html += '
••••••••
' + - (showView ? '' : '') + + html += '
••••••••
' + '
'; } else { html += '
🗑️ ' + timeAgo(e.deleted_at) + '
'; @@ -590,7 +630,7 @@ function gridC(e) { return html; } function listC(e) { - let html = '
'; + let html = '
'; if (selectMode) { html += ``; } @@ -599,6 +639,7 @@ function listC(e) { html += ''; html += ''; } else { + html += ''; html += ''; html += ''; } @@ -607,8 +648,7 @@ function listC(e) { if (showMail) html += '👤 ' + highlightText(e.username, searchQuery) + ''; if (!showTrash) { html += '📁 ' + esc(e.folder || 'All') + ''; - html += '
••••••••' + - (showView ? '' : '') + + html += '
••••••••' + '
'; } else { html += '🗑️ ' + timeAgo(e.deleted_at) + ''; @@ -617,7 +657,7 @@ function listC(e) { return html; } function compC(e) { - let html = '
'; + let html = '
'; if (selectMode) { html += ``; } @@ -626,6 +666,7 @@ function compC(e) { html += ''; html += ''; } else { + html += ''; html += ''; html += ''; } @@ -634,8 +675,7 @@ function compC(e) { if (showMail) html += '👤 ' + highlightText(e.username, searchQuery) + ''; if (!showTrash) { html += '📁 ' + esc(e.folder || 'All') + ''; - html += '••••••••'; - if (showView) html += ''; + html += '••••••••'; html += ''; } else { html += '🗑️ ' + timeAgo(e.deleted_at) + ''; @@ -714,8 +754,20 @@ function entryPw(id) { const e = entries.find(x => x.id == id); return e ? e.pas 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('.toggle-p').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); const el = document.getElementById('p-' + this.dataset.id); const pw = entryPw(this.dataset.id); el.textContent = el.textContent === '••••••••' ? pw : '••••••••'; }); + 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;