Compare commits

...
1 Commits
Author SHA1 Message Date
bruno f4ad4f5b6d feat: système de tags + vue table enrichie pour recherche/filtre workspace
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- DB: tables tags + page_tags avec index
- API: CRUD tags (GET/POST/DELETE items/{id}/tags, GET /tags, GET /tags/search)
- Tree endpoint enrichi: size, dates, author, tags par nœud
- Table enrichie: colonnes Name, Path, Size, Modified, Type, Author, Tags
- Tag chips avec compteurs + filtrage par clic
- Ajout/retrait tags inline dans la table
- Filtre Folders + auto-switch table quand recherche active
2026-07-12 10:52:42 -04:00
5 changed files with 509 additions and 13 deletions
+12
View File
@@ -1,5 +1,17 @@
# Changelog — FlowDeck
## v2.2.0 (2026-07-12) — Tags & Recherche Avancée
### Added
- **Tags**: système complet de tags avec tables `tags` + `page_tags`, CRUD API
- **API Tags**: `GET/POST/DELETE /api/local-workspace/items/{id}/tags`, `GET /api/local-workspace/tags`, `GET /api/local-workspace/tags/search?tags=...`
- **Vue Table enrichie**: colonnes Name, Path, Size, Modified, Type, Author, Tags
- **Filtrage par tags**: barre de chips avec compteurs, clic pour filtrer
- **Ajout/retrait tags inline**: bouton + dans la table, chips avec × pour retirer
- **Tree endpoint enrichi**: chaque nœud retourne `size`, `size_display`, `created_at`, `updated_at`, `author`, `tags`
- **Filtre "Dossiers"**: nouveau chip 📁 Folders dans la barre de filtres
- **Auto-switch table**: quand recherche/filtre actif, passe en vue table automatiquement
## v2.1.1 (2026-07-12) — Fix Arborescence Workspace
### Fixed
+19 -2
View File
@@ -123,8 +123,8 @@ def init_db():
project_owner TEXT NOT NULL,
project_name TEXT NOT NULL,
name TEXT NOT NULL,
prop_type TEXT NOT NULL DEFAULT 'select', -- select, multi_select, date, person, text
options_json TEXT DEFAULT '[]', -- for select/multi_select
prop_type TEXT NOT NULL DEFAULT 'select',
options_json TEXT DEFAULT '[]',
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project_owner, project_name, name)
@@ -293,6 +293,23 @@ def init_db():
content_json TEXT DEFAULT '[]',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- v2.2.0: Tags system
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
color TEXT DEFAULT '#787774',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS page_tags (
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (page_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_pt_page ON page_tags(page_id);
CREATE INDEX IF NOT EXISTS idx_pt_tag ON page_tags(tag_id);
""")
# Migration: add parent_id if missing (v1.0.0+)
try:
+1 -1
View File
@@ -39,7 +39,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="2.1.1",
version="2.2.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
+182 -4
View File
@@ -581,21 +581,60 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
"""Recursively build the tree of children for a node."""
if parent_id is None:
rows = conn.execute(
"SELECT id, title, parent_section, content_format FROM pages "
"WHERE parent_id IS NULL AND workspace_id=? ORDER BY created_at DESC",
"SELECT id, title, parent_section, content_format, content, "
"created_at, updated_at FROM pages "
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL "
"ORDER BY created_at DESC",
(ws_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, title, parent_section, content_format FROM pages "
"WHERE parent_id=? AND workspace_id=? ORDER BY created_at DESC",
"SELECT id, title, parent_section, content_format, content, "
"created_at, updated_at FROM pages "
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
"ORDER BY created_at DESC",
(parent_id, ws_id),
).fetchall()
# Get workspace owner name for author display
ws_owner = conn.execute(
"SELECT u.full_name, u.login FROM workspaces w JOIN users u ON u.id=w.owner_id WHERE w.id=?",
(ws_id,),
).fetchone()
author = ws_owner["full_name"] or ws_owner["login"] if ws_owner else "—"
# Collect all page IDs to fetch tags in one query
all_ids = [r["id"] for r in rows]
tags_map = {}
if all_ids:
placeholders = ",".join("?" for _ in all_ids)
tag_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})",
all_ids,
).fetchall()
for tr in tag_rows:
tags_map.setdefault(tr["page_id"], []).append({
"id": tr["id"], "name": tr["name"], "color": tr["color"],
})
tree = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
children = _build_tree_children(conn, r["id"], ws_id)
# Compute size
size = 0
if r["content_format"] == "file":
import json as _json
try:
meta = _json.loads(r["content"])
size = meta.get("size", 0)
except Exception:
size = len(r["content"] or "")
else:
size = len(r["content"] or "")
tree.append({
"id": r["id"],
"name": r["title"] or "Untitled",
@@ -605,10 +644,27 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
"children": children,
"has_children": len(children) > 0,
"child_count": len(children),
"size": size,
"size_display": _format_size(size),
"created_at": r["created_at"],
"updated_at": r["updated_at"],
"author": author,
"tags": tags_map.get(r["id"], []),
})
return tree
def _format_size(size_bytes: int) -> str:
"""Human-readable file size."""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
elif size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
def _build_breadcrumb(conn, folder_id: int) -> list:
"""Build breadcrumb trail from root to folder_id."""
breadcrumb = []
@@ -1022,3 +1078,125 @@ async def select_workspace(request: Request, ws_id: int):
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
return response
# ═══════════ Tags API ═══════════
@router.get("/api/local-workspace/tags")
async def list_tags(request: Request):
"""List all tags used in the active workspace."""
ws = _get_active_workspace(request)
ws_id = ws["id"] if ws else None
if not ws_id:
return {"tags": []}
import json
with get_conn() as conn:
rows = conn.execute(
"SELECT DISTINCT t.id, t.name, t.color, COUNT(pt.page_id) as count "
"FROM tags t "
"JOIN page_tags pt ON pt.tag_id = t.id "
"JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? "
"GROUP BY t.id ORDER BY t.name",
(ws_id,),
).fetchall()
return {"tags": [dict(r) for r in rows]}
@router.get("/api/local-workspace/items/{item_id:int}/tags")
async def get_item_tags(item_id: int):
"""Get tags for a specific item."""
with get_conn() as conn:
rows = conn.execute(
"SELECT t.id, t.name, t.color FROM tags t "
"JOIN page_tags pt ON pt.tag_id = t.id "
"WHERE pt.page_id = ? ORDER BY t.name",
(item_id,),
).fetchall()
return {"tags": [dict(r) for r in rows]}
@router.post("/api/local-workspace/items/{item_id:int}/tags")
async def add_item_tag(request: Request, item_id: int):
"""Add a tag to an item (creates tag if new)."""
import json
body = await request.json()
tag_name = body.get("name", "").strip().lower()
if not tag_name:
return {"error": "Tag name required"}, 400
with get_conn() as conn:
# Get or create tag
tag = conn.execute("SELECT id, name, color FROM tags WHERE name = ?", (tag_name,)).fetchone()
if not tag:
cursor = conn.execute(
"INSERT INTO tags (name) VALUES (?)", (tag_name,)
)
conn.commit()
tag_id = cursor.lastrowid
tag = {"id": tag_id, "name": tag_name, "color": "#787774"}
else:
tag_id = tag["id"]
# Link tag to page (ignore duplicate)
try:
conn.execute(
"INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)",
(item_id, tag_id),
)
conn.commit()
except Exception:
pass
return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}}
@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}")
async def remove_item_tag(item_id: int, tag_id: int):
"""Remove a tag from an item."""
with get_conn() as conn:
conn.execute(
"DELETE FROM page_tags WHERE page_id = ? AND tag_id = ?",
(item_id, tag_id),
)
conn.commit()
return {"status": "ok"}
@router.get("/api/local-workspace/tags/search")
async def search_by_tags(request: Request, tags: str = ""):
"""Search items by tags (comma-separated)."""
ws = _get_active_workspace(request)
ws_id = ws["id"] if ws else None
if not ws_id:
return {"items": []}
import json
tag_names = [t.strip().lower() for t in tags.split(",") if t.strip()]
if not tag_names:
return {"items": []}
with get_conn() as conn:
placeholders = ",".join("?" for _ in tag_names)
rows = conn.execute(
f"SELECT DISTINCT p.id, p.title, p.content_format, p.parent_section, "
f"p.content, p.created_at, p.updated_at "
f"FROM pages p "
f"JOIN page_tags pt ON pt.page_id = p.id "
f"JOIN tags t ON t.id = pt.tag_id "
f"WHERE t.name IN ({placeholders}) AND p.workspace_id = ? AND p.deleted_at IS NULL "
f"ORDER BY p.updated_at DESC",
tag_names + [ws_id],
).fetchall()
items = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
size = len(r["content"] or "")
items.append({
"id": r["id"],
"name": r["title"] or "Untitled",
"is_folder": is_folder,
"content_format": r["content_format"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
"size": size,
"size_display": _format_size(size),
})
return {"items": items}
+295 -6
View File
@@ -189,6 +189,38 @@
.file-preview{position:fixed;z-index:300;background:#1a1a1a;background:var(--bg,#1a1a1a);border:1px solid var(--border-strong,#333);border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.5);max-width:400px;min-width:200px;pointer-events:auto;}
.file-preview-header{padding:8px 12px;font-size:12px;font-weight:600;color:var(--text-secondary,#888);border-bottom:1px solid var(--border,#333);}
.file-preview-body{padding:10px 12px;margin:0;font-size:12px;line-height:1.5;color:var(--text,#ddd);max-height:200px;overflow:auto;font-family:'Fira Code','Cascadia Code',monospace;white-space:pre-wrap;word-wrap:break-word;background:#141414;}
/* Tag chips */
.tag-chip{display:inline-flex;align-items:center;gap:3px;padding:1px 7px;background:rgba(120,119,116,.15);border-radius:10px;font-size:11px;color:var(--text-secondary);white-space:nowrap;margin:1px 2px;transition:all 150ms;}
.tag-chip:hover{background:rgba(120,119,116,.25);}
.tag-chip.rm-btn{background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:13px;line-height:1;padding:0 2px;border-radius:50%;}
.tag-chip.rm-btn:hover{color:var(--danger);background:rgba(255,80,80,.15);}
.tag-chip.active{background:var(--accent);color:#fff;}
.tag-add-btn{display:inline-flex;align-items:center;gap:3px;padding:1px 7px;background:none;border:1px dashed var(--border);border-radius:10px;font-size:11px;color:var(--text-tertiary);cursor:pointer;white-space:nowrap;transition:all 150ms;}
.tag-add-btn:hover{border-color:var(--accent);color:var(--accent);}
.tag-input{background:var(--bg);border:1px solid var(--accent);border-radius:8px;color:var(--text);font-size:11px;padding:2px 6px;outline:none;min-width:80px;}
.tag-filter-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:8px;padding:4px 0;}
/* Enhanced table */
.ws-table-enhanced{width:100%;border-collapse:collapse;font-size:13px;margin-top:4px;}
.ws-table-enhanced th{text-align:left;padding:10px 12px;color:var(--text-tertiary);font-weight:500;border-bottom:2px solid var(--border);font-size:11px;text-transform:uppercase;letter-spacing:.5px;position:sticky;top:0;background:var(--bg);z-index:1;}
.ws-table-enhanced td{padding:8px 12px;border-bottom:1px solid var(--border);vertical-align:middle;}
.ws-table-enhanced tr:hover td{background:var(--bg-hover);}
.ws-table-enhanced .col-name{font-weight:500;cursor:pointer;min-width:150px;}
.ws-table-enhanced .col-name:hover{color:var(--accent);}
.ws-table-enhanced .col-path{color:var(--text-tertiary);font-size:12px;font-family:'Fira Code',monospace;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ws-table-enhanced .col-size{color:var(--text-secondary);font-size:12px;white-space:nowrap;}
.ws-table-enhanced .col-date{color:var(--text-tertiary);font-size:12px;white-space:nowrap;}
.ws-table-enhanced .col-type{font-size:12px;white-space:nowrap;}
.ws-table-enhanced .col-author{font-size:12px;color:var(--text-secondary);white-space:nowrap;}
.ws-table-enhanced .col-tags{min-width:120px;}
.ws-table-enhanced .table-icon{font-size:16px;margin-right:6px;vertical-align:-2px;}
/* Table compact mode (inside filter/search) */
.ws-table-compact{width:100%;border-collapse:collapse;font-size:13px;}
.ws-table-compact th{text-align:left;padding:6px 10px;color:var(--text-tertiary);font-weight:500;border-bottom:2px solid var(--border);font-size:11px;text-transform:uppercase;}
.ws-table-compact td{padding:5px 10px;border-bottom:1px solid var(--border);}
.ws-table-compact tr:hover td{background:var(--bg-hover);}
</style>
<div class="ws-layout" x-data="wsInit()"
@@ -294,6 +326,7 @@
<!-- Filter chips -->
<div class="filter-chips" x-show="tree.length>0">
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders only">📁 Folders</button>
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="FlowDeck pages">📝 Pages</button>
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF">📕 PDF</button>
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images">🖼️ Img</button>
@@ -376,8 +409,75 @@
</template>
</ul>
<!-- Table view -->
<table class="ws-table" x-show="viewMode==='table' && displayTree.length>0">
<!-- Tag filter bar -->
<div class="tag-filter-bar" x-show="workspaceTags && workspaceTags.length > 0">
<span style="font-size:11px;color:var(--text-tertiary);margin-right:4px;">Tags:</span>
<template x-for="t in workspaceTags" :key="'tf'+t.id">
<button class="tag-chip" :class="{ active: tagFilter === t.name }"
@click="toggleTagFilter(t.name)">
<span x-text="t.name"></span>
<span style="font-size:10px;opacity:.7" x-text="'('+t.count+')'"></span>
</button>
</template>
<button x-show="tagFilter" class="tag-chip rm-btn" @click="tagFilter='';doFilter()" style="margin-left:4px;">✕ clear</button>
</div>
<!-- Filter table (shown when search/filter/tag is active) -->
<table class="ws-table-enhanced" x-show="(searchQuery || filterType || tagFilter) && filteredTableItems.length > 0">
<thead><tr>
<th style="width:30px"><input type="checkbox" @click="selectAllFiltered($event)"></th>
<th>Name</th>
<th>Path</th>
<th>Size</th>
<th>Modified</th>
<th>Type</th>
<th>Author</th>
<th>Tags</th>
<th style="width:60px"></th>
</tr></thead>
<tbody>
<template x-for="node in filteredTableItems" :key="'ft'+node.id">
<tr :class="{ selected: !!selectedIds[node.id] }">
<td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></td>
<td>
<span class="table-icon" x-text="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
<span class="col-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span>
</td>
<td class="col-path" x-text="node._path || '—'"></td>
<td class="col-size" x-text="node.size_display || '—'"></td>
<td class="col-date" x-text="_formatDate(node.updated_at)"></td>
<td class="col-type" x-text="node.is_folder ? '📁 Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
<td class="col-author" x-text="node.author || '—'"></td>
<td class="col-tags">
<span style="display:flex;align-items:center;gap:2px;flex-wrap:wrap;">
<template x-for="t in (node.tags||[])" :key="'ftag'+t.id">
<span class="tag-chip">
<span x-text="t.name"></span>
<button class="tag-chip rm-btn" @click.stop="removeTag(node.id, t.id)" title="Remove tag">×</button>
</span>
</template>
<button class="tag-add-btn" @click.stop="startTagInput(node)" title="Add tag">+</button>
<span x-show="tagInputId === node.id" style="display:contents;">
<input class="tag-input" x-ref="tagInputEl"
@keydown.enter="addTag(node.id, $event.target.value); $event.target.value=''"
@keydown.escape="tagInputId=null"
@blur="tagInputId=null"
@click.stop
placeholder="tag...">
</span>
</span>
</td>
<td>
<button class="ws-act" @click.stop="startRename(node)" title="Rename">✏️</button>
<button class="ws-act danger" @click.stop="deleteWithUndo(node)" title="Delete">🗑</button>
</td>
</tr>
</template>
</tbody>
</table>
<!-- Regular table view (toggle) -->
<table class="ws-table" x-show="viewMode==='table' && !searchQuery && !filterType && !tagFilter && displayTree.length>0">
<thead><tr>
<th style="width:30px"><input type="checkbox" @click="selectAll($event)"></th>
<th>Name</th><th>Type</th><th style="width:100px">Size</th>
@@ -391,7 +491,7 @@
<span class="table-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span>
</td>
<td x-text="node.is_folder ? 'Folder' : 'File'"></td>
<td class="table-meta" x-text="node.is_folder ? (node.child_count ? node.child_count+' items' : 'Empty') : '—'"></td>
<td class="table-meta" x-text="node.is_folder ? (node.child_count ? node.child_count+' items' : 'Empty') : (node.size_display || '—')"></td>
</tr>
</template>
</tbody>
@@ -503,6 +603,11 @@ document.addEventListener('alpine:init', function() {
sortBy: '',
viewMode: 'tree',
// Tags
workspaceTags: [],
tagFilter: '',
tagInputId: null,
// Multi-select
selectedIds: {},
selectedIdsCount: 0,
@@ -580,6 +685,8 @@ document.addEventListener('alpine:init', function() {
var self = this;
window.addEventListener('online', function() { self.online = true; });
window.addEventListener('offline', function() { self.online = false; });
// Load workspace tags
this.loadWorkspaceTags();
} catch(e) { console.error('Tree load error:', e); this.loaded = true; }
},
@@ -663,6 +770,66 @@ document.addEventListener('alpine:init', function() {
}
},
// ── Filtered table items (for enhanced filter/search table) ──
get filteredTableItems() {
var self = this;
var q = (this.searchQuery || '').toLowerCase().trim();
var ft = this.filterType || '';
var tf = this.tagFilter || '';
// If no filter active, return empty (show regular views)
if (!q && !ft && !tf) return [];
// Flatten entire tree
var all = [];
this._flattenTree(this.tree, all);
// Build path for each item
for (var i=0; i<all.length; i++) {
all[i]._path = self._getItemPath(all[i].id);
}
// Filter
return all.filter(function(n) {
var nameMatch = !q || (n.name || '').toLowerCase().indexOf(q) !== -1;
var typeMatch = true;
if (ft) {
switch(ft) {
case 'folder': typeMatch = n.is_folder; break;
case 'pdf': typeMatch = /\.pdf$/.test((n.name||'').toLowerCase()); break;
case 'image': typeMatch = /\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test((n.name||'').toLowerCase()); break;
case 'code': typeMatch = /\.(py|js|jsx|ts|tsx|rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r|sh|bash|ps1|sql)$/.test((n.name||'').toLowerCase()); break;
case 'text': typeMatch = /\.(txt|md|markdown|log)$/.test((n.name||'').toLowerCase()); break;
case 'page': typeMatch = !n.is_folder && !/\.(pdf|png|jpe?g|gif|webp|svg|bmp|ico|py|js|jsx|ts|tsx|rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r|sh|bash|ps1|sql|txt|md|markdown|log)$/.test((n.name||'').toLowerCase()); break;
}
}
var tagMatch = true;
if (tf) {
tagMatch = (n.tags || []).some(function(t) { return t.name === tf; });
}
return nameMatch && typeMatch && tagMatch;
});
},
_getItemPath(id) {
// Build folder path by walking up parent chain via tree
var path = [];
var found = this._findInTree(this.tree, id);
if (!found) return '—';
return '/' + found.path.reverse().join('/');
},
_findInTree(nodes, targetId, currentPath) {
if (!currentPath) currentPath = [];
for (var i=0; i<nodes.length; i++) {
var n = nodes[i];
if (n.id === targetId) {
return {node: n, path: currentPath.concat([n.name])};
}
if (n.children && n.children.length > 0) {
var r = this._findInTree(n.children, targetId, currentPath.concat([n.name]));
if (r) return r;
}
}
return null;
},
_markTree(nodes, depth) {
for (var i=0; i<nodes.length; i++) {
var n = nodes[i];
@@ -1114,9 +1281,9 @@ document.addEventListener('alpine:init', function() {
doFilter() {
var q = (this.searchQuery || '').toLowerCase().trim();
var ft = this.filterType || '';
if (!q && !ft) { this.displayTree = [...this.tree]; return; }
var self = this;
this.displayTree = this._filterTree(this.tree, q, ft);
if (!q && !ft && !this.tagFilter) { this.displayTree = [...this.tree]; return; }
// filteredTableItems is a computed getter — just trigger reactivity
this.displayTree = [...this.tree];
},
filterByType(type) {
this.filterType = (this.filterType === type) ? '' : type;
@@ -1133,6 +1300,7 @@ document.addEventListener('alpine:init', function() {
if (fileType && !n.is_folder) {
var nm = (n.name || '').toLowerCase();
switch(fileType) {
case 'folder': typeMatch = n.is_folder; break;
case 'pdf': typeMatch = /\.pdf$/.test(nm); break;
case 'image': typeMatch = /\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(nm); break;
case 'code': typeMatch = /\.(py|js|jsx|ts|tsx|rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r|sh|bash|ps1|sql)$/.test(nm); break;
@@ -1520,9 +1688,130 @@ document.addEventListener('alpine:init', function() {
this.uploadProgress = 0;
setTimeout(function() { self.uploading = false; }, 2000);
}
},
// ── Tag management ──
async loadWorkspaceTags() {
try {
var r = await fetch('/api/local-workspace/tags');
var d = await r.json();
this.workspaceTags = d.tags || [];
} catch(e) { this.workspaceTags = []; }
},
toggleTagFilter(tagName) {
this.tagFilter = (this.tagFilter === tagName) ? '' : tagName;
this.filterType = '';
this.searchQuery = '';
},
startTagInput(node) {
this.tagInputId = node.id;
var self = this;
this.$nextTick(function() {
var inp = self.$el.querySelector('.tag-input:not([style*="display: none"])');
if (inp) inp.focus();
});
},
async addTag(itemId, tagName) {
tagName = (tagName || '').trim();
if (!tagName) { this.tagInputId = null; return; }
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: tagName})
});
if (r.ok) {
var d = await r.json();
// Update node tags in tree
this._updateNodeTags(this.tree, itemId, d.tag, 'add');
this.displayTree = [...this.tree];
this.loadWorkspaceTags();
}
} catch(e) {}
this.tagInputId = null;
},
async removeTag(itemId, tagId) {
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {
method: 'DELETE'
});
if (r.ok) {
this._updateNodeTags(this.tree, itemId, null, 'remove', tagId);
this.displayTree = [...this.tree];
this.loadWorkspaceTags();
}
} catch(e) {}
},
_updateNodeTags(nodes, itemId, tag, action, removeTagId) {
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);
}
} else if (action === 'remove') {
nodes[i].tags = (nodes[i].tags || []).filter(function(t) { return t.id !== removeTagId; });
}
return true;
}
if (nodes[i].children && this._updateNodeTags(nodes[i].children, itemId, tag, action, removeTagId)) {
return true;
}
}
return false;
},
// ── Helpers ──
_formatDate(dt) {
if (!dt) return '—';
var d = new Date(dt.replace(' ', 'T') + 'Z');
if (isNaN(d.getTime())) return '—';
var now = new Date();
var diffMs = now - d;
var diffMin = Math.floor(diffMs / 60000);
var diffHrs = Math.floor(diffMs / 3600000);
var diffDays = Math.floor(diffMs / 86400000);
if (diffMin < 1) return 'Just now';
if (diffMin < 60) return diffMin + 'm ago';
if (diffHrs < 24) return diffHrs + 'h ago';
if (diffDays < 7) return diffDays + 'd ago';
return d.toLocaleDateString('en-CA');
},
_fileTypeLabel(name, contentFormat) {
if (contentFormat && contentFormat !== 'file') return '📝 Page';
var n = (name || '').toLowerCase();
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return '🖼️ Image';
if (/\.pdf$/.test(n)) return '📕 PDF';
if (/\.(py|js|jsx|ts|tsx|rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r|sh|bash|ps1|sql)$/.test(n)) return '📜 Code';
if (/\.(txt|md|markdown|log)$/.test(n)) return '📄 Text';
if (/\.(zip|tar|gz|rar|7z)$/.test(n)) return '📦 Archive';
if (/\.(mp3|wav|ogg|flac|aac)$/.test(n)) return '🎵 Audio';
if (/\.(mp4|webm|mov|avi)$/.test(n)) return '🎬 Video';
return '📄 File';
},
selectAllFiltered(ev) {
if (ev.target.checked) {
for (var i=0; i<this.filteredTableItems.length; i++) {
this.selectedIds[this.filteredTableItems[i].id] = true;
}
} else {
for (var k in this.selectedIds) delete this.selectedIds[k];
}
this.selectedIds = Object.assign({}, this.selectedIds);
this.selectedIdsCount = Object.keys(this.selectedIds).length;
}
};
});
});
</script>