feat(library): tags + tag filtering, monochrome icons; fix live tag updates
- library: expose page tags (batch), render tag chips per row and add a tag filter bar; register tag create/associate/remove in the shared context menu exactly like local-workspace - library: restore monochrome SVG icons (folder/edit/image/file) matching local-workspace, with optional custom page_icon - workspace: refresh tag chips/filter live on tag add/remove (reassign arrays + tagsVersion) instead of requiring a page reload - ctxmenu: measure the rendered menu and clamp it to the viewport on open
This commit is contained in:
+31
-2
@@ -69,6 +69,7 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
|
||||
"id": page_id,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"page_icon": db_row.get("page_icon") or "",
|
||||
"is_folder": bool(db_row.get("is_folder", 0)),
|
||||
"source_type": source_type,
|
||||
"source_label": _source_label(source_type, workspace),
|
||||
@@ -84,6 +85,7 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
|
||||
"content_format": content_format,
|
||||
"favorited": bool(db_row.get("favorited", 0)),
|
||||
"share_mode": db_row.get("share_mode", "private"),
|
||||
"tags": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +100,30 @@ def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[st
|
||||
|
||||
|
||||
def _rows_to_items(rows, uid: int = 1) -> list:
|
||||
return [_build_item(dict(r), uid) for r in rows]
|
||||
items = [_build_item(dict(r), uid) for r in rows]
|
||||
return _attach_tags(items)
|
||||
|
||||
|
||||
def _attach_tags(items: list) -> list:
|
||||
"""Batch-load page tags for library items."""
|
||||
if not items:
|
||||
return items
|
||||
ids = [it["id"] for it in items]
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT pt.page_id, t.id, t.name, t.color FROM page_tags pt "
|
||||
f"JOIN tags t ON t.id = pt.tag_id WHERE pt.page_id IN ({placeholders})",
|
||||
ids,
|
||||
).fetchall()
|
||||
tag_map: dict = {}
|
||||
for r in rows:
|
||||
tag_map.setdefault(r["page_id"], []).append({
|
||||
"id": r["id"], "name": r["name"], "color": r["color"],
|
||||
})
|
||||
for it in items:
|
||||
it["tags"] = tag_map.get(it["id"], [])
|
||||
return items
|
||||
|
||||
|
||||
def _enrich_children(items: list) -> list:
|
||||
@@ -124,7 +149,7 @@ def _enrich_children(items: list) -> list:
|
||||
|
||||
BASE_SELECT = (
|
||||
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
|
||||
"p.parent_id, p.share_mode, p.parent_section"
|
||||
"p.parent_id, p.share_mode, p.parent_section, p.page_icon"
|
||||
)
|
||||
|
||||
|
||||
@@ -361,6 +386,8 @@ async def library_local_workspace_children(item_id: int, request: Request):
|
||||
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
|
||||
"content_format": r["content_format"] or "file",
|
||||
"favorited": False,
|
||||
"page_icon": "",
|
||||
"tags": [],
|
||||
"size_display": _format_size(r["size"]) if r["size"] else "",
|
||||
})
|
||||
|
||||
@@ -469,6 +496,8 @@ async def library_local_workspace(
|
||||
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
|
||||
"content_format": r["content_format"] or "file",
|
||||
"favorited": False,
|
||||
"page_icon": "",
|
||||
"tags": [],
|
||||
"size_display": _format_size(r["size"]) if r["size"] else "",
|
||||
})
|
||||
|
||||
|
||||
@@ -54,9 +54,27 @@
|
||||
this.tagExistingOpen = false;
|
||||
this.iconOpen = false;
|
||||
this.newTagColor = (node && node.tags && node.tags[0] && node.tags[0].color) || '#787774';
|
||||
this.x = Math.max(4, Math.min((ev && ev.clientX) || 0, window.innerWidth - 240));
|
||||
this.y = Math.max(4, Math.min((ev && ev.clientY) || 0, window.innerHeight - 48));
|
||||
var cx = (ev && ev.clientX) || 0;
|
||||
var cy = (ev && ev.clientY) || 0;
|
||||
this.x = Math.max(8, Math.min(cx, window.innerWidth - 240));
|
||||
this.y = Math.max(8, Math.min(cy, window.innerHeight - 48));
|
||||
this.open = true;
|
||||
var self = this;
|
||||
/* Mesure la taille réelle du menu (une fois rendu) puis le replace
|
||||
pour qu'il reste TOUJOURS entièrement visible dans le viewport. */
|
||||
var place = function () {
|
||||
var el = document.querySelector('.fd-ctx-menu');
|
||||
var w = el ? el.offsetWidth : 240;
|
||||
var h = el ? el.offsetHeight : 320;
|
||||
var vw = window.innerWidth, vh = window.innerHeight;
|
||||
var nx = cx, ny = cy;
|
||||
if (nx + w > vw - 8) nx = vw - w - 8;
|
||||
if (ny + h > vh - 8) ny = vh - h - 8;
|
||||
self.x = Math.max(8, nx);
|
||||
self.y = Math.max(8, ny);
|
||||
};
|
||||
if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(place);
|
||||
else setTimeout(place, 0);
|
||||
},
|
||||
close() {
|
||||
this.open = false;
|
||||
|
||||
+130
-18
@@ -88,6 +88,13 @@
|
||||
.row-title:hover{text-decoration:underline;}
|
||||
.row-title input{width:100%;font-size:13px;padding:2px 4px;background:var(--bg-primary);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);outline:none;}
|
||||
|
||||
/* ── Tag chips (représentation + filtre) ── */
|
||||
.row-tags{display:flex;align-items:center;gap:3px;flex-shrink:0;overflow:hidden;max-width:45%;}
|
||||
.tag-chip{display:inline-flex;align-items:center;gap:3px;font-size:10px;line-height:1;padding:2px 6px;border-radius:999px;color:#fff;white-space:nowrap;border:none;cursor:pointer;font-family:inherit;}
|
||||
.tag-chip.active{outline:2px solid var(--text-primary);outline-offset:1px;}
|
||||
.lib-tag-filter{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:4px 16px 2px;}
|
||||
.lib-tag-filter .tf-label{font-size:11px;color:var(--text-dim);margin-right:2px;}
|
||||
|
||||
/* ── Shared direction sub-filter pils ── */
|
||||
.lib-share-directory{display:flex;align-items:center;gap:6px;padding:4px 16px;}
|
||||
.lib-dir-pill{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:transparent;color:var(--text-secondary);cursor:pointer;transition:background 0.15s,color 0.15s,border-color 0.15s;}
|
||||
@@ -271,6 +278,20 @@
|
||||
<button class="lib-dir-pill" :class="{ active: shareDir === 'received' }" @click="setShareDir('received')">Avec moi</button>
|
||||
</div>
|
||||
|
||||
<!-- Tag filter bar -->
|
||||
<div class="lib-tag-filter" x-show="workspaceTags && workspaceTags.length > 0" x-cloak>
|
||||
<span class="tf-label">Tags:</span>
|
||||
<template x-for="t in workspaceTags" :key="'ltf'+t.id">
|
||||
<button class="tag-chip" :class="{ active: tagFilter === t.name }"
|
||||
:style="{ background: t.color }"
|
||||
@click="toggleTagFilter(t.name)">
|
||||
<span x-text="t.name"></span>
|
||||
<span style="font-size:10px;opacity:.7" x-text="'('+(t.count||0)+')'"></span>
|
||||
</button>
|
||||
</template>
|
||||
<button x-show="tagFilter" class="tag-chip" style="background:var(--bg-tertiary);color:var(--text-secondary);" @click="tagFilter='', _recomputeFlatItems()">clear</button>
|
||||
</div>
|
||||
|
||||
<!-- Search bar (toggleable) -->
|
||||
<div class="lib-search-bar" x-show="searchOpen" x-transition>
|
||||
<input id="lib-search-input" type="text" placeholder="Search in this list..." x-model="searchQuery" @input="doSearch()">
|
||||
@@ -376,7 +397,7 @@
|
||||
<div class="move-modal-item" @click="confirmMove(0)">{{ fd_icon("file",14) }} <span>Root (no parent)</span></div>
|
||||
<template x-for="it in moveCandidates" :key="it.id">
|
||||
<div class="move-modal-item" @click="confirmMove(it.id)">
|
||||
<span x-html="it.icon ? getSvgIcon(it.icon,14) : getSvgIcon('file',14)"></span> <span x-text="it.title"></span>
|
||||
<span x-html="_renderIcon(it)"></span> <span x-text="it.title"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -393,7 +414,7 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="peek-title">
|
||||
<span x-html="peekItem.icon ? getSvgIcon(peekItem.icon,14) : getSvgIcon('file',14)"></span>
|
||||
<span x-html="_renderIcon(peekItem)"></span>
|
||||
<span x-text="peekItem.title"></span>
|
||||
</div>
|
||||
<button @click="openItem(peekItem)" title="Open full page">
|
||||
@@ -467,6 +488,10 @@ function libraryPage() {
|
||||
showAuthor: false,
|
||||
showSource: false,
|
||||
|
||||
// Tags
|
||||
workspaceTags: [],
|
||||
tagFilter: '',
|
||||
|
||||
// Sort
|
||||
sortBy: '',
|
||||
|
||||
@@ -553,19 +578,24 @@ function libraryPage() {
|
||||
} else {
|
||||
html += '<span style="width:18px;flex-shrink:0;"></span>';
|
||||
}
|
||||
// Icon — folders get a folder icon
|
||||
// Icon — monochrome, comme local-workspace (emoji custom si défini)
|
||||
var isFolder = item.is_folder || item.has_children;
|
||||
if (isFolder) {
|
||||
html += '<span class="row-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/></svg></span>';
|
||||
} else {
|
||||
html += '<span class="row-icon">' + self._renderIcon(item.icon, 'file') + '</span>';
|
||||
}
|
||||
html += '<span class="row-icon">' + self._renderIcon(item) + '</span>';
|
||||
// Title or rename input
|
||||
if (isRenaming) {
|
||||
html += '<input id="rename-inp-' + item.id + '" value="' + self._escAttr(self.renameValue) + '" onkeydown="if(event.key===\'Enter\')window._libData.commitRenameById(' + item.id + ');if(event.key===\'Escape\')window._libData.cancelRename();" onblur="window._libData.commitRenameById(' + item.id + ')" style="flex:1;min-width:80px;font-size:13px;padding:2px 4px;background:var(--bg-primary);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);outline:none;" onclick="event.stopPropagation()">';
|
||||
} else {
|
||||
html += '<span class="row-title" title="' + self._escAttr(item.title) + '">' + self._escHtml(item.title) + '</span>';
|
||||
}
|
||||
// Tags
|
||||
if (item.tags && item.tags.length) {
|
||||
html += '<span class="row-tags">';
|
||||
for (var ti = 0; ti < item.tags.length; ti++) {
|
||||
var tg = item.tags[ti];
|
||||
html += '<span class="tag-chip" style="background:' + self._escAttr(tg.color || '#787774') + '" title="' + self._escAttr(tg.name) + '">' + self._escHtml(tg.name) + '</span>';
|
||||
}
|
||||
html += '</span>';
|
||||
}
|
||||
// OPEN button (side peek) — hidden for folders
|
||||
if (!isFolder) {
|
||||
html += '<button class="btn-open hover-only" onclick="event.stopPropagation();window._libData.openPeekById(' + item.id + ')" title="Open in side peek">';
|
||||
@@ -587,14 +617,15 @@ function libraryPage() {
|
||||
|
||||
_escHtml(s) { var d=document.createElement('div'); d.textContent = s||''; return d.innerHTML; },
|
||||
_escAttr(s) { return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); },
|
||||
_renderIcon(icon, fallback) {
|
||||
var name = icon || fallback || 'file';
|
||||
// Only allow known icon names, otherwise use fallback
|
||||
var valid = ['folder','file','star','link','trash','book','home','settings','lock','globe','image','edit','calendar','users','user','search','tag','bar-chart','grid','list','align-left','zap','paperclip','key','refresh','upload'];
|
||||
if (valid.indexOf(name) !== -1) return getSvgIcon(name, 14);
|
||||
_renderIcon(item) {
|
||||
if (!item) return getSvgIcon('file', 14);
|
||||
// Custom emoji / non-ASCII icon (e.g. 📝) — render as-is
|
||||
if (/[^\x00-\x7F]/.test(name)) return '<span style="font-size:14px;line-height:1;">' + this._escHtml(name) + '</span>';
|
||||
return getSvgIcon(fallback || 'file', 14);
|
||||
if (item.page_icon) return '<span style="font-size:14px;line-height:1;">' + this._escHtml(item.page_icon) + '</span>';
|
||||
if (item.is_folder || item.has_children) return getSvgIcon('folder', 14);
|
||||
if (item.content_format && item.content_format !== 'file') return getSvgIcon('edit', 14);
|
||||
var n = (item.title || '').toLowerCase();
|
||||
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return getSvgIcon('image', 14);
|
||||
return getSvgIcon('file', 14);
|
||||
},
|
||||
|
||||
_recomputeFlatItems() {
|
||||
@@ -606,6 +637,12 @@ function libraryPage() {
|
||||
return (r.title || '').toLowerCase().indexOf(q) !== -1;
|
||||
});
|
||||
}
|
||||
if (this.tagFilter) {
|
||||
var tf = this.tagFilter;
|
||||
result = result.filter(function(r) {
|
||||
return (r.tags || []).some(function(t) { return t.name === tf; });
|
||||
});
|
||||
}
|
||||
// Use splice for Alpine 3.14.9 reactivity (array reference reassign fails)
|
||||
this.flatItems.splice(0, this.flatItems.length, ...result);
|
||||
this.selectedCount = Object.keys(this.selected).length;
|
||||
@@ -683,10 +720,15 @@ function libraryPage() {
|
||||
link: function (n) { self.copyLink(n); },
|
||||
move: function (n) { self.openMovePicker([n.id]); },
|
||||
fav: function (n) { self.toggleFavoriteItem(n); },
|
||||
delete: function (n) { self.deleteItem(n); }
|
||||
delete: function (n) { self.deleteItem(n); },
|
||||
tagExisting: function (t) { self.ctxAddExistingTag(t); },
|
||||
tagAdd: function (name, color) { self.ctxAddNewTag(name, color); },
|
||||
tagRemove: function (tagId) { self.ctxRemoveTag(tagId); }
|
||||
};
|
||||
if (this.tab === 'recents') handlers.recent = function (n) { self.removeItemFromRecents(n); };
|
||||
Alpine.store('fdCtx').openMenu(evt, item, 'library', handlers);
|
||||
var store = Alpine.store('fdCtx');
|
||||
store.setAvail(self._ctxAvailTags(item));
|
||||
store.openMenu(evt, item, 'library', handlers);
|
||||
},
|
||||
|
||||
// ── Drag & drop (move / reparent) ──
|
||||
@@ -821,6 +863,7 @@ function libraryPage() {
|
||||
this.tab = t;
|
||||
}
|
||||
this._recomputeEmpty();
|
||||
this.loadWorkspaceTags();
|
||||
console.log('[Library] loading tab: ' + this.tab);
|
||||
this.loadTab();
|
||||
},
|
||||
@@ -830,6 +873,7 @@ function libraryPage() {
|
||||
if (t === 'repository' && !this.isGiteaActive) return;
|
||||
this.tab = t;
|
||||
this.shareDir = 'all';
|
||||
this.tagFilter = '';
|
||||
this.items = [];
|
||||
this.selected = {};
|
||||
this.selectedCount = 0;
|
||||
@@ -1075,7 +1119,7 @@ function libraryPage() {
|
||||
body: JSON.stringify({icon: icon})
|
||||
});
|
||||
if (!r.ok) throw new Error('icon update failed');
|
||||
item.icon = icon;
|
||||
item.page_icon = icon;
|
||||
this._renderTable();
|
||||
if (window.showToast) window.showToast('Icon updated', 'success');
|
||||
} catch(e) {
|
||||
@@ -1083,6 +1127,74 @@ function libraryPage() {
|
||||
}
|
||||
},
|
||||
|
||||
// ── Tags ──
|
||||
async loadWorkspaceTags() {
|
||||
try {
|
||||
var r = await fetch('/api/settings/tags/all');
|
||||
var d = await r.json();
|
||||
this.workspaceTags = d.tags || [];
|
||||
} catch(e) { this.workspaceTags = []; }
|
||||
},
|
||||
|
||||
toggleTagFilter(tagName) {
|
||||
this.tagFilter = (this.tagFilter === tagName) ? '' : tagName;
|
||||
this._recomputeFlatItems();
|
||||
},
|
||||
|
||||
_ctxAvailTags(item) {
|
||||
return (this.workspaceTags || []).filter(function(t) {
|
||||
return !((item && item.tags) || []).some(function(nt) { return nt.id === t.id; });
|
||||
});
|
||||
},
|
||||
|
||||
_ctxRefreshAvail() {
|
||||
var store = window.Alpine && Alpine.store('fdCtx');
|
||||
if (store && store.node) store.setAvail(this._ctxAvailTags(store.node));
|
||||
},
|
||||
|
||||
ctxAddExistingTag(tag) {
|
||||
this.ctxAddNewTag(tag.name, tag.color);
|
||||
},
|
||||
|
||||
ctxRemoveTag(tagId) {
|
||||
var store = window.Alpine && Alpine.store('fdCtx');
|
||||
var item = store && store.node;
|
||||
if (!item) return;
|
||||
var self = this;
|
||||
fetch('/api/local-workspace/items/' + item.id + '/tags/' + tagId, { method: 'DELETE' })
|
||||
.then(function(r) {
|
||||
if (!r.ok) return;
|
||||
item.tags = (item.tags || []).filter(function(t) { return t.id !== tagId; });
|
||||
self.loadWorkspaceTags();
|
||||
self._ctxRefreshAvail();
|
||||
self._recomputeFlatItems();
|
||||
});
|
||||
},
|
||||
|
||||
async ctxAddNewTag(tagName, color) {
|
||||
tagName = (tagName || '').trim();
|
||||
if (!tagName) return;
|
||||
var store = window.Alpine && Alpine.store('fdCtx');
|
||||
var item = store && store.node;
|
||||
if (!item) return;
|
||||
color = color || (store && store.newTagColor) || '#787774';
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/items/' + item.id + '/tags', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: tagName, color: color})
|
||||
});
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
var cur = item.tags || [];
|
||||
if (!cur.some(function(t) { return t.id === d.tag.id; })) item.tags = cur.concat([d.tag]);
|
||||
await this.loadWorkspaceTags();
|
||||
this._ctxRefreshAvail();
|
||||
this._recomputeFlatItems();
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
async toggleFavoriteItem(item) {
|
||||
if (!item) return;
|
||||
var csrf = this._getCsrf();
|
||||
|
||||
@@ -532,6 +532,7 @@ window._wsInitData = (function() {
|
||||
workspaceTags: [],
|
||||
tagFilter: '',
|
||||
tagInputId: null,
|
||||
tagsVersion: 0,
|
||||
|
||||
// Preview panel
|
||||
previewItem: null,
|
||||
@@ -1158,11 +1159,13 @@ window._wsInitData = (function() {
|
||||
});
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
if (!node.tags) node.tags = [];
|
||||
if (!node.tags.some(function(t) { return t.id === d.tag.id; })) node.tags.push(d.tag);
|
||||
var cur = node.tags || [];
|
||||
if (!cur.some(function(t) { return t.id === d.tag.id; })) node.tags = cur.concat([d.tag]);
|
||||
this._updateNodeTags(this.tree, node.id, d.tag, 'add');
|
||||
this.displayTree = [...this.tree];
|
||||
this.loadWorkspaceTags();
|
||||
this.tagsVersion++;
|
||||
await this.loadWorkspaceTags();
|
||||
this._computeFilteredTableItems();
|
||||
this._ctxRefreshAvail();
|
||||
}
|
||||
} catch(e) {}
|
||||
@@ -2241,7 +2244,9 @@ window._wsInitData = (function() {
|
||||
// Update node tags in tree
|
||||
this._updateNodeTags(this.tree, itemId, d.tag, 'add');
|
||||
this.displayTree = [...this.tree];
|
||||
this.loadWorkspaceTags();
|
||||
this.tagsVersion++;
|
||||
await this.loadWorkspaceTags();
|
||||
this._computeFilteredTableItems();
|
||||
}
|
||||
} catch(e) {}
|
||||
this.tagInputId = null;
|
||||
@@ -2255,7 +2260,9 @@ window._wsInitData = (function() {
|
||||
if (r.ok) {
|
||||
this._updateNodeTags(this.tree, itemId, null, 'remove', tagId);
|
||||
this.displayTree = [...this.tree];
|
||||
this.loadWorkspaceTags();
|
||||
this.tagsVersion++;
|
||||
await this.loadWorkspaceTags();
|
||||
this._computeFilteredTableItems();
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
@@ -2264,10 +2271,10 @@ window._wsInitData = (function() {
|
||||
for (var i=0; i<nodes.length; i++) {
|
||||
if (nodes[i].id === itemId) {
|
||||
if (action === 'add') {
|
||||
if (!nodes[i].tags) nodes[i].tags = [];
|
||||
// Avoid duplicates
|
||||
if (!nodes[i].tags.some(function(t) { return t.id === tag.id; })) {
|
||||
nodes[i].tags.push(tag);
|
||||
// Réassigne un nouveau tableau → déclenche la réactivité Alpine
|
||||
var cur = nodes[i].tags || [];
|
||||
if (!cur.some(function(t) { return t.id === tag.id; })) {
|
||||
nodes[i].tags = cur.concat([tag]);
|
||||
}
|
||||
} else if (action === 'remove') {
|
||||
nodes[i].tags = (nodes[i].tags || []).filter(function(t) { return t.id !== removeTagId; });
|
||||
@@ -2682,7 +2689,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
</div>
|
||||
<!-- Children -->
|
||||
<ul class="ws-tree" x-show="expanded[node.id]" x-transition
|
||||
x-html="renderChildren(node.children, (node.depth||0)+1)">
|
||||
x-html="renderChildren(node.children, (node.depth||0)+1, tagsVersion)">
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user