Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d7af3fb64 | ||
|
|
b0af1bbfb6 | ||
|
|
de751ffe35 | ||
|
|
48b5551b93 | ||
|
|
e4552c3763 | ||
|
|
6914780f24 | ||
|
|
d7d9966edf | ||
|
|
3cab76fed5 | ||
|
|
6ff88237fc | ||
|
|
840d2b2615 | ||
|
|
ab6ac1e84c | ||
|
|
3a74ea8bbd |
@@ -13,7 +13,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.13'
|
||||
- name: Install lint tools
|
||||
run: pip install -r requirements-dev.txt
|
||||
- name: Ruff (Python)
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.13'
|
||||
- name: Install system dependencies (WeasyPrint / emoji fonts)
|
||||
run: |-
|
||||
SUDO=""
|
||||
|
||||
+350
@@ -1,5 +1,355 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.45.0 (2026-10-01) — Éditeur visuel d'automations + correction CSP (multi-instructions)
|
||||
|
||||
### Added
|
||||
|
||||
- **Pipeline visuel (steps) dans Settings → Automations** : cartes
|
||||
ordonnées par étape avec badge + résumé (`📡 Déclencheur · event`,
|
||||
`⚖ Condition · prop op val`, `⏳ Attente · Xs`, `⚡ Action · type → …`),
|
||||
**éditeur typé par kind/type** (événements en datalist, 7 ops de
|
||||
condition, 8 types d'action avec leurs champs réels : url, property/value,
|
||||
collection/title, message, webhook_url/text, to/subject/body,
|
||||
owner/repo/labels, agent_id/message), ajout/édition/suppression/**↑↓** via
|
||||
`POST/PUT/DELETE /workspace/automations[/steps]/…`, conversion legacy
|
||||
**✨ Convertir le JSON en pipeline** (trigger + conditions + actions
|
||||
ordonnés) ; les textareas JSON disparaissent dès qu'un step existe.
|
||||
- **Gate E2E** « éditeur visuel de steps » : création → édition → ajout
|
||||
d'étape → carte `Action · webhook` visible (sous CSP réel).
|
||||
|
||||
### Fixed (trouvé par le gate)
|
||||
|
||||
- **51 expressions Alpine multi-instructions** (`activeSection='x';
|
||||
loadX()` etc.) = **interdites par le parseur CSP** (une seule expression
|
||||
par directive ; `;` = token inattendu) — invisible pour le scan par
|
||||
tokens ! Conversion en **méthodes** : settings nav ×8 (`navTo`), menu de
|
||||
section base ×7 (`closeAndSetCount/…`), parts/éditeur ×13
|
||||
(`setSharePerm`, `more*`, `markAndSave`…), breadcrumb ×5
|
||||
(`hoverEllipsis/goClose`), library/local ×6 (menus), board ×3
|
||||
(`pickStatus/…`), ctx-menu ×2 (`addTagAndClear`), agent/card/gitea/
|
||||
workspaces ×6. Scanner dédié `scan_semi` (inventaire `;` hors chaînes).
|
||||
|
||||
### Notes
|
||||
|
||||
- 39 templates Jinja parse OK, scan d'expressions = 0 incompatibilité
|
||||
(4 faux positifs en chaînes), E2E **8/8**, suite **1094/1094**.
|
||||
|
||||
## v7.44.0 (2026-10-01) — Palette Ctrl+K : onglets Pages / ✨ Réponses IA
|
||||
|
||||
### Added
|
||||
|
||||
- **Palette `Ctrl+K` = 2 onglets** (markup + CSS + wiring dans l'IIFE) :
|
||||
· **Pages** — comportement inchangé (recherche `/api/search` + actions).
|
||||
· **✨ Réponses IA** — `POST /api/v2/search/ask` (debounce 150 ms,
|
||||
CSRF via `getCsrf()`, gardes staleness onglet+requête) → rendu de
|
||||
`answer_markdown` : **échappement AVANT injection**, `[[fdpage:ID]]` →
|
||||
lien citation (ids = chiffres, type = `[a-z]+` — pas d'injection),
|
||||
`**gras**`, bloc « Sources » avec liens `/pages/{id}` · `/db/{id}`.
|
||||
États : hint / chargement / erreur.
|
||||
- **Gate E2E étendu** (`gate A20 palette`) : ouverture → clic onglet IA →
|
||||
réponse non-échaffée affichée (le backend tourne en extractif ou LLM —
|
||||
probe : 200 en 1,6 s, 8 citations).
|
||||
- **Reporté (backend absent)** : onglet `Fichiers` — `/api/search` ne
|
||||
renvoie que `pages`/`collections` → endpoint à créer d'abord (ROADMAP).
|
||||
|
||||
### Notes
|
||||
|
||||
- Un 401 transitoire sur le 1er ask d'un run E2E a été observé (session
|
||||
fraîche) — non reproductible ensuite ; à surveiller si ça revient.
|
||||
|
||||
## v7.43.0 (2026-10-01) — 🎉 A20 TERMINÉ : Alpine en build CSP, `unsafe-eval` retiré
|
||||
|
||||
### Changed (la bascule A20 phase 3)
|
||||
|
||||
- **`static/js/alpine.csp.min.js`** (build officiel `@alpinejs/csp`,
|
||||
0 `eval`/`new Function`, parseur d'expressions maison) servi partout :
|
||||
`base.html`, `import.html`, `welcome.html` + entrée `sw.js` (cache bump
|
||||
v8).
|
||||
- **CSP** : `script-src 'self' 'nonce-…'` — **`unsafe-eval` supprimé** (il
|
||||
ne servait plus qu'Alpine standard). htmx : `allowEval: false` déjà posé
|
||||
(v7.37).
|
||||
- Assertion test inversée : `assert "'unsafe-eval'" not in script_src`.
|
||||
- Scan statique final sur **tous** les templates : 0 expression incompatible
|
||||
(4 résidus = faux positifs dans des chaînes de texte).
|
||||
|
||||
### Notes
|
||||
|
||||
- 12 surfaces migrées + gateées en amont (lots 1-3a/3b) ; **E2E 7/7 sous
|
||||
CSP réel** (le route-swap du `csp_preview` sert désormais le même
|
||||
fichier — les gates restent utiles contre une régression du build).
|
||||
- board/table_view/teamload/card_detail : scan propre mais non gateés
|
||||
(gitea down) → à vérifier au premier usage avec gitea remonté.
|
||||
- Risque résiduel assumé : expressions n'ayant jamais tourné en runtime
|
||||
sur ces 4 surfaces (les gates + le filet 0-erreur les attraperont).
|
||||
|
||||
## v7.42.0 (2026-10-01) — 🐛 BUG TOPBAR CORRIGÉ (boutons du header en texte brut)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Les `right_actions` du topbar étaient servis ÉCHAPPÉS sur TOUTES les
|
||||
pages** (entities `"`/`<` → boutons Share/Star/Settings/Login en
|
||||
texte brut). **Cause racine** : `{% set right_actions = '…' ~ fd_icon(…) ~ '…' %}`
|
||||
— `fd_icon` est une **macro → `Markup`**, et `Markup.__radd__/__add__`
|
||||
**échappe ses arguments `str`** → tous les segments littéraux sortent
|
||||
entité-és ; le `|safe` de `_header:141` est then no-op sur un Markup déjà
|
||||
échappé. Découvert en cherchant l'échec du gate éditeur CSP (v7.41.0),
|
||||
reproductible partout (`curl /workspaces`).
|
||||
- **Fix racine (5 templates)** : conversion en **block-set**
|
||||
`{% set right_actions %}…{{ fd_icon(…) }}…{% endset %}` (source = brute,
|
||||
interpolation = Markup brut — la forme idiomatique Jinja) :
|
||||
`gitea_workspace`, `page_editor`, `page_editor_collection`, `workspace`,
|
||||
`workspaces`. Piège du script : regex greedy multi-lignes = set avalé →
|
||||
matcher **une ligne**.
|
||||
- **Test permanent** `tests/test_topbar_right_actions.py` : `/workspaces`
|
||||
doit contenir `class="topbar-btn"` parsé et ZÉRIE entité `"`.
|
||||
- Gate éditeur : l'assertion `.star-btn` **ré-ajoutée** (les boutons
|
||||
rendent à nouveau).
|
||||
|
||||
### Notes
|
||||
|
||||
- Probable régression depuis A10 (activation d'autoescape) : les `~`
|
||||
étaient des no-op avant, Markup.__radd__ échappait déjà… les
|
||||
`|safe` devenaient nécessaires et ne pouvaient plus réparer.
|
||||
|
||||
## v7.41.0 (2026-10-01) — A20 phase 3 LOT 3b : gitea + agent + éditeur verts
|
||||
|
||||
### Changed
|
||||
|
||||
- **gitea_workspace** : `x-data="giteaWorkspace"` → appel `giteaWorkspace()`,
|
||||
`new Date(…)` → `fmtGwDate(pp)`, x-html icône d'arbre → `bindGwIcon`
|
||||
(x-init + `Alpine.effect`).
|
||||
- **agent_panel** : x-html markdown → `bindMarkdown($el, m)` (effet réactif
|
||||
sur `m.content`).
|
||||
- **page_editor (les 12 sites `window.E` du topbar `right_actions`)** →
|
||||
délégués `appState` : `edCall('…')` (6 appels, arg littéral = seule forme
|
||||
parsable), `edTimeAgo`, `edCommentCount`, `edShared`, `bindStar` (les 2
|
||||
branches du ternaire favorited étaient identiques → rendu 1×) — les deux
|
||||
templates `page_editor.html` + `page_editor_collection.html` + garde
|
||||
Jinja : les quotes insérées doivent être `\'` (le `set` est délimité par
|
||||
`'`, une quote nue casse le template = 500).
|
||||
- **_page_editor_content** : +3 sites (`window.E.commentOnSelection` →
|
||||
`edCall`, `backlinksOpen…location.href` → `openBacklink(b)`,
|
||||
`Math.round(importFile…)` → `fmtImportSize(f)`) + x-html icône de page →
|
||||
`bindIconHtml` (effet sur `iconHtml()`).
|
||||
- **Gate éditeur** (`csp_preview`) : création collection → `/pages/{id}`,
|
||||
délégués `appState` liés + `editorState` lié + filet 0-erreur.
|
||||
|
||||
### Fixed
|
||||
|
||||
- x-html `iconHtml()` du contenu éditeur : directive **interdite** sous
|
||||
build CSP (le filet l'a attrapé) → `x-init` + `Alpine.effect`.
|
||||
|
||||
### ⚠️ Nouveau bug pré-existant identifié (PAS introdui par ce lot)
|
||||
|
||||
- **Les `right_actions` du topbar sont servi ÉCHAPPÉS sur TOUTES les
|
||||
pages** (entities `"`/`<` — les boutons Share/Star/Settings/…
|
||||
s'affichent en texte brut). Reproductible : `curl /workspaces` →
|
||||
`"topbar-btn"`. Le `{{ right_actions|safe … }}` de `_header:141`
|
||||
EST présent, l'ENV Jinja est standard, un rendu local du même motif sort
|
||||
PARSED — la cause exacte côté serveur reste à cerner (piste : valeur déjà
|
||||
échappée à la construction). Roadmap = suivi dédié ; le gate éditeur
|
||||
n'asserte donc pas la présence des boutons.
|
||||
|
||||
## v7.40.0 (2026-10-01) — A20 phase 3 LOT 3a : 5 surfaces de plus vertes
|
||||
|
||||
### Added
|
||||
|
||||
- **Gate `csp_preview` « surfaces simples »** : `/welcome`, `/trash`,
|
||||
`/accounts`, `/workspace`, `/import` — **0 modification de code
|
||||
nécessaire** sur les 4 premières (scan statique 0 expression/x-html +
|
||||
registres Alpine.data posés au lot 1). **8 surfaces couvertes** au total
|
||||
(base shell, library, settings, local workspace + celles-ci).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bug pré-existant sur `/import` (visible sous les DEUX builds)** :
|
||||
`x-text="'🔗 '+report.relations…"` évalué alors que `report = null`
|
||||
(le `x-show` parent ne masque pas, il initialise quand même) → pageerror
|
||||
« Cannot read property … 'relations' » — garde `report && report.relations`.
|
||||
(Le probe montrait aussi un 401 console pré-existant sur la page — hors
|
||||
périmètre, non touché.)
|
||||
|
||||
### Notes
|
||||
|
||||
- Reste ph3 : page_editor (12 sites `window.E` dans `right_actions`),
|
||||
gitea_workspace (`new Date`), agent_panel (site `x-text` markdown +
|
||||
1 x-html), board + table_view/teamload/card_detail (gabarits liés au
|
||||
contexte Gitea, scan statique propre) → bascule réelle ensuite.
|
||||
|
||||
## v7.39.0 (2026-10-01) — A20 phase 3 LOT 2 : settings + local workspace verts
|
||||
|
||||
### Added
|
||||
|
||||
- **2 gates `csp_preview` de plus** : `settings` (composant lié, overlay
|
||||
visible, 0 erreur) et `local workspace` (recherche focalisée via
|
||||
`Alpine.nextTick`, chips filtre en SVG via `bindSvg`, 0 erreur).
|
||||
**3 surfaces vertes** sous build CSP : library, settings, local workspace.
|
||||
|
||||
### Changed
|
||||
|
||||
- **settings** : `window.history.back()`/`new Date(…)` → méthodes
|
||||
`historyBack`/`fmtLastLogin`/`fmtAuditDate` ; `?.` → ternaires.
|
||||
- **local workspace** : `x-data="_wsInitData"` → registre `wsInitData()`
|
||||
(le build CSP ne résout que le registre) ; 14 `x-html` → `x-init` +
|
||||
`Alpine.effect` (`bindSvg`/`bindFileIcon`/`bindNodeIcon`/`bindChildren`/
|
||||
`bindPreview`) ; `$nextTick`+`$refs` arrow → `toggleSearch()` ;
|
||||
`window.FlowDeck.*` → `createPageAt`/`createFolderAt` ; `?.` → ternaires ;
|
||||
`@contextmenu="_wsInitData.…"` → appel de méthode.
|
||||
- **Partage d'état JS↔Alpine (piège du build CSP)** : `ji` = snapshot des
|
||||
**valeurs** de toutes les propriétés `globalThis` au boot → l'objet mis
|
||||
sur `window` avant Alpine est **banni** (« Accessing global variables is
|
||||
prohibited »). Fix : objet porté par une **const lexicale** (non propriété
|
||||
`globalThis`) + factory Alpine.data qui le retourne → **même objet**
|
||||
partagé, réactivité intacte (une copie `Object.assign` aurait coupé les
|
||||
mises à jour JS : preview, uploads, isDragging).
|
||||
- **Bloc preview hors div racine** (structure pré-existante : le parseur
|
||||
referme la racine avant, masquée par le fallback window d'Alpine
|
||||
standard) → composant `wsPreview` **déléguant** vers `_wsInitData`
|
||||
(`Alpine.reactive` pour la réactivité ; wrapper = objet unique, les
|
||||
magics `$nextTick` ne sont redéfinissables qu'une fois — deuxième
|
||||
montage du même objet = « Cannot redefine property »).
|
||||
- **`.env` local : `RATE_LIMIT_REQUESTS=600`** — les rafales E2E
|
||||
Playwright (5 tests × ~25 requêtes) butaient sur le 60/min par IP ;
|
||||
défaut produit inchangé.
|
||||
|
||||
### Notes
|
||||
|
||||
- Reste ph3 : page_editor, board, agent_panel, import, gitea_workspace,
|
||||
welcome/accounts/trash/team_load/workspace/table_view/card_detail →
|
||||
puis bascule réelle (retrait `unsafe-eval`).
|
||||
|
||||
## v7.38.0 (2026-10-01) — A20 phase 3 LOT 1 : shell + library migres
|
||||
|
||||
### Added
|
||||
|
||||
- **`e2e/csp_preview.spec.js`** — aperçu CSP strict SANS déployer : le
|
||||
build `@alpinejs/csp` (fichier officiel, `e2e/fixtures/alpine.csp.js`)
|
||||
est servi **à la place** de `alpine.min.js` par interception Playwright ;
|
||||
tout échec du parseur maison = `pageerror` (filet). **Première surface
|
||||
verte : library** (composant lié, icônes SVG rendues via `Alpine.effect`,
|
||||
recherche ouverte + focalisée, 0 erreur console/page).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Composants `x-data="fn()"` → registre `Alpine.data(...)`** (15 +
|
||||
`appState` + `libraryPage`) : le build CSP ne résout que le registre
|
||||
(probe : globale window → `Undefined variable`) — scripts/classiques
|
||||
chargés pendant le parsing = `alpine:init` toujours joint à temps.
|
||||
- **base.html (shell) migré** : `x-effect document.*` → `syncSidebarClass()`,
|
||||
`$nextTick(arrow)` → `initSidebarSort()`, `window.FlowDeck.*` →
|
||||
`fdCreatePage/fdCreateFolder/fdGwRefresh`, `Object.keys`/`Math.min`/
|
||||
`window.innerWidth` dans `x-for`/`:style` → `sidebarSections()`/
|
||||
`sectionMenuPos()` — toutes les formes = simple appel de méthode.
|
||||
- **x-html restants du shell** → `x-init` + `Alpine.effect` :
|
||||
icône agent (`bindAgentIcon`), carte projet (`bindProjectIcon`),
|
||||
library ×3 (`bindHtmlIcon`/`bindHtmlItem`), recherche library
|
||||
(`toggleSearch` avec `Alpine.nextTick`), `openMoveSelected` (library).
|
||||
- **eslint : 70 warnings → 0/0** : `getCsrf` (helper A38 ph1) déclaré dans
|
||||
les globals du config, `/* exported openCardDetail */` +
|
||||
`/* global owner, repo */` (app.js), 3 `;;` résiduels de la conversion
|
||||
A38 supprimés.
|
||||
|
||||
### Notes
|
||||
|
||||
- Portes A20 ph3 : surfaces restantes = settings, local_workspace,
|
||||
gitea_workspace, page_editor, board (partiels), agent_panel, import,
|
||||
welcome, accounts, trash, team_load, workspace, table_view, card_detail ;
|
||||
**bascule réelle** (retrait `unsafe-eval`) = quand csp_preview est vert
|
||||
sur toutes les pages principales.
|
||||
|
||||
## v7.37.0 (2026-10-01) — A20 : htmx allowEval off + plan Alpine CSP (phase 3)
|
||||
|
||||
### Changed
|
||||
|
||||
- **htmx `allowEval: false`** dans le `<meta name="htmx-config">` : htmx
|
||||
ne peut plus évaluer de JS (`hx-on`/`hx-vars`/`hx-vals`) — grep = **0
|
||||
usage** dans les templates, donc zéro régression possible. `unsafe-eval`
|
||||
reste **uniquement** pour Alpine standard.
|
||||
- **Gate E20 renforcé** : le smoke vérifie désormais que `Alpine.$data()`
|
||||
lie un vrai composant `[x-data]` de la page (le lien composant = le cœur
|
||||
de tout basculement CSP).
|
||||
|
||||
### Notes — A20 phase 3 (unsafe-eval, scopé par probes)
|
||||
|
||||
Le build `@alpinejs/csp` a été **testé empiriquement** (fichier 72 Ko,
|
||||
**0 `eval`/`new Function`**, parseur d'expressions maison) : il tourne sous
|
||||
CSP strict, mais **bloqué sur FlowDeck** par deux familles d'usages :
|
||||
- **13 expressions non parsables** par la grammaire restreinte :
|
||||
arrows (`$nextTick(() => …)` ×2), `typeof` ×1, `new Date(…)` ×4,
|
||||
optional-chaining `?.` ×6 (base, library, local_workspace, settings,
|
||||
gitea_workspace) ;
|
||||
- **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`,
|
||||
markdown agent, preview) — **interdits par le build CSP** (innerHTML) :
|
||||
nécessitent de reposer les icônes sur des composants `Alpine.data`.
|
||||
- Portée : 0 variable globale/`document` accessible dans les expressions
|
||||
du build CSP (scope = données du composant + magics) → chaque site
|
||||
devient une méthode de composant enregistrée via `Alpine.data`.
|
||||
**Plan phase 3** : migrer composant par composant (library → settings →
|
||||
local_workspace → gitea → base) avec gate E2E dédiée, puis retirer
|
||||
`unsafe-eval`. En attendant : `unsafe-eval` conservé (Alpine standard).
|
||||
|
||||
## v7.36.0 (2026-10-01) — Fondations E2E + 2 bugs trouvés au passage
|
||||
|
||||
### Added
|
||||
|
||||
- **`e2e/smoke.spec.js`** — 2 gates vert contre l'instance de test :
|
||||
· **gate A39** : bascule de vues d'une collection (clic onglet Calendar →
|
||||
`?view_type=calendar`, grille `.calendar` + `.cal-header` rendue,
|
||||
collection créée puis **supprimée** = répétable)
|
||||
· **gate A20** : palette Ctrl+K (ouverture Alpine `.open`, recherche GET
|
||||
rend `.cmd-palette-item`, fermeture Échap)
|
||||
· **filet console** : 0 erreur JS/CSP (les violations atterrissent ici ;
|
||||
le bruit `Failed to load resource` 401/403 est filtré)
|
||||
· **Service Workers bloqués** dans le smoke : `/sw.js` sert sa page
|
||||
« hors ligne » sur les navigations redirigées (`redirect:'manual'`) —
|
||||
bruit PWA hors sujet, `pwa_offline.spec.js` couvre le SW
|
||||
· bootstrap autonome : login OU création du compte e2e documenté,
|
||||
workspace si absent — lecture seule sur les données existantes
|
||||
- Commande : `cd e2e && node node_modules/@playwright/test/cli.js test smoke.spec.js`
|
||||
|
||||
### Fixed (trouvés par les gates)
|
||||
|
||||
- **Bascule de vues standalone jamais fonctionnelle** : les onglets
|
||||
émettaient `?view=…` mais la route lit `view_type` (FastAPI) → l'onglet
|
||||
restait sur Table quel que soit le clic (bug pré-existant, non introduit
|
||||
par A28). Onglets → `?view_type=` + assertion comportementale ajoutée à
|
||||
`test_all_view_tabs_present`
|
||||
- **Inter bloqué par la CSP depuis v7.27** : `app.css` importait encore
|
||||
Google Fonts (`@import` raté par le grep de v7.27) → violation
|
||||
`style-src` sur chaque page + police tombée en fallback. Inter
|
||||
**auto-hébergé** : 2 faces variables (100-900, latin + latin-ext) dans
|
||||
`static/fonts/`, `@import` supprimé
|
||||
|
||||
### Notes
|
||||
|
||||
- Suite complète : **1093/1093** · ruff OK · E2E **2/2**
|
||||
- Portes : A20 (unsafe-eval) attaquable avec ce filet ; A39 couvre la
|
||||
bascule collection (pas la bascule htmx board → décision « rien »
|
||||
maintenue) ; A38 twins reste conditionné à une couverture élargie
|
||||
|
||||
## v7.35.0 (2026-10-01) — Audit : A35 TERMINÉ (Python 3.13 aligné + rebuild)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Drift Python résolu** (dernier reliquat A35) : tout le projet est en
|
||||
**3.13** — `Dockerfile` (`python:3.13-slim` builder + runtime), CI Gitea
|
||||
(`python-version: '3.13'` ×2), `README.md` ×2, ruff
|
||||
`target-version = "py313"` (0 nouvelle remarque ruff). Restait 3.12 aux
|
||||
6 endroits alors que `uv.lock` = `requires-python >=3.13` et le venv =
|
||||
3.13.14. Zéro référence 3.12 résiduelle.
|
||||
|
||||
### Validation (le point que l'audit laissait « à faire par rebuild »)
|
||||
|
||||
- `docker build` **vert** sur `python:3.13-slim` (image `flowdeck:a35-py313`)
|
||||
- dans le conteneur : `python -V` = **3.13.16**, `import app.main` OK
|
||||
(version 7.35.0) → wheels `requirements.txt` construits et importables
|
||||
sur 3.13
|
||||
- Suite locale complète : **1093/1093** · ruff OK (target py313)
|
||||
|
||||
### Notes
|
||||
|
||||
- A35 = **TERMINÉ** (OpenAPI/README/titre faits en 7.3.9 + drift aujourd'hui)
|
||||
|
||||
## v7.34.0 (2026-10-01) — Audit : A38 phase 2 (0 doublon de fonction globale)
|
||||
|
||||
### Changed
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
# Stage 1 "builder": build Python wheels once.
|
||||
# Stage 2 "runtime": minimal image with WeasyPrint system libs.
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
FROM python:3.12-slim AS builder
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -11,7 +11,7 @@ COPY requirements.txt .
|
||||
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
|
||||
|
||||
# ── runtime stage ───────────────────────────────────────────
|
||||
FROM python:3.12-slim AS runtime
|
||||
FROM python:3.13-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -73,9 +73,9 @@ docker compose up -d
|
||||
| Couche | Techno |
|
||||
|--------|--------|
|
||||
| Frontend | Jinja2 + HTMX + Alpine.js + SortableJS + CSS |
|
||||
| Backend | Python 3.12 + FastAPI + httpx |
|
||||
| Backend | Python 3.13 + FastAPI + httpx |
|
||||
| BDD | SQLite (WAL, 21 tables) — `/data/flowdeck.db` |
|
||||
| Déploiement | Docker (python:3.12-slim), docker-compose |
|
||||
| Déploiement | Docker (python:3.13-slim), docker-compose |
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
+27
-5
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.34.0 (audit — A38 phase 2 : 0 doublon de fonction globale, garde-fou testé | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.45.0 (Éditeur visuel d'automations = pipeline steps CRUD + conversion JSON + **fix CSP : 51 expressions multi-instructions → méthodes** (30 fichiers-tpl)) | **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.34.0",
|
||||
version="7.45.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -71,15 +71,15 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
|
||||
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
|
||||
# détaché de script-src (sinon le nonce les désactiverait aussi).
|
||||
# `unsafe-eval` reste : Alpine (x-data en string) et htmx (hx-vars) en ont
|
||||
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
|
||||
# A20 TERMINÉ : `unsafe-eval` retiré — Alpine tourne en build CSP
|
||||
# (static/js/alpine.csp.min.js, 0 eval) ; htmx allowEval=false.
|
||||
# 15 surfaces en csp_preview vert + scan statique 0 (board/gitea/cards
|
||||
# = props propres, gitea down empêche un gate dédié).
|
||||
CSP_VALUE = (
|
||||
"default-src 'self'; "
|
||||
# A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
|
||||
# (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
|
||||
# `unsafe-eval` reste : Alpine (x-data) + htmx (hx-on/hx-vars) en
|
||||
# ont besoin → retrait avec le build CSP d'Alpine (reste d'A20).
|
||||
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}'; "
|
||||
"script-src 'self' 'nonce-{nonce}'; "
|
||||
"script-src-attr 'unsafe-inline'; "
|
||||
# ponytail: aucun @font-face Google (grep négatif) → les deux
|
||||
# hôtes fonts étaient morts, supprimés.
|
||||
|
||||
@@ -55,17 +55,17 @@ h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
|
||||
</style></head><body>
|
||||
<h1>{icon} {title}</h1>
|
||||
<div class="view-tabs">
|
||||
<a class="tab{' active' if view_type=='table' else ''}" href="?view=table">📊 Table</a>
|
||||
<a class="tab{' active' if view_type=='board' else ''}" href="?view=board">📋 Board</a>
|
||||
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view=calendar">📅 Calendar</a>
|
||||
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view=gallery">🖼️ Gallery</a>
|
||||
<a class="tab{' active' if view_type=='list' else ''}" href="?view=list">📝 List</a>
|
||||
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view=timeline">📈 Timeline</a>
|
||||
<a class="tab{' active' if view_type=='gantt' else ''}" href="?view=gantt">📊 Gantt</a>
|
||||
<a class="tab{' active' if view_type=='chart' else ''}" href="?view=chart">📉 Chart</a>
|
||||
<a class="tab{' active' if view_type=='form' else ''}" href="?view=form">📋 Form</a>
|
||||
<a class="tab{' active' if view_type=='map' else ''}" href="?view=map">🗺️ Map</a>
|
||||
<a class="tab{' active' if view_type=='feed' else ''}" href="?view=feed">📰 Feed</a>
|
||||
<a class="tab{' active' if view_type=='table' else ''}" href="?view_type=table">📊 Table</a>
|
||||
<a class="tab{' active' if view_type=='board' else ''}" href="?view_type=board">📋 Board</a>
|
||||
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view_type=calendar">📅 Calendar</a>
|
||||
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view_type=gallery">🖼️ Gallery</a>
|
||||
<a class="tab{' active' if view_type=='list' else ''}" href="?view_type=list">📝 List</a>
|
||||
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view_type=timeline">📈 Timeline</a>
|
||||
<a class="tab{' active' if view_type=='gantt' else ''}" href="?view_type=gantt">📊 Gantt</a>
|
||||
<a class="tab{' active' if view_type=='chart' else ''}" href="?view_type=chart">📉 Chart</a>
|
||||
<a class="tab{' active' if view_type=='form' else ''}" href="?view_type=form">📋 Form</a>
|
||||
<a class="tab{' active' if view_type=='map' else ''}" href="?view_type=map">🗺️ Map</a>
|
||||
<a class="tab{' active' if view_type=='feed' else ''}" href="?view_type=feed">📰 Feed</a>
|
||||
</div>
|
||||
{body}
|
||||
</body></html>"""
|
||||
@@ -130,9 +130,9 @@ def _render_calendar(view_type: str, collection: dict, pages: list[dict], config
|
||||
.cal-nav span{{font-size:16px;font-weight:600}}
|
||||
</style>
|
||||
<div class="cal-nav">
|
||||
<a href="?view=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
|
||||
<a href="?view_type=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
|
||||
<span>{first.strftime('%B %Y')}</span>
|
||||
<a href="?view=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
|
||||
<a href="?view_type=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
|
||||
</div>
|
||||
<div class="calendar">
|
||||
<div class="cal-header">Mon</div><div class="cal-header">Tue</div><div class="cal-header">Wed</div>
|
||||
|
||||
@@ -163,9 +163,9 @@
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;padding:0 4px;">
|
||||
<input class="ctx-input" x-ref="ctxTagInput" placeholder="Tag name…" @click.stop
|
||||
@keydown.enter.prevent="$store.fdCtx.addNewTag($event.target.value, $store.fdCtx.newTagColor); $event.target.value = ''"
|
||||
@keydown.enter.prevent="$store.fdCtx.addTagAndClear($event.target.value, $store.fdCtx.newTagColor, $event.target)"
|
||||
@keydown.escape.stop="$store.fdCtx.tagAdding = false">
|
||||
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.addNewTag($refs.ctxTagInput.value, $store.fdCtx.newTagColor); $refs.ctxTagInput.value = ''">Add</button>
|
||||
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.addTagAndClear($refs.ctxTagInput.value, $store.fdCtx.newTagColor, $refs.ctxTagInput)">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,14 +60,14 @@
|
||||
{# ── Collapsed "…" segment ── #}
|
||||
<template x-if="crumb.ellipsis">
|
||||
<span class="crumb-anchor"
|
||||
@mouseenter="cancelCloseTimer(); ellipsisOpen = true"
|
||||
@mouseenter="hoverEllipsis()"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<button type="button" class="breadcrumb-link crumb-ellipsis">…</button>
|
||||
<div class="fd-nav-menu" x-show="ellipsisOpen" x-cloak x-transition.opacity
|
||||
@mouseenter="cancelCloseTimer()"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<template x-for="h in crumb.hidden" :key="h.id">
|
||||
<div class="fd-nav-item" @click.stop="go(h.url); closeAll()">
|
||||
<div class="fd-nav-item" @click.stop="goClose(h.url)">
|
||||
<span class="fd-nav-ico" :title="h.icon || 'file'"></span>
|
||||
<span class="fd-nav-label" x-text="h.label"></span>
|
||||
</div>
|
||||
@@ -91,7 +91,7 @@
|
||||
{# ── Interactive crumb with navigation dropdown (Notion-style hover) ── #}
|
||||
<template x-if="!crumb.ellipsis && crumb.menu">
|
||||
<span class="crumb-anchor"
|
||||
@mouseenter="cancelCloseTimer(); openMenu(crumb.origIndex)"
|
||||
@mouseenter="hoverMenu(crumb.origIndex)"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<button type="button" class="breadcrumb-link"
|
||||
:class="{ 'breadcrumb-current': crumb.origIndex === crumbs.length - 1 }"
|
||||
@@ -110,7 +110,7 @@
|
||||
<template x-for="item in activeItems" :key="item.id">
|
||||
<div class="fd-nav-item"
|
||||
@mouseenter="openSub(item)"
|
||||
@click.stop="go(item.url); closeAll()">
|
||||
@click.stop="goClose(item.url)">
|
||||
<span class="fd-nav-ico" :title="item.icon || 'file'"></span>
|
||||
<span class="fd-nav-label" x-text="item.name"></span>
|
||||
<span class="fd-nav-arrow" x-show="item.has_children">›</span>
|
||||
@@ -118,7 +118,7 @@
|
||||
@mouseenter="cancelCloseTimer()"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<template x-for="s in subItems" :key="s.id">
|
||||
<div class="fd-nav-item" @click.stop="go(s.url); closeAll()">
|
||||
<div class="fd-nav-item" @click.stop="goClose(s.url)">
|
||||
<span class="fd-nav-ico" :title="s.icon || 'file'"></span>
|
||||
<span class="fd-nav-label" x-text="s.name"></span>
|
||||
<span class="fd-nav-arrow" x-show="s.has_children">›</span>
|
||||
@@ -152,6 +152,9 @@ document.addEventListener('alpine:init', function () {
|
||||
wsId: 0,
|
||||
openIdx: null,
|
||||
ellipsisOpen: false,
|
||||
hoverEllipsis(){ this.cancelCloseTimer(); this.ellipsisOpen = true; },
|
||||
hoverMenu(i){ this.cancelCloseTimer(); this.openMenu(i); },
|
||||
goClose(u){ this.go(u); this.closeAll(); },
|
||||
loading: false,
|
||||
cache: {},
|
||||
activeItems: [],
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
@keydown.enter.prevent="inviteEnter()"
|
||||
@keydown.down.prevent="inviteMove(1)"
|
||||
@keydown.up.prevent="inviteMove(-1)"
|
||||
@keydown.escape="inviteSuggestOpen = false; inviteUsers = []"
|
||||
@keydown.escape="closeInviteSuggest()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button class="sd-btn-primary" @click="shareInvite()">
|
||||
@@ -169,7 +169,7 @@
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'edit' }"
|
||||
@click="updateShare(a.id, 'edit'); a.permOpen=false"
|
||||
@click="setSharePerm(a, 'edit')"
|
||||
>
|
||||
<span>Can edit</span>
|
||||
<span class="sd-perm-desc"
|
||||
@@ -179,7 +179,7 @@
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'comment' }"
|
||||
@click="updateShare(a.id, 'comment'); a.permOpen=false"
|
||||
@click="setSharePerm(a, 'comment')"
|
||||
>
|
||||
<span>Can comment</span>
|
||||
<span class="sd-perm-desc"
|
||||
@@ -189,7 +189,7 @@
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'view' }"
|
||||
@click="updateShare(a.id, 'view'); a.permOpen=false"
|
||||
@click="setSharePerm(a, 'view')"
|
||||
>
|
||||
<span>Can view</span>
|
||||
<span class="sd-perm-desc"
|
||||
@@ -199,7 +199,7 @@
|
||||
<div class="sd-perm-sep"></div>
|
||||
<div
|
||||
class="sd-perm-option danger"
|
||||
@click="removeShare(a.id); a.permOpen=false"
|
||||
@click="removeShareAndClose(a)"
|
||||
>
|
||||
Remove
|
||||
</div>
|
||||
@@ -233,7 +233,7 @@
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: generalAccess === 'invited' }"
|
||||
@click="generalAccess='invited'; accessMenuOpen=false"
|
||||
@click="pickGeneralAccess('invited')"
|
||||
>
|
||||
<span>{{ fd_icon('lock',14) }} Only people invited</span>
|
||||
<span class="sd-perm-desc"
|
||||
@@ -243,7 +243,7 @@
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: generalAccess === 'anyone' }"
|
||||
@click="generalAccess='anyone'; accessMenuOpen=false"
|
||||
@click="pickGeneralAccess('anyone')"
|
||||
>
|
||||
<span>{{ fd_icon('globe',14) }} Anyone with the link</span>
|
||||
<span class="sd-perm-desc"
|
||||
@@ -312,7 +312,7 @@
|
||||
<template x-for="(ic, ici) in ['📄','📝','📋','📊','🗂️','📁','📂','🗒️','🗓️','📌','🔖','⭐','🚀','💡','🎯','🔑','📚','🧪','🌍','💰','📝','🧩','🎨','🔧','⚙️','🗃️','📦','🏷️','📍','🏠','👤']" :key="ici">
|
||||
<button type="button" class="sd-icon-btn" @click="setIcon(ic)" :class="{ active: iconValue === ic }" style="width:36px;height:36px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);font-size:18px;display:flex;align-items:center;justify-content:center;cursor:pointer;" x-text="ic"></button>
|
||||
</template>
|
||||
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="setIcon(iconValue);iconOpen=false;">
|
||||
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="applyIcon()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -498,16 +498,16 @@
|
||||
</div>
|
||||
<div class="more-menu-item" @click="movePage">↗ Move to</div>
|
||||
<div class="more-menu-sep"></div>
|
||||
<div class="more-menu-item" @click="moreOpen=false; toggleLock()">
|
||||
<div class="more-menu-item" @click="moreToggleLock()">
|
||||
<span x-text="isLocked ? '🔓 Unlock page' : '🔒 Lock page'"></span>
|
||||
</div>
|
||||
<div class="more-menu-item" @click="moreOpen=false; setPageOption('full_width', !fullWidth)">
|
||||
<div class="more-menu-item" @click="moreFullWidth()">
|
||||
↔ Full width <span x-show="fullWidth" style="margin-left:auto;font-size:11px">✓</span>
|
||||
</div>
|
||||
<div class="more-menu-item" @click="moreOpen=false; setPageOption('font_small', !fontSmall)">
|
||||
<div class="more-menu-item" @click="moreFontSmall()">
|
||||
Aa Small text <span x-show="fontSmall" style="margin-left:auto;font-size:11px">✓</span>
|
||||
</div>
|
||||
<div class="more-menu-item" @click="moreOpen=false; saveAsTemplate()">
|
||||
<div class="more-menu-item" @click="moreSaveTemplate()">
|
||||
📑 Save as template
|
||||
</div>
|
||||
<div class="more-menu-sep"></div>
|
||||
@@ -546,13 +546,13 @@
|
||||
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.6) 100%);z-index:1;"></div>
|
||||
</div>
|
||||
<div class="page-title-block">
|
||||
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-html="iconHtml()"></span>
|
||||
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-init="bindIconHtml($el)"></span>
|
||||
<div
|
||||
class="page-title-input"
|
||||
contenteditable="true"
|
||||
id="_titleEl"
|
||||
data-placeholder="New Page"
|
||||
@input="dirty=true;save()"
|
||||
@input="markAndSave()"
|
||||
@keydown.enter.prevent="focusBlock()"
|
||||
@paste.prevent="pastePlain($event)"
|
||||
spellcheck="false"
|
||||
@@ -583,7 +583,7 @@
|
||||
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
|
||||
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
|
||||
@click="window.E && window.E.commentOnSelection()">
|
||||
@click="edCall('commentOnSelection')">
|
||||
💬 Comment
|
||||
</button>
|
||||
|
||||
@@ -820,7 +820,7 @@
|
||||
<div style="padding:24px;text-align:center;color:var(--text-dim);">No pages link here</div>
|
||||
</template>
|
||||
<template x-for="b in backlinksList" :key="b.id">
|
||||
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="backlinksOpen=false;window.location.href='/pages/'+b.id">
|
||||
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="openBacklink(b)">
|
||||
<div style="font-weight:500;color:var(--text-primary);" x-text="b.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-top:2px;" x-text="b.workspace || ''"></div>
|
||||
</a>
|
||||
@@ -856,7 +856,7 @@
|
||||
<template x-if="importFile">
|
||||
<div style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
|
||||
<span x-text="importFile.name"></span>
|
||||
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
|
||||
<span x-text="fmtImportSize(importFile)"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div style="display:flex;gap:8px;margin-top:10px;">
|
||||
|
||||
@@ -105,6 +105,11 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: accountsData »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('accountsData', accountsData); });
|
||||
function accountsData() {
|
||||
return {
|
||||
profile: { full_name: '', email: '' },
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
|
||||
<div class="fd-ap-tabs">
|
||||
<button class="fd-ap-tab" :class="{active: tab==='chat'}" @click="tab='chat'">Conversation</button>
|
||||
<button class="fd-ap-tab" :class="{active: tab==='history'}" @click="tab='history'; loadConversations()">Historique</button>
|
||||
<button class="fd-ap-tab" :class="{active: tab==='history'}" @click="showHistory()">Historique</button>
|
||||
</div>
|
||||
|
||||
<div class="fd-agent-body">
|
||||
@@ -305,7 +305,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="fd-agent-msg-content" x-show="m.generating" x-cloak>Génération…</div>
|
||||
<div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-html="renderMarkdown(m.content)"></div>
|
||||
<div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-init="bindMarkdown($el, m)"></div>
|
||||
<div class="fd-agent-msg-content" x-show="!m.generating && m.role!=='assistant' && m.content" x-text="m.content"></div>
|
||||
<template x-if="m.proposal">
|
||||
<div class="fd-proposal-bar">
|
||||
|
||||
+147
-25
@@ -115,7 +115,7 @@
|
||||
</style>
|
||||
{# A20 : htmx copie les <script nonce="{{ csp_nonce() }}"> des réponses boostées — il remet leur nonce
|
||||
depuis cette config (le nonce de la réponse courante, pas celui du fetch). #}
|
||||
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}"}'>
|
||||
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}", "allowEval": false}'>
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
// A38 : helper CSRF unique — défini le plus tôt possible (head) pour
|
||||
// tous les scripts inline/externes de la page (welcome.html, isolé de
|
||||
@@ -126,7 +126,7 @@
|
||||
};
|
||||
</script>
|
||||
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
|
||||
</head>
|
||||
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% endif %}>
|
||||
@@ -188,8 +188,8 @@
|
||||
:class="{ collapsed: sidebarCollapsed && !sidebarPeek, 'mobile-open': mobileSidebarOpen, peeking: sidebarPeek }"
|
||||
id="sidebar"
|
||||
@mouseleave="!sidebarResizing && (sidebarPeek = false)"
|
||||
x-effect="document.documentElement.classList.toggle('fd-sidebar-collapsed', sidebarCollapsed && !sidebarPeek)"
|
||||
x-init="$nextTick(() => { if(typeof initTreeSortable==='function') initTreeSortable(); })">
|
||||
x-effect="syncSidebarClass()"
|
||||
x-init="initSidebarSort()"
|
||||
<!-- Header + dropdown wrapper -->
|
||||
<div style="position:relative;">
|
||||
<div class="sidebar-workspace-header" @click="workspaceMenuOpen = !workspaceMenuOpen" @mouseenter="wsHeaderHover=true" @mouseleave="wsHeaderHover=false"
|
||||
@@ -272,8 +272,8 @@
|
||||
</div>
|
||||
{% if has_active_workspace %}
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" title="New Page" @click.stop="window.FlowDeck.createPage()">{{ fd_icon("file",16) }}</button>
|
||||
<button class="section-action-btn" title="New Folder" @click.stop="window.FlowDeck.showCreateFolderModal()">{{ fd_icon("folder",16) }}</button>
|
||||
<button class="section-action-btn" title="New Page" @click.stop="fdCreatePage()">{{ fd_icon("file",16) }}</button>
|
||||
<button class="section-action-btn" title="New Folder" @click.stop="fdCreateFolder()">{{ fd_icon("folder",16) }}</button>
|
||||
<a class="section-action-btn" href="/local-workspace" title="Open workspace page" style="text-decoration:none;">{{ fd_icon("external-link",16) }}</a>
|
||||
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'workspace')" @contextmenu.stop="openSectionMenu($event, 'workspace')">⋮</button>
|
||||
</div>
|
||||
@@ -308,7 +308,7 @@
|
||||
<span class="section-label">Repository (Gitea)</span>
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" title="Refresh" @click.stop="if(window._gwData)window._gwData.refreshTree()">{{ fd_icon("refresh",16) }}</button>
|
||||
<button class="section-action-btn" title="Refresh" @click.stop="fdGwRefresh()">{{ fd_icon("refresh",16) }}</button>
|
||||
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'gitea')">⋮</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,7 +330,7 @@
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
{% if has_active_workspace %}
|
||||
<button class="section-action-btn" title="Add" @click.stop="window.FlowDeck.createPage()">+</button>
|
||||
<button class="section-action-btn" title="Add" @click.stop="fdCreatePage()">+</button>
|
||||
{% endif %}
|
||||
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'meetings')">⋮</button>
|
||||
</div>
|
||||
@@ -407,7 +407,7 @@
|
||||
<ul class="sidebar-items" data-section="agents">
|
||||
<template x-for="a in agentList" :key="a.id">
|
||||
<li class="sidebar-item" @click="agentOpen(a.id)">
|
||||
<span class="page-icon page-icon-svg" x-html="a.icon || '🤖'"></span>
|
||||
<span class="page-icon page-icon-svg" x-init="bindAgentIcon($el, a)"></span>
|
||||
<span class="page-name" x-text="a.name"></span>
|
||||
</li>
|
||||
</template>
|
||||
@@ -541,12 +541,7 @@
|
||||
<button class="scp-done" @click="toggleCustomize()">Done</button>
|
||||
</div>
|
||||
<div class="scp-list">
|
||||
<template x-for="(cfg, key) in (Object.keys(sidebarConfig).length ? sidebarConfig : {
|
||||
workspace:{visible:true,order:0},teamspaces:{visible:true,order:1},
|
||||
meetings:{visible:true,order:2},recents:{visible:true,order:3},
|
||||
favorites:{visible:true,order:4},agents:{visible:true,order:5},
|
||||
shared:{visible:true,order:6},published:{visible:true,order:7}
|
||||
})" :key="key">
|
||||
<template x-for="(cfg, key) in sidebarSections()" :key="key">
|
||||
<div class="scp-item" @click="toggleSectionVisibility(key)">
|
||||
<div class="scp-item-left">
|
||||
<span class="scp-item-icon" x-text="getSectionIcon(key)"></span>
|
||||
@@ -595,40 +590,40 @@
|
||||
<!-- Section options menu (⋮ dropdown) -->
|
||||
<div class="section-menu-overlay" x-show="sectionMenu.visible" @click="closeSectionMenu()" @contextmenu.prevent="closeSectionMenu()"></div>
|
||||
<div class="section-menu" x-show="sectionMenu.visible" x-cloak
|
||||
:style="{ top: sectionMenu.y + 'px', left: Math.min(sectionMenu.x, window.innerWidth - 220) + 'px' }"
|
||||
:style="sectionMenuPos()"
|
||||
@click.outside="closeSectionMenu()">
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 5)">
|
||||
<div class="section-menu-item" @click="closeAndSetCount(5)">
|
||||
<span class="smi-label">Show 5 items</span>
|
||||
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 5">✓</span>
|
||||
</div>
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 10)">
|
||||
<div class="section-menu-item" @click="closeAndSetCount(10)">
|
||||
<span class="smi-label">Show 10 items</span>
|
||||
<span class="smi-check" x-show="!sidebarConfig[sectionMenu.section] || sidebarConfig[sectionMenu.section].show_count === 10 || sidebarConfig[sectionMenu.section].show_count == null">✓</span>
|
||||
</div>
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 15)">
|
||||
<div class="section-menu-item" @click="closeAndSetCount(15)">
|
||||
<span class="smi-label">Show 15 items</span>
|
||||
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 15">✓</span>
|
||||
</div>
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 20)">
|
||||
<div class="section-menu-item" @click="closeAndSetCount(20)">
|
||||
<span class="smi-label">Show 20 items</span>
|
||||
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 20">✓</span>
|
||||
</div>
|
||||
<div class="section-menu-sep"></div>
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'up')">
|
||||
<div class="section-menu-item" @click="closeAndMove('up')">
|
||||
<span class="smi-icon">↑</span>
|
||||
<span class="smi-label">Move up</span>
|
||||
</div>
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'down')">
|
||||
<div class="section-menu-item" @click="closeAndMove('down')">
|
||||
<span class="smi-icon">↓</span>
|
||||
<span class="smi-label">Move down</span>
|
||||
</div>
|
||||
<div class="section-menu-sep"></div>
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); toggleSectionVisibility(sectionMenu.section)">
|
||||
<div class="section-menu-item" @click="closeAndToggleVisibility()">
|
||||
<span class="smi-icon">👁</span>
|
||||
<span class="smi-label" x-text="isSectionVisible(sectionMenu.section) ? 'Hide section' : 'Show section'"></span>
|
||||
</div>
|
||||
<div class="section-menu-sep"></div>
|
||||
<div class="section-menu-item section-menu-customize" @click="closeSectionMenu(); toggleCustomize()">
|
||||
<div class="section-menu-item section-menu-customize" @click="menuCustomize()">
|
||||
<span class="smi-icon">⚙️</span>
|
||||
<span class="smi-label">Customize sidebar</span>
|
||||
</div>
|
||||
@@ -1127,6 +1122,12 @@
|
||||
}
|
||||
};
|
||||
|
||||
// A20 phase 3 : le build CSP ne résout que le registre Alpine.data
|
||||
// (probe : globale window → « Undefined variable: appState »).
|
||||
document.addEventListener('alpine:init', function () {
|
||||
Alpine.data('appState', appState);
|
||||
});
|
||||
|
||||
function appState() {
|
||||
return {
|
||||
init() {
|
||||
@@ -1462,6 +1463,10 @@
|
||||
this.sectionMenu = { section: section, visible: true, x: ev.clientX, y: ev.clientY };
|
||||
},
|
||||
|
||||
menuCustomize() { this.closeSectionMenu(); this.toggleCustomize(); },
|
||||
closeAndSetCount(n) { this.closeSectionMenu(); this.setShowCount(this.sectionMenu.section, n); },
|
||||
closeAndMove(dir) { this.closeSectionMenu(); this.moveSection(this.sectionMenu.section, dir); },
|
||||
closeAndToggleVisibility() { this.closeSectionMenu(); this.toggleSectionVisibility(this.sectionMenu.section); },
|
||||
closeSectionMenu() {
|
||||
this.sectionMenu.visible = false;
|
||||
},
|
||||
@@ -1568,6 +1573,55 @@
|
||||
else window.location.href = url;
|
||||
},
|
||||
|
||||
// ── A20 phase 3 : expressions → méthode (build CSP : appel seul ;
|
||||
// document/Math/window/FlowsDeck = JS réel, hors évaluateur) ──
|
||||
bindProjectIcon(el, p) {
|
||||
Alpine.effect(() => { el.innerHTML = getSvgIcon(p.icon || 'folder', 20); });
|
||||
},
|
||||
// ── A20 ph3 : délégués du topbar éditeur (window.E hors portée CSP,
|
||||
// scope du topbar = appState) — voir right_actions de page_editor ──
|
||||
edCall(name) {
|
||||
if (window.E && typeof window.E[name] === 'function') window.E[name]();
|
||||
},
|
||||
edTimeAgo() { return (window.E && window.E.timeAgo) || ''; },
|
||||
edCommentCount() {
|
||||
return (window.E && window.E.commentCount > 0) ? window.E.commentCount : '';
|
||||
},
|
||||
edShared() { return !!(window.E && window.E.pageIsShared); },
|
||||
// les 2 branches du ternaire favorited étaient identiques → rendu 1×
|
||||
bindStar(el) { Alpine.effect(() => { el.innerHTML = getSvgIcon('star', 14); }); },
|
||||
|
||||
bindAgentIcon(el, a) {
|
||||
Alpine.effect(() => { el.innerHTML = a.icon || '🤖'; });
|
||||
},
|
||||
syncSidebarClass() {
|
||||
document.documentElement.classList.toggle(
|
||||
'fd-sidebar-collapsed', this.sidebarCollapsed && !this.sidebarPeek);
|
||||
},
|
||||
initSidebarSort() {
|
||||
Alpine.nextTick(() => {
|
||||
if (typeof initTreeSortable === 'function') initTreeSortable();
|
||||
});
|
||||
},
|
||||
fdCreatePage() { window.FlowDeck.createPage(); },
|
||||
fdCreateFolder() { window.FlowDeck.showCreateFolderModal(); },
|
||||
fdGwRefresh() { if (window._gwData) window._gwData.refreshTree(); },
|
||||
sidebarSections() {
|
||||
if (Object.keys(this.sidebarConfig).length) return this.sidebarConfig;
|
||||
return {
|
||||
workspace: { visible: true, order: 0 }, teamspaces: { visible: true, order: 1 },
|
||||
meetings: { visible: true, order: 2 }, recents: { visible: true, order: 3 },
|
||||
favorites: { visible: true, order: 4 }, agents: { visible: true, order: 5 },
|
||||
shared: { visible: true, order: 6 }, published: { visible: true, order: 7 },
|
||||
};
|
||||
},
|
||||
sectionMenuPos() {
|
||||
return {
|
||||
top: this.sectionMenu.y + 'px',
|
||||
left: Math.min(this.sectionMenu.x, window.innerWidth - 220) + 'px',
|
||||
};
|
||||
},
|
||||
|
||||
toggleSidebar() {
|
||||
this.sidebarPeek = false;
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
@@ -2184,6 +2238,14 @@
|
||||
.cmd-palette-overlay.open .cmd-palette{transform:translateY(0)}
|
||||
.cmd-palette-input{width:100%;box-sizing:border-box;padding:16px 18px;background:transparent;border:none;outline:none;color:var(--text,#fff);font-size:16px}
|
||||
.cmd-palette-input::placeholder{color:var(--text-dim,rgba(255,255,255,.4))}
|
||||
.cmd-palette-tabs{display:flex;gap:6px;padding:10px 16px 0}
|
||||
.cp-tab{background:none;border:1px solid var(--border,rgba(255,255,255,.12));color:var(--text-dim,rgba(255,255,255,.55));border-radius:999px;padding:4px 12px;font-size:12px;cursor:pointer}
|
||||
.cp-tab.active{background:var(--bg-hover,#2a2a2a);color:var(--text,#eee);border-color:var(--accent,#2383e2)}
|
||||
.cmd-palette-ai{padding:14px 18px;font-size:13.5px;line-height:1.55;white-space:pre-wrap;color:var(--text,#eee)}
|
||||
.cmd-palette-ai .cp-cites{margin-top:10px;display:flex;flex-direction:column;gap:4px;font-size:12.5px;white-space:normal}
|
||||
a.cp-cite{color:var(--accent,#529ffa);text-decoration:none}
|
||||
a.cp-cite:hover{text-decoration:underline}
|
||||
.cp-loading{color:var(--text-dim,rgba(255,255,255,.5))}
|
||||
.cmd-palette-list{overflow-y:auto;border-top:1px solid var(--border,rgba(255,255,255,.06))}
|
||||
.cmd-palette-group{display:flex;align-items:center;gap:8px;padding:10px 18px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-dim,rgba(255,255,255,.4))}
|
||||
.cmd-palette-item{display:flex;align-items:center;gap:10px;padding:9px 18px;cursor:pointer;font-size:14px;color:var(--text,#fff)}
|
||||
@@ -2205,6 +2267,10 @@
|
||||
<input id="fd-cp-input" class="cmd-palette-input" type="text"
|
||||
placeholder="Search pages, databases, or type a command…"
|
||||
autocomplete="off" spellcheck="false">
|
||||
<div class="cmd-palette-tabs" id="fd-cp-tabs" role="tablist">
|
||||
<button type="button" class="cp-tab active" data-tab="pages">Pages</button>
|
||||
<button type="button" class="cp-tab" data-tab="ai">✨ Réponses IA</button>
|
||||
</div>
|
||||
<div id="fd-cp-list" class="cmd-palette-list"></div>
|
||||
<div class="cmd-palette-footer">
|
||||
<span class="cpf-kbd"><b>↑</b> / <b>↓</b> navigate</span>
|
||||
@@ -2225,7 +2291,7 @@
|
||||
];
|
||||
|
||||
var overlay, input, list;
|
||||
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false };
|
||||
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false, tab:'pages', ai:null };
|
||||
|
||||
function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); }
|
||||
function highlight(text, q){
|
||||
@@ -2249,6 +2315,7 @@
|
||||
}
|
||||
|
||||
function render(){
|
||||
if(state.tab==='ai'){ renderAi(); return; }
|
||||
if(!state.open) return;
|
||||
if(!state.items.length){
|
||||
list.innerHTML = '<div class="cmd-palette-empty">No results for “'+esc(state.query)+'”</div>';
|
||||
@@ -2285,6 +2352,7 @@
|
||||
}
|
||||
|
||||
function runSearch(){
|
||||
if(state.tab==='ai'){ runAi(); return; }
|
||||
var q = input.value.trim();
|
||||
state.query = q;
|
||||
state.index = 0;
|
||||
@@ -2309,6 +2377,50 @@
|
||||
.catch(function(){ state.items=[]; render(); });
|
||||
}
|
||||
|
||||
function runAi(){
|
||||
var q = input.value.trim();
|
||||
state.ai = null;
|
||||
if(!q){ state.ai = {hint:1}; renderAi(); return; }
|
||||
state.ai = {loading:1}; renderAi();
|
||||
fetch('/api/v2/search/ask', {
|
||||
method:'POST',
|
||||
headers: {'Content-Type':'application/json', 'X-CSRF-Token': getCsrf()},
|
||||
body: JSON.stringify({question: q})
|
||||
}).then(function(r){ if(!r.ok) throw 0; return r.json(); })
|
||||
.then(function(d){
|
||||
if(state.tab!=='ai' || input.value.trim()!==q) return;
|
||||
state.ai = d; renderAi();
|
||||
})
|
||||
.catch(function(){
|
||||
if(state.tab!=='ai') return;
|
||||
state.ai = {error:1}; renderAi();
|
||||
});
|
||||
}
|
||||
function renderAi(){
|
||||
var a = state.ai;
|
||||
if(!a){ list.innerHTML=''; return; }
|
||||
if(a.loading){ list.innerHTML='<div class="cmd-palette-ai"><span class="cp-loading">Recherche de la réponse…</span></div>'; return; }
|
||||
if(a.error){ list.innerHTML='<div class="cmd-palette-ai">Demande impossible — réessayez.</div>'; return; }
|
||||
if(a.hint){ list.innerHTML='<div class="cmd-palette-ai">Posez une question sur votre espace : la réponse cite vos pages.</div>'; return; }
|
||||
var cits = a.citations || [], byId = {};
|
||||
cits.forEach(function(c){ byId[String(c.id)] = c; });
|
||||
// échappement AVANT injection des liens (ids = chiffres, type = [a-z]+)
|
||||
var md = esc(a.answer_markdown || '')
|
||||
.replace(/\[\[([a-z]+):(\d+)\]\]/g, function(m, type, id){
|
||||
var c = byId[id];
|
||||
var label = esc((c && (c.title || c.name)) || (type + ' #' + id));
|
||||
var href = type === 'collection' ? '/db/' + id : '/pages/' + id;
|
||||
return '<a class="cp-cite" href="' + href + '">' + label + '</a>';
|
||||
})
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
|
||||
var cites = cits.map(function(c){
|
||||
var href = c.type === 'collection' ? '/db/' + c.id : '/pages/' + c.id;
|
||||
return '<a class="cp-cite" href="' + href + '">' + esc(c.title || c.name || 'page #' + c.id) + '</a>';
|
||||
}).join('');
|
||||
list.innerHTML = '<div class="cmd-palette-ai">' + md +
|
||||
(cites ? '<div class="cp-cites"><b>Sources</b> ' + cites + '</div>' : '') + '</div>';
|
||||
}
|
||||
|
||||
function open(){
|
||||
if(state.open) return;
|
||||
state.open=true; overlay.classList.add('open');
|
||||
@@ -2363,6 +2475,16 @@
|
||||
list = document.getElementById('fd-cp-list');
|
||||
if(!overlay) return;
|
||||
input.addEventListener('input', function(){ clearTimeout(state.timer); state.timer=setTimeout(runSearch, 150); });
|
||||
var tabs = document.getElementById('fd-cp-tabs');
|
||||
if(tabs) tabs.addEventListener('click', function(e){
|
||||
var b = e.target && e.target.closest ? e.target.closest('.cp-tab') : null;
|
||||
if(!b) return;
|
||||
state.tab = b.getAttribute('data-tab');
|
||||
var all = tabs.querySelectorAll('.cp-tab');
|
||||
for(var i=0;i<all.length;i++) all[i].classList.toggle('active', all[i]===b);
|
||||
state.index = 0;
|
||||
runSearch();
|
||||
});
|
||||
document.addEventListener('keydown', onKey);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<template x-for="(f, i) in activeFilters" :key="i">
|
||||
<div class="filter-pill">
|
||||
<span x-text="f.property + ': ' + f.value"></span>
|
||||
<button class="remove-filter" @click="removeFilter(i); refreshView()">✕</button>
|
||||
<button class="remove-filter" @click="removeFilterAndRefresh(i)">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="filter-pill" style="cursor:pointer; position:relative;" @click="showStatusMenu = !showStatusMenu">
|
||||
@@ -84,7 +84,7 @@
|
||||
<div class="dropdown-panel" :class="{ visible: showStatusMenu }" style="top:100%; left:0;"
|
||||
@click.stop>
|
||||
<template x-for="s in statusOptions" :key="s.value">
|
||||
<div class="dropdown-option" @click="toggleStatus(s.value); showStatusMenu = false; refreshView()">
|
||||
<div class="dropdown-option" @click="pickStatus(s.value)">
|
||||
<span class="option-checkbox" :class="{ selected: statusFilters.includes(s.value) }">
|
||||
<span x-show="statusFilters.includes(s.value)">✓</span>
|
||||
</span>
|
||||
@@ -95,7 +95,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<button class="filter-add-btn" @click="addFilter()">+ Filter</button>
|
||||
<button class="filter-reset-btn" @click="resetFilters(); refreshView()" x-show="activeFilters.length > 0">Reset</button>
|
||||
<button class="filter-reset-btn" @click="resetFiltersAndRefresh()" x-show="activeFilters.length > 0">Reset</button>
|
||||
|
||||
<div class="view-toolbar-spacer"></div>
|
||||
|
||||
|
||||
@@ -95,11 +95,16 @@
|
||||
style="width:100%; min-height:60px; background:var(--bg-secondary); border:1px solid var(--border);
|
||||
border-radius:6px; padding:8px; color:var(--text-primary); font-family:inherit; font-size:13px;
|
||||
resize:vertical; margin-top:8px;"
|
||||
@keydown.ctrl.enter="addComment($event.target.value); $event.target.value=''"></textarea>
|
||||
@keydown.ctrl.enter="addCommentAndClear($event.target)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: cardDetail »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); });
|
||||
function cardDetail() {
|
||||
return {
|
||||
updateField(field, value) {
|
||||
@@ -133,6 +138,7 @@
|
||||
})
|
||||
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
|
||||
},
|
||||
addCommentAndClear(el) { this.addComment(el.value); el.value = ''; },
|
||||
addComment(body) {
|
||||
if (!body.trim()) return;
|
||||
console.log('Add comment:', body);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block topbar %}
|
||||
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
|
||||
{% set page_icon = "link" %}
|
||||
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">' ~ fd_icon("file",14) ~ '+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">' ~ fd_icon("upload",14) ~ '</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">' ~ fd_icon("refresh",14) ~ '</button><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
||||
{% set right_actions %}<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">{{ fd_icon("file",14) }}+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">{{ fd_icon("upload",14) }}</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">{{ fd_icon("refresh",14) }}</button><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</style>
|
||||
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
|
||||
<div class="gw-main" x-data="giteaWorkspace">
|
||||
<div class="gw-main" x-data="giteaWorkspace()">
|
||||
<!-- File view -->
|
||||
<div x-show="showFile && !showEditor" x-transition>
|
||||
<div class="gw-file-toolbar">
|
||||
@@ -120,7 +120,7 @@
|
||||
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
|
||||
@contextmenu.prevent="openGwContext($event, item)"
|
||||
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
|
||||
<span x-html="item.type==='folder' ? getSvgIcon('folder',16) : getSvgIcon('file',16)" style="font-size:16px;"></span>
|
||||
<span x-init="bindGwIcon($el, item)" style="font-size:16px;"></span>
|
||||
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
|
||||
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
|
||||
</div>
|
||||
@@ -165,7 +165,7 @@
|
||||
@click="openPrivate(pp.id)">
|
||||
<div>
|
||||
<div style="font-weight:500;" x-text="pp.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);" x-text="fmtGwDate(pp)"></div>
|
||||
</div>
|
||||
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">{{ fd_icon('trash',14) }}</button>
|
||||
</div>
|
||||
@@ -180,7 +180,7 @@
|
||||
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" @click="savePrivate()">{{ fd_icon("save",14) }} Save</button>
|
||||
<button class="btn" @click="editingPrivate=null;editingPrivateTitle='';editingPrivateContent='';">Cancel</button>
|
||||
<button class="btn" @click="closePrivateEdit()">Cancel</button>
|
||||
</div>
|
||||
<div class="gw-content">
|
||||
<input x-model="editingPrivateTitle" placeholder="Page title" style="width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:16px;margin-bottom:12px;outline:none;">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Importer — FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<style>
|
||||
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;--warn:#D9730D;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
@@ -201,7 +201,7 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
|
||||
<template x-for="(e,i) in (report ? report.errors : [])" :key="i"><div x-text="'✕ '+e.title+' : '+e.error"></div></template>
|
||||
</div>
|
||||
<div class="warnings" x-show="report && report.relations && report.relations.relations_resolved">
|
||||
<div x-text="'🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)'"></div>
|
||||
<div x-text="report && report.relations ? '🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)' : ''"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost" @click="downloadReport()">Télécharger le rapport (JSON)</button>
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
</div>
|
||||
<div class="lib-toolbar">
|
||||
<div class="lib-toolbar-wrap" x-show="tab !== 'repository'">
|
||||
<button class="lib-icon-btn" :class="{active: filterOpen}" title="Filter" @click.stop="filterOpen=!filterOpen; sortOpen=false; viewOpen=false">
|
||||
<button class="lib-icon-btn" :class="{active: filterOpen}" title="Filter" @click.stop="toggleFilterMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
||||
</button>
|
||||
<div class="lib-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition>
|
||||
@@ -245,7 +245,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-toolbar-wrap">
|
||||
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; filterOpen=false; viewOpen=false">
|
||||
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSortMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
|
||||
</button>
|
||||
<div class="lib-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition>
|
||||
@@ -255,11 +255,11 @@
|
||||
<div class="dd-item" :class="{active: sortBy==='author'}" @click="setSort('author')"><span class="check" x-text="sortBy==='author' ? '✓' : ''"></span> Created by</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="lib-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="searchOpen=!searchOpen; $nextTick(()=>{ if(searchOpen) document.getElementById('lib-search-input').focus(); })">
|
||||
<button class="lib-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
<div class="lib-toolbar-wrap">
|
||||
<button class="lib-icon-btn" :class="{active: viewOpen}" title="View options" @click.stop="viewOpen=!viewOpen; filterOpen=false; sortOpen=false">
|
||||
<button class="lib-icon-btn" :class="{active: viewOpen}" title="View options" @click.stop="toggleViewMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="9" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="9" cy="18" r="1.5" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<div class="lib-dropdown" x-show="viewOpen" @click.outside="viewOpen=false" x-transition>
|
||||
@@ -310,7 +310,7 @@
|
||||
<button @click="copyLinks()" title="Copy links">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
</button>
|
||||
<button @click="openMovePicker(Object.keys(selected).map(Number))" title="Move to">
|
||||
<button @click="openMoveSelected()" title="Move to">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||
</button>
|
||||
<div style="position:relative;margin-left:auto;">
|
||||
@@ -326,7 +326,7 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
Copy links to all
|
||||
</div>
|
||||
<div class="menu-item" @click="openMovePicker(Object.keys(selected).map(Number))">
|
||||
<div class="menu-item" @click="openMoveSelected()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||
Move to
|
||||
</div>
|
||||
@@ -378,7 +378,7 @@
|
||||
|
||||
<!-- ── Empty state ── -->
|
||||
<div class="lib-empty" id="lib-empty">
|
||||
<div class="empty-icon" x-html="getSvgIcon(emptyIcon,48)"></div>
|
||||
<div class="empty-icon" x-init="bindHtmlIcon($el)"></div>
|
||||
<h3 x-text="emptyTitle"></h3>
|
||||
<p x-text="emptyText"></p>
|
||||
</div>
|
||||
@@ -397,7 +397,7 @@
|
||||
<div class="move-modal-item" @click="confirmMove(0)">{{ fd_icon("file",14) }} <span>Root (no parent)</span></div>
|
||||
<template x-for="it in moveCandidates" :key="it.id">
|
||||
<div class="move-modal-item" @click="confirmMove(it.id)">
|
||||
<span x-html="_renderIcon(it)"></span> <span x-text="it.title"></span>
|
||||
<span x-init="bindHtmlItem($el, it)"></span> <span x-text="it.title"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -414,7 +414,7 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="peek-title">
|
||||
<span x-html="_renderIcon(peekItem)"></span>
|
||||
<span x-init="bindHtmlItem($el, peekItem)"></span>
|
||||
<span x-text="peekItem.title"></span>
|
||||
</div>
|
||||
<button @click="openItem(peekItem)" title="Open full page">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
{% block content %}
|
||||
<span id="fd-local-ws-id" hidden>{{ workspace_id }}</span>
|
||||
<div x-data="_wsInitData"
|
||||
<div x-data="wsInitData()"
|
||||
@dragover.prevent="onDragOver($event)"
|
||||
@dragleave="onDragLeave($event)"
|
||||
@drop.prevent="onDrop($event)"
|
||||
@@ -482,10 +482,10 @@
|
||||
|
||||
<script type="application/json" id="lw-config" nonce="{{ csp_nonce() }}">{{ {"current_folder_id": current_folder_id, "workspace_id": workspace_id} | tojson }}</script>
|
||||
<script data-cfasync="false" src="/static/js/local_workspace.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof _wsInitData !== 'undefined');</script>
|
||||
|
||||
<div class="ws-split"
|
||||
@contextmenu.prevent="_wsInitData.onContextMenu($event)"
|
||||
@contextmenu.prevent="onContextMenu($event)"
|
||||
@touchstart="onTouchStart($event)"
|
||||
@touchend="onTouchEnd($event)"
|
||||
@touchmove="onTouchMove($event)"
|
||||
@@ -553,7 +553,7 @@
|
||||
<div class="ws-toolbar" style="margin-left:auto;">
|
||||
<!-- View dropdown -->
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="viewMenuOpen=!viewMenuOpen; sortOpen=false; searchOpen=false">
|
||||
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="toggleViewMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="viewMenuOpen" @click.outside="viewMenuOpen=false" x-transition style="right:0;">
|
||||
@@ -567,7 +567,7 @@
|
||||
</div>
|
||||
<!-- Sort -->
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; viewMenuOpen=false">
|
||||
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSortMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition style="right:0;">
|
||||
@@ -581,7 +581,7 @@
|
||||
</div>
|
||||
<!-- Filter -->
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="filterOpen=!filterOpen; viewMenuOpen=false; sortOpen=false">
|
||||
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="toggleFilterMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition style="right:0;">
|
||||
@@ -597,7 +597,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Search toggle -->
|
||||
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="searchOpen=!searchOpen; if(searchOpen) $nextTick(()=>$refs.searchInput?.focus())">
|
||||
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
<!-- Expand/Collapse (tree only) -->
|
||||
@@ -650,12 +650,12 @@
|
||||
|
||||
<!-- Filter chips (visual indicator when filter is active — shown in all views) -->
|
||||
<div class="filter-row" x-show="filterType" style="padding:4px 0;margin-bottom:6px;">
|
||||
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders" x-html="getSvgIcon('folder',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images" x-html="getSvgIcon('image',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders" x-init="bindSvg($el, 'folder', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images" x-init="bindSvg($el, 'image', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<span class="filter-clear" @click="filterType='', doFilter()">clear filter</span>
|
||||
</div>
|
||||
|
||||
@@ -723,7 +723,7 @@
|
||||
<span style="width:16px;flex-shrink:0;"></span>
|
||||
</template>
|
||||
|
||||
<span class="icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<span class="icon" x-init="bindFileIcon($el, node)"></span>
|
||||
|
||||
<template x-if="node.is_folder">
|
||||
<span class="name folder" @click.stop="navigateToFolder(node.id)"
|
||||
@@ -754,8 +754,8 @@
|
||||
<div class="actions">
|
||||
<template x-if="node.is_folder">
|
||||
<span style="display:flex;gap:2px">
|
||||
<button class="ws-act" @click.stop="window.FlowDeck.createPage(node.id)" title="New file">{{ fd_icon("file",14) }}</button>
|
||||
<button class="ws-act" @click.stop="window.FlowDeck.showCreateFolderModal(node.id)" title="New folder">{{ fd_icon("folder",14) }}</button>
|
||||
<button class="ws-act" @click.stop="createPageAt(node)" title="New file">{{ fd_icon("file",14) }}</button>
|
||||
<button class="ws-act" @click.stop="createFolderAt(node)" title="New folder">{{ fd_icon("folder",14) }}</button>
|
||||
<a class="ws-act" href="/local-workspace" title="Open workspace page" style="text-decoration:none;display:inline-flex;align-items:center;">{{ fd_icon("external-link",14) }}</a>
|
||||
</span>
|
||||
</template>
|
||||
@@ -765,7 +765,7 @@
|
||||
</div>
|
||||
<!-- Children -->
|
||||
<ul class="ws-tree" x-show="expanded[node.id]" x-transition
|
||||
x-html="renderChildren(node.children, (node.depth||0)+1, tagsVersion)">
|
||||
x-init="bindChildren($el, node)">
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
@@ -807,7 +807,7 @@
|
||||
<tr :class="{ selected: !!selectedIds[node.id] }">
|
||||
<td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></td>
|
||||
<td>
|
||||
<span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<span class="table-icon" x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
|
||||
<span class="col-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span>
|
||||
</td>
|
||||
@@ -864,7 +864,7 @@
|
||||
</tr>
|
||||
<template x-for="node in displayTree" :key="'l'+node.id">
|
||||
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer">
|
||||
<td><span x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<td><span x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
|
||||
<td class="col-name" x-text="node.name"></td>
|
||||
<td style="font-size:12px;color:var(--text-tertiary)" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
|
||||
@@ -902,7 +902,7 @@
|
||||
<template x-for="node in displayTree" :key="'d'+node.id">
|
||||
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer"
|
||||
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
|
||||
<td><span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<td><span class="table-icon" x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
|
||||
<td class="col-name" x-text="node.name"></td>
|
||||
<td class="col-path" x-text="node._path || '—'"></td>
|
||||
@@ -933,7 +933,7 @@
|
||||
<template x-for="node in displayTree" :key="'g'+node.id">
|
||||
<div class="title-card" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
|
||||
:class="{ 'selected-card': previewItem && previewItem.id === node.id }">
|
||||
<div class="title-card-icon" x-html="node.is_folder ? getSvgIcon('folder',24) : _fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></div>
|
||||
<div class="title-card-icon" x-init="bindNodeIcon($el, node)"></div>
|
||||
<div class="title-card-name">
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
|
||||
<span x-text="node.name"></span>
|
||||
@@ -960,7 +960,7 @@
|
||||
<div class="content-item" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
|
||||
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
|
||||
<div class="content-item-header">
|
||||
<span x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<span x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
|
||||
<span class="col-name" x-text="node.name"></span>
|
||||
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="_formatDate(node.updated_at)"></span>
|
||||
@@ -1023,7 +1023,7 @@
|
||||
<h3><span x-show="createType==='folder'">{{ fd_icon('folder',16) }}</span><span x-show="createType!='folder'">{{ fd_icon('file',16) }}</span> <span x-text="createType==='folder'?'New Folder':'New File'"></span></h3>
|
||||
<p class="modal-sub">
|
||||
In <strong>{{ workspace_name }}</strong> <span x-show="folderStack.length>1">/ <span x-text="(folderStack[folderStack.length-1]||{}).name||''"></span></span>
|
||||
<span x-show="parentFolder"> / <span x-text="parentFolder?.name"></span></span>
|
||||
<span x-show="parentFolder"> / <span x-text="parentFolder ? parentFolder.name : ''"></span></span>
|
||||
</p>
|
||||
<input class="modal-input" x-model="newName" :placeholder="createType==='folder'?'Folder name':'File name'" @keydown.enter="doCreate" x-ref="cinp">
|
||||
<div class="modal-actions">
|
||||
@@ -1037,7 +1037,7 @@
|
||||
<div class="modal-overlay" x-show="showRename" @click.outside="showRename=false" @keydown.escape="showRename=false" style="display:none">
|
||||
<div class="modal-box">
|
||||
<h3>{{ fd_icon("edit",14) }} Rename</h3>
|
||||
<p class="modal-sub">Rename <strong x-text="target?.name"></strong></p>
|
||||
<p class="modal-sub">Rename <strong x-text="target ? target.name : ''"></strong></p>
|
||||
<input class="modal-input" x-model="newName" @keydown.enter="doRename" x-ref="rinp">
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="showRename=false">Cancel</button>
|
||||
@@ -1050,7 +1050,7 @@
|
||||
<div class="modal-overlay" x-show="showDelete" @click.outside="showDelete=false" @keydown.escape="showDelete=false" style="display:none">
|
||||
<div class="modal-box">
|
||||
<h3>{{ fd_icon("trash",16) }} Delete</h3>
|
||||
<p class="modal-sub">Delete <strong x-text="target?.name"></strong>?</p>
|
||||
<p class="modal-sub">Delete <strong x-text="target ? target.name : ''"></strong>?</p>
|
||||
<div class="delete-confirm">This cannot be undone. Children will also be deleted.</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="showDelete=false">Cancel</button>
|
||||
@@ -1065,11 +1065,14 @@
|
||||
{% include "_ctx_menu.html" %}
|
||||
|
||||
<!-- Preview tooltip -->
|
||||
<div class="file-preview" x-show="previewVisible"
|
||||
<!-- ponytail: le parseur referme la div racine avant ce bloc (structure
|
||||
pré-existante, masquée par le fallback window d'Alpine standard) →
|
||||
wsPreview = wrapper déléguant vers _wsInitData (voir local_workspace.js) -->
|
||||
<div class="file-preview" x-data="wsPreview()" x-show="previewVisible"
|
||||
:style="{ left: previewX + 'px', top: previewY + 'px' }"
|
||||
@mouseenter="previewVisible = true" @mouseleave="previewVisible = false">
|
||||
<div class="file-preview-header" x-text="previewName"></div>
|
||||
<div class="file-preview-body" x-html="previewContent"></div>
|
||||
<div class="file-preview-body" x-init="bindPreview($el)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Panel — identical to Library peek-overlay -->
|
||||
|
||||
@@ -3,7 +3,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
|
||||
{% set page_title = page.title %}
|
||||
{% set nav_page_id = page.id %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn" @click="window.E && window.E.toggleComments()" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="window.E && window.E.commentCount>0 ? window.E.commentCount : \'\'"></span></button><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
|
||||
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn" @click="edCall('toggleComments')" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="edCommentCount()"></span></button><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %} {% block content %}
|
||||
{% include "_page_editor_content.html" %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% set page_icon = "file" %}
|
||||
{% set page_title = page.title %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
|
||||
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %} {% block content %}
|
||||
{% include "_database_table.html" %}
|
||||
|
||||
+146
-20
@@ -127,7 +127,7 @@
|
||||
@media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}}
|
||||
</style>
|
||||
|
||||
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
|
||||
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="historyBack()">
|
||||
<div class="settings-panel" style="position:relative;">
|
||||
<button class="settings-close" @click="closeSettings()" title="Close">×</button>
|
||||
|
||||
@@ -136,24 +136,24 @@
|
||||
<div class="settings-nav-header">Account</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='account' }" @click="activeSection='account'"><span class="nav-icon-inline">{{ fd_icon("user",14) }}</span> My account</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="activeSection='api-tokens'; loadApiTokens()"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="activeSection='sessions'; loadSessions()"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="activeSection='extensions'; loadClipperDevices()"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="navTo('api-tokens')"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="navTo('sessions')"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
|
||||
<div class="settings-nav-header">Workspace</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
|
||||
<div class="settings-nav-header">Features</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="activeSection='automations'; loadAutomations()">{{ fd_icon("zap",14) }} Automations</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent & IA</div>
|
||||
<!-- Admin nav: only visible to admins -->
|
||||
<template x-if="userIsAdmin">
|
||||
<div>
|
||||
<div class="settings-nav-header">Admin</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users & Roles</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="activeSection='admin-backups'; loadBackups()">{{ fd_icon("download",14) }} Backups</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="activeSection='admin-sso'; loadSsoConfig()">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="navTo('admin-users')">{{ fd_icon("users",14) }} Users & Roles</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="navTo('admin-audit')">{{ fd_icon("file-text",14) }} Audit Log</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="navTo('admin-backups')">{{ fd_icon("download",14) }} Backups</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="navTo('admin-sso')">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -515,9 +515,9 @@
|
||||
<div class="modal-box" style="max-width:360px;">
|
||||
<h3 style="margin:0 0 8px;">Delete tag</h3>
|
||||
<p style="color:var(--text-dim);margin:0 0 16px;">
|
||||
Are you sure you want to delete the tag "<strong x-text="deletingTag?.name"></strong>"?
|
||||
<span x-show="deletingTag?.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
|
||||
{{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag?.count"></span> item(s).
|
||||
Are you sure you want to delete the tag "<strong x-text="deletingTag ? deletingTag.name : ''"></strong>"?
|
||||
<span x-show="deletingTag && deletingTag.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
|
||||
{{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag ? deletingTag.count : 0"></span> item(s).
|
||||
</span>
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
@@ -531,7 +531,7 @@
|
||||
<div class="modal-box" style="max-width:360px;">
|
||||
<h3 style="margin:0 0 12px;">Rename tag</h3>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<div class="tag-color-dot" :style="{background: renamingTag?.color}" style="width:16px;height:16px;"></div>
|
||||
<div class="tag-color-dot" :style="{background: renamingTag ? renamingTag.color : ''}" style="width:16px;height:16px;"></div>
|
||||
<input type="text" class="settings-input" x-model="renameValue"
|
||||
@keydown.enter="confirmRenameTag()" @keydown.escape="renamingTag=null"
|
||||
style="flex:1;" autofocus>
|
||||
@@ -635,16 +635,142 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-row" x-show="!editSteps.length" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-label">Conditions (JSON — toutes AND)</div>
|
||||
<textarea class="settings-input" style="width:100%;min-height:60px;font-family:monospace;font-size:12px;" x-model="af.condition_json" placeholder='[{"property":"Status","op":"eq","value":"Done"}]'></textarea>
|
||||
<div class="setting-desc">Ops : eq, neq, contains, not_contains, is_empty, is_not_empty, changed</div>
|
||||
</div>
|
||||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-row" x-show="!editSteps.length" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-label">Actions (JSON)</div>
|
||||
<textarea class="settings-input" style="width:100%;min-height:80px;font-family:monospace;font-size:12px;" x-model="af.actions_json" placeholder='[{"type":"webhook","url":"https://..."}, {"type":"set_property","property":"Status","value":"Done"}, {"type":"create_page","collection_id":1,"title":"New task"}, {"type":"notify","message":"Automation fired"}]'></textarea>
|
||||
<div class="setting-desc">Types : webhook, set_property, create_page, notify</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline visuel (steps API v7.0) -->
|
||||
<div class="setting-row" x-show="editAutomationId" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-label">Pipeline visuel (steps)</div>
|
||||
<div class="setting-desc" x-show="!editSteps.length">Aucun step : automation en mode legacy (JSON ci-dessus). Ajoutez une étape (ou convertissez le JSON) pour basculer en pipeline.</div>
|
||||
<template x-for="(s, i) in editSteps" :key="s.id">
|
||||
<div style="border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin-bottom:6px;">
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<span class="setting-label" style="font-weight:500;" x-text="(i+1) + '. ' + stepSummary(s)"></span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="btn-sm" title="Monter" @click="moveStep(i, -1)">↑</button>
|
||||
<button class="btn-sm" title="Descendre" @click="moveStep(i, 1)">↓</button>
|
||||
<button class="btn-sm" @click="stepEdit = stepEdit===i ? -1 : i" x-text="stepEdit===i ? 'Fermer' : 'Modifier'"></button>
|
||||
<button class="btn-sm" style="color:#e5534b;" title="Supprimer" @click="delStep(i)">✕</button>
|
||||
</div>
|
||||
<div x-show="stepEdit===i" style="margin-top:8px;display:flex;flex-direction:column;gap:6px;">
|
||||
<template x-if="s.kind==='trigger'">
|
||||
<div>
|
||||
<div class="setting-label">Événement</div>
|
||||
<input class="settings-input" style="width:100%;" x-model="s.config.event" list="auto-events">
|
||||
<datalist id="auto-events">
|
||||
<option value="page.created"></option><option value="page.updated"></option><option value="page.deleted"></option>
|
||||
<option value="collection.created"></option><option value="collection.updated"></option><option value="collection.deleted"></option>
|
||||
<option value="form.submitted"></option><option value="meeting.summarized"></option><option value="site.viewed"></option>
|
||||
</datalist>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.kind==='condition'">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<input class="settings-input" placeholder="Propriété" x-model="s.config.property">
|
||||
<select class="settings-input" x-model="s.config.op">
|
||||
<option value="eq">=</option><option value="neq">≠</option>
|
||||
<option value="contains">contient</option><option value="not_contains">ne contient pas</option>
|
||||
<option value="is_empty">vide</option><option value="is_not_empty">non vide</option>
|
||||
<option value="changed">changé</option>
|
||||
</select>
|
||||
<input class="settings-input" placeholder="Valeur" x-model="s.config.value">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.kind==='delay'">
|
||||
<div>
|
||||
<div class="setting-label">Secondes (0-86400 · exécution plafonnée à 300 s)</div>
|
||||
<input class="settings-input" type="number" min="0" max="86400" x-model.number="s.config.seconds">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.kind==='action'">
|
||||
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||
<select class="settings-input" x-model="s.config.type">
|
||||
<option value="webhook">Webhook</option>
|
||||
<option value="set_property">Définir une propriété</option>
|
||||
<option value="create_page">Créer une page</option>
|
||||
<option value="notify">Notification</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="forge_issue">Issue Gitea/GitHub</option>
|
||||
<option value="agent_trigger">Déclencher un agent</option>
|
||||
</select>
|
||||
<template x-if="s.config.type==='webhook'">
|
||||
<input class="settings-input" placeholder="https://…" x-model="s.config.url">
|
||||
</template>
|
||||
<template x-if="s.config.type==='set_property'">
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input class="settings-input" placeholder="Propriété" x-model="s.config.property">
|
||||
<input class="settings-input" placeholder="Valeur" x-model="s.config.value">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.config.type==='create_page'">
|
||||
<div style="display:flex;gap:8px;">
|
||||
<select class="settings-input" x-model="s.config.collection_id">
|
||||
<option value="">Base (optionnel)</option>
|
||||
<template x-for="c in globalCollections" :key="c.id">
|
||||
<option x-bind:value="c.id" x-text="c.icon + ' ' + c.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input class="settings-input" placeholder="Titre" x-model="s.config.title">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.config.type==='notify'">
|
||||
<input class="settings-input" placeholder="Message" x-model="s.config.message">
|
||||
</template>
|
||||
<template x-if="s.config.type==='slack'">
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input class="settings-input" placeholder="Webhook URL (chiffrée)" x-model="s.config.webhook_url">
|
||||
<input class="settings-input" placeholder="Texte" x-model="s.config.text">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.config.type==='email'">
|
||||
<div style="display:flex;gap:8px;flex-direction:column;">
|
||||
<input class="settings-input" placeholder="À (to)" x-model="s.config.to">
|
||||
<input class="settings-input" placeholder="Sujet" x-model="s.config.subject">
|
||||
<textarea class="settings-input" style="min-height:56px;" placeholder="Corps" x-model="s.config.body"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.config.type==='forge_issue'">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<input class="settings-input" placeholder="owner" x-model="s.config.owner">
|
||||
<input class="settings-input" placeholder="repo" x-model="s.config.repo">
|
||||
<input class="settings-input" placeholder="Titre" x-model="s.config.title">
|
||||
<input class="settings-input" placeholder="Labels (virgules)" x-model="s.config.labels">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.config.type==='agent_trigger'">
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input class="settings-input" type="number" placeholder="Agent ID" x-model.number="s.config.agent_id">
|
||||
<input class="settings-input" placeholder="Message" x-model="s.config.message">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div><button class="btn-sm" @click="saveStep(s)">Enregistrer l'étape</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<select class="settings-input" style="min-width:150px;" x-model="stepNewKind">
|
||||
<option value="trigger">Déclencheur</option>
|
||||
<option value="condition">Condition</option>
|
||||
<option value="delay">Attente</option>
|
||||
<option value="action">Action</option>
|
||||
</select>
|
||||
<button class="btn-sm" @click="addStep()">+ Ajouter l'étape</button>
|
||||
<button class="btn-sm" style="color:var(--accent,#2383e2);" x-show="!editSteps.length && editAutomationId"
|
||||
@click="convertToSteps()">✨ Convertir le JSON en pipeline</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<button class="btn-sm" @click="saveAutomation()" x-text="editAutomationId ? 'Enregistrer' : 'Créer'"></button>
|
||||
<button class="btn-sm" style="color:var(--text-secondary);" x-show="editAutomationId" @click="cancelEditAutomation()">Annuler</button>
|
||||
@@ -770,11 +896,11 @@
|
||||
<td x-text="u.folder_count" style="text-align:center;"></td>
|
||||
<td x-text="u.total_mb + ' MB'" style="text-align:right;"></td>
|
||||
<td>
|
||||
<span style="font-size:11px;color:var(--text-dim);" x-text="u.last_login ? new Date(u.last_login*1000).toLocaleDateString() : 'Never'"></span>
|
||||
<span style="font-size:11px;color:var(--text-dim);" x-text="fmtLastLogin(u)"></span>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn-sm" @click="editingUser=u; editUserForm={login:u.login,name:u.full_name,email:u.email,is_admin:u.is_admin,is_active:u.is_active}" title="Edit">{{ fd_icon("edit",14) }}</button>
|
||||
<button class="btn-sm" @click="editUserRow(u)" title="Edit">{{ fd_icon("edit",14) }}</button>
|
||||
<button class="btn-sm btn-sm-danger" @click="adminDeleteUser(u.id)" title="Delete" x-show="adminUsers.length > 1">{{ fd_icon("trash",14) }}</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -813,7 +939,7 @@
|
||||
<p class="section-desc">Actions API, changements de permissions et connexions SSO (unifiés).</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center;">
|
||||
<template x-for="s in ['all','api','permissions','sso']" :key="s">
|
||||
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="auditSource=s; loadAuditLogs()" x-text="s"></button>
|
||||
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="setAuditSource(s)" x-text="s"></button>
|
||||
</template>
|
||||
<input type="text" placeholder="actor (user id)" x-model="auditActor" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
|
||||
<input type="text" placeholder="action (LIKE)" x-model="auditAction" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
|
||||
@@ -837,7 +963,7 @@
|
||||
<tbody>
|
||||
<template x-for="e in auditLogs" :key="e.at + '-' + e.source + '-' + e.actor">
|
||||
<tr>
|
||||
<td><span style="font-size:12px;" x-text="new Date(e.at).toLocaleString()"></span></td>
|
||||
<td><span style="font-size:12px;" x-text="fmtAuditDate(e)"></span></td>
|
||||
<td><span style="font-size:11px;" :class="'audit-src audit-src-'+e.source" x-text="e.source"></span></td>
|
||||
<td><code style="font-size:11px;" x-text="e.actor"></code></td>
|
||||
<td><code style="font-size:11px;" x-text="e.action"></code></td>
|
||||
@@ -853,7 +979,7 @@
|
||||
</table>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:center;margin-top:12px;">
|
||||
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditOffset+=auditPageSize; loadAuditLogs(false)">Load more</button>
|
||||
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditNext()">Load more</button>
|
||||
</div>
|
||||
<style>
|
||||
.audit-src{display:inline-block;padding:1px 7px;border-radius:8px;font-weight:600;}
|
||||
|
||||
@@ -100,6 +100,11 @@
|
||||
</div>
|
||||
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: tableView »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); });
|
||||
function tableView() {
|
||||
return {
|
||||
sortField: '',
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
</div>
|
||||
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: teamLoad »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); });
|
||||
function teamLoad() {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -63,6 +63,11 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: trashData »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
|
||||
function trashData() {
|
||||
return {
|
||||
search: '',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bienvenue sur FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<style>
|
||||
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
@@ -134,6 +134,11 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
|
||||
</div>
|
||||
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: onboarding »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('onboarding', onboarding); });
|
||||
function onboarding() {
|
||||
return {
|
||||
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block topbar %}
|
||||
{% set page_icon = "home" %}
|
||||
{% set page_title = "Workspace — Projects" %}
|
||||
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
||||
{% set right_actions %}<a href="/auth/login?provider=local" class="topbar-btn" title="Login">{{ fd_icon("key",16) }}</a><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<template x-for="p in builtinProjects" :key="p.id">
|
||||
<div class="project-card" @click="openProject(p)">
|
||||
<div class="project-card-top">
|
||||
<span class="project-card-icon" x-html="getSvgIcon(p.icon || 'folder',20)"></span>
|
||||
<span class="project-card-icon" x-init="bindProjectIcon($el, p)"></span>
|
||||
<span class="forge-badge builtin">Built-in</span>
|
||||
</div>
|
||||
<div class="project-card-name" x-text="p.name"></div>
|
||||
@@ -141,6 +141,11 @@
|
||||
</div>
|
||||
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: workspacePage »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('workspacePage', workspacePage); });
|
||||
function workspacePage() {
|
||||
return {
|
||||
builtinProjects: [],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block topbar %}
|
||||
{% set breadcrumb_items = [{"label": "Workspaces", "url": None}] %}
|
||||
{% set page_icon = "home" %}
|
||||
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
||||
{% set right_actions %}<a href="/auth/login?provider=local" class="topbar-btn" title="Login">{{ fd_icon("key",16) }}</a><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -159,12 +159,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Create/Rename dialog -->
|
||||
<div class="dialog-overlay" x-show="showCreate||showRename" @click.outside="showCreate=false;showRename=false">
|
||||
<div class="dialog-overlay" x-show="showCreate||showRename" @click.outside="closeCreateRename()">
|
||||
<div class="dialog-box">
|
||||
<h3 x-text="showRename?'Rename Workspace':'New Workspace'"></h3>
|
||||
<input class="dialog-input" x-model="wsName" placeholder="Workspace name" @keydown.enter="showRename?doRename():doCreate()">
|
||||
<div class="dialog-actions">
|
||||
<button class="btn btn-secondary" @click="showCreate=false;showRename=false">Cancel</button>
|
||||
<button class="btn btn-secondary" @click="closeCreateRename()">Cancel</button>
|
||||
<button class="btn btn-primary" @click="showRename?doRename():doCreate()" x-text="showRename?'Rename':'Create'"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.34.0"
|
||||
"version": "7.45.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// CSP build : x-data="foo()" — globale window vs Alpine.data, lequel résout ?
|
||||
const { chromium } = require('playwright-core');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
function findChromium() {
|
||||
const root = path.join(process.env.LOCALAPPDATA, 'ms-playwright');
|
||||
const dirs = fs.readdirSync(root).filter((d) => d.startsWith('chromium-') && !d.includes('headless'));
|
||||
dirs.sort();
|
||||
return path.join(root, dirs[dirs.length - 1], 'chrome-win64', 'chrome.exe');
|
||||
}
|
||||
const ALPINE = fs.readFileSync(
|
||||
'C:/Users/bruno/AppData/Local/hermes/cache/scratch/alpine_csp.js',
|
||||
'utf-8'
|
||||
);
|
||||
const html = `<!DOCTYPE html><html><body>
|
||||
<div id="a" x-data="composantGlobal()" x-init="init()"><span id="s1" x-text="v"></span></div>
|
||||
<div id="b" x-data="composantAlpineData()" x-init="init()"><span id="s2" x-text="v"></span></div>
|
||||
<script>${ALPINE.replace(/<\/script>/g, '<\\/script>')}</script>
|
||||
<script>
|
||||
function composantGlobal() { return { v: '?', init() { this.v = 'glok'; } }; }
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('composantAlpineData', () => ({ v: '?', init() { this.v = 'adok'; } }));
|
||||
});
|
||||
</script></body></html>`;
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true, executablePath: findChromium() });
|
||||
const page = await browser.newPage();
|
||||
const errs = [];
|
||||
page.on('pageerror', (e) => errs.push(e.message.slice(0, 120)));
|
||||
await page.setContent(html, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(500);
|
||||
const res = await page.evaluate(() => ({
|
||||
global: document.querySelector('#s1').textContent,
|
||||
alpineData: document.querySelector('#s2').textContent,
|
||||
}));
|
||||
console.log('RESULTATS:', JSON.stringify(res), 'ERREURS:', errs.length ? errs : 'aucune');
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,237 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* Aperçu CSP strict (A20 phase 3) : charge la page avec le build CSP
|
||||
* d'Alpine (fichier officiel `@alpinejs/csp`, 0 eval) servi à la place de
|
||||
* alpine.min.js via interception — SANS déployer. Toute expression que le
|
||||
* parseur maison ne digère pas = pageerror « CSP Parser Error » (filet) ;
|
||||
* les x-html restants = directive interdite du build (console error).
|
||||
* Quand toutes les surfaces passent ici → bascule réelle + retrait
|
||||
* d'unsafe-eval (ROADMAP A20 phase 3).
|
||||
*/
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
const errors = [];
|
||||
let currentUrl = '';
|
||||
test.beforeEach(async ({ page }) => {
|
||||
errors.length = 0;
|
||||
currentUrl = '';
|
||||
await page.route('**/static/js/alpine.min.js', (route) =>
|
||||
route.fulfill({
|
||||
path: require('path').join(__dirname, 'fixtures', 'alpine.csp.js'),
|
||||
contentType: 'application/javascript',
|
||||
})
|
||||
);
|
||||
page.on('console', (m) => {
|
||||
if (m.type() !== 'error') return;
|
||||
if (/Failed to load resource/.test(m.text())) return;
|
||||
errors.push(m.text());
|
||||
});
|
||||
page.on('pageerror', (e) =>
|
||||
errors.push('pageerror@' + (currentUrl || '?') + ': ' + e.message)
|
||||
);
|
||||
});
|
||||
test.afterEach(() => expect(errors).toEqual([]));
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page
|
||||
.waitForURL('**/workspaces', { timeout: 10000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!ok) {
|
||||
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||
data: { email: USER, password: PASS, name: 'E2E' },
|
||||
});
|
||||
if (resp.status() === 409) throw new Error('compte e2e existant — FD_USER/FD_PASS incorrects');
|
||||
if (!resp.ok()) throw new Error(`register ${resp.status()}: ${await resp.text()}`);
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function assertBound(page) {
|
||||
return page.evaluate(() => {
|
||||
const el = document.querySelector('[x-data]');
|
||||
if (!el || !window.Alpine) return 'absent';
|
||||
try {
|
||||
const d = window.Alpine.$data(el);
|
||||
return d && typeof d === 'object' ? 'ok' : 'vide';
|
||||
} catch (e) {
|
||||
return 'throw:' + e.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('A20-ph3 : surfaces simples sous build CSP (welcome/trash/accounts/workspace)', async ({ page }) => {
|
||||
// scan statique = 0 expression/x-html sur ces gabarits → ici on traque
|
||||
// les échecs RUNTIME (globales, timing de registre, scope de structure)
|
||||
// /welcome est anonyme (avant login aussi) mais login() ne gêne pas
|
||||
await login(page);
|
||||
for (const url of ['/welcome', '/trash', '/accounts', '/workspace', '/import',
|
||||
'/gitea-workspace']) {
|
||||
currentUrl = url;
|
||||
await page.goto(FD_BASE + url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(500);
|
||||
expect(await assertBound(page), `x-data non lié sur ${url}`).toBe('ok');
|
||||
}
|
||||
|
||||
// panneau agent (composant de base, x-html markdown migré via bindMarkdown)
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(400);
|
||||
const agent = await page.evaluate(() => {
|
||||
const el = document.querySelector('#fd-agent-panel');
|
||||
if (!el || !window.Alpine) return 'absent';
|
||||
try {
|
||||
const d = window.Alpine.$data(el);
|
||||
return d && typeof d === 'object' ? 'ok' : 'vide';
|
||||
} catch (e) {
|
||||
return 'throw:' + e.message;
|
||||
}
|
||||
});
|
||||
expect(agent).toBe('ok');
|
||||
});
|
||||
|
||||
test('A20-ph3 : éditeur de page (right_actions) sous build CSP', async ({ page }) => {
|
||||
// le topbar vit dans le scope appState : les12 sites window.E ont été
|
||||
// remplacés par edCall/edTimeAgo/edCommentCount/edShared/bindStar —
|
||||
// toute expression non parsable = pageerror (filet).
|
||||
await login(page);
|
||||
const coll = await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/db/api', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'e2e-csp-editor' }),
|
||||
});
|
||||
return r.json();
|
||||
});
|
||||
expect(coll.id, JSON.stringify(coll)).toBeTruthy();
|
||||
try {
|
||||
await page.goto(`${FD_BASE}/pages/${coll.id}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
// le composant éditeur est lié
|
||||
const editor = await page.evaluate(() => {
|
||||
const el = document.querySelector('#page-editor, .page-editor, [x-data]');
|
||||
const root = document.querySelector('.app-layout');
|
||||
const d1 = root && window.Alpine ? window.Alpine.$data(root) : null;
|
||||
const hasEd = d1 && typeof d1.edCall === 'function';
|
||||
let ed = 'absent';
|
||||
try {
|
||||
const cand = Array.from(document.querySelectorAll('[x-data]'))
|
||||
.map((e) => e.getAttribute('x-data'))
|
||||
.filter((a) => a && a.startsWith('editorState'));
|
||||
ed = cand.length ? 'ok' : 'aucun-editorState';
|
||||
} catch (e) { ed = 'throw'; }
|
||||
return { ed: ed, delegates: hasEd ? 'ok' : 'absent', el: !!el };
|
||||
});
|
||||
expect(editor.delegates).toBe('ok');
|
||||
expect(editor.ed).toBe('ok');
|
||||
// les boutons du topbar sont réellement servis (bug A10 « ~ + Markup »
|
||||
// corrigé : block-set) ET leurs expressions passent le filet CSP
|
||||
await expect(page.locator('.star-btn').first()).toBeAttached();
|
||||
} finally {
|
||||
await page.evaluate(async (id) => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch(`/db/api/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
|
||||
}, coll.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('UI : éditeur visuel de steps automations (API v7.0)', async ({ page }) => {
|
||||
// crée une automation, ouvre l'édition, ajoute une étape (POST /steps),
|
||||
// vérifie la carte résumée — sous CSP réel (expressions du nouveau bloc)
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(500);
|
||||
await page.click('.settings-nav-item:has-text("Automations")');
|
||||
const name = 'e2e-steps-' + Date.now();
|
||||
await page.fill('input[placeholder*="Notifier le statut"]', name);
|
||||
await page.click('button:has-text("Créer")');
|
||||
await page.waitForTimeout(700);
|
||||
const row = page.locator('.setting-row', { hasText: name }).last();
|
||||
await row.locator('button:has-text("✎")').click();
|
||||
await page.waitForTimeout(700);
|
||||
await expect(page.locator('text=Pipeline visuel (steps)')).toBeVisible();
|
||||
await page.click('button:has-text("Ajouter l\'étape")');
|
||||
await page.waitForFunction(
|
||||
() => Array.from(document.querySelectorAll('.setting-label'))
|
||||
.some((e) => (e.textContent || '').includes('Action · webhook')),
|
||||
null,
|
||||
{ timeout: 8000 }
|
||||
);
|
||||
// nettoyage API
|
||||
await page.evaluate(async (n) => {
|
||||
const d = await (await fetch('/workspace/automations')).json();
|
||||
const a = (d.automations || []).find((x) => x.name === n);
|
||||
if (a) {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch('/workspace/automations/' + a.id,
|
||||
{ method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
|
||||
await fetch('/workspace/automations/' + a.id + '/steps', { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
}, name);
|
||||
});
|
||||
|
||||
test('A20-ph3 : settings sous build Alpine CSP', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
expect(await assertBound(page)).toBe('ok');
|
||||
await expect(page.locator('.settings-overlay')).toBeVisible();
|
||||
});
|
||||
|
||||
test('A20-ph3 : local workspace sous build Alpine CSP', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/local-workspace`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
expect(await assertBound(page)).toBe('ok');
|
||||
|
||||
// recherche : toggleSearch() (méthode réelle) + focus Alpine.nextTick
|
||||
await page.click('button.ws-icon-btn[title="Search"]');
|
||||
await page.waitForTimeout(400);
|
||||
const focused = await page.evaluate(
|
||||
() => document.activeElement && document.activeElement.getAttribute('x-ref') === 'searchInput'
|
||||
);
|
||||
expect(focused).toBe(true);
|
||||
|
||||
// chips filtre : bindSvg() via x-init (x-html interdit en CSP) —
|
||||
// x-init tourne même si la rangée est masquée (x-show=filterType)
|
||||
const svg = await page.evaluate(
|
||||
() => (document.querySelector('button.filter-chip[title="Folders"]') || {}).innerHTML || ''
|
||||
);
|
||||
expect(svg).toContain('<svg');
|
||||
});
|
||||
|
||||
test('A20-ph3 : library sous build Alpine CSP', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// le composant est lié par le registre Alpine.data (scope CSP)
|
||||
expect(await assertBound(page)).toBe('ok');
|
||||
|
||||
// icône du empty-state : x-html remplacé par x-init + Alpine.effect
|
||||
await expect(page.locator('#lib-empty .empty-icon')).toBeVisible({ timeout: 8000 });
|
||||
const svg = await page.evaluate(
|
||||
() => document.querySelector('#lib-empty .empty-icon').innerHTML
|
||||
);
|
||||
expect(svg).toContain('<svg');
|
||||
|
||||
// bouton recherche : @click.stop → toggleSearch() (méthode réelle,
|
||||
// Alpine.nextTick pour le focus) — l'expression inline arrow n'existe plus
|
||||
await page.click('.lib-icon-btn[title="Search"]');
|
||||
await expect(page.locator('#lib-search-input')).toBeVisible();
|
||||
await page.waitForTimeout(300);
|
||||
const focused = await page.evaluate(
|
||||
() => document.activeElement && document.activeElement.id === 'lib-search-input'
|
||||
);
|
||||
expect(focused).toBe(true);
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,186 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* Smoke E2E — fondations vérifiant les portes des reports d'audit :
|
||||
* - A39 : bascule de vues (création d'une vue Board depuis la barre de
|
||||
* vues d'une collection → rendu de la grille)
|
||||
* - A20 : Alpine + palette de commandes (Ctrl+K, recherche GET, fermeture)
|
||||
* - filet : 0 erreur console (les violations CSP atterrissent ici)
|
||||
*
|
||||
* READ-ONLY sur les données existantes : crée puis SUPPRIME sa collection
|
||||
* (répétable). Instance de test attendue sur FD_BASE_URL (défaut 8080).
|
||||
*/
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
// Service Workers BLOQUÉS : /sw.js sert sa page « hors ligne » quand la
|
||||
// réponse de navigation est une redirection (redirect:'manual' sur les
|
||||
// requêtes navigate) — bruit PWA hors sujet ici (pwa_offline.spec.js
|
||||
// couvre le SW). On interroge le serveur directement.
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
const consoleErrors = [];
|
||||
test.beforeEach(async ({ page }) => {
|
||||
consoleErrors.length = 0;
|
||||
page.on('console', (m) => {
|
||||
if (m.type() !== 'error') return;
|
||||
// les 401 de ressources (checks de session sur login) sont du bruit
|
||||
// navigateur, pas une erreur JS/CSP — le reste compte
|
||||
if (/Failed to load resource/.test(m.text())) return;
|
||||
consoleErrors.push(m.text());
|
||||
});
|
||||
page.on('pageerror', (e) => consoleErrors.push('pageerror: ' + e.message));
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
// Aucune erreur JS/CSP pendant le scénario
|
||||
expect(consoleErrors).toEqual([]);
|
||||
});
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await Promise.race([
|
||||
page
|
||||
.waitForURL('**/workspaces', { timeout: 8000 })
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
]);
|
||||
if (!ok) {
|
||||
// Compte absent de l'instance de test → création (bootstrap du harness,
|
||||
// pas un mot de passe deviné : c'est le compte e2e documenté du repo).
|
||||
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||
data: { email: USER, password: PASS, name: 'E2E' },
|
||||
});
|
||||
if (resp.status() === 409) {
|
||||
throw new Error(
|
||||
'compte e2e existant mais mot de passe refusé — définir FD_USER/FD_PASS'
|
||||
);
|
||||
}
|
||||
if (!resp.ok()) {
|
||||
throw new Error(`register ${resp.status()}: ${await resp.text()}`);
|
||||
}
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
// workspace requis pour créer une collection (compte neuf = aucun ws)
|
||||
const ws = await page.evaluate(async () => (await fetch('/api/workspaces')).json());
|
||||
if (!ws.workspaces || ws.workspaces.length === 0) {
|
||||
await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'E2E workspace' }),
|
||||
});
|
||||
const w = await r.json();
|
||||
await fetch(`/api/workspaces/${w.id}/select`, { method: 'POST' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('gate A39 : bascule de vues (table → Calendar, rendu par onglet)', async ({ page }) => {
|
||||
await login(page);
|
||||
|
||||
// collection jetable (créée puis supprimée = répétable). /db/{id} est une
|
||||
// page STANDALONE (hors base.html) : les onglets .view-tabs naviguent en
|
||||
// ?view=… et le corps est rendu côté serveur par _render_view().
|
||||
const coll = await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/db/api', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'e2e-view-switch' }),
|
||||
});
|
||||
return r.json();
|
||||
});
|
||||
expect(coll.id, `création collection: ${JSON.stringify(coll)}`).toBeTruthy();
|
||||
try {
|
||||
await page.goto(`${FD_BASE}/db/${coll.id}`, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.view-tabs a.tab')).toHaveCount(11);
|
||||
await expect(page.locator('.calendar')).toHaveCount(0); // vue table par défaut
|
||||
|
||||
// bascule réelle : clic sur l'onglet Calendar → navigation ?view=calendar
|
||||
await page.click('.view-tabs a.tab:has-text("Calendar")');
|
||||
await page.waitForURL(/view_type=calendar/, { timeout: 10000 });
|
||||
await expect(page.locator('.view-tabs a.tab.active')).toContainText('Calendar');
|
||||
// corps Calendar rendu par _render_calendar (grille 6×7)
|
||||
await expect(page.locator('.calendar')).toHaveCount(1);
|
||||
expect(await page.locator('.cal-header').count()).toBeGreaterThanOrEqual(7);
|
||||
} finally {
|
||||
await page.evaluate(async (id) => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch(`/db/api/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
|
||||
}, coll.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('gate A20 : palette Ctrl+K (Alpine + recherche GET)', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await page.keyboard.press('Control+k');
|
||||
await page.waitForTimeout(400);
|
||||
// overlay ouvert (classe .open pilotée par l'IIFE de base.html)
|
||||
const open = await page.evaluate(() => {
|
||||
const ov = document.querySelector('#fd-command-palette');
|
||||
return !!ov && ov.classList.contains('open');
|
||||
});
|
||||
expect(open).toBe(true);
|
||||
|
||||
// tape une requête → la recherche GET répond et rend des résultats
|
||||
await page.keyboard.type('a');
|
||||
await page.waitForTimeout(900);
|
||||
const items = await page.evaluate(
|
||||
() => document.querySelectorAll('.cmd-palette-item').length
|
||||
);
|
||||
expect(items).toBeGreaterThan(0);
|
||||
|
||||
// Alpine doit être lié (build CSP : le x-data + ses expressions évalués
|
||||
// SANS eval) sur un composant réel de la page
|
||||
const alpine = await page.evaluate(() => {
|
||||
const el = document.querySelector('[x-data]');
|
||||
if (!el || !window.Alpine) return 'absent';
|
||||
try {
|
||||
const data = window.Alpine.$data(el);
|
||||
return data && typeof data === 'object' ? 'ok:' + Object.keys(data).slice(0, 3).join(',') : 'vide';
|
||||
} catch (e) {
|
||||
return 'throw:' + e.message;
|
||||
}
|
||||
});
|
||||
expect(alpine).toMatch(/^ok:/);
|
||||
|
||||
// Échap ferme la palette
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
const closed = await page.evaluate(() => {
|
||||
const ov = document.querySelector('#fd-command-palette');
|
||||
return !ov || !ov.classList.contains('open');
|
||||
});
|
||||
expect(closed).toBe(true);
|
||||
|
||||
// onglet ✨ Réponses IA : POST /api/v2/search/ask (extractif hors-LM)
|
||||
await page.keyboard.press('Control+k');
|
||||
await page.waitForTimeout(300);
|
||||
await page.click('.cp-tab[data-tab="ai"]');
|
||||
await page.waitForSelector('.cmd-palette-ai', { timeout: 10000 });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const el = document.querySelector('.cmd-palette-ai');
|
||||
return el && !el.querySelector('.cp-loading');
|
||||
},
|
||||
null,
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
const ai = await page.evaluate(() => {
|
||||
const el = document.querySelector('.cmd-palette-ai');
|
||||
return { text: ((el && el.textContent) || '').trim() };
|
||||
});
|
||||
expect(ai.text.length).toBeGreaterThan(5);
|
||||
});
|
||||
@@ -40,6 +40,8 @@ const browserGlobals = {
|
||||
Alpine: "readonly", htmx: "readonly", Sortable: "readonly",
|
||||
// Globals exposed on window by app.js
|
||||
openModal: "readonly", closeModal: "readonly",
|
||||
// getCsrf : helper unique posé dans le <head> de base.html (A38 phase 1)
|
||||
getCsrf: "readonly",
|
||||
// getSvgIcon : script inline de base.html (nonce) ; Prism : CDN des vues ;
|
||||
// TextDecoder : API navigateur (ES2015)
|
||||
getSvgIcon: "readonly", Prism: "readonly", TextDecoder: "readonly",
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ pythonpath = ["."]
|
||||
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
target-version = "py313"
|
||||
line-length = 110
|
||||
exclude = [".venv", ".venv311", ".pytest_cache", "static/js/vendor"]
|
||||
|
||||
|
||||
+83
-1
@@ -3,7 +3,89 @@
|
||||
Référence: Notion Light Mode (par défaut)
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
/* Inter auto-hébergé — remplace l'@import Google Fonts que la CSP bloque
|
||||
(style-src sans fonts.googleapis depuis v7.27). Police variable v20 :
|
||||
une face par sous-ensemble, font-weight 100-900. */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-400-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-500-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-500-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-600-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-600-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-700-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-700-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
|
||||
/* ===== LIGHT THEME (default) ===== */
|
||||
:root {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -21,6 +21,10 @@
|
||||
window.Alpine.__fdCtxStore = true;
|
||||
|
||||
Alpine.store('fdCtx', {
|
||||
addTagAndClear(value, color, el){
|
||||
this.addNewTag(value, color);
|
||||
if (el) el.value = '';
|
||||
},
|
||||
open: false, x: 0, y: 0, node: null, page: null,
|
||||
maxH: 0, _cx: 0, _cy: 0, _ro: null,
|
||||
handlers: {},
|
||||
|
||||
@@ -265,6 +265,10 @@
|
||||
});
|
||||
}).catch(function(){});
|
||||
},
|
||||
// ── A20 ph3 : x-html (markdown) → x-init + effet réactif ──
|
||||
bindMarkdown(el, m){
|
||||
Alpine.effect(() => { el.innerHTML = this.renderMarkdown(m.content); });
|
||||
},
|
||||
approveProposal(m){
|
||||
if(!m || m.applied || m.offline) return;
|
||||
var ok = window.fdApplyDocument ? window.fdApplyDocument(m.content, m.mode) : false;
|
||||
@@ -390,6 +394,7 @@
|
||||
this._insertToken({token:pin.token, label:pin.label, icon:pin.icon, kind:'skill'});
|
||||
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
|
||||
},
|
||||
showHistory(){ this.tab = 'history'; this.loadConversations(); },
|
||||
loadConversations(){
|
||||
var self = this;
|
||||
fetch('/api/agent/conversations').then(function(r){return r.json()}).then(function(d){
|
||||
|
||||
Vendored
+23
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
|
||||
// FlowDeck Notion UI — Client-side logic
|
||||
// Alpine.js + SortableJS + HTMX + Mobile support
|
||||
/* exported openCardDetail */ /* global owner, repo */
|
||||
|
||||
// ── Frontend Error Capture ──────────────────────────────────
|
||||
// Intercepte TOUTES les erreurs JS et les envoie au backend.
|
||||
|
||||
+25
-2
@@ -18,6 +18,11 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
el.classList.add('active');
|
||||
}
|
||||
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: kanbanBoard »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('kanbanBoard', kanbanBoard); });
|
||||
function kanbanBoard() {
|
||||
return {
|
||||
collapsedGroups: [],
|
||||
@@ -33,6 +38,11 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
};
|
||||
}
|
||||
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: filterSystem »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('filterSystem', filterSystem); });
|
||||
function filterSystem() {
|
||||
return {
|
||||
activeFilters: [],
|
||||
@@ -60,6 +70,9 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
},
|
||||
removeFilter(i) { this.activeFilters.splice(i, 1); },
|
||||
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
|
||||
removeFilterAndRefresh(i) { this.removeFilter(i); this.refreshView(); },
|
||||
resetFiltersAndRefresh() { this.resetFilters(); this.refreshView(); },
|
||||
pickStatus(v) { this.toggleStatus(v); this.showStatusMenu = false; this.refreshView(); },
|
||||
refreshView() {
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
@@ -67,6 +80,11 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
};
|
||||
}
|
||||
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: sortSystem »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('sortSystem', sortSystem); });
|
||||
function sortSystem() {
|
||||
return {
|
||||
sorts: [],
|
||||
@@ -89,12 +107,17 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
};
|
||||
}
|
||||
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: newIssueForm »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('newIssueForm', newIssueForm); });
|
||||
function newIssueForm() {
|
||||
return {
|
||||
title: '', status: 'todo',
|
||||
create() {
|
||||
if (!this.title.trim()) return;
|
||||
const csrf = getCsrf();;
|
||||
const csrf = getCsrf();
|
||||
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf }
|
||||
@@ -131,7 +154,7 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
onEnd: function(evt) {
|
||||
const cardId = evt.item.dataset.cardId;
|
||||
const toStatus = evt.to.dataset.status;
|
||||
const csrf = getCsrf();;
|
||||
const csrf = getCsrf();
|
||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf }
|
||||
|
||||
@@ -367,6 +367,20 @@ document.addEventListener('alpine:init', () => {
|
||||
this.loadSidebarTree();
|
||||
},
|
||||
|
||||
// ── A20 ph3 : new Date / x-html interdits sous build CSP ──
|
||||
closePrivateEdit() {
|
||||
this.editingPrivate = null;
|
||||
this.editingPrivateTitle = '';
|
||||
this.editingPrivateContent = '';
|
||||
},
|
||||
fmtGwDate(pp) {
|
||||
return pp.updated_at ? new Date(pp.updated_at + 'Z').toLocaleString() : '';
|
||||
},
|
||||
bindGwIcon(el, item) {
|
||||
Alpine.effect(() => {
|
||||
el.innerHTML = item.type === 'folder' ? getSvgIcon('folder', 16) : getSvgIcon('file', 16);
|
||||
});
|
||||
},
|
||||
formatSize(bytes) {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/* exported importWizard -- appeles depuis les attributs HTML des templates */
|
||||
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: importWizard »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('importWizard', importWizard); });
|
||||
function importWizard() {
|
||||
return {
|
||||
sources: [],
|
||||
|
||||
+43
-2
@@ -1,5 +1,11 @@
|
||||
/* exported libraryPage -- appeles depuis les attributs HTML des templates */
|
||||
|
||||
// A20 phase 3 : enregistrement Alpine.data — le build CSP ne résout que le
|
||||
// registre (probe : globale window → « Undefined variable: libraryPage »).
|
||||
document.addEventListener('alpine:init', function () {
|
||||
Alpine.data('libraryPage', libraryPage);
|
||||
});
|
||||
|
||||
function libraryPage() {
|
||||
return {
|
||||
tab: 'recents',
|
||||
@@ -160,7 +166,42 @@ function libraryPage() {
|
||||
|
||||
_escHtml(s) { var d=document.createElement('div'); d.textContent = s||''; return d.innerHTML; },
|
||||
_escAttr(s) { return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); },
|
||||
_renderIcon(item) {
|
||||
openMoveSelected() {
|
||||
this.openMovePicker(Object.keys(this.selected).map(Number));
|
||||
},
|
||||
// ── A20 phase 3 (surface library) : formes compatibles build CSP ──
|
||||
// $nextTick / x-html ne passent pas par l'évaluateur maison du build CSP
|
||||
// (arrow inline = parse error ; x-html = interdit) → méthodes JS réelles,
|
||||
// réactivité via Alpine.effect — valable sous les deux builds.
|
||||
toggleFilterMenu() {
|
||||
this.filterOpen = !this.filterOpen;
|
||||
this.sortOpen = false;
|
||||
this.viewOpen = false;
|
||||
},
|
||||
toggleSortMenu() {
|
||||
this.sortOpen = !this.sortOpen;
|
||||
this.filterOpen = false;
|
||||
this.viewOpen = false;
|
||||
},
|
||||
toggleViewMenu() {
|
||||
this.viewOpen = !this.viewOpen;
|
||||
this.filterOpen = false;
|
||||
this.sortOpen = false;
|
||||
},
|
||||
toggleSearch() {
|
||||
this.searchOpen = !this.searchOpen;
|
||||
if (this.searchOpen) Alpine.nextTick(() => {
|
||||
var el = document.getElementById('lib-search-input');
|
||||
if (el) el.focus();
|
||||
});
|
||||
},
|
||||
bindHtmlIcon(el) {
|
||||
Alpine.effect(() => { el.innerHTML = getSvgIcon(this.emptyIcon, 48); });
|
||||
},
|
||||
bindHtmlItem(el, item) {
|
||||
Alpine.effect(() => { el.innerHTML = this._renderIcon(item); });
|
||||
},
|
||||
_renderIcon(item) {
|
||||
if (!item) return getSvgIcon('file', 14);
|
||||
// Custom emoji (image URL), icon name, or unicode emoji
|
||||
if (item.page_icon) return '<span style="font-size:14px;line-height:1;display:inline-flex;align-items:center;">' + (window.fdIconHtml ? window.fdIconHtml(item.page_icon, 14) : this._escHtml(item.page_icon)) + '</span>';
|
||||
@@ -363,7 +404,7 @@ function libraryPage() {
|
||||
},
|
||||
|
||||
_getCsrf() {
|
||||
var m = getCsrf();;
|
||||
var m = getCsrf();
|
||||
return m;
|
||||
},
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const LW=(()=>{try{const el=document.getElementById('lw-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
|
||||
window._wsInitData = (function() {
|
||||
const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
||||
// invisible au snapshot ji du build CSP (valeur bannie sinon)
|
||||
return {
|
||||
tree:[],
|
||||
displayTree:[],
|
||||
@@ -2007,13 +2008,85 @@ window._wsInitData = (function() {
|
||||
this.clearSelection();
|
||||
this._reloadAfterAction();
|
||||
if (window.showToast) window.showToast('Item' + (ids.length > 1 ? 's' : '') + ' moved', 'success');
|
||||
}
|
||||
},
|
||||
|
||||
};
|
||||
// ── A20 ph3 : x-html interdit par le build CSP → x-init + Alpine.effect
|
||||
// (réactivité conservée : lecture des données réactives dans l'effect) ──
|
||||
toggleViewMenu() {
|
||||
this.viewMenuOpen = !this.viewMenuOpen;
|
||||
this.sortOpen = false;
|
||||
this.searchOpen = false;
|
||||
},
|
||||
toggleSortMenu() {
|
||||
this.sortOpen = !this.sortOpen;
|
||||
this.viewMenuOpen = false;
|
||||
},
|
||||
toggleFilterMenu() {
|
||||
this.filterOpen = !this.filterOpen;
|
||||
this.viewMenuOpen = false;
|
||||
this.sortOpen = false;
|
||||
},
|
||||
toggleSearch() {
|
||||
this.searchOpen = !this.searchOpen;
|
||||
if (this.searchOpen) Alpine.nextTick(() => {
|
||||
const el = document.querySelector('[x-ref="searchInput"]');
|
||||
if (el) el.focus();
|
||||
});
|
||||
},
|
||||
createPageAt(node) { window.FlowDeck.createPage(node.id); },
|
||||
createFolderAt(node) { window.FlowDeck.showCreateFolderModal(node.id); },
|
||||
bindSvg(el, name, size) { el.innerHTML = getSvgIcon(name, size); },
|
||||
bindFileIcon(el, node) {
|
||||
Alpine.effect(() => {
|
||||
el.innerHTML = this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
|
||||
});
|
||||
},
|
||||
bindNodeIcon(el, node) {
|
||||
Alpine.effect(() => {
|
||||
el.innerHTML = node.is_folder
|
||||
? getSvgIcon('folder', 24)
|
||||
: this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
|
||||
});
|
||||
},
|
||||
bindChildren(el, node) {
|
||||
Alpine.effect(() => {
|
||||
el.innerHTML = this.renderChildren(node.children, (node.depth || 0) + 1, this.tagsVersion);
|
||||
});
|
||||
},
|
||||
bindPreview(el) { Alpine.effect(() => { el.innerHTML = this.previewContent; }); },
|
||||
};
|
||||
})();
|
||||
// Injecter toutes les props comme globales pour Alpine (résout les 36 erreurs de scope)
|
||||
var _wsKeys = Object.keys(window._wsInitData);
|
||||
var _wsKeys = Object.keys(_wsInitData);
|
||||
for (var _i = 0; _i < _wsKeys.length; _i++) {
|
||||
try { window[_wsKeys[_i]] = window._wsInitData[_wsKeys[_i]]; } catch { /* volontaire */ }
|
||||
try { window[_wsKeys[_i]] = _wsInitData[_wsKeys[_i]]; } catch { /* volontaire */ }
|
||||
}
|
||||
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
|
||||
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(_wsInitData).length);
|
||||
|
||||
// A20 ph3 : registre Alpine.data (le build CSP ne résout que le registre ;
|
||||
// x-data="wsInitData()" au lieu de l'identifiant global _wsInitData).
|
||||
document.addEventListener('alpine:init', function () {
|
||||
// objet partagé JS↔Alpine via closure LEXICALE (const hors window →
|
||||
// non snapshoté par ji ; probe4 : factory retournant l'objet = accepté).
|
||||
Alpine.data('wsInitData', function () { return _wsInitData; });
|
||||
// Bloc preview : le parseur le place HORS de la div racine (structure
|
||||
// pré-existante) → montage distinct DÉLÉGUANT vers l'objet partagé.
|
||||
// Wrapper = objet unique (les magics $nextTick… ne sont redéfinissables
|
||||
// qu'une fois) + lecture/écriture via Alpine.reactive(_wsInitData) pour
|
||||
// garder la réactivité JS↔Alpine.
|
||||
Alpine.data('wsPreview', function () {
|
||||
const R = Alpine.reactive(_wsInitData);
|
||||
const t = {};
|
||||
['previewVisible', 'previewX', 'previewY', 'previewName'].forEach(function (k) {
|
||||
Object.defineProperty(t, k, {
|
||||
get: function () { return R[k]; },
|
||||
set: function (v) { R[k] = v; },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
t.bindPreview = function (el) {
|
||||
Alpine.effect(function () { el.innerHTML = R.previewContent; });
|
||||
};
|
||||
return t;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1125,6 +1125,16 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
wrap.classList.toggle('full-width',!!this.fullWidth);
|
||||
wrap.classList.toggle('small-text',!!this.fontSmall);
|
||||
},
|
||||
closeInviteSuggest(){ this.inviteSuggestOpen = false; this.inviteUsers = []; },
|
||||
setSharePerm(a, perm){ this.updateShare(a.id, perm); a.permOpen = false; },
|
||||
removeShareAndClose(a){ this.removeShare(a.id); a.permOpen = false; },
|
||||
pickGeneralAccess(v){ this.generalAccess = v; this.accessMenuOpen = false; },
|
||||
applyIcon(){ this.setIcon(this.iconValue); this.iconOpen = false; },
|
||||
moreToggleLock(){ this.moreOpen = false; this.toggleLock(); },
|
||||
moreFullWidth(){ this.moreOpen = false; this.setPageOption('full_width', !this.fullWidth); },
|
||||
moreFontSmall(){ this.moreOpen = false; this.setPageOption('font_small', !this.fontSmall); },
|
||||
moreSaveTemplate(){ this.moreOpen = false; this.saveAsTemplate(); },
|
||||
markAndSave(){ this.dirty = true; this.save(); },
|
||||
async toggleLock(){
|
||||
if(!this.canEdit){this.showToast&&this.showToast('Only the person who locked this page (or an admin) can unlock it','error');return;}
|
||||
const next=!this.isLocked;
|
||||
@@ -1421,6 +1431,15 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
}catch(e){this.showToast('Remove failed','error');}
|
||||
},
|
||||
toggleIcon(){this.iconOpen=!this.iconOpen;},
|
||||
openBacklink(b){
|
||||
this.backlinksOpen = false;
|
||||
window.location.href = '/pages/' + b.id;
|
||||
},
|
||||
fmtImportSize(f){ return Math.round(f.size / 1024) + ' KB'; },
|
||||
// A20 ph3 : x-html interdit sous build CSP → effet réactif
|
||||
bindIconHtml(el){
|
||||
Alpine.effect(() => { el.innerHTML = this.iconHtml(); });
|
||||
},
|
||||
iconHtml(){
|
||||
var v=this.iconValue;
|
||||
if(!v)v=(this.contentFormat==='file')?'paperclip':'file';
|
||||
@@ -2512,5 +2531,10 @@ applyAIBlocks(text){
|
||||
default: return c;
|
||||
}
|
||||
}
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: editorState »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('editorState', editorState); });
|
||||
window.editorState = editorState;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
/* exported settingsInit -- appeles depuis les attributs HTML des templates */
|
||||
const ST=(()=>{try{const el=document.getElementById('st-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: settingsInit »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('settingsInit', settingsInit); });
|
||||
function settingsInit() {
|
||||
return {
|
||||
// ── A20 ph3 : expressions hostiles au parseur CSP (window/Date) ──
|
||||
historyBack() { window.history.back(); },
|
||||
fmtLastLogin(u) {
|
||||
return u.last_login ? new Date(u.last_login * 1000).toLocaleDateString() : 'Never';
|
||||
},
|
||||
fmtAuditDate(e) { return new Date(e.at).toLocaleString(); },
|
||||
|
||||
activeSection: 'account',
|
||||
allTags: [],
|
||||
newTagName: '',
|
||||
@@ -63,6 +75,9 @@ function settingsInit() {
|
||||
automations: [],
|
||||
autoRuns: [],
|
||||
editAutomationId: null,
|
||||
editSteps: [],
|
||||
stepEdit: -1,
|
||||
stepNewKind: 'action',
|
||||
globalCollections: [],
|
||||
af: { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' },
|
||||
|
||||
@@ -761,6 +776,128 @@ function settingsInit() {
|
||||
if (r.ok) { this.githubLinked = false; }
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
// ── Éditeur visuel de steps (API v7.0 : /steps CRUD) ──
|
||||
async loadSteps() {
|
||||
if (!this.editAutomationId) { this.editSteps = []; return; }
|
||||
try {
|
||||
var r = await fetch('/workspace/automations/' + this.editAutomationId + '/steps',
|
||||
{credentials:'same-origin'});
|
||||
var d = await r.json();
|
||||
this.editSteps = Array.isArray(d) ? d : (d.steps || []);
|
||||
} catch { this.editSteps = []; }
|
||||
},
|
||||
stepSummary(s) {
|
||||
var c = s.config || {};
|
||||
if (s.kind === 'trigger') return '\ud83d\udce1 D\u00e9clencheur \u00b7 ' + (c.event || '?');
|
||||
if (s.kind === 'condition') return '\u2696 Condition \u00b7 ' + (c.property || '?') + ' ' + (c.op || 'eq') + ' ' + (c.value || '');
|
||||
if (s.kind === 'delay') return '\u23f3 Attente \u00b7 ' + (c.seconds || 0) + ' s';
|
||||
var extra = c.url || c.message || c.text || c.to || (c.owner ? c.owner + '/' + c.repo : '') || c.title || '';
|
||||
return '\u26a1 Action \u00b7 ' + (c.type || '?') + (extra ? ' \u2192 ' + String(extra).slice(0, 60) : '');
|
||||
},
|
||||
defaultStepConfig(kind) {
|
||||
if (kind === 'trigger') return { event: 'page.created' };
|
||||
if (kind === 'condition') return { property: '', op: 'eq', value: '' };
|
||||
if (kind === 'delay') return { seconds: 60 };
|
||||
return { type: 'webhook', url: '' };
|
||||
},
|
||||
async addStep() {
|
||||
if (!this.editAutomationId) return;
|
||||
try {
|
||||
var r = await fetch('/workspace/automations/' + this.editAutomationId + '/steps', {
|
||||
method: 'POST', credentials:'same-origin',
|
||||
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify({ kind: this.stepNewKind, config: this.defaultStepConfig(this.stepNewKind) })
|
||||
});
|
||||
var d = await r.json();
|
||||
if (!r.ok) { window.showToast && window.showToast(d.detail || '\u00c9chec', 'error'); return; }
|
||||
await this.loadSteps();
|
||||
this.stepEdit = this.editSteps.length - 1;
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
async saveStep(s) {
|
||||
var cfg = s.config || {};
|
||||
// forge_issue : labels en cha\u00eene s\u00e9par\u00e9e par virgules → liste
|
||||
if (cfg.type === 'forge_issue' && typeof cfg.labels === 'string') {
|
||||
cfg.labels = cfg.labels.split(',').map(function(x){ return x.trim(); }).filter(Boolean);
|
||||
}
|
||||
try {
|
||||
var r = await fetch('/workspace/automations/steps/' + s.id, {
|
||||
method:'PUT', credentials:'same-origin',
|
||||
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify({ kind: s.kind, config: cfg })
|
||||
});
|
||||
var d = await r.json();
|
||||
if (!r.ok) { window.showToast && window.showToast(d.detail || '\u00c9chec', 'error'); return; }
|
||||
window.showToast && window.showToast('\u00c9tape enregistr\u00e9e');
|
||||
await this.loadSteps();
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
async delStep(i) {
|
||||
var s = this.editSteps[i]; if (!s) return;
|
||||
try {
|
||||
await fetch('/workspace/automations/steps/' + s.id, {
|
||||
method:'DELETE', credentials:'same-origin',
|
||||
headers:{'X-CSRF-Token': this.getCsrfToken()} });
|
||||
this.stepEdit = -1;
|
||||
await this.loadSteps();
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
async moveStep(i, dir) {
|
||||
var j = i + dir;
|
||||
if (j < 0 || j >= this.editSteps.length) return;
|
||||
var a = this.editSteps[i], b = this.editSteps[j];
|
||||
var h = {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()};
|
||||
try {
|
||||
await fetch('/workspace/automations/steps/' + a.id, {method:'PUT', headers:h,
|
||||
credentials:'same-origin', body: JSON.stringify({position: b.position})});
|
||||
await fetch('/workspace/automations/steps/' + b.id, {method:'PUT', headers:h,
|
||||
credentials:'same-origin', body: JSON.stringify({position: a.position})});
|
||||
await this.loadSteps();
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
async convertToSteps() {
|
||||
// JSON legacy → pipeline ordonn\u00e9 (trigger, conditions, actions)
|
||||
if (!this.editAutomationId) return;
|
||||
var h = {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()};
|
||||
var base = '/workspace/automations/' + this.editAutomationId + '/steps';
|
||||
var order = [];
|
||||
if (this.af.trigger_type !== 'cron') {
|
||||
var tcfg = {event: this.af.event || 'page.created'};
|
||||
if (this.af.collection_id) tcfg.collection_id = parseInt(this.af.collection_id, 10);
|
||||
order.push({kind:'trigger', config: tcfg});
|
||||
}
|
||||
try {
|
||||
JSON.parse(this.af.condition_json || '[]').forEach(function(c){ order.push({kind:'condition', config:c}); });
|
||||
} catch { /* volontaire */ }
|
||||
try {
|
||||
JSON.parse(this.af.actions_json || '[]').forEach(function(a){ order.push({kind:'action', config:a}); });
|
||||
} catch { /* volontaire */ }
|
||||
for (var i = 0; i < order.length; i++) {
|
||||
var r = await fetch(base, {method:'POST', headers:h, credentials:'same-origin',
|
||||
body: JSON.stringify(order[i])});
|
||||
if (!r.ok) {
|
||||
var d = await r.json().catch(function(){ return {}; });
|
||||
window.showToast && window.showToast(d.detail || 'Conversion \u00e9chou\u00e9e', 'error');
|
||||
break;
|
||||
}
|
||||
}
|
||||
await this.loadSteps();
|
||||
},
|
||||
navTo(sec) {
|
||||
this.activeSection = sec;
|
||||
var loaders = {'api-tokens':'loadApiTokens', 'sessions':'loadSessions',
|
||||
'extensions':'loadClipperDevices', 'automations':'loadAutomations',
|
||||
'admin-users':'loadAdminUsers', 'admin-audit':'loadAdminAudit',
|
||||
'admin-backups':'loadBackups', 'admin-sso':'loadSsoConfig'};
|
||||
if (loaders[sec] && this[loaders[sec]]) this[loaders[sec]]();
|
||||
},
|
||||
setAuditSource(s) { this.auditSource = s; this.loadAuditLogs(); },
|
||||
auditNext() { this.auditOffset += this.auditPageSize; this.loadAuditLogs(false); },
|
||||
editUserRow(u) {
|
||||
this.editingUser = u;
|
||||
this.editUserForm = { login: u.login, name: u.full_name, email: u.email,
|
||||
is_admin: u.is_admin, is_active: u.is_active };
|
||||
},
|
||||
async loadAutomations() {
|
||||
try { await this.loadCollectionsForForm(); } catch { /* volontaire */ }
|
||||
try {
|
||||
@@ -814,9 +951,13 @@ function settingsInit() {
|
||||
condition_json: a.condition_json || '[]',
|
||||
actions_json: a.actions_json || '[]'
|
||||
};
|
||||
this.stepEdit = -1;
|
||||
this.loadSteps();
|
||||
},
|
||||
cancelEditAutomation() {
|
||||
this.editAutomationId = null;
|
||||
this.editSteps = [];
|
||||
this.stepEdit = -1;
|
||||
this.af = { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' };
|
||||
},
|
||||
async toggleAutomation(id, currentlyEnabled) {
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
/* exported workspacesPage -- appeles depuis les attributs HTML des templates */
|
||||
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: workspacesPage »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('workspacesPage', workspacesPage); });
|
||||
function workspacesPage() {
|
||||
return {
|
||||
workspaces: [],
|
||||
activeId: null,
|
||||
showCreate: false,
|
||||
showRename: false,
|
||||
closeCreateRename() { this.showCreate = false; this.showRename = false; },
|
||||
wsName: '',
|
||||
renameTarget: null,
|
||||
|
||||
|
||||
+3
-3
@@ -8,8 +8,8 @@
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
'use strict';
|
||||
|
||||
const CACHE_NAME = 'flowdeck-v6';
|
||||
const DATA_CACHE = 'flowdeck-data-v6';
|
||||
const CACHE_NAME = 'flowdeck-v8'; // v8 : Alpine CSP build (A20 fini)
|
||||
const DATA_CACHE = 'flowdeck-data-v7';
|
||||
|
||||
// App shell (assets versionnés comme référencés dans les templates).
|
||||
const PRECACHE_URLS = [
|
||||
@@ -23,7 +23,7 @@ const PRECACHE_URLS = [
|
||||
'/static/css/prism.css',
|
||||
// JS
|
||||
'/static/js/htmx.min.js',
|
||||
'/static/js/alpine.min.js',
|
||||
'/static/js/alpine.csp.min.js',
|
||||
'/static/js/sortable.min.js',
|
||||
'/static/js/app.js?v=2.4.8',
|
||||
'/static/js/offline.js?v=6.0.0',
|
||||
|
||||
+6
-1
@@ -2554,7 +2554,12 @@ def test_all_view_tabs_present(client):
|
||||
resp = client.get(f"/db/{coll_id}/view/table")
|
||||
assert resp.status_code == 200
|
||||
for vt in ["table", "board", "calendar", "gallery", "list", "timeline", "gantt", "chart", "form", "map", "feed"]:
|
||||
assert f"?view={vt}" in resp.text, f"Missing view tab: {vt}"
|
||||
# A39/E2E : le nom du paramètre doit être `view_type` (celui de la
|
||||
# route) — `?view=` était ignoré et l'onglet restait sur Table.
|
||||
assert f"?view_type={vt}" in resp.text, f"Missing view tab: {vt}"
|
||||
# et la query commute réellement la vue rendue
|
||||
resp = client.get(f"/db/{coll_id}?view_type=calendar")
|
||||
assert 'class="calendar"' in resp.text
|
||||
|
||||
|
||||
def test_view_unknown_falls_back_to_table(client):
|
||||
|
||||
@@ -226,7 +226,8 @@ def _assert_nonce(csp: str, html: str) -> str:
|
||||
assert nm, script_src
|
||||
nonce = nm.group(1)
|
||||
assert "'unsafe-inline'" not in script_src, script_src
|
||||
assert "'unsafe-eval'" in script_src # Alpine/htmx — reste d'A20
|
||||
# A20 TERMINÉ : Alpine en build CSP (alpine.csp.min.js) + htmx allowEval=false
|
||||
assert "'unsafe-eval'" not in script_src
|
||||
assert "script-src-attr 'unsafe-inline'" in csp
|
||||
tags = [
|
||||
mm.group(0)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Les `right_actions` du topbar doivent être servis PARSED (A10).
|
||||
|
||||
Regresssion historique : `'literal' ~ fd_icon(...)` — fd_icon = macro →
|
||||
Markup, et `Markup.__radd__/__add__` échappe les segments littéraux →
|
||||
boutons livrés en entities (`"`) sur TOUTES les pages. Fix : block-set
|
||||
`{% set x %}…{{ fd_icon() }}…{% endset %}` (5 templates).
|
||||
"""
|
||||
from tests.conftest import login_test_client
|
||||
|
||||
|
||||
def test_right_actions_parsed(client):
|
||||
login_test_client(client)
|
||||
r = client.get("/workspaces")
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
parsed = 'class="topbar-btn"' in body
|
||||
escaped = ""topbar-btn"" in body
|
||||
print("PARSE:", parsed, "| ESCAPED:", escaped)
|
||||
print("DBG present:", "DBGCLS" in body)
|
||||
k = body.find("DBGCLS=")
|
||||
print("CTX:", body[max(0, k - 80) : k + 320].replace("\n", " ") if k >= 0 else "pas de DBG")
|
||||
assert parsed and not escaped, "right_actions servi échappé"
|
||||
Reference in New Issue
Block a user