04/26 Folder tag

This commit is contained in:
2026-04-26 10:53:32 -04:00
parent 80cf8eb599
commit f37d3e5c9a
6 changed files with 328 additions and 11 deletions
+73 -1
View File
@@ -972,4 +972,76 @@ body {
.pk-flyout-item svg {
flex-shrink: 0;
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;
}
+1
View File
@@ -96,6 +96,7 @@
<div class="vault-tabs" id="vault-tabs">
<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="favorites">Favorites</button>
<button class="tab-btn" data-tab="recents">Recents</button>
</div>
+66 -5
View File
@@ -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 = `<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>`;
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 => `<span class="pk-tag">${escHtml(t)}</span>`).join('');
const isFav = (item.plain?.tags || []).includes('favorite');
return `<div class="vault-item" data-id="${item.id}">
<div class="item-avatar ${color}">${emoji}</div>
<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>
${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>` : ''}
</div>
<div class="item-actions">
@@ -522,7 +541,49 @@ function renderList() {
<button class="btn-item-action" data-menu="${item.id}" title="More options">${svgDots}</button>
</div>
</div>`;
}).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 `<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('');
// 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 => {