04/26 Folder tag
This commit is contained in:
@@ -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; }
|
||||
|
||||
+89
-5
@@ -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 = '<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) {
|
||||
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 =
|
||||
`<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);
|
||||
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 =>
|
||||
`<span class="item-tag">${escHtml(t)}</span>`
|
||||
).join('');
|
||||
|
||||
li.innerHTML = `
|
||||
<div class="item-icon">${icon}</div>
|
||||
<div class="item-info">
|
||||
<span class="item-name">${escHtml(item.name)}</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>` : ""}
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
@@ -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 => `<span class="item-tag">${escHtml(t)}</span>`).join('');
|
||||
});
|
||||
|
||||
// Sidebar navigation
|
||||
document.getElementById("sidebar-all")?.addEventListener("click", () => {
|
||||
_activeFilter = null;
|
||||
|
||||
@@ -140,6 +140,8 @@
|
||||
<span class="sidebar-icon">⚡</span>
|
||||
<span class="sidebar-label">Password Generator</span>
|
||||
</li>
|
||||
<li class="sidebar-section-header">Tags</li>
|
||||
<ul id="sidebar-tags" class="sidebar-tag-list"></ul>
|
||||
<li class="sidebar-section-header">
|
||||
Folders
|
||||
<button class="btn-new-folder" id="btn-new-folder" title="New folder">
|
||||
@@ -685,6 +687,18 @@
|
||||
<option value="">— No folder —</option>
|
||||
</select>
|
||||
</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="form-group">
|
||||
<label for="field-notes">Notes</label
|
||||
|
||||
Reference in New Issue
Block a user