diff --git a/CHANGELOG.md b/CHANGELOG.md index 7751d7d..9aa4c04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog - FlowDeck +## v7.21.0 (2026-10-01) — Audit : A27 phase 1 (4 243 L de JS extraites) + +### Changed + +- **A27 (phase 1)** — les 7 templates dont le JS **n'est pas interpolé Jinja** + sont extraits vers `static/js/*.js` : agent_panel_1/_2 (dont un bloc de + **1 788 lignes livré sur chaque page**), library (1 039), gitea_workspace + (626), _icon_picker_1/_2, _ctx_menu, import, workspaces → + **4 243 lignes, -30 % de JS inline** (13 904 → 9 661) +- Extraction **un fichier par bloc** : ordre et timing d'exécution identiques + (pas de defer/async, attributs d'origine conservés dont `data-cfasync`), + cache-busting `?v={{ asset_version }}` (source unique A40), CSP : les + scripts externes relèvent de `'self'` (pas de nonce requis) + +### Lint + +- ESLint **installé globalement** (`npm i -g eslint`) — `eslint.config.mjs` + existait déjà en flat config « sans dépendances » mais **aucun binaire** + n'était installé (d'où « 0 linté ») +- `eslint static/js` : **0 erreur, 120 warnings** sur 8 fichiers + (no-unused-vars 69, no-empty 36, no-undef 15) → baseline à nettoyer +- `node --check` vert sur les 9 fichiers extraits ; `eslint.config.mjs` couvre + déjà `static/js/**/*.js` donc les extraits sont lintés d'office + +### Notes + +- Reste A27 : les blocs interpolés Jinja (page_editor 2 517, local_workspace + 2 031, base 1 523, database_table 1 323, settings 1 093… ≈ 9 661 L) → + extraction en 2 temps (config JSON injectée + script statique) +- Suite complète : **1089/1089** + ## v7.20.0 (2026-10-01) — Audit : A32 TERMINÉ (routes Gitea + bug fd_icon) ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index c643741..c9427e4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1147,7 +1147,7 @@ Quality DB views, Agent IA Palette → Realtime + E ### 🟡 P2 — Moyennes - [x] **A26 — Config piège** : (a) `FLOWDECK_STANDALONE` documenté dans `config.py:26` mais **jamais lu** (le champ réel est `STANDALONE`, pas de `env_prefix`, `extra="ignore"` avale la coquille) ; (b) `.env.example` documente `postgresql://…` mais `db_path` (`config.py:133`) retombe silencieusement sur SQLite ; (c) `db_path` fait `Path("/" + p)` (`config.py:132`) → pour `sqlite:////data/flowdeck.db` le résultat est le chemin UNC `\\data\flowdeck.db` (vérifié : inexistant) ; (d) `app_secret_key="change-me-to-random"` (`config.py:36`) + `gitea_oauth_client_secret="test-secret"` sans aucun garde au boot alors qu'il signe `flowdeck_session` (`session.py:14`). *Fix : 1 normalisation de `db_path` + `raise` au boot si secret par défaut + corriger le commentaire/`env_prefix`. Effort : **S**.* -- [ ] **A27 — 13 900 lignes de JS inline dans 37 blocs**, ~3 800 livrées sur **chaque** page (`base.html` 1520 + `agent_panel` 1805 + `_icon_picker` 297 + `_header` 124 + `_notification_bell` 69), et **0 linté** : `eslint.config.mjs:50` ne couvre que `static/js/**/*.js` (soit `app.js` + `offline.js`), 2 blocs se neutralisent avec `/* eslint-disable */`. Grosseurs : `_page_editor_scripts` 2517, `local_workspace` 2030, `agent_panel` 1805, `base` 1520, `_database_table_scripts` 1323, `settings` 1093, `library` 1039. *Fix : extraire les gros partials vers `/static/js/*.js` (ils ne sont pas Jinja-interpolés) + ajouter les templates à eslint. Effort : **L**.* +- [x] **A27 — 13 900 lignes de JS inline — PARTIEL 2026-10-01 (4 243 extraites) dans 37 blocs**, ~3 800 livrées sur **chaque** page (`base.html` 1520 + `agent_panel` 1805 + `_icon_picker` 297 + `_header` 124 + `_notification_bell` 69), et **0 linté** : `eslint.config.mjs:50` ne couvre que `static/js/**/*.js` (soit `app.js` + `offline.js`), 2 blocs se neutralisent avec `/* eslint-disable */`. Grosseurs : `_page_editor_scripts` 2517, `local_workspace` 2030, `agent_panel` 1805, `base` 1520, `_database_table_scripts` 1323, `settings` 1093, `library` 1039. *Fix : extraire les gros partials vers `/static/js/*.js` (ils ne sont pas Jinja-interpolés) + ajouter les templates à eslint.* — **phase 1 faite 2026-10-01** : **7 templates sans Jinja → 9 fichiers `static/js/*.js` (4 243 lignes, -30 % du JS inline)** : agent_panel_1/2 (4 243… dont le 1 788 L livré sur CHAQUE page), library 1039, gitea_workspace 626, _icon_picker_1/2, _ctx_menu, import, workspaces — **un fichier par bloc** (ordre/timing identique, pas de defer, attributs conservés, `?v={{ asset_version }}`), `node --check` vert sur les 9, 0 script inline restant dans les cibles, suite 1089/1089. **Lint** : eslint **installé globalement** (`npm i -g eslint`, la config était déjà flat v9 sans dépendances) → `eslint static/js` = **0 erreur / 120 warnings** (no-unused-vars 69, no-empty 36, no-undef 15 — baseline à nettoyer opportunistiquement). **Reste A27** : les blocs **interpolés Jinja** (page_editor 2517, local_workspace 2031, base 1523, database_table 1323, settings 1093, realtime 531… ≈ 9 661 L) → extraction en 2 temps (config JSON injectée + script statique). Effort : **L**. - [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.* - [x] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent.* — **fait 2026-10-01** : `services/publish.py` (slugify unique, 404 partout, événements) ; les 3 paires publish/unpublish déléguent (sharing + board + v2), board gagne `_require_auth`, les bonus divergents (`share_mode='anyone'` / `is_shared=1`) supprimés — le share dialog reste propriétaire de ces drapeaux ; **byproduct sécurité** : `GET /api/users/me` (v1) et le contexte de `/accounts` faisaient `SELECT *` → `password_hash` exposé → colonnes whitelistées. **Décision** : `/api/users/me` ×2 et listing collections ×3 **restent** — contrats versionnés distincts (session+guest vs Bearer+scope, formes différentes). Effort : **M**. - [x] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.* @@ -1181,4 +1181,4 @@ Quality DB views, Agent IA Palette → Realtime + E → Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20. *Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).* -→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. · **A20 (partiel)** : CSP nonce par requête — `unsafe-inline` retiré de `script-src`, 38 scripts templates + login constant + 3 scripts Python noncés, meta `htmx-config` pour htmx, `script-src-attr` pour les 74 `onclick=`, CDN chart/leaflet débloqués (déjà cassés avant) → suite 1037/1037, version 7.7.0. · **A21 phase 1** : 352 routes `async def` sans `await` → `def` (threadpool FastAPI, SQLite hors loop, zéro changement de logique — scan corps par corps) ; reste phase 2 = 311 routes avec `await` → `anyio.to_thread.run_sync` par bloc DB → suite 1037/1037, version 7.8.0. · **A21 phase 2a** : `body` JSON → paramètre `Body(default={})` sur les 36 routes api_v2 dont c'était le seul `await` → conversion en `def` → api_v2 à 96/115 hors loop (19 async restantes : fire_event/form/gitea) → suite 1037/1037, version 7.9.0. · **A21 phase 2b** : `run_event_sync()` (asyncio.run dans le worker, événement attendu = déterministe) + les 15 routes json/événements en `def` → api_v2 bouclé à 111/115 (4 async = vrais awaits réseau) ; repo-wide 403 sync / 260 async → suite 1037/1037, version 7.10.0. · **A32 phase 1** : les 4 routers à 0 test couverts (webhooks/notes/sidebar_config/github_routes = 10 smokes, 0 réseau réel, échappement notes vérifié) ; reste quasi nuls library/api/dashboard/api_v2 → suite 1047/1047, version 7.11.0. · **A32 phase 2a** : library 1/10 → 8 routes couvertes ; découverte = 2 routes lisant la table fantôme `local_workspace_items` (500 systématique, 0 ref front) supprimées + `_format_size` mort → suite 1053/1053, version 7.12.0. · **A32 phase 2b** : api.py 3 → 16/22 routes couvertes (board-config, col-mapping, card, collaborators stubbé, frontend-error dédup, mutations checklist vérifiées en base) ; reste 6 routes gitea + dashboard/api_v2 → suite 1059/1059, version 7.13.0. · **A32 phase 2c** : api_v2 scan strict → 5 routes à 0 ref couvertes (formula, rollup, audit-logs avec portail vérifié, webhooks/events, verify-signature signé) → suite 1064/1064, version 7.14.0. · **A32 phase 2d** : dashboard scan strict = 44 routes à 0 ref, 10 couvertes (tags CRUD, page content→rename→trash vérifiés en base, tree HTML, avatar-color restauré, members) → suite 1069/1069, version 7.15.0. · **A32 phase 2e** : +9 routes dashboard (pages comptes sans hash, profile/password A3 403, token fd_+64hex, forge, settings/account 400, select cookie, breadcrumb) → suite 1075/1075, version 7.16.0. · **A32 phase 2f** : +7 routes dashboard centrées A16 (files traversal 403, download/file-content sans fuite, avatar 302 sans réseau, table-data + création de ligne) → suite 1079/1079, version 7.17.0. · **A32 phase 2g** : +13 routes dashboard (gitea-workspace HTML, projects CRUD, cycle items 5 routes, cycle tags d'item 5 routes avec ws dédié) → suite 1083/1083, version 7.18.0. · **A32 phase 2h → dashboard bloqué** : members (cycle complet + quirk tuple), upload-folder (validations seules), convert-to-database (vérifié en base, ordre FK) ; les 44 routes à 0 ref sont toutes exercées (faux positifs f-string rapprochés) → suite 1086/1086, version 7.19.0. · **A32 TERMINÉ** : 6 routes Gitea stubbées (canevas mutable, carte board, HTML `?format=html`, 404) + **bug prod `card_detail.html`/`fd_icon` corrigé** (500 garanti avant) → suite 1089/1089, version 7.20.0. +→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. · **A20 (partiel)** : CSP nonce par requête — `unsafe-inline` retiré de `script-src`, 38 scripts templates + login constant + 3 scripts Python noncés, meta `htmx-config` pour htmx, `script-src-attr` pour les 74 `onclick=`, CDN chart/leaflet débloqués (déjà cassés avant) → suite 1037/1037, version 7.7.0. · **A21 phase 1** : 352 routes `async def` sans `await` → `def` (threadpool FastAPI, SQLite hors loop, zéro changement de logique — scan corps par corps) ; reste phase 2 = 311 routes avec `await` → `anyio.to_thread.run_sync` par bloc DB → suite 1037/1037, version 7.8.0. · **A21 phase 2a** : `body` JSON → paramètre `Body(default={})` sur les 36 routes api_v2 dont c'était le seul `await` → conversion en `def` → api_v2 à 96/115 hors loop (19 async restantes : fire_event/form/gitea) → suite 1037/1037, version 7.9.0. · **A21 phase 2b** : `run_event_sync()` (asyncio.run dans le worker, événement attendu = déterministe) + les 15 routes json/événements en `def` → api_v2 bouclé à 111/115 (4 async = vrais awaits réseau) ; repo-wide 403 sync / 260 async → suite 1037/1037, version 7.10.0. · **A32 phase 1** : les 4 routers à 0 test couverts (webhooks/notes/sidebar_config/github_routes = 10 smokes, 0 réseau réel, échappement notes vérifié) ; reste quasi nuls library/api/dashboard/api_v2 → suite 1047/1047, version 7.11.0. · **A32 phase 2a** : library 1/10 → 8 routes couvertes ; découverte = 2 routes lisant la table fantôme `local_workspace_items` (500 systématique, 0 ref front) supprimées + `_format_size` mort → suite 1053/1053, version 7.12.0. · **A32 phase 2b** : api.py 3 → 16/22 routes couvertes (board-config, col-mapping, card, collaborators stubbé, frontend-error dédup, mutations checklist vérifiées en base) ; reste 6 routes gitea + dashboard/api_v2 → suite 1059/1059, version 7.13.0. · **A32 phase 2c** : api_v2 scan strict → 5 routes à 0 ref couvertes (formula, rollup, audit-logs avec portail vérifié, webhooks/events, verify-signature signé) → suite 1064/1064, version 7.14.0. · **A32 phase 2d** : dashboard scan strict = 44 routes à 0 ref, 10 couvertes (tags CRUD, page content→rename→trash vérifiés en base, tree HTML, avatar-color restauré, members) → suite 1069/1069, version 7.15.0. · **A32 phase 2e** : +9 routes dashboard (pages comptes sans hash, profile/password A3 403, token fd_+64hex, forge, settings/account 400, select cookie, breadcrumb) → suite 1075/1075, version 7.16.0. · **A32 phase 2f** : +7 routes dashboard centrées A16 (files traversal 403, download/file-content sans fuite, avatar 302 sans réseau, table-data + création de ligne) → suite 1079/1079, version 7.17.0. · **A32 phase 2g** : +13 routes dashboard (gitea-workspace HTML, projects CRUD, cycle items 5 routes, cycle tags d'item 5 routes avec ws dédié) → suite 1083/1083, version 7.18.0. · **A32 phase 2h → dashboard bloqué** : members (cycle complet + quirk tuple), upload-folder (validations seules), convert-to-database (vérifié en base, ordre FK) ; les 44 routes à 0 ref sont toutes exercées (faux positifs f-string rapprochés) → suite 1086/1086, version 7.19.0. · **A32 TERMINÉ** : 6 routes Gitea stubbées (canevas mutable, carte board, HTML `?format=html`, 404) + **bug prod `card_detail.html`/`fd_icon` corrigé** (500 garanti avant) → suite 1089/1089, version 7.20.0. · **A27 phase 1** : 7 templates sans Jinja → 9 fichiers static/js (4 243 L, -30 % du inline, node --check vert), eslint installé globalement → `eslint static/js` 0 erreur/120 warnings ; reste = blocs interpolés Jinja (~9 661 L) → suite 1089/1089, version 7.21.0. diff --git a/VERSION b/VERSION index 2540a3a..a29ba3d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.20.0 +7.21.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index 798627e..2f1c930 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.20.0 (audit — A32 TERMINÉ : routes Gitea stubbées + bug fd_icon) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.21.0 (audit — A27 partiel : 4 243 L extraites, 0 erreur eslint) | **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 diff --git a/app/main.py b/app/main.py index 12bf383..9078ab0 100644 --- a/app/main.py +++ b/app/main.py @@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.20.0", + version="7.21.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/templates/_ctx_menu.html b/app/templates/_ctx_menu.html index d1aa175..45ca865 100644 --- a/app/templates/_ctx_menu.html +++ b/app/templates/_ctx_menu.html @@ -15,172 +15,7 @@ open, openTab, peek, folder, rename, setIcon, duplicate, link, download, copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove ############################################################################} - + {# ── Global Agent API (available before Alpine for the FAB / shortcut) ── #} - + {# ── Floating button ── #} @@ -456,1792 +439,4 @@ - + diff --git a/app/templates/gitea_workspace.html b/app/templates/gitea_workspace.html index 281cc4a..a4c3e2c 100644 --- a/app/templates/gitea_workspace.html +++ b/app/templates/gitea_workspace.html @@ -35,633 +35,7 @@ .gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;} - +
diff --git a/app/templates/import.html b/app/templates/import.html index 6c5e3ad..3cd4d64 100644 --- a/app/templates/import.html +++ b/app/templates/import.html @@ -210,164 +210,6 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
- + diff --git a/app/templates/library.html b/app/templates/library.html index 6ad57a4..3bfc529 100644 --- a/app/templates/library.html +++ b/app/templates/library.html @@ -456,1044 +456,5 @@ {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/app/templates/workspaces.html b/app/templates/workspaces.html index 578bcc1..e62819f 100644 --- a/app/templates/workspaces.html +++ b/app/templates/workspaces.html @@ -172,158 +172,5 @@ - + {% endblock %} diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index 2678d48..101a0aa 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.20.0" + "version": "7.21.0" }, "paths": { "/auth/register": { diff --git a/static/js/_ctx_menu.js b/static/js/_ctx_menu.js new file mode 100644 index 0000000..9e19405 --- /dev/null +++ b/static/js/_ctx_menu.js @@ -0,0 +1,165 @@ + +/* ═════════════════════════════════════════════════════════════════════ + fdCtx — store Alpine UNIQUE du menu contextuel partagé. + Enregistré dans 'alpine:init' avec garde d'exécution : que la page + inclue ce partial dans base.html ou directement, le js ne s'exécute + qu'une seule fois. Chaque page, à l'ouverture du menu, positionne : + fdCtx.openMenu(evt, node, pageHash, handlers) + ─────────────────────────────────────────────────────────────────── */ +(function () { + if (window.__fdCtxMenuRegistered) return; + window.__fdCtxMenuRegistered = true; + + // Sur un chargement complet de page, ce script inline s'exécute AVANT + // alpine.min.js (defer) → Alpine n'existe pas encore : on attend 'alpine:init'. + // Sur une navigation partielle (fdNavigate → htmx), Alpine est déjà démarré et + // 'alpine:init' ne sera plus jamais émis → on enregistre le store tout de suite, + // sinon le menu contextuel reste mort jusqu'au prochain chargement complet. + function registerFdCtx() { + if (!window.Alpine) return; + if (window.Alpine.__fdCtxStore) return; + window.Alpine.__fdCtxStore = true; + + Alpine.store('fdCtx', { + open: false, x: 0, y: 0, node: null, page: null, + maxH: 0, _cx: 0, _cy: 0, _ro: null, + handlers: {}, + /* Tags (workspace) */ + availTags: [], tagAdding: false, tagExistingOpen: false, + newTagColor: '#787774', + tagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'], + /* Icon picker */ + iconOpen: false, + iconChoices: ['📄','📝','📕','📁','⭐','🔖','📌','✅','💡','🔥','🚀','🎯','📊','🗓️','🔗','🧩','📎','🎨','🐛','⚙️','❤️','👍','✨','🏷️','🗒️','📚'], + + /* Positionne le menu à l'écran et expose les handlers de la page. + ⚠️ Nom : openMenu (et PAS open) — « open » est réservé au booléen + d'état x-show. Une collision ici rendrait le menu PERMANENT. */ + openMenu(ev, node, pageHash, handlers) { + handlers = handlers || {}; + this.node = node; + this.page = pageHash; + this.handlers = handlers; + this.tagAdding = false; + this.tagExistingOpen = false; + this.iconOpen = false; + this.newTagColor = (node && node.tags && node.tags[0] && node.tags[0].color) || '#787774'; + var cx = (ev && ev.clientX) || 0; + var cy = (ev && ev.clientY) || 0; + this._cx = cx; + this._cy = cy; + this.x = Math.max(8, Math.min(cx, window.innerWidth - 240)); + this.y = Math.max(8, Math.min(cy, window.innerHeight - 48)); + this.open = true; + var self = this; + /* Mesure la taille réelle du menu (une fois rendu) puis le replace + pour qu'il reste TOUJOURS entièrement visible dans le viewport. + Un ResizeObserver relance le placement à chaque fois que le menu + change de taille (ouverture d'un sous-menu « tag », icônes, …), + sinon il grandissait vers le bas et sortait de l'écran. */ + var after = function () { + self._place(); + var el = document.querySelector('.fd-ctx-menu'); + if (el && window.ResizeObserver) { + if (self._ro) { try { self._ro.disconnect(); } catch (e) {} } + self._ro = new ResizeObserver(function () { self._place(); }); + self._ro.observe(el); + } + }; + if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(after); + else setTimeout(after, 0); + }, + /* Reclasse le menu dans le viewport et limite sa hauteur à l'espace + disponible sous son ancre (le contenu déborde en scroll interne). */ + _place() { + var el = document.querySelector('.fd-ctx-menu'); + if (!el) return; + var prev = el.style.maxHeight; + el.style.maxHeight = 'none'; + var w = el.offsetWidth || 240; + var h = el.offsetHeight || 320; + el.style.maxHeight = prev || ''; + var vw = window.innerWidth, vh = window.innerHeight; + var cx = (this._cx == null ? this.x : this._cx); + var cy = (this._cy == null ? this.y : this._cy); + var nx = cx; + if (nx + w > vw - 8) nx = vw - w - 8; + nx = Math.max(8, nx); + var ny = cy; + if (ny + h > vh - 8) { + if (cy - h >= 8) ny = cy - h; + else ny = Math.max(8, vh - h - 8); + } + this.x = nx; + this.y = ny; + this.maxH = Math.max(120, vh - ny - 8); + }, + close() { + if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; } + this.open = false; + this.node = null; + this.handlers = {}; + this.tagAdding = false; + this.tagExistingOpen = false; + this.iconOpen = false; + }, + + /* Un item est-il disponible pour CETTE page ? (union vs capacités) */ + has(key) { return typeof this.handlers[key] === 'function'; }, + + /* Exécute l'action → handler fourni par la page courante. */ + run(key) { + if (!this.node) { this.close(); return; } + var fn = this.handlers[key]; + if (fn) try { fn(this.node); } catch (e) { console.error('fdCtx.run', e); } + this.close(); + }, + + /* ── Tags ── */ + avail() { return this.availTags || []; }, + setAvail(a) { this.availTags = a || []; }, + removeTag(id) { + var fn = this.handlers.tagRemove; + if (fn) try { fn(id); } catch (e) { console.error('fdCtx.removeTag', e); } + }, + addExistingTag(t) { + var fn = this.handlers.tagExisting; + if (fn) try { fn(t); } catch (e) { console.error('fdCtx.addExistingTag', e); } + this.tagExistingOpen = false; + }, + addNewTag(name, color) { + name = (name || '').trim(); + if (!name) return; + var fn = this.handlers.tagAdd; + if (fn) try { fn(name, color || this.newTagColor); } catch (e) { console.error('fdCtx.addNewTag', e); } + this.tagAdding = false; + }, + + /* ── Icon picker ── */ + setIcon(icon) { + icon = (icon || '').trim(); + var fn = this.handlers.setIcon; + if (fn) try { fn(icon); } catch (e) { console.error('fdCtx.setIcon', e); } + this.close(); + }, + openIconPicker() { + var fn = this.handlers.setIcon; + if (!fn) return; + if (!window.FDIconPicker) return; + window.FDIconPicker.openFor({ + x: this.x, + y: this.y, + onPick: fn, + onRemove: function () { fn(''); } + }); + this.close(); + }, + }); + } + + if (window.Alpine) { + registerFdCtx(); + } else { + document.addEventListener('alpine:init', registerFdCtx); + } +})(); diff --git a/static/js/_icon_picker_1.js b/static/js/_icon_picker_1.js new file mode 100644 index 0000000..fa40485 --- /dev/null +++ b/static/js/_icon_picker_1.js @@ -0,0 +1,77 @@ + +/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */ +(function () { + var FD_ICONS = { + 'folder': '', + 'file': '', + 'calendar': '', + 'clock': '', + 'star': '', + 'bot': '', + 'users': '', + 'globe': '', + 'lock': '', + 'book': '', + 'check-square': '', + 'trash': '', + 'help-circle': '', + 'settings': '', + 'refresh': '', + 'log-out': '', + 'message-square': '', + 'home': '', + 'search': '', + 'link': '', + 'plus': '', + 'bell': '', + 'image': '', + 'download': '', + 'chevron-left': '', + 'chevron-right': '', + 'list': '', + 'bar-chart': '', + 'grid': '', + 'align-left': '', + 'corner-down-right': '', + 'copy': '', + 'chevron-down': '', + 'key': '', + 'inbox': '', + 'edit': '', + 'eye-off': '', + 'eye': '', + 'share': '', + 'more-horizontal': '', + 'x': '', + 'paperclip': '', + 'external-link': '', + 'sparkles': '', + 'lightbulb': '', + 'tag': '', + 'file-text': '', + 'save': '', + 'upload': '', + 'trending-up': '', + 'zap': '', + 'alert-triangle': '', + 'user': '' + }; + window.FD_ICONS = FD_ICONS; + window.fd_icon = function (name, size) { + size = size || 18; + var inner = FD_ICONS[name]; + if (inner) return '' + inner + ''; + return (window.getSvgIcon ? getSvgIcon(name, size) : ''); + }; + /* Render a page_icon value: image URL, known icon name, or emoji text. */ + window.fdIconHtml = function (value, size) { + size = size || 16; + var v = (value == null ? '' : String(value)).trim(); + if (!v) return ''; + if (v.charAt(0) === '/' || v.slice(0, 4) === 'http') { + return ''; + } + if (FD_ICONS[v] || (window.getSvgIcon && getSvgIcon(v, size))) return window.fd_icon(v, size); + return v; + }; +})(); diff --git a/static/js/_icon_picker_2.js b/static/js/_icon_picker_2.js new file mode 100644 index 0000000..e6c17e5 --- /dev/null +++ b/static/js/_icon_picker_2.js @@ -0,0 +1,220 @@ + +(function () { + if (window.__fdIconPickerRegistered) return; + window.__fdIconPickerRegistered = true; + + var TONES = ['', '\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}']; + + var TONEABLE = {}; + ['👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','🫶','👐','🤲','🤝','🙏','💪','🦵','🦶','👂','👃','👶','🧒','👦','👧','🧑','👨','👩','🧓','👴','👵','🙍','🙎','🙅','🙆','💁','🙋','🧏','🙇','🤦','🤷','👮','🕵️','💂','👷','🤴','👸','👳','👲','🧕','🤵','👰','🤰','🤱','👼','🎅','🤶','🦸','🦹','🧙','🧚','🧛','🧜','🧝','💆','💇','🚶','🧍','🧎','🏃','💃','🕺','👯','🧖','🧗','🏇','⛷️','🏂','🏌️','🏄','🚣','🏊','⛹️','🏋️','🚴','🚵','🤸','🤼','🤽','🤾','🤹','🧘','🛀','🛌'].forEach(function (e) { TONEABLE[e] = true; }); + + var CATS = [ + { id: 'people', label: 'People', items: [ + ['😀','grinning face smile happy'],['😃','smiley happy'],['😄','smile laugh happy'],['😁','grin happy'],['😆','laughing happy'],['😅','sweat smile'],['🤣','rofl rolling laugh'],['😂','joy tears laugh'],['🙂','slight smile'],['🙃','upside down'],['😉','wink'],['😊','blush smile happy'],['😇','innocent halo angel'],['🥰','love hearts'],['😍','heart eyes love'],['🤩','star struck wow'],['😘','kiss love'],['😗','kissing'],['😚','kissing'],['😙','kissing'],['🥲','tear smile happy'],['😋','yum tasty'],['😛','tongue'],['😜','wink tongue'],['🤪','crazy zany'],['😝','tongue'],['🤑','money rich'],['🤗','hug'],['🤭','giggle'],['🤫','shush quiet'],['🤔','thinking'],['🤐','zip quiet'],['🤨','raised eyebrow'],['😐','neutral'],['😑','expressionless'],['😶','no mouth'],['😏','smirk'],['😒','unamused'],['🙄','roll eyes'],['😬','grimace'],['🤥','lying'],['😌','relieved'],['😔','pensive sad'],['😪','sleepy'],['🤤','drool'],['😴','sleeping'],['😷','mask sick'],['🤒','sick thermometer'],['🤕','hurt bandage'],['🤢','nauseated'],['🤮','vomit'],['🤧','sneeze'],['🥵','hot'],['🥶','cold'],['🥴','woozy'],['😵','dizzy'],['🤯','mind blown'],['🤠','cowboy'],['🥳','party'],['🥺','pleading'],['😎','cool sunglasses'],['🤓','nerd'],['🧐','monocle'],['😕','confused'],['😟','worried'],['🙁','frown'],['☹️','frown sad'],['😮','surprised'],['😯','hushed'],['😲','astonished'],['😳','flushed'],['😨','fearful'],['😰','anxious'],['😥','sad'],['😢','cry'],['😭','sob cry'],['😱','scream fear'],['😖','confounded'],['😣','persevere'],['😞','disappointed'],['😓','sweat'],['😩','weary'],['😫','tired'],['🥱','yawn'],['😤','triumph'],['😡','angry'],['😠','rage'],['🤬','cursing'],['😈','devil'],['👿','imp'],['💀','skull'],['💩','poop'],['🤡','clown'],['👻','ghost'],['👽','alien'],['🤖','robot'],['😺','cat'],['🙈','monkey see'],['🙉','monkey hear'],['🙊','monkey speak'],['👋','wave hand'],['🤚','raised hand'],['🖐️','hand'],['✋','raised hand'],['🖖','vulcan'],['👌','ok'],['🤌','pinched'],['🤏','pinch'],['✌️','peace'],['🤞','cross fingers luck'],['🤟','love you'],['🤘','rock'],['🤙','call me'],['👈','point left'],['👉','point right'],['👆','point up'],['👇','point down'],['☝️','point up'],['👍','thumbs up like'],['👎','thumbs down dislike'],['✊','fist'],['👊','fist bump'],['🤛','fist'],['🤜','fist'],['👏','clap'],['🙌','raise hands celebrate'],['🫶','heart hands'],['👐','open hands'],['🤲','palms'],['🤝','handshake'],['🙏','pray thanks'],['💪','muscle strong'],['👂','ear'],['👃','nose'],['👀','eyes look'],['👁️','eye'],['🧠','brain'],['👶','baby'],['🧒','child'],['👦','boy'],['👧','girl'],['🧑','person'],['👨','man'],['👩','woman'],['🧓','older person'],['👴','old man'],['👵','old woman'],['👮','police'],['🕵️','detective'],['💂','guard'],['👷','worker'],['🤴','prince'],['👸','princess'],['👳','turban'],['🧕','hijab'],['🤵','tuxedo'],['👰','bride'],['🤰','pregnant'],['🤱','breastfeeding'],['👼','angel'],['🎅','santa'],['🤶','mrs claus'],['🦸','superhero'],['🦹','supervillain'],['🧙','mage wizard'],['🧚','fairy'],['🧛','vampire'],['🧜','mermaid'],['🧝','elf'],['💆','massage'],['💇','haircut'],['🚶','walk'],['🏃','run'],['💃','dance'],['🕺','dance'],['👯','people dancing'],['🧗','climb'],['🏇','horse race'],['🏂','snowboard'],['🏄','surf'],['🚣','row'],['🏊','swim'],['🚴','bike'],['🚵','mountain bike'],['🤸','cartwheel'],['🤼','wrestle'],['🤽','water polo'],['🤾','handball'],['🤹','juggle'],['🧘','meditate yoga'],['🛌','sleeping bed'],['💋','kiss mark'],['💌','love letter'],['❤️','heart love red'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart exclamation'],['💕','two hearts'],['💞','revolving hearts'],['💓','beating heart'],['💗','growing heart'],['💖','sparkling heart'],['💘','cupid heart'],['💝','heart gift'],['✨','sparkles'],['⭐','star'],['🌟','glowing star'],['💫','dizzy star'],['💥','boom'],['💯','hundred perfect'] + ]}, + { id: 'nature', label: 'Nature', items: [ + ['🐶','dog'],['🐱','cat'],['🐭','mouse'],['🐹','hamster'],['🐰','rabbit'],['🦊','fox'],['🐻','bear'],['🐼','panda'],['🐨','koala'],['🐯','tiger'],['🦁','lion'],['🐮','cow'],['🐷','pig'],['🐸','frog'],['🐵','monkey'],['🐔','chicken'],['🐧','penguin'],['🐦','bird'],['🐤','chick'],['🦆','duck'],['🦅','eagle'],['🦉','owl'],['🦇','bat'],['🐺','wolf'],['🐗','boar'],['🐴','horse'],['🦄','unicorn'],['🐝','bee'],['🐛','bug'],['🦋','butterfly'],['🐌','snail'],['🐞','ladybug'],['🐜','ant'],['🦗','cricket'],['🕷️','spider'],['🦂','scorpion'],['🐢','turtle'],['🐍','snake'],['🦎','lizard'],['🦖','dinosaur'],['🐙','octopus'],['🦑','squid'],['🦐','shrimp'],['🦀','crab'],['🐡','fish'],['🐠','fish'],['🐟','fish'],['🐬','dolphin'],['🐳','whale'],['🦈','shark'],['🐊','crocodile'],['🌵','cactus'],['🎄','tree christmas'],['🌲','tree evergreen'],['🌳','tree'],['🌴','palm tree'],['🌱','seedling plant'],['🌿','herb leaf'],['☘️','shamrock'],['🍀','clover luck'],['🎍','bamboo'],['🌾','wheat'],['🌷','tulip flower'],['🌹','rose flower'],['🌺','hibiscus flower'],['🌸','cherry blossom'],['🌼','flower'],['🌻','sunflower'],['🌞','sun'],['🌝','moon'],['🌚','moon'],['🌙','moon crescent'],['⭐','star'],['🌟','star'],['☀️','sun sunny'],['⛅','cloud sun'],['☁️','cloud'],['🌧️','rain'],['⛈️','storm'],['🌩️','lightning'],['❄️','snowflake'],['☃️','snowman'],['⛄','snowman'],['🔥','fire'],['💧','droplet water'],['🌊','wave water'],['🌈','rainbow'],['🌍','earth globe'],['🌎','earth globe'],['🌏','earth globe'] + ]}, + { id: 'food', label: 'Food', items: [ + ['🍏','apple green'],['🍎','apple red'],['🍐','pear'],['🍊','orange tangerine'],['🍋','lemon'],['🍌','banana'],['🍉','watermelon'],['🍇','grapes'],['🍓','strawberry'],['🫐','blueberry'],['🍈','melon'],['🍒','cherry'],['🍑','peach'],['🥭','mango'],['🍍','pineapple'],['🥥','coconut'],['🥝','kiwi'],['🍅','tomato'],['🍆','eggplant'],['🥑','avocado'],['🥦','broccoli'],['🥬','lettuce'],['🥒','cucumber'],['🌶️','pepper hot'],['🌽','corn'],['🥕','carrot'],['🧄','garlic'],['🧅','onion'],['🥔','potato'],['🍠','sweet potato'],['🥐','croissant'],['🥯','bagel'],['🍞','bread'],['🥖','baguette'],['🧀','cheese'],['🥚','egg'],['🍳','cooking egg'],['🥓','bacon'],['🥩','meat steak'],['🍗','chicken leg'],['🍖','meat'],['🌭','hot dog'],['🍔','burger'],['🍟','fries'],['🍕','pizza'],['🥪','sandwich'],['🥙','pita'],['🌮','taco'],['🌯','burrito'],['🥗','salad'],['🍝','pasta spaghetti'],['🍜','ramen noodles'],['🍲','stew'],['🍛','curry rice'],['🍣','sushi'],['🍱','bento'],['🥟','dumpling'],['🍤','shrimp fried'],['🍙','rice ball'],['🍚','rice'],['🍘','rice cracker'],['🍥','fish cake'],['🥠','fortune cookie'],['🍢','oden'],['🍡','dango'],['🍧','shaved ice'],['🍨','ice cream'],['🍦','ice cream'],['🥧','pie'],['🧁','cupcake'],['🍰','cake'],['🎂','birthday cake'],['🍮','custard'],['🍭','lollipop'],['🍬','candy'],['🍫','chocolate'],['🍿','popcorn'],['🍩','donut'],['🍪','cookie'],['☕','coffee'],['🍵','tea'],['🧃','juice'],['🥤','cup drink'],['🍺','beer'],['🍻','beers cheers'],['🥂','champagne'],['🍷','wine'],['🥃','whiskey'],['🍸','cocktail'],['🍹','tropical drink'],['🧉','mate'],['🍾','champagne bottle'] + ]}, + { id: 'activity', label: 'Activity', items: [ + ['⚽','soccer football'],['🏀','basketball'],['🏈','football'],['⚾','baseball'],['🥎','softball'],['🎾','tennis'],['🏐','volleyball'],['🏉','rugby'],['🥏','frisbee'],['🎱','pool billiards'],['🪀','yo-yo'],['🏓','ping pong'],['🏸','badminton'],['🏒','hockey'],['🏑','field hockey'],['🥍','lacrosse'],['🏏','cricket'],['🥅','goal'],['⛳','golf'],['🏹','archery'],['🎣','fishing'],['🥊','boxing'],['🥋','martial arts'],['🎽','running shirt'],['🛹','skateboard'],['🛼','roller skate'],['🛷','sled'],['⛸️','ice skate'],['🥌','curling'],['🎿','ski'],['⛷️','ski'],['🏂','snowboard'],['🏋️','weight lift'],['🤼','wrestle'],['🤸','cartwheel'],['⛹️','basketball'],['🤺','fencing'],['🤾','handball'],['🏌️','golf'],['🏇','horse race'],['🧘','yoga meditate'],['🏄','surf'],['🏊','swim'],['🤽','water polo'],['🚣','row'],['🧗','climb'],['🚴','bike'],['🚵','mountain bike'],['🎪','circus'],['🎭','theater masks'],['🎨','art palette'],['🎬','clapper film'],['🎤','microphone'],['🎧','headphones'],['🎼','music score'],['🎹','piano'],['🥁','drum'],['🎷','saxophone'],['🎺','trumpet'],['🎸','guitar'],['🪕','banjo'],['🎻','violin'],['🎲','dice random game'],['♟️','chess'],['🎯','target dart'],['🎳','bowling'],['🎮','game controller'],['🎰','slot machine'],['🧩','puzzle'],['🏆','trophy win'],['🥇','gold medal first'],['🥈','silver medal'],['🥉','bronze medal'],['🏅','medal'],['🎖️','military medal'],['🎗️','reminder ribbon'],['🎫','ticket'],['🎟️','tickets'],['🎁','gift present'],['🎉','party popper celebrate'],['🎊','confetti'],['🎈','balloon'],['🎂','cake birthday'],['🎃','pumpkin halloween'],['🎄','christmas tree'],['🎆','fireworks'],['🎇','fireworks'],['🧨','firecracker'],['✨','sparkles'],['🎓','graduation cap'] + ]}, + { id: 'travel', label: 'Travel', items: [ + ['🚗','car'],['🚕','taxi'],['🚙','car suv'],['🚌','bus'],['🚎','trolley bus'],['🏎️','race car'],['🚓','police car'],['🚑','ambulance'],['🚒','fire truck'],['🚐','van'],['🛻','pickup truck'],['🚚','truck'],['🚛','truck'],['🚜','tractor'],['🏍️','motorcycle'],['🛵','scooter'],['🚲','bicycle'],['🛴','kick scooter'],['🚨','police light'],['🚔','police car'],['🚍','bus'],['🚝','monorail'],['🚄','train'],['🚅','train bullet'],['🚈','train'],['🚂','locomotive train'],['🚆','train'],['🚇','metro subway'],['🚊','tram'],['🚉','station'],['✈️','airplane flight'],['🛫','airplane takeoff'],['🛬','airplane landing'],['🛩️','plane'],['💺','seat'],['🚁','helicopter'],['🛸','ufo'],['🚀','rocket launch'],['🛰️','satellite'],['🚢','ship'],['⛵','sailboat'],['🛥️','motor boat'],['🚤','speedboat'],['⛴️','ferry'],['🛳️','cruise ship'],['⚓','anchor'],['🚧','construction'],['⛽','fuel gas'],['🚏','bus stop'],['🗺️','map world'],['🗿','moai'],['🗽','statue liberty'],['🗼','tokyo tower'],['🏰','castle'],['🏯','castle japanese'],['🏟️','stadium'],['🎡','ferris wheel'],['🎢','roller coaster'],['🎠','carousel'],['⛲','fountain'],['⛱️','beach umbrella'],['🏖️','beach'],['🏝️','island'],['🏜️','desert'],['🌋','volcano'],['⛰️','mountain'],['🏔️','snow mountain'],['🗻','mount fuji'],['🏕️','camping'],['🏠','house home'],['🏡','house garden'],['🏢','office building'],['🏥','hospital'],['🏦','bank'],['🏨','hotel'],['🏫','school'],['🏭','factory'],['🏛️','classical building'],['⛪','church'],['🕌','mosque'],['🕍','synagogue'],['🛕','temple'],['🗼','tower'],['🌆','city sunset'],['🌃','night city'],['🌉','bridge night'],['🌌','milky way'],['🌠','shooting star'],['🌅','sunrise'],['🌄','sunrise mountain'],['🌇','sunset city'] + ]}, + { id: 'objects', label: 'Objects', items: [ + ['⌚','watch'],['📱','phone mobile'],['💻','laptop computer'],['⌨️','keyboard'],['🖥️','desktop computer'],['🖨️','printer'],['🖱️','mouse computer'],['💽','minidisc'],['💾','floppy disk save'],['💿','cd disk'],['📀','dvd'],['🧮','abacus'],['🎥','movie camera'],['🎞️','film frames'],['📽️','projector'],['📺','tv television'],['📷','camera'],['📸','camera flash'],['📹','video camera'],['📼','videocassette'],['🔍','magnifying search'],['🔎','magnifying search'],['🕯️','candle'],['💡','bulb idea'],['🔦','flashlight'],['🏮','lantern'],['🪔','lamp diya'],['📔','notebook'],['📕','book closed'],['📖','book open'],['📗','book green'],['📘','book blue'],['📙','book orange'],['📚','books library'],['📓','notebook'],['📒','ledger'],['📃','page'],['📜','scroll'],['📄','page document'],['📰','newspaper'],['🗞️','newspaper'],['📑','bookmark tabs'],['🔖','bookmark'],['🏷️','label tag'],['💰','money bag'],['🪙','coin'],['💴','yen'],['💵','dollar'],['💶','euro'],['💷','pound'],['💸','money wings'],['💳','credit card'],['🧾','receipt'],['✉️','envelope mail'],['📧','email'],['📨','envelope'],['📩','envelope'],['📤','outbox'],['📥','inbox'],['📦','package box'],['📫','mailbox'],['📪','mailbox'],['📬','mailbox'],['📭','mailbox'],['📮','postbox'],['🗳️','ballot box'],['✏️','pencil'],['✒️','pen nib'],['🖋️','pen'],['🖊️','pen'],['🖌️','paintbrush'],['🖍️','crayon'],['📝','memo note write'],['💼','briefcase work'],['📁','folder'],['📂','folder open'],['🗂️','card index'],['📅','calendar date'],['📆','calendar'],['🗒️','notepad'],['🗓️','calendar'],['📇','card index'],['📈','chart up trending'],['📉','chart down'],['📊','bar chart stats'],['📋','clipboard'],['📌','pushpin'],['📍','pin location'],['📎','paperclip attach'],['🖇️','paperclips'],['📏','ruler'],['📐','triangle ruler'],['✂️','scissors cut'],['🗃️','file box'],['🗄️','file cabinet'],['🗑️','trash waste'],['🔒','lock locked'],['🔓','lock open'],['🔑','key'],['🗝️','old key'],['🔨','hammer'],['🪓','axe'],['⛏️','pick'],['⚒️','tools'],['🛠️','tools'],['🔧','wrench'],['🔩','bolt nut'],['⚙️','gear settings'],['🧰','toolbox'],['🧲','magnet'],['🔫','water gun'],['💣','bomb'],['🧪','test tube science'],['🧫','petri dish'],['🧬','dna'],['🔬','microscope'],['🔭','telescope'],['📡','satellite antenna'],['💉','syringe'],['💊','pill medicine'],['🩹','bandage'],['🩺','stethoscope'],['🚪','door'],['🛏️','bed'],['🛋️','couch'],['🪑','chair'],['🚽','toilet'],['🚿','shower'],['🛁','bathtub'],['🧴','lotion'],['🧷','safety pin'],['🧹','broom'],['🧺','basket'],['🧻','toilet paper'],['🧼','soap'],['🪒','razor'],['🧽','sponge'],['🧯','extinguisher'],['🛒','cart shopping'],['🚬','cigarette'],['⚰️','coffin'],['🪦','headstone'],['⚱️','urn'] + ]}, + { id: 'symbols', label: 'Symbols', items: [ + ['❤️','heart love'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart'],['💕','hearts'],['💞','hearts'],['💓','heartbeat'],['💗','heart'],['💖','heart'],['💘','heart arrow'],['💝','heart gift'],['💟','heart decoration'],['☮️','peace'],['✝️','cross'],['☪️','star crescent'],['🕉️','om'],['☸️','dharma'],['✡️','star david'],['🔯','star'],['🕎','menorah'],['☯️','yin yang'],['☦️','orthodox cross'],['🛐','worship'],['⛎','ophiuchus'],['♈','aries'],['♉','taurus'],['♊','gemini'],['♋','cancer'],['♌','leo'],['♍','virgo'],['♎','libra'],['♏','scorpio'],['♐','sagittarius'],['♑','capricorn'],['♒','aquarius'],['♓','pisces'],['🆔','id'],['⚛️','atom'],['🉑','accept'],['☢️','radioactive'],['☣️','biohazard'],['📴','phone off'],['📳','vibrate'],['📵','no phone'],['🚭','no smoking'],['❗','exclamation'],['❕','exclamation'],['❓','question'],['❔','question'],['‼️','double exclamation'],['⁉️','exclamation question'],['🔅','dim'],['🔆','bright'],['〽️','part alternation'],['⚠️','warning'],['🚸','children crossing'],['🔱','trident'],['⚜️','fleur de lis'],['🔰','beginner'],['♻️','recycle'],['✅','check done'],['🈯','reserved'],['💹','chart yen'],['❇️','sparkle'],['✳️','asterisk'],['❎','cross mark'],['🌐','globe'],['💠','diamond'],['Ⓜ️','m'],['🌀','cyclone'],['💤','zzz sleep'],['🏧','atm'],['🚾','wc'],['♿','wheelchair'],['🅿️','parking'],['🈳','vacancy'],['🈂️','sa'],['🛂','passport control'],['🛃','customs'],['🛄','baggage'],['🛅','left luggage'],['🚹','men'],['🚺','women'],['🚼','baby'],['🚻','restroom'],['🚮','litter'],['🎦','cinema'],['📶','signal'],['🈁','here'],['🔣','symbols'],['ℹ️','info'],['🔤','abc'],['🔡','abcd'],['🔠','abcd'],['🆖','ng'],['🆗','ok'],['🆙','up'],['🆒','cool'],['🆕','new'],['🆓','free'],['0️⃣','zero'],['1️⃣','one'],['2️⃣','two'],['3️⃣','three'],['4️⃣','four'],['5️⃣','five'],['6️⃣','six'],['7️⃣','seven'],['8️⃣','eight'],['9️⃣','nine'],['🔟','ten'],['🔢','numbers'],['#️⃣','hash'],['*️⃣','asterisk'],['⏏️','eject'],['▶️','play'],['⏸️','pause'],['⏹️','stop'],['⏺️','record'],['⏭️','next'],['⏮️','previous'],['⏩','fast forward'],['⏪','rewind'],['⏫','up'],['⏬','down'],['◀️','left'],['🔼','up'],['🔽','down'],['➡️','right'],['⬅️','left'],['⬆️','up'],['⬇️','down'],['↗️','up right'],['↘️','down right'],['↙️','down left'],['↖️','up left'],['↕️','up down'],['↔️','left right'],['↩️','return'],['↪️','redo'],['⤴️','up'],['⤵️','down'],['🔀','shuffle'],['🔁','repeat'],['🔂','repeat one'],['🔄','refresh'],['🔃','refresh'],['🎵','music note'],['🎶','music notes'],['➕','plus'],['➖','minus'],['➗','divide'],['✖️','multiply'],['♾️','infinity'],['💲','dollar'],['💱','currency'],['™️','tm'],['©️','copyright'],['®️','registered'],['〰️','wavy'],['➰','curly loop'],['➿','double loop'],['🔚','end'],['🔙','back'],['🔛','on'],['🔝','top'],['🔜','soon'],['✔️','check'],['☑️','checkbox'],['🔘','radio'],['🔴','red circle'],['🟠','orange circle'],['🟡','yellow circle'],['🟢','green circle'],['🔵','blue circle'],['🟣','purple circle'],['⚫','black circle'],['⚪','white circle'],['🟤','brown circle'],['🔺','red triangle'],['🔻','red triangle'],['🔸','orange diamond'],['🔹','blue diamond'],['🔶','orange diamond'],['🔷','blue diamond'],['🔳','white square'],['🔲','black square'],['▪️','black square'],['▫️','white square'],['◾','black square'],['◽','white square'],['◼️','black square'],['◻️','white square'],['🟥','red square'],['🟧','orange square'],['🟨','yellow square'],['🟩','green square'],['🟦','blue square'],['🟪','purple square'],['⬛','black square'],['⬜','white square'],['🟫','brown square'],['🔈','speaker'],['🔇','mute'],['🔉','speaker'],['🔊','speaker loud'],['🔔','bell'],['🔕','bell off'],['📣','megaphone'],['📢','loudspeaker'],['💬','speech bubble'],['💭','thought bubble'],['🗯️','anger bubble'],['♠️','spade'],['♣️','club'],['♥️','heart suit'],['♦️','diamond suit'],['🃏','joker'],['🎴','flower cards'],['🀄','mahjong'] + ]}, + { id: 'flags', label: 'Flags', items: [ + ['🏁','chequered flag finish'],['🚩','triangular flag'],['🎌','crossed flags'],['🏴','black flag'],['🏳️','white flag'],['🏳️‍🌈','rainbow flag pride'],['🏴‍☠️','pirate flag'],['🇺🇸','usa united states'],['🇬🇧','uk united kingdom'],['🇫🇷','france french'],['🇩🇪','germany german'],['🇪🇸','spain spanish'],['🇮🇹','italy italian'],['🇵🇹','portugal'],['🇳🇱','netherlands'],['🇧🇪','belgium'],['🇨🇭','switzerland'],['🇦🇹','austria'],['🇸🇪','sweden'],['🇳🇴','norway'],['🇩🇰','denmark'],['🇫🇮','finland'],['🇮🇪','ireland'],['🇵🇱','poland'],['🇬🇷','greece'],['🇷🇺','russia'],['🇺🇦','ukraine'],['🇹🇷','turkey'],['🇨🇦','canada'],['🇲🇽','mexico'],['🇧🇷','brazil'],['🇦🇷','argentina'],['🇨🇱','chile'],['🇨🇴','colombia'],['🇨🇳','china chinese'],['🇯🇵','japan japanese'],['🇰🇷','korea south'],['🇮🇳','india'],['🇦🇺','australia'],['🇳🇿','new zealand'],['🇿🇦','south africa'],['🇪🇬','egypt'],['🇲🇦','morocco'],['🇳🇬','nigeria'],['🇰🇪','kenya'],['🇸🇦','saudi arabia'],['🇦🇪','uae emirates'],['🇮🇱','israel'],['🇸🇬','singapore'],['🇹🇭','thailand'],['🇻🇳','vietnam'],['🇮🇩','indonesia'],['🇵🇭','philippines'],['🇲🇾','malaysia'],['🇵🇰','pakistan'],['🇧🇩','bangladesh'],['🇺🇳','united nations'] + ]} + ]; + + document.addEventListener('alpine:init', function () { + if (window.Alpine && window.Alpine.__fdIconPicker) return; + if (window.Alpine) window.Alpine.__fdIconPicker = true; + + Alpine.store('fdIconPicker', { + open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people', + skinTone: 0, toneOpen: false, recent: [], custom: [], customLoaded: false, + customModal: false, customName: '', customPreview: '', customFile: null, customBusy: false, + onPick: null, onRemove: null, _cx: 0, _cy: 0, + cats: CATS, + + openFor(opts) { + opts = opts || {}; + this.onPick = opts.onPick || null; + this.onRemove = opts.onRemove || null; + this.query = ''; + this.toneOpen = false; + this.customModal = false; + this._cx = (opts.x != null) ? opts.x : 240; + this._cy = (opts.y != null) ? opts.y : 200; + this.x = this._cx; + this.y = this._cy; + this.open = true; + var self = this; + var place = function () { + var el = document.querySelector('.fd-icon-picker'); + if (!el) return; + var w = el.offsetWidth || 344, h = el.offsetHeight || 440; + var vw = window.innerWidth, vh = window.innerHeight; + var nx = self._cx, ny = self._cy; + if (nx + w > vw - 8) nx = vw - w - 8; + if (ny + h > vh - 8) ny = vh - h - 8; + self.x = Math.max(8, nx); + self.y = Math.max(8, ny); + }; + if (window.Alpine && Alpine.nextTick) Alpine.nextTick(place); + else setTimeout(place, 0); + this.loadRecent(); + this.loadCustom(); + }, + + close() { + this.open = false; + this.customModal = false; + this.toneOpen = false; + this.onPick = null; + this.onRemove = null; + }, + + matches(name) { + var q = (this.query || '').trim().toLowerCase(); + if (!q) return true; + return name.toLowerCase().indexOf(q) >= 0; + }, + + items() { + var q = (this.query || '').trim().toLowerCase(); + if (q) { + var out = []; + this.cats.forEach(function (c) { + c.items.forEach(function (it) { + if ((it[1] || '').toLowerCase().indexOf(q) >= 0 || it[0].indexOf(q) >= 0) out.push(it[0]); + }); + }); + return out; + } + if (this.category === 'recent') return this.recent; + var found = []; + for (var i = 0; i < this.cats.length; i++) { + if (this.cats[i].id === this.category) { found = this.cats[i].items.map(function (it) { return it[0]; }); break; } + } + return found; + }, + + withTone(e) { + if (!this.skinTone) return e; + if (TONEABLE[e]) return e + TONES[this.skinTone]; + return e; + }, + + pick(v) { + v = (v || '').trim(); + if (!v) return; + this.pushRecent(v); + var fn = this.onPick; + if (fn) { try { fn(v); } catch (e) { console.error('fdIconPicker.pick', e); } } + this.close(); + }, + + remove() { + var fn = this.onRemove || this.onPick; + if (fn) { try { fn(''); } catch (e) {} } + this.close(); + }, + + random() { + var pool = []; + this.cats.forEach(function (c) { c.items.forEach(function (it) { pool.push(it[0]); }); }); + if (!pool.length) return; + this.pick(pool[Math.floor(Math.random() * pool.length)]); + }, + + setTone(i) { this.skinTone = i; this.toneOpen = false; }, + setCategory(id) { this.category = id; this.tab = 'emoji'; this.query = ''; }, + setTab(t) { this.tab = t; this.query = ''; if (t === 'upload') this.loadCustom(); }, + + loadRecent() { + try { this.recent = JSON.parse(localStorage.getItem('fd_icon_recent') || '[]'); } + catch (e) { this.recent = []; } + }, + pushRecent(e) { + try { + var r = this.recent.filter(function (x) { return x !== e; }); + r.unshift(e); + this.recent = r.slice(0, 32); + localStorage.setItem('fd_icon_recent', JSON.stringify(this.recent)); + } catch (err) {} + }, + + async loadCustom() { + try { + var r = await fetch('/api/custom-emojis', { credentials: 'same-origin' }); + var d = await r.json(); + this.custom = (d && d.emojis) || []; + this.customLoaded = true; + } catch (e) { this.custom = []; } + }, + + openCustomModal() { + this.customModal = true; + this.customName = ''; + this.customPreview = ''; + this.customFile = null; + this.tab = 'upload'; + }, + closeCustomModal() { this.customModal = false; }, + onCustomFile(ev) { + var f = ev.target.files && ev.target.files[0]; + if (!f) return; + this.customFile = f; + var self = this; + var fr = new FileReader(); + fr.onload = function () { self.customPreview = fr.result; }; + fr.readAsDataURL(f); + if (!this.customName) this.customName = (f.name || '').replace(/\.[^.]+$/, '').slice(0, 40); + }, + async saveCustom() { + if (!this.customFile || this.customBusy) return; + this.customBusy = true; + try { + var fd = new FormData(); + fd.append('name', this.customName || 'emoji'); + fd.append('file', this.customFile); + var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''; + var r = await fetch('/api/custom-emojis', { method: 'POST', headers: { 'X-CSRF-Token': csrf }, body: fd, credentials: 'same-origin' }); + var d = await r.json(); + if (d && d.emoji) { + this.custom.unshift(d.emoji); + this.customModal = false; + this.pick(d.emoji.url); + } + } catch (e) { + if (window.showToast) window.showToast('Emoji upload failed', 'error'); + } + this.customBusy = false; + }, + async deleteCustom(id) { + try { + var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''; + await fetch('/api/custom-emojis/' + id, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf }, credentials: 'same-origin' }); + this.custom = this.custom.filter(function (e) { return e.id !== id; }); + } catch (e) {} + } + }); + }); + + window.FDIconPicker = { + openFor: function (opts) { + var s = window.Alpine && Alpine.store('fdIconPicker'); + if (s) s.openFor(opts); + } + }; +})(); diff --git a/static/js/agent_panel_1.js b/static/js/agent_panel_1.js new file mode 100644 index 0000000..d46b716 --- /dev/null +++ b/static/js/agent_panel_1.js @@ -0,0 +1,17 @@ + +(function(){ + var defaultAPI = { + open: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); }, + close: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle', { detail: { close: true } })); }, + toggle: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); }, + ask: function(){}, generate: function(){ return Promise.resolve(''); } + }; + window.fdAgent = window.fdAgent || defaultAPI; + // Ctrl/Cmd+J — open / close the Agent panel from anywhere. + document.addEventListener('keydown', function(e){ + if((e.ctrlKey || e.metaKey) && (e.key === 'j' || e.key === 'J')){ + e.preventDefault(); + if(window.fdAgent && window.fdAgent.toggle) window.fdAgent.toggle(); + } + }); +})(); diff --git a/static/js/agent_panel_2.js b/static/js/agent_panel_2.js new file mode 100644 index 0000000..524c17f --- /dev/null +++ b/static/js/agent_panel_2.js @@ -0,0 +1,1788 @@ + +(function(){ + // ── Skills intégrés (workflows « / ») et commandes admin du panneau ── + var FD_ADMIN_CMDS = [ + {cmd:'/help', desc:'liste des commandes'}, + {cmd:'/providers', desc:'liste les fournisseurs actifs'}, + {cmd:'/provider ', desc:'changer de fournisseur LLM'}, + {cmd:'/model ', desc:'changer de modèle LLM'}, + {cmd:'/keys', desc:'fournisseurs avec clé API enregistrée'} + ]; + var FD_SKILLS = [ + {cmd:'/research', icon:'🔎', desc:'recherche structurée + recommandation', + template:'Fais un état des lieux de [sujet] : contexte, avantages/inconvénients, options, puis une recommandation finale. Indique tes sources si tu en utilises.'}, + {cmd:'/create-new-skill', icon:'🛠️', desc:'crée un workflow réutilisable (skill)', + template:'Je veux créer un skill réutilisable qui [décris le workflow]. Produis : nom, description courte, étapes et format de sortie attendu.'}, + {cmd:'/resume', icon:'📄', desc:'résumé / synthèse structurée', + template:'Résume ce contenu de façon structurée et concise : points clés, décisions, TL;DR.'}, + {cmd:'/actions', icon:'✅', desc:'extraire les actions & to-dos', + template:'À partir de ce contenu, liste les actions à mener avec responsable et échéance quand c\u2019est indiqué.'}, + {cmd:'/reformuler', icon:'✍️', desc:'réécriture clarté / ton', + template:'Reformule ce contenu pour le rendre plus clair et plus concis en conservant le sens. Précise le ton souhaité (interne, client, formel…).'}, + {cmd:'/correction', icon:'🔤', desc:'correction grammaire / orthographe / style', + template:'Corrige la grammaire, l\u2019orthographe et le style de ce contenu sans en changer le sens.'}, + {cmd:'/brainstorm', icon:'💡', desc:'générer des idées, angles, variantes', + template:'Propose des idées variées sur [sujet] : angles, exemples concrets, variantes de messages (annonce, email, post).'}, + {cmd:'/plan', icon:'🧭', desc:'planifier / structurer un document', + template:'Construis un plan structuré pour [sujet] : sections logiques, sous-points et ordre de rédaction.'}, + {cmd:'/ask', icon:'💬', desc:'Q&A sur un contenu référencé', + template:'À partir du contenu référencé, réponds précisément à [ta question].'}, + {cmd:'/meeting-note', icon:'📝', desc:'compte-rendu / note de réunion', + template:'Rédige une AI Meeting Note : Résumé, Décisions, Action items (avec responsables et échéances), Prochaines étapes.'}, + {cmd:'/livrable', icon:'📨', desc:'email / compte-rendu / message Slack', + template:'Rédige un livrable professionnel (email, compte-rendu ou message Slack) sur [sujet / destinataire / contexte].'} + ]; + + function agentPanel(){ + return { + open: false, modelOpen: false, tab: 'chat', + agents: [], conversations: [], currentConv: null, + messages: [], input: '', streaming: false, liveReasoning: '', + provider: 'offline', providerAvailable: false, errorMsg: '', notice: '', + allProviders: [], providers: [], providerModels: [], llmProvider: 'offline', + llmModel: '', llmKeys: [], defaultProvider: 'offline', + activeContext: null, ctxPinnedKey: null, + contextChips: [], skills: [], gallery: [], + mentionOpen: false, mentionItems: [], mentionQuery: '', mentionLoad: false, + mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null, + cmdFocus: -1, slashDismiss: false, + toastMsg: '', toastErr: false, _toastTimer: null, + _livePh: null, + previewVisible: false, previewTitle: '', previewMeta: '', previewStart: '', previewEnd: '', + _previewCache: null, _previewTimer: null, _longPressTimer: null, _suppressNextClick: false, + _slashSeq: 0, + + init(){ + var self = this; + var el = document.getElementById('fd-agent-panel'); + + function applyState(){ + if(el){ if(self.open){ el.classList.add('open'); el.setAttribute('aria-hidden','false'); } else { el.classList.remove('open'); el.setAttribute('aria-hidden','true'); } } + } + + window.addEventListener('fd-agent-toggle', function(e){ + if(e && e.detail){ + if(e.detail.close){ self.close(); return; } + if(e.detail.agentId){ self.agentId = e.detail.agentId; } + self.openAgent(); return; + } + // legacy toggle semantics + if(self.open){ self.close(); } else { self.openAgent(); } + }); + + this._applyState = applyState; + this.fetchProviders(); + this.fetchAgents(); + this.fetchSkills(); + + // Exposed imperative API (used by the FAB, the sidebar, Ask AI…) + window.fdAgent.open = function(){ self.openAgent(); }; + window.fdAgent.close = function(){ self.close(); }; + window.fdAgent.toggle = function(){ self.open ? self.close() : self.openAgent(); }; + window.fdAgent.ask = function(text){ + var t = (text || '').trim(); + if(!t) return; + self.openAgent(); + self.setComposerText(t); + setTimeout(function(){ self.send(); }, 60); + }; + // Headless text generation (no tools) via /agent/generate — the clean + // path used by "Ask AI" / "AI meeting note" / summarization. Sending a + // plain write request through the tool-enabled conversation engine sends + // a tools/tool_choice schema that some providers (e.g. DeepSeek) reject + // with a 400. This endpoint returns {ok, text, offline, model}. + window.fdAgent.generate = function(message, context){ + message = (message || '').trim(); + if(!message) return Promise.resolve(''); + var payload = {prompt: message}; + if(context && context.trim()) payload.context = String(context).slice(0, 20000); + return fetch('/api/agent/generate', { + method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, + body: JSON.stringify(payload) + }).then(function(resp){ + return resp.json(); + }).then(function(d){ + if(d && d.ok){ return d.text || ''; } + throw new Error((d && d.error) ? d.error : 'Erreur IA'); + }); + }; + }, + + // ── Open / close (robust) ── + openAgent(){ + if(this.open) { this.refreshContext(); return; } + this.open = true; + this.refreshContext(true); + if(this._applyState) this._applyState(); + this.loadConversations(); + var self = this; + this.$nextTick(function(){ + var t = self.$refs && self.$refs.cmdInput; + if(t){ t.focus(); self.resizeInput(); } + }); + }, + close(){ + if(!this.open) return; + this.open = false; + this.modelOpen = false; + this.hidePreview(); + if(this._applyState) this._applyState(); + }, + toggle(){ this.open ? this.close() : this.openAgent(); }, + + // ── Context (reflected as an @-style mention chip in the composer) ── + refreshContext(force){ + var ctx = FlowDeckCtx.current(); + if(!ctx){ this.activeContext = null; return; } + if(!this.activeContext) { this.activeContext = ctx; this._syncContextChip(); return; } + // Always follow a currently-open document so follow-up messages use it. + if(ctx.kind === 'page' || force || ctx.key !== this.activeContext.key){ + this.activeContext = ctx; + this._syncContextChip(); + } + }, + _syncContextChip(){ + var c = this.activeContext; + if(!c || !c.mentionToken) return; + var exists = this.contextChips.some(function(ch){ return ch.token === c.mentionToken; }); + if(exists) return; + this.contextChips.push({ + key: c.mentionToken, kind: 'mention', type: c.refType || 'document', + icon: c.icon || '📎', label: c.title || c.label, token: c.mentionToken, refId: c.refId + }); + }, + get chatContextActive(){ return !!(this.activeContext && this.activeContext.kind === 'page'); }, + contextPayload(){ + this.refreshContext(); + var c = this.activeContext; + if(!c) return ''; + // Si le contexte est déjà épinglé comme mention (@), le backend résout son + // contenu via context_builder — on évite de dupliquer le texte du document. + if(c.mentionToken && this.contextChips.some(function(ch){ return ch.token === c.mentionToken; })) return ''; + if(c.text && c.text.trim()) return c.text.trim().slice(0, 9000); + return c.title ? ('Contexte : ' + c.title + '.').slice(0, 2000) : ''; + }, + get composerHint(){ + var chips = this.contextChips.length; + var base = 'Demandez, tapez / pour un skill, @ pour référencer un élément'; + var extra = chips ? ' · ' + chips + ' réf./skill épinglé(s)' : ''; + return base + extra + ' · Entrée ↵ = envoyer · Maj+Entrée = ligne'; + }, + get canInsert(){ return !!(this.activeContext && this.activeContext.kind === 'page'); }, + + // ── Quick actions (contextual) ── + get quickIntro(){ + var c = this.activeContext; + if(c && c.kind === 'page') return 'Comment puis-je vous aider sur ce document ?'; + return 'Que voulez-vous faire ?'; + }, + get quickActions(){ + var c = this.activeContext || {}; + var out = []; + if(c.kind === 'page'){ + out.push({icon:'📄', label:'Résumer ce document', prompt:'Résume ce document de façon structurée et concise.', docRun:true, mode:'insert'}); + out.push({icon:'🌐', label:'Traduire cette page', prompt:'Traduis le contenu de ce document en anglais, en conservant sa structure (titres, listes, paragraphes).', docRun:true, mode:'replace'}); + out.push({icon:'✍️', label:'Proposer des améliorations', prompt:'Analyse ce document et propose une version améliorée (structure, clarté, complétude) sans en perdre le sens.', docRun:true, mode:'insert'}); + out.push({icon:'📝', label:'Créer une note de réunion', prompt:'Rédige une « AI Meeting Note » structurée à partir de ce document : en-tête avec date, sections Résumé, Décisions, Action items et Prochaines étapes.', docRun:true, mode:'insert'}); + } else if(c.kind === 'db'){ + out.push({icon:'🗄️', label:'Analyser cette base', prompt:'Analyse cette base de données et résume sa structure, ses propriétés et ses données.'}); + out.push({icon:'📊', label:'Créer une vue', prompt:'Propose une vue adaptée (board, tableau, calendrier…) pour cette base et explique pourquoi.'}); + out.push({icon:'📈', label:'Synthèse des données', prompt:'Produis une synthèse des données présentes dans cette base.'}); + out.push({icon:'📝', label:'Créer une note de réunion', prompt:'Crée un squelette d\u2019AI meeting note structuré.'}); + } else if(c.kind === 'tasks'){ + out.push({icon:'✅', label:'Mes tâches', prompt:'Liste mes tâches et suggère les priorités de la journée.'}); + out.push({icon:'🗓️', label:'Planifier ma journée', prompt:'Aide-moi à organiser ma journée à partir de mes tâches.'}); + out.push({icon:'🤖', label:'Question générale', prompt:'Explique-moi comment fonctionne My Tasks dans FlowDeck.'}); + } else { + out.push({icon:'🗄️', label:'Créer une collection', prompt:'Crée une collection « Projets » avec les propriétés Status et Priorité, puis une vue board.', new:true}); + out.push({icon:'📋', label:'Créer un board', prompt:'Crée un board pour suivre le travail de l\u2019équipe.'}); + out.push({icon:'❓', label:'Comment utiliser FlowDeck ?', prompt:'Guide-moi sur les fonctionnalités principales de FlowDeck et comment les utiliser.'}); + out.push({icon:'📝', label:'Créer une note de réunion', prompt:'Crée un squelette d\u2019AI meeting note structuré.'}); + } + return out; + }, + runQuick(q){ + if(!q || !q.prompt) return; + if(q.docRun){ this.runDocAction(q); return; } + var self = this; + this.ensureConversation().then(function(){ + self.setComposerText(q.prompt); + self.send(); + }).catch(function(){}); + }, + + // ── Document actions (no tools) → proposition à approuver / rejeter ── + _isContentRequest(t){ + var low = (t || '').toLowerCase(); + var kws = ['écris','écrire','écrit un','rédige','rédiger','génère','générer','produis', + 'développe','texte sur','paragraphe','article sur','rédaction','résume','résumer', + 'résumé de','traduis','traduire','améliore','améliorer','note de réunion', + 'série tv','série','contenu de la page','poème','histoire sur','sujet de','courriel','email sur']; + for(var i=0;i=0;i--){ + var m = self.messages[i]; + if(m.role==='assistant' && !m.proposal){ lastAsst = m; break; } + } + if(!lastAsst) return; + var steps = acts.map(function(a){ + var res = null; + try{ res = a.result_json ? JSON.parse(a.result_json) : {}; }catch(e){ res = {}; } + var msg = (res && res.message) ? res.message : ''; + return agentPanel._actionStep({ + tool: a.tool_name, status: a.status, target_id: a.target_id, + message: msg, detail: msg || '' + }); + }); + lastAsst.steps = steps; + lastAsst.stepsOpen = false; + }).catch(function(){}); + }, + get previewEmptyHint(){ return 'Aucun échange enregistré pour cette conversation.'; }, + historyClick(id){ + if(this._suppressNextClick){ this._suppressNextClick = false; return; } + this.loadConversation(id); + }, + _previewSummary(msgs){ + var first = '', last = ''; + for(var i=0;i 220) ? 'auto' : 'hidden'; + }, + _emptyCheck(){ + var el = this._composerEl(); + if(!el) return; + var empty = !el.textContent.trim() && !el.querySelector('[data-ctoken]'); + el.classList.toggle('empty', !!empty); + }, + setComposerText(str){ + var el = this._composerEl(); + if(!el){ this.input = str || ''; return; } + el.textContent = str || ''; + this.input = str || ''; + this.resizeInput(); + this._emptyCheck(); + }, + clearComposer(){ + var el = this._composerEl(); + if(el){ el.textContent = ''; } + this.input = ''; + this.resizeInput(); + this._emptyCheck(); + }, + _focusComposer(){ + var t = this._composerEl(); + if(t) t.focus(); + }, + insertNewline(){ + var el = this._composerEl(); + if(!el) return; + try{ + var sel = window.getSelection(); + if(!sel.rangeCount) return; + var r = sel.getRangeAt(0); + r.deleteContents(); + var br = document.createElement('br'); + r.insertNode(br); + r.setStartAfter(br); r.collapse(true); + this.input = this.composerText(); + }catch(e){} + this.resizeInput(); + }, + _caretQuery(){ + try{ + var sel = window.getSelection(); + if(!sel || !sel.rangeCount || !sel.isCollapsed) return null; + var r = sel.getRangeAt(0); + var node = r.startContainer; + if(node.nodeType !== 3) return null; + var s = node.nodeValue, off = r.startOffset, i = off; + while(i > 0 && s[i-1] !== '@' && !/\s/.test(s[i-1])) i--; + if(i > 0 && s[i-1] === '@' && (i === 1 || /\s/.test(s[i-2]))){ + return {node: node, at: i-1, raw: s.slice(i, off)}; + } + }catch(e){} + return null; + }, + _expandToAt(range){ + var node = range.startContainer; + if(node.nodeType !== 3) return; + var s = node.nodeValue, off = range.startOffset, i = off; + while(i > 0 && s[i-1] !== '@' && !/\s/.test(s[i-1])) i--; + if(i > 0 && s[i-1] === '@') range.setStart(node, i-1); + }, + _insertToken(item, range){ + var el = this._composerEl(); + if(!el) return; + try{ + var span = document.createElement('span'); + span.setAttribute('contenteditable','false'); + span.setAttribute('data-ctoken','1'); + span.setAttribute('data-token', item.token); + span.setAttribute('data-label', item.label); + span.setAttribute('data-kind', item.kind || 'mention'); + span.className = 'ment-token' + (item.kind === 'skill' ? ' skill' : ''); + var ico = document.createElement('span'); ico.className='tk-ico'; ico.textContent = ' ' + (item.icon || ''); + var lbl = document.createElement('span'); lbl.className='tk-lbl'; lbl.textContent = ' ' + item.label + ' '; + span.appendChild(ico); span.appendChild(lbl); + var useRange = (range && range.startContainer) ? range : null; + if(!useRange){ + var sel = window.getSelection(); + if(sel && sel.rangeCount){ + var cand = sel.getRangeAt(0); + if(cand && el.contains(cand.startContainer)) useRange = cand; + } + } + if(useRange){ + if(this._mentionFrom !== '+' && !range) this._expandToAt(useRange); + useRange.deleteContents(); + useRange.insertNode(span); + var sp = document.createTextNode(' '); + useRange.setStartAfter(span); useRange.collapse(true); + useRange.insertNode(sp); + useRange.setStart(sp, 1); useRange.setEnd(sp, 1); + try{ var s2 = window.getSelection(); if(s2){ s2.removeAllRanges(); s2.addRange(useRange); } }catch(e2){} + } else { + if(el.textContent) el.appendChild(document.createTextNode(' ')); + el.appendChild(span); + el.appendChild(document.createTextNode(' ')); + try{ var r2 = document.createRange(); r2.selectNodeContents(el); r2.collapse(false); var s3 = window.getSelection(); if(s3){ s3.removeAllRanges(); s3.addRange(r2); } }catch(e3){} + } + }catch(e){} + this.input = this.composerText(); + this.resizeInput(); + this._emptyCheck(); + }, + _insertMentionAt(item){ + var node = this._atNode; + if(node && node.nodeType === 3 && node.parentNode){ + try{ + var s = node.nodeValue; + var idx = -1; + for(var k=0;k= 0){ + var r = document.createRange(); + r.setStart(node, idx); + r.setEnd(node, s.length); + this._insertToken(item, r); + return; + } + }catch(e){} + } + this._insertToken(item); + }, + _removeTokenSpan(token){ + var el = this._composerEl(); + if(!el) return; + var spans = el.querySelectorAll('[data-ctoken][data-token="'+token+'"]'); + for(var i=0;i]*>\s*$/, ' ')); + this.cmdFocus = -1; + this._focusComposer(); + return; + } + if(c._type === 'gallery'){ + this.installGallerySkill(c.slug, c.icon, c.skillName); + this.cmdFocus = -1; this.slashDismiss = false; + this._focusComposer(); + return; + } + if(c._type === 'db'){ + this._pinDbSkill(c.skillId, c.cmd, c.icon); + } else { + var slug = String(c.cmd).replace(/^\/+/, '').toLowerCase().replace(/\s+/g, '-'); + var builtinExists = this.contextChips.some(function(ch){ return ch.kind === 'skill' && ch.token === 'builtin:' + slug; }); + if(builtinExists){ this.toast('Ce skill est déjà épinglé au contexte.'); } + else { + this.contextChips.push({key:'bi-'+slug, kind:'skill', icon:c.icon||'✨', + label:slug, token:'builtin:'+slug, skillId:null, builtin:true, template:c.template||''}); + // On n\u2019injecte PAS le texte du skill dans la bo\u00eete d\u2019\u00e9dition : + // la requ\u00eate est compos\u00e9e en arri\u00e8re-plan \u00e0 l\u2019envoi. + this._focusComposer(); + this.toast('Skill épinglé — votre demande sera composée avec lui à l\u2019envoi.'); + } + } + this.cmdFocus = -1; this.slashDismiss = false; + this._focusComposer(); + }, + echo(msg){ + this.messages.push({id:'cmd-'+Date.now()+Math.random(), role:'assistant', content: msg, model:''}); + this.scrollBottom(); + }, + handleCommand(text){ + var self = this; + var parts = text.replace(/^\/+/, '').trim().split(/\s+/); + var cmd = (parts.shift() || '').toLowerCase(); + var arg = parts.join(' ').trim(); + if(cmd === 'help' || cmd === 'aide' || cmd === '?'){ + this.echo('Tapez / pour choisir un skill (research, resume, brainstorm, plan, meeting-note…). Commandes : /providers · /keys · /provider · /model '); + return true; + } + if(cmd === 'providers'){ + var list = this.providers.filter(function(x){ return x.id !== 'offline'; }) + .map(function(x){ return x.id + (x.verified ? ' ✓' : ''); }).join(', '); + this.echo('Fournisseurs actifs : ' + (list || 'aucun — configurez-en un dans Paramètres → Agent & IA')); + return true; + } + if(cmd === 'keys'){ + var saved = this.llmKeys.map(function(k){ return k.provider + (k.verified ? ' ✓' : ' (à tester)'); }).join(', '); + this.echo('Clés enregistrées : ' + (saved || 'aucune — configurez-les dans Paramètres → Agent & IA')); + return true; + } + if(cmd === 'provider'){ + var avail = this.providers.map(function(x){ return x.id; }); + if(!arg){ + this.echo('Utilisez : /provider . Fournisseurs : ' + avail.join(', ')); + return true; + } + var p = this.providers.find(function(x){ return x.id === arg.toLowerCase(); }); + if(!p){ + this.echo('Fournisseur inconnu : ' + arg + ' (disponibles : ' + avail.join(', ') + ')'); + return true; + } + this.llmProvider = p.id; + this.onProviderChange(); + if(p.models && p.models.length && this.providerModels.indexOf(this.llmModel) === -1){ + this.llmModel = (p.default_model) || p.models[0]; + } + this.patchConversation(); + this.echo('Provider activé : ' + p.id + ' · modèle : ' + (this.llmModel || 'défaut')); + return true; + } + if(cmd === 'model'){ + if(!arg){ + this.echo('Utilisez : /model . Modèles pour ' + this.llmProvider + ' : ' + this.providerModels.join(', ')); + return true; + } + this.llmModel = arg; + this.patchConversation(); + this.echo('Modèle activé : ' + arg + ' (' + this.llmProvider + ')'); + return true; + } + return false; + }, + + // ── Toast ── + toast(msg, err){ + var self = this; + this.toastMsg = msg || ''; + this.toastErr = !!err; + if(this._toastTimer) clearTimeout(this._toastTimer); + if(!msg){ return; } + this._toastTimer = setTimeout(function(){ self.toastMsg = ''; self.toastErr = false; }, 3200); + }, + + // ── Mentions & contexte épinglé (@ / +) ── + toggleAddPicker(){ + if(this.mentionOpen && this._mentionFrom === '+'){ this.mentionOpen = false; this.mentionQuery = ''; return; } + this.openMentionPicker('+'); + }, + openMentionPicker(from){ + this.mentionOpen = true; + this._mentionFrom = from || '+'; + this.mentionQuery = ''; + this.mentionFocus = -1; + this.searchMentions(''); + }, + searchMentions(q){ + var self = this; + q = (q || '').trim(); + this.mentionQuery = q; + this.mentionLoad = true; + if(this._mentionTimer) clearTimeout(this._mentionTimer); + this._mentionTimer = setTimeout(function(){ + var ws = (window.appState && window.appState.localWsId) || 0; + fetch('/api/agent/mentions?q=' + encodeURIComponent(q) + '&workspace_id=' + ws) + .then(function(r){ return r.json(); }) + .then(function(d){ + var flat = [], gi = 0; + (d.sections || []).forEach(function(grp){ + var items = grp.items || []; + if(!items.length) return; + flat.push({_sep: grp.label || '', key: 'sep-' + (grp.key || gi), _kind: 'sep'}); + items.forEach(function(it){ it._i = gi++; flat.push(it); }); + }); + self.mentionItems = flat; + self.mentionLoad = false; + var sel = self._selectableMentionItems(); + if(sel.length) self.mentionFocus = sel[0]._i; + self.$nextTick(function(){ self._scrollMentionItem(); }); + }) + .catch(function(){ self.mentionItems = []; self.mentionLoad = false; }); + }, q ? 180 : 0); + }, + get hasMentionItems(){ return (this.mentionItems || []).some(function(it){ return !it._sep; }); }, + _selectableMentionItems(){ + return (this.mentionItems || []).filter(function(it){ return it && it._sep !== true && it._kind !== 'sep'; }); + }, + setMentionFocus(i){ + this.mentionFocus = i; + this._scrollMentionItem(); + }, + _scrollMentionItem(){ + var menu = this.$refs && this.$refs.mentionMenu; + if(!menu) return; + var el = menu.querySelector('.fd-mention-item.focus'); + if(el && el.scrollIntoView){ menu.scrollTop = el.offsetTop - menu.clientHeight / 2 + el.clientHeight / 2; } + }, + addChip(item){ + if(!item || !item.token) return; + var dup = this.contextChips.some(function(ch){ return ch.token === item.token; }); + if(dup){ this.toast('Déjà dans le contexte'); return; } + this.contextChips.push({key: item.token, kind: 'mention', icon: item.icon || '📎', + label: item.label, token: item.token, type: item.type, refId: item.id}); + }, + pickMention(item){ + if(!item) return; + this.addChip(item); + if(this._mentionFrom === '@'){ + this._insertMentionAt(item); + } + this.mentionOpen = false; + this.mentionQuery = ''; + this._mentionFrom = null; + this._atNode = null; + this._focusComposer(); + }, + removeChip(token){ + var idx = -1; + for(var i=0;i 0 ? -1 : 0; + idx = (idx + delta + n) % n; + this.mentionFocus = sel[idx]._i; + this._scrollMentionItem(); + }, + moveSlashFocus(delta){ + var items = this._slashItems().filter(function(o){ return !o._admin; }); + if(!items.length) return; + var idx = items.findIndex(function(o){ return o._i === this.cmdFocus; }.bind(this)); + if(idx < 0) idx = delta > 0 ? -1 : 0; + idx = (idx + delta + items.length) % items.length; + this.cmdFocus = items[idx]._i; + var menu = this.$refs && this.$refs.cmdMenu; + if(menu){ + var el = menu.querySelector('.fd-agent-cmd-item.focus'); + if(el && el.offsetTop != null){ menu.scrollTop = el.offsetTop - menu.clientHeight / 2 + el.clientHeight / 2; } + } + }, + onComposerKeydown(e){ + var key = e.key; + if(key === 'Backspace' || key === 'Delete'){ + try{ + var sel = window.getSelection(); + if(sel && sel.rangeCount){ + var r = sel.getRangeAt(0); + if(r.collapsed){ + var tok = key === 'Backspace' + ? this._prevToken(r.startContainer, r.startOffset) + : this._nextToken(r.startContainer, r.startOffset); + if(tok){ e.preventDefault(); this._removeTokenDom(tok); return; } + } + } + }catch(err){} + } + if(key === 'Escape'){ + if(this.mentionOpen){ e.preventDefault(); e.stopPropagation(); this.mentionOpen = false; this._mentionFrom = null; this._atNode = null; return; } + if(this.commandOpen){ e.preventDefault(); e.stopPropagation(); this.slashDismiss = true; this.cmdFocus = -1; return; } + return; // laisse fermer le panneau + } + if(key === 'ArrowDown' || key === 'ArrowUp'){ + var delta = key === 'ArrowDown' ? 1 : -1; + if(this.mentionOpen && this.mentionItems.length){ e.preventDefault(); this.moveMentionFocus(delta); return; } + if(this.commandOpen && !this.slashDismiss && this._slashItems().length){ e.preventDefault(); this.moveSlashFocus(delta); return; } + return; + } + // Entrée / Tab avec un menu ouvert (skills / mentions) + if(this.mentionOpen){ + var mSel = this._selectableMentionItems(); + if((key === 'Enter' || key === 'Tab') && mSel.length){ + e.preventDefault(); + var mi = mSel.find(function(it){ return it._i === this.mentionFocus; }.bind(this)) || mSel[0]; + this.pickMention(mi); + return; + } + } + if(this.commandOpen && !this.slashDismiss && this._slashItems().length){ + var items = this._slashItems(); + if(key === 'Enter' || key === 'Tab'){ + var focusItem = items.find(function(o){ return o._i === this.cmdFocus; }.bind(this)); + if(focusItem){ + if(!focusItem._admin){ e.preventDefault(); this.useSuggestion(focusItem); return; } + e.preventDefault(); this.send(); return; + } + var queryTyped = (this.composerText() || '').replace(/^\/+/, '').trim().length > 0; + var onlyAdmin = items.every(function(o){ return o._admin; }); + var onlySkill = !onlyAdmin && items.every(function(o){ return !o._admin; }); + if(onlySkill && queryTyped){ + e.preventDefault(); this.useSuggestion(items[0]); return; + } + if(onlyAdmin){ + e.preventDefault(); this.send(); return; + } + e.preventDefault(); return; + } + } + // Entrée : envoyer (Maj+Entrée = nouvelle ligne) + if(key === 'Enter'){ + if(e.shiftKey){ e.preventDefault(); this.insertNewline(); } + else { e.preventDefault(); this.send(); } + return; + } + }, + + // ── Actions sous chaque réponse agent ── + // Render agent markdown responses (headings, tables, code, lists, …) to + // HTML so the panel displays content like Notion (no raw `##` / `**`). + renderMarkdown(md){ + var self = this; + function escHtml(s){ return String(s==null?'':s).replace(/&/g,'&').replace(//g,'>'); } + function inline(t){ + t = escHtml(t); + return t + .replace(/`([^`]+)`/g,'$1') + .replace(/\*\*([^*]+)\*\*/g,'$1') + .replace(/\*([^*]+)\*/g,'$1') + .replace(/~~([^~]+)~~/g,'$1') + .replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g,'$1'); + } + if(!md) return ''; + var src = String(md); + // Trim leading/trailing blank lines to avoid stray

+ var lines = src.split(/\r?\n/); + var html = ''; + var i = 0; + function flush(block){ + if(!block) return ''; + if(block.type === 'ul' || block.type === 'ol'){ + var tag = block.type === 'ul' ? 'ul' : 'ol'; + return '<'+tag+' class="fd-md-list">' + block.items.map(function(it){ + var li = inline(it.text); + if(it.checked != null){ + return '
  • '+li+'
  • '; + } + return '
  • '+li+'
  • '; + }).join('') + ''; + } + if(block.type === 'quote'){ + return '
    '+block.items.map(function(t){ return '

    '+inline(t)+'

    '; }).join('')+'
    '; + } + if(block.type === 'p'){ + return block.text ? '

    '+inline(block.text)+'

    ' : ''; + } + return ''; + } + while(i < lines.length){ + var raw = lines[i]; + var t = raw.trim(); + // Fenced code block + if(t.startsWith('```')){ + var lang = t.substring(3).trim(); + var code = []; + i++; + while(i < lines.length && !lines[i].trim().startsWith('```')){ + code.push(lines[i]); i++; + } + i++; // skip closing ``` + html += '
    ' + escHtml(code.join('\n')) + '
    '; + continue; + } + // Table: | a | b | followed by | --- | --- | + if(t.startsWith('|') && i+1 < lines.length && /^\|[\s\-:|]+\|?$/.test(lines[i+1].trim())){ + var headerCells = t.replace(/^\||\|$/g,'').split('|').map(function(c){return c.trim();}); + var rows = []; + var j = i+2; + while(j < lines.length && lines[j].trim().startsWith('|')){ + rows.push(lines[j].trim().replace(/^\||\|$/g,'').split('|').map(function(c){return c.trim();})); + j++; + } + var thead = ''+headerCells.map(function(c){return ''+inline(c)+'';}).join('')+''; + var tb = rows.map(function(r){ + var cells = []; + for(var k=0;k'+cells.map(function(c){return ''+inline(c)+'';}).join('')+''; + }).join(''); + html += '
    '+thead+''+tb+'
    '; + i = j; + continue; + } + // Horizontal rule + if(/^(-{3,}|\*{3,}|_{3,})$/.test(t)){ html += '
    '; i++; continue; } + // Blockquote (accumulate consecutive) + if(t.startsWith('> ')){ + var q = {type:'quote', items:[]}; + while(i < lines.length && lines[i].trim().startsWith('> ')){ + q.items.push(lines[i].trim().substring(2)); i++; + } + html += flush(q); continue; + } + // Lists (accumulate consecutive ul/ol, incl. checkboxes) + if(/^[-*+] /.test(t) || /^\d+\. /.test(t)){ + var ul = /^[-*+] /.test(t) ? 'ul' : 'ol'; + var b = {type: ul, items: []}; + while(i < lines.length){ + var lt = lines[i].trim(); + if(ul === 'ul' && /^[-*+] /.test(lt)){ + var mt = lt.substring(2); + var chk = mt.match(/^\[([ xX])\]\s*/); + if(chk){ b.items.push({text: mt.substring(3), checked: chk[1]!==' '}); } + else b.items.push({text: mt}); + i++; + } else if(ul === 'ol' && /^\d+\. /.test(lt)){ + b.items.push({text: lt.replace(/^\d+\. /,'')}); i++; + } else break; + } + html += flush(b); continue; + } + // Headings + var hm = t.match(/^(#{1,4})\s+(.*)$/); + if(hm){ html += ''+inline(hm[2])+''; i++; continue; } + // Paragraph (accumulate consecutive non-empty non-special) + if(t){ + var para = []; + while(i < lines.length){ + var pt = lines[i].trim(); + if(!pt) break; + if(/^(```|#{1,4}\s|> |[-*+] |\d+\. |\||-{3,}$)/.test(pt)) break; + para.push(pt); i++; + } + html += para.map(function(p){ return '

    '+inline(p)+'

    '; }).join(''); + continue; + } + i++; + } + return html; + }, + copyResponse(m){ + var self = this; + var text = (m && m.content) || ''; + if(!text) return; + function fallback(){ + try{ + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + var ok = document.execCommand('copy'); + document.body.removeChild(ta); + self.toast(ok ? 'Réponse copiée.' : 'Copie impossible.', !ok); + }catch(err){ self.toast('Copie impossible.', true); } + } + if(navigator.clipboard && navigator.clipboard.writeText){ + navigator.clipboard.writeText(text).then(function(){ self.toast('Réponse copiée.'); }).catch(function(){ fallback(); }); + } else { fallback(); } + }, + insertIntoPage(m){ + var c = FlowDeckCtx.current(); + if(!c || c.kind !== 'page'){ this.toast('Aucune page ouverte pour insérer cette réponse.', true); return; } + if(!window.fdApplyDocument){ this.toast('Insertion indisponible ici.', true); return; } + var text = (m && m.content) || ''; + if(!text){ return; } + var ok = window.fdApplyDocument(text, m && m.mode && !m.applied ? m.mode : 'insert'); + this.toast(ok ? 'Réponse insérée dans la page.' : 'Insertion impossible (page non éditable).', !ok); + }, + feedback(m, rating){ + var self = this; + if(!m || m.fb) return; + var payload = {rating: rating, content: ((m.content) || '').slice(0, 1000)}; + if(this.currentConv && this.currentConv.id) payload.conversation_id = this.currentConv.id; + if(m.id && /^\d+$/.test(String(m.id))) payload.message_id = Number(m.id); + fetch('/api/agent/feedback', { + method: 'POST', + headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'}, + body: JSON.stringify(payload) + }).then(function(r){ return r.json(); }).then(function(d){ + if(d && d.status === 'recorded'){ m.fb = rating; } + self.toast(rating === 'up' ? 'Merci pour votre retour 👍' : 'Retour enregistré 👎', false); + }).catch(function(){ self.toast('Retour non envoyé.', true); }); + }, + + toggleSteps(m){ + if(!m) return; + m.stepsOpen = !m.stepsOpen; + this.$nextTick(function(){ this.scrollBottom(); }); + }, + toggleStep(s){ + if(!s) return; + s.open = !s.open; + }, + + // ── Send / SSE ── + async send(){ + var self = this; + if(self.streaming) return; + var text = (self.input || '').trim(); + if(!text && !self.contextChips.length) return; + if(self.commandOpen && self.handleCommand(text)){ self.clearComposer(); return; } + // skill intégré tapé « en dur » (« /research », « /meeting-note … ») → applique son modèle + if(self.commandOpen){ + var firstTok = (text.split(/\s+/)[0] || ''); + var sk = self._slugSkill(firstTok); + if(sk){ + var rest = text.slice(String(firstTok).length).trim(); + text = sk.template + (rest ? '\n\n' + rest : ''); + } + } + // barre oblique seule (après Escape du menu /) → on ne l'envoie pas + if(/^\/+$/.test(text)){ self.clearComposer(); return; } + var ment = self.composerMentions(); + var skillIds = []; + (self.contextChips || []).forEach(function(ch){ if(ch.kind==='skill' && ch.skillId) skillIds.push(ch.skillId); }); + var hasSkills = skillIds.length > 0 || (self.contextChips || []).some(function(ch){ return ch.kind==='skill'; }); + if(!text){ + if(ment.length){ text = 'Analyse le contexte référencé.'; } + else if(!hasSkills){ return; } + } + try { await self.ensureConversation(); } catch(e){ return; } + var rawText = text; + // La requête est composée EN ARRIÈRE-PLAN (hors de la boîte d'édition) : + // contexte1 + contexte2 + skill-1 + skill-2 + texte. + var composed = this._composePrompt(rawText); + var skillLabels = (self.contextChips || []).filter(function(ch){ return ch.kind==='skill'; }) + .map(function(ch){ return ch.label; }); + var displayText = rawText || (ment.length ? 'Analyse le contexte référencé.' + : ('Skills : ' + skillLabels.join(', '))); + self.clearComposer(); + self.errorMsg = ''; + self.mentionOpen = false; + + // Une demande d'écriture avec document ouvert et SANS skill/contexte épinglé → + // génération headless avec Appliquer/Rejeter. Dès qu'on épingle un skill ou un + // contexte, on passe par l'agent (outils + skills + mentions). + if(rawText && self.activeContext && self.activeContext.kind === 'page' + && self._isContentRequest(rawText) && !self.contextChips.length){ + self.refreshContext(); + return self.runDocAction({prompt: rawText, label: rawText, mode: self._docModeFor(rawText)}); + } + + self.streaming = true; + self.liveReasoning = '…'; + self._livePh = null; + var runSteps = []; + var ph = null; + function ensurePlaceholder(finalContent){ + if(ph) return ph; + ph = {id:'r-'+Date.now(), role:'assistant', generating: !finalContent, + content: finalContent || '', steps: [], stepsOpen: true, + model: self.autoLabel || ''}; + self.messages.push(ph); + self._livePh = ph; + return ph; + } + function addStep(s){ + runSteps.push(s); + if(ph){ ph.steps = runSteps.slice(); ph.generating = true; } + if(self._livePh){ self._livePh.steps = runSteps.slice(); } + } + self.messages.push({id:'u-'+Date.now(), role:'user', content: displayText}); + self.scrollBottom(); + + var payload = {message: composed}; + if(self.llmProvider) payload.provider = self.llmProvider; + if(self.llmModel) payload.model = self.llmModel; + var ctxText = self.contextPayload(); + if(ctxText) payload.context = ctxText; + if(ment && ment.length) payload.mentions = ment; + if(skillIds.length) payload.skill_ids = skillIds; + + return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', { + method:'POST', + headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, + body: JSON.stringify(payload) + }).then(function(resp){ + if(!resp.ok){ return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP '+resp.status)); }); } + var reader = resp.body.getReader(); + var dec = new TextDecoder(); + var respBuf = ''; + function read(){ + return reader.read().then(function(res){ + if(res.done){ return; } + respBuf += dec.decode(res.value); + var parts = respBuf.split('\n\n'); + respBuf = parts.pop(); + parts.forEach(function(p){ + var line = p.replace(/^data:\s*/, '').trim(); + if(!line) return; + try{ + var ev = JSON.parse(line); + if(ev.type==='reasoning'){ + // A thought: collapsed by default, expandable to its full text. + addStep({type:'thought', title:agentPanel._thoughtTitle(ev.content), + content:(ev.content||'').replace(/\s+$/,''), open:false}); + } + else if(ev.type==='action'){ + addStep(agentPanel._actionStep(ev)); + } + else if(ev.type==='final'){ + var fph = ensurePlaceholder(ev.content); + fph.generating = false; + fph.content = ev.content; + fph.steps = runSteps.slice(); + fph.stepsOpen = false; + fph.model = self.autoLabel || ''; + if(window.__fdInsertAI){ try{ var h=window.__fdInsertAI; window.__fdInsertAI=null; h(ev.content||''); }catch(e){} } + } + else if(ev.type==='notice'){ self.notice = ev.message; } + else if(ev.type==='error'){ + var eph = ensurePlaceholder(); + eph.generating = false; + eph.content = '⚠️ ' + ev.message; + eph.steps = runSteps.slice(); + self.errorMsg = ev.message; + } + }catch(e){} + }); + self.scrollBottom(); + return read(); + }); + } + return read(); + }).catch(function(err){ self.errorMsg = (err && err.message) ? err.message : 'Erreur agent'; }) + .finally(function(){ + self.streaming = false; self.liveReasoning=''; self._livePh = null; + self.fetchConversationsRefresh(); + }); + }, + fetchConversationsRefresh(){ + var self = this; + fetch('/api/agent/conversations').then(function(r){return r.json()}).then(function(d){ + self.conversations = d.conversations || []; + // keep the open conversation in sync so its auto-updated title shows + // immediately in the header and in a later History screen. + var cur = self.currentConv; + if(cur && cur.id){ + var match = self.conversations.find(function(c){ return c.id === cur.id; }); + if(match){ cur.title = match.title; cur.updated_at = match.updated_at; } + } + }).catch(function(){}); + }, + scrollBottom(){ + this.$nextTick(function(){ var el = document.querySelector('.fd-agent-body'); if(el) el.scrollTop = el.scrollHeight; }); + } + }; + } + + document.addEventListener('alpine:init', function(){ + Alpine.data('agentPanel', agentPanel); + }); + window.agentPanel = agentPanel; + // Plain-text helper for the history preview (strip markdown noise). + agentPanel._strip = function(t){ + if(!t) return ''; + return String(t) + .replace(/```[\s\S]*?```/g, ' [code] ') + .replace(/[#*_>`~]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 280); + }; + // Short title for a reasoning thought (first non-empty line, truncated). + agentPanel._thoughtTitle = function(text){ + var s = (text || '').trim(); + if(!s) return 'Réflexion'; + // Skip leading blank lines and get first meaningful line + var lines = s.split(/\r?\n/); + var first = ''; + for(var i=0;i { + Alpine.data('giteaWorkspace', () => { + const params = new URLSearchParams(window.location.search); + const owner = params.get('owner') || ''; + const repo = params.get('repo') || ''; + + return { + owner, repo, + showFile: false, + showEditor: false, + showPrivate: false, + privatePages: [], + editingPrivate: null, + editingPrivateTitle: '', + editingPrivateContent: '', + filePath: '', + fileContent: '', + fileSize: 0, + fileSha: '', + fileLoading: false, + fileLanguage: 'text', + editContent: '', + commitMessage: 'Update via FlowDeck', + commitHistory: JSON.parse(localStorage.getItem('fd_commit_msgs') || '[]'), + // File tree browser + treeItems: [], + sortedTreeItems: [], + treeLoading: false, + folderStack: [], + currentPath: '', + // Context menu + gwCtx: { visible: false, x: 0, y: 0, item: null }, + + _sortTree() { + this.sortedTreeItems = [...this.treeItems].sort(function(a, b) { + if (a.type === b.type) return a.name.localeCompare(b.name); + return a.type === 'folder' ? -1 : 1; + }); + }, + + init() { + // Expose globally for header to access + window._gwData = this; + // Set cookie for sidebar + document.cookie = 'flowdeck_workspace=gitea:' + this.owner + ':' + this.repo + ';path=/;SameSite=Lax'; + // Load sidebar tree (into gitea section) + this.loadSidebarTree(); + // Load main content tree + this.loadMainTree(''); + // Listen for sidebar clicks on Gitea items + this.setupSidebarClicks(); + }, + + async loadMainTree(path) { + this.treeLoading = true; + this.currentPath = path; + try { + var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree'; + if (path) url += '?path=' + encodeURIComponent(path); + var r = await fetch(url); + if (!r.ok) { this.treeItems = []; this.sortedTreeItems = []; return; } + var d = await r.json(); + this.treeItems = d.tree || []; + this._sortTree(); + } catch(e) { this.treeItems = []; this.sortedTreeItems = []; } + finally { this.treeLoading = false; } + }, + + drillDown(path) { + this.folderStack.push(path.split('/').pop()); + this.loadMainTree(path); + }, + + navigateToFolder(idx) { + // Truncate stack and rebuild path + this.folderStack = this.folderStack.slice(0, idx + 1); + var path = this.folderStack.join('/'); + this.loadMainTree(path); + }, + + navigateToRoot() { + this.folderStack = []; + this.loadMainTree(''); + }, + + openGwContext(ev, item) { + this.gwCtx = { visible: true, x: ev.clientX, y: ev.clientY, item: item }; + }, + gwRename() { + this.gwCtx.visible = false; + var item = this.gwCtx.item; + if (!item) return; + var newName = prompt('Rename:', item.name); + if (!newName || !newName.trim() || newName.trim() === item.name) return; + var self = this; + var oldPath = item.path; + var newPath = oldPath.replace(/[^/]+$/, newName.trim()); + fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', { + method: 'PUT', + headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath }) + }).then(function(r){ return r.json(); }) + .then(function(d){ + if (d.status === 'ok') { self.refreshTree(); } + else { window.showToast('Rename failed', 'error'); } + }) + .catch(function(){ window.showToast('Rename failed', 'error'); }); + }, + gwDelete() { + this.gwCtx.visible = false; + var item = this.gwCtx.item; + if (!item) return; + if (!confirm('Delete ' + item.name + '?')) return; + var self = this; + fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, + method: 'DELETE' + }).then(function(r){ + if (r.ok) { self.refreshTree(); } + else { window.showToast('Delete failed', 'error'); } + }).catch(function(){ window.showToast('Delete failed', 'error'); }); + }, + + async loadSidebarTree() { + try { + var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree'); + if (!r.ok) { + // If API fails (e.g. no Gitea token), set a message + var container = document.getElementById('sidebar-gitea-items'); + if (container) container.innerHTML = ''; + return; + } + var d = await r.json(); + var items = d.tree || []; + var container = document.getElementById('sidebar-gitea-items'); + if (!container) return; + // Ensure gitea section is visible + if (window.appState && window.appState.sectionsOpen && !window.appState.sectionsOpen.gitea) { + window.appState.sectionsOpen.gitea = true; + } + container.innerHTML = ''; + // Build tree HTML as string and set once (more performant) + var html = ''; + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var icon = item.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14); + html += ''; + } + // Add Private Pages link + html += '
  • '; + html += ''; + container.innerHTML = html; + // Re-attach event listeners + this.setupSidebarClicks(); + } catch(e) { + console.error('Gitea sidebar tree load failed:', e); + } + }, + + setupSidebarClicks() { + var self = this; + document.addEventListener('click', function(e) { + var el = e.target.closest('.sidebar-item[data-gitea-path]'); + if (!el) return; + + var path = el.getAttribute('data-gitea-path'); + var type = el.getAttribute('data-gitea-type'); + + // If clicking the checkbox, let its own handler deal with selection + if (e.target.classList.contains('gitea-checkbox')) return; + + // If clicking the inline rename input, don't navigate + if (e.target.classList.contains('inline-rename-input')) return; + + // If Shift or Ctrl/Meta is pressed, use multi-selection + if (e.shiftKey || e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + self.selectItem(path, el, e); + return; + } + + // Normal click: navigate (open file/folder) + e.preventDefault(); + e.stopPropagation(); + + // Update visual "active" state + document.querySelectorAll('.sidebar-item.active').forEach(function(si) { + si.classList.remove('active'); + }); + el.classList.add('active'); + + if (type === 'folder') { + self.loadSubdir(path, el); + } else { + self.openFile(path, el.getAttribute('data-gitea-sha')); + } + }); + + // Listen for custom delete event on gitea sidebar items + document.addEventListener('gitea-delete', function(e) { + var el = e.target; + var path = el.getAttribute('data-gitea-path'); + if (!path) return; + if (!confirm('Delete ' + path + ' from ' + self.owner + '/' + self.repo + '?')) return; + var sha = el.getAttribute('data-gitea-sha') || ''; + fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, + method: 'DELETE', + }).then(function(r) { + if (r.ok) self.refreshTree(); + }).catch(function() {}); + }); + }, + + async loadSubdir(path, el) { + var self = this; + // Check if already loaded + var ul = el.querySelector('ul'); + if (ul) { + ul.style.display = ul.style.display === 'none' ? '' : 'none'; + return; + } + try { + var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree?path=' + encodeURIComponent(path)); + if (!r.ok) return; + var d = await r.json(); + var children = d.tree || []; + ul = document.createElement('ul'); + ul.className = 'sidebar-items'; + ul.style.paddingLeft = '20px'; + children.forEach(function(child) { + var icon = child.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14); + var li = document.createElement('li'); + li.className = 'sidebar-item'; + li.setAttribute('data-gitea-path', child.path); + li.setAttribute('data-gitea-type', child.type === 'folder' ? 'folder' : 'file'); + li.setAttribute('data-gitea-sha', child.sha || ''); + li.style.cssText = 'cursor:pointer;display:flex;align-items:center;gap:4px;'; + // Checkbox + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'gitea-checkbox'; + cb.style.cssText = 'flex-shrink:0;margin:0;display:none;'; + cb.addEventListener('click', function(ev) { + ev.stopPropagation(); + self.selectItem(child.path, li, ev); + }); + li.appendChild(cb); + // Icon + var iconSpan = document.createElement('span'); + iconSpan.className = 'sidebar-icon'; + iconSpan.innerHTML = icon; // icon = HTML SVG from getSvgIcon(), NOT text + li.appendChild(iconSpan); + // Name + var nameSpan = document.createElement('span'); + nameSpan.textContent = child.name; + nameSpan.addEventListener('dblclick', function(ev) { + ev.preventDefault(); + ev.stopPropagation(); + self.startInlineRename(nameSpan, child.path, li); + }); + li.appendChild(nameSpan); + ul.appendChild(li); + }); + el.appendChild(ul); + } catch(e) {} + }, + + async openFile(path, sha) { + this.filePath = path; + this.fileSha = sha || ''; + this.showEditor = false; + this.showFile = true; + this.fileLoading = true; + this.fileLanguage = this.getLanguage(path); + try { + var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(path)); + if (r.ok) { + var d = await r.json(); + this.fileContent = d.content || ''; + this.fileSize = d.content ? d.content.length : 0; + } else { + this.fileContent = '[Error loading file]'; + } + } catch(e) { + this.fileContent = '[Error loading file]'; + } + this.fileLoading = false; + // Highlight after Alpine renders + var self = this; + this.$nextTick(function() { + var block = self.$refs.codeBlock; + if (block && block.textContent && typeof Prism !== 'undefined') { + Prism.highlightElement(block); + } + }); + }, + + getLanguage(path) { + var ext = (path || '').split('.').pop().toLowerCase(); + var map = { + js:'javascript', jsx:'javascript', ts:'typescript', tsx:'typescript', + py:'python', rb:'ruby', rs:'rust', go:'go', java:'java', kt:'kotlin', + c:'c', cpp:'c', h:'c', hpp:'c', cs:'csharp', + html:'markup', htm:'markup', xml:'markup', svg:'markup', css:'css', scss:'css', + json:'json', yaml:'yaml', yml:'yaml', toml:'toml', ini:'ini', + md:'markdown', sql:'sql', sh:'bash', bash:'bash', zsh:'bash', + php:'php', swift:'swift', r:'r', lua:'lua', dart:'dart', + dockerfile:'docker', makefile:'makefile', cmake:'cmake', + }; + return map[ext] || 'text'; + }, + + openEditor() { + this.editContent = this.fileContent; + this.showEditor = true; + }, + + async saveFile() { + if (!this.filePath) return; + try { + var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', { + method: 'PUT', + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()}, + body: JSON.stringify({ + path: this.filePath, content: this.editContent, + message: this.commitMessage, sha: this.fileSha, + }) + }); + if (r.ok) { + this.fileContent = this.editContent; + this.showEditor = false; + if (!this.commitHistory.includes(this.commitMessage)) { + this.commitHistory.unshift(this.commitMessage); + if (this.commitHistory.length > 10) this.commitHistory.pop(); + localStorage.setItem('fd_commit_msgs', JSON.stringify(this.commitHistory)); + } + } else { + var d = await r.json(); + window.showToast('Failed to save: ' + (d.error || 'Unknown error'), 'error'); + } + } catch(e) { window.showToast('Network error', 'error'); } + }, + + async deleteCurrentFile() { + if (!this.filePath) return; + if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return; + try { + var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, + method: 'DELETE', + }); + if (r.ok) { + this.showFile = false; + this.filePath = ''; + await this.refreshTree(); + } + } catch(e) {} + }, + + async refreshTree() { + // Reload main tree and sidebar tree without full page reload + this.loadMainTree(this.currentPath); + this.loadSidebarTree(); + }, + + formatSize(bytes) { + if (!bytes) return ''; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB'; + return (bytes/1048576).toFixed(1) + ' MB'; + }, + + // ── Private Pages ── + + async loadPrivatePages() { + try { + var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages'); + if (r.ok) { var d = await r.json(); this.privatePages = d.pages || []; } + } catch(e) {} + }, + + newPrivate() { + this.editingPrivate = 'new'; + this.editingPrivateTitle = ''; + this.editingPrivateContent = ''; + }, + + async openPrivate(id) { + try { + var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id); + if (r.ok) { + var d = await r.json(); + this.editingPrivate = id; + this.editingPrivateTitle = d.page.title; + this.editingPrivateContent = d.page.content; + } + } catch(e) {} + }, + + async savePrivate() { + var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages'; + var method = 'POST'; + if (this.editingPrivate !== 'new') { + url += '/' + this.editingPrivate; + method = 'PUT'; + } + try { + var r = await fetch(url, { + method, headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''}, + body: JSON.stringify({title: this.editingPrivateTitle, content: this.editingPrivateContent}) + }); + if (r.ok) { + this.editingPrivate = null; + this.editingPrivateTitle = ''; + this.editingPrivateContent = ''; + await this.loadPrivatePages(); + } + } catch(e) {} + }, + + // Create new file + showNewFile: false, + newFilePath: '', + newFileContent: '', + newFileMsg: 'Create via FlowDeck', + async createNewFile() { + if (!this.newFilePath.trim()) return; + try { + var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', { + method: 'PUT', + headers: {'Content-Type':'application/json', 'X-CSRF-Token': this.getCsrfToken()}, + body: JSON.stringify({path: this.newFilePath.trim(), content: this.newFileContent, message: this.newFileMsg || 'Create via FlowDeck'}) + }); + if (r.ok) { + this.showNewFile = false; + this.newFilePath = ''; + this.newFileContent = ''; + this.newFileMsg = 'Create via FlowDeck'; + await this.loadSidebarTree(); + } else { + var d = await r.json(); + window.showToast('Failed: ' + (d.error || d.detail || 'Unknown error'), 'error'); + } + } catch(e) { window.showToast('Error: ' + e.message, 'error'); } + }, + + // Upload files + triggerUpload() { document.getElementById('gitea-upload-input').click(); }, + async doUpload(ev) { + var files = ev.target.files; + if (!files.length) return; + for (var i = 0; i < files.length; i++) { + var form = new FormData(); + form.append('file', files[i]); + form.append('message', 'Upload ' + files[i].name + ' via FlowDeck'); + try { + var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/upload', { + method: 'POST', + headers: {'X-CSRF-Token': this.getCsrfToken()}, + body: form + }); + if (!r.ok) { var d = await r.json(); console.error('Upload failed:', d.error); } + } catch(e) { console.error('Upload error:', e); } + } + await this.loadSidebarTree(); + ev.target.value = ''; + }, + + async deletePrivate(id) { + if (!confirm('Delete this private page?')) return; + try { + var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id, { + method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''} + }); + if (r.ok) await this.loadPrivatePages(); + } catch(e) {} + }, + + getCsrfToken() { + return document.cookie.split('; ').find(function(c) { return c.startsWith('csrf_token='); })?.split('=')[1] || ''; + }, + + // ── Multi-selection ── + multiSelect: false, + selectedPaths: [], + lastClickedPath: null, + + toggleMultiSelect() { + this.multiSelect = !this.multiSelect; + var cbs = document.querySelectorAll('.gitea-checkbox'); + var self = this; + cbs.forEach(function(cb) { cb.style.display = self.multiSelect ? '' : 'none'; }); + if (!this.multiSelect) { + // Clear selection when leaving multi-select mode + cbs.forEach(function(cb) { cb.checked = false; }); + this.selectedPaths = []; + this.lastClickedPath = null; + // Remove selected class + document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) { + el.classList.remove('gitea-selected'); + }); + } + }, + + selectItem(path, li, ev) { + if (ev.shiftKey && this.lastClickedPath) { + // Range select + var all = Array.from(document.querySelectorAll('.sidebar-item[data-gitea-path]')); + var startIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === this.lastClickedPath; }.bind(this)); + var endIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === path; }.bind(this)); + if (startIdx >= 0 && endIdx >= 0) { + var lo = Math.min(startIdx, endIdx); + var hi = Math.max(startIdx, endIdx); + this.selectedPaths = []; + for (var i = lo; i <= hi; i++) { + this.selectedPaths.push(all[i].getAttribute('data-gitea-path')); + all[i].classList.add('gitea-selected'); + var cb = all[i].querySelector('.gitea-checkbox'); + if (cb) cb.checked = true; + } + } + this.multiSelect = true; + this.toggleMultiSelect(); // ensure checkboxes are visible + } else if (ev.ctrlKey || ev.metaKey) { + // Toggle single + var idx = this.selectedPaths.indexOf(path); + if (idx >= 0) { + this.selectedPaths.splice(idx, 1); + li.classList.remove('gitea-selected'); + var cb = li.querySelector('.gitea-checkbox'); + if (cb) cb.checked = false; + } else { + this.selectedPaths.push(path); + li.classList.add('gitea-selected'); + var cb = li.querySelector('.gitea-checkbox'); + if (cb) cb.checked = true; + } + this.multiSelect = this.selectedPaths.length > 0; + this.toggleMultiSelect(); + } else { + // Normal select — clear multi-selection + this.selectedPaths = [path]; + this.lastClickedPath = path; + document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) { + el.classList.remove('gitea-selected'); + }); + li.classList.add('gitea-selected', 'active'); + var cb = li.querySelector('.gitea-checkbox'); + if (cb) cb.checked = true; + } + }, + + isSelected(path) { + return this.selectedPaths.indexOf(path) >= 0; + }, + + // ── Inline rename ── + startInlineRename(nameSpan, path, li) { + var originalName = nameSpan.textContent; + var input = document.createElement('input'); + input.type = 'text'; + input.value = originalName; + input.className = 'inline-rename-input'; + input.style.cssText = 'flex:1;min-width:0;padding:2px 6px;background:var(--bg-tertiary);border:1px solid var(--accent);border-radius:4px;color:var(--text);font-size:13px;outline:none;'; + nameSpan.replaceWith(input); + input.focus(); + input.select(); + var self = this; + var finish = function() { self.finishInlineRename(input, originalName, path, li); }; + var cancel = function() { + var span = document.createElement('span'); + span.className = 'page-name'; + span.textContent = originalName; + input.replaceWith(span); + }; + input.addEventListener('blur', finish); + input.addEventListener('keydown', function(e) { + if (e.key === 'Enter') finish(); + if (e.key === 'Escape') { e.stopPropagation(); cancel(); } + }); + }, + + finishInlineRename(input, originalName, path, li) { + if (input._renaming) return; // guard against double-fire + input._renaming = true; + var newName = input.value.trim(); + if (!newName || newName === originalName) { + var span = document.createElement('span'); + span.className = 'page-name'; + span.textContent = originalName; + input.replaceWith(span); + return; + } + var self = this; + // Construct new path by replacing the last segment + var parts = path.split('/'); + parts.pop(); + var newPath = (parts.length > 0 ? parts.join('/') + '/' : '') + newName; + fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/rename', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()}, + body: JSON.stringify({old_path: path, new_path: newPath, new_name: newName}) + }).then(function(r) { + if (r.ok) { + self.refreshTree(); + } else { + var span = document.createElement('span'); + span.className = 'page-name'; + span.textContent = originalName; + input.replaceWith(span); + window.showToast('Rename failed', 'error'); + } + }).catch(function() { + var span = document.createElement('span'); + span.className = 'page-name'; + span.textContent = originalName; + input.replaceWith(span); + }); + }, + }; + }); +}); diff --git a/static/js/import.js b/static/js/import.js new file mode 100644 index 0000000..7da2186 --- /dev/null +++ b/static/js/import.js @@ -0,0 +1,158 @@ + +function importWizard() { + return { + sources: [], + sourceId: '', + files: [], + dragOver: false, + mode: 'skip', + busy: false, + progress: 0, + error: '', + preview: null, + report: null, + mapping: {}, + csrf: '', + urlValue: '', + urlError: '', + forgeProvider: 'gitea', + forgeOwner: '', + forgeRepo: '', + forgeState: 'all', + forgeError: '', + types: ['text','number','date','checkbox','email','url','phone','select','multi_select','status'], + async init() { + try { + const r = await fetch('/api/import/sources'); + this.sources = (await r.json()).sources || []; + } catch(e) {} + try { + const c = await fetch('/api/csrf-token'); + this.csrf = (await c.json()).csrf_token; + } catch(e) {} + }, + humanSize(n) { return n > 1048576 ? (n/1048576).toFixed(1)+' Mo' : Math.max(1, Math.round(n/1024))+' Ko'; }, + addFiles(list) { + for (const f of list) this.files.push({file:f, name:f.name, size:f.size, status:'queued'}); + this.preview = null; this.report = null; this.error = ''; + }, + onFiles(e) { this.addFiles(e.target.files); e.target.value=''; }, + onDrop(e) { this.dragOver=false; this.addFiles(e.dataTransfer.files); }, + buildForm(f) { + const fd = new FormData(); + fd.append('file', f.file); + if (this.sourceId) fd.append('source', this.sourceId); + fd.append('mode', this.mode); + return fd; + }, + async doPreview() { + this.busy = true; this.error=''; this.report=null; this.progress=10; + try { + let merged = null; + for (let i=0;i (p.schema||[]).forEach(c => { this.mapping[c.name]=c.type; })); + } catch(e) { this.error = 'Erreur réseau'; } + finally { this.busy = false; setTimeout(()=>{this.progress=0;},800); } + }, + async importOne(f) { + f.status = 'running'; + const fd = this.buildForm(f); + if (Object.keys(this.mapping).length) fd.append('mapping', JSON.stringify(this.mapping)); + const big = f.size > 5*1024*1024; + try { + if (big) { + fd.append('async','true'); + const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd}); + const d = await r.json(); + if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; } + return await this.pollJob(d.job_id, f); + } + const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd}); + const d = await r.json(); + if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; } + f.status = (d.status==='partial' ? 'partial' : 'done'); + return d; + } catch(e) { f.status='error'; f.error='Erreur réseau'; return null; } + }, + async doImport() { + this.busy = true; this.error=''; this.report=null; this.progress=0; + const aggregate = {pages_created:0,pages_updated:0,collections_created:0,rows_created:0, + attachments:0,skipped:0,warnings:[],errors:[],per_file:[]}; + for (let i=0;i setTimeout(res, 700)); + const r = await fetch('/api/import/jobs/'+jobId); + const j = await r.json(); + if (j.status === 'done') { if (f) f.status='done'; return j.report; } + if (j.status === 'error') { if (f) { f.status='error'; f.error=j.error; } return null; } + } + if (f) { f.status='error'; f.error='Délai dépassé'; } + return null; + }, + downloadReport() { + const blob = new Blob([JSON.stringify(this.report, null, 2)], {type:'application/json'}); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'flowdeck-import-report.json'; + a.click(); + URL.revokeObjectURL(a.href); + } + }; +} diff --git a/static/js/library.js b/static/js/library.js new file mode 100644 index 0000000..4668a01 --- /dev/null +++ b/static/js/library.js @@ -0,0 +1,1039 @@ + +function libraryPage() { + return { + tab: 'recents', + workspaceId: 0, + isGiteaActive: false, + giteaOwner: '', + giteaRepo: '', + items: [], + loading: false, + searchOpen: false, + searchQuery: '', + + // Selection + selected: {}, + selectedCount: 0, + allSelected: false, + + // Tree + expanded: {}, + renamingId: null, + renameValue: '', + + // Toolbar dropdowns + filterOpen: false, + sortOpen: false, + viewOpen: false, + sourceFilter: 'all', + shareDir: 'all', + showAuthor: false, + showSource: false, + + // Tags + workspaceTags: [], + tagFilter: '', + + // Sort + sortBy: '', + + // More menu + moreOpen: false, + + // Move-to modal + moveModalOpen: false, + moveIds: [], + moveSearchQuery: '', + + // No workspace guard + noWorkspace: false, + moveCandidates: [], + _moveAllCandidates: [], + + // Drag & drop + _dragId: null, + + // Side peek + peekItem: null, + peekContent: '', + peekLoading: false, + peekFavorited: false, + peekMoreOpen: false, + + // ── Computed flat list ── + flatItems: [], + + _renderTable() { + var self = this; + var body = document.getElementById('lib-table-body'); + var table = document.getElementById('lib-table'); + var emptyDiv = document.getElementById('lib-empty'); + var loadingDiv = document.getElementById('lib-loading'); + if (!body) return; + + // Show/hide table vs empty state + if (this.flatItems.length === 0) { + if (table) table.style.display = 'none'; + if (emptyDiv) emptyDiv.style.display = ''; + return; + } + if (table) table.style.display = ''; + if (emptyDiv) emptyDiv.style.display = 'none'; + if (loadingDiv) loadingDiv.style.display = 'none'; + var html = ''; + for (var i = 0; i < this.flatItems.length; i++) { + var item = this.flatItems[i]; + + // ── Synthetic "+ Add new" placeholder row for an expanded parent ── + if (item._isAddNew) { + var pad = 30 + (item.depth || 0) * 20; + html += '
    '; + html += ' Add new
    '; + continue; + } + + var selClass = this.selected[item.id] ? ' selected' : ''; + var indentPx = 4 + (item.depth || 0) * 20; + var checkClass = this.selected[item.id] ? ' checked' : ''; + var chevOpen = this.expanded[item.id] ? ' open' : ''; + var isRenaming = this.renamingId === item.id; + html += '
    '; + html += '
    '; + // Drag handle + html += '
    '; + html += '
    '; + // Checkbox + html += '
    '; + if (this.selected[item.id]) html += ''; + html += '
    '; + // Chevron or spacer + if (item.has_children) { + html += ''; + } else { + html += ''; + } + // Icon — monochrome, comme local-workspace (emoji custom si défini) + var isFolder = item.is_folder || item.has_children; + html += '' + self._renderIcon(item) + ''; + // Title or rename input + if (isRenaming) { + html += ''; + } else { + html += '' + self._escHtml(item.title) + ''; + } + // Tags + if (item.tags && item.tags.length) { + html += ''; + for (var ti = 0; ti < item.tags.length; ti++) { + var tg = item.tags[ti]; + html += '' + self._escHtml(tg.name) + ''; + } + html += ''; + } + // OPEN button (side peek) — hidden for folders + if (!isFolder) { + html += ''; + } + html += '
    '; + // Author + html += '
    '; + html += '
    ' + (item.author_initial || '?') + '
    '; + html += '' + (item.author || '\u2014') + '
    '; + // Source + html += '
    ' + (item.source_label || 'Private') + '
    '; + // Edited + html += '
    ' + self.formatTime(item.updated_at) + '
    '; + html += '
    '; + } + body.innerHTML = html; + }, + + _escHtml(s) { var d=document.createElement('div'); d.textContent = s||''; return d.innerHTML; }, + _escAttr(s) { return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(//g,'>'); }, + _renderIcon(item) { + if (!item) return getSvgIcon('file', 14); + // Custom emoji (image URL), icon name, or unicode emoji + if (item.page_icon) return '' + (window.fdIconHtml ? window.fdIconHtml(item.page_icon, 14) : this._escHtml(item.page_icon)) + ''; + if (item.is_folder || item.has_children) return getSvgIcon('folder', 14); + if (item.content_format && item.content_format !== 'file') return getSvgIcon('edit', 14); + var n = (item.title || '').toLowerCase(); + if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return getSvgIcon('image', 14); + return getSvgIcon('file', 14); + }, + + _recomputeFlatItems() { + var result = []; + this._flatten(this.items, result, 0); + if (this.searchQuery.trim()) { + var q = this.searchQuery.toLowerCase(); + result = result.filter(function(r) { + return (r.title || '').toLowerCase().indexOf(q) !== -1; + }); + } + if (this.tagFilter) { + var tf = this.tagFilter; + result = result.filter(function(r) { + return (r.tags || []).some(function(t) { return t.name === tf; }); + }); + } + // Use splice for Alpine 3.14.9 reactivity (array reference reassign fails) + this.flatItems.splice(0, this.flatItems.length, ...result); + this.selectedCount = Object.keys(this.selected).length; + this.allSelected = this.flatItems.length > 0 && this.selectedCount === this.flatItems.length; + this._renderTable(); + }, + + // ── Global dispatch (bypasses Alpine x-for reactivity issues) ── + toggleItemById(id) { + var item = this._findItem(id); + if (!item) return; + this.toggleItem(item); + }, + toggleExpandById(id) { + var item = this._findItem(id); + if (!item) return; + this.toggleExpand(item); + }, + startRenameById(id) { + var item = this._findItem(id); + if (!item) return; + this.startRename(item); + }, + commitRenameById(id) { + var item = this._findItem(id); + if (!item) { this.renamingId = null; this._renderTable(); return; } + // Read value from the input + var inp = document.getElementById('rename-inp-' + id); + if (inp) this.renameValue = inp.value; + this.commitRename(item); + }, + openItemById(id) { + var item = this._findItem(id); + if (!item) return; + this.openItem(item); + }, + openPeekById(id) { + var item = this._findItem(id); + if (!item) return; + this.openPeek(item); + }, + cancelRename() { + this.renamingId = null; + this._renderTable(); + }, + _findItem(id) { + for (var i = 0; i < this.flatItems.length; i++) { + if (this.flatItems[i].id === id) return this.flatItems[i]; + } + return null; + }, + + // ── Row click / context menu ── + onRowClick(evt, id) { + var item = this._findItem(id); + if (!item) return; + this.openItem(item); + }, + onRowContextMenu(evt, id) { + evt.preventDefault(); + var item = this._findItem(id); + if (!item) return; + if (!(window.Alpine && Alpine.store('fdCtx'))) return; + /* MENU UNIFIÉ (partial _ctx_menu.html + store fdCtx) — handlers library + complète l'union. Open folder/tags non prévus côté library : + fdCtx les cache automatiquement via handlers absents. */ + var self = this; + var handlers = { + open: function (n) { self.openItem(n); }, + openTab: function (n) { self.openInNewTab(n); }, + peek: function (n) { self.openPeek(n); }, + rename: function (n) { self.startRename(n); }, + setIcon: function (icon) { self.setItemIcon(item, icon); }, + duplicate: function (n) { self.duplicateItem(n); }, + link: function (n) { self.copyLink(n); }, + download: function (n) { self.downloadItem(n); }, + copyContent: function (n) { self.copyItemContent(n); }, + move: function (n) { self.openMovePicker([n.id]); }, + fav: function (n) { self.toggleFavoriteItem(n); }, + delete: function (n) { self.deleteItem(n); }, + tagExisting: function (t) { self.ctxAddExistingTag(t); }, + tagAdd: function (name, color) { self.ctxAddNewTag(name, color); }, + tagRemove: function (tagId) { self.ctxRemoveTag(tagId); } + }; + if (this.tab === 'recents') handlers.recent = function (n) { self.removeItemFromRecents(n); }; + var store = Alpine.store('fdCtx'); + store.setAvail(self._ctxAvailTags(item)); + store.openMenu(evt, item, 'library', handlers); + }, + + downloadItem(node) { + if (!node || node.is_folder) return; + var url = '/api/pages/' + node.id + '/download'; + var a = document.createElement('a'); + a.href = url; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + if (window.showToast) window.showToast('Download started', 'success'); + }, + + async copyItemContent(node) { + if (!node || node.is_folder) return; + try { + var r = await fetch('/api/pages/' + node.id + '/file-content'); + var d = await r.json(); + if (d.ok && d.content !== undefined) { + await navigator.clipboard.writeText(d.content); + if (window.showToast) window.showToast('Content copied to clipboard', 'success'); + } else { + if (window.showToast) window.showToast('Not a text file', 'error'); + } + } catch (e) { + if (window.showToast) window.showToast('Copy failed', 'error'); + } + }, + + // ── Drag & drop (move / reparent) ── + onRowDragStart(evt, id) { + if (!evt.target.closest('.drag-handle')) { evt.preventDefault(); return; } + this._dragId = id; + evt.dataTransfer.effectAllowed = 'move'; + evt.dataTransfer.setData('text/plain', String(id)); + evt.currentTarget.classList.add('dragging'); + }, + onRowDragOver(evt, id) { + if (this._dragId === null || this._dragId === id) return; + evt.preventDefault(); + evt.currentTarget.classList.add('drag-over'); + }, + onRowDragLeave(evt, id) { + evt.currentTarget.classList.remove('drag-over'); + }, + async onRowDrop(evt, id) { + evt.preventDefault(); + evt.currentTarget.classList.remove('drag-over'); + var draggedId = this._dragId; + this._dragId = null; + if (!draggedId || draggedId === id) return; + var target = this._findItem(id); + var dragged = this._findItem(draggedId); + if (!target || !dragged) return; + // Prevent dropping a page onto one of its own (already-loaded) descendants + if (this._isDescendant(dragged, id)) { + if (window.showToast) window.showToast('Cannot move a page into its own subpage', 'error'); + return; + } + await this._movePages([draggedId], id); + if (window.showToast) window.showToast('Page moved', 'success'); + }, + onRowDragEnd(evt) { + this._dragId = null; + document.querySelectorAll('.lib-row.dragging').forEach(function(el){ el.classList.remove('dragging'); }); + document.querySelectorAll('.lib-row.drag-over').forEach(function(el){ el.classList.remove('drag-over'); }); + }, + + _flatten(nodes, out, depth) { + if (!nodes) return; + for (var i = 0; i < nodes.length; i++) { + var n = nodes[i]; + n.depth = depth || 0; + out.push(n); + if (n.has_children && this.expanded[n.id]) { + this._flatten(n.children, out, depth + 1); + if (this._canAddChild()) { + out.push({ _isAddNew: true, _parentId: n.id, depth: (depth || 0) + 1, id: 'add-' + n.id }); + } + } + } + }, + + _canAddChild() { + return this.tab !== 'favorites' && this.tab !== 'shared' && this.tab !== 'published' && this.tab !== 'repository'; + }, + + _getCsrf() { + var m = document.cookie.match(/csrf_token=([^;]+)/); + return m ? m[1] : ''; + }, + + _syncSidebar() { + if (window.appState && window.appState.refreshSidebarTree) window.appState.refreshSidebarTree(); + if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites(); + }, + + _isDescendant(node, targetId) { + if (!node || !node.children) return false; + for (var i = 0; i < node.children.length; i++) { + if (node.children[i].id === targetId) return true; + if (this._isDescendant(node.children[i], targetId)) return true; + } + return false; + }, + + async _movePages(ids, parentId) { + var csrf = this._getCsrf(); + for (var i = 0; i < ids.length; i++) { + try { + await fetch('/board/api/pages/' + ids[i] + '/move', { + method: 'PUT', + headers: {'Content-Type':'application/json', 'X-CSRF-Token': csrf}, + body: JSON.stringify({ parent_id: parentId || 0 }) + }); + } catch(e) {} + } + this.clearSelection(); + this.loadTab(); + this._syncSidebar(); + }, + + // ── Empty states ── + emptyIcon: 'file', + emptyTitle: 'No items', + emptyText: '', + + _recomputeEmpty() { + var icons = {recents:'clock',favorites:'star',shared:'users',published:'globe',private:'lock',workspace:'folder',repository:'folder'}; + this.emptyIcon = icons[this.tab] || 'file'; + var titles = {recents:'No recent pages',favorites:'No favorites',shared:'No shared pages', + published:'No published pages',private:'No private pages',workspace:'No workspace pages', + repository:'Empty repository'}; + this.emptyTitle = titles[this.tab] || 'No items'; + var texts = {recents:'Pages you\'ve recently viewed or edited will appear here.', + favorites:'Right-click a page in the sidebar and select "Add to Favorites".', + shared:'Shared pages from your team will appear here.', + published:'Pages published to the web will appear here.', + private:'Create a new page with the + button.', + workspace:'Pages inside workspaces will appear here.', + repository:'Repository files and pages will appear here.'}; + this.emptyText = texts[this.tab] || ''; + }, + + // ── Init ── + init() { + window._libData = this; // expose for onclick handlers + console.log('[Library] init start'); + var wsEl = document.getElementById('lib-workspace-id'); + this.workspaceId = parseInt(wsEl ? wsEl.textContent : '0') || 0; + var giteaEl = document.getElementById('lib-gitea-active'); + this.isGiteaActive = (giteaEl ? giteaEl.textContent : '0') === '1'; + this.giteaOwner = (document.getElementById('lib-gitea-owner') || {}).textContent || ''; + this.giteaRepo = (document.getElementById('lib-gitea-repo') || {}).textContent || ''; + // Detect no workspace: no local wsId AND no Gitea active + if (!this.workspaceId && !this.isGiteaActive) { + this.noWorkspace = true; + this.loading = false; // don't show loading spinner + return; + } + console.log('[Library] workspaceId=' + this.workspaceId + ' isGitea=' + this.isGiteaActive); + var params = new URLSearchParams(window.location.search); + var t = params.get('tab'); + if (t && ['recents','favorites','shared','published','private','workspace','repository'].indexOf(t) !== -1) { + this.tab = t; + } + this._recomputeEmpty(); + this.loadWorkspaceTags(); + console.log('[Library] loading tab: ' + this.tab); + this.loadTab(); + }, + + switchTab(t) { + if (this.tab === t) return; + if (t === 'repository' && !this.isGiteaActive) return; + this.tab = t; + this.shareDir = 'all'; + this.tagFilter = ''; + this.items = []; + this.selected = {}; + this.selectedCount = 0; + this.allSelected = false; + this.expanded = {}; + this.searchQuery = ''; + this.searchOpen = false; + this._recomputeEmpty(); + this.updateUrl(); + this.loadTab(); + }, + + updateUrl() { + var p = new URLSearchParams(); + if (this.tab !== 'recents') p.set('tab', this.tab); + var qs = p.toString(); + window.history.replaceState({}, '', qs ? '/library?' + qs : '/library'); + }, + + // ── Load ── + async loadTab() { + this.loading = true; + try { + var url = '/api/library/' + this.tab; + var sep = '?'; + if (this.workspaceId && (this.tab === 'recents' || this.tab === 'workspace')) { + url += sep + 'workspace_id=' + this.workspaceId; sep = '&'; + } + if (this.tab === 'repository' && this.isGiteaActive) { + url += sep + 'gitea_owner=' + encodeURIComponent(this.giteaOwner) + '&gitea_repo=' + encodeURIComponent(this.giteaRepo); + sep = '&'; + } else if (this.tab !== 'repository' && this.tab !== 'recents' && this.tab !== 'favorites') { + // Hierarchical tree view: show only root-level pages at the top + url += sep + 'tree=1'; + sep = '&'; + } else if (this.tab !== 'repository' && this.tab !== 'workspace' && this.sourceFilter && this.sourceFilter !== 'all') { + url += sep + 'source_type=' + encodeURIComponent(this.sourceFilter); sep = '&'; + } + if (this.tab === 'shared' && this.shareDir && this.shareDir !== 'all') { + url += sep + 'dir=' + encodeURIComponent(this.shareDir); sep = '&'; + } + var r = await fetch(url); + var d = await r.json(); + // Backend already computes has_children correctly for each item (children + // are loaded lazily on expand) — do NOT overwrite it here. + this.items = d.items || []; + this._recomputeFlatItems(); + this._renderTable(); + var self = this; + this.$nextTick(function() { self.loading = false; }); + } catch(e) { + console.error('[Library] load error:', e); + this.items = []; + this.loading = false; + } + }, + + doSearch() { this._recomputeFlatItems(); }, + + setSourceFilter(v) { + this.sourceFilter = v; + this.filterOpen = false; + this.loadTab(); + }, + + setShareDir(v) { + if (this.shareDir === v) return; + this.shareDir = v; + this.loadTab(); + }, + + setSort(field) { + this.sortBy = field; + this.sortOpen = false; + this.doSort(); + }, + + toggleColumn(col) { + if (col === 'author') this.showAuthor = !this.showAuthor; + else if (col === 'source') this.showSource = !this.showSource; + }, + + doSort() { + var s = this.sortBy; + if (!s) return; + var self = this; + function sortNodes(nodes) { + if (!nodes) return; + nodes.sort(function(a,b){ + if (s === 'name') return (a.title||'').localeCompare(b.title||''); + if (s === 'edited') return (b.updated_at||'').localeCompare(a.updated_at||''); + if (s === 'author') return (a.author||'').localeCompare(b.author||''); + return 0; + }); + for (var i = 0; i < nodes.length; i++) { + if (nodes[i].children && nodes[i].children.length) sortNodes(nodes[i].children); + } + } + sortNodes(this.items); + this._recomputeFlatItems(); + }, + + // ── Tree expand ── + async toggleExpand(item) { + if (this.expanded[item.id]) { + // Collapse + delete this.expanded[item.id]; + this.expanded = Object.assign({}, this.expanded); + this._recomputeFlatItems(); + this._renderTable(); + return; + } + // Expand — load children if not loaded yet + this.expanded[item.id] = true; + this.expanded = Object.assign({}, this.expanded); + if (!item.children || !item.children.length) { + try { + var r = await fetch('/api/library/children/' + item.id); + var d = await r.json(); + item.children = d.items || []; + // Mark the tree for depth calculation + this._markChildren(item.children, (item.depth || 0) + 1); + } catch(e) { + item.children = []; + } + } + this._recomputeFlatItems(); + this._renderTable(); + }, + + _markChildren(nodes, depth) { + if (!nodes) return; + for (var i = 0; i < nodes.length; i++) { + nodes[i].depth = depth; + if (nodes[i].children && nodes[i].children.length > 0) { + this._markChildren(nodes[i].children, depth + 1); + } + } + }, + + // ── Selection ── + toggleItem(item) { + if (this.selected[item.id]) { + delete this.selected[item.id]; + } else { + this.selected[item.id] = true; + } + this.selected = Object.assign({}, this.selected); + this._recomputeFlatItems(); + this._renderTable(); + }, + + toggleSelectAll() { + if (this.allSelected) { + this.selected = {}; + } else { + var s = {}; + for (var i = 0; i < this.flatItems.length; i++) { + s[this.flatItems[i].id] = true; + } + this.selected = s; + } + this._recomputeFlatItems(); + this._renderTable(); + }, + + clearSelection() { + this.selected = {}; + this.moreOpen = false; + this._recomputeFlatItems(); + this._renderTable(); + }, + + // ── Multi-select actions ── + async deleteSelected() { + var ids = Object.keys(this.selected); + if (!ids.length) return; + if (!confirm('Move ' + ids.length + ' item(s) to Trash?')) return; + var csrf = this._getCsrf(); + for (var i = 0; i < ids.length; i++) { + await fetch('/board/api/pages/' + ids[i], {method:'DELETE', headers:{'X-CSRF-Token': csrf}}); + } + this.clearSelection(); + this.loadTab(); + this._syncSidebar(); + if (window.showToast) window.showToast('Moved to trash', 'success'); + }, + + async deleteItem(item) { + if (!item) return; + if (!confirm('Move "' + item.title + '" to Trash?')) return; + var csrf = this._getCsrf(); + await fetch('/board/api/pages/' + item.id, {method:'DELETE', headers:{'X-CSRF-Token': csrf}}); + this.loadTab(); + this._syncSidebar(); + if (window.showToast) window.showToast('Moved to trash', 'success'); + }, + + removeFromRecents() { + // No server-side "visited" tracking exists yet — remove from the + // current view only (session-scoped). + this.moreOpen = false; + var ids = Object.keys(this.selected).map(Number); + if (!ids.length) return; + this.items = this.items.filter(function(it) { return ids.indexOf(it.id) === -1; }); + this.clearSelection(); + this._recomputeFlatItems(); + }, + + copyLinks() { + this.moreOpen = false; + var ids = Object.keys(this.selected); + var links = ids.map(function(id) { return window.location.origin + '/pages/' + id; }); + navigator.clipboard.writeText(links.join('\n')); + if (window.showToast) window.showToast('Links copied', 'success'); + }, + + copyLink(item) { + if (!item) return; + navigator.clipboard.writeText(window.location.origin + '/pages/' + item.id); + if (window.showToast) window.showToast('Link copied', 'success'); + }, + + openInNewTab(item) { + if (!item || !item.id) return; + window.open('/pages/' + item.id, '_blank'); + }, + + removeItemFromRecents(item) { + if (!item) return; + var id = item.id; + this.items = this.items.filter(function(it) { return it.id !== id; }); + this._recomputeFlatItems(); + if (window.showToast) window.showToast('Removed from Recents', 'success'); + }, + + async setItemIcon(item, icon) { + if (!item || !item.id) return; + icon = (icon || '').trim(); + try { + var r = await fetch('/board/api/pages/' + item.id + '/icon', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this._getCsrf()}, + body: JSON.stringify({icon: icon}) + }); + if (!r.ok) throw new Error('icon update failed'); + item.page_icon = icon; + this._renderTable(); + if (window.showToast) window.showToast('Icon updated', 'success'); + } catch(e) { + if (window.showToast) window.showToast('Failed to update icon', 'error'); + } + }, + + // ── Tags ── + async loadWorkspaceTags() { + try { + var r = await fetch('/api/settings/tags/all'); + var d = await r.json(); + this.workspaceTags = d.tags || []; + } catch(e) { this.workspaceTags = []; } + }, + + toggleTagFilter(tagName) { + this.tagFilter = (this.tagFilter === tagName) ? '' : tagName; + this._recomputeFlatItems(); + }, + + _ctxAvailTags(item) { + return (this.workspaceTags || []).filter(function(t) { + return !((item && item.tags) || []).some(function(nt) { return nt.id === t.id; }); + }); + }, + + _ctxRefreshAvail() { + var store = window.Alpine && Alpine.store('fdCtx'); + if (store && store.node) store.setAvail(this._ctxAvailTags(store.node)); + }, + + ctxAddExistingTag(tag) { + this.ctxAddNewTag(tag.name, tag.color); + }, + + ctxRemoveTag(tagId) { + var store = window.Alpine && Alpine.store('fdCtx'); + var item = store && store.node; + if (!item) return; + var self = this; + fetch('/api/local-workspace/items/' + item.id + '/tags/' + tagId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' }) + .then(function(r) { + if (!r.ok) return; + item.tags = (item.tags || []).filter(function(t) { return t.id !== tagId; }); + self.loadWorkspaceTags(); + self._ctxRefreshAvail(); + self._recomputeFlatItems(); + }); + }, + + async ctxAddNewTag(tagName, color) { + tagName = (tagName || '').trim(); + if (!tagName) return; + var store = window.Alpine && Alpine.store('fdCtx'); + var item = store && store.node; + if (!item) return; + color = color || (store && store.newTagColor) || '#787774'; + try { + var r = await fetch('/api/local-workspace/items/' + item.id + '/tags', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, + body: JSON.stringify({name: tagName, color: color}) + }); + if (r.ok) { + var d = await r.json(); + var cur = item.tags || []; + if (!cur.some(function(t) { return t.id === d.tag.id; })) item.tags = cur.concat([d.tag]); + await this.loadWorkspaceTags(); + this._ctxRefreshAvail(); + this._recomputeFlatItems(); + } + } catch(e) {} + }, + + async toggleFavoriteItem(item) { + if (!item) return; + var csrf = this._getCsrf(); + var method = item.favorited ? 'DELETE' : 'POST'; + var r = await fetch('/board/api/favorites/' + item.id, { method: method, headers: {'X-CSRF-Token': csrf} }); + if (r.ok) { + item.favorited = !item.favorited; + this._renderTable(); + if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites(); + if (this.tab === 'favorites') this.loadTab(); + } + }, + + async duplicateItem(item) { + if (!item) return; + try { + var r = await fetch('/board/api/pages/' + item.id); + var page = await r.json(); + var csrf = this._getCsrf(); + var section = page.parent_section || 'Private'; + var parentId = page.parent_id || 0; + var ws = page.workspace || ''; + var createUrl = '/board/api/pages?title=' + encodeURIComponent((page.title || 'Untitled') + ' (Copy)') + + '§ion=' + encodeURIComponent(section) + '&project=' + encodeURIComponent(ws) + + '&parent_id=' + parentId; + var cr = await fetch(createUrl, { method: 'POST', headers: {'X-CSRF-Token': csrf} }); + var created = await cr.json(); + if (page.content) { + var updUrl = '/board/api/pages/' + created.id + '?content=' + encodeURIComponent(page.content) + + '&content_format=' + encodeURIComponent(page.content_format || ''); + await fetch(updUrl, { method: 'PUT', headers: {'X-CSRF-Token': csrf} }); + } + this.loadTab(); + this._syncSidebar(); + if (window.showToast) window.showToast('Page duplicated', 'success'); + } catch(e) { + if (window.showToast) window.showToast('Failed to duplicate page', 'error'); + } + }, + + // ── Move to (picker modal) ── + async openMovePicker(ids) { + this.moreOpen = false; + this.peekMoreOpen = false; + if (!ids || !ids.length) return; + this.moveIds = ids; + this.moveSearchQuery = ''; + this.moveModalOpen = true; + try { + var r = await fetch('/api/library/recents?workspace_id=' + (this.workspaceId || 0)); + var d = await r.json(); + var all = (d.items || []).filter(function(it) { return ids.indexOf(it.id) === -1; }); + this._moveAllCandidates = all; + this.moveCandidates = all.slice(0, 30); + } catch(e) { + this._moveAllCandidates = []; + this.moveCandidates = []; + } + }, + + doMoveSearch() { + var q = (this.moveSearchQuery || '').toLowerCase().trim(); + if (!q) { this.moveCandidates = this._moveAllCandidates.slice(0, 30); return; } + this.moveCandidates = this._moveAllCandidates.filter(function(it) { + return (it.title || '').toLowerCase().indexOf(q) !== -1; + }).slice(0, 30); + }, + + async confirmMove(parentId) { + this.moveModalOpen = false; + var ids = this.moveIds; + this.moveIds = []; + if (!ids || !ids.length) return; + await this._movePages(ids, parentId); + if (window.showToast) window.showToast('Page moved', 'success'); + }, + + // ── Item actions ── + openItem(item) { + if (!item || !item.id) return; + if (window.fdNavigate) window.fdNavigate('/pages/' + item.id); + else window.location.href = '/pages/' + item.id; + }, + + startRename(item) { + this.renamingId = item.id; + this.renameValue = item.title; + this._renderTable(); + // Focus the rename input + setTimeout(function() { + var inp = document.getElementById('rename-inp-' + item.id); + if (inp) { inp.focus(); inp.select(); } + }, 50); + }, + + async commitRename(item) { + var newName = (this.renameValue || '').trim(); + this.renamingId = null; + if (!newName || newName === item.title) { this._renderTable(); return; } + try { + var csrf = this._getCsrf(); + var r = await fetch('/board/api/pages/' + item.id + '?title=' + encodeURIComponent(newName), { + method: 'PUT', headers: {'X-CSRF-Token': csrf} + }); + if (r.ok) { + item.title = newName; + } + } catch(e) {} + this._renderTable(); + this._syncSidebar(); + }, + + // ── Add new ── + async addNewPage(parentId) { + var name = prompt('Page name:'); + if (!name || !name.trim()) return; + var csrf = this._getCsrf(); + var section = 'Private'; + try { + var url = '/board/api/pages?title=' + encodeURIComponent(name.trim()) + + '§ion=' + encodeURIComponent(section) + + '&parent_id=' + (parentId || 0); + var r = await fetch(url, { method: 'POST', headers: {'X-CSRF-Token': csrf} }); + if (r.ok) { + var d = await r.json(); + if (parentId) { + // Refresh the parent's children so the new page appears inline + var parent = this._findItem(parentId); + if (parent) { + this.expanded[parentId] = true; + try { + var cr = await fetch('/api/library/children/' + parentId); + var cd = await cr.json(); + parent.children = cd.items || []; + } catch(e) {} + this._recomputeFlatItems(); + this._renderTable(); + return; + } + } + if (window.fdNavigate) window.fdNavigate('/pages/' + d.id); + else window.location.href = '/pages/' + d.id; + } + } catch(e) { + if (window.showToast) window.showToast('Failed to create page', 'error'); + } + }, + + newPage() { this.addNewPage(); }, + + // ── Side peek ── + async openPeek(item) { + this.peekItem = item; + this.peekFavorited = item.favorited || false; + }, + + closePeek() { + this.peekItem = null; + }, + + async toggleFavoritePeek() { + if (!this.peekItem) return; + var id = this.peekItem.id; + var csrf = this._getCsrf(); + var method = this.peekFavorited ? 'DELETE' : 'POST'; + var r = await fetch('/board/api/favorites/' + id, { + method: method, headers: {'X-CSRF-Token': csrf} + }); + if (r.ok) { + this.peekFavorited = !this.peekFavorited; + this.peekItem.favorited = this.peekFavorited; + if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites(); + } + }, + + copyPeekLink() { + if (!this.peekItem) return; + navigator.clipboard.writeText(window.location.origin + '/pages/' + this.peekItem.id); + if (window.showToast) window.showToast('Link copied', 'success'); + }, + + duplicatePeek() { + this.peekMoreOpen = false; + if (!this.peekItem) return; + this.duplicateItem(this.peekItem); + }, + + movePeekTo() { + this.peekMoreOpen = false; + if (!this.peekItem) return; + this.openMovePicker([this.peekItem.id]); + }, + + async deletePeek() { + this.peekMoreOpen = false; + if (!this.peekItem) return; + if (!confirm('Move to Trash?')) return; + var csrf = this._getCsrf(); + await fetch('/board/api/pages/' + this.peekItem.id, {method:'DELETE', headers:{'X-CSRF-Token': csrf}}); + this.closePeek(); + this.loadTab(); + this._syncSidebar(); + if (window.showToast) window.showToast('Moved to trash', 'success'); + }, + + // ── Time formatting ── + formatTime(d) { + if (!d) return '—'; + try { + var dt = new Date(d); + if (isNaN(dt.getTime())) return d; + var now = new Date(); + var diff = now - dt; + var mins = Math.floor(diff/60000); + if (mins < 1) return 'Just now'; + if (mins < 60) return mins + 'm ago'; + var hours = Math.floor(mins/60); + if (hours < 24) return hours + 'h ago'; + var days = Math.floor(hours/24); + if (days < 7) return days + 'd ago'; + if (days < 30) return Math.floor(days/7) + 'w ago'; + return dt.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}); + } catch(e) { return d; } + } + }; +} +// Init peek resize handle +(function() { + var panel = document.querySelector('.peek-overlay'); + var handle = document.getElementById('lib-peek-resize-handle'); + if (!panel || !handle) return; + var startX, startWidth, dragging = false; + handle.addEventListener('pointerdown', function(e) { + e.preventDefault(); + handle.setPointerCapture(e.pointerId); + startX = e.clientX; + startWidth = panel.offsetWidth; + dragging = true; + document.body.style.userSelect = 'none'; + }); + handle.addEventListener('pointermove', function(e) { + if (!dragging) return; + var deltaX = startX - e.clientX; + var newWidth = Math.max(300, Math.min(window.innerWidth * 0.9, startWidth + deltaX)); + panel.style.width = newWidth + 'px'; + }); + handle.addEventListener('pointerup', function(e) { + if (!dragging) return; + document.body.style.userSelect = ''; + var moved = Math.abs(e.clientX - startX); + if (moved < 3) { + var el = document.querySelector('.peek-overlay'); + if (el && el.__x) { el.__x.$data.peekItem = null; } + } + dragging = false; + try { localStorage.setItem('fd_peek_width', panel.style.width); } catch(ex) {} + handle.releasePointerCapture(e.pointerId); + }); + try { + var saved = localStorage.getItem('fd_peek_width'); + if (saved) panel.style.width = saved; + } catch(ex) {} +})(); diff --git a/static/js/workspaces.js b/static/js/workspaces.js new file mode 100644 index 0000000..2a7542c --- /dev/null +++ b/static/js/workspaces.js @@ -0,0 +1,153 @@ + +function workspacesPage() { + return { + workspaces: [], + activeId: null, + showCreate: false, + showRename: false, + wsName: '', + renameTarget: null, + + // Gitea + giteaStatus: 'loading', // loading|ok|not_linked|error + giteaGroups: [], + giteaTotal: 0, + activeOrg: 'all', + giteaSearch: '', + + get filteredGroups() { + var self = this; + var q = (this.giteaSearch || '').toLowerCase(); + var groups = this.activeOrg === 'all' ? this.giteaGroups : this.giteaGroups.filter(function(g) { return g.org === self.activeOrg; }); + if (!q) return groups; + return groups.map(function(g) { + return {org: g.org, avatar: g.avatar, projects: g.projects.filter(function(p) { + return p.name.toLowerCase().includes(q) || (p.description||'').toLowerCase().includes(q); + })}; + }).filter(function(g) { return g.projects.length > 0; }); + }, + + async init() { + await this.load(); + await this.loadGitea(); + }, + + async load() { + const r = await fetch('/api/workspaces'); + const d = await r.json(); + this.workspaces = d.workspaces || []; + this.activeId = d.active_id; + }, + + async selectLocal(ws) { + await fetch(`/api/workspaces/${ws.id}/select`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'POST'}); + window.location = '/local-workspace'; + }, + + async doCreate() { + if (!this.wsName.trim()) return; + await fetch('/api/workspaces', { + method:'POST', + headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, + body:JSON.stringify({name:this.wsName.trim()}) + }); + this.wsName = ''; + this.showCreate = false; + await this.load(); + }, + + renameWs(ws) { + this.renameTarget = ws; + this.wsName = ws.name; + this.showRename = true; + }, + + async doRename() { + if (!this.wsName.trim()||!this.renameTarget) return; + await fetch(`/api/workspaces/${this.renameTarget.id}`, { + method:'PUT', + headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, + body:JSON.stringify({name:this.wsName.trim()}) + }); + this.wsName = ''; + this.showRename = false; + this.renameTarget = null; + await this.load(); + }, + + async deleteWs(ws) { + if (!confirm(`Delete workspace "${ws.name}" and all its pages?`)) return; + await fetch(`/api/workspaces/${ws.id}`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE'}); + await this.load(); + }, + + // ── Gitea ── + + async loadGitea() { + this.giteaStatus = 'loading'; + try { + // Get orgs + const orgsRes = await fetch('/api/gitea/orgs'); + if (orgsRes.status === 401) { this.giteaStatus = 'not_linked'; return; } + if (!orgsRes.ok) { this.giteaStatus = 'error'; return; } + const orgsData = await orgsRes.json(); + const orgs = orgsData.orgs || []; + + // Fetch repos: user repos + org repos + const groups = []; + + // Get username for the "personal" tab + let myLogin = 'Me'; + try { + const meRes = await fetch('/auth/user'); + if (meRes.ok) { + const meData = await meRes.json(); + myLogin = meData.user?.login || 'Me'; + if (myLogin.includes('_')) myLogin = myLogin.split('_').pop(); // strip gitea_ prefix if present + } + } catch(e) {} + + // User repos (no org filter) + try { + const userRes = await fetch('/api/gitea/projects'); + if (userRes.ok) { + const d = await userRes.json(); + const repos = d.projects || []; + if (repos.length) { + groups.push({org: myLogin, avatar: '', projects: repos.map(r => ({...r, owner: r.owner || {login: myLogin}}))}); + } + } + } catch(e) {} + + // Org repos + for (const org of orgs) { + try { + const res = await fetch('/api/gitea/projects?org=' + encodeURIComponent(org.username || org.login || org.name)); + if (res.ok) { + const d = await res.json(); + if (d.projects && d.projects.length) { + groups.push({org: org.username || org.login || org.name, avatar: org.avatar_url || '', projects: d.projects}); + } + } + } catch(e) {} + } + + this.giteaGroups = groups; + this.giteaTotal = groups.reduce((sum, g) => sum + g.projects.length, 0); + this.giteaStatus = 'ok'; + } catch(e) { + this.giteaStatus = 'error'; + } + }, + + openGitea(proj) { + const owner = proj.owner?.login || proj.owner?.username || proj.full_name?.split('/')[0] || ''; + const repo = proj.name; + if (owner && repo) { + // Set workspace cookie so sidebar shows tree + document.cookie = 'flowdeck_workspace=gitea:' + owner + ':' + repo + ';path=/;SameSite=Lax'; + window.location = `/gitea-workspace?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`; + } + } + }; +}