v5.5.0 Partage v2 : fenêtre Share rechargée, sidebar « Par moi »/« Avec moi », Library dir=made/received, badge 👥 + indicateurs
- 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:
@@ -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
@@ -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*
|
||||
|
||||
+2
-2
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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+')"'
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user