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
This commit is contained in:
2026-05-08 23:53:47 +01:00
parent c6504f70d1
commit 8abb1c5ad0
4 changed files with 97 additions and 18 deletions
+14
View File
@@ -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']);
+8
View File
@@ -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); }
+6 -1
View File
@@ -189,7 +189,12 @@
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useNum" checked onchange="genPwd()"> 0-9</label>
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useSym" checked onchange="genPwd()"> !@#$</label>
</div>
<div style="display:flex;gap:.4rem;margin-top:.8rem">
<div style="display:flex;gap:0.3rem;margin-top:0.8rem;flex-wrap:wrap">
<button class="btn btn-sm" onclick="genPreset(16,'upper+lower+num+sym')" style="flex:1;min-width:70px">🔒 Strong 16</button>
<button class="btn btn-sm" onclick="genPreset(20,'upper+lower+num+sym')" style="flex:1;min-width:70px">🔒 Strong 20</button>
<button class="btn btn-sm btn-danger" onclick="genPreset(32,'upper+lower+num+sym')" style="flex:1;min-width:70px">🛡️ Paranoid 32</button>
</div>
<div style="display:flex;gap:.4rem;margin-top:.6rem">
<button class="btn" onclick="genPwd()" style="flex:1">🔄</button>
<button class="btn" onclick="useGen()" style="flex:1">✅ Use</button>
<button class="btn btn-outline" onclick="closeGen()">Cancel</button>
+69 -17
View File
@@ -204,10 +204,35 @@ function renderFolders() {
if (!f) return;
const count = counts[f] || 0;
const color = folderColor(f);
html += `<span class="folder-chip${selectedFolder === f ? ' active' : ''}" ${color} 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 += `<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;
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 = '<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>Site</th>' + (showMail ? '<th>User</th>' : '') + '<th>Password</th>' + (!showTrash ? '<th>Folder</th>' : '<th>Deleted</th>') + '<th>Actions</th></tr></thead><tbody>';
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 => {
h += '<tr class="table-row-drag" draggable="true" data-id="' + e.id + '">' +
h += '<tr class="table-row-drag' + (e.favorite ? ' favorite' : '') + '" draggable="true" data-id="' + e.id + '">' +
'<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 + '">••••••••</span></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">' +
(selectMode ? `<input type="checkbox" class="select-checkbox" style="position:static;margin-right:8px;" ${selectedIds.has(e.id) ? 'checked' : ''} onclick="event.stopPropagation();toggleSelectEntry(${e.id})">` : '') +
(showView ? '<button class="icon-btn toggle-p" data-id="' + e.id + '">👁️</button> ' : '') +
'<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>' +
@@ -563,7 +603,7 @@ function render() {
}
function gridC(e) {
let html = '<div class="entry-card" draggable="' + (!selectMode) + '" data-id="' + e.id + '">';
let html = '<div class="entry-card' + (e.favorite ? ' favorite' : '') + '" draggable="' + (!selectMode) + '" data-id="' + e.id + '">';
if (selectMode) {
html += `<input type="checkbox" class="select-checkbox" ${selectedIds.has(e.id) ? 'checked' : ''} onclick="event.stopPropagation();toggleSelectEntry(${e.id})">`;
}
@@ -572,6 +612,7 @@ function gridC(e) {
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>';
}
@@ -580,8 +621,7 @@ function gridC(e) {
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 + '">••••••••</span><div>' +
(showView ? '<button class="icon-btn toggle-p" data-id="' + e.id + '">👁️</button>' : '') +
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>';
@@ -590,7 +630,7 @@ function gridC(e) {
return html;
}
function listC(e) {
let html = '<div class="entry-row" draggable="' + (!selectMode) + '" data-id="' + e.id + '">';
let html = '<div class="entry-row' + (e.favorite ? ' favorite' : '') + '" draggable="' + (!selectMode) + '" data-id="' + e.id + '">';
if (selectMode) {
html += `<input type="checkbox" class="select-checkbox" ${selectedIds.has(e.id) ? 'checked' : ''} onclick="event.stopPropagation();toggleSelectEntry(${e.id})">`;
}
@@ -599,6 +639,7 @@ function listC(e) {
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>';
}
@@ -607,8 +648,7 @@ function listC(e) {
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" id="p-' + e.id + '">••••••••</span>' +
(showView ? '<button class="icon-btn toggle-p" data-id="' + e.id + '">👁️</button>' : '') +
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>';
@@ -617,7 +657,7 @@ function listC(e) {
return html;
}
function compC(e) {
let html = '<div class="entry-compact" draggable="' + (!selectMode) + '" data-id="' + e.id + '">';
let html = '<div class="entry-compact' + (e.favorite ? ' favorite' : '') + '" draggable="' + (!selectMode) + '" data-id="' + e.id + '">';
if (selectMode) {
html += `<input type="checkbox" class="select-checkbox" ${selectedIds.has(e.id) ? 'checked' : ''} onclick="event.stopPropagation();toggleSelectEntry(${e.id})">`;
}
@@ -626,6 +666,7 @@ function compC(e) {
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>';
}
@@ -634,8 +675,7 @@ function compC(e) {
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 + '">••••••••</span>';
if (showView) html += '<button class="icon-btn toggle-p" data-id="' + e.id + '">👁️</button>';
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>';
@@ -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;