diff --git a/CHANGELOG.md b/CHANGELOG.md
index f038246..745a9f2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/app/db.py b/app/db.py
index 0cebc7f..21cbf0a 100644
--- a/app/db.py
+++ b/app/db.py
@@ -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:
diff --git a/app/main.py b/app/main.py
index 82d32c8..9e81d57 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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,
diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py
index 6fca9a5..12065d7 100644
--- a/app/routers/dashboard.py
+++ b/app/routers/dashboard.py
@@ -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}
diff --git a/app/templates/local_workspace.html b/app/templates/local_workspace.html
index cc1c13f..f50c10b 100644
--- a/app/templates/local_workspace.html
+++ b/app/templates/local_workspace.html
@@ -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);}
+
@@ -376,8 +409,75 @@
-
-
+
+
+ Tags:
+
+
+
+
+
+
+
+
+
+
+
|
Name | Type | Size |
@@ -391,7 +491,7 @@
|
- |
+ |
@@ -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 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