Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa88cf6665 | ||
|
|
b2ea8ec537 | ||
|
|
1051a72b52 | ||
|
|
afbc236cc2 | ||
|
|
c20aeaada1 | ||
|
|
6d7af3fb64 | ||
|
|
b0af1bbfb6 |
+181
@@ -1,5 +1,186 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.45.4 (2026-10-03) — Anti-FOUC au chargement complet + morts nettoyés
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Flashs au chargement COMPLET** (F5, première visite, navigation servie par
|
||||
le service worker) : le masquage `x-cloak`/`opacity` de v7.45.3 ne couvrait
|
||||
que les swaps partiels fdLoad — un chargement de document peignait la page
|
||||
tant qu'Alpine n'avait pas initialisé `.app-layout`, donc sidebar brute (toutes
|
||||
sections ouvertes, menu utilisateur visible) **et** contenu de la zone dont la
|
||||
bande rouge « You are offline. Changes will sync when connection is
|
||||
restored. » (aucun `x-cloak` sur elle). Fix : `x-cloak` sur le nœud racine
|
||||
Alpine `.app-layout` (base.html) — tout l'app (sidebar + zone + bandeau) reste
|
||||
masqué tant que `appState()` n'est pas monté, puis Alpine retire l'attribut.
|
||||
Vérifié par le probe (3 pages × plein chargement) : 0 frame de contenu brut,
|
||||
`x-cloak` retiré et `display:flex` à la fin (donc pas de page blanche).
|
||||
|
||||
### Changed
|
||||
|
||||
- **`fd-navigating` supprimée** : classe posée/retirée à chaque `fdLoad` mais
|
||||
sans aucune règle CSS dans le dépôt (grep css/js/html → seulement app.js) —
|
||||
code mort.
|
||||
- **`e2e/probe_nav_perf.spec.js` étendu à 4 scénarios avec assertions** :
|
||||
navigations partielles (5 pages), **clic réel sur le bouton Home**, Home avec
|
||||
réseau coupé (SW actif), et plein chargement. Asserte : 0 frame de contenu
|
||||
brut peint, zone révélée (`opacity:1`) et montée (`0 [x-show]` non montés,
|
||||
`x-ignore` absent), `.app-layout` sans `x-cloak` et visible. Capture
|
||||
screenshot automatique si du contenu brut est peint + collecte console.
|
||||
|
||||
### Not reproduced
|
||||
|
||||
- Le symptôme rapporté (Home → rafale de menus/fenêtres + bande rouge) n'est
|
||||
**pas rejouable** dans un navigateur frais : clic Home en ligne comme hors
|
||||
ligne, nav partielle et plein chargement donnent 0 frame de contenu brut et 0
|
||||
erreur console. Reste à confirmer côté navigateur signalé (recharge dure
|
||||
après deploy, URL/instance testée).
|
||||
|
||||
## v7.45.3 (2026-10-03) — Navigation partielle : le contenu brut n'est plus peint
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Flashs de fenêtres / frames à chaque changement de page ou de section** :
|
||||
pendant la fenêtre `x-ignore` (swap → `Alpine.initTree`), la zone
|
||||
`.main-wrapper` était peinte **non montée** — tous ses `[x-show]` visibles à
|
||||
leur valeur brute (menus, panneaux, états loading/empty/table superposés), puis
|
||||
masqués d'un coup au montage Alpine. Mesure (`e2e/probe_nav_perf.spec.js`,
|
||||
instance locale) : **19 éléments bruts visibles sur 87 ms** en arrivant sur
|
||||
/library (CLS 0.149, sources `lib-loading`/`lib-empty`/`lib-table`), **80
|
||||
éléments sur 732 ms** sur /settings, 7 sur /workspaces, 0 sur /trash. Fix :
|
||||
`opacity: 0` + `pointer-events: none` posés **dans** le handler
|
||||
`htmx:afterSwap` (même task que le swap → aucun paint intermédiaire possible)
|
||||
et retirés **après** `Alpine.initTree` sur les 3 chemins de démontage (scripts
|
||||
chargés, zéro script, filet 4 s). Seul le contenu monté est peint.
|
||||
|
||||
### Added
|
||||
|
||||
- **`e2e/probe_nav_perf.spec.js`** — probe de fluidité de navigation : marques
|
||||
htmx (`beforeRequest`/`beforeSwap`/`afterSwap`/`afterSettle`), fenêtre
|
||||
`x-ignore`, échantillon par frame du nombre d'`[x-show]` bruts encore
|
||||
visibles et d'`[x-cloak]` masqués, `layout-shift` (CLS) avec sources.
|
||||
`node node_modules/@playwright/test/cli.js test probe_nav_perf` (npx est cassé
|
||||
sur ce poste).
|
||||
|
||||
## v7.45.2 (2026-10-03) — Navigation partielle : montage Alpine sans course + scripts idempotents
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cascades d'erreurs Alpine en navigation partielle** (« Undefined
|
||||
variable: … » puis « reading 'has' » sur `$store.fdCtx`) : sur un swap
|
||||
fdLoad, les `<script src>` de page s'exécutent APRÈS le microtask
|
||||
MutationObserver d'Alpine → toute la zone `.main-wrapper` s'initialisait
|
||||
avant que composants ET stores (menu contextuel) existent, et le
|
||||
démontage anticipé dès le premier script chargé perdait la course avec
|
||||
les autres. Fix : `x-ignore` sur **toute** la zone swapée au
|
||||
`htmx:afterSwap` (uniquement quand le nœud `.main-wrapper` a été
|
||||
remplacé — swaps sidebar/vues inchangés), démontage **unique** quand
|
||||
**tous** les `<script src>` ont exécuté (load/error + filet 4 s) via
|
||||
`Alpine.initTree` (idempotent grâce au `_x_marker`). Vérifié : 7 pages ×
|
||||
(complet / 1ʳᵉ / 2ᵉ visite partielle) + éditeur (3 phases) = 0 erreur.
|
||||
- **`SyntaxError: Identifier 'LW' has already been declared`** : tout
|
||||
script de page ré-exécuté à la 2ᵉ visite partielle plantait entièrement
|
||||
(`const` de haut niveau re-déclaré) → panneaux Alpine non montés.
|
||||
Garde de fichier posé sur `local_workspace.js`, `board.js`,
|
||||
`settings.js`, `database_table.js`, `page_editor_realtime.js` (pattern
|
||||
déjà utilisé par `page_editor_scripts.js`).
|
||||
- **Enregistrement Alpine différé** : `if (window.Alpine) Alpine.data(…
|
||||
sinon listener 'alpine:init'` (l'événement ne sera plus jamais émis sur
|
||||
swap) dans 8 scripts de page et 7 templates inline (`accounts`,
|
||||
`card_detail`, `table_view`, `team_load`, `trash`, `welcome`,
|
||||
`workspace`).
|
||||
- **Polices Inter orphelines** : `app.css` référençait 7 fichiers
|
||||
inexistants (`inter-400-latin.woff2`…) → 302 → HTML → « Failed to
|
||||
decode downloaded font » sur chaque page. Blocs legacy supprimés (les
|
||||
faces variable Inter 100-900 restent).
|
||||
|
||||
### Added
|
||||
|
||||
- **Gates E2E** : `e2e/regression_editor_mount.spec.js` (éditeur en 3
|
||||
phases : partielle / complet / 2ᵉ partielle) et
|
||||
`e2e/regression_partial_nav.spec.js` (7 pages × 3 passages + état
|
||||
fdCtx/appState) —0 erreur Alpine attendue.
|
||||
|
||||
## v7.45.1 (2026-10-02) — Fix logout « hors ligne » (SW) + drag & drop upload 403
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Logout → page « Vous êtes hors ligne »** : le fetch event de navigation
|
||||
arrive en `redirect:'manual'` → une 302 du serveur se lisait
|
||||
`opaqueredirect` (status 0) → `bad status` → filet offline. Tout logout
|
||||
(et toute navigation redirigée) affichait la page hors ligne alors que le
|
||||
backend répondait en ~10 ms. `networkFirst` rejoue désormais la requête en
|
||||
`redirect:'follow'` et sert une **302 synthétique vers l'URL finale**
|
||||
(Chromium refuse une response `redirected` servie à une navigation →
|
||||
`net::ERR_FAILED`). Bonus : `timeoutFetch` annule réellement la requête
|
||||
(l'`AbortController` créée dans le `setTimeout` n'était pas branchée sur
|
||||
`fetch`).
|
||||
- **Drag & drop de fichiers/dossiers en échec silencieux (403 CSRF)** :
|
||||
`_doUpload()` (POST `/api/local-workspace/upload[-folder]`) et
|
||||
`toggleFavorite()` du local workspace n'envoyaient pas `X-CSRF-Token` —
|
||||
les 2 derniers appels mutants du front oubliés par A19 (exemption CSRF
|
||||
retirée, balayage complet des `fetch` mutants refait).
|
||||
- **Gate E2E** : `e2e/regression_logout_dnd.spec.js` enregistre les 2 bugs
|
||||
(logout sous SW → page login, drop de fichier → 200 + création, avec
|
||||
nettoyage).
|
||||
|
||||
## 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)
|
||||
|
||||
+7
-2
@@ -987,7 +987,7 @@ Détails livrés :
|
||||
- [x] **Migrations 25** — `semantic_embeddings`, `semantic_index_state`, colonne `pages.search_excluded`
|
||||
- [x] **Tests** — `tests/test_v69_search_ask.py` (**24 tests** : migration, chunk/overlap, déterminisme/norme, cosinus, index idempotent, exclusion, purge, rappel vectoriel partiel, hybride keyword/auth/400/pagination/isolation ACL/exclusion/collections, ask citations/auth/400/cache/ACL/rate-limit, index-status)
|
||||
- [x] **Version** — 6.9.0 (`VERSION` + `app/main.py`) · `ruff check` OK
|
||||
- [ ] **UI** — palette `Ctrl+K` onglets `Pages / Fichiers / ✨ Réponses IA` (reporté : backend livré, frontend en follow-up)
|
||||
- [x] **UI** — palette `Ctrl+K` : **onglets `Pages / ✨ Réponses IA`** livrés (v7.44.0) — onglet IA = `POST /api/v2/search/ask` (CSRF via `getCsrf()`, debounce 150 ms, échappement AVANT injection des liens `[[fdpage:ID]]` → citations cliquables + bloc « Sources ») ; gate E2E étendu (onglet cliqué → réponse non-échappée). **Reporté** : onglet `Fichiers` — aucun backend (`/api/search` ne renvoie que `pages`/`collections`) → à créer d'abord.
|
||||
|
||||
### v7.0.0 — Automations v2 + Workers ✅ (2026-09-28)
|
||||
> **Objectif** : automatiser au-delà du if-this-then-that + custom code sandboxé.
|
||||
@@ -1001,7 +1001,12 @@ Détails livrés :
|
||||
- [x] **Migrations 26** — `automation_steps`, `workers`, `worker_runs`, `automations.trigger_mode`, `collection_properties.button_automation_id`
|
||||
- [x] **Tests** — `tests/test_v70_automations_workers.py` (**31 tests** : migration, steps CRUD/validation/auth, mode any/all, `form.submitted`, chaînes + interpolation, condition, delay, slack + secret chiffré, email no-SMTP, forge mock + sans-token, agent mock + 404, button press/validation, legacy single-run, workers CRUD/auth/rejet code/run ok/error/timeout/budget/fork/privacy/usage/cron/masquage code)
|
||||
- [x] **Version** — 7.0.0 (`VERSION` + `app/main.py`) · `ruff check` OK
|
||||
- [ ] **Éditeur visuel** — canvas Settings → Automations (reporté : API steps livrée, UI en follow-up)
|
||||
- [x] **Éditeur visuel** — **pipeline steps** dans Settings → Automations (v7.45.0) : cartes ordonnées (trigger/condition/delay/action) avec **config typée par type** (8 actions : webhook/set_property/create_page/notify/slack/email/forge_issue/agent_trigger + ops condition + datalist événements), ajout/édition/suppression/**↑↓** via l'API `/steps` + bouton **✨ Convertir le JSON en pipeline** (legacy → steps ordonnés). **Bonus trouvé par le gate** : **51 expressions multi-instructions** (`a=1; b()` — `;` = SEULE expression par directive interdit sous le parseur CSP, non couvert par le scan `tokens`) → converties en méthodes dans **11 fichiers** (nav settings ×8, menu section base ×7, partages/éditeur ×13, breadcrumb ×5, lib/local ×6, board ×3, ctx/agent/workspaces/card/gitea ×6). Nouveau scanner `scan_semi` ajouté au lot.
|
||||
- [x] **Fix SW + CSRF uploads** (v7.45.1) — logout ne sert plus la page « hors ligne » (le fetch event de navigation est en `redirect:'manual'` → 302 lue `opaqueredirect` → rejet ; rejoué en `redirect:'follow'` + **302 synthétique vers l'URL finale**, Chromium refusant les responses `redirected` servies à une navigation) et drag & drop de fichiers/dossiers réparé (`_doUpload` + `toggleFavorite` sans `X-CSRF-Token` = oublis A19, derniers appels mutants du front). `timeoutFetch` annule enfin ses requêtes. Gate `e2e/regression_logout_dnd.spec.js` (2 tests verts).
|
||||
- [x] **Navigation partielle Alpine** (v7.45.2) — les swaps fdLoad initialisaient `.main-wrapper` avant l'exécution des scripts de page (courses : « Undefined variable » sur l'éditeur, `reading 'has'` sur `$store.fdCtx`, `SyntaxError: 'LW' already declared` à la 2ᵉ visite). Fix maison dans `app.js` : `x-ignore` sur la zone swapée (nœud remplacé uniquement) → démontage unique à tous `<script src>` chargés via `Alpine.initTree` idempotent ; garde de fichier sur 5 scripts à `const` de haut niveau ; `if (window.Alpine)` sur 8 scripts + 7 templates inline ; polices Inter orphelines purgées d'`app.css`. Gates : `regression_editor_mount` + `regression_partial_nav` (7 pages × complet/partiel/2ᵉ = 0 erreur).
|
||||
- [x] **Flashs de navigation** (v7.45.3) — pendant la fenêtre `x-ignore` (swap fdLoad → `Alpine.initTree`), la zone `.main-wrapper` était peinte **non montée** : tous ses `[x-show]` visibles à leur valeur brute (menus/panneaux/états loading+empty+table superposés) puis masqués d'un coup au montage = la rafale de fenêtres qui flashent à chaque changement de page/section. Fix 6 lignes dans `app.js` : `opacity:0` + `pointer-events:none` posés dans `htmx:afterSwap` (même task que le swap → aucun paint intermédiaire), retirés **après** `initTree` sur les 3 chemins de démontage. Mesure avant : 19 éléments bruts/87 ms sur /library (CLS 0.149), 80 éléments/732 ms sur /settings — après : 0 frame de contenu brut. Gate maison `e2e/probe_nav_perf.spec.js` (marques htmx + fenêtre x-ignore + échantillon par frame + layout-shift).
|
||||
- Suivis restants (mesurés, non traités) : CLS résiduel **0.058** sur /library (états `lib-loading`/`lib-empty` → `lib-table` : le remplacement du spinner par les lignes décale le contenu — nécessiterait un squelette de lignes, pas un réglage CSS) · fenêtre `x-ignore` de **~150–300 ms** sur /settings (coût des scripts de page avant `initTree`, pas de gain sans changer l'ordre de montage) · la **topbar est dans `.main-wrapper`** (base.html) → re-swappée et re-montée à chaque navigation (avec le masquage elle disparaît/revient au lieu de flasher — la sortir demanderait de la swapper séparément).
|
||||
- Suite v7.45.4 : `x-cloak` posé sur `.app-layout` (racine Alpine) → les **chargements complets** (F5, SW, hors ligne) ne peignent plus la sidebar brute ni la bande rouge hors ligne — le masquage fdLoad ne couvrait que les swaps partiels. `fd-navigating` (classe sans règle CSS) supprimée. Probe étendu à 4 scénarios (nav / clic Home réel / Home hors ligne / plein chargement) avec assertions. **Symptôme Home non rejouable** en navigateur frais (0 frame brut, 0 erreur console) → à confirmer sur le navigateur signalé.
|
||||
|
||||
### v7.1.0 — Calendar sync + Meeting Notes ✅ (2026-09-28)
|
||||
> **Objectif** : calendrier bidirectionnel + transcription → agents (cf. Notion 07/2026 : Meeting Notes trigger Custom Agents).
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.43.0 (**A20 TERMINÉ** : bascule Alpine CSP + unsafe-eval retiré de la CSP) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.45.4 (anti-FOUC : `x-cloak` sur `.app-layout` = plus de sidebar brute ni de bande rouge hors ligne au chargement complet, classe morte `fd-navigating` supprimée, probe étendu à 4 scénarios avec assertions) | **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.43.0",
|
||||
version="7.45.4",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -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>
|
||||
@@ -552,7 +552,7 @@
|
||||
contenteditable="true"
|
||||
id="_titleEl"
|
||||
data-placeholder="New Page"
|
||||
@input="dirty=true;save()"
|
||||
@input="markAndSave()"
|
||||
@keydown.enter.prevent="focusBlock()"
|
||||
@paste.prevent="pastePlain($event)"
|
||||
spellcheck="false"
|
||||
|
||||
@@ -109,7 +109,8 @@
|
||||
|
||||
// globales window — probe « Undefined variable: accountsData »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('accountsData', accountsData); });
|
||||
if (window.Alpine) { Alpine.data('accountsData', accountsData); }
|
||||
else 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">
|
||||
|
||||
+82
-10
@@ -130,7 +130,7 @@
|
||||
<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 %}>
|
||||
<div class="app-layout" x-data="appState()">
|
||||
<div class="app-layout" x-data="appState()" x-cloak>
|
||||
|
||||
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
|
||||
<div class="sidebar-overlay"
|
||||
@@ -592,38 +592,38 @@
|
||||
<div class="section-menu" x-show="sectionMenu.visible" x-cloak
|
||||
: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>
|
||||
@@ -1463,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;
|
||||
},
|
||||
@@ -2234,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)}
|
||||
@@ -2255,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>
|
||||
@@ -2275,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){
|
||||
@@ -2299,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>';
|
||||
@@ -2335,6 +2352,7 @@
|
||||
}
|
||||
|
||||
function runSearch(){
|
||||
if(state.tab==='ai'){ runAi(); return; }
|
||||
var q = input.value.trim();
|
||||
state.query = q;
|
||||
state.index = 0;
|
||||
@@ -2359,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');
|
||||
@@ -2413,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,7 +95,7 @@
|
||||
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>
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
|
||||
// globales window — probe « Undefined variable: cardDetail »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); });
|
||||
if (window.Alpine) { Alpine.data('cardDetail', cardDetail); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); });
|
||||
function cardDetail() {
|
||||
return {
|
||||
updateField(field, value) {
|
||||
@@ -138,6 +139,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);
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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>
|
||||
@@ -259,7 +259,7 @@
|
||||
<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>
|
||||
|
||||
@@ -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;">
|
||||
|
||||
+139
-13
@@ -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>
|
||||
@@ -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>
|
||||
@@ -774,7 +900,7 @@
|
||||
</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()">
|
||||
@@ -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;}
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
|
||||
// globales window — probe « Undefined variable: tableView »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); });
|
||||
if (window.Alpine) { Alpine.data('tableView', tableView); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); });
|
||||
function tableView() {
|
||||
return {
|
||||
sortField: '',
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
|
||||
// globales window — probe « Undefined variable: teamLoad »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); });
|
||||
if (window.Alpine) { Alpine.data('teamLoad', teamLoad); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); });
|
||||
function teamLoad() {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -67,7 +67,8 @@
|
||||
|
||||
// globales window — probe « Undefined variable: trashData »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
|
||||
if (window.Alpine) { Alpine.data('trashData', trashData); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
|
||||
function trashData() {
|
||||
return {
|
||||
search: '',
|
||||
|
||||
@@ -138,7 +138,8 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
|
||||
|
||||
// globales window — probe « Undefined variable: onboarding »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('onboarding', onboarding); });
|
||||
if (window.Alpine) { Alpine.data('onboarding', onboarding); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('onboarding', onboarding); });
|
||||
function onboarding() {
|
||||
return {
|
||||
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
|
||||
|
||||
@@ -145,7 +145,8 @@
|
||||
|
||||
// globales window — probe « Undefined variable: workspacePage »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('workspacePage', workspacePage); });
|
||||
if (window.Alpine) { Alpine.data('workspacePage', workspacePage); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('workspacePage', workspacePage); });
|
||||
function workspacePage() {
|
||||
return {
|
||||
builtinProjects: [],
|
||||
|
||||
@@ -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.43.0"
|
||||
"version": "7.45.4"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
|
||||
@@ -145,6 +145,41 @@ test('A20-ph3 : éditeur de page (right_actions) sous build CSP', async ({ page
|
||||
}
|
||||
});
|
||||
|
||||
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' });
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Probe de fluidité de navigation partielle (fdLoad) — mesure brute, pas un test gate.
|
||||
*
|
||||
* Pour chaque navigation il enregistre :
|
||||
* - les marques htmx (beforeRequest / beforeSwap / afterSwap / afterSettle)
|
||||
* - la fenêtre x-ignore (zone non montée Alpine)
|
||||
* - un échantillon par frame : nb d'éléments [x-show] ENCORE VISIBLES non montés
|
||||
* (= le contenu « brut » qui se voit avant qu'Alpine ne le masque → les flashes
|
||||
* de fenêtres) et nb d'éléments [x-cloak] encore masqués (= contenu qui pop)
|
||||
* - les layout-shift (CLS) et leur source
|
||||
*
|
||||
* Lancement : npx playwright test probe_nav_perf --reporter=list
|
||||
*/
|
||||
const { test } = require('@playwright/test');
|
||||
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';
|
||||
|
||||
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: 8000 }).then(() => true).catch(() => false);
|
||||
if (!ok) {
|
||||
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
// Instrumentation posée UNE fois dans la page (avant le clic).
|
||||
const INSTALL = () => {
|
||||
const P = (window.__probe = { t0: performance.now(), marks: [], frames: [], shifts: [], rawMax: 0, rawFrames: 0 });
|
||||
const mark = (n) => P.marks.push([n, Math.round(performance.now() - P.t0)]);
|
||||
|
||||
['htmx:beforeRequest', 'htmx:beforeSwap', 'htmx:afterSwap', 'htmx:afterSettle'].forEach((ev) =>
|
||||
document.addEventListener(ev, () => mark(ev))
|
||||
);
|
||||
|
||||
// Fenêtre x-ignore sur .main-wrapper (le nœud change à chaque swap → on observe tout le doc)
|
||||
new MutationObserver((muts) => {
|
||||
for (const m of muts) {
|
||||
if (m.type !== 'attributes' || m.attributeName !== 'x-ignore') continue;
|
||||
const t = m.target;
|
||||
if (!(t instanceof Element) || !t.classList.contains('main-wrapper')) continue;
|
||||
mark(t.hasAttribute('x-ignore') ? 'x-ignore ON' : 'x-ignore OFF');
|
||||
}
|
||||
}).observe(document.documentElement, { attributes: true, attributeFilter: ['x-ignore'], subtree: true });
|
||||
|
||||
new PerformanceObserver((list) => {
|
||||
for (const e of list.getEntries()) {
|
||||
P.shifts.push({
|
||||
v: +e.value.toFixed(4),
|
||||
t: Math.round(e.startTime - P.t0),
|
||||
sources: (e.sources || []).map((s) => {
|
||||
const n = s.node;
|
||||
return n ? `${n.nodeName}.${(n.className || '').toString().split(' ')[0]}` : '?';
|
||||
}),
|
||||
});
|
||||
}
|
||||
}).observe({ type: 'layout-shift', buffered: false });
|
||||
|
||||
// Échantillonnage par frame. « visible » = peint à l'écran : la zone peut
|
||||
// porter opacity:0 (fix v7.45.3) → rects toujours là, mais rien n'est peint.
|
||||
const zonePainted = (z) => z && parseFloat(getComputedStyle(z).opacity || '1') > 0;
|
||||
const tick = () => {
|
||||
let raw = 0, rawVisible = 0, cloak = 0;
|
||||
const zone = document.querySelector('.main-wrapper');
|
||||
const painted = zonePainted(zone);
|
||||
if (zone) {
|
||||
for (const el of zone.querySelectorAll('[x-show]')) {
|
||||
if (el._x_marker) continue; // monté par Alpine
|
||||
raw++;
|
||||
if (painted && el.getClientRects().length) rawVisible++; // peint ET en flow
|
||||
}
|
||||
cloak = zone.querySelectorAll('[x-cloak]').length;
|
||||
}
|
||||
P.rawMax = Math.max(P.rawMax, rawVisible);
|
||||
if (rawVisible) P.rawFrames++;
|
||||
P.frames.push([Math.round(performance.now() - P.t0), raw, rawVisible, cloak, painted ? 1 : 0]);
|
||||
if (performance.now() - P.t0 < 3000) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
return true;
|
||||
};
|
||||
|
||||
test('probe fluidité nav', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await login(page);
|
||||
|
||||
const NAVS = ['/library', '/settings', '/workspaces', '/trash', '/local-workspace'];
|
||||
const results = [];
|
||||
const SHOTS = require('path').join(__dirname, 'shots');
|
||||
require('fs').mkdirSync(SHOTS, { recursive: true });
|
||||
|
||||
for (const dest of NAVS) {
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(1200);
|
||||
await page.evaluate(INSTALL);
|
||||
await page.evaluate((p) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = p;
|
||||
a.textContent = 'probe';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
}, dest);
|
||||
// Filet : si du contenu brut est peint, on capture pour voir CE qui flash
|
||||
let shot = null;
|
||||
const tEnd = Date.now() + 3200;
|
||||
while (Date.now() < tEnd && !shot) {
|
||||
const last = await page.evaluate(() => {
|
||||
const f = window.__probe.frames;
|
||||
return f.length ? f[f.length - 1] : null;
|
||||
});
|
||||
if (last && last[2] > 0) {
|
||||
shot = `navflash_${dest.replace(/\W+/g, '')}.png`;
|
||||
await page.screenshot({ path: require('path').join(SHOTS, shot) });
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
const P = await page.evaluate(() => window.__probe);
|
||||
// État final : la zone doit être révélée (opacity>0) ET montée Alpine,
|
||||
// et .app-layout (x-cloak anti-FOUC) doit être retiré → pas de page blanche
|
||||
const final = await page.evaluate(() => {
|
||||
const z = document.querySelector('.main-wrapper');
|
||||
const a = document.querySelector('.app-layout');
|
||||
if (!z) return { opacity: 'NO-ZONE', unmounted: -1, xIgnore: null };
|
||||
return {
|
||||
opacity: getComputedStyle(z).opacity,
|
||||
pointerEvents: getComputedStyle(z).pointerEvents,
|
||||
unmounted: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length,
|
||||
xIgnore: z.hasAttribute('x-ignore'),
|
||||
layout: a ? { cloak: a.hasAttribute('x-cloak'), display: getComputedStyle(a).display } : 'ABSENT',
|
||||
};
|
||||
});
|
||||
|
||||
// Résumé : première et dernière frame où du contenu brut visible subsiste
|
||||
const rawFrames = P.frames.filter((f) => f[2] > 0);
|
||||
const results_summary = {
|
||||
dest,
|
||||
marks: Object.fromEntries(P.marks),
|
||||
rawVisibleFrames: rawFrames.length,
|
||||
rawFirst: rawFrames.length ? rawFrames[0] : null,
|
||||
rawLast: rawFrames.length ? rawFrames[rawFrames.length - 1] : null,
|
||||
rawMaxElements: P.rawMax,
|
||||
hiddenFrames: P.frames.filter((f) => f[4] === 0).length,
|
||||
cloakMax: Math.max(0, ...P.frames.map((f) => f[3])),
|
||||
shifts: P.shifts,
|
||||
final,
|
||||
frameCount: P.frames.length,
|
||||
};
|
||||
results.push(results_summary);
|
||||
console.log('NAV ' + dest + ' => ' + JSON.stringify(results_summary));
|
||||
}
|
||||
|
||||
// GATE : aucun contenu brut peint + zone révélée et montée à la fin
|
||||
const paintedRaw = results.filter((r) => r.rawVisibleFrames > 0);
|
||||
const notRevealed = results.filter((r) => parseFloat(r.final.opacity) !== 1 || r.final.unmounted !== 0 || r.final.xIgnore
|
||||
|| !r.final.layout || r.final.layout.cloak === true || r.final.layout.display === 'none');
|
||||
console.log('SOMMAIRE ' + JSON.stringify(results.map((r) => ({ d: r.dest, raw: r.rawVisibleFrames, hidden: r.hiddenFrames, op: r.final.opacity, un: r.final.unmounted, shift: r.shifts.length }))));
|
||||
if (paintedRaw.length) throw new Error('contenu brut peint: ' + paintedRaw.map((r) => `${r.dest} (${r.rawVisibleFrames} frames, max ${r.rawMaxElements})`).join(' ; '));
|
||||
if (notRevealed.length) throw new Error('zone non révélée/montée: ' + JSON.stringify(notRevealed.map((r) => [r.dest, r.final])));
|
||||
console.log('GATE OK — 0 frame de contenu brut, zone révélée et montée sur ' + results.length + ' navigations');
|
||||
});
|
||||
|
||||
// Le geste réel du rapport : clic sur le bouton Home de la sidebar (et non un
|
||||
// ancre synthétique) — on mesure ce qui peint et on garde une capture si flash.
|
||||
test('probe bouton Home', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
const SHOTS = require('path').join(__dirname, 'shots');
|
||||
require('fs').mkdirSync(SHOTS, { recursive: true });
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(1200);
|
||||
await page.evaluate(INSTALL);
|
||||
|
||||
const console_ = [];
|
||||
page.on('console', (m) => { if (m.type() !== 'log') console_.push(m.type() + ' | ' + m.text().slice(0, 180).replace(/\s+/g, ' ')); });
|
||||
page.on('pageerror', (e) => console_.push('PAGEERROR | ' + String(e).slice(0, 180)));
|
||||
|
||||
const before = await page.evaluate(() => ({ onLine: navigator.onLine, home: !!document.querySelector('.home-btn'), href: (document.querySelector('.home-btn') || {}).href }));
|
||||
console.log('HOME avant clic ' + JSON.stringify(before));
|
||||
|
||||
await page.click('.home-btn');
|
||||
let shot = null;
|
||||
const tEnd = Date.now() + 4000;
|
||||
while (Date.now() < tEnd && !shot) {
|
||||
const last = await page.evaluate(() => {
|
||||
const f = window.__probe.frames;
|
||||
return f.length ? f[f.length - 1] : null;
|
||||
});
|
||||
if (last && last[2] > 0) {
|
||||
shot = 'home_flash.png';
|
||||
await page.screenshot({ path: require('path').join(SHOTS, shot) });
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
await page.waitForTimeout(2000);
|
||||
const P = await page.evaluate(() => window.__probe);
|
||||
const rawFrames = P.frames.filter((f) => f[2] > 0);
|
||||
const after = await page.evaluate(() => {
|
||||
const b = document.querySelector('.offline-banner');
|
||||
const z = document.querySelector('.main-wrapper');
|
||||
return {
|
||||
url: location.pathname + location.search,
|
||||
onLine: navigator.onLine,
|
||||
banner: b ? { cloak: b.hasAttribute('x-cloak'), display: getComputedStyle(b).display, mounted: !!b._x_marker } : 'absente',
|
||||
zone: z ? { op: getComputedStyle(z).opacity, un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length, xIgnore: z.hasAttribute('x-ignore') } : null,
|
||||
mounts: typeof window.__fdLocalWorkspaceScriptsLoaded !== 'undefined' ? window.__fdLocalWorkspaceScriptsLoaded : 'n/a',
|
||||
};
|
||||
});
|
||||
const res = { marks: Object.fromEntries(P.marks), rawVisibleFrames: rawFrames.length, rawFirst: rawFrames[0] || null, rawMax: P.rawMax, shot, shifts: P.shifts, console: console_, after };
|
||||
console.log('HOME résultat => ' + JSON.stringify(res));
|
||||
if (res.rawVisibleFrames > 0) throw new Error(`contenu brut peint au clic Home: ${res.rawVisibleFrames} frames (max ${res.rawMax})`);
|
||||
console.log('GATE HOME OK — 0 frame de contenu brut');
|
||||
});
|
||||
|
||||
// Scénario du rapport : la bande rouge dit « hors ligne » → on rejoue le clic
|
||||
// Home AVEC le réseau coupé (SW actif) pour voir ce qui peint.
|
||||
test('probe bouton Home hors ligne', async ({ page, context }) => {
|
||||
test.setTimeout(120000);
|
||||
const SHOTS = require('path').join(__dirname, 'shots');
|
||||
require('fs').mkdirSync(SHOTS, { recursive: true });
|
||||
await login(page);
|
||||
await page.addInitScript(INSTALL);
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(1500); // laisse le SW s'installer et cacher les pages
|
||||
await page.evaluate(() => { window.__probe = null; window.__probe = undefined; });
|
||||
await page.evaluate(INSTALL);
|
||||
|
||||
await context.setOffline(true);
|
||||
await page.waitForTimeout(200);
|
||||
const onLine = await page.evaluate(() => navigator.onLine);
|
||||
|
||||
await page.click('.home-btn');
|
||||
const shots = [];
|
||||
for (const [delay, name] of [[700, 't700'], [2500, 't2500']]) {
|
||||
await page.waitForTimeout(delay === 700 ? 700 : 1800);
|
||||
const p = `home_offline_${name}.png`;
|
||||
await page.screenshot({ path: require('path').join(SHOTS, p) }).catch(() => {});
|
||||
shots.push(p);
|
||||
}
|
||||
const P = await page.evaluate(() => window.__probe || null).catch(() => null);
|
||||
const after = await page.evaluate(() => {
|
||||
const b = document.querySelector('.offline-banner');
|
||||
const z = document.querySelector('.main-wrapper');
|
||||
const visible = (sel) => Array.from(document.querySelectorAll(sel)).filter((e) => e.getClientRects().length && getComputedStyle(e).display !== 'none').map((e) => (e.className || '').toString().split(' ')[0]).slice(0, 12);
|
||||
return {
|
||||
url: location.pathname + location.search,
|
||||
onLine: navigator.onLine,
|
||||
banner: b ? { display: getComputedStyle(b).display, mounted: !!b._x_marker } : 'absente',
|
||||
zone: z ? { op: getComputedStyle(z).opacity, un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length, xIgnore: z.hasAttribute('x-ignore') } : null,
|
||||
overlays: visible('.modal-overlay, .ctx-menu, .context-menu, .user-menu-dropdown, .dropdown-menu, .popover'),
|
||||
};
|
||||
}).catch((e) => ({ err: String(e) }));
|
||||
const rawFrames = P ? P.frames.filter((f) => f[2] > 0) : [];
|
||||
const res = { onLineBefore: onLine, marks: P ? Object.fromEntries(P.marks) : null, rawVisibleFrames: rawFrames.length, rawFirst: rawFrames[0] || null, rawMax: P ? P.rawMax : null, shots, shifts: P ? P.shifts : null, after };
|
||||
console.log('HOME OFFLINE => ' + JSON.stringify(res));
|
||||
});
|
||||
|
||||
// Charge COMPLÈTE (F5 / premier visit / SW) : le masquage fdLoad ne s'applique
|
||||
// pas là, seul x-cloak protège — on mesure ce qui peint avant Alpine.start().
|
||||
test('probe fluidité plein chargement', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
const SHOTS = require('path').join(__dirname, 'shots');
|
||||
require('fs').mkdirSync(SHOTS, { recursive: true });
|
||||
await login(page);
|
||||
await page.addInitScript(INSTALL);
|
||||
|
||||
const OUT = [];
|
||||
for (const dest of ['/local-workspace', '/library', '/workspaces']) {
|
||||
await page.goto(FD_BASE + dest, { waitUntil: 'commit' });
|
||||
let shot = null;
|
||||
const tEnd = Date.now() + 4000;
|
||||
while (Date.now() < tEnd && !shot) {
|
||||
const last = await page.evaluate(() => {
|
||||
const f = window.__probe && window.__probe.frames;
|
||||
return f && f.length ? f[f.length - 1] : null;
|
||||
}).catch(() => null);
|
||||
if (last && last[2] > 0) {
|
||||
shot = `fullflash_${dest.replace(/\W+/g, '')}.png`;
|
||||
await page.screenshot({ path: require('path').join(SHOTS, shot) });
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
await page.waitForTimeout(1500);
|
||||
const P = await page.evaluate(() => window.__probe);
|
||||
const rawFrames = P.frames.filter((f) => f[2] > 0);
|
||||
const res = {
|
||||
dest,
|
||||
rawVisibleFrames: rawFrames.length,
|
||||
rawFirst: rawFrames[0] || null,
|
||||
rawLast: rawFrames[rawFrames.length - 1] || null,
|
||||
rawMax: P.rawMax,
|
||||
shot,
|
||||
shifts: P.shifts,
|
||||
final: await page.evaluate(() => {
|
||||
const z = document.querySelector('.main-wrapper');
|
||||
const a = document.querySelector('.app-layout');
|
||||
return z ? {
|
||||
op: getComputedStyle(z).opacity,
|
||||
un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length,
|
||||
layout: a ? { cloak: a.hasAttribute('x-cloak'), display: getComputedStyle(a).display } : 'ABSENT',
|
||||
} : null;
|
||||
}),
|
||||
};
|
||||
OUT.push(res);
|
||||
console.log('FULL ' + dest + ' => ' + JSON.stringify(res));
|
||||
}
|
||||
console.log('SOMMAIRE_FULL ' + JSON.stringify(OUT.map((r) => [r.dest, r.rawVisibleFrames, r.rawMax, r.shot])));
|
||||
const notRevealed = OUT.filter((r) => !r.final || parseFloat(r.final.op) !== 1 || r.final.un !== 0
|
||||
|| !r.final.layout || r.final.layout.cloak === true || r.final.layout.display === 'none');
|
||||
if (notRevealed.length) throw new Error('page blanche/non montée au chargement: ' + JSON.stringify(notRevealed.map((r) => [r.dest, r.final])));
|
||||
if (OUT.some((r) => r.rawVisibleFrames > 0)) {
|
||||
throw new Error('contenu brut peint au plein chargement: ' + OUT.filter((r) => r.rawVisibleFrames > 0).map((r) => `${r.dest} (${r.rawVisibleFrames} frames, max ${r.rawMax})`).join(' ; '));
|
||||
}
|
||||
console.log('GATE FULL OK — 0 frame de contenu brut au chargement complet');
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/** Diag temporaire : navigation partielle vers l'éditeur, erreurs par phase */
|
||||
const { test } = require('@playwright/test');
|
||||
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';
|
||||
|
||||
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: 8000 }).then(() => true).catch(() => false);
|
||||
if (!ok) {
|
||||
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
function alpineErrs(msgs) {
|
||||
return msgs.filter((m) => m.includes('Alpine Expression Error') || m.includes('PAGEERROR'));
|
||||
}
|
||||
|
||||
async function partialClick(page, pid) {
|
||||
await page.evaluate((id) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = '/pages/' + id;
|
||||
a.textContent = 'x';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
}, pid);
|
||||
}
|
||||
|
||||
test('diag phases', async ({ page }) => {
|
||||
test.setTimeout(90000);
|
||||
const msgs = [];
|
||||
page.on('console', (m) => msgs.push(m.type() + ' | ' + m.text().slice(0, 160).replace(/\s+/g, ' ')));
|
||||
page.on('pageerror', (e) => msgs.push('PAGEERROR | ' + String(e).slice(0, 200)));
|
||||
|
||||
await login(page);
|
||||
const pid = await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/api/local-workspace/items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'Diag2', type: 'page' }),
|
||||
});
|
||||
const d = await r.json();
|
||||
return d.id || (d.item && d.item.id);
|
||||
});
|
||||
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(800);
|
||||
msgs.length = 0;
|
||||
|
||||
// PHASE A : navigation partielle (clic intercepte par fdLoad)
|
||||
await partialClick(page, pid);
|
||||
await page.waitForTimeout(4000);
|
||||
const stackA = await page.evaluate(() => {
|
||||
const el = document.querySelector('[x-data="editorState()"]');
|
||||
const st = el && el._x_dataStack;
|
||||
return { stack: st ? st.map((o) => (o ? Object.keys(o).length : -1)) : 'none', refreshed: !!(el && el.__fdRefreshed) };
|
||||
});
|
||||
const trace = await page.evaluate(() => window.__fdTrace || []);
|
||||
stackA.trace = trace;
|
||||
console.log('PHASE A (partielle): alpineErrs=', alpineErrs(msgs).length, 'total=', msgs.length, JSON.stringify(stackA));
|
||||
console.log('TRACE:', JSON.stringify(trace));
|
||||
alpineErrs(msgs).slice(0, 6).forEach((m) => console.log(' A>', m));
|
||||
msgs.filter((m) => !m.includes('Alpine Expression')).slice(0, 5).forEach((m) => console.log(' A-other>', m));
|
||||
|
||||
// A2 : les erreurs s'arretent-elles apres le montage (fenetre transitoire) ?
|
||||
const a1 = alpineErrs(msgs).length;
|
||||
await page.waitForTimeout(3000);
|
||||
const a2 = alpineErrs(msgs).length;
|
||||
const ui = await page.evaluate(() => ({
|
||||
title: !!document.getElementById('_titleEl'),
|
||||
blocks: document.querySelectorAll('[data-bid]').length,
|
||||
editorVisible: !!document.querySelector('.page-editor-wrapper, .editor-wrap, [x-data^="editorState"]'),
|
||||
}));
|
||||
console.log('PHASE A2: erreurs a t0=', a1, '-> apres +3s=', a2, 'UI=', JSON.stringify(ui));
|
||||
|
||||
// PHASE B : rechargement complet de la meme page
|
||||
msgs.length = 0;
|
||||
await page.goto(`${FD_BASE}/pages/${pid}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(3000);
|
||||
console.log('PHASE B (complet): alpineErrs=', alpineErrs(msgs).length, 'total=', msgs.length);
|
||||
alpineErrs(msgs).slice(0, 6).forEach((m) => console.log(' B>', m));
|
||||
|
||||
// PHASE C : deuxieme navigation partielle vers la meme page
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
msgs.length = 0;
|
||||
await partialClick(page, pid);
|
||||
await page.waitForTimeout(3500);
|
||||
console.log('PHASE C (2e partielle): alpineErrs=', alpineErrs(msgs).length, 'total=', msgs.length);
|
||||
alpineErrs(msgs).slice(0, 6).forEach((m) => console.log(' C>', m));
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Régression — 2 bugs rapportés (post v7.45.0) :
|
||||
* 1. Logout : le SW servait sa page « hors ligne » sur TOUTE navigation
|
||||
* redirigée (fetch event en redirect:'manual' → opaqueredirect → rejet).
|
||||
* 2. Drag & drop de fichiers : POST /api/local-workspace/upload sans
|
||||
* X-CSRF-Token → 403 (A19 a retiré l'exemption sans équiper l'appel).
|
||||
*
|
||||
* Instance attendue sur FD_BASE_URL (défaut 8080), compte e2e documenté.
|
||||
*/
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
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';
|
||||
|
||||
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: 8000 })
|
||||
.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.ok() && resp.status() !== 409) {
|
||||
throw new Error(`register ${resp.status()}: ${await resp.text()}`);
|
||||
}
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
// workspace actif requis par /api/local-workspace/upload (A22)
|
||||
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',
|
||||
headers: { 'X-CSRF-Token': csrf },
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('logout avec SW : atterrit sur la page login, pas hors ligne', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.evaluate(async () => {
|
||||
if ('serviceWorker' in navigator) await navigator.serviceWorker.ready;
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
expect(await page.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
|
||||
|
||||
await page.goto(`${FD_BASE}/auth/logout`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL(/\/auth\/login/, { timeout: 10000 });
|
||||
const body = await page.evaluate(() => document.body.innerText);
|
||||
expect(body).not.toContain('hors ligne');
|
||||
expect(page.url()).toContain('/auth/login');
|
||||
});
|
||||
|
||||
test('drop de fichier : upload accepté (CSRF), 200 et item créé', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/local-workspace`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const uploadResp = [];
|
||||
page.on('response', (r) => {
|
||||
if (r.url().includes('/api/local-workspace/upload')) {
|
||||
uploadResp.push(
|
||||
r.text().then((body) => ({ status: r.status(), body })).catch(() => ({ status: r.status(), body: '' }))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await page.evaluate(() => {
|
||||
const zone = document.querySelector('[x-data*="wsInitData"]') || document.body;
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File(['hello world'], 'e2e-drop.txt', { type: 'text/plain' }));
|
||||
zone.dispatchEvent(new DragEvent('dragover', { dataTransfer: dt, bubbles: true, cancelable: true }));
|
||||
zone.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
await expect.poll(() => uploadResp.length, { timeout: 15000 }).toBeGreaterThan(0);
|
||||
const resp = await uploadResp[0];
|
||||
expect(resp.status, resp.body).toBe(200);
|
||||
|
||||
// Nettoyage : l'upload crée une page fichier → on la supprime.
|
||||
const parsed = JSON.parse(resp.body || '{}');
|
||||
for (const item of parsed.items || []) {
|
||||
if (!item.id) continue;
|
||||
await page.evaluate(async (pid) => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch('/api/local-workspace/items/' + pid, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrf },
|
||||
});
|
||||
}, item.id);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/** Sweep diag : nav partielle vs complet sur N pages — erreurs + état fdCtx/appState */
|
||||
const { test } = require('@playwright/test');
|
||||
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';
|
||||
|
||||
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: 8000 }).then(() => true).catch(() => false);
|
||||
if (!ok) {
|
||||
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
const PAGES = ['/workspaces', '/library', '/local-workspace', '/settings', '/trash', '/accounts', '/import'];
|
||||
|
||||
test('sweep', async ({ page }) => {
|
||||
test.setTimeout(180000);
|
||||
const msgs = [];
|
||||
page.on('console', (m) => msgs.push(m.type() + ' | ' + m.text().slice(0, 200).replace(/\s+/g, ' ')));
|
||||
page.on('pageerror', (e) => msgs.push('PAGEERROR | ' + String(e).slice(0, 160)));
|
||||
|
||||
await login(page);
|
||||
|
||||
for (const path of PAGES) {
|
||||
// baseline : chargement complet
|
||||
msgs.length = 0;
|
||||
await page.goto(FD_BASE + path, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(2200);
|
||||
const fullErrs = msgs.filter((m) => m.includes('PAGEERROR') || m.includes('Alpine Expression Error') || m.includes('Uncaught'));
|
||||
const stFull = await page.evaluate(() => ({
|
||||
fdCtx: (() => { try { return typeof Alpine.store('fdCtx'); } catch (e) { return 'ERR'; } })(),
|
||||
fdCtxHas: (() => { try { var s = Alpine.store('fdCtx'); return s ? typeof s.has + '/' + typeof s.avail : 'no store'; } catch (e) { return 'ERR'; } })(),
|
||||
appState: typeof window.appState,
|
||||
}));
|
||||
|
||||
// nav partielle : depuis /workspaces
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(700);
|
||||
msgs.length = 0;
|
||||
await page.evaluate((p) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = p;
|
||||
a.textContent = 'x';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
}, path);
|
||||
await page.waitForTimeout(2500);
|
||||
const partErrs = msgs.filter((m) => m.includes('PAGEERROR') || m.includes('Alpine Expression Error') || m.includes('Uncaught'));
|
||||
// 2e visite partielle dans LE MEME document (declencheur du SyntaxError
|
||||
// « Identifier ... has already been declared » sur script sans garde)
|
||||
await page.evaluate(() => {
|
||||
const a = document.createElement('a');
|
||||
a.href = '/workspaces';
|
||||
a.textContent = 'x';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
});
|
||||
await page.waitForTimeout(900);
|
||||
msgs.length = 0;
|
||||
await page.evaluate((p) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = p;
|
||||
a.textContent = 'x';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
}, path);
|
||||
await page.waitForTimeout(2500);
|
||||
const part2Errs = msgs.filter((m) => m.includes('PAGEERROR') || m.includes('Alpine Expression Error') || m.includes('Uncaught') || m.includes('already been declared'));
|
||||
const stPart = await page.evaluate(() => ({
|
||||
fdCtx: (() => { try { return typeof Alpine.store('fdCtx'); } catch (e) { return 'ERR'; } })(),
|
||||
fdCtxHas: (() => { try { var s = Alpine.store('fdCtx'); return s ? typeof s.has + '/' + typeof s.avail : 'no store'; } catch (e) { return 'ERR'; } })(),
|
||||
appState: typeof window.appState,
|
||||
lwGuard: !!window.__fdLocalWorkspaceScriptsLoaded,
|
||||
}));
|
||||
|
||||
console.log(`PAGE ${path}`);
|
||||
console.log(` complet : errs=${fullErrs.length} ${JSON.stringify(stFull)}`);
|
||||
fullErrs.slice(0, 2).forEach((m) => console.log(' F>', m));
|
||||
console.log(` 1ere partiel : errs=${partErrs.length} ${JSON.stringify(stPart)}`);
|
||||
partErrs.slice(0, 3).forEach((m) => console.log(' P>', m));
|
||||
console.log(` 2e partiel : errs=${part2Errs.length}`);
|
||||
part2Errs.slice(0, 3).forEach((m) => console.log(' P2>', m));
|
||||
}
|
||||
});
|
||||
@@ -164,4 +164,23 @@ test('gate A20 : palette Ctrl+K (Alpine + recherche GET)', async ({ page }) => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -23,68 +23,7 @@
|
||||
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) ===== */
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -394,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){
|
||||
|
||||
+75
-2
@@ -283,10 +283,8 @@
|
||||
if (!window.htmx) { window.location.href = url; return; }
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
document.documentElement.classList.add('fd-navigating');
|
||||
function done() {
|
||||
loading = false;
|
||||
document.documentElement.classList.remove('fd-navigating');
|
||||
}
|
||||
var p;
|
||||
try {
|
||||
@@ -326,6 +324,81 @@
|
||||
fdLoad(window.location.href, false);
|
||||
});
|
||||
|
||||
// ── Swap partiel : monter proprement la zone swapée sous Alpine ──
|
||||
// Lors d'une navigation partielle (fdLoad), les <script src> de la page
|
||||
// s'exécutent APRÈS le microtask MutationObserver d'Alpine : toute la zone
|
||||
// .main-wrapper serait initialisée avant que composants ET stores (menu
|
||||
// contextuel $store.fdCtx, …) existent → « Undefined variable: … » puis
|
||||
// « reading 'has' » sur les stores. On pose x-ignore sur TOUTE la zone
|
||||
// pendant le chargement des scripts, puis on démarle quand tous les
|
||||
// <script src> ont exécuté (load/error) — filet de sécurité 4 s.
|
||||
// (initTree est idempotent : le _x_marker posé au premier passage évite
|
||||
// tout double montage.)
|
||||
var fdLastMw = document.querySelector('.main-wrapper'); // nœud du chargement complet
|
||||
window.fdRefreshXData = function (name) {
|
||||
document.querySelectorAll('[x-data="' + name + '()"]').forEach(function (el) {
|
||||
if (el.__fdRefreshed) return;
|
||||
var st = el._x_dataStack;
|
||||
if (!st) return; // sous x-ignore → done() montera
|
||||
if (st[0] && Object.keys(st[0]).length) return; // déjà monté correctement
|
||||
el.__fdRefreshed = true; // monté cassé (hors watcher) → on remonte
|
||||
var fresh = el.cloneNode(true);
|
||||
el.parentNode.replaceChild(fresh, el);
|
||||
if (window.htmx) { try { htmx.process(fresh); } catch { /* volontaire */ } }
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('htmx:afterSwap', function () {
|
||||
var mw = document.querySelector('.main-wrapper');
|
||||
// nœud inchangé = swap hors navigation (sidebar, vues) : rien à faire
|
||||
if (!mw || mw === fdLastMw || mw.__fdSwapWatch) return;
|
||||
fdLastMw = mw;
|
||||
mw.__fdSwapWatch = true;
|
||||
// Avant tout paint (même task que le swap) : la zone est x-ignore, donc non
|
||||
// montée Alpine — tous ses [x-show] seraient peints à leur valeur brute
|
||||
// (rafale de menus/panneaux/états visibles une fraction de seconde). On ne
|
||||
// la peint qu'une fois initTree passé.
|
||||
mw.style.opacity = '0';
|
||||
mw.style.pointerEvents = 'none';
|
||||
mw.setAttribute('x-ignore', '');
|
||||
var pending = mw.querySelectorAll('script[src]').length;
|
||||
var reveal = function () {
|
||||
if (!mw.isConnected) return;
|
||||
mw.style.opacity = '';
|
||||
mw.style.pointerEvents = '';
|
||||
};
|
||||
var done = function () {
|
||||
reveal(); // idempotent : appelé même si la branche suivante retourne
|
||||
if (!mw.__fdSwapWatch) return;
|
||||
mw.__fdSwapWatch = false;
|
||||
document.removeEventListener('load', onScript, true);
|
||||
document.removeEventListener('error', onScript, true);
|
||||
if (!mw.isConnected || !mw.hasAttribute('x-ignore')) return;
|
||||
mw.removeAttribute('x-ignore');
|
||||
mw._x_ignore = false; // propriété posée par Alpine au checkpoint
|
||||
try { Alpine.initTree(mw); } catch (e) { console.warn('[FlowDeck] mount', e); }
|
||||
reveal();
|
||||
};
|
||||
var onScript = function (e) {
|
||||
if (!e.target || e.target.tagName !== 'SCRIPT') return;
|
||||
if (--pending <= 0) done();
|
||||
};
|
||||
if (!pending) {
|
||||
// Avant le microtask MutationObserver d'Alpine : retirer x-ignore suffit,
|
||||
// le MO initialisera au checkpoint (initTree de sécurité = no-op).
|
||||
mw.removeAttribute('x-ignore');
|
||||
mw.__fdSwapWatch = false;
|
||||
setTimeout(function () {
|
||||
if (mw.isConnected) { try { Alpine.initTree(mw); } catch (e) { console.warn('[FlowDeck] mount', e); } }
|
||||
reveal(); // après initTree : un paint max de blanc, jamais de contenu brut
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
document.addEventListener('load', onScript, true);
|
||||
document.addEventListener('error', onScript, true);
|
||||
setTimeout(done, 4000);
|
||||
});
|
||||
|
||||
// Ctrl/Cmd + \ : toggle the sidebar (Notion-style).
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === '\\' || e.code === 'Backslash')) {
|
||||
|
||||
+10
-4
@@ -1,3 +1,5 @@
|
||||
if (!window.__fdBoardScriptsLoaded) {
|
||||
window.__fdBoardScriptsLoaded = true;
|
||||
/* exported setActiveTab, kanbanBoard, filterSystem, sortSystem, newIssueForm, showNewIssue -- appeles depuis les attributs HTML des templates */
|
||||
const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
@@ -22,7 +24,7 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
|
||||
// globales window — probe « Undefined variable: kanbanBoard »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('kanbanBoard', kanbanBoard); });
|
||||
if (window.Alpine) { Alpine.data('kanbanBoard', kanbanBoard); fdRefreshXData('kanbanBoard'); } else document.addEventListener('alpine:init', function () { Alpine.data('kanbanBoard', kanbanBoard); });
|
||||
function kanbanBoard() {
|
||||
return {
|
||||
collapsedGroups: [],
|
||||
@@ -42,7 +44,7 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
|
||||
// globales window — probe « Undefined variable: filterSystem »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('filterSystem', filterSystem); });
|
||||
if (window.Alpine) { Alpine.data('filterSystem', filterSystem); fdRefreshXData('filterSystem'); } else document.addEventListener('alpine:init', function () { Alpine.data('filterSystem', filterSystem); });
|
||||
function filterSystem() {
|
||||
return {
|
||||
activeFilters: [],
|
||||
@@ -70,6 +72,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();
|
||||
@@ -81,7 +86,7 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
|
||||
// globales window — probe « Undefined variable: sortSystem »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('sortSystem', sortSystem); });
|
||||
if (window.Alpine) { Alpine.data('sortSystem', sortSystem); fdRefreshXData('sortSystem'); } else document.addEventListener('alpine:init', function () { Alpine.data('sortSystem', sortSystem); });
|
||||
function sortSystem() {
|
||||
return {
|
||||
sorts: [],
|
||||
@@ -108,7 +113,7 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
|
||||
// globales window — probe « Undefined variable: newIssueForm »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('newIssueForm', newIssueForm); });
|
||||
if (window.Alpine) { Alpine.data('newIssueForm', newIssueForm); fdRefreshXData('newIssueForm'); } else document.addEventListener('alpine:init', function () { Alpine.data('newIssueForm', newIssueForm); });
|
||||
function newIssueForm() {
|
||||
return {
|
||||
title: '', status: 'todo',
|
||||
@@ -166,3 +171,4 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
if (!window.__fdDbTableScriptsLoaded) {
|
||||
window.__fdDbTableScriptsLoaded = true;
|
||||
const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=el?JSON.parse(el.textContent):null;return v===undefined?null:v}catch{return null}})();
|
||||
|
||||
(function() {
|
||||
@@ -1309,3 +1311,4 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=
|
||||
.catch(function(){ return rowId; });
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
var _regGiteaWs = () => {
|
||||
Alpine.data('giteaWorkspace', () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const owner = params.get('owner') || '';
|
||||
@@ -368,6 +368,11 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
// ── 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() : '';
|
||||
},
|
||||
@@ -632,4 +637,10 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
if (window.Alpine) {
|
||||
_regGiteaWs();
|
||||
fdRefreshXData('giteaWorkspace');
|
||||
} else {
|
||||
document.addEventListener('alpine:init', _regGiteaWs);
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
// globales window — probe « Undefined variable: importWizard »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('importWizard', importWizard); });
|
||||
if (window.Alpine) { Alpine.data('importWizard', importWizard); fdRefreshXData('importWizard'); } else document.addEventListener('alpine:init', function () { Alpine.data('importWizard', importWizard); });
|
||||
function importWizard() {
|
||||
return {
|
||||
sources: [],
|
||||
|
||||
+17
-3
@@ -2,9 +2,8 @@
|
||||
|
||||
// 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);
|
||||
});
|
||||
if (window.Alpine) { Alpine.data('libraryPage', libraryPage); fdRefreshXData('libraryPage'); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('libraryPage', libraryPage); });
|
||||
|
||||
function libraryPage() {
|
||||
return {
|
||||
@@ -173,6 +172,21 @@ function libraryPage() {
|
||||
// $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(() => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
if (!window.__fdLocalWorkspaceScriptsLoaded) {
|
||||
window.__fdLocalWorkspaceScriptsLoaded = true;
|
||||
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');
|
||||
@@ -628,7 +630,7 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
||||
if (!node) return;
|
||||
try {
|
||||
var method = node.favorited ? 'DELETE' : 'POST';
|
||||
var r = await fetch('/board/api/favorites/' + node.id, { method: method });
|
||||
var r = await fetch('/board/api/favorites/' + node.id, { method: method, headers: {'X-CSRF-Token': getCsrf()} });
|
||||
if (r.ok) {
|
||||
node.favorited = !node.favorited;
|
||||
if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites();
|
||||
@@ -1763,7 +1765,7 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
||||
var url = folderHandled ? '/api/local-workspace/upload-folder' : '/api/local-workspace/upload';
|
||||
|
||||
try {
|
||||
var r = await fetch(url, { method: 'POST', body: formData });
|
||||
var r = await fetch(url, { method: 'POST', headers: {'X-CSRF-Token': getCsrf()}, body: formData });
|
||||
this.uploadProgress = 80;
|
||||
var d = await r.json();
|
||||
if (r.ok) {
|
||||
@@ -2012,6 +2014,20 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
||||
|
||||
// ── 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(() => {
|
||||
@@ -2051,7 +2067,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(_wsI
|
||||
|
||||
// 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 () {
|
||||
var _regWs = 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; });
|
||||
@@ -2075,4 +2091,12 @@ document.addEventListener('alpine:init', function () {
|
||||
};
|
||||
return t;
|
||||
});
|
||||
});
|
||||
};
|
||||
if (window.Alpine) {
|
||||
_regWs();
|
||||
fdRefreshXData('wsInitData');
|
||||
fdRefreshXData('wsPreview');
|
||||
} else {
|
||||
document.addEventListener('alpine:init', _regWs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
if (!window.__fdRtScriptsLoaded) {
|
||||
window.__fdRtScriptsLoaded = true;
|
||||
const RT=(()=>{try{const el=document.getElementById('rt-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
/* eslint-disable */
|
||||
@@ -530,3 +532,4 @@ window.__fdRT = (function () {
|
||||
|
||||
return { start, syncNow };
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -2525,6 +2535,10 @@ applyAIBlocks(text){
|
||||
|
||||
// globales window — probe « Undefined variable: editorState »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('editorState', editorState); });
|
||||
// Navigation partielle (fdLoad) : Alpine démarre déjà → 'alpine:init'
|
||||
// ne sera plus émis, on enregistre tout de suite (pattern _ctx_menu.js)
|
||||
// et on remonte la racine montée avant l'enregistrement.
|
||||
if (window.Alpine) { Alpine.data('editorState', editorState); fdRefreshXData('editorState'); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('editorState', editorState); });
|
||||
window.editorState = editorState;
|
||||
}
|
||||
|
||||
+133
-1
@@ -1,3 +1,5 @@
|
||||
if (!window.__fdSettingsScriptsLoaded) {
|
||||
window.__fdSettingsScriptsLoaded = true;
|
||||
/* 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 {}}})();
|
||||
|
||||
@@ -5,7 +7,7 @@ const ST=(()=>{try{const el=document.getElementById('st-config');return el?JSON.
|
||||
|
||||
// globales window — probe « Undefined variable: settingsInit »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('settingsInit', settingsInit); });
|
||||
if (window.Alpine) { Alpine.data('settingsInit', settingsInit); fdRefreshXData('settingsInit'); } else document.addEventListener('alpine:init', function () { Alpine.data('settingsInit', settingsInit); });
|
||||
function settingsInit() {
|
||||
return {
|
||||
// ── A20 ph3 : expressions hostiles au parseur CSP (window/Date) ──
|
||||
@@ -75,6 +77,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:'[]' },
|
||||
|
||||
@@ -773,6 +778,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 {
|
||||
@@ -826,9 +953,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) {
|
||||
@@ -1104,3 +1235,4 @@ function settingsInit() {
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
|
||||
// globales window — probe « Undefined variable: workspacesPage »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('workspacesPage', workspacesPage); });
|
||||
if (window.Alpine) { Alpine.data('workspacesPage', workspacesPage); fdRefreshXData('workspacesPage'); } else 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,
|
||||
|
||||
|
||||
+18
-5
@@ -156,9 +156,22 @@ async function cacheFirst(request, { cacheName = CACHE_NAME } = {}) {
|
||||
async function networkFirst(request, { cacheName = CACHE_NAME, fallbackToShell = false, timeoutMs = 4000 } = {}) {
|
||||
const cache = await caches.open(cacheName);
|
||||
try {
|
||||
const res = await timeoutFetch(request, timeoutMs);
|
||||
let res = await timeoutFetch(request, timeoutMs);
|
||||
// Les navigations interceptées arrivent en redirect:'manual' : une 302 du
|
||||
// serveur se lit opaqueredirect (status 0) → on rejoue la requête avec suivi
|
||||
// explicite. Sans ça, tout logout / redirection rendait la page hors ligne.
|
||||
if (res.type === 'opaqueredirect') {
|
||||
// Navigation en redirect:'manual' : on suit la redirection à la main.
|
||||
res = await timeoutFetch(new Request(request, { redirect: 'follow' }), timeoutMs);
|
||||
if (res && res.ok && res.redirected) {
|
||||
// Chromium refuse une response 'redirected' servie à une navigation
|
||||
// (ERR_FAILED) → on émet une vraie redirection vers l'URL finale.
|
||||
return new Response(null, { status: 302, headers: { Location: res.url } });
|
||||
}
|
||||
}
|
||||
if (res && res.ok) {
|
||||
cache.put(request, res.clone());
|
||||
// Ne pas mettre en cache une réponse de redirection sous l'URL d'origine.
|
||||
if (!res.redirected) cache.put(request, res.clone());
|
||||
return res;
|
||||
}
|
||||
throw new Error('bad status');
|
||||
@@ -176,12 +189,12 @@ async function networkFirst(request, { cacheName = CACHE_NAME, fallbackToShell =
|
||||
|
||||
function timeoutFetch(request, ms) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
ctrl.abort();
|
||||
reject(new Error('timeout'));
|
||||
}, ms);
|
||||
fetch(request).then(
|
||||
fetch(request, { signal: ctrl.signal }).then(
|
||||
(res) => { clearTimeout(timer); resolve(res); },
|
||||
(err) => { clearTimeout(timer); reject(err); }
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user