Compare commits

...
2 Commits
Author SHA1 Message Date
bruno e36dead312 feat: multi-vues workspace + preview panel (tree/list/details/title/content)
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
- Sélecteur de vue avec 5 modes: Tree, List, Details, Title, Content
- Vue List: compacte (Name, Type, Size, Modified)
- Vue Details: table complète (Path, Author, Tags)
- Vue Title: grille de cartes avec icônes
- Vue Content: liste avec aperçu inline
- Preview Panel: panneau latéral avec rendu contenu + métadonnées + actions
- Animation slide-in/out pour le preview panel
- Tri par date (Oldest/Newest)
2026-07-12 11:18:57 -04:00
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 758 additions and 23 deletions
+29
View File
@@ -1,5 +1,34 @@
# Changelog — FlowDeck
## v2.3.0 (2026-07-12) — Multi-Vues Workspace & Preview Panel
### Added
- **Sélecteur de vue**: 🌳 Tree, 📋 List, 📊 Details, 🏷️ Title, 📝 Content — 5 modes d'affichage
- **Vue List**: liste compacte avec Name, Type, Size, Modified
- **Vue Details**: table complète avec Path, Author, Tags
- **Vue Title**: grille de cartes avec icônes et métadonnées
- **Vue Content**: liste avec aperçu inline (taille ou nombre d'items)
- **Preview Panel**: panneau latéral droit avec contenu du fichier sélectionné
- Header avec icône + titre + bouton fermer
- Métadonnées (type, taille, date)
- Tags affichés en chips
- Corps avec rendu du contenu (blocks JSON → HTML, texte brut)
- Actions: Open, Rename, Delete
- Animation slide-in/out
- **Tri par date**: options Oldest/Newest dans le selecteur de tri
## 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.3.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}
+527 -16
View File
@@ -189,6 +189,87 @@
.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);}
/* View selector */
.view-selector{display:flex;gap:2px;background:var(--bg-tertiary);border-radius:8px;padding:2px;}
.view-btn{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;border-radius:6px;cursor:pointer;font-size:14px;color:var(--text-tertiary);transition:all 150ms;}
.view-btn:hover{background:var(--bg-hover);color:var(--text);}
.view-btn.active{background:var(--accent);color:#fff;}
/* Title grid */
.title-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px;margin-top:8px;}
.title-card{display:flex;flex-direction:column;align-items:center;padding:16px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:all 150ms;min-height:100px;justify-content:center;}
.title-card:hover{background:var(--bg-hover);border-color:var(--accent);}
.title-card.selected-card{background:rgba(35,131,226,.1);border-color:var(--accent);}
.title-card-icon{font-size:28px;margin-bottom:8px;}
.title-card-name{font-size:13px;font-weight:500;text-align:center;word-break:break-word;max-width:100%;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;}
.title-card-meta{display:flex;gap:8px;margin-top:6px;font-size:11px;color:var(--text-tertiary);}
/* Content list view */
.content-list{display:flex;flex-direction:column;gap:4px;margin-top:8px;}
.content-item{padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;cursor:pointer;transition:all 150ms;}
.content-item:hover{background:var(--bg-hover);border-color:var(--accent);}
.content-item.selected-row{background:rgba(35,131,226,.08);border-color:var(--accent);}
.content-item-header{display:flex;align-items:center;gap:8px;font-size:14px;}
.content-item-preview{font-size:12px;color:var(--text-secondary);margin-top:6px;padding-left:24px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:600px;}
.content-item-tags{display:flex;gap:4px;margin-top:6px;padding-left:24px;}
/* Selected row highlight */
.selected-row{background:rgba(35,131,226,.08)!important;}
/* Preview panel */
.preview-panel{position:fixed;top:44px;right:0;width:380px;max-width:90vw;height:calc(100vh - 44px);background:var(--bg);border-left:1px solid var(--border-strong);box-shadow:-4px 0 20px rgba(0,0,0,.3);z-index:200;display:flex;flex-direction:column;overflow:hidden;}
@media(max-width:768px){.preview-panel{width:100vw;max-width:100vw;}}
.preview-panel-header{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--border);flex-shrink:0;}
.preview-panel-title{font-size:15px;font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.preview-close{background:none;border:none;color:var(--text-tertiary);font-size:20px;cursor:pointer;padding:2px 6px;border-radius:4px;line-height:1;}
.preview-close:hover{color:var(--text);background:var(--bg-hover);}
.preview-panel-meta{display:flex;gap:12px;padding:8px 16px;font-size:12px;color:var(--text-tertiary);border-bottom:1px solid var(--border);flex-shrink:0;}
.preview-panel-tags{display:flex;gap:4px;flex-wrap:wrap;padding:8px 16px;border-bottom:1px solid var(--border);flex-shrink:0;}
.preview-panel-body{flex:1;overflow:auto;padding:16px;font-size:13px;line-height:1.6;color:var(--text);}
.preview-panel-body pre{background:var(--bg-tertiary);border-radius:8px;padding:12px;overflow:auto;font-size:12px;font-family:'Fira Code',monospace;white-space:pre-wrap;word-wrap:break-word;}
.preview-panel-body img{max-width:100%;border-radius:8px;}
.preview-panel-actions{display:flex;gap:8px;padding:12px 16px;border-top:1px solid var(--border);flex-shrink:0;}
/* Transitions */
.preview-enter{transition:transform .2s ease,opacity .2s ease;}
.preview-enter-start{transform:translateX(100%);opacity:0;}
.preview-enter-end{transform:translateX(0);opacity:1;}
.preview-leave{transition:transform .15s ease,opacity .15s ease;}
.preview-leave-start{transform:translateX(0);opacity:1;}
.preview-leave-end{transform:translateX(100%);opacity:0;}
</style>
<div class="ws-layout" x-data="wsInit()"
@@ -260,17 +341,23 @@
<div class="ws-toolbar">
<button class="btn-icon" @click="goBack()" title="Back" :disabled="!canGoBack">←</button>
<button class="btn-icon" @click="goForward()" title="Forward" :disabled="!canGoForward">→</button>
<button class="btn-icon" @click="viewMode = (viewMode==='tree'?'table':'tree')" :title="viewMode==='tree'?'Table view':'Tree view'">
<span x-text="viewMode==='tree'?'📋':'🌳'"></span>
</button>
<div class="view-selector">
<button class="view-btn" :class="{ active: viewMode==='tree' }" @click="viewMode='tree'; previewItem=null" title="Tree view">🌳</button>
<button class="view-btn" :class="{ active: viewMode==='list' }" @click="viewMode='list'; previewItem=null" title="List view">📋</button>
<button class="view-btn" :class="{ active: viewMode==='details' }" @click="viewMode='details'; previewItem=null" title="Details view">📊</button>
<button class="view-btn" :class="{ active: viewMode==='title' }" @click="viewMode='title'; previewItem=null" title="Title cards">🏷️</button>
<button class="view-btn" :class="{ active: viewMode==='content' }" @click="viewMode='content'; previewItem=null" title="Content preview">📝</button>
</div>
<select class="sort-select" x-model="sortBy" @change="doSort()">
<option value="">Sort by...</option>
<option value="name-asc">Name A→Z</option>
<option value="name-desc">Name Z→A</option>
<option value="type">Type</option>
<option value="date-asc">Oldest</option>
<option value="date-desc">Newest</option>
</select>
<button class="btn-icon" @click="expandAll()" title="Expand all">⊞</button>
<button class="btn-icon" @click="collapseAll()" title="Collapse all">⊟</button>
<button class="btn-icon" @click="expandAll()" title="Expand all" x-show="viewMode==='tree'">⊞</button>
<button class="btn-icon" @click="collapseAll()" title="Collapse all" x-show="viewMode==='tree'">⊟</button>
<button class="btn-icon" @click="openModal('file')" title="New file">📄</button>
<button class="btn-icon" @click="openModal('folder')" title="New folder">📁</button>
</div>
@@ -284,6 +371,7 @@
</div>
<!-- Tree -->
<div x-show="viewMode==='tree'" style="display:contents;">
<!-- Search bar -->
<div class="search-bar" x-show="tree.length>0 || searchQuery">
<span class="search-icon">🔍</span>
@@ -294,6 +382,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>
@@ -375,28 +464,175 @@
</li>
</template>
</ul>
</div>
<!-- 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="selectAll($event)"></th>
<th>Name</th><th>Type</th><th style="width:100px">Size</th>
<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 flatTree" :key="'t'+node.id">
<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="table-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></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>
<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>
</tr>
</template>
</tbody>
</table>
<!-- List view — compact flat list -->
<table class="ws-table-compact" x-show="viewMode==='list' && !searchQuery && !filterType && !tagFilter && flatTree.length>0">
<thead><tr>
<th style="width:24px"></th>
<th>Name</th>
<th style="width:80px">Type</th>
<th style="width:80px">Size</th>
<th style="width:100px">Modified</th>
</tr></thead>
<tbody>
<template x-for="node in flatTree" :key="'l'+node.id">
<tr @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" style="cursor:pointer">
<td><span x-text="_fileIcon(node.name, node.is_folder, node.content_format)"></span></td>
<td class="col-name" x-text="node.name"></td>
<td style="font-size:12px;color:var(--text-tertiary)" x-text="node.is_folder ? '📁 Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
<td style="font-size:12px;color:var(--text-secondary)" x-text="node.is_folder ? (node.child_count||0)+' items' : (node.size_display||'—')"></td>
<td style="font-size:12px;color:var(--text-tertiary)" x-text="_formatDate(node.updated_at)"></td>
</tr>
</template>
</tbody>
</table>
<!-- Details view — full table with all columns -->
<table class="ws-table-enhanced" x-show="viewMode==='details' && !searchQuery && !filterType && !tagFilter && flatTree.length>0">
<thead><tr>
<th style="width:24px"></th>
<th>Name</th>
<th>Path</th>
<th>Size</th>
<th>Modified</th>
<th>Type</th>
<th>Author</th>
<th>Tags</th>
</tr></thead>
<tbody>
<template x-for="node in flatTree" :key="'d'+node.id">
<tr @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" style="cursor:pointer"
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
<td><span class="table-icon" x-text="_fileIcon(node.name, node.is_folder, node.content_format)"></span></td>
<td class="col-name" x-text="node.name"></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="'dtag'+t.id">
<span class="tag-chip"><span x-text="t.name"></span></span>
</template>
</span>
</td>
</tr>
</template>
</tbody>
</table>
<!-- Title view — card grid -->
<div class="title-grid" x-show="viewMode==='title' && !searchQuery && !filterType && !tagFilter && flatTree.length>0">
<template x-for="node in flatTree" :key="'g'+node.id">
<div class="title-card" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)"
:class="{ 'selected-card': previewItem && previewItem.id === node.id }">
<div class="title-card-icon" x-text="node.is_folder ? '📁' : _fileIcon(node.name, node.is_folder, node.content_format)"></div>
<div class="title-card-name" x-text="node.name"></div>
<div class="title-card-meta">
<span x-text="_formatDate(node.updated_at)"></span>
<span x-text="node.size_display || '—'"></span>
</div>
</div>
</template>
</div>
<!-- Content view — list with inline preview -->
<div class="content-list" x-show="viewMode==='content' && !searchQuery && !filterType && !tagFilter && flatTree.length>0">
<template x-for="node in flatTree" :key="'c'+node.id">
<div class="content-item" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)"
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
<div class="content-item-header">
<span x-text="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
<span class="col-name" x-text="node.name"></span>
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="_formatDate(node.updated_at)"></span>
</div>
<div class="content-item-preview" x-text="node.is_folder ? (node.child_count||0)+' items' : (node.size_display||'—')"></div>
<div class="content-item-tags" x-show="(node.tags||[]).length > 0">
<template x-for="t in (node.tags||[])" :key="'ctag'+t.id">
<span class="tag-chip"><span x-text="t.name"></span></span>
</template>
</div>
</div>
</template>
</div>
<!-- Old table view kept for backward compat, hidden -->
<table class="ws-table" x-show="false" style="display:none">
<thead><tr><th>Name</th><th>Type</th><th>Size</th></tr></thead>
<tbody><template x-for="node in flatTree" :key="'t'+node.id">
<tr><td x-text="node.name"></td></tr>
</template></tbody>
</table>
<!-- Empty -->
<div class="empty-state" x-show="tree.length===0&&loaded">
<div class="empty-icon">📁</div>
@@ -463,6 +699,31 @@
<div class="file-preview-body" x-html="previewContent"></div>
</div>
<!-- Preview Panel — right side panel for file preview -->
<div class="preview-panel" x-show="previewItem && (viewMode==='list' || viewMode==='details' || viewMode==='title' || viewMode==='content')" x-transition:enter="preview-enter" x-transition:leave="preview-leave">
<div class="preview-panel-header">
<span x-text="_fileIcon(previewItem.name, previewItem.is_folder, previewItem.content_format)"></span>
<span class="preview-panel-title" x-text="previewItem.name"></span>
<button class="preview-close" @click="previewItem=null" title="Close preview">×</button>
</div>
<div class="preview-panel-meta">
<span x-text="previewItem.is_folder ? '📁 Folder' : _fileTypeLabel(previewItem.name, previewItem.content_format)"></span>
<span x-text="previewItem.size_display || '—'"></span>
<span x-text="_formatDate(previewItem.updated_at)"></span>
</div>
<div class="preview-panel-tags" x-show="(previewItem.tags||[]).length > 0">
<template x-for="t in (previewItem.tags||[])" :key="'ptag'+t.id">
<span class="tag-chip"><span x-text="t.name"></span></span>
</template>
</div>
<div class="preview-panel-body" x-html="previewPanelContent || '<div style=text-align:center;padding:40px;color:var(--text-tertiary)>Loading preview...</div>'"></div>
<div class="preview-panel-actions">
<button class="btn btn-secondary" @click="openPage(previewItem.id)">📄 Open</button>
<button class="btn btn-secondary" @click="startRename(previewItem)">✏️ Rename</button>
<button class="btn btn-danger" @click="deleteWithUndo(previewItem)">🗑 Delete</button>
</div>
</div>
</div>
<script>
@@ -503,6 +764,15 @@ document.addEventListener('alpine:init', function() {
sortBy: '',
viewMode: 'tree',
// Tags
workspaceTags: [],
tagFilter: '',
tagInputId: null,
// Preview panel
previewItem: null,
previewPanelContent: '',
// Multi-select
selectedIds: {},
selectedIdsCount: 0,
@@ -580,6 +850,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,11 +935,126 @@ 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;
},
// ── Preview panel ──
selectForPreview(node) {
this.previewItem = node;
this.previewPanelContent = '<div style="text-align:center;padding:40px;color:var(--text-tertiary)">Loading...</div>';
this._loadPreviewContent(node);
},
async _loadPreviewContent(node) {
if (node.is_folder) {
this.previewPanelContent = '<div style="text-align:center;padding:40px;color:var(--text-secondary)">'
+ '<span style="font-size:48px;display:block;margin-bottom:12px">📁</span>'
+ '<div style="font-size:14px">' + (node.child_count||0) + ' item(s)</div>'
+ '<button class="btn btn-secondary" style="margin-top:16px" onclick="window.location=\'/local-workspace?folder=' + node.id + '\'">Open folder</button>'
+ '</div>';
return;
}
try {
var r = await this._fetchRetry('/api/local-workspace/page-content/' + node.id, true);
var content = r.content || '';
var format = r.format || '';
var preview = '';
if (format === 'file') {
preview = '<div style="text-align:center;padding:20px;color:var(--text-secondary)">'
+ '<span style="font-size:48px;display:block;margin-bottom:8px">📄</span>'
+ 'Uploaded file — open to view</div>';
} else if (format === 'blocks') {
try {
var blocks = typeof content === 'string' ? JSON.parse(content) : content;
if (blocks && blocks.blocks) {
preview = blocks.blocks.slice(0, 30).map(function(b) {
var txt = b.content || b.text || (typeof b === 'string' ? b : '');
return '<div style="margin-bottom:6px;line-height:1.5">' + this._escHtml(txt) + '</div>';
}, this).join('');
}
} catch(e) { preview = '<pre>' + this._escHtml(String(content).substring(0, 2000)) + '</pre>'; }
} else {
preview = '<pre>' + this._escHtml(String(content).substring(0, 2000)) + '</pre>';
}
this.previewPanelContent = preview || '<div style="color:var(--text-tertiary);text-align:center;padding:20px">(empty)</div>';
} catch(e) {
this.previewPanelContent = '<div style="text-align:center;padding:20px;color:var(--text-tertiary)">Preview not available</div>';
}
},
_escHtml(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
},
// ── Content snippet for content view ──
get contentSnippetMap() {
// Preload snippets? Too expensive. Return empty, load on hover.
return {};
},
_markTree(nodes, depth) {
for (var i=0; i<nodes.length; i++) {
var n = nodes[i];
n.depth = depth;
n.db_id = n.id;
n._path = '/' + n.name;
if (n.children && n.children.length > 0) {
this._markTree(n.children, depth + 1);
}
@@ -893,6 +1280,8 @@ document.addEventListener('alpine:init', function() {
var sorted = this.tree.slice();
if (s === 'name-asc') sorted.sort(function(a,b) { return (a.name||'').localeCompare(b.name||''); });
else if (s === 'name-desc') sorted.sort(function(a,b) { return (b.name||'').localeCompare(a.name||''); });
else if (s === 'date-asc') sorted.sort(function(a,b) { return (a.updated_at||'').localeCompare(b.updated_at||''); });
else if (s === 'date-desc') sorted.sort(function(a,b) { return (b.updated_at||'').localeCompare(a.updated_at||''); });
else if (s === 'type') sorted.sort(function(a,b) { return (a.is_folder===b.is_folder) ? 0 : a.is_folder ? -1 : 1; });
this.displayTree = sorted;
},
@@ -1114,9 +1503,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 +1522,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 +1910,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>