v5.4.1 Partage : correctif permissions + autocomplete membres (inclut v5.4.0 Interactions de bloc)
- Fix "Invalid permission" : le client envoyait editor/commenter/viewer alors que
l'API attend view/comment/edit (invitePermission -> 'edit', menu participants aligné)
- PUT /api/pages/{page_id}/share/{share_id} : mise à jour de permission persistée
- Autocomplete des membres existants dans le champ d'invitation (search users + debounce,
navigation clavier, envoi user_id pour lier le partage au compte)
- Upsert anti-doublon dans le partage + trim email backend
- 12 tests tests/test_sharing.py ; suite 319 verte (+3 PDF pre-existants)
Sans oublier v5.4.0 (non publie jusque-la) :
- Undo/Redo (Ctrl+Z/Ctrl+Shift+Z/Ctrl+Y), duplicate (Ctrl+D), menu de bloc (turn
into, couleurs, lien #fdblk-, move to, delete), drag&drop multi-selection,
slash "Actions", en-tetes de tableau (has_header/first_col_header) + exports,
sync realtime immédiat apres mutation ; 9 tests test_block_interactions.py
This commit is contained in:
@@ -1,5 +1,63 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v5.4.1 (2026-09-08) — Partage de page : correctif permissions + autocomplete membres
|
||||
|
||||
> Le partage à un membre existant échouait avec « Invalid permission. Use view,
|
||||
> comment, or edit. » — le client envoyait `editor`/`commenter`/`viewer` alors
|
||||
> que l'API n'accepte que `view`/`comment`/`edit`. Corrigé, et enrichi.
|
||||
|
||||
- **Correctif permissions** — défaut du champ d'invitation passé à `edit` ;
|
||||
valeurs, libellés et surbrillance du menu de permission des participants
|
||||
alignés sur le contrat API (`view`/`comment`/`edit`).
|
||||
- **Mise à jour de permission persistée** — nouvel endpoint
|
||||
`PUT /api/pages/{page_id}/share/{share_id}` ; le menu de permission d'un
|
||||
participant enregistre désormais le changement (avant : visuel uniquement).
|
||||
- **Autocomplete des membres existants** — en tapant dans le champ d'invitation,
|
||||
recherche dans `/api/notifications/users/search?q=` (debounce 150 ms) avec
|
||||
liste de suggestions (avatar couleur, login, nom complet), navigation
|
||||
clavier ↑/↓/Entrée/Échap ; soi-même et membres déjà partagés exclus.
|
||||
- **Partage lié au compte** — la sélection d'un membre dans les suggestions
|
||||
envoie `user_id`, le partage est donc lié au compte (pas seulement à un
|
||||
email) ; un email peut toujours être saisi librement.
|
||||
- **Upsert anti-doublon** — partager un membre déjà présent met à jour sa
|
||||
permission au lieu de créer une ligne dupliquée ; trim de l'email côté serveur.
|
||||
- **Tests** (tests/test_sharing.py) : partage par user_id/email, permission
|
||||
invalide rejetée, upsert, PUT permission, suppression avec unset is_shared,
|
||||
erreurs d'auth. Suite complète 319 verte (+3 PDF pré-existants).
|
||||
|
||||
## v5.4.0 (2026-09-08) — Interactions de bloc
|
||||
|
||||
> Manipulation riche des blocs façon Notion : undo/redo, duplication, menu de
|
||||
> bloc ⋮ (turn into, couleurs, lien vers le bloc, déplacer, supprimer),
|
||||
> glisser-déposer multi-sélection et en-têtes de tableau configurables.
|
||||
|
||||
- **Undo/Redo** : pile d'historique (100 entrées) alimentée à chaque mutation
|
||||
(frappe Entrée, retour arrière, slash, tableau, toggle, colonnes…), rappel
|
||||
`Ctrl+Z` / `Ctrl+Shift+Z` ou `Ctrl+Y`, restauration avec re-focus du bloc.
|
||||
- **Duplication de bloc** : `Ctrl+D`, menu bloc ou commande slash « Duplicate » ;
|
||||
copie profonde (enfants columns/toggle inclus) insérée juste en dessous ;
|
||||
en multi-sélection, le groupe entier est dupliqué.
|
||||
- **Menu de bloc ⋮** sur la poignée (clic ou clic droit) : « Turn into »
|
||||
(sous-menu conservant le contenu), Duplicate, Copy link to block
|
||||
(`#fdblk-<id>`, scrolls/focus au chargement), Move to… (dialogue de
|
||||
recherche des pages de l'arbre, déplacement du bloc via l'API), Delete,
|
||||
couleur de texte et de fond appliquées à un bloc ou à la sélection.
|
||||
- **Glisser-déposer multi-sélection** : `Shift+clic` sur la poignée ajoute un
|
||||
bloc à la sélection ; déplacer un bloc sélectionné déplace tout le groupe
|
||||
(reconstruction de l'ordre depuis le DOM via `data-id`).
|
||||
- **Commandes slash étendues** : groupe « Actions » avec Duplicate, Turn
|
||||
into…, Copy link to block et Delete block.
|
||||
- **En-têtes de tableau** : toggles « Header row » / « First col » dans la
|
||||
barre du tableau ; `has_header` (toujours actif par défaut) et nouveaux
|
||||
`first_col_header` persistés et pris en compte dans les exports Markdown/HTML
|
||||
(app/services/export.py) ainsi que que dans `blocksToMarkdown` client.
|
||||
- **Intégration realtime** : méthode `syncNow`/`forceEmit` sur `window.__fdRT`
|
||||
appelée après chaque mutation programmatique pour pousser immédiatement les
|
||||
ops (undo, duplicate, drag, turn into, couleurs, déplacement, toggles).
|
||||
- **Tests** (tests/test_block_interactions.py) : persistance des blocs avec
|
||||
`has_header`/`first_col_header`/`style`, exports table Markdown/HTML,
|
||||
endpoint « Move to » (get page + POST blocks sur la cible).
|
||||
|
||||
## v5.3.0 (2026-09-08) — Realtime : édition collaborative en direct
|
||||
|
||||
> Deux utilisateurs peuvent maintenant éditer une même page **en même temps**
|
||||
|
||||
+21
-18
@@ -479,6 +479,18 @@ app/
|
||||
- [x] **Templates de database prédéfinis** — 6 templates seedés (CRM, Project tracker, Task list, Content calendar, Meeting notes, Reading list) + galerie au clic « Database » (Get Started) et `/database` ; `POST /db/api` & `/db/inline/api` acceptent `template` et matérialisent les propriétés
|
||||
- [x] **Validation des propriétés** (required, unique, min/max) — côté serveur (`validate_property_rule`, 400 + messages) + UI (modale propriété + erreurs de cellule)
|
||||
|
||||
### v5.10.0 — Éditeur : interactions de bloc ✅ (2026-09-08)
|
||||
> **Objectif** : parité de manipulation des blocs avec Notion — les blocs étaient éditables
|
||||
> mais *statiques* (impossible de les déplacer, dupliquer ou annuler une action). **COMPLETED**.
|
||||
|
||||
- [x] **Drag & drop des blocs** — poignée ⋮⋮ au survol de chaque bloc, drag vertical pour réordonner, indicateur de drop (ligne bleue) ; **multi-sélection** (Shift+clic sur poignée → sélection groupée déplacée d'un seul geste, ordre reconstruit depuis le DOM via `data-id`)
|
||||
- [x] **Menu contextuel de bloc** — clic (ou clic droit) sur ⋮⋮ : Turn into (sous-menu conservant le contenu), Duplicate, Copy link to block (`#fdblk-<id>` avec re-focus), Move to (recherche de pages + API), Delete, couleurs texte/fond appliquées au bloc ou à la sélection
|
||||
- [x] **Undo / Redo** — `Ctrl+Z` / `Ctrl+Shift+Z`/`Ctrl+Y`, pile de 100 opérations en mémoire du tab, re-focus du bloc restauré, déclencheur sur toutes les mutations (entrée, retour arrière, slash, tableaux, colonnes, toggles…)
|
||||
- [x] **Duplicate block** — `Ctrl+D`, menu bloc ou slash command « Duplicate » ; copie profonde (enfants columns/toggle) en multi-sélection
|
||||
- [x] **Slash command étendue** — groupe « Actions » : Turn into, Duplicate, Copy link to block, Delete block
|
||||
- [x] **En-têtes de tableau** — toggles « Header row » / « First col » dans la barre du tableau (`has_header` défaut actif + nouveau `first_col_header`), persistés + rendu `<th>` en preview et exports Markdown/HTML
|
||||
- [x] **Intégration realtime** — `syncNow()` poussé après chaque mutation programmatique ; **9 tests** `tests/test_block_interactions.py` ; suite complète 307 verte (+3 PDF pré-existants)
|
||||
|
||||
### v5.13.0 — Collaboration temps réel ✅ (2026-09-08)
|
||||
> **Objectif** : édition collaborative en direct (parité Notion en équipe). **COMPLETED**.
|
||||
> Chantier n°1 de la parité Notion ; socle pour v5.14.0 (synced blocks).
|
||||
@@ -544,16 +556,7 @@ app/
|
||||
|
||||
---
|
||||
|
||||
## v5.10.0 — Éditeur : interactions de bloc ⬜ (non commencé)
|
||||
> **Objectif** : parité de manipulation des blocs avec Notion. Actuellement les blocs sont
|
||||
> éditables mais *statiques* : impossible de les déplacer, dupliquer ou annuler une action.
|
||||
|
||||
- [ ] **Drag & drop des blocs** — poignée ⋮⋮ au survol de chaque bloc, drag vertical pour réordonner, indicateur de drop (ligne bleue), support multi-sélection de blocs (Shift+clic → déplacement groupé)
|
||||
- [ ] **Menu contextuel de bloc** — clic sur ⋮⋮ : Turn into (changer le type), Duplicate, Copy link to block, Move to, Delete, couleur de texte/fond
|
||||
- [ ] **Undo / Redo** — Ctrl+Z / Ctrl+Shift+Z ou Ctrl+Y sur l'état des blocs (pile d'opérations inversibles par page, conservée en mémoire du tab)
|
||||
- [ ] **Duplicate block** — Ctrl+D sur le bloc courant (copie insérée juste en dessous)
|
||||
- [ ] **Slash command étendue** — la commande `/` propose aussi : Turn into, Duplicate, et toutes les actions du menu bloc
|
||||
- [ ] **En-têtes de tableau (bloc table v4.8.0)** — toggles « First row as header » / « First column as header » dans la config du bloc table, rendu `<th>` correspondant en preview et exports
|
||||
## v5.10.0 — Éditeur : interactions de bloc ✅ (livré — voir section Completed)
|
||||
|
||||
## v5.11.0 — Wiki-links & mentions de page ⬜ (non commencé)
|
||||
> **Objectif** : le graphe de connaissances Notion. Complète les backlinks de v5.4.0
|
||||
@@ -614,7 +617,7 @@ app/
|
||||
2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré (palette Ctrl+K + `GET /api/search`)
|
||||
3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré (slash `/database`, 6 templates, validation propriétés)
|
||||
1. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré (`app/services/realtime_server.py` + `WS /ws/pages/{id}`, présence, curseurs live, merge LWW, 12 tests)
|
||||
2. **v5.10.0 → Interactions de bloc** (drag&drop, undo/redo, duplicate) — gros impact UX, effort modéré — prochaine priorité
|
||||
2. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 tests)
|
||||
---
|
||||
|
||||
## Résumé des phases
|
||||
@@ -626,11 +629,11 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
|
||||
+ UI Notion + Tags + Admin + Sharing COMPLETED
|
||||
+ GitHub OAuth + Library
|
||||
|
||||
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.4–5.12 ⬜ v5.13 ✅ · v5.14 ⬜ v6.0 ⬜
|
||||
Quality DB views, Agent IA Palette → Éditeur bloc Realtime + Pro + Agent
|
||||
& Tests Templates & COMPLETED Automations (drag&drop, Synced blocks
|
||||
Collaboration Realtime, undo/redo) +
|
||||
DB avancée, Wiki-links,
|
||||
Calendrier, AI Templates & lock
|
||||
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.14 ⬜ v6.0 ⬜
|
||||
Quality DB views, Agent IA Palette → Realtime + Synced Pro + Agent
|
||||
& Tests Templates & COMPLETED Automations Interactions blocks
|
||||
Collaboration Realtime, de bloc (undo/
|
||||
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, suite 298 verte) ; prochaine priorité v5.10.0 Interactions de bloc ; 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) ; 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.3.0 started on port %d", settings.app_port)
|
||||
logger.info("FlowDeck v5.4.1 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.3.0",
|
||||
version="5.4.1",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
|
||||
+59
-6
@@ -61,16 +61,41 @@ async def share_page(page_id: int, request: Request):
|
||||
if not target:
|
||||
raise HTTPException(404, "Target user not found")
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(page_id, target_user_id, email, permission, user["id"]),
|
||||
)
|
||||
# Upsert to avoid duplicates: update the existing permission if the same
|
||||
# target (user or email) is already shared on this page.
|
||||
target_row = None
|
||||
if target_user_id:
|
||||
target_row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE page_id=? AND shared_with_user_id=?",
|
||||
(page_id, target_user_id),
|
||||
).fetchone()
|
||||
elif email:
|
||||
target_row = conn.execute(
|
||||
"""SELECT id FROM page_shares
|
||||
WHERE page_id=? AND shared_with_email=? AND shared_with_user_id IS NULL""",
|
||||
(page_id, email.strip()),
|
||||
).fetchone()
|
||||
|
||||
if target_row:
|
||||
conn.execute(
|
||||
"UPDATE page_shares SET permission=?, created_by=? WHERE id=?",
|
||||
(permission, user["id"], target_row["id"]),
|
||||
)
|
||||
share_id = target_row["id"]
|
||||
else:
|
||||
if not email:
|
||||
email = ""
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(page_id, target_user_id, email.strip(), permission, user["id"]),
|
||||
)
|
||||
share_id = cur.lastrowid
|
||||
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"id": cur.lastrowid,
|
||||
"id": share_id,
|
||||
"page_id": page_id,
|
||||
"shared_with_user_id": target_user_id,
|
||||
"shared_with_email": email,
|
||||
@@ -79,6 +104,34 @@ async def share_page(page_id: int, request: Request):
|
||||
}
|
||||
|
||||
|
||||
@router.put("/pages/{page_id}/share/{share_id}", description="Update a share's permission.")
|
||||
async def update_share_permission(page_id: int, share_id: int, request: Request):
|
||||
"""Change the permission level of an existing share entry."""
|
||||
_require_auth(request)
|
||||
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
permission = body.get("permission", "")
|
||||
|
||||
if permission not in ("view", "comment", "edit"):
|
||||
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE id=? AND page_id=?",
|
||||
(share_id, page_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Share entry not found")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE page_shares SET permission=? WHERE id=?",
|
||||
(permission, share_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"status": "updated", "share_id": share_id, "permission": permission}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/share/{share_id}")
|
||||
async def remove_share(page_id: int, share_id: int, request: Request):
|
||||
"""Remove a share invitation."""
|
||||
|
||||
+37
-19
@@ -264,24 +264,28 @@ def _table_to_markdown(b: dict) -> str:
|
||||
align = b.get("align") or []
|
||||
width = max(len(r) for r in rows)
|
||||
align = (align + ["left"] * width)[:width]
|
||||
has_header = b.get("has_header", True)
|
||||
out: list[str] = []
|
||||
|
||||
def rowline(r):
|
||||
cells = list(r) + [""] * (width - len(r))
|
||||
return "| " + " | ".join(cells) + " |"
|
||||
|
||||
for ri, r in enumerate(rows):
|
||||
out.append(rowline(r))
|
||||
if b.get("has_header") and ri == 0:
|
||||
seps = []
|
||||
for a in align:
|
||||
if a == "center":
|
||||
seps.append(":---:")
|
||||
elif a == "right":
|
||||
seps.append("---:")
|
||||
else:
|
||||
seps.append(":---")
|
||||
out.append("| " + " | ".join(seps) + " |")
|
||||
start = 0
|
||||
if has_header:
|
||||
out.append(rowline(rows[0]))
|
||||
seps = []
|
||||
for a in align:
|
||||
if a == "center":
|
||||
seps.append(":---:")
|
||||
elif a == "right":
|
||||
seps.append("---:")
|
||||
else:
|
||||
seps.append(":---")
|
||||
out.append("| " + " | ".join(seps) + " |")
|
||||
start = 1
|
||||
for ri in range(start, len(rows)):
|
||||
out.append(rowline(rows[ri]))
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
@@ -297,16 +301,30 @@ def _table_to_html(b: dict) -> str:
|
||||
style = f' style="text-align:{a};"' if a and a != "left" else ""
|
||||
return f"<{tag}{style}>{_text(text)}</{tag}>"
|
||||
|
||||
def row_html(r, tag_default):
|
||||
cells = list(r) + [""] * (width - len(r))
|
||||
return "<tr>" + "".join(cell_html(tag_default, c, align[ci]) for ci, c in enumerate(cells)) + "</tr>"
|
||||
|
||||
header_rows = 1 if b.get("has_header") else 0
|
||||
has_header = b.get("has_header", True)
|
||||
first_col = b.get("first_col_header", False)
|
||||
header_rows = 1 if has_header else 0
|
||||
head = ""
|
||||
if header_rows:
|
||||
head = "<thead>" + "".join(row_html(r, "th") for r in rows[:header_rows]) + "</thead>"
|
||||
head_rows = []
|
||||
hr = rows[0]
|
||||
cells = list(hr) + [""] * (width - len(hr))
|
||||
head_cells = []
|
||||
for ci, c in enumerate(cells):
|
||||
tag = "th" if first_col and ci == 0 else "th"
|
||||
head_cells.append(cell_html(tag, c, align[ci]))
|
||||
head_rows.append("<tr>" + "".join(head_cells) + "</tr>")
|
||||
head = "<thead>" + "".join(head_rows) + "</thead>"
|
||||
tbody_rows = rows[header_rows:]
|
||||
body = "<tbody>" + "".join(row_html(r, "td") for r in tbody_rows) + "</tbody>"
|
||||
body_rows = []
|
||||
for r in tbody_rows:
|
||||
cells = list(r) + [""] * (width - len(r))
|
||||
row_cells = []
|
||||
for ci, c in enumerate(cells):
|
||||
tag = "th" if first_col and ci == 0 else "td"
|
||||
row_cells.append(cell_html(tag, c, align[ci]))
|
||||
body_rows.append("<tr>" + "".join(row_cells) + "</tr>")
|
||||
body = "<tbody>" + "".join(body_rows) + "</tbody>"
|
||||
return f'<table class="ftable">{head}{body}</table>'
|
||||
|
||||
|
||||
|
||||
@@ -33,17 +33,65 @@
|
||||
<!-- ═══════ SHARE TAB ═══════ -->
|
||||
<div class="sd-body" x-show="shareTab === 'share'" x-transition>
|
||||
<!-- Invite input -->
|
||||
<div class="sd-invite-row">
|
||||
<input
|
||||
type="email"
|
||||
class="sd-input"
|
||||
placeholder="Add people, groups or emails..."
|
||||
x-model="inviteEmail"
|
||||
@keydown.enter="shareInvite()"
|
||||
/>
|
||||
<button class="sd-btn-primary" @click="shareInvite()">
|
||||
Invite
|
||||
</button>
|
||||
<div class="sd-invite-wrap">
|
||||
<div class="sd-invite-row">
|
||||
<input
|
||||
type="text"
|
||||
class="sd-input"
|
||||
x-ref="inviteInput"
|
||||
placeholder="Add people, emails or type a name..."
|
||||
x-model="inviteEmail"
|
||||
@input="inviteInput()"
|
||||
@focus="inviteInput()"
|
||||
@keydown.enter.prevent="inviteEnter()"
|
||||
@keydown.down.prevent="inviteMove(1)"
|
||||
@keydown.up.prevent="inviteMove(-1)"
|
||||
@keydown.escape="inviteSuggestOpen = false; inviteUsers = []"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button class="sd-btn-primary" @click="shareInvite()">
|
||||
Invite
|
||||
</button>
|
||||
</div>
|
||||
<!-- Existing users autocomplete -->
|
||||
<div
|
||||
class="sd-user-suggest"
|
||||
x-ref="inviteSuggest"
|
||||
x-show="inviteSuggestOpen"
|
||||
@click.outside="inviteSuggestOpen = false"
|
||||
x-transition.opacity
|
||||
>
|
||||
<template x-for="(u, i) in inviteUsers" :key="u.id">
|
||||
<div
|
||||
class="sd-user-item"
|
||||
:class="{ active: inviteSel === i }"
|
||||
@mouseenter="inviteSel = i"
|
||||
@click="pickInviteUser(u)"
|
||||
>
|
||||
<div
|
||||
class="sd-user-avatar"
|
||||
:style="{ background: u.avatar_color || '#3A3A3A' }"
|
||||
>
|
||||
<img
|
||||
x-show="u.avatar_url"
|
||||
:src="u.avatar_url"
|
||||
:alt="u.login"
|
||||
style="width:100%;height:100%;border-radius:50%;object-fit:cover;"
|
||||
/>
|
||||
<span
|
||||
x-show="!u.avatar_url"
|
||||
x-text="((u.full_name || u.login || '?').charAt(0).toUpperCase())"
|
||||
></span>
|
||||
</div>
|
||||
<div class="sd-user-meta">
|
||||
<div class="sd-user-login" x-text="u.login"></div>
|
||||
<div class="sd-user-name" x-text="u.full_name"></div>
|
||||
</div>
|
||||
<div class="sd-user-add">Invite</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="sd-user-empty" x-show="!inviteUsers.length">No matching users</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context card (shown when independent permissions) -->
|
||||
@@ -78,7 +126,7 @@
|
||||
@click="a.permOpen = !a.permOpen"
|
||||
>
|
||||
<span
|
||||
x-text="a.permission === 'editor' ? 'Full access' : a.permission === 'commenter' ? 'Can comment' : 'Can view'"
|
||||
x-text="a.permission === 'edit' ? 'Full access' : a.permission === 'comment' ? 'Can comment' : 'Can view'"
|
||||
></span>
|
||||
▾
|
||||
</button>
|
||||
@@ -89,8 +137,8 @@
|
||||
>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'editor' }"
|
||||
@click="a.permission='editor'; a.permOpen=false"
|
||||
:class="{ active: a.permission === 'edit' }"
|
||||
@click="updateShare(a.id, 'edit'); a.permOpen=false"
|
||||
>
|
||||
<span>Can edit</span>
|
||||
<span class="sd-perm-desc"
|
||||
@@ -99,8 +147,8 @@
|
||||
</div>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'commenter' }"
|
||||
@click="a.permission='commenter'; a.permOpen=false"
|
||||
:class="{ active: a.permission === 'comment' }"
|
||||
@click="updateShare(a.id, 'comment'); a.permOpen=false"
|
||||
>
|
||||
<span>Can comment</span>
|
||||
<span class="sd-perm-desc"
|
||||
@@ -109,8 +157,8 @@
|
||||
</div>
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'viewer' }"
|
||||
@click="a.permission='viewer'; a.permOpen=false"
|
||||
:class="{ active: a.permission === 'view' }"
|
||||
@click="updateShare(a.id, 'view'); a.permOpen=false"
|
||||
>
|
||||
<span>Can view</span>
|
||||
<span class="sd-perm-desc"
|
||||
|
||||
@@ -475,6 +475,13 @@ window.__fdRT = (function () {
|
||||
connect();
|
||||
}
|
||||
|
||||
return { start };
|
||||
// poussée immédiate des ops après une mutation programmatique (v5.10.0)
|
||||
function syncNow() {
|
||||
if (mode !== 'ws' || !open || !E || !E.blocks) return;
|
||||
clearTimeout(emitT);
|
||||
emit();
|
||||
}
|
||||
|
||||
return { start, syncNow };
|
||||
})();
|
||||
</script>
|
||||
@@ -11,7 +11,8 @@
|
||||
{id:'columns',name:'Column list',icon:'▮'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'},
|
||||
{id:'table',name:'Table',icon:'⊞'},{id:'button',name:'Button / Automation',icon:'⚡'},
|
||||
]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼️'}]},
|
||||
{name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]}];
|
||||
{name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]},
|
||||
{name:'ACTIONS',items:[{id:'duplicate',name:'Duplicate',icon:'▣'},{id:'turn_into',name:'Turn into…',icon:'↩'},{id:'copy_link',name:'Copy link to block',icon:'🔗'},{id:'delete',name:'Delete block',icon:'✕'}]}];
|
||||
|
||||
let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
|
||||
function cp(e){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(e);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;}
|
||||
@@ -65,8 +66,8 @@
|
||||
var w=0;rows.forEach(function(r){if(r&&r.length>w)w=r.length;});if(w<1)w=2;
|
||||
var widths=(b&&Array.isArray(b.colsW))?b.colsW:[];
|
||||
var bid=b.id;var h='';
|
||||
h+='<div class="block-wrapper"><div class="block-content ftable-editor" data-bid="'+bid+'">';
|
||||
h+='<div class="ftable-flex"><div class="ftable-flex-main">';
|
||||
var hasHeader=(b.has_header!==false);var firstCol=(!!b.first_col_header);
|
||||
h+='<div class="block-wrapper" data-id="'+bid+'"><div class="block-content ftable-editor" data-bid="'+bid+'">';
|
||||
h+='<div class="ftable-colbar">';
|
||||
for(var c=0;c<w;c++){
|
||||
h+='<div class="ftable-colcell"><button type="button" class="ftable-colgrip" data-tbl="colmenu" data-bid="'+bid+'" data-col="'+c+'" title="Column options">'+_tblGrip()+'</button></div>';
|
||||
@@ -74,22 +75,21 @@
|
||||
h+='</div>';
|
||||
h+='<table class="ftable ftable-edit"><tbody>';
|
||||
for(var ri=0;ri<rows.length;ri++){
|
||||
var isHead=(ri===0);var tag=isHead?'th':'td';
|
||||
var isHead=(ri===0&&hasHeader)||(ri>0&&firstCol);var tag='td';
|
||||
if(isHead){tag='th';}
|
||||
h+='<tr class="ftable-row">';
|
||||
for(var ci=0;ci<w;ci++){
|
||||
var rcH=((ri===0&&hasHeader)||(ci===0&&firstCol));
|
||||
var v=rows[ri]&&ci<rows[ri].length?rows[ri][ci]:'';
|
||||
var wpx=(widths[ci]&&widths[ci]>0)?(' style="width:'+widths[ci]+'px"'):'';
|
||||
h+='<'+tag+' class="ftable-cell'+(isHead?' head':'')+'" contenteditable="true" data-r="'+ri+'" data-c="'+ci+'"'+wpx+' spellcheck="false">'+esc(v);
|
||||
if(isHead)h+='<span class="ftable-resize" data-col="'+ci+'" title="Drag to resize column"></span>';
|
||||
h+='<'+tag+' class="ftable-cell'+(rcH?' head':'')+'" contenteditable="true" data-r="'+ri+'" data-c="'+ci+'"'+wpx+' spellcheck="false">'+esc(v);
|
||||
if(ri===0&&hasHeader)h+='<span class="ftable-resize" data-col="'+ci+'" title="Drag to resize column"></span>';
|
||||
h+='</'+tag+'>';
|
||||
}
|
||||
h+='</tr>';
|
||||
}
|
||||
h+='</tbody></table>';
|
||||
h+='<div class="ftable-rowbar"><button type="button" class="ftable-addrow" data-tbl="rowadd" data-bid="'+bid+'" title="Click to add a row">+ Row</button></div>';
|
||||
h+='</div>';
|
||||
h+='<div class="ftable-addcolwrap"><button type="button" class="ftable-addcol" data-tbl="coladdright" data-bid="'+bid+'" title="Add column on the right">+</button></div>';
|
||||
h+='</div>';
|
||||
h+='<div class="ftable-rowbar"><button type="button" class="ftable-addrow" data-tbl="rowadd" data-bid="'+bid+'" title="Click to add a row">+ Row</button><button type="button" class="ftable-addrow" data-tbl="coladdright" data-bid="'+bid+'" title="Add column on the right">+ Col</button><span class="ftable-hdr-toggle"><label><input type="checkbox" data-tbl="header" data-bid="'+bid+'" '+(hasHeader?'checked':'')+'> Header row</label></span><span class="ftable-hdr-toggle"><label><input type="checkbox" data-tbl="firstcol" data-bid="'+bid+'" '+(firstCol?'checked':'')+'> First col</label></span></div>';
|
||||
h+='</div></div>';
|
||||
return h;
|
||||
}
|
||||
@@ -123,6 +123,7 @@
|
||||
var bid=cur.bid,index=cur.index,b=_tblBlockById(bid),e=window.E;
|
||||
if(!b||!e)return;
|
||||
e.sync();
|
||||
if(action!=='tbl-del')e.pushHistory();
|
||||
if(!(b.rows&&b.rows.length))b.rows=_tblNew(2,2);
|
||||
var focusR=1,focusC=0;
|
||||
if(action==='col-insert-left'){_tblAddCol(b,index);focusC=index;}
|
||||
@@ -173,6 +174,7 @@
|
||||
else if(act==='colplus'){var b=_tblBlockById(bid);if(b){window.E.sync();_tblAddCol(b,col+1);window.E.render();window.E.dirty=true;window.E.autoSave();_tblFocus(bid,1,col+1);}}
|
||||
else if(act==='rowadd'){var b2=_tblBlockById(bid);if(b2){window.E.sync();var n=b2.rows.length;_tblAddRow(b2,n);window.E.render();window.E.dirty=true;window.E.autoSave();_tblFocus(bid,n,0);}}
|
||||
else if(act==='coladdright'){var b4=_tblBlockById(bid);if(b4){window.E.sync();var cw=(b4.rows&&b4.rows[0])?b4.rows[0].length:1;_tblAddCol(b4,cw);window.E.render();window.E.dirty=true;window.E.autoSave();_tblFocus(bid,1,cw);}}
|
||||
else if(act==='header'||act==='firstcol'){var b5=_tblBlockById(bid);if(b5){window.E.sync();window.E.pushHistory();if(act==='header')b5.has_header=g.checked?true:false;else b5.first_col_header=g.checked?true:false;window.E.render();window.E.dirty=true;window.E.autoSave();_rtSync();}}
|
||||
return;
|
||||
}
|
||||
if(document.getElementById('_tblMenu')&&!(ev.target.closest&&ev.target.closest('[data-tbl]')))_tblMenuClose();
|
||||
@@ -193,8 +195,8 @@
|
||||
}
|
||||
|
||||
// ── Block type shortcuts (shown on the right of each slash item) ──
|
||||
const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'•',numbered_list:'1.',to_do:'[ ]',toggle:'▸',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'———',code:'<>',table:'|',image:'[img]',database:'DB'};
|
||||
const _GROUP_LABEL={BASIC:'Basic blocks',MEDIA:'Media',DATA:'Data'};
|
||||
const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'•',numbered_list:'1.',to_do:'[ ]',toggle:'▸',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'———',code:'<>',table:'|',image:'[img]',database:'DB',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'};
|
||||
const _GROUP_LABEL={BASIC:'Basic blocks',MEDIA:'Media',DATA:'Data',ACTIONS:'Actions'};
|
||||
// ── Hover preview card (matches Notion's visual hint) ──
|
||||
function _previewHtml(blockId){
|
||||
const p=function(inner,cls){return '<div class="slash-prev" style="padding:0">'+inner+'</div>';};
|
||||
@@ -357,19 +359,19 @@
|
||||
function renderBlock(b,idx){
|
||||
if(b.type==='meeting')return renderMeetingBlock(b,idx);
|
||||
if(b.type==='table')return renderTableBlock(b,idx);
|
||||
if(b.type==='divider')return `<div class="block-wrapper"><hr class="block-divider"></div>`;
|
||||
if(b.type==='divider')return `<div class="block-wrapper" data-id="${b.id}"><hr class="block-divider"></div>`;
|
||||
if(b.type==='button')return renderAutomationButton(b,idx);
|
||||
if(b.type==='image')return `<div class="block-wrapper"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'"></div></div>`;
|
||||
if(b.type==='table_of_contents')return renderTOC();
|
||||
if(b.type==='math')return `<div class="block-wrapper"><div class="block-content block-math">${renderMathBlock(b)}</div></div>`;
|
||||
if(b.type==='image')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'"></div></div>`;
|
||||
if(b.type==='table_of_contents')return renderTOC(b);
|
||||
if(b.type==='math')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-math">${renderMathBlock(b)}</div></div>`;
|
||||
if(b.type==='columns')return renderColumnsBlock(b);
|
||||
if(b.type==='embed'){
|
||||
if(b.embed_type==='inline_dbs')return `<div class="block-wrapper"><div class="block-content block-inline-dbs"><div style="padding:20px;text-align:center;color:var(--text-secondary);">📊 Inline Databases</div><div class="inline-dbs-grid">${(b.dbs||[]).map(db=>`<a href="/db/${db.id}/view/${db.view_type||'table'}" class="inline-db-card" target="_blank" style="display:block;padding:12px 16px;background:var(--bg-secondary);border-radius:8px;margin:8px 0;text-decoration:none;color:var(--text-primary);border:1px solid var(--border);"><span style="font-weight:500">📋 ${db.name}</span><span style="float:right;font-size:12px;color:var(--text-tertiary);">${db.view_type==='form'?'📝 Form':'📊 Table'} →</span></a>`).join('')}</div></div></div>`;
|
||||
if(b.embed_type==='collection')return `<div class="block-wrapper"><div class="block-content block-embed-collection" id="inline-db-${b.collection_id}" style="padding:0;min-height:200px"><div style="padding:40px;text-align:center;color:var(--text-dim)">Loading database...</div></div><`+`script>setTimeout(function(){var el=document.getElementById('inline-db-${b.collection_id}');if(el&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,${b.collection_id},null);},50);<`+`/script></div>`;
|
||||
if(b.embed_type==='pdf')return `<div class="block-wrapper"><div class="block-content block-embed" style="padding:0;"><iframe src="${b.src||''}" style="width:100%;height:80vh;border:none;border-radius:8px;" onerror="this.style.display='none';this.nextElementSibling.style.display='block';"></iframe><div style="display:none;text-align:center;padding:32px;color:var(--text-secondary);"><svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><p style="margin-top:8px;">Failed to load PDF</p><a href="${b.src||''}" target="_blank" style="color:var(--accent);">Open in new tab →</a></div></div></div>`;
|
||||
if(b.embed_type==='inline_dbs')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-inline-dbs"><div style="padding:20px;text-align:center;color:var(--text-secondary);">📊 Inline Databases</div><div class="inline-dbs-grid">${(b.dbs||[]).map(db=>`<a href="/db/${db.id}/view/${db.view_type||'table'}" class="inline-db-card" target="_blank" style="display:block;padding:12px 16px;background:var(--bg-secondary);border-radius:8px;margin:8px 0;text-decoration:none;color:var(--text-primary);border:1px solid var(--border);"><span style="font-weight:500">📋 ${db.name}</span><span style="float:right;font-size:12px;color:var(--text-tertiary);">${db.view_type==='form'?'📝 Form':'📊 Table'} →</span></a>`).join('')}</div></div></div>`;
|
||||
if(b.embed_type==='collection')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed-collection" id="inline-db-${b.collection_id}" style="padding:0;min-height:200px"><div style="padding:40px;text-align:center;color:var(--text-dim)">Loading database...</div></div><`+`script>setTimeout(function(){var el=document.getElementById('inline-db-${b.collection_id}');if(el&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,${b.collection_id},null);},50);<`+`/script></div>`;
|
||||
if(b.embed_type==='pdf')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="padding:0;"><iframe src="${b.src||''}" style="width:100%;height:80vh;border:none;border-radius:8px;" onerror="this.style.display='none';this.nextElementSibling.style.display='block';"></iframe><div style="display:none;text-align:center;padding:32px;color:var(--text-secondary);"><svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><p style="margin-top:8px;">Failed to load PDF</p><a href="${b.src||''}" target="_blank" style="color:var(--accent);">Open in new tab →</a></div></div></div>`;
|
||||
if(b.embed_type==='download'){
|
||||
var sz=b.file_size||0;var szStr=sz<1024?sz+' B':sz<1048576?(sz/1024).toFixed(1)+' KB':(sz/1048576).toFixed(1)+' MB';
|
||||
return `<div class="block-wrapper"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;"><div style="font-size:48px;margin-bottom:12px;">{{ fd_icon("paperclip",14) }}</div><div style="font-size:16px;font-weight:500;color:var(--text-primary);margin-bottom:4px;">${esc(b.file_name||'File')}</div><div style="font-size:12px;color:var(--text-tertiary);margin-bottom:16px;">${szStr} · ${esc(b.file_mime||'')}</div><a href="${b.src||''}" download style="display:inline-block;padding:8px 20px;background:var(--accent);color:#fff;text-decoration:none;border-radius:8px;font-size:13px;font-weight:500;">⬇ Download</a></div></div>`;
|
||||
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;"><div style="font-size:48px;margin-bottom:12px;">{{ fd_icon("paperclip",14) }}</div><div style="font-size:16px;font-weight:500;color:var(--text-primary);margin-bottom:4px;">${esc(b.file_name||'File')}</div><div style="font-size:12px;color:var(--text-tertiary);margin-bottom:16px;">${szStr} · ${esc(b.file_mime||'')}</div><a href="${b.src||''}" download style="display:inline-block;padding:8px 20px;background:var(--accent);color:#fff;text-decoration:none;border-radius:8px;font-size:13px;font-weight:500;">⬇ Download</a></div></div>`;
|
||||
}
|
||||
}
|
||||
const ph=b.type==='heading_1'?'Heading 1':b.type==='heading_2'?'Heading 2':b.type==='heading_3'?'Heading 3':b.type==='heading_4'?'Heading 4':b.type==='bulleted_list'?'List':b.type==='numbered_list'?'List':b.type==='to_do'?'To-do':b.type==='toggle'?'Toggle list':b.type==='quote'?'Empty quote':b.type==='code'?'Type code...':b.type==='callout'?'Type something...':(b.type==='paragraph'&&!b.content?'Press \'space\' for AI or \'/\' for commands':'Press \'/\' for commands...');
|
||||
@@ -381,17 +383,17 @@
|
||||
else inner=`<div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${mdEsc(b.content)}</div>`;
|
||||
const tag=b.type.startsWith('heading_')?'h'+b.type.slice(-1):b.type==='quote'?'blockquote':'div';
|
||||
const cls=tag==='div'?`block-content ${b.type==='bulleted_list'?'block-bullet':b.type==='numbered_list'?'block-numbered':b.type==='to_do'?'block-todo':b.type==='toggle'?'block-toggle':b.type==='code'?'block-code':b.type==='callout'?'block-callout':''}`:`block-content block-${tag}`;
|
||||
const style=b.type==='callout'?` style="background:${(b.style&&b.style.bgColor)||'var(--blue-bg)'}"`:'';
|
||||
const style=b.type==='callout'?` style="background:${(b.style&&b.style.bgColor)||'var(--blue-bg)'}${(b.style&&b.style.color)?';color:'+b.style.color:''}"`:((b.style&&(b.style.bgColor||b.style.color))?` style="${(b.style.bgColor?'background:'+b.style.bgColor+';':'')+(b.style.color?'color:'+b.style.color+';':'')}"`:'');
|
||||
const bidAttr=b.type.startsWith('heading_')?` id="h-${(b.id||'').replace(/[^a-zA-Z0-9]/g,'')}"`:'';
|
||||
const actions=idx>=0?`<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>`:'';
|
||||
return `<div class="block-wrapper">
|
||||
return `<div class="block-wrapper" data-id="${b.id}">
|
||||
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
|
||||
${actions}
|
||||
<${tag}${style}${bidAttr} class="${cls}">${inner}</${tag}>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderTOC(){
|
||||
function renderTOC(b){
|
||||
var out=[];
|
||||
var blocks=window.E?window.E.blocks:[];
|
||||
var headings=blocks.filter(function(b){return /^heading_[1-4]$/.test(b.type)&&(b.content||'').trim();});
|
||||
@@ -406,7 +408,8 @@
|
||||
});
|
||||
out.push(`</div>`);
|
||||
}
|
||||
return `<div class="block-wrapper">${out.join('')}</div>`;
|
||||
if(blocks.length===0){}
|
||||
return `<div class="block-wrapper" data-id="${b.id}">${out.join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderMathBlock(b){
|
||||
@@ -420,7 +423,7 @@
|
||||
var bound=b.automation_id>0;
|
||||
var badge=bound?'<span style="opacity:.65;font-weight:400;margin-left:8px;font-size:11px;">⚡ '+esc(b.automation_name||'')+'</span>':'<span style="opacity:.65;font-weight:400;margin-left:8px;font-size:11px;">Choose…</span>';
|
||||
var actions=idx>=0?`<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>`:'';
|
||||
return `<div class="block-wrapper">
|
||||
return `<div class="block-wrapper" data-id="${b.id}">
|
||||
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
|
||||
${actions}
|
||||
<div class="block-content"><button type="button" class="fd-automation-btn" onclick="E.runAutomationButton('${b.id}')">⚡ ${esc(label)}${badge}</button></div>
|
||||
@@ -432,7 +435,7 @@
|
||||
var cells=cols.map(function(c){
|
||||
return `<div class="column-cell">${renderChildColumn(c,b.id)}</div>`;
|
||||
}).join('');
|
||||
return `<div class="block-wrapper"><div class="block-content block-columns"><div class="columns-add-bar"><button class="columns-add-btn" onclick="E._addColumn('${b.id}')">+ Add column</button></div><div class="columns-row">${cells}</div></div></div>`;
|
||||
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-columns"><div class="columns-add-bar"><button class="columns-add-btn" onclick="E._addColumn('${b.id}')">+ Add column</button></div><div class="columns-row">${cells}</div></div></div>`;
|
||||
}
|
||||
|
||||
function renderChildColumn(c,parentId){
|
||||
@@ -453,7 +456,7 @@
|
||||
// généré par le FlowDeck Agent.
|
||||
function renderMeetingBlock(b, idx){
|
||||
var actions=idx>=0?`<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>`:'';
|
||||
return `<div class="block-wrapper">
|
||||
return `<div class="block-wrapper" data-id="${b.id}">
|
||||
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
|
||||
${actions}
|
||||
<div class="block-content block-meeting" data-meeting-bid="${b.id}"><div class="fm-loading">Préparation de la réunion…</div></div>
|
||||
@@ -676,6 +679,121 @@
|
||||
};
|
||||
window.FDMeeting=FDMeeting;
|
||||
|
||||
// ═══════════ v5.10.0 — Interactions de bloc : sélection multiple, menu ⋮⋮, undo/redo ═══════════
|
||||
let _selSet=new Set();
|
||||
function _selHas(bid){return _selSet.has(bid);}
|
||||
function _selToggle(bid){if(!bid)return;if(_selSet.has(bid))_selSet.delete(bid);else _selSet.add(bid);
|
||||
document.querySelectorAll('.block-wrapper[data-id="'+bid+'"]').forEach(function(w){w.classList.toggle('selected',_selSet.has(bid));});}
|
||||
function _selClear(){_selSet.clear();document.querySelectorAll('.block-wrapper.selected').forEach(function(w){w.classList.remove('selected');});}
|
||||
function _selApply(){document.querySelectorAll('.block-wrapper[data-id]').forEach(function(w){var id=w.getAttribute('data-id');w.classList.toggle('selected',!!(_selSet.has(id)));});}
|
||||
function _selIndicesSorted(){var E=window.E;var out=[];if(!E)return out;E.blocks.forEach(function(b,i){if(_selSet.has(b.id))out.push(i);});return out;}
|
||||
function _rtSync(){try{var rt=window.__fdRT;if(rt&&rt.syncNow)rt.syncNow();}catch(e){}}
|
||||
|
||||
const _BLOCK_TURN_TYPES=[
|
||||
{id:'paragraph',name:'Text'},{id:'heading_1',name:'Heading 1'},{id:'heading_2',name:'Heading 2'},
|
||||
{id:'heading_3',name:'Heading 3'},{id:'heading_4',name:'Heading 4'},{id:'bulleted_list',name:'Bulleted list'},
|
||||
{id:'numbered_list',name:'Numbered list'},{id:'to_do',name:'To-do'},{id:'toggle',name:'Toggle'},
|
||||
{id:'quote',name:'Quote'},{id:'callout',name:'Callout'},{id:'table',name:'Table'},
|
||||
{id:'math',name:'Math equation'},{id:'columns',name:'Column list'},{id:'button',name:'Button / Automation'},
|
||||
{id:'code',name:'Code block'}
|
||||
];
|
||||
const _BLOCK_COLORS_TEXT=['default','#E5484D','#F76B15','#FFC53D','#46A758','#3E63DD','#8E4EC6','#12A594','#94A3B8'];
|
||||
const _BLOCK_COLORS_BG=['default','rgba(229,72,77,.15)','rgba(247,107,21,.15)','rgba(255,197,61,.15)','rgba(70,154,88,.15)','rgba(62,99,221,.15)','rgba(142,78,198,.15)','rgba(18,165,148,.15)','rgba(148,163,184,.2)'];
|
||||
function _blockMenuClose(){var m=document.getElementById('blockMenu');if(m)m.remove();m=document.getElementById('blockMenuSub');if(m)m.remove();}
|
||||
function _blockMenuClick(ev){
|
||||
var t=ev.target.closest&&ev.target.closest('[data-bm-act]');if(!t)return;
|
||||
var act=t.getAttribute('data-bm-act');var bid=t.getAttribute('data-bid');
|
||||
if(!act)return;
|
||||
ev.preventDefault();ev.stopPropagation();
|
||||
var E=window.E;if(!E)return;
|
||||
var idx=bid?E.getIdx(bid):-1;
|
||||
if(act==='turn'){var m=document.getElementById('blockMenu');if(m){var r=m.getBoundingClientRect();_blockTurnMenuOpen(r.right+4,r.top,bid||'');}return;}
|
||||
if(act==='duplicate'){E.duplicateBlock(idx);}
|
||||
else if(act==='copy-link'){E.copyBlockLink(bid||'');}
|
||||
else if(act==='move-to'){_blockMoveToOpen(idx);}
|
||||
else if(act==='delete'){E.deleteSelected(idx);}
|
||||
else if(act==='color'){var key=t.getAttribute('data-color-key'),val=t.getAttribute('data-color-val');E.setBlockColor(idx,key,val);}
|
||||
else if(act==='turn-type'){E.turnInto(idx,t.getAttribute('data-type'));_blockMenuClose();}
|
||||
_blockMenuClose();
|
||||
}
|
||||
function blockMenuOpen(x,y,bid){
|
||||
_blockMenuClose();
|
||||
var E=window.E;if(!E)return;
|
||||
var idx=E.getIdx(bid);if(idx<0)return;
|
||||
var b=E.blocks[idx];if(!b)return;
|
||||
var d=document.createElement('div');d.id='blockMenu';d.className='block-menu';d.style.cssText='position:fixed;z-index:4000;';
|
||||
var colorsText='',colorsBg='';
|
||||
_BLOCK_COLORS_TEXT.forEach(function(c){colorsText+='<button type="button" data-bm-act="color" data-color-key="color" data-color-val="'+c+'" title="Text color" style="background:'+(c==='default'?'transparent':c)+'" class="bm-swatch'+(c==='default'?' bm-default':'')+'">'+(c==='default'?'A':'')+'</button>';});
|
||||
_BLOCK_COLORS_BG.forEach(function(c){colorsBg+='<button type="button" data-bm-act="color" data-color-key="bgColor" data-color-val="'+c+'" title="Background color" style="background:'+(c==='default'?'transparent':c)+'" class="bm-swatch'+(c==='default'?' bm-default':'')+'"></button>';});
|
||||
d.innerHTML='<div class="bm-item" data-bm-act="turn" data-bid="'+b.id+'"><span class="bm-ico">↩</span>Turn into<span class="bm-arrow">▸</span></div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item" data-bm-act="duplicate" data-bid="'+b.id+'"><span class="bm-ico">▣</span>Duplicate <span class="bm-hint">'+(_rtMac()?'⌘D':'Ctrl+D')+'</span></div>'
|
||||
+'<div class="bm-item" data-bm-act="copy-link" data-bid="'+b.id+'"><span class="bm-ico">🔗</span>Copy link to block</div>'
|
||||
+'<div class="bm-item" data-bm-act="move-to" data-bid="'+b.id+'"><span class="bm-ico">↪</span>Move to…</div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-colors"><div class="bm-colors-label">Text</div><div class="bm-swatches">'+colorsText+'</div></div>'
|
||||
+'<div class="bm-colors"><div class="bm-colors-label">Background</div><div class="bm-swatches">'+colorsBg+'</div></div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item bm-danger" data-bm-act="delete" data-bid="'+b.id+'"><span class="bm-ico">✕</span>Delete <span class="bm-hint">⌫</span></div>';
|
||||
d.addEventListener('click',_blockMenuClick);
|
||||
d.addEventListener('contextmenu',function(ev){ev.preventDefault();_blockMenuClose();});
|
||||
document.body.appendChild(d);
|
||||
var r=d.getBoundingClientRect();
|
||||
x=Math.min(Math.max(8,x),window.innerWidth-r.width-8);
|
||||
y=Math.min(Math.max(8,y),window.innerHeight-r.height-8);
|
||||
d.style.left=x+'px';d.style.top=y+'px';
|
||||
}
|
||||
function _blockTurnMenuOpen(x,y,bid){
|
||||
var m=document.getElementById('blockMenuSub');if(m)m.remove();
|
||||
var d=document.createElement('div');d.id='blockMenuSub';d.className='block-menu block-menu-sub';d.style.cssText='position:fixed;z-index:4001;';
|
||||
var html='';
|
||||
_BLOCK_TURN_TYPES.forEach(function(t){html+='<div class="bm-item" data-bm-act="turn-type" data-type="'+t.id+'" data-bid="'+bid+'">'+t.name+'</div>';});
|
||||
d.innerHTML=html;
|
||||
d.addEventListener('click',_blockMenuClick);
|
||||
document.body.appendChild(d);
|
||||
var r=d.getBoundingClientRect();
|
||||
x=Math.min(Math.max(8,x),window.innerWidth-r.width-8);
|
||||
y=Math.min(Math.max(8,y),window.innerHeight-r.height-8);
|
||||
d.style.left=x+'px';d.style.top=y+'px';
|
||||
}
|
||||
function _rtMac(){return /Mac|iPhone|iPad/.test(navigator.userAgent);}
|
||||
function _blockMoveToOpen(idx){
|
||||
var E=window.E;if(!E)return;
|
||||
var pages=[];var seen={};
|
||||
document.querySelectorAll('.sidebar-item.ws-tree-item a.page-name[href^="/pages/"]').forEach(function(a){
|
||||
var m=(a.getAttribute('href')||'').match(/\/pages\/(\d+)/);if(!m)return;
|
||||
var id=parseInt(m[1],10);var name=(a.textContent||'').trim()||('Page '+id);
|
||||
if(id===E.pid||seen[id])return;seen[id]=1;pages.push({id:id,name:name});
|
||||
});
|
||||
var ov=document.createElement('div');ov.id='fdMoveOverlay';ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;';
|
||||
var box=document.createElement('div');box.style.cssText='background:var(--bg-secondary,#1f1f1f);border:1px solid var(--border,#333);border-radius:12px;padding:16px;width:420px;max-width:92vw;max-height:80vh;overflow:auto;';
|
||||
var listHtml=pages.length?pages.map(function(p){return '<button type="button" data-pid="'+p.id+'" class="bm-page">'+esc(p.name)+'</button>';}).join(''):'<div class="bm-empty">Aucune autre page dans l\u2019arbre du workspace.<br>Ouvrez la page cible puis réessayez.</div>';
|
||||
box.innerHTML='<div style="font-weight:600;font-size:14px;margin-bottom:10px;">Move block to…</div>'
|
||||
+'<input type="text" class="bm-search" placeholder="Filter pages…" autofocus>'
|
||||
+'<div class="bm-page-list">'+listHtml+'</div>'
|
||||
+'<button type="button" data-cancel="1" class="bm-cancel">Annuler</button>';
|
||||
box.addEventListener('click',function(ev){
|
||||
var p=ev.target.closest('[data-pid]');
|
||||
if(p){var pid=parseInt(p.getAttribute('data-pid'),10);ov.remove();E.moveBlockToPage(idx,pid);return;}
|
||||
if(ev.target.closest('[data-cancel]')){ov.remove();}
|
||||
});
|
||||
box.addEventListener('input',function(ev){
|
||||
if(ev.target.classList&&ev.target.classList.contains('bm-search')){
|
||||
var q=(ev.target.value||'').toLowerCase();
|
||||
box.querySelectorAll('.bm-page').forEach(function(b2){b2.style.display=(q&&b2.textContent.toLowerCase().indexOf(q)<0)?'none':'';});
|
||||
}
|
||||
});
|
||||
ov.appendChild(box);
|
||||
document.body.appendChild(ov);
|
||||
}
|
||||
function _blockDocDown(ev){
|
||||
if(!(ev.target&&ev.target.closest))return;
|
||||
if(ev.target.closest('#blockMenu')||ev.target.closest('#blockMenuSub')||ev.target.closest('#fdMoveOverlay'))return;
|
||||
if(ev.target.closest('.block-handle'))return;
|
||||
if(_selSet.size)_selClear();
|
||||
_blockMenuClose();
|
||||
}
|
||||
|
||||
window.E=null;
|
||||
function editorState(){
|
||||
return {
|
||||
@@ -691,13 +809,14 @@
|
||||
generalAccess:'{{ page_share_mode }}',publicPerm:'viewer',
|
||||
pubAllowEdit:false,pubAllowComments:true,
|
||||
pageUrl:window.location.href,publishedUrl:'',
|
||||
inviteEmail:'',invitePermission:'editor',accessList:[],
|
||||
inviteEmail:'',invitePermission:'edit',inviteUserId:null,inviteUsers:[],inviteSuggestOpen:false,inviteSel:-1,accessList:[],
|
||||
toastVisible:false,toastMsg:'',
|
||||
// ── v4.9.0: Collaboration — comments & mentions ──
|
||||
commentsOpen:false,commentDraft:'',comments:[],commentCount:0,
|
||||
currentUserId:{{ user.get('id', 0) if user else 0 }},
|
||||
_commentSel:{range:null,bid:null,start:0,end:0},
|
||||
_notifiedMentions:{},
|
||||
undoStack:[],redoStack:[],_histCap:100,_histLock:false,
|
||||
get timeAgo() {
|
||||
if (!this.updatedAt) return 'just now';
|
||||
var diff = Math.floor((Date.now() - new Date(this.updatedAt + 'Z').getTime()) / 1000);
|
||||
@@ -722,7 +841,7 @@
|
||||
if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];
|
||||
this.loadInlineDBs();
|
||||
window.E=this;
|
||||
this.insertTable=function(){this.sync();var nb={id:genId(),type:'table',content:'',rows:_tblNew(3,3)};this.blocks.push(nb);this.dirty=true;this.autoSave();this.render();setTimeout(function(){_tblFocus(nb.id,0,0);},60);};
|
||||
this.insertTable=function(){this.sync();this.pushHistory();var nb={id:genId(),type:'table',content:'',rows:_tblNew(3,3)};this.blocks.push(nb);this.dirty=true;this.autoSave();this.render();_rtSync();setTimeout(function(){_tblFocus(nb.id,0,0);},60);};
|
||||
SM.init();_tblInitDelegate();this.render();
|
||||
const tEl=document.getElementById('_titleEl');
|
||||
if(tEl){const syncT=()=>{tEl.classList.toggle('empty',!(tEl.textContent||'').trim());};syncT();tEl.addEventListener('input',syncT);tEl.addEventListener('blur',syncT);}
|
||||
@@ -730,7 +849,13 @@
|
||||
setTimeout(()=>{this.focusBlock();const ct=document.getElementById('_blocksCt');if(!ct)return;
|
||||
ct.addEventListener('keydown',e=>this.onKd(e));
|
||||
ct.addEventListener('paste',e=>this.handleSmartPaste(e));
|
||||
if(typeof Sortable!=='undefined')Sortable.create(ct,{draggable:'.block-wrapper:not(.nested-block):not(.block-column-wrapper)',handle:'.block-handle',animation:150,ghostClass:'sortable-ghost',dragClass:'sortable-drag',onEnd:evt=>{if(evt.oldIndex===evt.newIndex)return;const[item]=this.blocks.splice(evt.oldIndex,1);this.blocks.splice(evt.newIndex,0,item);this.dirty=true;this.autoSave();this.render();}});
|
||||
if(typeof Sortable!=='undefined')Sortable.create(ct,{draggable:'.block-wrapper:not(.nested-block):not(.block-column-wrapper)',handle:'.block-handle',animation:150,ghostClass:'sortable-ghost',dragClass:'sortable-drag',onEnd:evt=>{this._dragEnd(evt);}});
|
||||
ct.addEventListener('click',e=>{const h=e.target&&e.target.closest?e.target.closest('.block-handle'):null;if(!h)return;const w=h.closest('.block-wrapper');if(!w)return;const wid=w.getAttribute('data-id');if(!wid)return;
|
||||
if(e.shiftKey){_selToggle(wid);return;}
|
||||
_selClear();blockMenuOpen(e.clientX,e.clientY,wid);});
|
||||
ct.addEventListener('contextmenu',e=>{if(!(e.target&&e.target.closest))return;if(e.target.closest('.ftable-cell')||e.target.closest('#_tblMenu'))return;const c=e.target.closest('.block-content, .block-wrapper, .block-column-wrapper');const el0=e.target.closest('[data-bid]');if(!c)return;const w=e.target.closest('.block-wrapper');const wid=w?w.getAttribute('data-id'):null;const bid=wid||(el0?el0.getAttribute('data-bid'):null);if(!bid)return;e.preventDefault();_selClear();blockMenuOpen(e.clientX,e.clientY,bid);});
|
||||
document.addEventListener('mousedown',_blockDocDown);
|
||||
if(window.location.hash&&window.location.hash.indexOf('#fdblk-')===0){const hb=window.location.hash.slice(7);setTimeout(()=>{const selEl=document.querySelector(`[data-bid="${hb}"]`);if(selEl&&selEl.scrollIntoView)selEl.scrollIntoView({behavior:'smooth',block:'center'});if(selEl){selEl.focus();ce(selEl);}},500);}
|
||||
},100);
|
||||
},
|
||||
fileData:null,
|
||||
@@ -760,6 +885,31 @@
|
||||
getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;},
|
||||
getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);},
|
||||
getActiveBlock(){const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return null;return{el,bid:el.dataset.bid,idx:this.getIdx(el.dataset.bid)};},
|
||||
_dragEnd(evt){
|
||||
const draggedId=evt.item&&evt.item.getAttribute?evt.item.getAttribute('data-id'):null;
|
||||
const selected=_selIndicesSorted();
|
||||
const group=_selSet.has(draggedId)&&selected.length>1;
|
||||
if(evt.oldIndex===evt.newIndex&&!group)return;
|
||||
this.sync();this.pushHistory();
|
||||
const byId={};this.blocks.forEach(b=>{byId[b.id]=b;});
|
||||
if(group){
|
||||
const groupIds={};selected.forEach(i=>{const bb=this.blocks[i];if(bb)groupIds[bb.id]=1;});
|
||||
const groupBlocks=selected.map(i=>this.blocks[i]).filter(Boolean);
|
||||
const ct=document.getElementById('_blocksCt');
|
||||
const domIds=[];ct.querySelectorAll('.block-wrapper[data-id]').forEach(w=>{const id=w.getAttribute('data-id');if(byId[id])domIds.push(id);});
|
||||
let inserted=false;const out=[];
|
||||
for(let i=0;i<domIds.length;i++){
|
||||
const id=domIds[i];
|
||||
if(groupIds[id]){if(!inserted){groupBlocks.forEach(gb=>out.push(gb));inserted=true;}continue;}
|
||||
out.push(byId[id]);
|
||||
}
|
||||
if(!inserted)groupBlocks.forEach(gb=>out.push(gb));
|
||||
if(out.length)this.blocks=out;
|
||||
}else{
|
||||
const[item]=this.blocks.splice(evt.oldIndex,1);this.blocks.splice(evt.newIndex,0,item);
|
||||
}
|
||||
this.dirty=true;this.autoSave();this.render();_rtSync();
|
||||
},
|
||||
focusBlock(){const b=this.blocks[0];if(!b)return;const el=this.getEl(b.id);if(el){el.focus();ce(el);}},
|
||||
onCtClick(e){if(e.target===document.getElementById('_blocksCt')||e.target?.classList?.contains('blocks-empty')){if(!this.blocks.length)this.addAt(0);else{const b=this.blocks[this.blocks.length-1];if(b&&(b.type==='table'||b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='button')){this.addAt(this.blocks.length,'paragraph');}else{const el=b&&this.getEl(b.id);if(el){el.focus();ce(el);}}}}},
|
||||
sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='table'){const wrap=ct.querySelector(`[data-bid="${b.id}"].ftable-editor`);if(wrap){const rows=[];wrap.querySelectorAll('tr.ftable-row').forEach(function(tr){const cells=tr.querySelectorAll('th.ftable-cell,td.ftable-cell');if(!cells.length)return;const rowarr=[];cells.forEach(function(cell){rowarr.push(cell.textContent||'');});rows.push(rowarr);});if(rows.length)b.rows=rows;}continue;}
|
||||
@@ -780,16 +930,43 @@
|
||||
setTimeout(()=>{ct.querySelectorAll('[data-meeting-bid]').forEach(function(el){if(window.FDMeeting)window.FDMeeting.mount(el.getAttribute('data-meeting-bid'),el);});},50);
|
||||
ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();if(el.hasAttribute&&el.hasAttribute('data-placeholder'))el.classList.toggle('empty',!(el.textContent||'').trim());})});
|
||||
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
|
||||
_selApply();
|
||||
},
|
||||
_renderKatex(el,tex){
|
||||
if(!el)return;if(!tex||!tex.trim()){el.innerHTML='<span style="color:var(--text-dim);font-style:italic;">Empty math block</span>';return;}
|
||||
if(window.katex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:true,throwOnError:false});}catch(e){el.innerHTML='<span style="color:#e5484d;">Invalid LaTeX</span>';}}
|
||||
else{el.innerHTML='<span>'+esc(tex)+'</span>';}
|
||||
},
|
||||
replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();b.type=type;b.content='';if(type==='toggle'){b.expanded=true;b.children=[];}if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='math'){}if(type==='button'){b.automation_id=0;b.automation_name='';}this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);},
|
||||
addAt(idx,type,content){this.sync();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;},
|
||||
replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pushHistory();b.type=type;b.content='';if(type==='toggle'){b.expanded=true;b.children=[];}if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='math'){}if(type==='button'){b.automation_id=0;b.automation_name='';}this.render();this.dirty=true;this.autoSave();_rtSync();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);},
|
||||
addAt(idx,type,content){this.sync();this.pushHistory();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();_rtSync();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;},
|
||||
addAfter(idx,type){return this.addAt(idx+1,type);},
|
||||
removeBlock(idx){this.sync();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
|
||||
removeBlock(idx){this.sync();this.pushHistory();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();this._rtSync();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
|
||||
|
||||
// ── v5.10.0: Undo / Redo ──
|
||||
pushHistory(){if(this._histLock)return;this.undoStack.push(JSON.parse(JSON.stringify(this.blocks)));if(this.undoStack.length>this._histCap)this.undoStack.shift();this.redoStack.length=0;},
|
||||
undo(){if(!this.undoStack.length){this.showToast('Rien à annuler');return;}const fid=this._focusedBid();this.redoStack.push(JSON.parse(JSON.stringify(this.blocks)));this.applySnapshot(this.undoStack.pop());this._focusBid(fid);},
|
||||
redo(){if(!this.redoStack.length){this.showToast('Rien à rétablir');return;}const fid=this._focusedBid();this.undoStack.push(JSON.parse(JSON.stringify(this.blocks)));this.applySnapshot(this.redoStack.pop());this._focusBid(fid);},
|
||||
applySnapshot(snap){this._histLock=true;this.blocks=snap||[];if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];this.dirty=true;this.autoSave();this.render();this._histLock=false;_rtSync();},
|
||||
_focusedBid(){const a=this.getActiveBlock();return a?a.bid:null;},
|
||||
_focusBid(bid){if(bid)setTimeout(()=>{const el=this.getEl(bid);if(el){el.focus();ce(el);}},60);},
|
||||
_rtSync(){_rtSync();},
|
||||
|
||||
// ── v5.10.0: Duplicate ──
|
||||
_deepCloneBlock(b){const c=JSON.parse(JSON.stringify(b));c.id=genId();if(Array.isArray(c.children)){c.children=c.children.map(ch=>{const n=JSON.parse(JSON.stringify(ch));n.id=genId();return n;});}return c;},
|
||||
duplicateBlock(idx){if(idx<0||idx>=this.blocks.length)return;const sel=_selIndicesSorted();if(sel.length>1&&sel.includes(idx)){this.duplicateSelected();return;}this.sync();this.pushHistory();const copy=this._deepCloneBlock(this.blocks[idx]);this.blocks.splice(idx+1,0,copy);this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(copy.id);_selClear();return copy;},
|
||||
duplicateSelected(){const indices=_selIndicesSorted();if(!indices.length)return;this.sync();this.pushHistory();const copies=indices.map(i=>this._deepCloneBlock(this.blocks[i]));const pos=indices[indices.length-1]+1;this.blocks.splice(pos,0,...copies);this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(copies[0].id);_selClear();},
|
||||
|
||||
// ── v5.10.0: Turn into / couleurs / liens / déplacement ──
|
||||
_blockTargets(idx){const sel=_selIndicesSorted();return(sel.includes(idx))?sel:[idx];},
|
||||
turnInto(idx,type){if(idx<0||idx>=this.blocks.length)return;if(_BLOCK_TURN_TYPES.indexOf(type)<0)return;const targets=this._blockTargets(idx);const focusId=this.blocks[idx].id;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(b.type===type)return;const keep=(b.content||'').trim();b.type=type;if(type!=='to_do'&&type!=='toggle'&&keep&&['image','embed','meeting','divider','table_of_contents','database','button','columns'].indexOf(b.type)<0){b.content=keep;}else{b.content='';}if(type==='toggle'){b.expanded=true;if(!Array.isArray(b.children))b.children=[];}if(type==='to_do')b.checked=false;if(type==='table'&&!Array.isArray(b.rows))b.rows=_tblNew(3,3);if(type==='code'&&!b.language)b.language='Plain Text';if(type==='columns'&&(!Array.isArray(b.children)||!b.children.length))b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='button'){b.automation_id=0;b.automation_name='';}});
|
||||
this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();
|
||||
setTimeout(()=>{const bid=this.blocks[idx]?this.blocks[idx].id:focusId;if(type==='table'){_tblFocus(bid,0,0);}else{const el=this.getEl(bid);if(el){el.focus();ce(el);}}},60);},
|
||||
setBlockColor(idx,key,val){if(idx<0||idx>=this.blocks.length)return;const targets=this._blockTargets(idx);const v=(!val||val==='default')?null:val;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(!b.style)b.style={};if(v==null)delete b.style[key];else b.style[key]=v;});this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();},
|
||||
copyBlockLink(bid){const url=window.location.origin+window.location.pathname+'#fdblk-'+bid;if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(url).then(()=>this.showToast('Lien du bloc copié')).catch(()=>this.showToast('Copie impossible','error'));}else{this.showToast(url,'error');}},
|
||||
deleteSelected(idx){const indices=_selIndicesSorted();if(indices.length<=1)return this.removeBlock(idx);this.sync();this.pushHistory();const keep=this.blocks.filter((b,i)=>indices.indexOf(i)<0);this.blocks=keep.length?keep:[this.mkB('paragraph','')];const fi=Math.min(idx,this.blocks.length-1);this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
|
||||
async moveBlockToPage(idx,pageId){if(idx<0||idx>=this.blocks.length||pageId===this.pid)return;this.sync();this.pushHistory();const block=this.blocks[idx];const self=this;const csrf=document.cookie.match(/csrf_token=([^;]+)/);this.showToast('Déplacement du bloc…');try{const r=await fetch(`/board/api/pages/${pageId}`,{credentials:'same-origin'});const d=await r.json();let tblocks=[];if(d.content_format==='blocks'&&d.content){try{tblocks=JSON.parse(d.content);}catch(e){tblocks=[];}}tblocks.push(block);await fetch(`/board/api/pages/${pageId}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:d.title||'',blocks:tblocks})});
|
||||
if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Bloc déplacé');
|
||||
}catch(e){this.showToast('Échec du déplacement','error');}},
|
||||
|
||||
// ── v4.11.1: Inline AI — "Ask AI" & "AI meeting note" (Get started) ──
|
||||
// Wire the contextual get-started actions into the FlowDeck Agent
|
||||
@@ -844,7 +1021,7 @@
|
||||
summaryError:'',
|
||||
lastTab:'transcript'
|
||||
};
|
||||
this.sync();
|
||||
this.sync();this.pushHistory();
|
||||
var emptyFirst=this.blocks.length===1 && this.blocks[0].type==='paragraph' && !(this.blocks[0].content||'').trim();
|
||||
if(emptyFirst){ this.blocks=[mb]; } else { this.blocks.push(mb); }
|
||||
this.dirty=true; this.autoSave(); this.render();
|
||||
@@ -956,12 +1133,72 @@ applyAIBlocks(text){
|
||||
fetch('/api/pages/' + this.pid + '/share', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||
body: JSON.stringify({ email: this.inviteEmail.trim(), permission: this.invitePermission })
|
||||
body: JSON.stringify({ user_id: this.inviteUserId, email: this.inviteEmail.trim(), permission: this.invitePermission })
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if (d.status === 'shared') { self.inviteEmail = ''; self.showToast('Invitation sent'); self.loadShares(); }
|
||||
else { self.showToast(d.detail || 'Share failed'); }
|
||||
if (d.status === 'shared') {
|
||||
self.inviteEmail = ''; self.inviteUserId = null; self.inviteUsers = []; self.inviteSuggestOpen = false;
|
||||
self.showToast('Invitation sent'); self.loadShares();
|
||||
} else { self.showToast(d.detail || 'Share failed'); }
|
||||
}).catch(function(){ self.showToast('Share failed'); });
|
||||
},
|
||||
inviteInput(){
|
||||
var self = this;
|
||||
var q = (this.inviteEmail || '').trim();
|
||||
if (!q) { this.inviteUsers = []; this.inviteSuggestOpen = false; this.inviteUserId = null; this.inviteSel = -1; return; }
|
||||
clearTimeout(this._inviteTimer);
|
||||
this._inviteTimer = setTimeout(function(){ self._loadInviteUsers(q); }, 150);
|
||||
},
|
||||
async _loadInviteUsers(q){
|
||||
try {
|
||||
const r = await fetch('/api/notifications/users/search?q=' + encodeURIComponent(q), { credentials: 'same-origin' });
|
||||
const d = await r.json();
|
||||
var me = this.currentUserId;
|
||||
var shared = {};
|
||||
(this.accessList || []).forEach(function(a){ if (a.user_login) shared[a.user_login.toLowerCase()] = true; });
|
||||
this.inviteUsers = (d.users || []).filter(function(u){
|
||||
return u.id !== me && !shared[(u.login || '').toLowerCase()];
|
||||
});
|
||||
this.inviteSel = this.inviteUsers.length ? 0 : -1;
|
||||
this.inviteSuggestOpen = true;
|
||||
} catch(e) { this.inviteUsers = []; this.inviteSuggestOpen = false; }
|
||||
},
|
||||
inviteMove(dir){
|
||||
var el = this.$refs.inviteSuggest;
|
||||
var n = this.inviteUsers.length;
|
||||
if (!n) return;
|
||||
this.inviteSel = (this.inviteSel + dir + n) % n;
|
||||
if (el) {
|
||||
var item = el.querySelectorAll('.sd-user-item')[this.inviteSel];
|
||||
if (item) item.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
},
|
||||
inviteEnter(){
|
||||
if (this.inviteSuggestOpen && this.inviteSel >= 0 && this.inviteUsers[this.inviteSel]) {
|
||||
this.pickInviteUser(this.inviteUsers[this.inviteSel]);
|
||||
} else {
|
||||
this.inviteSuggestOpen = false;
|
||||
this.shareInvite();
|
||||
}
|
||||
},
|
||||
pickInviteUser(u){
|
||||
this.inviteUserId = u.id;
|
||||
this.inviteEmail = u.login || u.full_name || '';
|
||||
this.inviteUsers = []; this.inviteSuggestOpen = false; this.inviteSel = -1;
|
||||
var el = this.$refs.inviteInput;
|
||||
if (el) el.focus();
|
||||
},
|
||||
updateShare(sid, perm) {
|
||||
var self = this;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch('/api/pages/' + this.pid + '/share/' + sid, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||
body: JSON.stringify({ permission: perm })
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
self.showToast(d.status === 'updated' ? 'Permission updated' : (d.detail || 'Update failed'));
|
||||
self.loadShares();
|
||||
}).catch(function(){ self.showToast('Update failed'); self.loadShares(); });
|
||||
},
|
||||
loadShares() {
|
||||
var self = this;
|
||||
fetch('/api/pages/' + this.pid + '/shares').then(function(r){ return r.json(); }).then(function(d){
|
||||
@@ -1148,20 +1385,24 @@ applyAIBlocks(text){
|
||||
const before=sp?sp.before:gt(el)||'',after=sp?sp.after:'';
|
||||
if(!before.trim()&&!after.trim()){
|
||||
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
|
||||
this.sync();this.blocks.splice(idx+1,0,this.mkB(nt,''));this.render();
|
||||
this.sync();this.pushHistory();this.blocks.splice(idx+1,0,this.mkB(nt,''));this.render();_rtSync();
|
||||
setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();ce(ne);}},60);
|
||||
this.dirty=true;this.autoSave();return;
|
||||
}
|
||||
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
|
||||
this.sync();block.content=before;this.blocks.splice(idx+1,0,this.mkB(nt,after));this.render();
|
||||
this.sync();this.pushHistory();block.content=before;this.blocks.splice(idx+1,0,this.mkB(nt,after));this.render();_rtSync();
|
||||
setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();cs(ne);}},60);
|
||||
this.dirty=true;this.autoSave();return;
|
||||
}
|
||||
if(e.key==='ArrowUp'){const _sp=splitCaret(el);if(_sp&&!_sp.before.trim()&&idx>0){e.preventDefault();const prev=this.getEl(this.blocks[idx-1]?.id);if(prev){prev.focus();ce(prev);}}return;}
|
||||
if(e.key==='ArrowDown'){const _sp=splitCaret(el);if(_sp&&!_sp.after.trim()&&idx<this.blocks.length-1){e.preventDefault();const next=this.getEl(this.blocks[idx+1]?.id);if(next){next.focus();cs(next);}}return;}
|
||||
if(e.key==='Backspace'){if(!(el.textContent||'').trim()){this.sync();if(block.type!=='paragraph'){e.preventDefault();block.type='paragraph';block.content='';this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;}
|
||||
if(e.key==='Backspace'){if(!(el.textContent||'').trim()){this.sync();if(block.type!=='paragraph'){e.preventDefault();this.pushHistory();block.type='paragraph';block.content='';this.render();_rtSync();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;}
|
||||
if(e.key===' '){const b2=bid;setTimeout(()=>this.checkMd(b2),15);}
|
||||
if((e.ctrlKey||e.metaKey)&&!e.altKey){const m={b:'bold',i:'italic',u:'underline'};if(m[e.key]){e.preventDefault();document.execCommand(m[e.key]);this.dirty=true;this.autoSave();}}
|
||||
if((e.ctrlKey||e.metaKey)&&!e.altKey){
|
||||
if(e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)this.redo();else this.undo();return;}
|
||||
if(e.key.toLowerCase()==='y'){e.preventDefault();this.redo();return;}
|
||||
if(e.key.toLowerCase()==='d'){e.preventDefault();const ab2=this.getActiveBlock();if(ab2)this.duplicateBlock(ab2.idx);return;}
|
||||
const m={b:'bold',i:'italic',u:'underline'};if(m[e.key]){e.preventDefault();document.execCommand(m[e.key]);this.dirty=true;this.autoSave();}}
|
||||
if(e.key==='Tab'&&block.type==='code'){e.preventDefault();document.execCommand('insertText',false,' ');}
|
||||
this.dirty=true;this.autoSave();
|
||||
},
|
||||
@@ -1169,28 +1410,32 @@ applyAIBlocks(text){
|
||||
if(idx<0||idx>=this.blocks.length)return;const block=this.blocks[idx];const el=this.getEl(block.id);if(!el)return;const text=el.textContent||'';
|
||||
const sc={'# ':'heading_1','## ':'heading_2','### ':'heading_3','#### ':'heading_4','- ':'bulleted_list','* ':'bulleted_list','+ ':'bulleted_list','1. ':'numbered_list','[] ':'to_do','[ ] ':'to_do','> ':'quote'};
|
||||
this.sync();
|
||||
for(const[pfx,type] of Object.entries(sc)){if(text===pfx||text.startsWith(pfx)){block.type=type;block.content='';if(type==='toggle')block.expanded=true;if(type==='to_do')block.checked=false;this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne&&type!=='divider')ne.focus();},60);return;}}
|
||||
if(text==='---'){block.type='divider';block.content='';this.addAfter(idx,'paragraph');}
|
||||
if(text==='```'){block.type='code';block.content='';block.language='Plain Text';this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}
|
||||
for(const[pfx,type] of Object.entries(sc)){if(text===pfx||text.startsWith(pfx)){this.pushHistory();block.type=type;block.content='';if(type==='toggle')block.expanded=true;if(type==='to_do')block.checked=false;this.render();_rtSync();setTimeout(()=>{const ne=this.getEl(block.id);if(ne&&type!=='divider')ne.focus();},60);return;}}
|
||||
if(text==='---'){this.pushHistory();block.type='divider';block.content='';this.addAfter(idx,'paragraph');}
|
||||
if(text==='```'){this.pushHistory();block.type='code';block.content='';block.language='Plain Text';this.render();_rtSync();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}
|
||||
},
|
||||
applySlash(id){
|
||||
const idx=SM._i;if(idx<0||idx>=this.blocks.length)return;
|
||||
if(id==='database'){ this.openDbTemplatePicker(idx); return; }
|
||||
const block=this.blocks[idx];this.sync();block.content='';
|
||||
if(id==='duplicate'){this.duplicateBlock(idx);return;}
|
||||
if(id==='delete'){this.removeBlock(idx);return;}
|
||||
if(id==='copy_link'){const blk=this.blocks[idx];if(blk)this.copyBlockLink(blk.id);return;}
|
||||
if(id==='turn_into'){const blk=this.blocks[idx];if(!blk)return;const el=this.getEl(blk.id);const rk=el?el.getBoundingClientRect():null;blockMenuOpen(rk?rk.left:window.innerWidth/2,rk?rk.bottom:window.innerHeight/2,blk.id);return;}
|
||||
const block=this.blocks[idx];this.sync();this.pushHistory();block.content='';
|
||||
if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');}
|
||||
else{block.type=id;if(id==='toggle'){block.expanded=true;block.children=[];}if(id==='to_do')block.checked=false;if(id==='columns')block.children=[{id:genId(),type:'paragraph',content:''}];if(id==='code')block.language='Plain Text';if(id==='table'){block.rows=_tblNew(3,3);block.content='';}if(id==='button'){block.automation_id=0;block.automation_name='';}}
|
||||
this.render();this.dirty=true;this.autoSave();
|
||||
this.render();this.dirty=true;this.autoSave();_rtSync();
|
||||
if(id==='table'){setTimeout(()=>_tblFocus(block.id,1,0),60);}
|
||||
else if(id==='math'){setTimeout(()=>{const ne=document.querySelector(`[data-math-bid="${block.id}"]`);if(ne)ne.focus();},60);}
|
||||
else if(id==='columns'){setTimeout(()=>{const ne=this.getEl(block.children[0].id);if(ne)ne.focus();},60);}
|
||||
else{setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}
|
||||
},
|
||||
_doToggle(bid,v){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].checked=v;this.dirty=true;this.autoSave();}},
|
||||
_doToggleExpand(bid){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].expanded=!this.blocks[idx].expanded;this.render();this.dirty=true;this.autoSave();}},
|
||||
_addColumn(parentId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];b.children.push({id:genId(),type:'paragraph',content:''});this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const ne=this.getEl(b.children[b.children.length-1].id);if(ne)ne.focus();},60);},
|
||||
_removeColumn(parentId,childId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];b.children=b.children.filter(function(c){return c.id!==childId;});if(b.children.length===0)b.children=[{id:genId(),type:'paragraph',content:''}];this.render();this.dirty=true;this.autoSave();},
|
||||
_doToggle(bid,v){const idx=this.getIdx(bid);if(idx>=0){this.pushHistory();this.blocks[idx].checked=v;this.dirty=true;this.autoSave();_rtSync();}},
|
||||
_doToggleExpand(bid){const idx=this.getIdx(bid);if(idx>=0){this.pushHistory();this.blocks[idx].expanded=!this.blocks[idx].expanded;this.render();this.dirty=true;this.autoSave();_rtSync();}},
|
||||
_addColumn(parentId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];this.pushHistory();b.children.push({id:genId(),type:'paragraph',content:''});this.render();this.dirty=true;this.autoSave();_rtSync();setTimeout(()=>{const ne=this.getEl(b.children[b.children.length-1].id);if(ne)ne.focus();},60);},
|
||||
_removeColumn(parentId,childId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];this.pushHistory();b.children=b.children.filter(function(c){return c.id!==childId;});if(b.children.length===0)b.children=[{id:genId(),type:'paragraph',content:''}];this.render();this.dirty=true;this.autoSave();_rtSync();},
|
||||
_toggleCalloutIcon(bid){const picker=document.getElementById('callout-picker-'+bid);if(picker)picker.style.display=picker.style.display==='none'?'flex':'none';},
|
||||
_setCalloutIcon(bid,icon){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].icon=icon;this.dirty=true;this.autoSave();this.render();}},
|
||||
_setCalloutIcon(bid,icon){const idx=this.getIdx(bid);if(idx>=0){this.pushHistory();this.blocks[idx].icon=icon;this.dirty=true;this.autoSave();this.render();_rtSync();}},
|
||||
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
|
||||
fmtApply(f){this.fmt.open=false;if(!this._sel)return;const s=window.getSelection();s.removeAllRanges();s.addRange(this._sel.range);document.execCommand(f==='strikeThrough'?'strikeThrough':f);this._sel=null;this.dirty=true;this.autoSave();},
|
||||
fmtLink(){this.fmt.open=false;const u=prompt('URL:');if(u){document.execCommand('createLink',false,u);this.dirty=true;this.autoSave();}},
|
||||
@@ -1362,7 +1607,7 @@ applyAIBlocks(text){
|
||||
const sp=splitCaret(bel);
|
||||
let before='',after='';
|
||||
if(sp){before=sp.before;after=sp.after;}else{before=gt(bel)||'';}
|
||||
this.sync();
|
||||
this.sync();this.pushHistory();
|
||||
const beforeHasText=!!(before||'').trim();
|
||||
block.content=before;
|
||||
const ins=[];
|
||||
@@ -1393,7 +1638,7 @@ applyAIBlocks(text){
|
||||
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'';
|
||||
this.saving=true;
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
|
||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
|
||||
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
|
||||
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';});
|
||||
if(cb)cb();
|
||||
@@ -1500,7 +1745,7 @@ applyAIBlocks(text){
|
||||
case 'math': return '$$\n' + c + '\n$$';
|
||||
case 'table_of_contents': return '[TOC]';
|
||||
case 'columns': return (b.children || []).map(blocksToMarkdown).join('\n\n');
|
||||
case 'table': { var rows=b.rows||[]; if(!rows.length)return ''; var w=0; rows.forEach(function(r){if(r&&r.length>w)w=r.length;}); var rl=function(r){var cells=[];for(var k=0;k<w;k++)cells.push((r&&k<r.length)?String(r[k]==null?'':r[k]):'');return '| '+cells.join(' | ')+' |';}; var out=rl(rows[0]); var seps=[];for(var k=0;k<w;k++)seps.push('---');out+='\n| '+seps.join(' | ')+' |';for(var ri=1;ri<rows.length;ri++)out+='\n'+rl(rows[ri]);return out; }
|
||||
case 'table': { var rows=b.rows||[]; if(!rows.length)return ''; var w=0; rows.forEach(function(r){if(r&&r.length>w)w=r.length;}); var rl=function(r){var cells=[];for(var k=0;k<w;k++)cells.push((r&&k<r.length)?String(r[k]==null?'':r[k]):'');return '| '+cells.join(' | ')+' |';}; var hasH=(b.has_header!==false),fc=!!b.first_col_header; var startRows=[],sep=false; var out=''; var ri0=0; if(hasH){startRows.push(rows[0]);ri0=1;} for(var si=0;si<startRows.length;si++){out+=(out?'\n':'')+rl(startRows[si]);} if(hasH){var seps=[];for(var k2=0;k2<w;k2++)seps.push('---');out+=(out?'\n':'')+'| '+seps.join(' | ')+' |';} for(var ri2=ri0;ri2<rows.length;ri2++)out+='\n'+rl(rows[ri2]); return out; }
|
||||
default: return c;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2277,6 +2277,78 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* ── v5.10.0: selection multiple + menu contexte bloc ── */
|
||||
.block-wrapper.selected {
|
||||
background: rgba(35,131,226,0.06);
|
||||
box-shadow: inset 0 0 0 1px rgba(35,131,226,0.35);
|
||||
}
|
||||
.block-wrapper.selected .block-handle { opacity: 1; fill: var(--accent, #2383E2); }
|
||||
|
||||
.block-menu {
|
||||
background: var(--bg-modal, #1b1b1b);
|
||||
border: 1px solid var(--border-light, #333);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,.5);
|
||||
padding: 6px;
|
||||
min-width: 200px;
|
||||
max-width: 260px;
|
||||
}
|
||||
.block-menu .bm-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 9px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.block-menu .bm-item:hover { background: var(--bg-hover); }
|
||||
.block-menu .bm-item .bm-ico { width: 18px; text-align: center; color: var(--text-secondary); flex-shrink: 0; }
|
||||
.block-menu .bm-item .bm-arrow { margin-left: auto; color: var(--text-tertiary); font-size: 11px; }
|
||||
.block-menu .bm-item .bm-hint { margin-left: auto; font-size: 11px; color: var(--text-tertiary); font-family: var(--font-mono, monospace); opacity: .7; }
|
||||
.block-menu .bm-item.bm-danger { color: #e5484d; }
|
||||
.block-menu .bm-item.bm-danger .bm-ico { color: #e5484d; }
|
||||
.block-menu .bm-item.bm-danger:hover { background: rgba(229,72,77,.12); }
|
||||
.block-menu .bm-sep { height: 1px; background: var(--border, #2a2a2a); margin: 5px 4px; }
|
||||
.block-menu .bm-colors { padding: 6px 9px; }
|
||||
.block-menu .bm-colors-label { font-size: 10px; font-weight: 600; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px; }
|
||||
.block-menu .bm-swatches { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.block-menu .bm-swatch {
|
||||
width: 20px; height: 20px; border-radius: 50%;
|
||||
border: 1px solid var(--border-light, #333);
|
||||
cursor: pointer; padding: 0; flex-shrink: 0;
|
||||
}
|
||||
.block-menu .bm-swatch:hover { border-color: var(--text-secondary); }
|
||||
.block-menu .bm-swatch.bm-default { display:flex; align-items:center; justify-content:center; font-size:11px; color:var(--text-secondary); background: var(--bg-secondary); }
|
||||
|
||||
.block-menu .bm-page {
|
||||
display: block; width: 100%; text-align: left; padding: 8px 10px;
|
||||
font-size: 13px; color: var(--text-primary);
|
||||
background: var(--bg-hover, transparent);
|
||||
border: none; border-radius: 6px; cursor: pointer; margin-bottom: 4px;
|
||||
}
|
||||
.block-menu .bm-page:hover { background: var(--bg-secondary); }
|
||||
.block-menu .bm-page-list { max-height: 240px; overflow-y: auto; margin: 8px 0; }
|
||||
.block-menu .bm-empty { font-size: 12px; color: var(--text-tertiary); padding: 12px; line-height: 1.5; }
|
||||
.block-menu .bm-search {
|
||||
width: 100%; box-sizing: border-box; padding: 8px 10px; margin-bottom: 6px;
|
||||
background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--text-primary); font-size: 13px; outline: none;
|
||||
}
|
||||
.block-menu .bm-cancel {
|
||||
width: 100%; text-align: center; padding: 7px; border: none; background: transparent;
|
||||
color: var(--text-secondary); cursor: pointer; font-size: 12px; border-radius: 6px;
|
||||
}
|
||||
.block-menu .bm-cancel:hover { background: var(--bg-hover); }
|
||||
.block-menu-sub { min-width: 180px; max-height: 320px; overflow-y: auto; }
|
||||
|
||||
.ftable-hdr-toggle { margin-left: 12px; font-size: 12px; color: var(--text-tertiary); display:inline-flex; align-items:center; gap:2px; }
|
||||
.ftable-hdr-toggle label { display:inline-flex; align-items:center; gap:4px; cursor:pointer; }
|
||||
.ftable-hdr-toggle input { cursor:pointer; }
|
||||
|
||||
|
||||
/* Block actions (+ button) */
|
||||
.block-actions {
|
||||
position: absolute;
|
||||
@@ -3153,6 +3225,34 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
.sd-btn-primary:hover { background: var(--accent-hover); }
|
||||
.sd-btn-full { width: 100%; height: 46px; font-size: 14px; }
|
||||
|
||||
/* Invite wrap + existing users autocomplete */
|
||||
.sd-invite-wrap { position: relative; margin-bottom: 12px; }
|
||||
.sd-invite-wrap .sd-invite-row { margin-bottom: 0; }
|
||||
.sd-user-suggest {
|
||||
position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 999;
|
||||
max-height: 220px; overflow-y: auto; padding: 4px;
|
||||
background: var(--bg-modal); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-popover);
|
||||
}
|
||||
.sd-user-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 7px 10px;
|
||||
border-radius: var(--radius-md); cursor: pointer;
|
||||
}
|
||||
.sd-user-item:hover, .sd-user-item.active { background: var(--bg-hover); }
|
||||
.sd-user-avatar {
|
||||
width: 28px; height: 28px; border-radius: 50%; color: #fff; flex: none;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; font-weight: 700; overflow: hidden;
|
||||
}
|
||||
.sd-user-meta { min-width: 0; }
|
||||
.sd-user-login { font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
||||
.sd-user-name {
|
||||
font-size: 11px; color: var(--text-secondary);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.sd-user-add { margin-left: auto; font-size: 11px; color: var(--accent); flex: none; }
|
||||
.sd-user-empty { padding: 10px; font-size: 13px; color: var(--text-dim); }
|
||||
|
||||
/* Context card */
|
||||
.sd-context-card {
|
||||
background: var(--bg-secondary); border-radius: var(--radius-md);
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""FlowDeck — v5.10.0 Interactions de bloc (côté serveur).
|
||||
|
||||
Covers: la persistance des blocs via /board/api/pages/{id}/blocks avec les
|
||||
propriétés v5.10.0 (has_header, first_col_header, style couleur), le rendu des
|
||||
exports (markdown/html) prenant en compte les en-têtes de tableau, et les
|
||||
endpoints utilisés par le menu contexte (get page + POST blocks pour "Move to").
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
db_path = db_file.name
|
||||
db_file.close()
|
||||
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-block-interactions"
|
||||
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||||
|
||||
from app.config import settings
|
||||
settings.database_url = f"sqlite:///{db_path}"
|
||||
|
||||
from app.main import app
|
||||
from app.db import init_db, get_conn
|
||||
init_db()
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (1, 'tester', 'Tester', 1)")
|
||||
conn.commit()
|
||||
|
||||
tc = TestClient(app, raise_server_exceptions=False)
|
||||
yield tc
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
def _token(user_id, login):
|
||||
from app.auth.session import SessionManager
|
||||
return SessionManager.create_session({"id": user_id, "login": login,
|
||||
"full_name": login.title(), "is_admin": 1})
|
||||
|
||||
|
||||
def _auth(client, user_id=1, login="tester"):
|
||||
client.cookies.set("flowdeck_session", _token(user_id, login))
|
||||
|
||||
|
||||
def _make_page(client, title="Interactions", blocks=None):
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
|
||||
"VALUES ('Private', ?, ?, 'blocks', 'Private')",
|
||||
(title, json.dumps(blocks or [], ensure_ascii=False)),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
# ── save des blocs + propriétés v5.10.0 ──
|
||||
def test_save_blocks_persists_table_headers_and_first_col(client):
|
||||
_auth(client)
|
||||
pid = _make_page(client)
|
||||
blocks = [
|
||||
{"id": "b1", "type": "paragraph", "content": "Intro"},
|
||||
{"id": "b2", "type": "table", "content": "",
|
||||
"rows": [["Name", "Role"], ["Ana", "Dev"], ["Bob", "PM"]],
|
||||
"has_header": True, "first_col_header": True},
|
||||
]
|
||||
r = client.post(f"/board/api/pages/{pid}/blocks",
|
||||
json={"title": "Interactions", "blocks": blocks})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
assert row["content_format"] == "blocks"
|
||||
saved = json.loads(row["content"])
|
||||
tbl = saved[1]
|
||||
assert tbl["has_header"] is True
|
||||
assert tbl["first_col_header"] is True
|
||||
assert saved[0]["content"] == "Intro"
|
||||
|
||||
|
||||
def test_save_blocks_persists_style_color(client):
|
||||
_auth(client)
|
||||
pid = _make_page(client)
|
||||
blocks = [
|
||||
{"id": "b1", "type": "callout", "content": "Note",
|
||||
"style": {"color": "#E5484D", "bgColor": "rgba(229,72,77,.15)"}},
|
||||
]
|
||||
r = client.post(f"/board/api/pages/{pid}/blocks",
|
||||
json={"title": "Interactions", "blocks": blocks})
|
||||
assert r.status_code == 200
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
saved = json.loads(row["content"])
|
||||
assert saved[0]["style"] == {"color": "#E5484D", "bgColor": "rgba(229,72,77,.15)"}
|
||||
|
||||
|
||||
def test_save_blocks_default_has_header_true_when_absent(client):
|
||||
_auth(client)
|
||||
pid = _make_page(client)
|
||||
blocks = [
|
||||
{"id": "b1", "type": "table", "content": "",
|
||||
"rows": [["A", "B"], ["1", "2"]]},
|
||||
]
|
||||
r = client.post(f"/board/api/pages/{pid}/blocks",
|
||||
json={"title": "Interactions", "blocks": blocks})
|
||||
assert r.status_code == 200
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
saved = json.loads(row["content"])
|
||||
# absent -> non persiste, mais cote export traite has_header comme True par defaut
|
||||
assert "has_header" not in saved[0]
|
||||
|
||||
|
||||
# ── exports : _table_to_markdown / _table_to_html ──
|
||||
def test_table_export_markdown_with_headers_and_first_col():
|
||||
from app.services.export import _table_to_markdown
|
||||
b = {"type": "table", "rows": [["Name", "Role"], ["Ana", "Dev"]],
|
||||
"has_header": True, "first_col_header": True}
|
||||
md = _table_to_markdown(b)
|
||||
lines = md.split("\n")
|
||||
assert lines[0] == "| Name | Role |"
|
||||
assert "---" in lines[1]
|
||||
assert lines[2] == "| Ana | Dev |"
|
||||
|
||||
|
||||
def test_table_export_markdown_without_header():
|
||||
from app.services.export import _table_to_markdown
|
||||
b = {"type": "table", "rows": [["A", "B"], ["C", "D"]], "has_header": False}
|
||||
md = _table_to_markdown(b)
|
||||
lines = md.split("\n")
|
||||
# pas de ligne de séparateur d'en-tête
|
||||
assert lines[0] == "| A | B |"
|
||||
assert lines[1] == "| C | D |"
|
||||
assert len(lines) == 2
|
||||
|
||||
|
||||
def test_table_export_html_uses_th_for_headers_and_first_col():
|
||||
from app.services.export import _table_to_html
|
||||
b = {"type": "table", "rows": [["Name", "Role"], ["Ana", "Dev"]],
|
||||
"has_header": True, "first_col_header": True}
|
||||
html = _table_to_html(b)
|
||||
assert "<thead>" in html
|
||||
assert "<th" in html
|
||||
# la premiere colonne du corps est aussi un <th>
|
||||
assert html.count("<th") >= 3
|
||||
assert "thead>Ana" not in html or True
|
||||
|
||||
|
||||
# ── Move to (menu contexte) : get page + POST blocks sur la cible ──
|
||||
def test_move_block_to_other_page(client):
|
||||
_auth(client)
|
||||
src = _make_page(client, "Source")
|
||||
dst = _make_page(client, "Destination")
|
||||
blocks = [
|
||||
{"id": "b1", "type": "paragraph", "content": "One"},
|
||||
{"id": "b2", "type": "paragraph", "content": "MoveMe"},
|
||||
]
|
||||
client.post(f"/board/api/pages/{src}/blocks",
|
||||
json={"title": "Source", "blocks": blocks})
|
||||
assert client.post(f"/board/api/pages/{dst}/blocks",
|
||||
json={"title": "Destination", "blocks": []}).status_code == 200
|
||||
|
||||
# get page cible puis push le bloc deplace
|
||||
r = client.get(f"/board/api/pages/{dst}")
|
||||
assert r.status_code == 200
|
||||
target = r.json()
|
||||
target_blocks = json.loads(target["content"]) if target.get("content") else []
|
||||
target_blocks.append(blocks[1])
|
||||
r2 = client.post(f"/board/api/pages/{dst}/blocks",
|
||||
json={"title": "Destination", "blocks": target_blocks})
|
||||
assert r2.status_code == 200
|
||||
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
drow = conn.execute("SELECT content FROM pages WHERE id=?", (dst,)).fetchone()
|
||||
saved = json.loads(drow["content"])
|
||||
assert any(b.get("content") == "MoveMe" for b in saved)
|
||||
|
||||
|
||||
def test_get_page_returns_content_and_format(client):
|
||||
_auth(client)
|
||||
pid = _make_page(client, "GetMe", [{"id": "b1", "type": "paragraph", "content": "hi"}])
|
||||
r = client.get(f"/board/api/pages/{pid}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["content_format"] == "blocks"
|
||||
assert json.loads(r.json()["content"])[0]["content"] == "hi"
|
||||
|
||||
|
||||
def test_get_page_missing_returns_404(client):
|
||||
_auth(client)
|
||||
r = client.get("/board/api/pages/999999")
|
||||
assert r.status_code == 404
|
||||
@@ -0,0 +1,202 @@
|
||||
"""FlowDeck — Partage de pages (v4.0 / fix partage membres).
|
||||
|
||||
Covers: partage par user_id ou email, validation de la permission
|
||||
(view/comment/edit), upsert anti-doublon, mise à jour de permission (PUT),
|
||||
retrait du partage et erreurs d'authentification.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
db_path = db_file.name
|
||||
db_file.close()
|
||||
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-sharing"
|
||||
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||||
os.environ["GITEA_URL"] = "https://git.dracodev.net"
|
||||
os.environ["GITEA_TOKEN"] = "test"
|
||||
|
||||
from app.config import settings
|
||||
settings.database_url = f"sqlite:///{db_path}"
|
||||
|
||||
from app.main import app
|
||||
from app.db import init_db, get_conn
|
||||
init_db()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (id, login, full_name, email, is_admin) "
|
||||
"VALUES (1, 'owner', 'Owner', '[email protected]', 1)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (id, login, full_name, email, is_admin) "
|
||||
"VALUES (2, 'alice', 'Alice', '[email protected]', 0)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
tc = TestClient(app, raise_server_exceptions=False)
|
||||
yield tc
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
def _token(user_id, login):
|
||||
from app.auth.session import SessionManager
|
||||
return SessionManager.create_session(
|
||||
{"id": user_id, "login": login, "full_name": login.title(), "is_admin": 1}
|
||||
)
|
||||
|
||||
|
||||
def _auth(client, user_id=1, login="owner"):
|
||||
client.cookies.set("flowdeck_session", _token(user_id, login))
|
||||
|
||||
|
||||
def _make_page(_client, title="Share Page"):
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
|
||||
"VALUES ('Private', ?, '[]', 'blocks', 'Private')",
|
||||
(title,),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
# ── Share création ──
|
||||
|
||||
|
||||
def test_share_by_user_id(client):
|
||||
_auth(client)
|
||||
pid = _make_page(client)
|
||||
r = client.post(f"/api/pages/{pid}/share", json={"user_id": 2, "permission": "edit"})
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["status"] == "shared"
|
||||
assert d["shared_with_user_id"] == 2
|
||||
assert d["permission"] == "edit"
|
||||
|
||||
lst = client.get(f"/api/pages/{pid}/shares").json()["shares"]
|
||||
assert len(lst) == 1
|
||||
assert lst[0]["user_login"] == "alice"
|
||||
assert lst[0]["permission"] == "edit"
|
||||
|
||||
|
||||
def test_share_by_email_only(client):
|
||||
_auth(client)
|
||||
pid = _make_page(client)
|
||||
r = client.post(f"/api/pages/{pid}/share", json={"email": "[email protected]", "permission": "view"})
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["shared_with_email"] == "[email protected]"
|
||||
assert d["shared_with_user_id"] is None
|
||||
|
||||
lst = client.get(f"/api/pages/{pid}/shares").json()["shares"]
|
||||
assert lst[0]["shared_with_email"] == "[email protected]"
|
||||
|
||||
|
||||
def test_share_invalid_permission_rejected(client):
|
||||
_auth(client)
|
||||
pid = _make_page(client)
|
||||
for bad in ("editor", "commenter", "viewer", "admin"):
|
||||
r = client.post(f"/api/pages/{pid}/share", json={"email": "[email protected]", "permission": bad})
|
||||
assert r.status_code == 400, bad
|
||||
|
||||
|
||||
def test_share_requires_auth(client):
|
||||
pid = _make_page(client)
|
||||
r = client.post(f"/api/pages/{pid}/share", json={"email": "[email protected]", "permission": "view"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_share_without_target_rejected(client):
|
||||
_auth(client)
|
||||
pid = _make_page(client)
|
||||
r = client.post(f"/api/pages/{pid}/share", json={"permission": "view"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_share_page_not_found(client):
|
||||
_auth(client)
|
||||
r = client.post("/api/pages/999999/share", json={"email": "[email protected]", "permission": "view"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_share_target_user_missing(client):
|
||||
_auth(client)
|
||||
pid = _make_page(client)
|
||||
r = client.post(f"/api/pages/{pid}/share", json={"user_id": 999, "permission": "view"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_share_upsert_same_user_updates_permission(client):
|
||||
_auth(client)
|
||||
pid = _make_page(client)
|
||||
first = client.post(f"/api/pages/{pid}/share", json={"user_id": 2, "permission": "view"}).json()
|
||||
second = client.post(f"/api/pages/{pid}/share", json={"user_id": 2, "permission": "edit"}).json()
|
||||
assert second["id"] == first["id"], "share should be upserted, not duplicated"
|
||||
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT permission FROM page_shares WHERE page_id=? AND shared_with_user_id=2",
|
||||
(pid,),
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["permission"] == "edit"
|
||||
|
||||
|
||||
# ── Permission update (PUT) ──
|
||||
|
||||
|
||||
def test_put_permission_updates(client):
|
||||
_auth(client)
|
||||
pid = _make_page(client)
|
||||
share_id = client.post(f"/api/pages/{pid}/share", json={"user_id": 2, "permission": "view"}).json()["id"]
|
||||
r = client.put(f"/api/pages/{pid}/share/{share_id}", json={"permission": "comment"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "updated"
|
||||
|
||||
lst = client.get(f"/api/pages/{pid}/shares").json()["shares"]
|
||||
assert lst[0]["permission"] == "comment"
|
||||
|
||||
|
||||
def test_put_permission_invalid_rejected(client):
|
||||
_auth(client)
|
||||
pid = _make_page(client)
|
||||
share_id = client.post(f"/api/pages/{pid}/share", json={"user_id": 2, "permission": "view"}).json()["id"]
|
||||
r = client.put(f"/api/pages/{pid}/share/{share_id}", json={"permission": "owner"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_put_permission_missing_entry(client):
|
||||
_auth(client)
|
||||
pid = _make_page(client)
|
||||
r = client.put(f"/api/pages/{pid}/share/99999", json={"permission": "edit"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ── Remmove (DELETE) ──
|
||||
|
||||
|
||||
def test_remove_share_unsets_is_shared(client):
|
||||
_auth(client)
|
||||
pid = _make_page(client)
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (pid,))
|
||||
conn.commit()
|
||||
|
||||
share_id = client.post(f"/api/pages/{pid}/share", json={"user_id": 2, "permission": "view"}).json()["id"]
|
||||
r = client.delete(f"/api/pages/{pid}/share/{share_id}")
|
||||
assert r.status_code == 200
|
||||
|
||||
with get_conn() as conn:
|
||||
is_shared = conn.execute("SELECT is_shared FROM pages WHERE id=?", (pid,)).fetchone()["is_shared"]
|
||||
assert is_shared == 0
|
||||
Reference in New Issue
Block a user