04/26 Folder tag
This commit is contained in:
@@ -2123,3 +2123,88 @@ html.sidebar-open {
|
|||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
border: 1px solid #fecaca;
|
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; }
|
||||||
|
|||||||
+86
-2
@@ -211,6 +211,7 @@ const Vault = (() => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
renderFolderList();
|
renderFolderList();
|
||||||
|
renderTagList();
|
||||||
applyCurrentFilter();
|
applyCurrentFilter();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast("Failed to load vault: " + err.message, "error");
|
showToast("Failed to load vault: " + err.message, "error");
|
||||||
@@ -326,6 +327,50 @@ const Vault = (() => {
|
|||||||
return _folders.find((f) => f.id === id)?.name || "Unknown";
|
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 = '<li class="sidebar-tag-empty">No tags yet</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tags.forEach(tag => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'sidebar-item sidebar-tag-item';
|
||||||
|
li.dataset.tag = tag;
|
||||||
|
li.innerHTML = `<span class="sidebar-icon">🏷️</span><span class="sidebar-label">${escHtml(tag)}</span>`;
|
||||||
|
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) {
|
function renderItemList(items) {
|
||||||
const list = document.getElementById("vault-list");
|
const list = document.getElementById("vault-list");
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
@@ -356,13 +401,34 @@ const Vault = (() => {
|
|||||||
|
|
||||||
keys.forEach((groupName) => {
|
keys.forEach((groupName) => {
|
||||||
if (!groups[groupName]) return;
|
if (!groups[groupName]) return;
|
||||||
|
const isCollapsed = _collapsedGroups.has(groupName);
|
||||||
|
const count = groups[groupName].length;
|
||||||
|
|
||||||
|
// ── Collapsible group header ────────────────────────────────────────────
|
||||||
const header = document.createElement("li");
|
const header = document.createElement("li");
|
||||||
header.className = "vault-group-header";
|
header.className = "vault-group-header" + (isCollapsed ? " collapsed" : "");
|
||||||
header.textContent = groupName;
|
header.innerHTML =
|
||||||
|
`<span class="group-name">${escHtml(groupName)}</span>` +
|
||||||
|
`<span class="group-meta">` +
|
||||||
|
`<span class="group-count">${count}</span>` +
|
||||||
|
`<span class="group-chevron">${isCollapsed ? '▶' : '▼'}</span>` +
|
||||||
|
`</span>`;
|
||||||
|
header.addEventListener("click", () => {
|
||||||
|
if (_collapsedGroups.has(groupName)) {
|
||||||
|
_collapsedGroups.delete(groupName);
|
||||||
|
} else {
|
||||||
|
_collapsedGroups.add(groupName);
|
||||||
|
}
|
||||||
|
renderItemList(items); // re-render preserving collapse state
|
||||||
|
});
|
||||||
list.appendChild(header);
|
list.appendChild(header);
|
||||||
|
|
||||||
|
// ── Items (hidden when collapsed) ───────────────────────────────────────
|
||||||
|
if (!isCollapsed) {
|
||||||
groups[groupName].forEach((item) =>
|
groups[groupName].forEach((item) =>
|
||||||
list.appendChild(createItemElement(item)),
|
list.appendChild(createItemElement(item)),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +448,7 @@ const Vault = (() => {
|
|||||||
};
|
};
|
||||||
const icon = iconMap[item.item_type] || "🔑";
|
const icon = iconMap[item.item_type] || "🔑";
|
||||||
|
|
||||||
|
const itemTags = item.plain?.tags || [];
|
||||||
let subText = "";
|
let subText = "";
|
||||||
if (item.plain) {
|
if (item.plain) {
|
||||||
switch (item.item_type) {
|
switch (item.item_type) {
|
||||||
@@ -420,11 +487,16 @@ const Vault = (() => {
|
|||||||
item.item_type === "password" &&
|
item.item_type === "password" &&
|
||||||
!!extractTotpSecret(item.plain?.totp_uri);
|
!!extractTotpSecret(item.plain?.totp_uri);
|
||||||
|
|
||||||
|
const tagBadges = itemTags.map(t =>
|
||||||
|
`<span class="item-tag">${escHtml(t)}</span>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
li.innerHTML = `
|
li.innerHTML = `
|
||||||
<div class="item-icon">${icon}</div>
|
<div class="item-icon">${icon}</div>
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<span class="item-name">${escHtml(item.name)}</span>
|
<span class="item-name">${escHtml(item.name)}</span>
|
||||||
<span class="item-sub">${escHtml(subText)}</span>
|
<span class="item-sub">${escHtml(subText)}</span>
|
||||||
|
${tagBadges ? `<div class="item-tags">${tagBadges}</div>` : ''}
|
||||||
${showTotp ? `<span class="item-totp" id="totp-display-${item.id}"><span class="totp-code">······</span><span class="totp-timer"></span></span>` : ""}
|
${showTotp ? `<span class="item-totp" id="totp-display-${item.id}"><span class="totp-code">······</span><span class="totp-timer"></span></span>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
@@ -2375,6 +2447,8 @@ const Vault = (() => {
|
|||||||
renderItemList(_items.filter((i) => i.item_type === _activeFilter.value));
|
renderItemList(_items.filter((i) => i.item_type === _activeFilter.value));
|
||||||
else if (_activeFilter.type === "folder")
|
else if (_activeFilter.type === "folder")
|
||||||
renderItemList(_items.filter((i) => i.folder_id === _activeFilter.value));
|
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) {
|
function filterByFolder(folderId, name) {
|
||||||
@@ -2476,6 +2550,7 @@ const Vault = (() => {
|
|||||||
setVal("field-password", p.password);
|
setVal("field-password", p.password);
|
||||||
setVal("field-totp-uri", p.totp_uri || "");
|
setVal("field-totp-uri", p.totp_uri || "");
|
||||||
setVal("field-notes", p.notes);
|
setVal("field-notes", p.notes);
|
||||||
|
setVal("field-tags", (p.tags || []).join(', '));
|
||||||
break;
|
break;
|
||||||
case "note":
|
case "note":
|
||||||
setVal("field-note-body", p.note_body);
|
setVal("field-note-body", p.note_body);
|
||||||
@@ -2551,6 +2626,7 @@ const Vault = (() => {
|
|||||||
password: getVal("field-password"),
|
password: getVal("field-password"),
|
||||||
totp_uri: getVal("field-totp-uri").trim(),
|
totp_uri: getVal("field-totp-uri").trim(),
|
||||||
notes: getVal("field-notes").trim(),
|
notes: getVal("field-notes").trim(),
|
||||||
|
tags: _parseTags(getVal("field-tags")),
|
||||||
};
|
};
|
||||||
case "note":
|
case "note":
|
||||||
return { note_body: getVal("field-note-body") };
|
return { note_body: getVal("field-note-body") };
|
||||||
@@ -2959,6 +3035,14 @@ const Vault = (() => {
|
|||||||
el.type = el.type === "password" ? "text" : "password";
|
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 => `<span class="item-tag">${escHtml(t)}</span>`).join('');
|
||||||
|
});
|
||||||
|
|
||||||
// Sidebar navigation
|
// Sidebar navigation
|
||||||
document.getElementById("sidebar-all")?.addEventListener("click", () => {
|
document.getElementById("sidebar-all")?.addEventListener("click", () => {
|
||||||
_activeFilter = null;
|
_activeFilter = null;
|
||||||
|
|||||||
@@ -140,6 +140,8 @@
|
|||||||
<span class="sidebar-icon">⚡</span>
|
<span class="sidebar-icon">⚡</span>
|
||||||
<span class="sidebar-label">Password Generator</span>
|
<span class="sidebar-label">Password Generator</span>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="sidebar-section-header">Tags</li>
|
||||||
|
<ul id="sidebar-tags" class="sidebar-tag-list"></ul>
|
||||||
<li class="sidebar-section-header">
|
<li class="sidebar-section-header">
|
||||||
Folders
|
Folders
|
||||||
<button class="btn-new-folder" id="btn-new-folder" title="New folder">
|
<button class="btn-new-folder" id="btn-new-folder" title="New folder">
|
||||||
@@ -685,6 +687,18 @@
|
|||||||
<option value="">— No folder —</option>
|
<option value="">— No folder —</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="type-fields" data-for-types="password note card bank address ssn">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="field-tags">Tags</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="field-tags"
|
||||||
|
placeholder="e.g. work, finance, personal (comma-separated)"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<div id="field-tags-preview" class="tag-preview"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="type-fields" data-for-types="password card bank address ssn">
|
<div class="type-fields" data-for-types="password card bank address ssn">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="field-notes">Notes</label
|
<label for="field-notes">Notes</label
|
||||||
|
|||||||
@@ -973,3 +973,75 @@ body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Collapsible folder groups ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pk-group { margin-bottom: 2px; }
|
||||||
|
|
||||||
|
.pk-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 14px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-group-header:hover { background: #f0f4ff; }
|
||||||
|
|
||||||
|
.pk-group-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #374151;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-group-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-group-count {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-group-chevron {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9ca3af;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Item tags ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pk-tag-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 3px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-tag {
|
||||||
|
display: inline-block;
|
||||||
|
background: #ede9fe;
|
||||||
|
color: #6d28d9;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-fav {
|
||||||
|
color: #f59e0b;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@
|
|||||||
<div class="vault-tabs" id="vault-tabs">
|
<div class="vault-tabs" id="vault-tabs">
|
||||||
<button class="tab-btn active" data-tab="relevant">All relevant</button>
|
<button class="tab-btn active" data-tab="relevant">All relevant</button>
|
||||||
<button class="tab-btn" data-tab="all">All items</button>
|
<button class="tab-btn" data-tab="all">All items</button>
|
||||||
|
<button class="tab-btn" data-tab="favorites">Favorites</button>
|
||||||
<button class="tab-btn" data-tab="recents">Recents</button>
|
<button class="tab-btn" data-tab="recents">Recents</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ const ALERTS_URL = 'https://pwkeeper.ngodanguyen.tech/vault#security';
|
|||||||
|
|
||||||
let _vaultKey = null;
|
let _vaultKey = null;
|
||||||
let _items = [];
|
let _items = [];
|
||||||
|
let _folders = []; // fetched alongside vault items for folder grouping
|
||||||
let _mfaToken = null;
|
let _mfaToken = null;
|
||||||
let _currentUrl = '';
|
let _currentUrl = '';
|
||||||
let _activeTab = 'relevant';
|
let _activeTab = 'relevant';
|
||||||
|
const _collapsedFolders = new Set(); // persists collapsed state across re-renders
|
||||||
|
|
||||||
// ── DOM helpers ───────────────────────────────────────────────────────────────
|
// ── DOM helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -91,6 +93,11 @@ function siteLabel(item) {
|
|||||||
return item.name;
|
return item.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function folderName(id) {
|
||||||
|
if (!id) return '(none)';
|
||||||
|
return _folders.find(f => f.id === id)?.name || '(none)';
|
||||||
|
}
|
||||||
|
|
||||||
// ── Domain matching ───────────────────────────────────────────────────────────
|
// ── Domain matching ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function currentHostname() {
|
function currentHostname() {
|
||||||
@@ -332,9 +339,15 @@ async function restoreSessionIfAvailable() {
|
|||||||
async function fetchAndDecryptVault() {
|
async function fetchAndDecryptVault() {
|
||||||
$('vault-spinner').classList.remove('hidden');
|
$('vault-spinner').classList.remove('hidden');
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/api/vault');
|
const [res, foldersRes] = await Promise.all([
|
||||||
|
apiFetch('/api/vault'),
|
||||||
|
apiFetch('/api/folders'),
|
||||||
|
]);
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
const raw = await res.json();
|
const raw = await res.json();
|
||||||
|
try {
|
||||||
|
if (foldersRes?.ok) _folders = await foldersRes.json();
|
||||||
|
} catch (e) { _folders = []; }
|
||||||
|
|
||||||
_items = await Promise.all(raw.map(async item => {
|
_items = await Promise.all(raw.map(async item => {
|
||||||
try {
|
try {
|
||||||
@@ -459,6 +472,9 @@ function getTabItems() {
|
|||||||
items = items.filter(isMatch).sort((a, b) => a.name.localeCompare(b.name));
|
items = items.filter(isMatch).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
} else if (_activeTab === 'recents') {
|
} 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);
|
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 {
|
} else {
|
||||||
items = [...items].sort((a, b) => a.name.localeCompare(b.name));
|
items = [...items].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
@@ -496,7 +512,7 @@ function renderList() {
|
|||||||
const svgFill = `<svg viewBox="0 0 24 24" fill="none"><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M17 3l4 4-9 9H8v-4l9-9z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>`;
|
const svgFill = `<svg viewBox="0 0 24 24" fill="none"><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M17 3l4 4-9 9H8v-4l9-9z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>`;
|
||||||
const svgTotp = `<svg viewBox="0 0 24 24" fill="none" width="16" height="16"><rect x="5" y="2" width="14" height="20" rx="2" stroke="currentColor" stroke-width="1.7"/><circle cx="12" cy="17" r="1" fill="currentColor"/><path d="M9 7h6M9 11h4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>`;
|
const svgTotp = `<svg viewBox="0 0 24 24" fill="none" width="16" height="16"><rect x="5" y="2" width="14" height="20" rx="2" stroke="currentColor" stroke-width="1.7"/><circle cx="12" cy="17" r="1" fill="currentColor"/><path d="M9 7h6M9 11h4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>`;
|
||||||
|
|
||||||
listEl.innerHTML = items.map(item => {
|
function buildItemHtml(item) {
|
||||||
const matched = isMatch(item);
|
const matched = isMatch(item);
|
||||||
const site = escHtml(siteLabel(item));
|
const site = escHtml(siteLabel(item));
|
||||||
const name = escHtml(item.name);
|
const name = escHtml(item.name);
|
||||||
@@ -506,12 +522,15 @@ function renderList() {
|
|||||||
const canFill = item.item_type === 'password' && item.plain?.username && item.plain?.password;
|
const canFill = item.item_type === 'password' && item.plain?.username && item.plain?.password;
|
||||||
const canCopy = item.item_type === 'password' && item.plain?.password;
|
const canCopy = item.item_type === 'password' && item.plain?.password;
|
||||||
const hasTotp = item.item_type === 'password' && !!_extractTotpSecret(item.plain?.totp_uri);
|
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 => `<span class="pk-tag">${escHtml(t)}</span>`).join('');
|
||||||
|
const isFav = (item.plain?.tags || []).includes('favorite');
|
||||||
return `<div class="vault-item" data-id="${item.id}">
|
return `<div class="vault-item" data-id="${item.id}">
|
||||||
<div class="item-avatar ${color}">${emoji}</div>
|
<div class="item-avatar ${color}">${emoji}</div>
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<div class="item-site">${site}${badge}</div>
|
<div class="item-site">${site}${badge}${isFav ? '<span class="pk-fav">★</span>' : ''}</div>
|
||||||
<div class="item-name">${name}</div>
|
<div class="item-name">${name}</div>
|
||||||
|
${tagHtml ? `<div class="pk-tag-row">${tagHtml}</div>` : ''}
|
||||||
${hasTotp ? `<div class="item-totp-row"><span class="totp-code-inline" id="totp-${item.id}">······</span><span class="totp-timer-inline" id="totp-t-${item.id}"></span></div>` : ''}
|
${hasTotp ? `<div class="item-totp-row"><span class="totp-code-inline" id="totp-${item.id}">······</span><span class="totp-timer-inline" id="totp-t-${item.id}"></span></div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
@@ -522,8 +541,50 @@ function renderList() {
|
|||||||
<button class="btn-item-action" data-menu="${item.id}" title="More options">${svgDots}</button>
|
<button class="btn-item-action" data-menu="${item.id}" title="More options">${svgDots}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 `<div class="pk-group" data-group="${escHtml(groupName)}">
|
||||||
|
<div class="pk-group-header">
|
||||||
|
<span class="pk-group-name">${escHtml(groupName)}</span>
|
||||||
|
<span class="pk-group-meta">
|
||||||
|
<span class="pk-group-count">${count}</span>
|
||||||
|
<span class="pk-group-chevron">${chevron}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="pk-group-items">${itemsHtml}</div>
|
||||||
|
</div>`;
|
||||||
}).join('');
|
}).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.
|
// Start TOTP tickers for items that have a totp_uri.
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
if (item.item_type !== 'password' || !_extractTotpSecret(item.plain?.totp_uri)) return;
|
if (item.item_type !== 'password' || !_extractTotpSecret(item.plain?.totp_uri)) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user