diff --git a/app/static/css/app.css b/app/static/css/app.css index 7fd5ed0..d7df05f 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -2123,3 +2123,88 @@ html.sidebar-open { color: #dc2626; border: 1px solid #fecaca; } + +/* ── Collapsible vault folder groups ───────────────────────────────────────── */ + +.vault-group-header { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + user-select: none; + transition: color 0.12s; +} + +.vault-group-header:hover { color: var(--color-primary); } + +.group-name { flex: 1; } + +.group-meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; +} + +.group-count { + background: #e5e7eb; + color: #374151; + border-radius: 999px; + padding: 1px 7px; + font-size: 11px; + font-weight: 600; +} + +.group-chevron { + font-size: 10px; + color: var(--color-text-muted); + width: 14px; + text-align: center; + transition: transform 0.15s; +} + +.vault-group-header.collapsed .group-chevron { color: var(--color-primary); } + +/* ── Vault item tags ────────────────────────────────────────────────────────── */ + +.item-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 3px; +} + +.item-tag { + display: inline-block; + background: #ede9fe; + color: #6d28d9; + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 999px; + letter-spacing: 0.2px; +} + +.tag-preview { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; + min-height: 20px; +} + +/* ── Sidebar tags ────────────────────────────────────────────────────────────── */ + +.sidebar-tag-list { + list-style: none; + padding: 0; + margin: 0 0 4px; +} + +.sidebar-tag-empty { + font-size: 12px; + color: var(--color-text-muted); + padding: 4px 16px 8px; +} + +.sidebar-tag-item .sidebar-icon { font-size: 14px; } diff --git a/app/static/js/vault.js b/app/static/js/vault.js index 4385dcc..c003680 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -211,6 +211,7 @@ const Vault = (() => { ); renderFolderList(); + renderTagList(); applyCurrentFilter(); } catch (err) { showToast("Failed to load vault: " + err.message, "error"); @@ -326,6 +327,50 @@ const Vault = (() => { return _folders.find((f) => f.id === id)?.name || "Unknown"; } + /** Parse a comma-separated tag string into a sorted, deduped lowercase array. */ + function _parseTags(str) { + return [...new Set( + (str || '').split(',').map(t => t.trim().toLowerCase()).filter(Boolean) + )].sort(); + } + + /** Collect all unique tags across all loaded items. */ + function _allTags() { + const set = new Set(); + _items.forEach(i => (i.plain?.tags || []).forEach(t => set.add(t))); + return [...set].sort(); + } + + /** Render the tags sidebar list. */ + function renderTagList() { + const ul = document.getElementById('sidebar-tags'); + if (!ul) return; + ul.innerHTML = ''; + const tags = _allTags(); + if (!tags.length) { + ul.innerHTML = ''; + return; + } + tags.forEach(tag => { + const li = document.createElement('li'); + li.className = 'sidebar-item sidebar-tag-item'; + li.dataset.tag = tag; + li.innerHTML = `🏷️${escHtml(tag)}`; + li.addEventListener('click', () => { + document.querySelectorAll('.sidebar-item').forEach(el => el.classList.remove('active')); + li.classList.add('active'); + _activeFilter = { type: 'tag', value: tag }; + switchView('vault'); + updateVaultTitle('#' + tag); + renderItemList(_items.filter(i => (i.plain?.tags || []).includes(tag))); + }); + ul.appendChild(li); + }); + } + + // Persists which folder groups are collapsed across re-renders. + const _collapsedGroups = new Set(); + function renderItemList(items) { const list = document.getElementById("vault-list"); if (!list) return; @@ -356,13 +401,34 @@ const Vault = (() => { keys.forEach((groupName) => { if (!groups[groupName]) return; + const isCollapsed = _collapsedGroups.has(groupName); + const count = groups[groupName].length; + + // ── Collapsible group header ──────────────────────────────────────────── const header = document.createElement("li"); - header.className = "vault-group-header"; - header.textContent = groupName; + header.className = "vault-group-header" + (isCollapsed ? " collapsed" : ""); + header.innerHTML = + `${escHtml(groupName)}` + + `` + + `${count}` + + `${isCollapsed ? '▶' : '▼'}` + + ``; + header.addEventListener("click", () => { + if (_collapsedGroups.has(groupName)) { + _collapsedGroups.delete(groupName); + } else { + _collapsedGroups.add(groupName); + } + renderItemList(items); // re-render preserving collapse state + }); list.appendChild(header); - groups[groupName].forEach((item) => - list.appendChild(createItemElement(item)), - ); + + // ── Items (hidden when collapsed) ─────────────────────────────────────── + if (!isCollapsed) { + groups[groupName].forEach((item) => + list.appendChild(createItemElement(item)), + ); + } }); } @@ -382,6 +448,7 @@ const Vault = (() => { }; const icon = iconMap[item.item_type] || "🔑"; + const itemTags = item.plain?.tags || []; let subText = ""; if (item.plain) { switch (item.item_type) { @@ -420,11 +487,16 @@ const Vault = (() => { item.item_type === "password" && !!extractTotpSecret(item.plain?.totp_uri); + const tagBadges = itemTags.map(t => + `${escHtml(t)}` + ).join(''); + li.innerHTML = `
${icon}
${escHtml(item.name)} ${escHtml(subText)} + ${tagBadges ? `
${tagBadges}
` : ''} ${showTotp ? `······` : ""}
@@ -2375,6 +2447,8 @@ const Vault = (() => { renderItemList(_items.filter((i) => i.item_type === _activeFilter.value)); else if (_activeFilter.type === "folder") renderItemList(_items.filter((i) => i.folder_id === _activeFilter.value)); + else if (_activeFilter.type === "tag") + renderItemList(_items.filter((i) => (i.plain?.tags || []).includes(_activeFilter.value))); } function filterByFolder(folderId, name) { @@ -2476,6 +2550,7 @@ const Vault = (() => { setVal("field-password", p.password); setVal("field-totp-uri", p.totp_uri || ""); setVal("field-notes", p.notes); + setVal("field-tags", (p.tags || []).join(', ')); break; case "note": setVal("field-note-body", p.note_body); @@ -2551,6 +2626,7 @@ const Vault = (() => { password: getVal("field-password"), totp_uri: getVal("field-totp-uri").trim(), notes: getVal("field-notes").trim(), + tags: _parseTags(getVal("field-tags")), }; case "note": return { note_body: getVal("field-note-body") }; @@ -2959,6 +3035,14 @@ const Vault = (() => { el.type = el.type === "password" ? "text" : "password"; }); + // Live tag preview in the item modal. + document.getElementById("field-tags")?.addEventListener("input", (e) => { + const preview = document.getElementById("field-tags-preview"); + if (!preview) return; + const tags = _parseTags(e.target.value); + preview.innerHTML = tags.map(t => `${escHtml(t)}`).join(''); + }); + // Sidebar navigation document.getElementById("sidebar-all")?.addEventListener("click", () => { _activeFilter = null; diff --git a/app/templates/vault/index.html b/app/templates/vault/index.html index a052ea1..cf1abd0 100644 --- a/app/templates/vault/index.html +++ b/app/templates/vault/index.html @@ -140,6 +140,8 @@ Password Generator + +
+
+
+ + +
+
+
+
diff --git a/extension/popup/popup.js b/extension/popup/popup.js index 0686519..891cf75 100644 --- a/extension/popup/popup.js +++ b/extension/popup/popup.js @@ -19,9 +19,11 @@ const ALERTS_URL = 'https://pwkeeper.ngodanguyen.tech/vault#security'; let _vaultKey = null; let _items = []; +let _folders = []; // fetched alongside vault items for folder grouping let _mfaToken = null; let _currentUrl = ''; let _activeTab = 'relevant'; +const _collapsedFolders = new Set(); // persists collapsed state across re-renders // ── DOM helpers ─────────────────────────────────────────────────────────────── @@ -91,6 +93,11 @@ function siteLabel(item) { return item.name; } +function folderName(id) { + if (!id) return '(none)'; + return _folders.find(f => f.id === id)?.name || '(none)'; +} + // ── Domain matching ─────────────────────────────────────────────────────────── function currentHostname() { @@ -332,9 +339,15 @@ async function restoreSessionIfAvailable() { async function fetchAndDecryptVault() { $('vault-spinner').classList.remove('hidden'); try { - const res = await apiFetch('/api/vault'); + const [res, foldersRes] = await Promise.all([ + apiFetch('/api/vault'), + apiFetch('/api/folders'), + ]); if (!res) return; const raw = await res.json(); + try { + if (foldersRes?.ok) _folders = await foldersRes.json(); + } catch (e) { _folders = []; } _items = await Promise.all(raw.map(async item => { try { @@ -459,6 +472,9 @@ function getTabItems() { items = items.filter(isMatch).sort((a, b) => a.name.localeCompare(b.name)); } else if (_activeTab === 'recents') { items = [...items].sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at)).slice(0, 20); + } else if (_activeTab === 'favorites') { + items = items.filter(i => (i.plain?.tags || []).includes('favorite')) + .sort((a, b) => a.name.localeCompare(b.name)); } else { items = [...items].sort((a, b) => a.name.localeCompare(b.name)); } @@ -496,7 +512,7 @@ function renderList() { const svgFill = ``; const svgTotp = ``; - listEl.innerHTML = items.map(item => { + function buildItemHtml(item) { const matched = isMatch(item); const site = escHtml(siteLabel(item)); const name = escHtml(item.name); @@ -506,12 +522,15 @@ function renderList() { const canFill = item.item_type === 'password' && item.plain?.username && item.plain?.password; const canCopy = item.item_type === 'password' && item.plain?.password; const hasTotp = item.item_type === 'password' && !!_extractTotpSecret(item.plain?.totp_uri); - + const tags = (item.plain?.tags || []).filter(t => t !== 'favorite'); + const tagHtml = tags.map(t => `${escHtml(t)}`).join(''); + const isFav = (item.plain?.tags || []).includes('favorite'); return `
${emoji}
-
${site}${badge}
+
${site}${badge}${isFav ? '' : ''}
${name}
+ ${tagHtml ? `
${tagHtml}
` : ''} ${hasTotp ? `
······
` : ''}
@@ -522,7 +541,49 @@ function renderList() {
`; - }).join(''); + } + + // Build the vault list — grouped by folder for 'all' tab, flat otherwise. + if (_activeTab === 'all' && _folders.length > 0) { + const groups = {}; + items.forEach(item => { + const key = folderName(item.folder_id); + if (!groups[key]) groups[key] = []; + groups[key].push(item); + }); + const keys = ['(none)', ..._folders.map(f => f.name)].filter(k => groups[k]); + Object.keys(groups).forEach(k => { if (!keys.includes(k)) keys.push(k); }); + + listEl.innerHTML = keys.map(groupName => { + if (!groups[groupName]) return ''; + const isCollapsed = _collapsedFolders.has(groupName); + const count = groups[groupName].length; + const chevron = isCollapsed ? '›' : '⌄'; + const itemsHtml = isCollapsed ? '' : groups[groupName].map(buildItemHtml).join(''); + return `
+
+ ${escHtml(groupName)} + + ${count} + ${chevron} + +
+
${itemsHtml}
+
`; + }).join(''); + + // Wire collapse toggle on group headers. + listEl.querySelectorAll('.pk-group-header').forEach(hdr => { + hdr.addEventListener('click', () => { + const groupName = hdr.closest('.pk-group').dataset.group; + if (_collapsedFolders.has(groupName)) _collapsedFolders.delete(groupName); + else _collapsedFolders.add(groupName); + renderList(); + }); + }); + } else { + listEl.innerHTML = items.map(buildItemHtml).join(''); + } // Start TOTP tickers for items that have a totp_uri. items.forEach(item => {