v5.5.0 Partage v2 : fenêtre Share rechargée, sidebar « Par moi »/« Avec moi », Library dir=made/received, badge 👥 + indicateurs
FlowDeck CI / test (push) Failing after 18s
FlowDeck CI / docker (push) Skipped

- Correctif : la fenêtre Share rechargait ses partages à chaque ouverture (before: liste vide après refresh)
- Sidebar « Shared » scindée en sous-groupes « Par moi » (partages nominatifs + liens) et « Avec moi » (partages reçus), sans doublons
- Bibliothèque : filtre « Tous / Par moi / Avec moi » sur l'onglet Shared ; /api/library/shared?dir=made|received|all + share_dir par élément
- Document : bouton barre affiche « 👥 Shared ▾ » quand la page est partagée (membre, lien ou publiée), recalculé à la volée
- Workspace local : emoji 👥 juste avant le nom des fichiers partagés (is_shared exposé par /api/local-workspace/tree)
- Tests +6 (tests/test_sharing.py) : direction made/received/all, fallback dir invalide, rendu pageIsShared ; suite 325 verte (+3 PDF pré-existants)
This commit is contained in:
2026-09-08 11:52:29 -04:00
parent b3c90efa73
commit 0112a5b5d8
15 changed files with 286 additions and 36 deletions
+27
View File
@@ -1,5 +1,32 @@
# Changelog - FlowDeck
## v5.5.0 (2026-09-08) — Partage : réorganisation & indicateurs
> Vue complète du partage : sidebar « Shared » scindée en « Par moi » /
> « Avec moi », onglet Shared de la bibliothèque filtrable, fenêtre Share
> rechargée à l'ouverture (bug de refresh), et emoji 👥 sur les fichiers
> partagés du workspace local.
- **Correctif fenêtre Share** — ouverture de la boîte de dialogue
(`toggleShareOpen`) au lieu de laisser la liste vide après un refresh ;
les partages sont rechargés (`loadShares`) à chaque ouverture.
- **Sidebar Shared** — la section affiche deux sous-groupes : « Par moi »
(partages nominatifs créés + pages en « lien ») et « Avec moi » (partages
reçus), chacun avec son propre état vide (les pages déjà dans « Par moi »
sont exclues de « Avec moi » pour éviter les doublons).
- **Bibliothèque** — onglet Shared doté d'un filtre « Tous / Par moi /
Avec moi » ; l'endpoint `GET /api/library/shared` accepte `dir=made|received|all`
et renseigne `share_dir` sur chaque élément.
- **Indicateur de partage** — la barre du document affiche « 👥 Shared »
au lieu de « 🔒 Share » dès que la page est partagée (membre, lien ou
publiée) ; état recalculé à la volée après chaque action de partage.
- **Workspace local** — les fichiers partagés affichent l'emoji 👥 juste
avant leur nom dans l'arborescence (`is_shared` exposé par
`/api/local-workspace/tree`).
- **Tests** (tests/test_sharing.py +6) : direction made/received/all de la
bibliothèque, fallback dir invalide, et rendu `pageIsShared` sur la page.
Suite complète 325 verte (+3 PDF pré-existants).
## v5.4.1 (2026-09-08) — Partage de page : correctif permissions + autocomplete membres
> Le partage à un membre existant échouait avec « Invalid permission. Use view,
+1 -1
View File
@@ -636,4 +636,4 @@ Quality DB views, Agent IA Palette → Realtime + S
DB avancée, redo, drag&drop,
Calendrier, AI duplicate)
*Dernière mise à jour: 2026-09-08 — v5.13.0 Realtime livré (WebSocket gateway, présence, curseurs live, merge LWW + fallback polling, 12 tests) puis v5.10.0 Interactions de bloc livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, sync realtime, 9 tests, suite 307 verte) ; reste v5.4 → v6.0*
*Dernière mise à jour: 2026-09-08 — v5.13.0 Realtime livré (WebSocket gateway, présence, curseurs live, merge LWW + fallback polling, 12 tests) puis v5.10.0 Interactions de bloc livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, sync realtime, 9 tests, suite 307 verte) puis v5.5.0 (repo) Partage v2 livré (fenêtre Share rechargée, sidebar « Par moi »/« Avec moi », Library dir=made/received, badge 👥 Shared, 👥 workspace local, 6 tests, suite 325 verte) ; reste v5.4 → v6.0*
+1 -1
View File
@@ -1 +1 @@
5.4.1
5.5.0
+2 -2
View File
@@ -53,7 +53,7 @@ async def lifespan(_app: FastAPI):
from app.services.automations import automation_scheduler
automation_task = asyncio.create_task(automation_scheduler())
logger.info("FlowDeck v5.4.1 started on port %d", settings.app_port)
logger.info("FlowDeck v5.5.0 started on port %d", settings.app_port)
try:
yield
finally:
@@ -71,7 +71,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="5.4.1",
version="5.5.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
+41 -13
View File
@@ -338,20 +338,36 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
"children": [],
})
# Load shared pages (anyone with link or published)
# Load shared pages: made (page_shares created by me + link-mode pages) and
# received (page_shares toward me), plus published pages.
with get_conn() as conn:
shared_rows = conn.execute(
"SELECT id, title, workspace, share_mode, published FROM pages "
"WHERE share_mode='anyone' OR published=1 ORDER BY updated_at DESC LIMIT 20"
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()
shared_pages = []
published_pages = []
for r in shared_rows:
page_entry = {
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": "🌐" if r["published"] else "🔗",
"icon": icon,
"url": f"/pages/{r['id']}",
"active": False,
"indent": 0,
@@ -359,10 +375,17 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
"has_children": False,
"children": [],
}
if r["published"]:
published_pages.append(page_entry)
else:
shared_pages.append(page_entry)
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 = "local"
@@ -396,6 +419,8 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
"current_page": repo or "Dashboard", "last_edited": "now",
"recent_pages": recent, "private_pages": private_items,
"favorite_pages": favorites, "shared_pages": shared_pages,
"shared_made_pages": shared_made_pages,
"shared_received_pages": shared_received_pages,
"published_pages": published_pages,
"user": user,
"auth_method": auth_method,
@@ -542,6 +567,8 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
sidebar["favorite_pages"] = []
sidebar["private_pages"] = [p for p in all_pages if p.get("source") == "🔒 Private"]
sidebar["shared_pages"] = []
sidebar["shared_made_pages"] = []
sidebar["shared_received_pages"] = []
template = env.get_template("library.html")
return template.render(**sidebar)
@@ -1016,6 +1043,7 @@ async def view_page(request: Request, page_id: int):
ctx = {**sidebar, "page": page, "page_favorited": fav is not None,
"page_share_mode": page.get("share_mode", "private"),
"page_published": bool(page.get("published", 0)),
"page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)),
"page_data": page_data,
"breadcrumb_items": nav_crumbs,
"nav_workspace_id": page.get("workspace_id") or 0,
+6 -2
View File
@@ -222,6 +222,8 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
"private_pages": private_pages,
"favorite_pages": [],
"shared_pages": [],
"shared_made_pages": [],
"shared_received_pages": [],
"published_pages": [],
"user": user,
"avatar_url": avatar_url,
@@ -384,6 +386,7 @@ async def view_page_root(request: Request, page_id: int):
"page_favorited": fav is not None,
"page_share_mode": page.get("share_mode", "private"),
"page_published": bool(page.get("published", 0)),
"page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)),
"page_data": page_data,
"collection_data": collection_data,
"breadcrumb_items": nav_crumbs,
@@ -1021,7 +1024,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
if parent_id is None:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, "
"created_at, updated_at FROM pages "
"is_shared, 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,),
@@ -1029,7 +1032,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
else:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, "
"created_at, updated_at FROM pages "
"is_shared, 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),
@@ -1089,6 +1092,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
"updated_at": r["updated_at"],
"author": author,
"tags": tags_map.get(r["id"], []),
"is_shared": bool(r["is_shared"]),
})
return tree
+39 -1
View File
@@ -84,6 +84,7 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
"url": url,
"content_format": content_format,
"favorited": bool(db_row.get("favorited", 0)),
"share_mode": db_row.get("share_mode", "private"),
}
@@ -197,13 +198,45 @@ async def library_shared(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
dir: str = Query(default="all"),
):
if source_type not in SOURCE_TYPES:
source_type = "all"
if dir not in ("made", "received", "all"):
dir = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.share_mode != 'private' AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
# Page ids the user shares toward others (nominal page_shares) or receives
with get_conn() as conn:
made_rows = conn.execute(
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.created_by=?",
(uid,),
).fetchall()
recv_rows = conn.execute(
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.shared_with_user_id=?",
(uid,),
).fetchall()
made_ids = {r[0] for r in made_rows}
recv_ids = {r[0] for r in recv_rows}
conds: list[str] = []
params: list = []
if dir in ("all", "made"):
conds.append("p.share_mode != 'private'")
if dir in ("all", "received") and recv_ids:
conds.append(f"p.id IN ({','.join('?' for _ in recv_ids)})")
params.extend(recv_ids)
if dir == "made" and made_ids:
conds.append(f"p.id IN ({','.join('?' for _ in made_ids)})")
params.extend(made_ids)
if dir == "received" and not recv_ids:
return {"items": []}
query = (
f"{BASE_SELECT} FROM pages p "
f"WHERE ({' OR '.join(conds)}) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
)
if tree:
query += " AND p.parent_id IS NULL"
query, params = _apply_source_filter(query, params, source_type)
@@ -213,6 +246,11 @@ async def library_shared(
rows = conn.execute(query, params).fetchall()
items = _rows_to_items(rows, uid)
for it in items:
sid = it["id"]
is_made = sid in made_ids or it.get("share_mode", "private") != "private"
is_recv = sid in recv_ids
it["share_dir"] = "both" if (is_made and is_recv) else ("made" if is_made else ("received" if is_recv else ""))
_enrich_children(items)
return {"items": items}
+7 -4
View File
@@ -806,6 +806,7 @@
activityOpen:false,pageCreated:'{{ page.get("created_at", "") }}',
shareTab:'share',favorited:false,
pagePublished:{{ page_published | tojson }},
pageIsShared:{{ page_is_shared | tojson }},
generalAccess:'{{ page_share_mode }}',publicPerm:'viewer',
pubAllowEdit:false,pubAllowComments:true,
pageUrl:window.location.href,publishedUrl:'',
@@ -1062,7 +1063,7 @@ applyAIBlocks(text){
else{this.activityOpen=false;}
},
toggleShareOpen(){
if(!this.shareOpen){this.shareOpen=true;this._justOpened=true;setTimeout(()=>this._justOpened=false,200);}
if(!this.shareOpen){this.shareOpen=true;this._justOpened=true;setTimeout(()=>this._justOpened=false,200);this.loadShares();}
else{this.shareOpen=false;}
},
toggleMoreOpen(){
@@ -1070,10 +1071,11 @@ applyAIBlocks(text){
else{this.moreOpen=false;}
},
_justOpened:false,
togglePublish(){this.pagePublished=!this.pagePublished;this.saveShare();},
_syncIsShared(){this.pageIsShared=!!(this.pagePublished||this.generalAccess==='anyone'||(this.accessList||[]).length>0);},
togglePublish(){this.pagePublished=!this.pagePublished;this._syncIsShared();this.saveShare();},
saveShare(){
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/board/api/share/${this.pid}`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({mode:this.generalAccess,published:this.pagePublished})}).catch(()=>{});
fetch(`/board/api/share/${this.pid}`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({mode:this.generalAccess,published:this.pagePublished})}).then(()=>this._syncIsShared()).catch(()=>{});
},
async copyPageLink(){
console.log('copyPageLink invoked');
@@ -1203,7 +1205,8 @@ applyAIBlocks(text){
var self = this;
fetch('/api/pages/' + this.pid + '/shares').then(function(r){ return r.json(); }).then(function(d){
self.accessList = d.shares || [];
}).catch(function(){ self.accessList = []; });
self._syncIsShared();
}).catch(function(){ self.accessList = []; self._syncIsShared(); });
},
removeShare(sid) {
var self = this;
+22 -8
View File
@@ -393,14 +393,28 @@
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.shared" x-transition>
<ul class="sidebar-items" data-section="shared">
{% for page in shared_pages %}
{{ render_tree_item(page) }}
{% endfor %}
{% if not shared_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("users",16) }}</span><span class="page-name text-dim">No shared pages</span></li>
{% endif %}
</ul>
<div class="sidebar-subgroup">
<div class="sidebar-subgroup-label">Par moi</div>
<ul class="sidebar-items" data-section="shared-made">
{% for page in shared_made_pages %}
{{ render_tree_item(page) }}
{% endfor %}
{% if not shared_made_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("link",16) }}</span><span class="page-name text-dim">Aucun partage par moi</span></li>
{% endif %}
</ul>
</div>
<div class="sidebar-subgroup">
<div class="sidebar-subgroup-label">Avec moi</div>
<ul class="sidebar-items" data-section="shared-received">
{% for page in shared_received_pages %}
{{ render_tree_item(page) }}
{% endfor %}
{% if not shared_received_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("users",16) }}</span><span class="page-name text-dim">Aucun partage avec moi</span></li>
{% endif %}
</ul>
</div>
</div>
</div>
+25 -1
View File
@@ -88,6 +88,12 @@
.row-title:hover{text-decoration:underline;}
.row-title input{width:100%;font-size:13px;padding:2px 4px;background:var(--bg-primary);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);outline:none;}
/* ── Shared direction sub-filter pils ── */
.lib-share-directory{display:flex;align-items:center;gap:6px;padding:4px 16px;}
.lib-dir-pill{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:transparent;color:var(--text-secondary);cursor:pointer;transition:background 0.15s,color 0.15s,border-color 0.15s;}
.lib-dir-pill:hover{background:var(--bg-hover);color:var(--text-primary);}
.lib-dir-pill.active{background:var(--accent);border-color:var(--accent);color:#fff;}
/* ── Hover-only elements (hidden until row hover, or when checked/dragging) ── */
.hover-only{opacity:0;transition:opacity 0.12s;flex-shrink:0;}
.lib-row:hover .hover-only,.lib-row.selected .hover-only{opacity:1;}
@@ -247,7 +253,7 @@
</button>
<div class="lib-toolbar-wrap">
<button class="lib-icon-btn" :class="{active: viewOpen}" title="View options" @click.stop="viewOpen=!viewOpen; filterOpen=false; sortOpen=false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="9" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="9" cy="18" r="1.5" fill="currentColor" stroke="none"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="9" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="9" cy="18" r="1.5" fill="currentColor" stroke="none"/></svg>
</button>
<div class="lib-dropdown" x-show="viewOpen" @click.outside="viewOpen=false" x-transition>
<div class="dd-label">Show columns</div>
@@ -258,6 +264,13 @@
</div>
</div>
<!-- Shared direction sub-filter (Par moi / Avec moi) -->
<div class="lib-share-directory" x-show="tab === 'shared'">
<button class="lib-dir-pill" :class="{ active: shareDir === 'all' }" @click="setShareDir('all')">Tous</button>
<button class="lib-dir-pill" :class="{ active: shareDir === 'made' }" @click="setShareDir('made')">Par moi</button>
<button class="lib-dir-pill" :class="{ active: shareDir === 'received' }" @click="setShareDir('received')">Avec moi</button>
</div>
<!-- Search bar (toggleable) -->
<div class="lib-search-bar" x-show="searchOpen" x-transition>
<input id="lib-search-input" type="text" placeholder="Search in this list..." x-model="searchQuery" @input="doSearch()">
@@ -485,6 +498,7 @@ function libraryPage() {
sortOpen: false,
viewOpen: false,
sourceFilter: 'all',
shareDir: 'all',
showAuthor: false,
showSource: false,
@@ -837,6 +851,7 @@ function libraryPage() {
if (this.tab === t) return;
if (t === 'repository' && !this.isGiteaActive) return;
this.tab = t;
this.shareDir = 'all';
this.items = [];
this.selected = {};
this.selectedCount = 0;
@@ -875,6 +890,9 @@ function libraryPage() {
} else if (this.tab !== 'repository' && this.tab !== 'workspace' && this.sourceFilter && this.sourceFilter !== 'all') {
url += sep + 'source_type=' + encodeURIComponent(this.sourceFilter); sep = '&';
}
if (this.tab === 'shared' && this.shareDir && this.shareDir !== 'all') {
url += sep + 'dir=' + encodeURIComponent(this.shareDir); sep = '&';
}
var r = await fetch(url);
var d = await r.json();
// Backend already computes has_children correctly for each item (children
@@ -899,6 +917,12 @@ function libraryPage() {
this.loadTab();
},
setShareDir(v) {
if (this.shareDir === v) return;
this.shareDir = v;
this.loadTab();
},
setSort(field) {
this.sortBy = field;
this.sortOpen = false;
+2
View File
@@ -165,6 +165,7 @@
.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 .btn-open{flex:0 0 auto;}
.ws-shared-badge{flex-shrink:0;margin-right:4px;font-size:13px;line-height:1;opacity:.9;}
.ws-tree-item .actions{display:none;gap:2px;}
.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;}
@@ -1210,6 +1211,7 @@ window._wsInitData = (function() {
+ ' title="Click to rename, double-click to open"'
+ ' onmouseenter="var d=window._wsData;if(d)d.showPreview(event,{id:'+escId+',name:\''+escName+'\',is_folder:false,content_format:\''+(c.content_format||'')+'\'})"'
+ ' onmouseleave="var d=window._wsData;if(d)d.hidePreview()">'
+ (c.is_shared ? '<span class="ws-shared-badge" title="Partag\u00e9" onclick="event.stopPropagation()">👥</span>' : '')
+ '<span id="rc-name-'+escId+'">'+c.name+'</span>'
+ '<input id="rc-input-'+escId+'" class="inline-rename-input" style="display:none"'
+ ' onkeydown="if(event.key===\'Enter\')window._wsData.commitRenderChildRename('+escId+')"'
+1 -1
View File
@@ -2,7 +2,7 @@
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
{% set page_title = page.title %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn" @click="window.E && window.E.toggleComments()" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="window.E && window.E.commentCount>0 ? window.E.commentCount : \'\'"></span></button><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()">' ~ fd_icon("lock",14) ~ ' Share ▾</button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn" @click="window.E && window.E.toggleComments()" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="window.E && window.E.commentCount>0 ? window.E.commentCount : \'\'"></span></button><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% include '_header.html' %}
{% endblock %} {% block content %}
{% include "_page_editor_content.html" %}
+1 -1
View File
@@ -2,7 +2,7 @@
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set page_icon = "file" %}
{% set page_title = page.title %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()">' ~ fd_icon("lock",14) ~ ' Share ▾</button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% include '_header.html' %}
{% endblock %} {% block content %}
{% include "_database_table.html" %}
+21
View File
@@ -369,6 +369,27 @@ input, select, textarea { font-family: inherit; color: inherit; }
.sidebar-item.empty-hint:hover { background: none; }
.sidebar-item.empty-hint .page-name { font-size: 12px; }
/* Shared subgroups (Par moi / Avec moi) */
.sidebar-subgroup {
padding-top: 2px;
}
.sidebar-subgroup + .sidebar-subgroup {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--border);
}
.sidebar-subgroup-label {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
}
/* Sections */
.sidebar-section {
padding: var(--space-xs) var(--space-md);
+90 -1
View File
@@ -199,4 +199,93 @@ def test_remove_share_unsets_is_shared(client):
with get_conn() as conn:
is_shared = conn.execute("SELECT is_shared FROM pages WHERE id=?", (pid,)).fetchone()["is_shared"]
assert is_shared == 0
assert is_shared == 0
# ── Library : direction des partages (dir=made|received|all) ──
def _make_page_with(conn, title, share_mode="private", published=0):
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, parent_section, share_mode, published) "
"VALUES ('Private', ?, '[]', 'blocks', 'Private', ?, ?)",
(title, share_mode, published),
)
conn.commit()
return cur.lastrowid
def test_library_shared_dir_made_includes_nominal_and_link(client):
_auth(client, user_id=1, login="owner")
with get_conn_ctx() as conn:
a = _make_page_with(conn, "Shared A")
b = _make_page_with(conn, "Link B", share_mode="anyone")
_make_page_with(conn, "Private C")
r = client.post(f"/api/pages/{a}/share", json={"user_id": 2, "permission": "edit"})
assert r.status_code == 200
made = client.get("/api/library/shared?dir=made").json()["items"]
ids = [it["id"] for it in made]
assert a in ids, "page shared to a user should appear in made"
assert b in ids, "link-mode page should appear in made"
assert all(it["share_dir"] == "made" for it in made if it["id"] in (a, b))
def test_library_shared_dir_received(client):
_auth(client, user_id=1, login="owner")
with get_conn_ctx() as conn:
a = _make_page_with(conn, "Received A")
r = client.post(f"/api/pages/{a}/share", json={"user_id": 2, "permission": "view"})
assert r.status_code == 200
_auth(client, user_id=2, login="alice")
recv = client.get("/api/library/shared?dir=received").json()["items"]
ids = [it["id"] for it in recv]
assert a in ids
item = next(it for it in recv if it["id"] == a)
assert item["share_dir"] == "received"
def test_library_shared_dir_all_is_union(client):
_auth(client, user_id=1, login="owner")
with get_conn_ctx() as conn:
a = _make_page_with(conn, "Union A")
client.post(f"/api/pages/{a}/share", json={"user_id": 2, "permission": "comment"})
_auth(client, user_id=2, login="alice")
all_items = client.get("/api/library/shared").json()["items"]
ids = [it["id"] for it in all_items]
assert a in ids
def test_library_shared_invalid_dir_falls_back_to_all(client):
_auth(client, user_id=1, login="owner")
with get_conn_ctx() as conn:
a = _make_page_with(conn, "Fallback A", share_mode="anyone")
r = client.get("/api/library/shared?dir=bogus")
assert r.status_code == 200
assert a in [it["id"] for it in r.json()["items"]]
def test_library_shared_received_none(client):
_auth(client, user_id=2, login="alice")
r = client.get("/api/library/shared?dir=received")
assert r.status_code == 200
assert r.json()["items"] == []
def test_page_editor_renders_page_is_shared(client):
_auth(client, user_id=1, login="owner")
with get_conn_ctx() as conn:
a = _make_page_with(conn, "Marked A")
client.post(f"/api/pages/{a}/share", json={"user_id": 2, "permission": "view"})
r = client.get(f"/pages/{a}")
assert r.status_code == 200
assert "pageIsShared:true" in r.text
def get_conn_ctx():
from app.db import get_conn
return get_conn()