Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4ad4f5b6d |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user