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:
+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();
|
||||
|
||||
Reference in New Issue
Block a user