feat: badge emoji partagé (noir & blanc) dans l'arborescence + sidebar «Par moi» inclut is_shared
FlowDeck CI / test (push) Failing after 18s
FlowDeck CI / docker (push) Skipped

- board/dashboard: helper _load_shared_sidebar_pages intégrant les pages marquées is_shared directement
- _workspace_tree_macro: badge 👥 N&B sur les fichiers partagés du tree du sidebar
- local_workspace: badge 👥 en noir & blanc
- static/css/app.css: style .page-shared-badge
This commit is contained in:
2026-09-08 13:57:35 -04:00
parent 91b4e8d0e5
commit 3b27c57230
5 changed files with 103 additions and 58 deletions
+67 -50
View File
@@ -175,7 +175,9 @@ def _load_workspace_pages(ws_cookie: str) -> list:
ws_id = int(ws_cookie) ws_id = int(ws_cookie)
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT id, title, parent_section, content_format FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC", "SELECT id, title, parent_section, content_format, "
"is_shared, share_mode, COALESCE(published,0) AS published "
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC",
(ws_id,), (ws_id,),
).fetchall() ).fetchall()
items = [] items = []
@@ -183,11 +185,13 @@ def _load_workspace_pages(ws_cookie: str) -> list:
is_folder = r["parent_section"] == "Workspace" is_folder = r["parent_section"] == "Workspace"
title = r["title"] or "Untitled" title = r["title"] or "Untitled"
sub_children = _load_children(r["id"]) sub_children = _load_children(r["id"])
is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"])
items.append({ items.append({
"db_id": r["id"], "name": title, "db_id": r["id"], "name": title,
"id": f"page/{r['id']}", "id": f"page/{r['id']}",
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]), "icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
"is_folder": is_folder, "is_folder": is_folder,
"is_shared": is_shared,
"child_count": len(sub_children), "child_count": len(sub_children),
"children": sub_children, "children": sub_children,
}) })
@@ -200,7 +204,9 @@ def _load_children(parent_id: int) -> list:
"""Recursively load children of a page.""" """Recursively load children of a page."""
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT id, title, parent_section, content_format FROM pages WHERE parent_id=? AND deleted_at IS NULL ORDER BY created_at", "SELECT id, title, parent_section, content_format, "
"is_shared, share_mode, COALESCE(published,0) AS published "
"FROM pages WHERE parent_id=? AND deleted_at IS NULL ORDER BY created_at",
(parent_id,), (parent_id,),
).fetchall() ).fetchall()
children = [] children = []
@@ -208,11 +214,13 @@ def _load_children(parent_id: int) -> list:
is_folder = r["parent_section"] == "Workspace" is_folder = r["parent_section"] == "Workspace"
title = r["title"] or "Untitled" title = r["title"] or "Untitled"
sub_children = _load_children(r["id"]) sub_children = _load_children(r["id"])
is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"])
children.append({ children.append({
"db_id": r["id"], "name": title, "db_id": r["id"], "name": title,
"id": f"page/{r['id']}", "id": f"page/{r['id']}",
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]), "icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
"is_folder": is_folder, "is_folder": is_folder,
"is_shared": is_shared,
"child_count": len(sub_children), "child_count": len(sub_children),
"children": sub_children, "children": sub_children,
}) })
@@ -235,6 +243,61 @@ def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list
return [] return []
def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
"""Shared / received / published pages for the sidebar (reused by dashboard)."""
with get_conn() as conn:
made_nominal = conn.execute(
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
"JOIN pages p ON p.id=s.page_id "
"WHERE s.created_by=? AND p.deleted_at IS NULL",
(user_id,),
).fetchall()
made_link = conn.execute(
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE share_mode='anyone' AND published=0 AND deleted_at IS NULL"
).fetchall()
made_flag = conn.execute(
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE (is_shared=1 OR share_mode != 'private') AND COALESCE(published,0)=0 AND deleted_at IS NULL"
).fetchall()
published_rows = conn.execute(
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE published=1 AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20"
).fetchall()
received_rows = conn.execute(
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
"JOIN pages p ON p.id=s.page_id "
"WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL",
(user_id,),
).fetchall()
def _entry(r, icon):
return {
"id": f"page/{r['id']}",
"db_id": r["id"],
"name": r["title"] or "New page",
"icon": icon,
"url": f"/pages/{r['id']}",
"active": False,
"indent": 0,
"depth": 0,
"has_children": False,
"children": [],
}
made_map = {}
for r in (*made_nominal, *made_link, *made_flag):
made_map.setdefault(r["id"], r)
made_sorted = sorted(made_map.values(), key=lambda r: r["updated_at"] or "", reverse=True)[:20]
shared_made = [_entry(r, "link") for r in made_sorted]
received_sorted = [r for r in received_rows if r["id"] not in made_map]
received_sorted = sorted(received_sorted, key=lambda r: r["updated_at"] or "", reverse=True)[:20]
shared_received = [_entry(r, "users") for r in received_sorted]
published = [_entry(r, "globe") for r in published_rows]
shared_all = [_entry(r, "link") for r in made_sorted]
return shared_made, shared_received, published, shared_all
def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws_name = user.get("login", "Bruno") if user else "Bruno" ws_name = user.get("login", "Bruno") if user else "Bruno"
@@ -338,54 +401,8 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
"children": [], "children": [],
}) })
# Load shared pages: made (page_shares created by me + link-mode pages) and # Load shared pages
# received (page_shares toward me), plus published pages. shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(uid)
with get_conn() as conn:
made_nominal = conn.execute(
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
"JOIN pages p ON p.id=s.page_id "
"WHERE s.created_by=? AND p.deleted_at IS NULL",
(uid,),
).fetchall()
made_link = conn.execute(
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE share_mode='anyone' AND published=0 AND deleted_at IS NULL"
).fetchall()
published_rows = conn.execute(
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE published=1 AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20"
).fetchall()
received_rows = conn.execute(
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
"JOIN pages p ON p.id=s.page_id "
"WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL",
(uid,),
).fetchall()
def _shared_entry(r, icon):
return {
"id": f"page/{r['id']}",
"db_id": r["id"],
"name": r["title"] or "New page",
"icon": icon,
"url": f"/pages/{r['id']}",
"active": False,
"indent": 0,
"depth": 0,
"has_children": False,
"children": [],
}
made_map = {}
for r in (*made_nominal, *made_link):
made_map.setdefault(r["id"], r)
made_sorted = sorted(made_map.values(), key=lambda r: r["updated_at"] or "", reverse=True)[:20]
shared_made_pages = [_shared_entry(r, "link") for r in made_sorted]
received_sorted = [r for r in received_rows if r["id"] not in made_map]
received_sorted = sorted(received_sorted, key=lambda r: r["updated_at"] or "", reverse=True)[:20]
shared_received_pages = [_shared_entry(r, "users") for r in received_sorted]
published_pages = [_shared_entry(r, "globe") for r in published_rows]
shared_pages = [_shared_entry(r, "link") for r in made_sorted]
# Auth method & OAuth badge data # Auth method & OAuth badge data
auth_method = "local" auth_method = "local"
+13 -4
View File
@@ -208,6 +208,15 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
except Exception: except Exception:
pass pass
# Shared / received / published pages for the sidebar (Par moi / Avec moi)
shared_made_pages = []
shared_received_pages = []
published_pages = []
shared_pages = []
if user and user.get("id"):
from app.routers.board import _load_shared_sidebar_pages
shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(user["id"])
return { return {
"workspace_name": ws, "workspace_initial": initial, "workspace_name": ws, "workspace_initial": initial,
"active_ws_name": active_ws_name, "active_ws_name": active_ws_name,
@@ -221,10 +230,10 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
"recent_pages": recent_pages, "recent_pages": recent_pages,
"private_pages": private_pages, "private_pages": private_pages,
"favorite_pages": [], "favorite_pages": [],
"shared_pages": [], "shared_pages": shared_pages,
"shared_made_pages": [], "shared_made_pages": shared_made_pages,
"shared_received_pages": [], "shared_received_pages": shared_received_pages,
"published_pages": [], "published_pages": published_pages,
"user": user, "user": user,
"avatar_url": avatar_url, "avatar_url": avatar_url,
"avatar_color": avatar_color, "avatar_color": avatar_color,
+1
View File
@@ -27,6 +27,7 @@
<span style="width:12px;flex-shrink:0;"></span> <span style="width:12px;flex-shrink:0;"></span>
{% endif %} {% endif %}
<span class="page-icon">{% set valid_icons = ["folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap"] %}{% if page.icon in valid_icons %}{{ fd_icon(page.icon,14) }}{% elif page.is_folder %}{{ fd_icon("folder",14) }}{% else %}{{ fd_icon("file",14) }}{% endif %}</span> <span class="page-icon">{% set valid_icons = ["folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap"] %}{% if page.icon in valid_icons %}{{ fd_icon(page.icon,14) }}{% elif page.is_folder %}{{ fd_icon("folder",14) }}{% else %}{{ fd_icon("file",14) }}{% endif %}</span>
{% if page.is_shared %}<span class="page-shared-badge" title="Partagé">👥</span>{% endif %}
{% if page.is_folder %} {% if page.is_folder %}
<span class="page-name tree-folder-link" <span class="page-name tree-folder-link"
@click.stop="navigateToFolder({{ page.db_id }})" @click.stop="navigateToFolder({{ page.db_id }})"
+13 -4
View File
@@ -165,7 +165,7 @@
.ws-tree-item .name.file{display:inline-flex;align-items:center;max-width:100%;overflow:hidden;} .ws-tree-item .name.file{display:inline-flex;align-items:center;max-width:100%;overflow:hidden;}
.ws-tree-item .name.file .inline-rename-input{flex:0 1 auto;} .ws-tree-item .name.file .inline-rename-input{flex:0 1 auto;}
.ws-tree-item .name.file .btn-open{flex:0 0 auto;} .ws-tree-item .name.file .btn-open{flex:0 0 auto;}
.ws-shared-badge{flex-shrink:0;margin-right:4px;font-size:13px;line-height:1;opacity:.9;} .ws-shared-badge{flex-shrink:0;margin-right:4px;font-size:13px;line-height:1;opacity:.9;filter:grayscale(1) brightness(1.6);}
.ws-tree-item .actions{display:none;gap:2px;} .ws-tree-item .actions{display:none;gap:2px;}
.ws-tree-item:hover .actions{display:flex;} .ws-tree-item:hover .actions{display:flex;}
.ws-chevron{width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:8px;padding:0;border-radius:3px;transition:transform 150ms,color 100ms;} .ws-chevron{width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:8px;padding:0;border-radius:3px;transition:transform 150ms,color 100ms;}
@@ -2537,6 +2537,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<template x-if="node.is_folder"> <template x-if="node.is_folder">
<span class="name folder" @click.stop="navigateToFolder(node.id)" <span class="name folder" @click.stop="navigateToFolder(node.id)"
@dblclick.prevent.stop="startInlineRename(node, $event)"> @dblclick.prevent.stop="startInlineRename(node, $event)">
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span x-show="renamingId !== node.id" x-text="node.name"></span> <span x-show="renamingId !== node.id" x-text="node.name"></span>
<span class="child-count" x-show="node.child_count > 0 && renamingId !== node.id" <span class="child-count" x-show="node.child_count > 0 && renamingId !== node.id"
x-text="'(' + node.child_count + ')'"></span> x-text="'(' + node.child_count + ')'"></span>
@@ -2550,6 +2551,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
@mouseenter="showPreview($event, node)" @mouseenter="showPreview($event, node)"
@mouseleave="hidePreview()" @mouseleave="hidePreview()"
title="Click to rename, double-click to open"> title="Click to rename, double-click to open">
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span x-show="renamingId !== node.id" x-text="node.name"></span> <span x-show="renamingId !== node.id" x-text="node.name"></span>
<input x-show="renamingId === node.id" class="inline-rename-input" x-model="renamingName" <input x-show="renamingId === node.id" class="inline-rename-input" x-model="renamingName"
@keydown.enter="commitRename(node)" @keydown.escape="cancelRename()" @keydown.enter="commitRename(node)" @keydown.escape="cancelRename()"
@@ -2629,6 +2631,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></td> <td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></td>
<td> <td>
<span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span> <span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span class="col-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>
<td class="col-path" x-text="node._path || '—'"></td> <td class="col-path" x-text="node._path || '—'"></td>
@@ -2684,7 +2687,8 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
</tr> </tr>
<template x-for="node in displayTree" :key="'l'+node.id"> <template x-for="node in displayTree" :key="'l'+node.id">
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer"> <tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer">
<td><span x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span></td> <td><span x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
<td class="col-name" x-text="node.name"></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-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-secondary)" x-text="node.is_folder ? (node.child_count||0)+' items' : (node.size_display||'—')"></td>
@@ -2721,7 +2725,8 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<template x-for="node in displayTree" :key="'d'+node.id"> <template x-for="node in displayTree" :key="'d'+node.id">
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer" <tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer"
:class="{ 'selected-row': previewItem && previewItem.id === node.id }"> :class="{ 'selected-row': previewItem && previewItem.id === node.id }">
<td><span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span></td> <td><span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
<td class="col-name" x-text="node.name"></td> <td class="col-name" x-text="node.name"></td>
<td class="col-path" x-text="node._path || '—'"></td> <td class="col-path" x-text="node._path || '—'"></td>
<td class="col-size" x-text="node.size_display || '—'"></td> <td class="col-size" x-text="node.size_display || '—'"></td>
@@ -2752,7 +2757,10 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<div class="title-card" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" <div class="title-card" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
:class="{ 'selected-card': previewItem && previewItem.id === node.id }"> :class="{ 'selected-card': previewItem && previewItem.id === node.id }">
<div class="title-card-icon" x-html="node.is_folder ? getSvgIcon('folder',24) : _fileIcon(node.name, node.is_folder, node.content_format)"></div> <div class="title-card-icon" x-html="node.is_folder ? getSvgIcon('folder',24) : _fileIcon(node.name, node.is_folder, node.content_format)"></div>
<div class="title-card-name" x-text="node.name"></div> <div class="title-card-name">
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span x-text="node.name"></span>
</div>
<div class="title-card-meta"> <div class="title-card-meta">
<span x-text="_formatDate(node.updated_at)"></span> <span x-text="_formatDate(node.updated_at)"></span>
<span x-text="node.size_display || '—'"></span> <span x-text="node.size_display || '—'"></span>
@@ -2776,6 +2784,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
:class="{ 'selected-row': previewItem && previewItem.id === node.id }"> :class="{ 'selected-row': previewItem && previewItem.id === node.id }">
<div class="content-item-header"> <div class="content-item-header">
<span x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span> <span x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span class="col-name" x-text="node.name"></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> <span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="_formatDate(node.updated_at)"></span>
</div> </div>
+9
View File
@@ -696,6 +696,15 @@ input, select, textarea { font-family: inherit; color: inherit; }
background: var(--bg-hover); background: var(--bg-hover);
} }
/* Shared badge — black & white (grayscale) */
.page-shared-badge {
flex-shrink: 0;
margin-right: 2px;
font-size: 11px;
line-height: 1;
filter: grayscale(1) brightness(1.6);
}
.sidebar-item.active { .sidebar-item.active {
background: var(--bg-active); background: var(--bg-active);
} }