Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d0d69e7b8 | ||
|
|
103bc57418 | ||
|
|
45917c194d |
@@ -1,5 +1,87 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.25.0 (2026-10-01) — Audit : A27 CLOSED (eslint 0/0)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Toasts settings jamais affichés** : `settings.js` appelait
|
||||
`typeof toast === 'function'` — guard **toujours faux** (le `toast` global
|
||||
n'existait pas) → les retours « Timezone saved » / « SP metadata URL
|
||||
copiée » ne s'affichaient jamais. → `window.showToast` (global `base.html`)
|
||||
- `_wsInitData = window._wsInitData` (auto-affectation d'un nom non déclaré,
|
||||
sans effet observable car `window._wsInitData` existe depuis L4) supprimé
|
||||
|
||||
### Changed
|
||||
|
||||
- **A27 lint terminé : `eslint static/js` = 0 erreur / 0 warning** (285 → 0,
|
||||
22 fichiers) :
|
||||
· no-empty ×70 = `catch (x) {}` vides → `catch { /* volontaire */ }`
|
||||
(binding optionnel ES2019 ; zéro changement de comportement)
|
||||
· no-unused-vars ×171 = bindings de catch retirés + 24 lignes mortes
|
||||
déterministes (assert sur texte exact avant suppression) +
|
||||
`/* exported */` sur les 10 fonctions appelées depuis les attributs
|
||||
HTML des templates (vérifiées par grep)
|
||||
· no-undef ×44 = vrais globaux déclarés dans `eslint.config.mjs`
|
||||
(getSvgIcon = inline base.html, TextDecoder = API, Prism = CDN)
|
||||
|
||||
### Notes
|
||||
|
||||
- `node --check` vert sur tous les fichiers ; suite complète **1089/1089**
|
||||
- A27 reste ouvert uniquement pour : `base` 1 523 L structurel (inline par
|
||||
nature, décision assumée) + ~500 L de petits blocs hors cibles
|
||||
|
||||
## v7.24.0 (2026-10-01) — Audit : A27 extraction TERMINÉE (bilan -85 %)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A27 (phase 2c)** — `_database_table_scripts.html` : **1 314 L extraites**
|
||||
vers `static/js/database_table.js`. Le Jinja du bloc était confiné à la
|
||||
construction de l'objet de config (4 clés + `{% if collection_data %}`) →
|
||||
config JSON `#db-config` **null-vs-objet** : `new DBInstance(container,
|
||||
PAGE_COLLECTION_ID, DB_CONFIG)` remplace les 2 branches Jinja
|
||||
- 2 tests adaptés (lisaient le template source → `database_table.js`) ;
|
||||
`FlowDeckDB` plus dans le HTML → assert sur le JS
|
||||
|
||||
### Notes
|
||||
|
||||
- **BILAN A27 : 11 874 L extraites en 4 phases** (4 243 + 2 516 + 3 801 +
|
||||
1 314), **inline 13 904 → 2 022 L (-85 %)**, 22 fichiers `static/js/*.js`,
|
||||
`node --check` vert partout, eslint **0 erreur / 285 warnings**
|
||||
- Reste : `base` 1 523 L structurel (`{% block %}`/`{% for %}` — reste
|
||||
inline par nature, décision assumée), ~500 L de petits blocs hors cibles,
|
||||
nettoyage des 285 warnings
|
||||
- Suite complète : **1089/1089**
|
||||
|
||||
## v7.23.0 (2026-10-01) — Audit : A27 phase 2b (+3 801 L)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A27 (phase 2b)** — 4 blocs interpolés extraits avec la recette config
|
||||
JSON (identique à la 2a) :
|
||||
· `local_workspace.html` → `local_workspace.js` (2 031 L, `lw-config` :
|
||||
current_folder_id, workspace_id)
|
||||
· `settings.html` → `settings.js` (1 093 L, `st-config` : avatar_url,
|
||||
avatar_color, user full_name/login/email, is_admin, auth_method —
|
||||
**2 routes rendent ce template**, les expressions `or ""` sont préservées
|
||||
pour les valeurs Undefined, `is_admin` reste un booléen)
|
||||
· `_page_editor_realtime.html` → `page_editor_realtime.js` (531 L,
|
||||
`rt-config` : SELF id/login/full_name/color)
|
||||
· `board.html` → `board.js` (146 L, `bd-config` : owner/repo/initial_view)
|
||||
- BONUS sécurité : les valeurs passent par `|tojson` (échappement JSON
|
||||
explicite) au lieu d'être interpolées dans des strings JS
|
||||
- Tags : config JSON inline (nonce conservé) + `<script src>` avec
|
||||
`?v={{ asset_version }}` ; loaders `XX = JSON.parse(#xx-config)` en tête
|
||||
des fichiers extraits (try/catch → `{}`)
|
||||
|
||||
### Notes
|
||||
|
||||
- **Cumul A27 : 10 560 L extraites** (13 904 → **3 344 restantes**, -76 %)
|
||||
- `node --check` vert sur les 4 fichiers ; eslint : **0 erreur, 279 warnings**
|
||||
(12 fichiers, baseline mise à jour)
|
||||
- Reste structurel : `base` 1 338 (`{% block %}`/`{% for %}` — reste inline
|
||||
par nature) + `database_table` 1 323 (`if/else` sur collection_data)
|
||||
- Suite complète : **1089/1089**
|
||||
|
||||
## v7.22.0 (2026-10-01) — Audit : A27 phase 2a (l'éditeur, 2 516 L)
|
||||
|
||||
### Changed
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.22.0 (audit — A27 phase 2 : éditeur 2 516 L via page-data JSON) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.25.0 (audit — A27 CLOSED : eslint 0/0, 285 warnings nettoyés) | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||
|
||||
## Avancement Global
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.22.0",
|
||||
version="7.25.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,535 +9,5 @@
|
||||
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
|
||||
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
|
||||
</style>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
/* eslint-disable */
|
||||
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
|
||||
window.__fdRT = (function () {
|
||||
const SELF = {
|
||||
id: {{ (user.get('id') if user else 0) | tojson }},
|
||||
login: {{ (user.get('login','') if user else '') | tojson }},
|
||||
full_name: {{ (user.get('full_name','') if user else '') | tojson }},
|
||||
color: {{ (user.get('avatar_color','') or '' if user else '') | tojson }},
|
||||
};
|
||||
const COLORS = [
|
||||
"#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
|
||||
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333",
|
||||
];
|
||||
let E = null; // editorState (window.E)
|
||||
let ws = null;
|
||||
let mode = 'off'; // 'ws' | 'poll'
|
||||
let open = false;
|
||||
let base = []; // dernier snapshot serveur des blocs
|
||||
let version = 0;
|
||||
let pendingOps = 0;
|
||||
let needSync = false;
|
||||
let peers = []; // liste des présents (hors moi)
|
||||
let remoteCursors = {}; // userId -> {peer, block, offset}
|
||||
let myCursor = null; // {block, offset}
|
||||
let emitT = null, selT = null, titleT = null, drawT = null, retryT = null, pollT = null;
|
||||
let _pid = 0;
|
||||
|
||||
// Detach global listeners from a previous editor instance so that
|
||||
// partial (HTMX) navigation doesn't accumulate stale handlers.
|
||||
function unbindGlobal() {
|
||||
const g = window.__fdRTGlobal;
|
||||
if (!g) return;
|
||||
try {
|
||||
document.removeEventListener('selectionchange', g.onSel, true);
|
||||
window.removeEventListener('scroll', g.onScroll, true);
|
||||
window.removeEventListener('resize', g.onResize);
|
||||
} catch (e) { /* noop */ }
|
||||
window.__fdRTGlobal = null;
|
||||
}
|
||||
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
function colorOf(uid) { return COLORS[Math.abs(uid || 0) % COLORS.length]; }
|
||||
|
||||
function initials(p) {
|
||||
const n = (p && (p.full_name || p.login)) || '';
|
||||
const parts = String(n).trim().split(/\s+/);
|
||||
if (!parts[0]) return '?';
|
||||
return ((parts[0][0] || '') + (parts.length > 1 ? (parts[1][0] || '') : '')).toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
function send(obj) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(JSON.stringify(obj)); } catch (e) { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ops miroir du serveur (apply_op/merge) ── */
|
||||
function applyOpJS(blocks, op) {
|
||||
const t = op && op.type;
|
||||
if (t === 'insert') {
|
||||
const blk = op.block || {};
|
||||
const id = blk.id || ('rb' + Date.now().toString(36));
|
||||
blk.id = id;
|
||||
if (typeof ensureBlockIds === 'function') ensureBlockIds([blk]);
|
||||
let idx = op.index != null ? op.index : blocks.length;
|
||||
idx = Math.max(0, Math.min(idx, blocks.length));
|
||||
return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx));
|
||||
}
|
||||
if (t === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (!nb.id) return blocks;
|
||||
return blocks.map(b => (b.id === nb.id ? nb : b));
|
||||
}
|
||||
if (t === 'delete') {
|
||||
const bid = op.id;
|
||||
return blocks.filter(b => b.id !== bid);
|
||||
}
|
||||
if (t === 'move') {
|
||||
const bid = op.id, idx = op.index || 0;
|
||||
const out = blocks.filter(b => b.id !== bid);
|
||||
const moved = blocks.find(b => b.id === bid);
|
||||
if (!moved) return blocks;
|
||||
const i2 = Math.max(0, Math.min(idx, out.length));
|
||||
out.splice(i2, 0, moved);
|
||||
return out;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/* Diff base → courants : update/delete/move/insert (ordre pour le serveur). */
|
||||
function diffOps(cur) {
|
||||
if (!base.length && !cur.length) return [];
|
||||
const ops = [];
|
||||
const baseById = {}, curById = {};
|
||||
base.forEach(b => { baseById[b.id] = b; });
|
||||
cur.forEach(b => { curById[b.id] = b; });
|
||||
|
||||
cur.forEach(b => {
|
||||
if (baseById[b.id] !== undefined && JSON.stringify(baseById[b.id]) !== JSON.stringify(b)) {
|
||||
// v6.4.0 : on embarque la `base` dont dérive la saisie → le serveur
|
||||
// fait un merge 3-voix au lieu d'écraser le bloc (LWW).
|
||||
ops.push({ type: 'update', block: clone(b), base: clone(baseById[b.id]) });
|
||||
}
|
||||
});
|
||||
base.forEach(b => {
|
||||
if (curById[b.id] === undefined) ops.push({ type: 'delete', id: b.id });
|
||||
});
|
||||
|
||||
// structure : simule l'état serveur (base − supprimés) pour indices valides
|
||||
let sim = base.filter(b => curById[b.id] !== undefined).map(b => clone(b));
|
||||
const simById = {}; sim.forEach(b => { simById[b.id] = b; });
|
||||
const finalOrder = cur.map(b => b.id);
|
||||
let si = 0;
|
||||
finalOrder.forEach(id => {
|
||||
if (simById[id] !== undefined) {
|
||||
const curPos = sim.findIndex(b => b.id === id);
|
||||
if (curPos !== si) ops.push({ type: 'move', id, index: si });
|
||||
const [mv] = sim.splice(curPos, 1);
|
||||
sim.splice(si, 0, mv);
|
||||
si++;
|
||||
} else {
|
||||
ops.push({ type: 'insert', index: si, block: clone(curById[id]) });
|
||||
sim.splice(si, 0, curById[id]);
|
||||
simById[id] = curById[id];
|
||||
si++;
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/* ── rendu + présence ── */
|
||||
function activeBlockId() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
return a ? a.bid : null;
|
||||
}
|
||||
|
||||
function refocus(fid) {
|
||||
if (!fid) return;
|
||||
setTimeout(() => {
|
||||
const el = E.getEl(fid);
|
||||
if (el) { el.focus(); try { (typeof ce === 'function') && ce(el); } catch (e) { /* noop */ } }
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function renderPresence() {
|
||||
let host = document.querySelector('.topbar-right.header-actions');
|
||||
if (!host) return;
|
||||
let box = document.getElementById('rtPresence');
|
||||
if (!box) {
|
||||
box = document.createElement('span');
|
||||
box.id = 'rtPresence';
|
||||
box.className = 'rt-presence';
|
||||
host.insertBefore(box, host.firstChild);
|
||||
}
|
||||
const shown = peers.filter(p => p.id !== (SELF.id || 0));
|
||||
if (!shown.length) { box.style.display = 'none'; return; }
|
||||
box.style.display = 'inline-flex';
|
||||
box.innerHTML = '';
|
||||
shown.forEach(p => {
|
||||
const c = document.createElement('span');
|
||||
c.className = 'rt-avatar';
|
||||
c.title = (p.full_name || p.login) + ' est en train d\u2019éditer' + (mode === 'poll' ? ' (polling)' : '');
|
||||
c.textContent = initials(p);
|
||||
c.style.background = p.color || colorOf(p.id);
|
||||
box.appendChild(c);
|
||||
});
|
||||
if (mode === 'poll') {
|
||||
let off = document.getElementById('rtOffline');
|
||||
if (!off) {
|
||||
off = document.createElement('span');
|
||||
off.id = 'rtOffline';
|
||||
off.className = 'rt-offline';
|
||||
off.textContent = '●';
|
||||
off.title = 'Realtime indisponible — rafraîchissement toutes les 10 s';
|
||||
host.insertBefore(off, box.nextSibling || null);
|
||||
}
|
||||
off.style.display = 'inline';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── curseurs ── */
|
||||
function rangeFromOffset(el, offset) {
|
||||
try {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let n = walker.nextNode(), count = 0;
|
||||
while (n) {
|
||||
const len = (n.nodeValue || '').length;
|
||||
if (count + len >= offset) {
|
||||
const r = document.createRange();
|
||||
r.setStart(n, Math.min(offset - count, len));
|
||||
r.collapse(true);
|
||||
return r;
|
||||
}
|
||||
count += len;
|
||||
n = walker.nextNode();
|
||||
}
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(el);
|
||||
r.collapse(false);
|
||||
return r;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function drawCursors() {
|
||||
const layer = document.getElementById('rtCursors');
|
||||
if (!layer) return;
|
||||
layer.innerHTML = '';
|
||||
const ids = Object.keys(remoteCursors);
|
||||
if (!ids.length) return;
|
||||
ids.forEach(uid => {
|
||||
const c = remoteCursors[uid];
|
||||
if (!c || !c.block) return;
|
||||
const el = E.getEl(c.block);
|
||||
if (!el) return;
|
||||
const r = rangeFromOffset(el, c.offset || 0);
|
||||
let x, y, h;
|
||||
if (r) {
|
||||
const rc = r.getBoundingClientRect();
|
||||
if (!rc.width && !rc.height) return; // hors viewport positionné
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
} else {
|
||||
const rc = el.getBoundingClientRect();
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
}
|
||||
const m = document.createElement('div');
|
||||
m.className = 'rt-cursor';
|
||||
m.style.left = (x - 1) + 'px';
|
||||
m.style.top = (y - 1) + 'px';
|
||||
m.style.height = h + 'px';
|
||||
m.style.background = c.peer.color || colorOf(c.peer.id);
|
||||
const nm = document.createElement('span');
|
||||
nm.className = 'rt-cursor-name';
|
||||
nm.textContent = initials(c.peer);
|
||||
nm.style.background = m.style.background;
|
||||
m.appendChild(nm);
|
||||
layer.appendChild(m);
|
||||
});
|
||||
}
|
||||
|
||||
function emitCursor() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
let block = null, offset = 0;
|
||||
if (a && a.el && a.bid) {
|
||||
block = a.bid;
|
||||
try { offset = (typeof cp === 'function') ? cp(a.el) : 0; } catch (e) { offset = 0; }
|
||||
}
|
||||
const changed = !myCursor || myCursor.block !== block || myCursor.offset !== offset;
|
||||
myCursor = { block, offset };
|
||||
if (!changed) return;
|
||||
send({ t: 'sel', block, offset });
|
||||
}
|
||||
|
||||
function scheduleDraw() {
|
||||
clearTimeout(drawT);
|
||||
drawT = setTimeout(drawCursors, 40);
|
||||
}
|
||||
|
||||
/* ── application des changements distants ── */
|
||||
function applySync(blocks, title) {
|
||||
if (!E || !E.blocks) return;
|
||||
// v5.13.1: guarantee ids before comparing/merging. Server rooms may still
|
||||
// carry legacy id-less blocks; without this they collide on
|
||||
// data-bid="undefined" and the merge below duplicated every line.
|
||||
blocks = (blocks || []).slice();
|
||||
if (typeof ensureBlockIds === 'function') ensureBlockIds(blocks);
|
||||
const curIds = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srvIds = JSON.stringify(blocks.map(b => b.id));
|
||||
if (curIds === srvIds) {
|
||||
base = clone(E.blocks);
|
||||
E.dirty = false;
|
||||
return;
|
||||
}
|
||||
const fid = activeBlockId();
|
||||
const curById = {};
|
||||
(E.blocks || []).forEach(b => { curById[b.id] = b; });
|
||||
let out;
|
||||
try {
|
||||
if (!blocks.length) {
|
||||
// serveur vide : ne pas effacer le contenu local (page neuve).
|
||||
out = (E.blocks || []).slice();
|
||||
} else {
|
||||
out = blocks.map(b => {
|
||||
const cb = curById[b.id];
|
||||
if (cb && b.id === fid) return cb; // garde la frappe locale en cours
|
||||
return b;
|
||||
});
|
||||
}
|
||||
} catch (e) { return; }
|
||||
E.blocks = out;
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (title && title !== E.pageTitle && document.activeElement !== tEl) {
|
||||
tEl.textContent = title;
|
||||
E.pageTitle = title;
|
||||
}
|
||||
E.dirty = true;
|
||||
base = clone(E.blocks);
|
||||
E.render();
|
||||
refocus(fid);
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
function applyRemoteOp(op) {
|
||||
const fid = activeBlockId();
|
||||
if (op.type === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (nb.id === fid) {
|
||||
// bloc en cours d'édition : on garde la valeur locale, le serveur a déjà
|
||||
// l'op ; la prochaine frappe locale repartira (LWW).
|
||||
base = applyOpJS(base, op);
|
||||
return;
|
||||
}
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
} else {
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
}
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
/* ── diffusion des modifications locales ── */
|
||||
function emit() {
|
||||
if (mode !== 'ws' || !open || !E || !E.blocks) return;
|
||||
if (needSync) { send({ t: 'sync_req' }); return; }
|
||||
const cur = E.blocks.filter(b => b && b.id);
|
||||
const ops = diffOps(cur);
|
||||
if (!ops.length) return;
|
||||
ops.forEach(op => { send({ t: 'op', op, v: version }); pendingOps++; });
|
||||
base = clone(cur);
|
||||
}
|
||||
|
||||
function onMsg(m) {
|
||||
if (!m || !m.t) return;
|
||||
if (m.t === 'sync') {
|
||||
version = m.version || 0;
|
||||
base = clone(m.blocks || []);
|
||||
needSync = false;
|
||||
pendingOps = 0;
|
||||
if (typeof m.blocks === 'undefined') return;
|
||||
applySync(m.blocks || [], m.title || '');
|
||||
} else if (m.t === 'ack') {
|
||||
version = m.v || 0;
|
||||
if (pendingOps > 0) pendingOps--;
|
||||
// v6.4.0 : le serveur renvoie le bloc fusionné (merge 3-voix). On
|
||||
// l'adopte comme nouvelle base ; s'il diffère de notre saisie locale
|
||||
// c'est qu'un autre utilisateur avait modifié le même bloc.
|
||||
if (m.merged) {
|
||||
const mid = m.merged.id;
|
||||
const localBlock = (E && E.blocks || []).find(b => b.id === mid);
|
||||
const differs = localBlock && JSON.stringify(localBlock) !== JSON.stringify(m.merged);
|
||||
base = applyOpJS(base, { type: 'update', block: m.merged });
|
||||
if (differs && E) {
|
||||
const fid = activeBlockId();
|
||||
if (fid !== mid) {
|
||||
E.blocks = applyOpJS(E.blocks, { type: 'update', block: m.merged });
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
}
|
||||
if (m.conflict && window.showToast) {
|
||||
window.showToast('Editing conflict merged on a block', 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m.stale || needSync) { needSync = true; send({ t: 'sync_req' }); }
|
||||
} else if (m.t === 'op') {
|
||||
if (m.v) version = m.v;
|
||||
if (m.from === (SELF.id || 0)) { base = applyOpJS(base, m.op); return; }
|
||||
applyRemoteOp(m.op);
|
||||
} else if (m.t === 'title') {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (m.from !== (SELF.id || 0) && tEl && document.activeElement !== tEl && E) {
|
||||
tEl.textContent = m.title || '';
|
||||
E.pageTitle = m.title || '';
|
||||
}
|
||||
if (m.v) version = m.v;
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'sel') {
|
||||
if (m.from === (SELF.id || 0)) return;
|
||||
if (!m.block) { delete remoteCursors[m.from]; scheduleDraw(); return; }
|
||||
remoteCursors[m.from] = { peer: m.peer || { id: m.from }, block: m.block, offset: m.offset || 0 };
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'welcome') {
|
||||
peers = (m.peers || []).filter(p => p.id !== (SELF.id || 0));
|
||||
renderPresence();
|
||||
} else if (m.t === 'peer_join') {
|
||||
if (m.peer && m.peer.id !== (SELF.id || 0)) {
|
||||
peers = peers.filter(p => p.id !== m.peer.id).concat([m.peer]);
|
||||
renderPresence();
|
||||
}
|
||||
} else if (m.t === 'peer_leave') {
|
||||
peers = peers.filter(p => p.id !== m.id);
|
||||
delete remoteCursors[m.id];
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'synced_update') {
|
||||
// A synced block was updated — re-sync the whole page
|
||||
if (m.synced_id) {
|
||||
needSync = true;
|
||||
send({ t: 'sync_req' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── connexion WS + fallback polling ── */
|
||||
function startPolling() {
|
||||
if (pollT) return;
|
||||
mode = 'poll';
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
pollT = setInterval(poll, 10000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollT) { clearInterval(pollT); pollT = null; }
|
||||
const off = document.getElementById('rtOffline');
|
||||
if (off) off.style.display = 'none';
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (!E || !_pid) return;
|
||||
try {
|
||||
const r = await fetch('/board/api/pages/' + _pid, { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
if ((d.content_format || 'blocks') !== 'blocks' || !d.content) return;
|
||||
const serverBlocks = JSON.parse(d.content);
|
||||
// en cas de modifs locales non persistées : on garde local (LWW au prochain save)
|
||||
if (E.dirty) return;
|
||||
const cur = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srv = JSON.stringify(serverBlocks.map(b => b.id));
|
||||
if (cur === srv) return;
|
||||
version = version; // pas de version via polling — on adopte l'état serveur
|
||||
applySync(serverBlocks, d.title || E.pageTitle);
|
||||
} catch (e) { /* noop */ }
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (!E || !_pid || ws) return;
|
||||
const proto = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
try {
|
||||
ws = new WebSocket(proto + window.location.host + '/ws/pages/' + _pid);
|
||||
} catch (e) {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => {
|
||||
open = true;
|
||||
mode = 'ws';
|
||||
stopPolling();
|
||||
send({ t: 'hello' });
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let m;
|
||||
try { m = JSON.parse(ev.data); } catch (e) { return; }
|
||||
onMsg(m);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
open = false;
|
||||
ws = null;
|
||||
if (retryT) clearTimeout(retryT);
|
||||
retryT = setTimeout(connect, 15000);
|
||||
startPolling();
|
||||
};
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) { /* noop */ } };
|
||||
setTimeout(() => {
|
||||
if (!open) { try { ws && ws.close(); } catch (e) { /* noop */ } }
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function titleInput() {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (!tEl) return;
|
||||
clearTimeout(titleT);
|
||||
titleT = setTimeout(() => {
|
||||
send({ t: 'title', title: (tEl.textContent || '').trim() });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const ct = document.getElementById('_blocksCt');
|
||||
if (ct) {
|
||||
ct.addEventListener('input', () => {
|
||||
clearTimeout(emitT);
|
||||
emitT = setTimeout(emit, 350);
|
||||
setTimeout(emitCursor, 80);
|
||||
}, true);
|
||||
ct.addEventListener('keyup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('click', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('mouseup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
}
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (tEl) tEl.addEventListener('input', titleInput);
|
||||
unbindGlobal();
|
||||
const onSel = () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); };
|
||||
const onScroll = () => scheduleDraw();
|
||||
const onResize = () => scheduleDraw();
|
||||
document.addEventListener('selectionchange', onSel, true);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onResize);
|
||||
window.__fdRTGlobal = { onSel, onScroll, onResize };
|
||||
}
|
||||
|
||||
function start(ed) {
|
||||
if (!ed) return;
|
||||
if ((ed.contentFormat || 'blocks') !== 'blocks') return;
|
||||
E = ed;
|
||||
_pid = ed.pid || 0;
|
||||
if (!_pid) return;
|
||||
base = clone(ed.blocks || []);
|
||||
wire();
|
||||
connect();
|
||||
}
|
||||
|
||||
// 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>
|
||||
<script type="application/json" id="rt-config" nonce="{{ csp_nonce() }}">{{ {"id": user.get("id") if user else 0, "login": user.get("login", "") if user else "", "full_name": user.get("full_name", "") if user else "", "color": (user.get("avatar_color", "") or "") if user else ""} | tojson }}</script>
|
||||
<script data-cfasync="false" src="/static/js/page_editor_realtime.js?v={{ asset_version }}"></script>
|
||||
+2
-147
@@ -151,151 +151,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
var owner = '{{ owner }}';
|
||||
var repo = '{{ repo }}';
|
||||
var initialView = '{{ initial_view }}';
|
||||
|
||||
// Auto-switch to view from URL param
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (initialView && initialView !== 'kanban') {
|
||||
const tab = document.querySelector(`.view-tab[data-view="${initialView}"]`);
|
||||
if (tab) tab.click();
|
||||
}
|
||||
});
|
||||
|
||||
function setActiveTab(el) {
|
||||
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
}
|
||||
|
||||
function kanbanBoard() {
|
||||
return {
|
||||
collapsedGroups: [],
|
||||
toggleGroup(id) {
|
||||
const idx = this.collapsedGroups.indexOf(id);
|
||||
idx >= 0 ? this.collapsedGroups.splice(idx, 1) : this.collapsedGroups.push(id);
|
||||
},
|
||||
newCard(groupId, status) {
|
||||
document.getElementById('new-issue-form').style.display = 'block';
|
||||
document.querySelector('#new-issue-form').__x.$data.status = status;
|
||||
},
|
||||
newGroup() { console.log('New group'); }
|
||||
};
|
||||
}
|
||||
|
||||
function filterSystem() {
|
||||
return {
|
||||
activeFilters: [],
|
||||
statusFilters: [],
|
||||
showStatusMenu: false,
|
||||
statusOptions: [
|
||||
{ value: 'todo', label: 'To-do', color: 'var(--gray)' },
|
||||
{ value: 'progress', label: 'In progress', color: 'var(--blue)' },
|
||||
{ value: 'done', label: 'Complete', color: 'var(--green)' },
|
||||
],
|
||||
get statusFilterLabel() {
|
||||
return this.statusFilters.length ? this.statusFilters.join(', ') : 'All';
|
||||
},
|
||||
toggleStatus(val) {
|
||||
const idx = this.statusFilters.indexOf(val);
|
||||
idx >= 0 ? this.statusFilters.splice(idx, 1) : this.statusFilters.push(val);
|
||||
},
|
||||
addFilter() {
|
||||
const prop = prompt('Filter by property (status, assignee, label):');
|
||||
if (!prop) return;
|
||||
const val = prompt('Value:');
|
||||
if (!val) return;
|
||||
this.activeFilters.push({ property: prop, value: val });
|
||||
this.refreshView();
|
||||
},
|
||||
removeFilter(i) { this.activeFilters.splice(i, 1); },
|
||||
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
|
||||
refreshView() {
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sortSystem() {
|
||||
return {
|
||||
sorts: [],
|
||||
showSortPanel: false,
|
||||
addSort() {
|
||||
const field = prompt('Sort by (name, status, assignee, deadline):');
|
||||
if (!field) return;
|
||||
this.sorts.push({ field, dir: 'asc' });
|
||||
this.applySorts();
|
||||
},
|
||||
removeSort(i) { this.sorts.splice(i, 1); this.applySorts(); },
|
||||
toggleDir(i) { this.sorts[i].dir = this.sorts[i].dir === 'asc' ? 'desc' : 'asc'; this.applySorts(); },
|
||||
clearSorts() { this.sorts = []; this.applySorts(); },
|
||||
applySorts() {
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
const url = new URL(activeTab.getAttribute('hx-get'), window.location.origin);
|
||||
this.sorts.forEach(s => url.searchParams.append('sort', s.field + ':' + s.dir));
|
||||
htmx.ajax('GET', url.pathname + url.search, { target: '#view-content', swap: 'innerHTML' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function newIssueForm() {
|
||||
return {
|
||||
title: '', status: 'todo',
|
||||
create() {
|
||||
if (!this.title.trim()) return;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
this.title = '';
|
||||
this.hide();
|
||||
// Refresh current view
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
});
|
||||
},
|
||||
hide() { document.getElementById('new-issue-form').style.display = 'none'; },
|
||||
show() { document.getElementById('new-issue-form').style.display = 'block'; }
|
||||
};
|
||||
}
|
||||
|
||||
function showNewIssue() {
|
||||
const form = document.getElementById('new-issue-form');
|
||||
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Init SortableJS after HTMX swaps
|
||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||
if (evt.target.id === 'view-content') {
|
||||
document.querySelectorAll('.kanban-cards').forEach(el => {
|
||||
if (el._sortable) el._sortable.destroy();
|
||||
el._sortable = new Sortable(el, {
|
||||
group: 'kanban',
|
||||
animation: 200,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
onEnd: function(evt) {
|
||||
const cardId = evt.item.dataset.cardId;
|
||||
const toStatus = evt.to.dataset.status;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(() => {
|
||||
// ponytail: refresh current view after move
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script type="application/json" id="bd-config" nonce="{{ csp_nonce() }}">{{ {"owner": owner, "repo": repo, "initial_view": initial_view} | tojson }}</script>
|
||||
<script data-cfasync="false" src="/static/js/board.js?v={{ asset_version }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2
-1094
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.22.0"
|
||||
"version": "7.25.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
|
||||
@@ -40,6 +40,9 @@ const browserGlobals = {
|
||||
Alpine: "readonly", htmx: "readonly", Sortable: "readonly",
|
||||
// Globals exposed on window by app.js
|
||||
openModal: "readonly", closeModal: "readonly",
|
||||
// getSvgIcon : script inline de base.html (nonce) ; Prism : CDN des vues ;
|
||||
// TextDecoder : API navigateur (ES2015)
|
||||
getSvgIcon: "readonly", Prism: "readonly", TextDecoder: "readonly",
|
||||
};
|
||||
|
||||
export default [
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
self._place();
|
||||
var el = document.querySelector('.fd-ctx-menu');
|
||||
if (el && window.ResizeObserver) {
|
||||
if (self._ro) { try { self._ro.disconnect(); } catch (e) {} }
|
||||
if (self._ro) { try { self._ro.disconnect(); } catch { /* volontaire */ } }
|
||||
self._ro = new ResizeObserver(function () { self._place(); });
|
||||
self._ro.observe(el);
|
||||
}
|
||||
@@ -95,7 +95,7 @@
|
||||
this.maxH = Math.max(120, vh - ny - 8);
|
||||
},
|
||||
close() {
|
||||
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
|
||||
if (this._ro) { try { this._ro.disconnect(); } catch { /* volontaire */ } this._ro = null; }
|
||||
this.open = false;
|
||||
this.node = null;
|
||||
this.handlers = {};
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
|
||||
remove() {
|
||||
var fn = this.onRemove || this.onPick;
|
||||
if (fn) { try { fn(''); } catch (e) {} }
|
||||
if (fn) { try { fn(''); } catch { /* volontaire */ } }
|
||||
this.close();
|
||||
},
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
|
||||
loadRecent() {
|
||||
try { this.recent = JSON.parse(localStorage.getItem('fd_icon_recent') || '[]'); }
|
||||
catch (e) { this.recent = []; }
|
||||
catch { this.recent = []; }
|
||||
},
|
||||
pushRecent(e) {
|
||||
try {
|
||||
@@ -151,7 +151,7 @@
|
||||
r.unshift(e);
|
||||
this.recent = r.slice(0, 32);
|
||||
localStorage.setItem('fd_icon_recent', JSON.stringify(this.recent));
|
||||
} catch (err) {}
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
|
||||
async loadCustom() {
|
||||
@@ -160,7 +160,7 @@
|
||||
var d = await r.json();
|
||||
this.custom = (d && d.emojis) || [];
|
||||
this.customLoaded = true;
|
||||
} catch (e) { this.custom = []; }
|
||||
} catch { this.custom = []; }
|
||||
},
|
||||
|
||||
openCustomModal() {
|
||||
@@ -196,7 +196,7 @@
|
||||
this.customModal = false;
|
||||
this.pick(d.emoji.url);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
if (window.showToast) window.showToast('Emoji upload failed', 'error');
|
||||
}
|
||||
this.customBusy = false;
|
||||
@@ -206,7 +206,7 @@
|
||||
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch('/api/custom-emojis/' + id, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf }, credentials: 'same-origin' });
|
||||
this.custom = this.custom.filter(function (e) { return e.id !== id; });
|
||||
} catch (e) {}
|
||||
} catch { /* volontaire */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+20
-24
@@ -430,7 +430,7 @@
|
||||
if(!lastAsst) return;
|
||||
var steps = acts.map(function(a){
|
||||
var res = null;
|
||||
try{ res = a.result_json ? JSON.parse(a.result_json) : {}; }catch(e){ res = {}; }
|
||||
try{ res = a.result_json ? JSON.parse(a.result_json) : {}; }catch{ res = {}; }
|
||||
var msg = (res && res.message) ? res.message : '';
|
||||
return agentPanel._actionStep({
|
||||
tool: a.tool_name, status: a.status, target_id: a.target_id,
|
||||
@@ -672,7 +672,7 @@
|
||||
r.insertNode(br);
|
||||
r.setStartAfter(br); r.collapse(true);
|
||||
this.input = this.composerText();
|
||||
}catch(e){}
|
||||
}catch { /* volontaire */ }
|
||||
this.resizeInput();
|
||||
},
|
||||
_caretQuery(){
|
||||
@@ -687,7 +687,7 @@
|
||||
if(i > 0 && s[i-1] === '@' && (i === 1 || /\s/.test(s[i-2]))){
|
||||
return {node: node, at: i-1, raw: s.slice(i, off)};
|
||||
}
|
||||
}catch(e){}
|
||||
}catch { /* volontaire */ }
|
||||
return null;
|
||||
},
|
||||
_expandToAt(range){
|
||||
@@ -727,14 +727,14 @@
|
||||
useRange.setStartAfter(span); useRange.collapse(true);
|
||||
useRange.insertNode(sp);
|
||||
useRange.setStart(sp, 1); useRange.setEnd(sp, 1);
|
||||
try{ var s2 = window.getSelection(); if(s2){ s2.removeAllRanges(); s2.addRange(useRange); } }catch(e2){}
|
||||
try{ var s2 = window.getSelection(); if(s2){ s2.removeAllRanges(); s2.addRange(useRange); } }catch { /* volontaire */ }
|
||||
} else {
|
||||
if(el.textContent) el.appendChild(document.createTextNode(' '));
|
||||
el.appendChild(span);
|
||||
el.appendChild(document.createTextNode(' '));
|
||||
try{ var r2 = document.createRange(); r2.selectNodeContents(el); r2.collapse(false); var s3 = window.getSelection(); if(s3){ s3.removeAllRanges(); s3.addRange(r2); } }catch(e3){}
|
||||
try{ var r2 = document.createRange(); r2.selectNodeContents(el); r2.collapse(false); var s3 = window.getSelection(); if(s3){ s3.removeAllRanges(); s3.addRange(r2); } }catch { /* volontaire */ }
|
||||
}
|
||||
}catch(e){}
|
||||
}catch { /* volontaire */ }
|
||||
this.input = this.composerText();
|
||||
this.resizeInput();
|
||||
this._emptyCheck();
|
||||
@@ -753,7 +753,7 @@
|
||||
this._insertToken(item, r);
|
||||
return;
|
||||
}
|
||||
}catch(e){}
|
||||
}catch { /* volontaire */ }
|
||||
}
|
||||
this._insertToken(item);
|
||||
},
|
||||
@@ -803,12 +803,12 @@
|
||||
onPaste(e){
|
||||
e.preventDefault();
|
||||
var text = '';
|
||||
try{ if(e.clipboardData) text = e.clipboardData.getData('text/plain') || ''; }catch(err){ }
|
||||
try{ if(e.clipboardData) text = e.clipboardData.getData('text/plain') || ''; }catch { /* volontaire */ }
|
||||
if(!text) return;
|
||||
try{
|
||||
var sel = window.getSelection();
|
||||
if(sel && sel.rangeCount){ sel.getRangeAt(0).deleteContents(); document.execCommand('insertText', false, text); }
|
||||
}catch(err){}
|
||||
}catch { /* volontaire */ }
|
||||
this.input = this.composerText();
|
||||
this.resizeInput();
|
||||
},
|
||||
@@ -889,7 +889,6 @@
|
||||
return null;
|
||||
},
|
||||
useSuggestion(c){
|
||||
var self = this;
|
||||
if(!c || c._sep) return;
|
||||
if(c._admin){
|
||||
this.setComposerText(c.cmd.replace(/\s*<[^>]*>\s*$/, ' '));
|
||||
@@ -926,7 +925,6 @@
|
||||
this.scrollBottom();
|
||||
},
|
||||
handleCommand(text){
|
||||
var self = this;
|
||||
var parts = text.replace(/^\/+/, '').trim().split(/\s+/);
|
||||
var cmd = (parts.shift() || '').toLowerCase();
|
||||
var arg = parts.join(' ').trim();
|
||||
@@ -1164,7 +1162,7 @@
|
||||
if(tok){ e.preventDefault(); this._removeTokenDom(tok); return; }
|
||||
}
|
||||
}
|
||||
}catch(err){}
|
||||
}catch { /* volontaire */ }
|
||||
}
|
||||
if(key === 'Escape'){
|
||||
if(this.mentionOpen){ e.preventDefault(); e.stopPropagation(); this.mentionOpen = false; this._mentionFrom = null; this._atNode = null; return; }
|
||||
@@ -1219,7 +1217,6 @@
|
||||
// Render agent markdown responses (headings, tables, code, lists, …) to
|
||||
// HTML so the panel displays content like Notion (no raw `##` / `**`).
|
||||
renderMarkdown(md){
|
||||
var self = this;
|
||||
function escHtml(s){ return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function inline(t){
|
||||
t = escHtml(t);
|
||||
@@ -1261,7 +1258,6 @@
|
||||
var t = raw.trim();
|
||||
// Fenced code block
|
||||
if(t.startsWith('```')){
|
||||
var lang = t.substring(3).trim();
|
||||
var code = [];
|
||||
i++;
|
||||
while(i < lines.length && !lines[i].trim().startsWith('```')){
|
||||
@@ -1351,7 +1347,7 @@
|
||||
var ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
self.toast(ok ? 'Réponse copiée.' : 'Copie impossible.', !ok);
|
||||
}catch(err){ self.toast('Copie impossible.', true); }
|
||||
}catch{ self.toast('Copie impossible.', true); }
|
||||
}
|
||||
if(navigator.clipboard && navigator.clipboard.writeText){
|
||||
navigator.clipboard.writeText(text).then(function(){ self.toast('Réponse copiée.'); }).catch(function(){ fallback(); });
|
||||
@@ -1418,7 +1414,7 @@
|
||||
if(ment.length){ text = 'Analyse le contexte référencé.'; }
|
||||
else if(!hasSkills){ return; }
|
||||
}
|
||||
try { await self.ensureConversation(); } catch(e){ return; }
|
||||
try { await self.ensureConversation(); } catch{ return; }
|
||||
var rawText = text;
|
||||
// La requête est composée EN ARRIÈRE-PLAN (hors de la boîte d'édition) :
|
||||
// contexte1 + contexte2 + skill-1 + skill-2 + texte.
|
||||
@@ -1505,7 +1501,7 @@
|
||||
fph.steps = runSteps.slice();
|
||||
fph.stepsOpen = false;
|
||||
fph.model = self.autoLabel || '';
|
||||
if(window.__fdInsertAI){ try{ var h=window.__fdInsertAI; window.__fdInsertAI=null; h(ev.content||''); }catch(e){} }
|
||||
if(window.__fdInsertAI){ try{ var h=window.__fdInsertAI; window.__fdInsertAI=null; h(ev.content||''); }catch { /* volontaire */ } }
|
||||
}
|
||||
else if(ev.type==='notice'){ self.notice = ev.message; }
|
||||
else if(ev.type==='error'){
|
||||
@@ -1515,7 +1511,7 @@
|
||||
eph.steps = runSteps.slice();
|
||||
self.errorMsg = ev.message;
|
||||
}
|
||||
}catch(e){}
|
||||
}catch { /* volontaire */ }
|
||||
});
|
||||
self.scrollBottom();
|
||||
return read();
|
||||
@@ -1631,7 +1627,7 @@
|
||||
var out = title ? ('Titre du document : ' + title) : 'Document ouvert';
|
||||
if(text) out += '\n\n' + text;
|
||||
return out.slice(0, 9000);
|
||||
}catch(e){ return ''; }
|
||||
}catch{ return ''; }
|
||||
};
|
||||
if(!window.fdGetPageContext) window.fdGetPageContext = window.fdReadPageContext;
|
||||
|
||||
@@ -1644,7 +1640,7 @@
|
||||
try{
|
||||
var m = (window.location.pathname || '').match(new RegExp('^\\/?' + prefix + '\\/(\\d+)'));
|
||||
return m ? m[1] : null;
|
||||
}catch(e){ return null; }
|
||||
}catch{ return null; }
|
||||
}
|
||||
function labelFromTitle(){
|
||||
try{
|
||||
@@ -1652,7 +1648,7 @@
|
||||
t = t.replace(/^\s*FlowDeck\s*-\s*/i, '');
|
||||
t = t.replace(/\s*[-|]\s*FlowDeck\s*$/i, '');
|
||||
if(t && t.toLowerCase() !== 'home' && t.toLowerCase() !== 'flowdeck') return t;
|
||||
}catch(e){}
|
||||
}catch { /* volontaire */ }
|
||||
return '';
|
||||
}
|
||||
function docContext(){
|
||||
@@ -1671,7 +1667,7 @@
|
||||
refType: pid ? 'document' : null
|
||||
};
|
||||
}
|
||||
}catch(e){}
|
||||
}catch { /* volontaire */ }
|
||||
return null;
|
||||
}
|
||||
function marker(){
|
||||
@@ -1686,7 +1682,7 @@
|
||||
var lbl = b.getAttribute('data-fd-context');
|
||||
return {kind:'section', icon:'📍', label:lbl, text:'', key:'marker'};
|
||||
}
|
||||
}catch(e){}
|
||||
}catch { /* volontaire */ }
|
||||
return null;
|
||||
}
|
||||
function byPath(){
|
||||
@@ -1777,7 +1773,7 @@
|
||||
if(E.autoSave) E.autoSave();
|
||||
if(E.render) E.render();
|
||||
return true;
|
||||
}catch(e){ return false; }
|
||||
}catch{ return false; }
|
||||
};
|
||||
|
||||
// Backward-compatible detection hooks (banner + Ask AI)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/* exported setActiveTab, kanbanBoard, filterSystem, sortSystem, newIssueForm, showNewIssue -- appeles depuis les attributs HTML des templates */
|
||||
const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
var owner = BD.owner;
|
||||
var repo = BD.repo;
|
||||
var initialView = BD.initial_view;
|
||||
|
||||
// Auto-switch to view from URL param
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (initialView && initialView !== 'kanban') {
|
||||
const tab = document.querySelector(`.view-tab[data-view="${initialView}"]`);
|
||||
if (tab) tab.click();
|
||||
}
|
||||
});
|
||||
|
||||
function setActiveTab(el) {
|
||||
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
}
|
||||
|
||||
function kanbanBoard() {
|
||||
return {
|
||||
collapsedGroups: [],
|
||||
toggleGroup(id) {
|
||||
const idx = this.collapsedGroups.indexOf(id);
|
||||
idx >= 0 ? this.collapsedGroups.splice(idx, 1) : this.collapsedGroups.push(id);
|
||||
},
|
||||
newCard(groupId, status) {
|
||||
document.getElementById('new-issue-form').style.display = 'block';
|
||||
document.querySelector('#new-issue-form').__x.$data.status = status;
|
||||
},
|
||||
newGroup() { console.log('New group'); }
|
||||
};
|
||||
}
|
||||
|
||||
function filterSystem() {
|
||||
return {
|
||||
activeFilters: [],
|
||||
statusFilters: [],
|
||||
showStatusMenu: false,
|
||||
statusOptions: [
|
||||
{ value: 'todo', label: 'To-do', color: 'var(--gray)' },
|
||||
{ value: 'progress', label: 'In progress', color: 'var(--blue)' },
|
||||
{ value: 'done', label: 'Complete', color: 'var(--green)' },
|
||||
],
|
||||
get statusFilterLabel() {
|
||||
return this.statusFilters.length ? this.statusFilters.join(', ') : 'All';
|
||||
},
|
||||
toggleStatus(val) {
|
||||
const idx = this.statusFilters.indexOf(val);
|
||||
idx >= 0 ? this.statusFilters.splice(idx, 1) : this.statusFilters.push(val);
|
||||
},
|
||||
addFilter() {
|
||||
const prop = prompt('Filter by property (status, assignee, label):');
|
||||
if (!prop) return;
|
||||
const val = prompt('Value:');
|
||||
if (!val) return;
|
||||
this.activeFilters.push({ property: prop, value: val });
|
||||
this.refreshView();
|
||||
},
|
||||
removeFilter(i) { this.activeFilters.splice(i, 1); },
|
||||
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
|
||||
refreshView() {
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sortSystem() {
|
||||
return {
|
||||
sorts: [],
|
||||
showSortPanel: false,
|
||||
addSort() {
|
||||
const field = prompt('Sort by (name, status, assignee, deadline):');
|
||||
if (!field) return;
|
||||
this.sorts.push({ field, dir: 'asc' });
|
||||
this.applySorts();
|
||||
},
|
||||
removeSort(i) { this.sorts.splice(i, 1); this.applySorts(); },
|
||||
toggleDir(i) { this.sorts[i].dir = this.sorts[i].dir === 'asc' ? 'desc' : 'asc'; this.applySorts(); },
|
||||
clearSorts() { this.sorts = []; this.applySorts(); },
|
||||
applySorts() {
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
const url = new URL(activeTab.getAttribute('hx-get'), window.location.origin);
|
||||
this.sorts.forEach(s => url.searchParams.append('sort', s.field + ':' + s.dir));
|
||||
htmx.ajax('GET', url.pathname + url.search, { target: '#view-content', swap: 'innerHTML' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function newIssueForm() {
|
||||
return {
|
||||
title: '', status: 'todo',
|
||||
create() {
|
||||
if (!this.title.trim()) return;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
this.title = '';
|
||||
this.hide();
|
||||
// Refresh current view
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
});
|
||||
},
|
||||
hide() { document.getElementById('new-issue-form').style.display = 'none'; },
|
||||
show() { document.getElementById('new-issue-form').style.display = 'block'; }
|
||||
};
|
||||
}
|
||||
|
||||
function showNewIssue() {
|
||||
const form = document.getElementById('new-issue-form');
|
||||
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Init SortableJS after HTMX swaps
|
||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||
if (evt.target.id === 'view-content') {
|
||||
document.querySelectorAll('.kanban-cards').forEach(el => {
|
||||
if (el._sortable) el._sortable.destroy();
|
||||
el._sortable = new Sortable(el, {
|
||||
group: 'kanban',
|
||||
animation: 200,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
onEnd: function(evt) {
|
||||
const cardId = evt.item.dataset.cardId;
|
||||
const toStatus = evt.to.dataset.status;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(() => {
|
||||
// ponytail: refresh current view after move
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -63,7 +63,7 @@ document.addEventListener('alpine:init', () => {
|
||||
var d = await r.json();
|
||||
this.treeItems = d.tree || [];
|
||||
this._sortTree();
|
||||
} catch(e) { this.treeItems = []; this.sortedTreeItems = []; }
|
||||
} catch { this.treeItems = []; this.sortedTreeItems = []; }
|
||||
finally { this.treeLoading = false; }
|
||||
},
|
||||
|
||||
@@ -267,7 +267,7 @@ document.addEventListener('alpine:init', () => {
|
||||
ul.appendChild(li);
|
||||
});
|
||||
el.appendChild(ul);
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
|
||||
async openFile(path, sha) {
|
||||
@@ -286,7 +286,7 @@ document.addEventListener('alpine:init', () => {
|
||||
} else {
|
||||
this.fileContent = '[Error loading file]';
|
||||
}
|
||||
} catch(e) {
|
||||
} catch {
|
||||
this.fileContent = '[Error loading file]';
|
||||
}
|
||||
this.fileLoading = false;
|
||||
@@ -343,7 +343,7 @@ document.addEventListener('alpine:init', () => {
|
||||
var d = await r.json();
|
||||
window.showToast('Failed to save: ' + (d.error || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch(e) { window.showToast('Network error', 'error'); }
|
||||
} catch { window.showToast('Network error', 'error'); }
|
||||
},
|
||||
|
||||
async deleteCurrentFile() {
|
||||
@@ -358,7 +358,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.filePath = '';
|
||||
await this.refreshTree();
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
|
||||
async refreshTree() {
|
||||
@@ -380,7 +380,7 @@ document.addEventListener('alpine:init', () => {
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages');
|
||||
if (r.ok) { var d = await r.json(); this.privatePages = d.pages || []; }
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
|
||||
newPrivate() {
|
||||
@@ -398,7 +398,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.editingPrivateTitle = d.page.title;
|
||||
this.editingPrivateContent = d.page.content;
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
|
||||
async savePrivate() {
|
||||
@@ -419,7 +419,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.editingPrivateContent = '';
|
||||
await this.loadPrivatePages();
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
|
||||
// Create new file
|
||||
@@ -477,7 +477,7 @@ document.addEventListener('alpine:init', () => {
|
||||
method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''}
|
||||
});
|
||||
if (r.ok) await this.loadPrivatePages();
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
|
||||
getCsrfToken() {
|
||||
|
||||
+7
-6
@@ -1,3 +1,4 @@
|
||||
/* exported importWizard -- appeles depuis les attributs HTML des templates */
|
||||
|
||||
function importWizard() {
|
||||
return {
|
||||
@@ -25,11 +26,11 @@ function importWizard() {
|
||||
try {
|
||||
const r = await fetch('/api/import/sources');
|
||||
this.sources = (await r.json()).sources || [];
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
try {
|
||||
const c = await fetch('/api/csrf-token');
|
||||
this.csrf = (await c.json()).csrf_token;
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
humanSize(n) { return n > 1048576 ? (n/1048576).toFixed(1)+' Mo' : Math.max(1, Math.round(n/1024))+' Ko'; },
|
||||
addFiles(list) {
|
||||
@@ -61,7 +62,7 @@ function importWizard() {
|
||||
this.preview = merged;
|
||||
this.mapping = {};
|
||||
(merged ? merged.pages : []).forEach(p => (p.schema||[]).forEach(c => { this.mapping[c.name]=c.type; }));
|
||||
} catch(e) { this.error = 'Erreur réseau'; }
|
||||
} catch { this.error = 'Erreur réseau'; }
|
||||
finally { this.busy = false; setTimeout(()=>{this.progress=0;},800); }
|
||||
},
|
||||
async importOne(f) {
|
||||
@@ -82,7 +83,7 @@ function importWizard() {
|
||||
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
|
||||
f.status = (d.status==='partial' ? 'partial' : 'done');
|
||||
return d;
|
||||
} catch(e) { f.status='error'; f.error='Erreur réseau'; return null; }
|
||||
} catch { f.status='error'; f.error='Erreur réseau'; return null; }
|
||||
},
|
||||
async doImport() {
|
||||
this.busy = true; this.error=''; this.report=null; this.progress=0;
|
||||
@@ -115,7 +116,7 @@ function importWizard() {
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.urlError = d.detail || 'Erreur'; return; }
|
||||
this.report = d; this.progress = 100;
|
||||
} catch(e) { this.urlError = 'Erreur réseau'; }
|
||||
} catch { this.urlError = 'Erreur réseau'; }
|
||||
finally { this.busy = false; }
|
||||
},
|
||||
async doForge(kind) {
|
||||
@@ -132,7 +133,7 @@ function importWizard() {
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.forgeError = d.detail || 'Erreur'; return; }
|
||||
this.report = d; this.progress = 100;
|
||||
} catch(e) { this.forgeError = 'Erreur réseau'; }
|
||||
} catch { this.forgeError = 'Erreur réseau'; }
|
||||
finally { this.busy = false; }
|
||||
},
|
||||
async pollJob(jobId, f) {
|
||||
|
||||
+15
-15
@@ -1,3 +1,4 @@
|
||||
/* exported libraryPage -- appeles depuis les attributs HTML des templates */
|
||||
|
||||
function libraryPage() {
|
||||
return {
|
||||
@@ -298,7 +299,7 @@ function libraryPage() {
|
||||
} else {
|
||||
if (window.showToast) window.showToast('Not a text file', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
if (window.showToast) window.showToast('Copy failed', 'error');
|
||||
}
|
||||
},
|
||||
@@ -389,7 +390,7 @@ function libraryPage() {
|
||||
headers: {'Content-Type':'application/json', 'X-CSRF-Token': csrf},
|
||||
body: JSON.stringify({ parent_id: parentId || 0 })
|
||||
});
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
}
|
||||
this.clearSelection();
|
||||
this.loadTab();
|
||||
@@ -537,7 +538,6 @@ function libraryPage() {
|
||||
doSort() {
|
||||
var s = this.sortBy;
|
||||
if (!s) return;
|
||||
var self = this;
|
||||
function sortNodes(nodes) {
|
||||
if (!nodes) return;
|
||||
nodes.sort(function(a,b){
|
||||
@@ -574,7 +574,7 @@ function libraryPage() {
|
||||
item.children = d.items || [];
|
||||
// Mark the tree for depth calculation
|
||||
this._markChildren(item.children, (item.depth || 0) + 1);
|
||||
} catch(e) {
|
||||
} catch {
|
||||
item.children = [];
|
||||
}
|
||||
}
|
||||
@@ -701,7 +701,7 @@ function libraryPage() {
|
||||
item.page_icon = icon;
|
||||
this._renderTable();
|
||||
if (window.showToast) window.showToast('Icon updated', 'success');
|
||||
} catch(e) {
|
||||
} catch {
|
||||
if (window.showToast) window.showToast('Failed to update icon', 'error');
|
||||
}
|
||||
},
|
||||
@@ -712,7 +712,7 @@ function libraryPage() {
|
||||
var r = await fetch('/api/settings/tags/all');
|
||||
var d = await r.json();
|
||||
this.workspaceTags = d.tags || [];
|
||||
} catch(e) { this.workspaceTags = []; }
|
||||
} catch { this.workspaceTags = []; }
|
||||
},
|
||||
|
||||
toggleTagFilter(tagName) {
|
||||
@@ -771,7 +771,7 @@ function libraryPage() {
|
||||
this._ctxRefreshAvail();
|
||||
this._recomputeFlatItems();
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
|
||||
async toggleFavoriteItem(item) {
|
||||
@@ -809,7 +809,7 @@ function libraryPage() {
|
||||
this.loadTab();
|
||||
this._syncSidebar();
|
||||
if (window.showToast) window.showToast('Page duplicated', 'success');
|
||||
} catch(e) {
|
||||
} catch {
|
||||
if (window.showToast) window.showToast('Failed to duplicate page', 'error');
|
||||
}
|
||||
},
|
||||
@@ -828,7 +828,7 @@ function libraryPage() {
|
||||
var all = (d.items || []).filter(function(it) { return ids.indexOf(it.id) === -1; });
|
||||
this._moveAllCandidates = all;
|
||||
this.moveCandidates = all.slice(0, 30);
|
||||
} catch(e) {
|
||||
} catch {
|
||||
this._moveAllCandidates = [];
|
||||
this.moveCandidates = [];
|
||||
}
|
||||
@@ -881,7 +881,7 @@ function libraryPage() {
|
||||
if (r.ok) {
|
||||
item.title = newName;
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
this._renderTable();
|
||||
this._syncSidebar();
|
||||
},
|
||||
@@ -908,7 +908,7 @@ function libraryPage() {
|
||||
var cr = await fetch('/api/library/children/' + parentId);
|
||||
var cd = await cr.json();
|
||||
parent.children = cd.items || [];
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
this._recomputeFlatItems();
|
||||
this._renderTable();
|
||||
return;
|
||||
@@ -917,7 +917,7 @@ function libraryPage() {
|
||||
if (window.fdNavigate) window.fdNavigate('/pages/' + d.id);
|
||||
else window.location.href = '/pages/' + d.id;
|
||||
}
|
||||
} catch(e) {
|
||||
} catch {
|
||||
if (window.showToast) window.showToast('Failed to create page', 'error');
|
||||
}
|
||||
},
|
||||
@@ -996,7 +996,7 @@ function libraryPage() {
|
||||
if (days < 7) return days + 'd ago';
|
||||
if (days < 30) return Math.floor(days/7) + 'w ago';
|
||||
return dt.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'});
|
||||
} catch(e) { return d; }
|
||||
} catch { return d; }
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1029,11 +1029,11 @@ function libraryPage() {
|
||||
if (el && el.__x) { el.__x.$data.peekItem = null; }
|
||||
}
|
||||
dragging = false;
|
||||
try { localStorage.setItem('fd_peek_width', panel.style.width); } catch(ex) {}
|
||||
try { localStorage.setItem('fd_peek_width', panel.style.width); } catch { /* volontaire */ }
|
||||
handle.releasePointerCapture(e.pointerId);
|
||||
});
|
||||
try {
|
||||
var saved = localStorage.getItem('fd_peek_width');
|
||||
if (saved) panel.style.width = saved;
|
||||
} catch(ex) {}
|
||||
} catch { /* volontaire */ }
|
||||
})();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,532 @@
|
||||
const RT=(()=>{try{const el=document.getElementById('rt-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
/* eslint-disable */
|
||||
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
|
||||
window.__fdRT = (function () {
|
||||
const SELF = {
|
||||
id: RT.id,
|
||||
login: RT.login,
|
||||
full_name: RT.full_name,
|
||||
color: RT.color,
|
||||
};
|
||||
const COLORS = [
|
||||
"#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
|
||||
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333",
|
||||
];
|
||||
let E = null; // editorState (window.E)
|
||||
let ws = null;
|
||||
let mode = 'off'; // 'ws' | 'poll'
|
||||
let open = false;
|
||||
let base = []; // dernier snapshot serveur des blocs
|
||||
let version = 0;
|
||||
let pendingOps = 0;
|
||||
let needSync = false;
|
||||
let peers = []; // liste des présents (hors moi)
|
||||
let remoteCursors = {}; // userId -> {peer, block, offset}
|
||||
let myCursor = null; // {block, offset}
|
||||
let emitT = null, selT = null, titleT = null, drawT = null, retryT = null, pollT = null;
|
||||
let _pid = 0;
|
||||
|
||||
// Detach global listeners from a previous editor instance so that
|
||||
// partial (HTMX) navigation doesn't accumulate stale handlers.
|
||||
function unbindGlobal() {
|
||||
const g = window.__fdRTGlobal;
|
||||
if (!g) return;
|
||||
try {
|
||||
document.removeEventListener('selectionchange', g.onSel, true);
|
||||
window.removeEventListener('scroll', g.onScroll, true);
|
||||
window.removeEventListener('resize', g.onResize);
|
||||
} catch (e) { /* noop */ }
|
||||
window.__fdRTGlobal = null;
|
||||
}
|
||||
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
function colorOf(uid) { return COLORS[Math.abs(uid || 0) % COLORS.length]; }
|
||||
|
||||
function initials(p) {
|
||||
const n = (p && (p.full_name || p.login)) || '';
|
||||
const parts = String(n).trim().split(/\s+/);
|
||||
if (!parts[0]) return '?';
|
||||
return ((parts[0][0] || '') + (parts.length > 1 ? (parts[1][0] || '') : '')).toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
function send(obj) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(JSON.stringify(obj)); } catch (e) { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ops miroir du serveur (apply_op/merge) ── */
|
||||
function applyOpJS(blocks, op) {
|
||||
const t = op && op.type;
|
||||
if (t === 'insert') {
|
||||
const blk = op.block || {};
|
||||
const id = blk.id || ('rb' + Date.now().toString(36));
|
||||
blk.id = id;
|
||||
if (typeof ensureBlockIds === 'function') ensureBlockIds([blk]);
|
||||
let idx = op.index != null ? op.index : blocks.length;
|
||||
idx = Math.max(0, Math.min(idx, blocks.length));
|
||||
return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx));
|
||||
}
|
||||
if (t === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (!nb.id) return blocks;
|
||||
return blocks.map(b => (b.id === nb.id ? nb : b));
|
||||
}
|
||||
if (t === 'delete') {
|
||||
const bid = op.id;
|
||||
return blocks.filter(b => b.id !== bid);
|
||||
}
|
||||
if (t === 'move') {
|
||||
const bid = op.id, idx = op.index || 0;
|
||||
const out = blocks.filter(b => b.id !== bid);
|
||||
const moved = blocks.find(b => b.id === bid);
|
||||
if (!moved) return blocks;
|
||||
const i2 = Math.max(0, Math.min(idx, out.length));
|
||||
out.splice(i2, 0, moved);
|
||||
return out;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/* Diff base → courants : update/delete/move/insert (ordre pour le serveur). */
|
||||
function diffOps(cur) {
|
||||
if (!base.length && !cur.length) return [];
|
||||
const ops = [];
|
||||
const baseById = {}, curById = {};
|
||||
base.forEach(b => { baseById[b.id] = b; });
|
||||
cur.forEach(b => { curById[b.id] = b; });
|
||||
|
||||
cur.forEach(b => {
|
||||
if (baseById[b.id] !== undefined && JSON.stringify(baseById[b.id]) !== JSON.stringify(b)) {
|
||||
// v6.4.0 : on embarque la `base` dont dérive la saisie → le serveur
|
||||
// fait un merge 3-voix au lieu d'écraser le bloc (LWW).
|
||||
ops.push({ type: 'update', block: clone(b), base: clone(baseById[b.id]) });
|
||||
}
|
||||
});
|
||||
base.forEach(b => {
|
||||
if (curById[b.id] === undefined) ops.push({ type: 'delete', id: b.id });
|
||||
});
|
||||
|
||||
// structure : simule l'état serveur (base − supprimés) pour indices valides
|
||||
let sim = base.filter(b => curById[b.id] !== undefined).map(b => clone(b));
|
||||
const simById = {}; sim.forEach(b => { simById[b.id] = b; });
|
||||
const finalOrder = cur.map(b => b.id);
|
||||
let si = 0;
|
||||
finalOrder.forEach(id => {
|
||||
if (simById[id] !== undefined) {
|
||||
const curPos = sim.findIndex(b => b.id === id);
|
||||
if (curPos !== si) ops.push({ type: 'move', id, index: si });
|
||||
const [mv] = sim.splice(curPos, 1);
|
||||
sim.splice(si, 0, mv);
|
||||
si++;
|
||||
} else {
|
||||
ops.push({ type: 'insert', index: si, block: clone(curById[id]) });
|
||||
sim.splice(si, 0, curById[id]);
|
||||
simById[id] = curById[id];
|
||||
si++;
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/* ── rendu + présence ── */
|
||||
function activeBlockId() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
return a ? a.bid : null;
|
||||
}
|
||||
|
||||
function refocus(fid) {
|
||||
if (!fid) return;
|
||||
setTimeout(() => {
|
||||
const el = E.getEl(fid);
|
||||
if (el) { el.focus(); try { (typeof ce === 'function') && ce(el); } catch (e) { /* noop */ } }
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function renderPresence() {
|
||||
let host = document.querySelector('.topbar-right.header-actions');
|
||||
if (!host) return;
|
||||
let box = document.getElementById('rtPresence');
|
||||
if (!box) {
|
||||
box = document.createElement('span');
|
||||
box.id = 'rtPresence';
|
||||
box.className = 'rt-presence';
|
||||
host.insertBefore(box, host.firstChild);
|
||||
}
|
||||
const shown = peers.filter(p => p.id !== (SELF.id || 0));
|
||||
if (!shown.length) { box.style.display = 'none'; return; }
|
||||
box.style.display = 'inline-flex';
|
||||
box.innerHTML = '';
|
||||
shown.forEach(p => {
|
||||
const c = document.createElement('span');
|
||||
c.className = 'rt-avatar';
|
||||
c.title = (p.full_name || p.login) + ' est en train d\u2019éditer' + (mode === 'poll' ? ' (polling)' : '');
|
||||
c.textContent = initials(p);
|
||||
c.style.background = p.color || colorOf(p.id);
|
||||
box.appendChild(c);
|
||||
});
|
||||
if (mode === 'poll') {
|
||||
let off = document.getElementById('rtOffline');
|
||||
if (!off) {
|
||||
off = document.createElement('span');
|
||||
off.id = 'rtOffline';
|
||||
off.className = 'rt-offline';
|
||||
off.textContent = '●';
|
||||
off.title = 'Realtime indisponible — rafraîchissement toutes les 10 s';
|
||||
host.insertBefore(off, box.nextSibling || null);
|
||||
}
|
||||
off.style.display = 'inline';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── curseurs ── */
|
||||
function rangeFromOffset(el, offset) {
|
||||
try {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let n = walker.nextNode(), count = 0;
|
||||
while (n) {
|
||||
const len = (n.nodeValue || '').length;
|
||||
if (count + len >= offset) {
|
||||
const r = document.createRange();
|
||||
r.setStart(n, Math.min(offset - count, len));
|
||||
r.collapse(true);
|
||||
return r;
|
||||
}
|
||||
count += len;
|
||||
n = walker.nextNode();
|
||||
}
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(el);
|
||||
r.collapse(false);
|
||||
return r;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function drawCursors() {
|
||||
const layer = document.getElementById('rtCursors');
|
||||
if (!layer) return;
|
||||
layer.innerHTML = '';
|
||||
const ids = Object.keys(remoteCursors);
|
||||
if (!ids.length) return;
|
||||
ids.forEach(uid => {
|
||||
const c = remoteCursors[uid];
|
||||
if (!c || !c.block) return;
|
||||
const el = E.getEl(c.block);
|
||||
if (!el) return;
|
||||
const r = rangeFromOffset(el, c.offset || 0);
|
||||
let x, y, h;
|
||||
if (r) {
|
||||
const rc = r.getBoundingClientRect();
|
||||
if (!rc.width && !rc.height) return; // hors viewport positionné
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
} else {
|
||||
const rc = el.getBoundingClientRect();
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
}
|
||||
const m = document.createElement('div');
|
||||
m.className = 'rt-cursor';
|
||||
m.style.left = (x - 1) + 'px';
|
||||
m.style.top = (y - 1) + 'px';
|
||||
m.style.height = h + 'px';
|
||||
m.style.background = c.peer.color || colorOf(c.peer.id);
|
||||
const nm = document.createElement('span');
|
||||
nm.className = 'rt-cursor-name';
|
||||
nm.textContent = initials(c.peer);
|
||||
nm.style.background = m.style.background;
|
||||
m.appendChild(nm);
|
||||
layer.appendChild(m);
|
||||
});
|
||||
}
|
||||
|
||||
function emitCursor() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
let block = null, offset = 0;
|
||||
if (a && a.el && a.bid) {
|
||||
block = a.bid;
|
||||
try { offset = (typeof cp === 'function') ? cp(a.el) : 0; } catch (e) { offset = 0; }
|
||||
}
|
||||
const changed = !myCursor || myCursor.block !== block || myCursor.offset !== offset;
|
||||
myCursor = { block, offset };
|
||||
if (!changed) return;
|
||||
send({ t: 'sel', block, offset });
|
||||
}
|
||||
|
||||
function scheduleDraw() {
|
||||
clearTimeout(drawT);
|
||||
drawT = setTimeout(drawCursors, 40);
|
||||
}
|
||||
|
||||
/* ── application des changements distants ── */
|
||||
function applySync(blocks, title) {
|
||||
if (!E || !E.blocks) return;
|
||||
// v5.13.1: guarantee ids before comparing/merging. Server rooms may still
|
||||
// carry legacy id-less blocks; without this they collide on
|
||||
// data-bid="undefined" and the merge below duplicated every line.
|
||||
blocks = (blocks || []).slice();
|
||||
if (typeof ensureBlockIds === 'function') ensureBlockIds(blocks);
|
||||
const curIds = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srvIds = JSON.stringify(blocks.map(b => b.id));
|
||||
if (curIds === srvIds) {
|
||||
base = clone(E.blocks);
|
||||
E.dirty = false;
|
||||
return;
|
||||
}
|
||||
const fid = activeBlockId();
|
||||
const curById = {};
|
||||
(E.blocks || []).forEach(b => { curById[b.id] = b; });
|
||||
let out;
|
||||
try {
|
||||
if (!blocks.length) {
|
||||
// serveur vide : ne pas effacer le contenu local (page neuve).
|
||||
out = (E.blocks || []).slice();
|
||||
} else {
|
||||
out = blocks.map(b => {
|
||||
const cb = curById[b.id];
|
||||
if (cb && b.id === fid) return cb; // garde la frappe locale en cours
|
||||
return b;
|
||||
});
|
||||
}
|
||||
} catch (e) { return; }
|
||||
E.blocks = out;
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (title && title !== E.pageTitle && document.activeElement !== tEl) {
|
||||
tEl.textContent = title;
|
||||
E.pageTitle = title;
|
||||
}
|
||||
E.dirty = true;
|
||||
base = clone(E.blocks);
|
||||
E.render();
|
||||
refocus(fid);
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
function applyRemoteOp(op) {
|
||||
const fid = activeBlockId();
|
||||
if (op.type === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (nb.id === fid) {
|
||||
// bloc en cours d'édition : on garde la valeur locale, le serveur a déjà
|
||||
// l'op ; la prochaine frappe locale repartira (LWW).
|
||||
base = applyOpJS(base, op);
|
||||
return;
|
||||
}
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
} else {
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
}
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
/* ── diffusion des modifications locales ── */
|
||||
function emit() {
|
||||
if (mode !== 'ws' || !open || !E || !E.blocks) return;
|
||||
if (needSync) { send({ t: 'sync_req' }); return; }
|
||||
const cur = E.blocks.filter(b => b && b.id);
|
||||
const ops = diffOps(cur);
|
||||
if (!ops.length) return;
|
||||
ops.forEach(op => { send({ t: 'op', op, v: version }); pendingOps++; });
|
||||
base = clone(cur);
|
||||
}
|
||||
|
||||
function onMsg(m) {
|
||||
if (!m || !m.t) return;
|
||||
if (m.t === 'sync') {
|
||||
version = m.version || 0;
|
||||
base = clone(m.blocks || []);
|
||||
needSync = false;
|
||||
pendingOps = 0;
|
||||
if (typeof m.blocks === 'undefined') return;
|
||||
applySync(m.blocks || [], m.title || '');
|
||||
} else if (m.t === 'ack') {
|
||||
version = m.v || 0;
|
||||
if (pendingOps > 0) pendingOps--;
|
||||
// v6.4.0 : le serveur renvoie le bloc fusionné (merge 3-voix). On
|
||||
// l'adopte comme nouvelle base ; s'il diffère de notre saisie locale
|
||||
// c'est qu'un autre utilisateur avait modifié le même bloc.
|
||||
if (m.merged) {
|
||||
const mid = m.merged.id;
|
||||
const localBlock = (E && E.blocks || []).find(b => b.id === mid);
|
||||
const differs = localBlock && JSON.stringify(localBlock) !== JSON.stringify(m.merged);
|
||||
base = applyOpJS(base, { type: 'update', block: m.merged });
|
||||
if (differs && E) {
|
||||
const fid = activeBlockId();
|
||||
if (fid !== mid) {
|
||||
E.blocks = applyOpJS(E.blocks, { type: 'update', block: m.merged });
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
}
|
||||
if (m.conflict && window.showToast) {
|
||||
window.showToast('Editing conflict merged on a block', 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m.stale || needSync) { needSync = true; send({ t: 'sync_req' }); }
|
||||
} else if (m.t === 'op') {
|
||||
if (m.v) version = m.v;
|
||||
if (m.from === (SELF.id || 0)) { base = applyOpJS(base, m.op); return; }
|
||||
applyRemoteOp(m.op);
|
||||
} else if (m.t === 'title') {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (m.from !== (SELF.id || 0) && tEl && document.activeElement !== tEl && E) {
|
||||
tEl.textContent = m.title || '';
|
||||
E.pageTitle = m.title || '';
|
||||
}
|
||||
if (m.v) version = m.v;
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'sel') {
|
||||
if (m.from === (SELF.id || 0)) return;
|
||||
if (!m.block) { delete remoteCursors[m.from]; scheduleDraw(); return; }
|
||||
remoteCursors[m.from] = { peer: m.peer || { id: m.from }, block: m.block, offset: m.offset || 0 };
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'welcome') {
|
||||
peers = (m.peers || []).filter(p => p.id !== (SELF.id || 0));
|
||||
renderPresence();
|
||||
} else if (m.t === 'peer_join') {
|
||||
if (m.peer && m.peer.id !== (SELF.id || 0)) {
|
||||
peers = peers.filter(p => p.id !== m.peer.id).concat([m.peer]);
|
||||
renderPresence();
|
||||
}
|
||||
} else if (m.t === 'peer_leave') {
|
||||
peers = peers.filter(p => p.id !== m.id);
|
||||
delete remoteCursors[m.id];
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'synced_update') {
|
||||
// A synced block was updated — re-sync the whole page
|
||||
if (m.synced_id) {
|
||||
needSync = true;
|
||||
send({ t: 'sync_req' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── connexion WS + fallback polling ── */
|
||||
function startPolling() {
|
||||
if (pollT) return;
|
||||
mode = 'poll';
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
pollT = setInterval(poll, 10000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollT) { clearInterval(pollT); pollT = null; }
|
||||
const off = document.getElementById('rtOffline');
|
||||
if (off) off.style.display = 'none';
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (!E || !_pid) return;
|
||||
try {
|
||||
const r = await fetch('/board/api/pages/' + _pid, { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
if ((d.content_format || 'blocks') !== 'blocks' || !d.content) return;
|
||||
const serverBlocks = JSON.parse(d.content);
|
||||
// en cas de modifs locales non persistées : on garde local (LWW au prochain save)
|
||||
if (E.dirty) return;
|
||||
const cur = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srv = JSON.stringify(serverBlocks.map(b => b.id));
|
||||
if (cur === srv) return;
|
||||
version = version; // pas de version via polling — on adopte l'état serveur
|
||||
applySync(serverBlocks, d.title || E.pageTitle);
|
||||
} catch (e) { /* noop */ }
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (!E || !_pid || ws) return;
|
||||
const proto = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
try {
|
||||
ws = new WebSocket(proto + window.location.host + '/ws/pages/' + _pid);
|
||||
} catch (e) {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => {
|
||||
open = true;
|
||||
mode = 'ws';
|
||||
stopPolling();
|
||||
send({ t: 'hello' });
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let m;
|
||||
try { m = JSON.parse(ev.data); } catch (e) { return; }
|
||||
onMsg(m);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
open = false;
|
||||
ws = null;
|
||||
if (retryT) clearTimeout(retryT);
|
||||
retryT = setTimeout(connect, 15000);
|
||||
startPolling();
|
||||
};
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) { /* noop */ } };
|
||||
setTimeout(() => {
|
||||
if (!open) { try { ws && ws.close(); } catch (e) { /* noop */ } }
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function titleInput() {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (!tEl) return;
|
||||
clearTimeout(titleT);
|
||||
titleT = setTimeout(() => {
|
||||
send({ t: 'title', title: (tEl.textContent || '').trim() });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const ct = document.getElementById('_blocksCt');
|
||||
if (ct) {
|
||||
ct.addEventListener('input', () => {
|
||||
clearTimeout(emitT);
|
||||
emitT = setTimeout(emit, 350);
|
||||
setTimeout(emitCursor, 80);
|
||||
}, true);
|
||||
ct.addEventListener('keyup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('click', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('mouseup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
}
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (tEl) tEl.addEventListener('input', titleInput);
|
||||
unbindGlobal();
|
||||
const onSel = () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); };
|
||||
const onScroll = () => scheduleDraw();
|
||||
const onResize = () => scheduleDraw();
|
||||
document.addEventListener('selectionchange', onSel, true);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onResize);
|
||||
window.__fdRTGlobal = { onSel, onScroll, onResize };
|
||||
}
|
||||
|
||||
function start(ed) {
|
||||
if (!ed) return;
|
||||
if ((ed.contentFormat || 'blocks') !== 'blocks') return;
|
||||
E = ed;
|
||||
_pid = ed.pid || 0;
|
||||
if (!_pid) return;
|
||||
base = clone(ed.blocks || []);
|
||||
wire();
|
||||
connect();
|
||||
}
|
||||
|
||||
// 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 };
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
/* exported workspacesPage -- appeles depuis les attributs HTML des templates */
|
||||
|
||||
function workspacesPage() {
|
||||
return {
|
||||
@@ -105,7 +106,7 @@ function workspacesPage() {
|
||||
myLogin = meData.user?.login || 'Me';
|
||||
if (myLogin.includes('_')) myLogin = myLogin.split('_').pop(); // strip gitea_ prefix if present
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
|
||||
// User repos (no org filter)
|
||||
try {
|
||||
@@ -117,7 +118,7 @@ function workspacesPage() {
|
||||
groups.push({org: myLogin, avatar: '', projects: repos.map(r => ({...r, owner: r.owner || {login: myLogin}}))});
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
|
||||
// Org repos
|
||||
for (const org of orgs) {
|
||||
@@ -129,13 +130,13 @@ function workspacesPage() {
|
||||
groups.push({org: org.username || org.login || org.name, avatar: org.avatar_url || '', projects: d.projects});
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch { /* volontaire */ }
|
||||
}
|
||||
|
||||
this.giteaGroups = groups;
|
||||
this.giteaTotal = groups.reduce((sum, g) => sum + g.projects.length, 0);
|
||||
this.giteaStatus = 'ok';
|
||||
} catch(e) {
|
||||
} catch {
|
||||
this.giteaStatus = 'error';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -294,7 +294,7 @@ def test_editor_has_autocomplete(client):
|
||||
|
||||
|
||||
def test_database_has_ai_properties(client):
|
||||
src = _read_template("_database_table_scripts.html")
|
||||
src = _read_js("database_table.js")
|
||||
assert "db-ai-fill-btn" in src
|
||||
assert "aiFillRow" in src
|
||||
assert "/api/agent/writing/properties" in src
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import secrets
|
||||
import tempfile
|
||||
|
||||
@@ -273,8 +274,10 @@ def test_collection_page_renders_multi_view(client):
|
||||
resp = client.get(f"/pages/{page}", cookies={"flowdeck_session": cookie})
|
||||
assert resp.status_code == 200
|
||||
assert "db-view-bar" in resp.text
|
||||
assert "FlowDeckDB" in resp.text
|
||||
# New view types + settings are available client-side.
|
||||
assert "db-board" in resp.text
|
||||
assert "db-cal-grid" in resp.text
|
||||
assert "db-gallery" in resp.text
|
||||
# (A27 : le JS de la DB est extrait dans static/js/database_table.js.)
|
||||
src = pathlib.Path("static/js/database_table.js").read_text(encoding="utf-8")
|
||||
assert "FlowDeckDB" in src
|
||||
assert "db-board" in src
|
||||
assert "db-cal-grid" in src
|
||||
assert "db-gallery" in src
|
||||
|
||||
@@ -417,7 +417,7 @@ def test_notification_prefs_extended_keys(client):
|
||||
|
||||
def test_calendar_front_end_wired(client):
|
||||
import pathlib
|
||||
src = pathlib.Path("app/templates/_database_table_scripts.html").read_text(encoding="utf-8")
|
||||
src = pathlib.Path("static/js/database_table.js").read_text(encoding="utf-8")
|
||||
assert "calendar/api" in src
|
||||
assert "data-cal-mode" in src and "refreshCalendar" in src
|
||||
assert "openEventEditor" in src and "__recurrence__" in src and "__reminder__" in src
|
||||
|
||||
Reference in New Issue
Block a user