Compare commits

...
14 Commits
Author SHA1 Message Date
bruno ee1d46e965 fix: A27 phase 2a — éditeur 2 516 L extrait via page-data JSON (v7.22.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- `_page_editor_scripts.html` : le gros bloc interpolé (2 516 L) part vers
  `static/js/page_editor_scripts.js` — recette « config JSON » : les 8
  interpolations Jinja lisent `PD = JSON.parse(#page-data)`, bloc JSON qui
  EXISTAIT DÉJÀ juste avant le script (même ordre d'exécution), garde
  `__fdEditorScriptsLoaded` préservée, node --check vert.
- Route `view_page_root` : page_data enrichi de updated_at, created_at,
  user_id, is_shared (dérivé HOISTÉ : une seule expression sert le ctx ET le
  JSON) et clip_icon (macro fd_icon rendue côté serveur). workspace_key reste
  vide comme avant (jamais défini dans ce ctx → parité stricte).

8 tests adaptés à l'extraction (ils lisaient le template SOURCE) :
- test_ai_writing ×2 (+ helper _read_js), test_pwa_offline,
  test_v511 front_end_wired, test_v55 ×3 → lisent le JS extrait
- test_page_editor_renders_page_is_shared → parsing du JSON #page-data
  (`is_shared is True`) — la valeur sert toujours à la page

Cumul A27 : 6 759 L extraites (13 904 → 7 145 inline). Reste : local_workspace
2 031, base 1 523 (structurel {% for %}/{% block %}), database_table 1 323,
settings 1 093, realtime 531, board 146 ≈ 6 653 L + 120 warnings eslint.

suite **1089/1089** · ruff OK · docs à jour
2026-10-01 16:12:26 -04:00
bruno 587ec8d61b fix: A27 phase 1 — 4 243 L de JS inline extraites + eslint actif (v7.21.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Extraction des 7 templates dont le JS n'est PAS interpolé Jinja → 9 fichiers
static/js/*.js (4 243 lignes, -30 % du JS inline : 13 904 → 9 661) :
- agent_panel_1/_2 (bloc de 1 788 L livré sur CHAQUE page), library (1 039),
  gitea_workspace (626), _icon_picker_1/_2, _ctx_menu, import, workspaces
- UN fichier par bloc : ordre/timing identiques (pas de defer, attributs
  conservés dont data-cfasync), cache-busting via ?v={{ asset_version }}
  (source unique A40), scripts externes = 'self' en CSP (pas de nonce requis)
- garde-fou : le script refuse tout bloc contenant {{ ou {%
- vérifs : node --check vert sur les 9, 0 script inline restant dans les
  cibles, suite complète 1089/1089

Lint (la moitié « ajouter les templates à eslint » de l'audit) :
- eslint.config.mjs existait (flat v9, sans dépendances npm) mais AUCUN
  binaire eslint n'était installé → npm i -g eslint
- `eslint static/js` → 0 erreur, 120 warnings (no-unused-vars 69,
  no-empty 36, no-undef 15) sur 8 fichiers = baseline à nettoyer
- les extraits sont couverts d'office par la config (static/js/**/*.js)

Reste A27 : blocs interpolés Jinja (page_editor 2 517, local_workspace 2 031,
base 1 523, database_table 1 323, settings 1 093, realtime 531 ≈ 9 661 L)
→ extraction en 2 temps (config JSON injectée + script statique).

suite **1089/1089** · ruff OK · docs à jour
2026-10-01 15:55:09 -04:00
bruno 7a38ddd0f6 test: A32 TERMINÉ — 6 routes Gitea stubbées + bug prod fd_icon (v7.20.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Les 6 dernières routes d'A32 (api.py, gitea) avec stub de transport — zéro
réseau réel :

- _stub_gitea() : stubs manuels sur gitea_client.gitea (create_issue,
  update_issue, update_issue_labels, get_issue, get_issue_comments) avec
  ÉTAT MUTABLE PARTAGÉ — le handler PATCH re-fetch l'issue via get_issue,
  un canevas figé aurait masqué la mise à jour.
- POST /issues : carte INSÉRÉE sur le board (board seedé par endpoint) ;
  PATCH : colonne recalculée sans perdre la carte.
- GET /issues JSON + HTML : ?format=html requis (le segment /html ne fixe pas
  le paramètre, le handler le lit dans la query) ; stub qui lève → 404.
- POST /checklists + POST /checklist-items : lignes vérifiées en base,
  404 sans board ; cleanup (items → checklists).

BUG PROD corrigé (trouvé par le smoke HTML) : card_detail.html utilisait la
macro fd_icon SANS l'importer → UndefinedError → 500 systématique sur
GET /api/issues/...?format=html (seul rendu du template dans le code).
Fix : {% from '_icons.html' import fd_icon %}.

A32 COMPLET : plus aucun router « 0 test » (webhooks, notes, sidebar_config,
github_routes, library, api, dashboard, api_v2 tous couverts).

test_smoke_uncovered.py : 52 tests. suite **1089/1089** · ruff OK · docs à jour
2026-10-01 15:39:21 -04:00
bruno 113374e499 test: A32 phase 2h — dashboard bloqué : 44/44 routes à 0 ref (v7.19.0)
FlowDeck CI / test (push) Failing after 3h12m50s
FlowDeck CI / lint (push) Successful in 1m54s
FlowDeck CI / docker (push) Skipped
+5 routes dashboard (fichier test_smoke_uncovered.py à 49 tests) :

- Members POST/PUT/DELETE : invitation de soi-même dans un workspace dédié
  (_own_workspace), rôle admin relu en base, membre supprimé (COUNT=0).
  Quirk documenté : les retours tuple des routes (`{"error": ...}, 400`)
  sont sérialisés FastAPI en tableau + 200 → assert sur `[0]["error"]`.
- upload-folder : validations SEULES (structure absente → 400 « No
  structure provided », JSON cassé → 400 « Invalid structure JSON ») —
  zéro fichier écrit, workspace dédié nettoyé.
- convert-to-database : collection + propriété title + vue table + page en
  content_format='collection' VÉRIFIÉS en base, 404 page inconnue,
  cleanup dans l'ordre FK (pages avant collections — IntegrityError corrigée).

Recoupement final : scan des 44 routes strictement à 0 ref de dashboard.py →
TOUTES exercées. Les 19 résidus du scan sont des faux positifs (paths en
f-string dans les tests : /api/workspace/1/…, f"/api/pages/{id}/…", …)
rapprochés manuellement un par un.

Reste A32 : les 6 routes Gitea d'api.py (issues ×4, créations checklists)
→ stub de transport httpx (effort S).

suite **1086/1086** · `ruff check app tests` OK · docs à jour
2026-10-01 15:24:38 -04:00
bruno 0cb476e336 test: A32 phase 2g — dashboard +13 routes, cycles items/tags (v7.18.0)
FlowDeck CI / lint (push) Successful in 1m58s
FlowDeck CI / test (push) Successful in 14m55s
FlowDeck CI / docker (push) Canceled after 0s
Cumul dashboard : 43 → 56 des 63 routes. 4 nouveaux tests (fichier à 46) :

- GET /gitea-workspace : page HTML (200 ou redirection propre)
- workspace/projects GET+POST : shape {builtin, gitea, github} avec
  github == [] ; projet créé RETROUVÉ dans builtin ; quirk « error » sans nom
  ; nettoyage (DELETE page)
- Cycle items local-workspace (5 routes) : POST création (titre relu),
  PUT rename (relu en base), PUT move, DELETE soft-delete (deleted_at relu),
  POST restore (deleted_at NULL relu) — nettoyage finally
- Cycle tags d'item (5 routes) : POST (urgenta32 lowercasé), tags de l'item,
  liste workspace, search (shape), suppression vérifiée. Utilisateur DÉDIÉ +
  workspace créé dans le test (le endpoint /api/local-workspace/tags exige un
  workspace actif : fallback « premier workspace du user » — on n'attache pas
  ce workspace à l'utilisateur fixture partagé), tout est nettoyé.

Reste A32 : dashboard 7 routes (members invite/role/unsubscribe,
upload-folder, convert-to-database) + 6 routes Gitea d'api.py (stub httpx).

suite **1083/1083** · `ruff check app tests` OK · docs à jour
2026-10-01 15:11:45 -04:00
bruno 2339fa2586 test: A32 phase 2f — dashboard +7 routes, garde-fous A16 (v7.17.0)
FlowDeck CI / lint (push) Successful in 1m52s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 5m54s
Cumul dashboard : 36 → 43 des 63 routes. 4 nouveaux tests (fichier à 42),
centrés sur les garde-fous A16 :

- GET /api/files/{ws}/{path} : traversal encodé %2e%2e%2f → 403
  « Path traversal denied » (décodé par Starlette puis bloqué par resolve) ;
  inexistant → 404 ; vrai fichier écrit dans le data_dir de test →
  200 + octets exacts, nettoyé en finally
- GET /api/pages/{id}/download : page markdown → 404 « No downloadable file »
  (pas de 500) ; page « file » avec chemin ../ sortant de la racine →
  jamais 200 ; file-content → 404/415 sans fuite
- GET /api/local-workspace/page-content/{id} : contenu + format relus,
  404 sur id inconnu
- GET /api/avatar/{id} : 302 + Location avec follow_redirects=False
  (AUCUNE requête réelle vers l'URL externe), 404 sans avatar
- GET/POST /api/collections/{id}/table-data|pages : 404 inconnu, shape,
  ligne créée retrouvée dans table-data, nettoyage finally

Reste A32 : dashboard 20 routes (upload/local-workspace items/members/
projects/HTML gitea) + 6 routes Gitea d'api.py (stub transport httpx).

suite **1079/1079** · `ruff check app tests` OK · docs à jour
2026-10-01 14:40:06 -04:00
bruno 8b48dbdd4b test: A32 phase 2e — dashboard +9 routes, comptes A2/A3 (v7.16.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Cumul dashboard : 27 → 36 des 63 routes. 6 nouveaux tests (fichier à 38) :

- /accounts + /accounts/settings : 200 HTML et « password_hash » ABSENT du
  rendu (whitelist A2 vérifiée côté page)
- PUT /api/user/profile : persistance relue en base, restauration finally
- PUT /api/user/password : 403 « current password is incorrect » (A3 — la
  session seule ne change pas le mdp) + quirk assumé documenté : la longueur
  est validée AVANT l'auth et répond 200 + message
- POST /api/user/token : format fd_ + 64 hex ; ligne user_tokens nettoyée
- DELETE /api/user/forge/{provider} : {"status": "ok"}
- PUT /api/settings/account : full_name/email persistés + 400 sur mdp court,
  restauration finally
- POST /api/workspaces/1/select : Set-Cookie flowdeck_workspace vérifié ;
  GET /api/local-workspace/breadcrumb : shape liste

Reste A32 : dashboard 27 routes (fichiers/avatars/local-workspace/collections)
+ 6 routes Gitea d'api.py (stub transport httpx).

suite **1075/1075** · `ruff check app tests` OK · docs à jour
2026-10-01 13:59:00 -04:00
bruno 360c705fd4 test: A32 phase 2d — dashboard +10 routes couvertes (v7.15.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Scan strict dashboard.py : 44 routes à 0 référence stricte sur 63. 10
couvertes cette passe (test_smoke_uncovered.py → 32 tests) :

- Tags CRUD complet : POST (nom lowercasé `SmokeTag` → `smoketag`), GET all
  (présent), PUT couleur (relue), DELETE (absente de la liste ensuite)
- Vie d'une page : GET /api/pages/{id}/content (contenu seedé relu) →
  PUT rename (ok + **400 titre vide** + titre relu en base) →
  POST trash (parent_section='Trash' + deleted_at RELUS en base) ;
  nettoyage en finally
- GET /api/sidebar/workspace-tree : 200 HTML, fragment « No pages yet »
  (pas de cookie workspace)
- POST /api/settings/avatar-color : couleur relue SUR L'UTILISATEUR DE LA
  SESSION (pas LIMIT 1), avatar_color/avatar_url d'origine restaurés
- GET /api/workspace/1/members : shape {"members": [...]}

Helper _seed_page : les colonnes par défaut sont surchargeables (content=)
pour les seeds à contenu.

Reste A32 : dashboard 34 routes à 0 ref (fichiers/avatars/imports…) +
6 routes Gitea d'api.py (stub transport httpx).

suite **1069/1069** · `ruff check app tests` OK · docs à jour
2026-10-01 13:36:56 -04:00
bruno 0698645dbd test: A32 phase 2c — api_v2 : les 5 routes à 0 ref couvertes (v7.14.0)
FlowDeck CI / lint (push) Successful in 1m52s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 34m59s
Scan strict des 115 routes api_v2.py contre tous les tests (chaîne de chemin
littérale) → 5 routes sans AUCUNE référence, toutes couvertes maintenant :

- POST /properties/evaluate-formula : 200 + shape, 400 sans expression.
  Le moteur renvoie « 1 + 2 » tel quel aujourd'hui → le smoke valide le câble
  (bearer, Body param, parse), pas le moteur (réalm de ses propres tests).
- POST /properties/compute-rollup : 400 « collection_id required »,
  401 sans bearer.
- GET /admin/audit-logs : portail admin VÉRIFIÉ — l'attendu est calculé
  depuis /users/me (le tout premier utilisateur d'un worker est admin :
  état non contrôlable depuis un test isolé), + token scope admin → 200 + logs.
- GET /webhooks/events : catalogue non vide + wildcards * / page.*.
- POST /webhooks/verify-signature : valid=True avec sign_payload() (le même
  helper que le serveur), False avec signature bidon.

test_smoke_uncovered.py : 27 tests. Reste A32 : dashboard 17/63 + 6 routes
gitea d'api.py (stub transport).

suite **1064/1064** · `ruff check app tests` OK · docs à jour
2026-10-01 13:09:32 -04:00
bruno b2e38aece7 test: A32 phase 2b — api.py 3 → 16/22 routes couvertes (v7.13.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
+6 smokes dans test_smoke_uncovered.py (22 tests au total dans le fichier) :
- board-config GET/POST : défauts 5 colonnes sans board, création puis
  relecture du roundtrip (seed via l'endpoint lui-même, pas de SQL brut)
- col-mapping POST/DELETE : 404 sans board, upsert label vérifié, suppression
- card POST : 404 sans board, ok avec
- collaborators GET : gitea.get_collaborators STUBBÉ (zéro accès réseau réel)
- frontend-error(s) : capture, JSON invalide → ignored, DÉDUP d'une erreur
  répétée (count=2), lecture qui purge (cleared=true puis 0)
- checklist mutations : PATCH item (checked/content relus EN BASE), DELETE
  item, DELETE checklist (COUNT=0) — seed + cleanup en finally

Reste api.py : 6 routes Gitea (issues ×4 + créations checklists owner/repo) →
stub de transport httpx (phase suivante). Reste global : dashboard 17/63,
api_v2 50/115.

suite **1059/1059** (236 s) · `ruff check app tests` OK · docs à jour
2026-10-01 12:35:22 -04:00
bruno df9a269d76 test: A32 phase 2a — library 10/10 + 2 routes fantômes supprimées (v7.12.0)
FlowDeck CI / lint (push) Successful in 1m53s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 8m41s
- tests/test_smoke_uncovered.py : +6 tests pour library.py (1/10 → 8 routes
  couvertes) : les 5 listes en boucle (recents/favorites/published/private/
  workspace → 200 + items), /private avec page seedée retrouvée, /children/{id}
  avec parent/enfant seedés, /repository vide ET clé (string, aucun réseau),
  non-régression 404 sur les routes supprimées.
- DÉCOUVERTE (les smokes l'ont prouvé) : `/api/library/local-workspace-children`
  renvoyait un 500 systématique (test vert → ASGI double response.start) et
  `/api/library/local-workspace` 500 dès qu'un workspace existe — les deux
  lisaient `local_workspace_items`, table AUCUNEMENT créée dans le codebase
  (0 CREATE TABLE) et sans 1 seule référence front. Supprimés avec
  `library._format_size` devenu mort (la copie de dashboard.py est inchangée).
  `local_workspace_items` : 0 occurrence restante dans app/.
- helper `_seed_page` minimal (workspace NOT NULL inclus) + nettoyage en
  finally (pas de pollution des autres tests).

suite **1053/1053** (229 s) · `ruff check app tests` OK · docs à jour
restent phase 2b : api.py 3/23, dashboard.py 17/63, api_v2.py 50/115
2026-10-01 12:11:12 -04:00
bruno da7326ffde test: A32 phase 1 — 4 routers à 0 test couverts (10 smokes) (v7.11.0)
FlowDeck CI / lint (push) Successful in 1m50s
FlowDeck CI / test (push) Successful in 13m55s
FlowDeck CI / docker (push) Canceled after 0s
tests/test_smoke_uncovered.py — un smoke par route des 4 routers qui n'avaient
AUCUN test :
- webhooks.py 3/3 : réception sans secret → {"status":"ok"} ; HMAC faux → 401
  (secret piloté par monkeypatch, déterministe quel que soit le .env) ;
  register sans secret → 400 AVANT tout appel réseau ; status avec
  gitea.list_webhooks stubbé → {"registered": False} (zéro réseau réel)
- notes.py 2/2 : GET HTML + roundtrip POST→GET (upsert persisté en base,
  échappement HTML vérifié : &lt;b&gt; et non <b>)
- sidebar_config.py 2/2 : GET défauts ; PUT persisté puis RELU depuis
  users.sidebar_config ; 400 sans config ; remise en état en fin de test
- github_routes.py 2/2 : status {"linked": False} ; disconnect {"status": "ok"}

Reste (A32 phase 2) : quasi nuls — library 1/10, api 3/23, dashboard 17/63,
api_v2 50/115 → même recette, fixture client existante.

suite **1047/1047** · `ruff check app tests` OK · docs à jour
2026-10-01 11:54:43 -04:00
bruno 3a1276596c fix: A21 phase 2b — run_event_sync + 15 routes api_v2 en def (v7.10.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- `run_event_sync(coro)` (app/services/automations.py) : exécute une coroutine
  d'événement depuis un handler synchrone — `asyncio.run` sur une boucle
  dédiée dans le worker threadpool : le worker est bloqué, JAMAIS la boucle
  d'event, et la réponse n'est produite qu'une fois l'événement terminé
  (déterministe, équivalent sémantique de l'await). ponytail: les clients
  httpx sont créés à chaque appel partout → aucun lien de boucle ; sinon
  run_coroutine_threadsafe + boucle du lifespan.
- 15 routes api_v2 dont les SEULS awaits étaient `request.json`,
  `_fire_event`, `fire_published`, `fire_unpublished` →
  `Body(default={})` + `run_event_sync(...)` + conversion en `def` (script
  : wrapping par appariement de parenthèses chaîne-aware, assert de flip
  « plus aucun await »).
- api_v2 : **111/115 routes hors loop**. Les 4 restantes ont de vrais awaits
  réseau et restent async volontairement : import_csv_v2 (multipart),
  project_tree_v2 (gitea), test_webhook_v2 (delivery), retry_webhook_deliveries.
- Repo-wide : 403 routes sync (hors loop) / 260 async (phase 2c).

tests : ciblé public_api_v2 + v65 + webhooks_v2 + audit = 90/90 (les webhooks
prouvent la détermination de run_event_sync) ; suite complète **1037/1037**
(228 s) · `ruff check app tests` OK · docs à jour
2026-10-01 11:35:43 -04:00
bruno 07904f05e5 fix: A21 phase 2a — api_v2 : body JSON en paramètre, 36 routes hors loop (v7.9.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- Les 36 routes d'api_v2 dont le SEUL `await` était
  `body = await request.json()` (bloc try/except uniforme) → paramètre FastAPI
  `body: dict = Body(default={})` + conversion en `def` → threadpool :
  toute leur séquence SQLite quitte l'event loop.
- Équivalences vérifiées avant engament (probe FastAPI) :
  · corps absent → `{}` (identique à l'ancien try/except)
  · JSON invalide → 422 (avant : avalé comme `{}` — 422 est plus juste)
  · zéro `body[...]=` / setdefault / update dans api_v2 → défaut partagé
    jamais muté
- verify_webhook_signature (signature multi-ligne) traitée à la main.
- Piège courant évité : première version du script supprimait 5 lignes au
  lieu de 4 (slice m-1:m+4) → fichier restauré depuis git, slice corrigée,
  0 ligne perdue (diff logique +39/-183).

api_v2 : 96/115 routes hors loop (60 phase 1 + 36 ici) ; 19 async restantes
(fire_event, request.form, gitea/webhooks) = phase 2b.

suite **1037/1037** (242 s) · `ruff check app tests` OK · docs à jour
2026-10-01 11:19:58 -04:00
35 changed files with 8878 additions and 7278 deletions
+327
View File
@@ -1,5 +1,332 @@
# Changelog - FlowDeck
## v7.22.0 (2026-10-01) — Audit : A27 phase 2a (l'éditeur, 2 516 L)
### Changed
- **A27 (phase 2a)** — `_page_editor_scripts.html` : les **2 516 lignes** du
gros bloc interpolé partent vers `static/js/page_editor_scripts.js`
- Recette « config JSON » : les **8 interpolations Jinja** lisent maintenant
`PD = JSON.parse(document.getElementById('page-data'))` — le bloc JSON
`#page-data` **existait déjà** juste avant le script, même ordre
d'exécution, garde `__fdEditorScriptsLoaded` préservée
- Côté route (`view_page_root`) : `page_data` enrichi de `updated_at`,
`created_at`, `user_id`, `is_shared` (le dérivé est **hoisté** — une seule
expression sert le ctx ET le JSON) et `clip_icon` (macro `fd_icon` rendue
côté serveur). `workspace_key` reste vide comme avant (jamais défini dans
ce contexte → parité stricte)
- `node --check` vert ; template : 2 tags restants (JSON config + src)
### Fixed
- **8 tests adaptés** à l'extraction (ils lisaient le template source) :
`test_ai_writing` ×2 (+ helper `_read_js`), `test_pwa_offline`,
`test_v511` front_end_wired, `test_v55` ×3 (lightbox, previews, endpoints)
→ lisent `static/js/page_editor_scripts.js` ; `test_page_editor_renders_
page_is_shared` → **parsing du JSON `#page-data`** (`is_shared is True`,
la valeur sert toujours à la page)
### Notes
- Cumul A27 : **6 759 L extraites** (13 904 → 7 145 inline)
- Reste : local_workspace 2 031, base 1 523 (structurel {% for %}/{% block %}),
database_table 1 323 (if/else), settings 1 093, realtime 531, board 146
≈ 6 653 L + 120 warnings eslint à nettoyer
- Suite complète : **1089/1089**
## 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
- **Bug prod trouvé par les smokes** : `card_detail.html` appelait la macro
`fd_icon` **sans l'importer** → `UndefinedError` → **500 systématique** sur
`GET /api/issues/{o}/{r}/{id}?format=html` (seul rendu du template dans le
code) → `{% from '_icons.html' import fd_icon %}` ajouté
### Tests
- Les **6 dernières routes d'A32** (Gitea, stub de transport, zéro réseau) :
· stubs manuels sur `gitea_client.gitea` avec **état mutable partagé**
(le handler PATCH re-fetch l'issue via `get_issue` — un canevas figé
aurait masqué la mise à jour)
· `POST /issues` : carte **insérée sur le board** ; `PATCH` : colonne
recalculée **sans perdre la carte**
· `GET /issues` JSON + **HTML** (`?format=html` — le segment `/html` ne fixe
pas le paramètre `format`, il est lu dans la query) ; stub qui lève → 404
· `POST /checklists` + `POST /checklist-items` : lignes **vérifiées en
base**, 404 sans board
- `test_smoke_uncovered.py` : **52 tests** ; suite complète **1089/1089**
- **A32 complet** : plus aucun router « 0 test » (webhooks, notes,
sidebar_config, github_routes, library, api, dashboard, api_v2)
## v7.19.0 (2026-10-01) — Audit : A32 : dashboard bloqué (44/44)
### Tests
- +5 routes `dashboard.py` — `test_smoke_uncovered.py` : 49 tests :
· **Members** (POST/PUT/DELETE) : invitation de soi-même dans un workspace
dédié, rôle relu en base, membre supprimé (`COUNT=0`) ; **quirk documenté**
: les retours `(..., 400)` de ces routes sont sérialisés FastAPI en
tableau + 200 (`[{"error": "Invalid role"}, 400]`)
· `upload-folder` : **validations seules** (structure absente → 400,
JSON cassé → 400) — zéro fichier écrit sur disque, workspace dédié nettoyé
· `convert-to-database` : collection + propriété `title` + vue `table` +
page en `content_format='collection'` **vérifiés en base**, 404 page
inconnue, cleanup **dans l'ordre FK** (page avant collection)
- **Recoupement final** : scan des 44 routes strictement à 0 ref de
`dashboard.py` → **toutes exercées** (19 faux positifs résiduels =
paths en f-string dans les tests, rapprochés manuellement)
- Suite complète : **1086/1086**
## v7.18.0 (2026-10-01) — Audit : A32 phase 2g (dashboard +13)
### Tests
- +13 routes `dashboard.py` (cumul **43→56 sur 63**) —
`test_smoke_uncovered.py` : 46 tests :
· `/gitea-workspace` : page HTML (200 ou redirection propre)
· `workspace/projects` GET+POST : shape `{builtin, gitea, github}` avec
`github == []`, projet créé **retrouvé dans builtin**, quirk `error` sans
nom, nettoyage
· **Cycle items local-workspace** (5 routes) : création → renommage **relu en
base** → move → soft-delete (`deleted_at` **relu**) → restore
(`deleted_at IS NULL` **relu**), nettoyage
· **Cycle tags d'item** (5 routes) : POST (nom lowercasé), tags de l'item,
liste workspace, search (shape), suppression vérifiée. Utilisateur +
workspace **créés dans le test** : `/api/local-workspace/tags` a besoin
d'un workspace actif (fallback « premier workspace du user ») — on ne le
fait pas dépendre de l'utilisateur fixture partagé, tout est nettoyé
- Suite complète : **1083/1083**
## v7.17.0 (2026-10-01) — Audit : A32 phase 2f (dashboard +7, garde-fous A16)
### Tests
- +7 routes `dashboard.py` (cumul **36→43 sur 63**), centrées sur les
garde-fous A16 — `test_smoke_uncovered.py` : 42 tests :
· `GET /api/files/{ws}/{path}` : traversal encodé `%2e%2e%2f` →
**403 « Path traversal denied »** ; inexistant → 404 ; vrai fichier écrit
dans le data_dir de test → **200 + octets exacts** (nettoyé)
· `GET /api/pages/{id}/download` : page markdown → 404 « downloadable »
(pas de 500) ; page « file » avec chemin `../` qui sort de la racine →
**jamais 200** (404), et `file-content` → 404/415
· `GET /api/local-workspace/page-content/{id}` : contenu + format relus,
404 sur id inconnu
· `GET /api/avatar/{id}` : **302 + Location** avec `follow_redirects=False`
(AUCUNE requête réelle vers l'URL externe — règle « 0 réseau »), 404 sans
avatar
· `GET/POST /api/collections/{id}/table-data|pages` : 404 inconnu, shape,
ligne créée **retrouvée dans table-data**, nettoyage finally
- Suite complète : **1079/1079**
## v7.16.0 (2026-10-01) — Audit : A32 phase 2e (dashboard +9, comptes)
### Tests
- +9 routes `dashboard.py` (cumul **27→36 sur 63**) :
· `/accounts` + `/accounts/settings` : 200 HTML, **`password_hash` absent**
du rendu (whitelist A2 vérifiée côté page)
· `PUT /api/user/profile` : persistance **relue en base**, valeur d'origine
restaurée en `finally`
· `PUT /api/user/password` : **403 « current password is incorrect »** (A3 —
la session seule ne change pas le mdp) + quirk assumé et documenté :
longueur validée AVANT auth → 200 + message d'erreur
· `POST /api/user/token` : format `fd_` + 64 hex, ligne `user_tokens`
nettoyée en `finally`
· `DELETE /api/user/forge/{provider}` : `{"status": "ok"}`
· `PUT /api/settings/account` : full_name/email persistés + **400 sur mdp
court** (validateur), restauration en `finally`
· `POST /api/workspaces/{id}/select` : `Set-Cookie flowdeck_workspace`
vérifié ; `GET /api/local-workspace/breadcrumb` : shape liste
- `test_smoke_uncovered.py` : 38 tests ; suite complète **1075/1075**
## v7.15.0 (2026-10-01) — Audit : A32 phase 2d (dashboard +10 routes)
### Tests
- Scan strict `dashboard.py` : **44 routes à 0 référence** (sur 63) — 10
couvertes cette passe dans `test_smoke_uncovered.py` (32 tests au fichier) :
· **Tags CRUD** : création (`SmokeTag` → `smoketag` lowercasé), présence
dans la liste, changement de couleur relu, suppression puis absence
· **Vie d'une page** : GET `content` (contenu seedé relu) → PUT `rename`
(ok + **400 sur titre vide** + titre relu en base) → POST `trash`
(`parent_section='Trash'` + `deleted_at` **relus en base**)
· `sidebar/workspace-tree` : 200 HTML, fragment « No pages yet » sans cookie
· `settings/avatar-color` : couleur relue **sur l'utilisateur de la
session** (pas `LIMIT 1`), valeurs d'origine restaurées en `finally`
· `workspace/{id}/members` : shape `{"members": [...]}`
- Helper `_seed_page` : surcharge des colonnes par défaut (`content=`, …)
- Suite complète : **1069/1069**
## v7.14.0 (2026-10-01) — Audit : A32 phase 2c (api_v2 +5 routes)
### Tests
- Scan strict des 115 routes `api_v2.py` contre tous les tests (chaîne de
chemin littérale) → **5 routes à 0 référence**, toutes couvertes :
· `POST /properties/evaluate-formula` : 200 + shape, 400 sans `expression`
(le moteur renvoie `1 + 2` tel quel aujourd'hui — le smoke valide le câble
route/auth/parse, pas le moteur)
· `POST /properties/compute-rollup` : 400 `collection_id required`,
401 sans bearer
· `GET /admin/audit-logs` : portail admin vérifié — attendu **calculé depuis
`/users/me`** (le tout premier utilisateur d'un worker est admin, état non
contrôlable depuis le test), + token scope `admin` → 200 + `logs` liste
· `GET /webhooks/events` : catalogue non vide + wildcards `*`/`page.*`
· `POST /webhooks/verify-signature` : **valid=True** avec
`sign_payload(secret, payload)` (même helper que le serveur), False avec
une signature bidon
- `test_smoke_uncovered.py` : 27 tests au total
- Suite complète : **1064/1064**
## v7.13.0 (2026-10-01) — Audit : A32 phase 2b (api.py 16/22)
### Tests
- `api.py` passe de **3 à 16 routes couvertes** (22 `@router` au total) :
· `board-config` GET/POST : défauts à 5 colonnes sans board, création puis
relecture du roundtrip
· `col-mapping` POST/DELETE : 404 sans board, upsert `label` vérifié,
suppression vérifiée
· `card` POST : 404 sans board, `{"status": "ok"}` avec
· `collaborators` GET : **`gitea.get_collaborators` stubbé** (zéro réseau réel)
· `frontend-error(s)` : capture, JSON invalide → `ignored`, **dédup** d'une
erreur répétée (`count=2`), lecture qui purge (`cleared=true` puis 0)
· checklist mutations : PATCH item (checked/content relus EN BASE),
DELETE item, DELETE checklist (réapparition `COUNT=0`) — seed + cleanup
- Reste `api.py` : 6 routes Gitea (issues ×4 + créations checklists) →
stub de transport httpx. Reste global : `dashboard.py` 17/63,
`api_v2.py` 50/115
### Tests
- Suite complète : **1059/1059** (236 s) ; `test_smoke_uncovered.py` : 22 tests
## v7.12.0 (2026-10-01) — Audit : A32 phase 2a (library 10/10)
### Tests
- `library.py` passe de **1/10 à 8 routes couvertes** : les 5 listes
(recents/favorites/published/private/workspace) en un test de boucle,
`/private` avec une page seedée et retrouvée, `/children/{id}` avec un
parent/enfant seedés (titre retrouvé), `/repository` vide et clé
(aucun appel réseau — la clé n'est qu'une string de workspace)
- Test de non-régression 404 sur les 2 routes supprimées
### Removed
- **2 routes cassées supprimées** (découverte des smokes) :
`/api/library/local-workspace-children/{id}` renvoyait un 500 systématique
et `/api/library/local-workspace` un 500 dès qu'un workspace existait —
les deux lisaient `local_workspace_items`, **une table qui n'est créée nulle
part** dans le codebase (grep : 0 `CREATE TABLE`), avec **0 référence front**.
`library._format_size` devenu mort : supprimé aussi (une version vit dans
`dashboard.py`, inchangée)
- `local_workspace_items` : plus aucune occurrence dans `app/`
### Tests
- Suite complète : **1053/1053** (229 s) ; `test_smoke_uncovered.py` : 16 tests
## v7.11.0 (2026-10-01) — Audit : A32 phase 1 (routers à 0 test)
### Tests
- `tests/test_smoke_uncovered.py` — **10 smoke tests**, un par route des 4
routers qui n'avaient AUCUN test :
· `webhooks.py` (3/3) : réception sans secret → `{"status":"ok"}` ;
HMAC faux → 401 (secret piloté par monkeypatch) ; register sans secret →
400 **avant** tout appel réseau ; status avec `gitea.list_webhooks` stubbé
(zéro accès réseau réel)
· `notes.py` (2/2) : GET HTML + roundtrip POST→GET (upsert persisté,
échappement HTML vérifié `&lt;b&gt;`)
· `sidebar_config.py` (2/2) : GET défauts, PUT persisté relu depuis
`users.sidebar_config`, 400 sans `config`, remise en état en fin de test
· `github_routes.py` (2/2) : status `{"linked": False}`, disconnect ok
- Reste (phase 2) : quasi nuls — `library.py` 1/10, `api.py` 3/23,
`dashboard.py` 17/63, `api_v2.py` 50/115
- Suite complète : **1047/1047**
## v7.10.0 (2026-10-01) — Audit : A21 phase 2b (api_v2 bouclé)
### Changed
- **Helper** `run_event_sync(coro)` (`app/services/automations.py`) : exécute
une coroutine d'événement depuis un handler synchrone — `asyncio.run` sur une
boucle dédiée dans le **worker threadpool** : bloqué = le worker, jamais la
boucle d'event, et la réponse n'est envoyée qu'une fois l'événement terminé
(déterministe, équivalent sémantique de l'`await` d'avant). Note
`ponytail:` : clients httpx créés à chaque appel partout → aucun lien de
boucle ; sinon `run_coroutine_threadsafe` + boucle du lifespan
- **A21 (phase 2b)** — 15 routes `api_v2` dont les seuls awaits étaient
`request.json` / `_fire_event` / `fire_published` / `fire_unpublished` →
paramètre `Body(default={})` + `run_event_sync(...)` + conversion en `def`
- **`api_v2` : 111/115 routes hors event loop** — il ne reste que 4 routes
async, toutes avec de vrais awaits réseau : `import_csv_v2` (multipart),
`project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`
- Repo-wide : **403 routes synchrones (hors loop) / 260 async** (phase 2c)
### Tests
- Ciblé (public_api_v2 + v65 + webhooks_v2 + audit) : 90/90 — les webhooks
prouvent la détermination de `run_event_sync` ; suite complète **1037/1037**
en 228 s
## v7.9.0 (2026-10-01) — Audit : A21 phase 2a (api_v2 hors loop)
### Changed
- **A21 (phase 2a)** — dans `api_v2`, les 36 routes dont le **seul** `await`
était `body = await request.json()` passent à un paramètre FastAPI
`body: dict = Body(default={})` (parsing async fait par FastAPI avant
l'appel) puis sont converties en `def` → threadpool. Équivalences vérifiées
avant-engagement : corps absent → `{}` (identique au `try/except` d'avant),
JSON invalide → **422** (avant : traité silencieusement comme `{}`),
zéro `body[...] = ` dans le fichier (le défaut partagé n'est jamais muté)
- `api_v2` : **96/115 routes hors event loop** (60 en phase 1 + 36 ici) ;
il ne reste que **19 routes async** dans ce router (`fire_event`,
`request.form`, appels gitea/webhooks — phase 2b)
- Bug de transformation évité en cours de route : première version du script
supprimait 5 lignes au lieu de 4 (`slice` fermant d'un cran trop loin) —
fichier restauré depuis git puis script corrigé, 0 ligne perdue (diff
logique : +39/-183 = 36 signatures + import, 4 lignes de try/except × 35)
### Tests
- Suite complète **1037/1037** (242 s) ; ciblée sur `test_public_api_v2` +
`test_v65` + audit : 62/62 verts avant la passe complète
## v7.8.0 (2026-10-01) — Audit : A21 phase 1 (SQLite hors event loop)
### Changed
+4 -4
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
7.8.0
7.22.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.8.0 (audit — A21 phase 1 : 352 routes hors loop) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.22.0 (audit — A27 phase 2 : éditeur 2 516 L via page-data JSON) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.8.0",
version="7.22.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+70 -257
View File
@@ -12,7 +12,7 @@ import logging
import secrets
from datetime import datetime
from fastapi import APIRouter, Header, HTTPException, Request
from fastapi import APIRouter, Body, Header, HTTPException, Request
from fastapi.responses import JSONResponse, Response
from app.db import get_conn
@@ -31,6 +31,7 @@ from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est ut
validate_scopes_input,
)
from app.services.automations import fire_event as _fire_event
from app.services.automations import run_event_sync
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
logger = logging.getLogger(__name__)
@@ -48,13 +49,9 @@ def _v2_rate_check(request: Request, user: dict) -> None:
# ── Tokens ────────────────────────────────────────────────────────────────
@router.post("/tokens")
async def create_token(request: Request, authorization: str | None = Header(default=None)):
def create_token(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "API token").strip()[:100]
scopes = validate_scopes_input(body.get("scopes") or "read,write")
expires_at = body.get("expires_at")
@@ -166,13 +163,9 @@ def get_me(request: Request, authorization: str | None = Header(default=None)):
return d
@router.patch("/users/me")
async def patch_me(request: Request, authorization: str | None = Header(default=None)):
def patch_me(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
allowed = {"full_name", "email", "avatar_color", "notification_prefs", "sidebar_config", "timezone"}
updates = {}
for k in allowed:
@@ -232,16 +225,12 @@ def list_workspaces(request: Request, authorization: str | None = Header(default
return {"workspaces": out, "total": total, "limit": limit, "offset": offset}
@router.post("/workspaces")
async def create_workspace(request: Request, authorization: str | None = Header(default=None)):
def create_workspace(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
idem = check_idempotency(request, user["id"])
if idem:
return JSONResponse(content=idem["data"], status_code=idem["status"])
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(400, "name is required")
@@ -282,13 +271,9 @@ def get_workspace(workspace_id: int, request: Request, authorization: str | None
return d
@router.patch("/workspaces/{workspace_id}")
async def patch_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
def patch_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
if not row:
@@ -336,13 +321,9 @@ def list_workspace_members(workspace_id: int, request: Request, authorization: s
return {"members": [row_to_dict(r) for r in rows]}
@router.post("/workspaces/{workspace_id}/members")
async def invite_member(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
def invite_member(workspace_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
target_id = body.get("user_id") or body.get("uid")
email = (body.get("email") or "").strip()
role = (body.get("role") or "editor").strip().lower()
@@ -374,13 +355,9 @@ async def invite_member(workspace_id: int, request: Request, authorization: str
return {"workspace_id": workspace_id, "user_id": uid, "role": role, "status": "added"}
@router.patch("/workspaces/{workspace_id}/members/{uid}")
async def update_member_role(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None)):
def update_member_role(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
role = (body.get("role") or "").strip().lower()
if role not in ("owner", "admin", "editor", "viewer", "commenter"):
raise HTTPException(400, "Invalid role")
@@ -451,16 +428,12 @@ def list_collections_v2(request: Request, authorization: str | None = Header(def
return JSONResponse(content=resp, headers=paginate_headers(total))
@router.post("/collections")
async def create_collection_v2(request: Request, authorization: str | None = Header(default=None)):
def create_collection_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
idem = check_idempotency(request, user["id"])
if idem:
return JSONResponse(content=idem["data"], status_code=idem["status"])
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(400, "name is required")
@@ -507,13 +480,9 @@ def get_collection_v2(collection_id: int, request: Request, authorization: str |
return d
@router.patch("/collections/{collection_id}")
async def patch_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
def patch_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not row:
@@ -545,13 +514,9 @@ def delete_collection_v2(collection_id: int, request: Request, authorization: st
return {"id": collection_id, "status": "deleted"}
@router.post("/collections/{collection_id}/linked")
async def create_linked_db(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
def create_linked_db(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip() or f"Linked DB {collection_id}"
with get_conn() as conn:
src = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
@@ -607,13 +572,9 @@ def list_sources(collection_id: int, request: Request, authorization: str | None
return {"sources": [dict(r) for r in rows]}
@router.post("/collections/{collection_id}/sources")
async def add_source(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
def add_source(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
src_id = body.get("source_collection_id") or body.get("source_id")
if not src_id:
raise HTTPException(400, "source_collection_id required")
@@ -701,16 +662,12 @@ def list_collection_pages_v2(collection_id: int, request: Request, authorization
return JSONResponse(content={"pages": out, "total": total, "limit": limit, "offset": offset}, headers=paginate_headers(total))
@router.post("/collections/{collection_id}/pages")
async def create_collection_page_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
def create_collection_page_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
idem = check_idempotency(request, user["id"])
if idem:
return JSONResponse(content=idem["data"], status_code=idem["status"])
try:
body = await request.json()
except Exception:
body = {}
title = (body.get("title") or body.get("name") or "Untitled").strip() or "Untitled"
icon = body.get("icon", "file")
parent_id = body.get("parent_id")
@@ -743,7 +700,7 @@ async def create_collection_page_v2(collection_id: int, request: Request, author
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
audit_log(user, "page.create", "collection_page", pid, title, request)
try:
await _fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title})
run_event_sync(_fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title}))
except Exception:
logger.exception("create_collection_page_v2")
data = {"id": pid, "title": title, "status": "created", "page": row_to_dict(row)}
@@ -781,13 +738,9 @@ def get_page_v2(page_id: int, request: Request, authorization: str | None = Head
return d
@router.patch("/pages/{page_id}")
async def patch_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def patch_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not row:
@@ -822,13 +775,13 @@ async def patch_page_v2(page_id: int, request: Request, authorization: str | Non
conn.commit()
audit_log(user, "page.update", "collection_page", page_id, "", request)
try:
await _fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title})
run_event_sync(_fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title}))
except Exception:
logger.exception("patch_page_v2")
return {"id": page_id, "status": "updated"}
@router.delete("/pages/{page_id}")
async def delete_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def delete_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
@@ -839,13 +792,13 @@ async def delete_page_v2(page_id: int, request: Request, authorization: str | No
conn.commit()
audit_log(user, "page.delete", "collection_page", page_id, "", request)
try:
await _fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]})
run_event_sync(_fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]}))
except Exception:
logger.exception("delete_page_v2")
return {"id": page_id, "status": "deleted"}
@router.post("/pages/{page_id}/restore")
async def restore_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def restore_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
# For soft-deleted pages (deleted_at) - but collection_pages has no deleted_at; handle pages table
@@ -855,20 +808,16 @@ async def restore_page_v2(page_id: int, request: Request, authorization: str | N
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
conn.commit()
try:
await _fire_event("page.restored", {"page_id": page_id})
run_event_sync(_fire_event("page.restored", {"page_id": page_id}))
except Exception:
logger.exception("restore_page_v2")
return {"id": page_id, "status": "restored"}
raise HTTPException(404, "Page not found or not deleted")
@router.post("/pages/{page_id}/move")
async def move_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def move_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not row:
@@ -891,13 +840,9 @@ def list_sub_items_v2(page_id: int, request: Request, authorization: str | None
return {"sub_items": [row_to_dict(r) for r in rows]}
@router.post("/pages/{page_id}/sub-items")
async def create_sub_item_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def create_sub_item_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
parent = conn.execute("SELECT collection_id FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not parent:
@@ -920,13 +865,9 @@ def list_dependencies_v2(page_id: int, request: Request, authorization: str | No
return {"dependencies": [dict(r) for r in rows]}
@router.post("/pages/{page_id}/dependencies")
async def add_dependency_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def add_dependency_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
dep_id = body.get("dependency_id") or body.get("depends_on")
dtype = body.get("dependency_type") or "blocks"
if not dep_id:
@@ -967,13 +908,9 @@ def list_properties_v2(collection_id: int, request: Request, authorization: str
return {"properties": [row_to_dict(r) for r in rows]}
@router.post("/collections/{collection_id}/properties")
async def create_property_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
def create_property_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(400, "name is required")
@@ -992,13 +929,9 @@ async def create_property_v2(collection_id: int, request: Request, authorization
return {"id": pid, "name": name, "prop_type": ptype, "status": "created"}
@router.patch("/properties/{prop_id}")
async def patch_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)):
def patch_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_properties WHERE id=?", (prop_id,)).fetchone()
if not row:
@@ -1028,13 +961,9 @@ def delete_property_v2(prop_id: int, request: Request, authorization: str | None
return {"id": prop_id, "status": "deleted"}
@router.post("/properties/{prop_id}/relation")
async def create_relation_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)):
def create_relation_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
related_id = body.get("related_collection_id")
reverse = (body.get("reverse_name") or "").strip()
if not related_id:
@@ -1054,13 +983,9 @@ async def create_relation_v2(prop_id: int, request: Request, authorization: str
return {"id": prop_id, "status": "updated"}
@router.post("/properties/evaluate-formula")
async def evaluate_formula_v2(request: Request, authorization: str | None = Header(default=None)):
def evaluate_formula_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
expr = body.get("expression") or body.get("formula")
if not expr:
raise HTTPException(400, "expression required")
@@ -1073,13 +998,9 @@ async def evaluate_formula_v2(request: Request, authorization: str | None = Head
return {"result": res, "expression": expr}
@router.post("/properties/compute-rollup")
async def compute_rollup_v2(request: Request, authorization: str | None = Header(default=None)):
def compute_rollup_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
for k in ("collection_id", "relation_property_id", "target_property_id", "page_id"):
if k not in body:
raise HTTPException(400, f"{k} required")
@@ -1101,13 +1022,9 @@ def list_views_v2(collection_id: int, request: Request, authorization: str | Non
return {"views": [row_to_dict(r) for r in rows]}
@router.post("/collections/{collection_id}/views")
async def create_view_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
def create_view_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "New View").strip()
vtype = body.get("view_type") or body.get("type") or "table"
config = body.get("config") or body.get("config_json") or {}
@@ -1120,19 +1037,15 @@ async def create_view_v2(collection_id: int, request: Request, authorization: st
conn.commit()
audit_log(user, "view.create", "view", vid, name, request)
try:
await _fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype})
run_event_sync(_fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype}))
except Exception:
logger.exception("create_view_v2")
return {"id": vid, "name": name, "view_type": vtype, "status": "created"}
@router.patch("/views/{view_id}")
async def patch_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)):
def patch_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not row:
@@ -1169,13 +1082,9 @@ def delete_view_v2(view_id: int, request: Request, authorization: str | None = H
return {"id": view_id, "status": "deleted"}
@router.post("/views/{view_id}/save-as")
async def save_as_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)):
def save_as_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip() or "Copy"
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
@@ -1196,13 +1105,9 @@ def list_dashboards_v2(collection_id: int, request: Request, authorization: str
return {"dashboards": [row_to_dict(r) for r in rows]}
@router.post("/collections/{collection_id}/dashboards")
async def create_dashboard_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
def create_dashboard_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "Dashboard").strip()
layout = body.get("layout") or body.get("layout_json") or {"columns": 1, "widgets": []}
with get_conn() as conn:
@@ -1212,13 +1117,9 @@ async def create_dashboard_v2(collection_id: int, request: Request, authorizatio
return {"id": did, "name": name, "status": "created"}
@router.patch("/dashboards/{dashboard_id}")
async def patch_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None)):
def patch_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_dashboards WHERE id=?", (dashboard_id,)).fetchone()
if not row:
@@ -1255,13 +1156,9 @@ def list_comments_v2(page_id: int, request: Request, authorization: str | None =
return {"comments": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
@router.post("/pages/{page_id}/comments")
async def create_comment_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def create_comment_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
text = (body.get("body") or body.get("content") or "").strip()
if not text:
raise HTTPException(400, "body is required")
@@ -1272,19 +1169,15 @@ async def create_comment_v2(page_id: int, request: Request, authorization: str |
row = conn.execute("SELECT * FROM comments WHERE id=?", (nid,)).fetchone()
audit_log(user, "comment.create", "comment", nid, text[:80], request)
try:
await _fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]})
run_event_sync(_fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]}))
except Exception:
logger.exception("create_comment_v2")
return {"id": nid, "status": "created", "comment": row_to_dict(row)}
@router.patch("/comments/{comment_id}")
async def patch_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None)):
def patch_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
if not row:
@@ -1298,7 +1191,7 @@ async def patch_comment_v2(comment_id: int, request: Request, authorization: str
conn.commit()
if int(bool(resolved)) and not was_resolved:
try:
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
run_event_sync(_fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}))
except Exception:
logger.exception("patch_comment_v2")
return {"id": comment_id, "status": "updated"}
@@ -1318,13 +1211,9 @@ def delete_comment_v2(comment_id: int, request: Request, authorization: str | No
return {"id": comment_id, "status": "deleted"}
@router.post("/pages/{page_id}/mentions")
async def create_mention_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def create_mention_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
targets = body.get("user_ids") or body.get("mentions") or []
if isinstance(targets, int):
targets = [targets]
@@ -1341,7 +1230,7 @@ async def create_mention_v2(page_id: int, request: Request, authorization: str |
conn.commit()
if created:
try:
await _fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created})
run_event_sync(_fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created}))
except Exception:
logger.exception("create_mention_v2")
return {"mentions": created, "status": "created"}
@@ -1390,13 +1279,9 @@ def mark_all_read(request: Request, authorization: str | None = Header(default=N
return {"status": "all read"}
@router.patch("/users/me/preferences")
async def patch_prefs(request: Request, authorization: str | None = Header(default=None)):
def patch_prefs(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone()
try:
@@ -1419,13 +1304,9 @@ def list_favorites_v2(request: Request, authorization: str | None = Header(defau
return {"favorites": [row_to_dict(r) for r in rows]}
@router.post("/favorites")
async def add_favorite_v2(request: Request, authorization: str | None = Header(default=None)):
def add_favorite_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
pid = body.get("page_id")
if not pid:
raise HTTPException(400, "page_id required")
@@ -1436,20 +1317,20 @@ async def add_favorite_v2(request: Request, authorization: str | None = Header(d
except Exception as e:
raise HTTPException(409, str(e)) from None
try:
await _fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]})
run_event_sync(_fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]}))
except Exception:
logger.exception("add_favorite_v2")
return {"page_id": pid, "status": "added"}
@router.delete("/favorites/{page_id}")
async def remove_favorite_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def remove_favorite_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (user["id"], page_id))
conn.commit()
try:
await _fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]})
run_event_sync(_fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]}))
except Exception:
logger.exception("remove_favorite_v2")
return {"page_id": page_id, "status": "removed"}
@@ -1467,13 +1348,9 @@ def list_tags_v2(request: Request, authorization: str | None = Header(default=No
return {"tags": [dict(r) for r in rows]}
@router.post("/tags")
async def create_tag_v2(request: Request, authorization: str | None = Header(default=None)):
def create_tag_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(400, "name required")
@@ -1488,13 +1365,9 @@ async def create_tag_v2(request: Request, authorization: str | None = Header(def
return {"id": tid, "name": name, "color": color, "status": "created"}
@router.patch("/tags/{tag_id}")
async def patch_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None)):
def patch_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"])).fetchone()
if not row:
@@ -1515,13 +1388,9 @@ def delete_tag_v2(tag_id: int, request: Request, authorization: str | None = Hea
return {"id": tag_id, "status": "deleted"}
@router.post("/pages/{page_id}/tags")
async def attach_tag_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def attach_tag_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
tag_id = body.get("tag_id")
if not tag_id:
raise HTTPException(400, "tag_id required")
@@ -1566,13 +1435,9 @@ def list_shares_v2(page_id: int, request: Request, authorization: str | None = H
return {"shares": [dict(r) for r in rows]}
@router.post("/pages/{page_id}/shares")
async def create_share_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def create_share_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
perm = (body.get("permission") or "view").strip().lower()
if perm not in ("view", "comment", "edit"):
raise HTTPException(400, "Invalid permission. Use view, comment, or edit")
@@ -1587,19 +1452,15 @@ async def create_share_v2(page_id: int, request: Request, authorization: str | N
nid = cur.lastrowid
audit_log(user, "share.create", "share", nid, f"page={page_id}", request)
try:
await _fire_event("page.shared", {"page_id": page_id, "share_id": nid, "permission": perm})
run_event_sync(_fire_event("page.shared", {"page_id": page_id, "share_id": nid, "permission": perm}))
except Exception:
logger.exception("create_share_v2")
return {"id": nid, "page_id": page_id, "status": "shared"}
@router.patch("/shares/{share_id}")
async def patch_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None)):
def patch_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
perm = (body.get("permission") or "").strip().lower()
if perm not in ("view", "comment", "edit"):
raise HTTPException(400, "Invalid permission")
@@ -1627,25 +1488,21 @@ def delete_share_v2(share_id: int, request: Request, authorization: str | None =
return {"id": share_id, "status": "revoked"}
@router.post("/pages/{page_id}/publish")
async def publish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def publish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
slug_in = (body.get("slug") or body.get("publish_slug") or "").strip() or None
slug, _title = publish(page_id, explicit_slug=slug_in)
audit_log(user, "page.publish", "page", page_id, slug, request)
await fire_published(page_id, slug)
run_event_sync(fire_published(page_id, slug))
return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"}
@router.delete("/pages/{page_id}/publish")
async def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
unpublish(page_id)
await fire_unpublished(page_id)
run_event_sync(fire_unpublished(page_id))
return {"page_id": page_id, "status": "unpublished"}
# ── History ───────────────────────────────────────────────────────────────
@@ -1661,13 +1518,9 @@ def list_history_v2(page_id: int, request: Request, authorization: str | None =
return {"history": [row_to_dict(r) for r in rows], "versions": [row_to_dict(r) for r in vrows]}
@router.post("/pages/{page_id}/history/restore")
async def restore_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def restore_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
hid = body.get("history_id") or body.get("id") or body.get("version_id")
if not hid:
raise HTTPException(400, "history_id required")
@@ -1703,13 +1556,9 @@ def list_sprints_v2(collection_id: int, request: Request, authorization: str | N
return {"sprints": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
@router.post("/collections/{collection_id}/sprints")
async def create_sprint_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
def create_sprint_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "Sprint").strip()
start = body.get("start_date") or body.get("start") or ""
end = body.get("end_date") or body.get("end") or ""
@@ -1720,19 +1569,15 @@ async def create_sprint_v2(collection_id: int, request: Request, authorization:
sid = cur.lastrowid
conn.commit()
try:
await _fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name})
run_event_sync(_fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name}))
except Exception:
logger.exception("create_sprint_v2")
return {"id": sid, "name": name, "status": "created"}
@router.patch("/sprints/{sprint_id}")
async def patch_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
def patch_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM sprints WHERE id=?", (sprint_id,)).fetchone()
if not row:
@@ -1745,7 +1590,7 @@ async def patch_sprint_v2(sprint_id: int, request: Request, authorization: str |
conn.execute("UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=? WHERE id=?", (name, start, end, goal, status, sprint_id))
conn.commit()
try:
await _fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status})
run_event_sync(_fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status}))
except Exception:
logger.exception("patch_sprint_v2")
return {"id": sprint_id, "status": "updated"}
@@ -1760,13 +1605,9 @@ def delete_sprint_v2(sprint_id: int, request: Request, authorization: str | None
return {"id": sprint_id, "status": "deleted"}
@router.post("/sprints/{sprint_id}/assign")
async def assign_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
def assign_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
pid = body.get("page_id")
if not pid:
raise HTTPException(400, "page_id required")
@@ -1824,13 +1665,9 @@ def list_templates_v2(collection_id: int, request: Request, authorization: str |
return {"templates": [row_to_dict(r) for r in rows]}
@router.post("/collections/{collection_id}/templates")
async def create_template_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
def create_template_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "Template").strip()
pv = json.dumps(body.get("property_values") or body.get("property_values_json") or {})
cj = json.dumps(body.get("content") or body.get("content_json") or [])
@@ -1841,13 +1678,9 @@ async def create_template_v2(collection_id: int, request: Request, authorization
return {"id": tid, "name": name, "status": "created"}
@router.patch("/templates/{template_id}")
async def patch_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
def patch_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone()
if not row:
@@ -1892,13 +1725,9 @@ def list_db_templates_v2(request: Request, authorization: str | None = Header(de
return {"templates": [row_to_dict(r) for r in rows]}
@router.post("/templates/database/{template_id}/apply")
async def apply_db_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
def apply_db_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
tpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (template_id,)).fetchone()
if not tpl:
@@ -2065,15 +1894,11 @@ def admin_list_users_v2(request: Request, limit: int = 30, offset: int = 0, auth
return {"users": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
@router.patch("/admin/users/{uid}")
async def admin_patch_user_v2(uid: int, request: Request, authorization: str | None = Header(default=None)):
def admin_patch_user_v2(uid: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"):
raise HTTPException(403, "Admin scope required")
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
if not conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone():
raise HTTPException(404, "User not found")
@@ -2144,13 +1969,9 @@ def list_webhooks_v2(request: Request, authorization: str | None = Header(defaul
)
@router.post("/webhooks")
async def create_webhook_v2(request: Request, authorization: str | None = Header(default=None)):
def create_webhook_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
from app.services.webhook_outbound import EVENTS, _event_matches
url = (body.get("url") or "").strip()
event = (body.get("event") or "page.created").strip()
@@ -2172,13 +1993,9 @@ async def create_webhook_v2(request: Request, authorization: str | None = Header
"signature_header": "X-FlowDeck-Signature (HMAC-SHA256, sha256=<hex>)" if secret else None}
@router.patch("/webhooks/{webhook_id}")
async def patch_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
def patch_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone()
if not row:
@@ -2274,19 +2091,15 @@ async def retry_webhook_deliveries(webhook_id: int, request: Request,
@router.post("/webhooks/verify-signature")
async def verify_webhook_signature(request: Request,
authorization: str | None = Header(default=None)):
def verify_webhook_signature(request: Request,
authorization: str | None = Header(default=None),
body: dict = Body(default={})):
"""Verify a webhook signature (for debugging/testing)."""
user = get_bearer_user(request, authorization)
_v2_rate_check(request, user)
from app.services.webhook_outbound import verify_signature
try:
body = await request.json()
except Exception:
body = {}
secret = body.get("secret", "")
payload = body.get("payload", "{}")
signature = body.get("signature", "")
+20 -1
View File
@@ -413,17 +413,36 @@ def view_page_root(request: Request, page_id: int):
with get_conn() as conn:
nav_crumbs = _nav_breadcrumb(conn, page_id)
# dérivé calculé UNE fois : ctx ET page_data (JSON) l'utilisent (A27)
page_is_shared = (
bool(page.get("is_shared", 0))
or page.get("share_mode", "private") != "private"
or bool(page.get("published", 0))
)
ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs],
"page_favorited": fav is not None,
"page_share_mode": page.get("share_mode", "private"),
"page_published": bool(page.get("published", 0)),
"page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)),
"page_is_shared": page_is_shared,
"page_data": page_data,
"collection_data": collection_data,
"breadcrumb_items": nav_crumbs,
"nav_workspace_id": page.get("workspace_id") or 0,
"nav_page_id": page_id,
"embed_mode": embed}
# A27 phase 2 : le JS de l'éditeur lit ces valeurs dans page-data (JSON)
# au lieu des interpolations Jinja — une seule source, même calculs que le
# ctx ci-dessus.
from app.templating import ENV as _ENV27
page_data.update(
updated_at=page.get("updated_at", ""),
created_at=page.get("created_at", ""),
user_id=_uid or 0,
is_shared=page_is_shared,
clip_icon=_ENV27.from_string(
"{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}"
).render(),
)
# Select template: collection pages use database table view
if page.get("content_format") == "collection" and not embed:
template = env.get_template("page_editor_collection.html")
-153
View File
@@ -341,74 +341,6 @@ def library_private(
return {"items": items}
@router.get("/local-workspace-children/{item_id:int}")
def library_local_workspace_children(item_id: int, request: Request):
"""Return children of a local workspace item for tree expansion."""
_get_user_id(request)
with get_conn() as conn:
# Get the item to find its workspace
item = conn.execute(
"SELECT workspace_id FROM local_workspace_items WHERE id=? AND deleted_at IS NULL",
[item_id],
).fetchone()
if not item:
return {"items": []}
rows = conn.execute(
"SELECT id, name, is_folder, parent_id, content_format, size, "
"COALESCE(updated_at, created_at) as updated_at "
"FROM local_workspace_items "
"WHERE parent_id = ? AND deleted_at IS NULL "
"ORDER BY is_folder DESC, name COLLATE NOCASE",
[item_id],
).fetchall()
items = []
for r in rows:
name = r["name"] or "Untitled"
is_folder = bool(r["is_folder"])
icon = "📁" if is_folder else "📄"
fn = name.lower()
if not is_folder:
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
with get_conn() as conn:
child_count = conn.execute(
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
[r["id"]],
).fetchone()[0]
items.append({
"id": r["id"],
"title": name,
"icon": icon,
"is_folder": is_folder,
"source_type": "local-ws",
"source_label": "",
"workspace": "",
"workspace_name": "",
"author": "",
"author_initial": "?",
"updated_at": r["updated_at"] or "",
"visited_at": "",
"has_children": child_count > 0,
"children": [],
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
"content_format": r["content_format"] or "file",
"favorited": False,
"page_icon": "",
"tags": [],
"size_display": _format_size(r["size"]) if r["size"] else "",
})
return {"items": items}
@router.get("/children/{page_id:int}")
def library_children(page_id: int, request: Request):
"""Return child pages for a given parent page (for tree expansion in Library)."""
@@ -449,91 +381,6 @@ def library_repository(
return {"items": items}
@router.get("/local-workspace")
def library_local_workspace(
request: Request,
workspace_id: int = Query(default=0),
):
"""Return local workspace items (files/folders) formatted for Library display."""
from app.routers.dashboard import _get_active_workspace
uid = _get_user_id(request)
# Get the active workspace
ws = _get_active_workspace(request, user_id=uid)
if not ws:
return {"items": []}
ws_id = workspace_id or ws["id"]
# Query local workspace tree
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, is_folder, parent_id, content_format, size, "
"COALESCE(updated_at, created_at) as updated_at "
"FROM local_workspace_items "
"WHERE workspace_id = ? AND deleted_at IS NULL "
"ORDER BY is_folder DESC, name COLLATE NOCASE",
[ws_id],
).fetchall()
items = []
for r in rows:
name = r["name"] or "Untitled"
is_folder = bool(r["is_folder"])
icon = "📁" if is_folder else "📄"
fn = name.lower()
if not is_folder:
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
# Check for children
child_count = conn.execute(
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
[r["id"]],
).fetchone()[0]
items.append({
"id": r["id"],
"title": name,
"icon": icon,
"is_folder": is_folder,
"source_type": "local-ws",
"source_label": ws.get("name", "Workspace"),
"workspace": ws.get("name", ""),
"workspace_name": ws.get("name", ""),
"author": "",
"author_initial": "?",
"updated_at": r["updated_at"] or "",
"visited_at": "",
"has_children": child_count > 0,
"children": [],
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
"content_format": r["content_format"] or "file",
"favorited": False,
"page_icon": "",
"tags": [],
"size_display": _format_size(r["size"]) if r["size"] else "",
})
return {"items": items}
def _format_size(size_bytes):
if not size_bytes:
return ""
if size_bytes < 1024:
return f"{size_bytes} B"
if size_bytes < 1048576:
return f"{size_bytes/1024:.1f} KB"
if size_bytes < 1073741824:
return f"{size_bytes/1048576:.1f} MB"
return f"{size_bytes/1073741824:.1f} GB"
@router.get("/workspace")
def library_workspace(
request: Request,
+13
View File
@@ -355,6 +355,19 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
return {"status": "error", "detail": str(exc)}
def run_event_sync(coro, timeout: float = 60.0):
"""A21 phase 2b : exécute une coroutine d'événement depuis un handler synchrone.
Bloque le worker threadpool (jamais la boucle d'event) et attend la fin —
déterministe, exactement ce que faisait l'await avant la conversion des
routes en `def`.
ponytail: les clients httpx des services sont créés à chaque appel (aucun
lien de boucle) ; si un jour un client/queue est lié à la boucle de l'app,
passer à `asyncio.run_coroutine_threadsafe` + boucle capturée au lifespan.
"""
return asyncio.run(asyncio.wait_for(coro, timeout))
async def fire_event(event: str, payload: dict):
"""Dispatch an event to outbound webhooks and matching automations."""
# v7.3.0: page.updated → in-app notification to followers (throttled).
+1 -166
View File
@@ -15,172 +15,7 @@
open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
############################################################################}
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* ═════════════════════════════════════════════════════════════════════
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);
}
})();
</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/_ctx_menu.js?v={{ asset_version }}"></script>
<style>
.fd-ctx-menu{position:fixed !important;top:0;left:0;min-width:230px;max-width:280px;max-height:calc(100vh - 16px);overflow-y:auto;z-index:2000;padding:4px;}
+2 -299
View File
@@ -10,306 +10,9 @@
############################################################################}
{% set picker_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','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %}
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
(function () {
var FD_ICONS = {
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
'file': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
'bot': '<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/><line x1="12" y1="20" x2="12" y2="24"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/>',
'users': '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'book': '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
'check-square': '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
'help-circle': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
'refresh': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
'bell': '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
'chevron-left': '<polyline points="15 18 9 12 15 6"/>',
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
'bar-chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
'grid': '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
'align-left': '<line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/>',
'corner-down-right': '<polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/>',
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
'key': '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
'inbox': '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
'edit': '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>',
'eye-off': '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>',
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
'share': '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>',
'more-horizontal': '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
'paperclip': '<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
'sparkles': '<path d="M12 3l1.9 5.8 6.1.5-4.9 3.6 1.7 5.8-4.8-3.5-4.8 3.5 1.7-5.8-4.9-3.6 6.1-.5z"/>',
'lightbulb': '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/>',
'tag': '<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
'trending-up': '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'user': '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'
};
window.FD_ICONS = FD_ICONS;
window.fd_icon = function (name, size) {
size = size || 18;
var inner = FD_ICONS[name];
if (inner) return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + inner + '</svg>';
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 '<img src="' + v.replace(/"/g, '&quot;') + '" alt="" style="width:' + size + 'px;height:' + size + 'px;object-fit:contain;vertical-align:middle;display:inline-block;">';
}
if (FD_ICONS[v] || (window.getSvgIcon && getSvgIcon(v, size))) return window.fd_icon(v, size);
return v;
};
})();
</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/_icon_picker_1.js?v={{ asset_version }}"></script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(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);
}
};
})();
</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/_icon_picker_2.js?v={{ asset_version }}"></script>
<style>
.fd-icon-picker{position:fixed;top:0;left:0;width:344px;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.28);z-index:2200;display:flex;flex-direction:column;max-height:440px;overflow:hidden;padding:0;}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,4 +1,5 @@
{# Card detail modal content — full issue info + comments #}
{% from '_icons.html' import fd_icon %}
<div class="card-detail" x-data="cardDetail()">
<!-- Title -->
<div style="display:flex; align-items:flex-start; gap:12px; margin-bottom:16px;">
+1 -627
View File
@@ -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;}
</style>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', () => {
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 = '<li class="sidebar-item empty-hint"><span class="page-icon">'+getSvgIcon('link',14)+'</span><span class="page-name text-dim">Connect Gitea to browse files</span></li>';
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 += '<li class="sidebar-item" data-gitea-path="' + item.path + '" data-gitea-type="' + (item.type === 'folder' ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '') + '" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
html += '<span class="page-icon">' + icon + '</span>';
html += '<span class="page-name">' + item.name + '</span>';
html += '</li>';
}
// Add Private Pages link
html += '<li style="border-top:1px solid var(--border);margin:8px 0;"></li>';
html += '<li class="sidebar-item" id="gitea-private-link" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
html += '<span class="page-icon">'+getSvgIcon('lock',14)+'</span><span class="page-name">Private Pages</span></li>';
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);
});
},
};
});
});
</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
<div class="gw-main" x-data="giteaWorkspace">
<!-- File view -->
<div x-show="showFile && !showEditor" x-transition>
+1 -159
View File
@@ -210,164 +210,6 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
</div>
</div>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
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<this.files.length;i++) {
const r = await fetch('/api/import/preview', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:this.buildForm(this.files[i])});
const d = await r.json();
if (!r.ok) { this.error = (this.files[i].name+': '+(d.detail||'Erreur')); continue; }
if (!merged) merged = {source_label:d.source_label, pages:[], warnings:[], stats:{}};
merged.pages.push(...(d.pages||[]));
merged.warnings.push(...(d.warnings||[]));
this.progress = Math.round(((i+1)/this.files.length)*100);
}
this.preview = merged;
this.mapping = {};
(merged ? merged.pages : []).forEach(p => (p.schema||[]).forEach(c => { this.mapping[c.name]=c.type; }));
} catch(e) { this.error = 'Erreur réseau'; }
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<this.files.length;i++) {
const d = await this.importOne(this.files[i]);
if (d) {
for (const k of ['pages_created','pages_updated','collections_created','rows_created','attachments','skipped'])
aggregate[k] += (d[k]||0);
aggregate.warnings.push(...(d.warnings||[]));
aggregate.errors.push(...(d.errors||[]));
aggregate.per_file.push({filename:this.files[i].name, report:d});
} else {
aggregate.errors.push({title:this.files[i].name, error:this.files[i].error||'Erreur'});
aggregate.per_file.push({filename:this.files[i].name, report:{status:'error'}});
}
this.progress = Math.round(((i+1)/this.files.length)*100);
}
this.report = aggregate;
this.busy = false;
},
async doUrl() {
this.busy = true; this.urlError = ''; this.report = null; this.progress = 40;
try {
const r = await fetch('/api/import/url', {
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
body: JSON.stringify({url:this.urlValue.trim()})
});
const d = await r.json();
if (!r.ok) { this.urlError = d.detail || 'Erreur'; return; }
this.report = d; this.progress = 100;
} catch(e) { this.urlError = 'Erreur réseau'; }
finally { this.busy = false; }
},
async doForge(kind) {
this.busy = true; this.forgeError = ''; this.report = null; this.progress = 30;
const endpoint = kind === 'repo' ? '/api/import/forge-repo' : '/api/import/forge';
try {
const r = await fetch(endpoint, {
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
body: JSON.stringify({
provider:this.forgeProvider, owner:this.forgeOwner.trim(),
repo:this.forgeRepo.trim(), state:this.forgeState
})
});
const d = await r.json();
if (!r.ok) { this.forgeError = d.detail || 'Erreur'; return; }
this.report = d; this.progress = 100;
} catch(e) { this.forgeError = 'Erreur réseau'; }
finally { this.busy = false; }
},
async pollJob(jobId, f) {
for (let i=0;i<600;i++) {
await new Promise(res => 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);
}
};
}
</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/import.js?v={{ asset_version }}"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+1 -154
View File
@@ -172,158 +172,5 @@
</div>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
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)}`;
}
}
};
}
</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/workspaces.js?v={{ asset_version }}"></script>
{% endblock %}
+518 -84
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.8.0"
"version": "7.22.0"
},
"paths": {
"/auth/register": {
@@ -10765,47 +10765,6 @@
}
}
},
"/api/library/local-workspace-children/{item_id}": {
"get": {
"tags": [
"library"
],
"summary": "Library Local Workspace Children",
"description": "Return children of a local workspace item for tree expansion.",
"operationId": "library_local_workspace_children_api_library_local_workspace_children__item_id__get",
"parameters": [
{
"name": "item_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Item Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/library/children/{page_id}": {
"get": {
"tags": [
@@ -10899,48 +10858,6 @@
}
}
},
"/api/library/local-workspace": {
"get": {
"tags": [
"library"
],
"summary": "Library Local Workspace",
"description": "Return local workspace items (files/folders) formatted for Library display.",
"operationId": "library_local_workspace_api_library_local_workspace_get",
"parameters": [
{
"name": "workspace_id",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 0,
"title": "Workspace Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/library/workspace": {
"get": {
"tags": [
@@ -16725,6 +16642,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -16974,6 +16902,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17123,6 +17062,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17233,6 +17183,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17397,6 +17358,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17462,6 +17434,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17617,6 +17600,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17727,6 +17721,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17837,6 +17842,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18003,6 +18019,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18178,6 +18205,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18288,6 +18326,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18454,6 +18503,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18564,6 +18624,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18674,6 +18745,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18849,6 +18931,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18905,6 +18998,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19015,6 +19119,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19062,6 +19177,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19109,6 +19235,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19219,6 +19356,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19275,6 +19423,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19385,6 +19544,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19495,6 +19665,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19551,6 +19732,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19715,6 +19907,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19771,6 +19974,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19881,6 +20095,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20125,6 +20350,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20217,6 +20453,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20365,6 +20612,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20421,6 +20679,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20531,6 +20800,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20753,6 +21033,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20809,6 +21100,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20919,6 +21221,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21085,6 +21398,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21195,6 +21519,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21251,6 +21586,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21361,6 +21707,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21592,6 +21949,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21648,6 +22016,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21861,6 +22240,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22367,6 +22757,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22618,6 +23019,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22674,6 +23086,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22961,6 +23384,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
+165
View File
@@ -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);
}
})();
+77
View File
@@ -0,0 +1,77 @@
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
(function () {
var FD_ICONS = {
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
'file': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
'bot': '<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/><line x1="12" y1="20" x2="12" y2="24"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/>',
'users': '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'book': '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
'check-square': '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
'help-circle': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
'refresh': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
'bell': '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
'chevron-left': '<polyline points="15 18 9 12 15 6"/>',
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
'bar-chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
'grid': '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
'align-left': '<line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/>',
'corner-down-right': '<polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/>',
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
'key': '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
'inbox': '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
'edit': '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>',
'eye-off': '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>',
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
'share': '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>',
'more-horizontal': '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
'paperclip': '<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
'sparkles': '<path d="M12 3l1.9 5.8 6.1.5-4.9 3.6 1.7 5.8-4.8-3.5-4.8 3.5 1.7-5.8-4.9-3.6 6.1-.5z"/>',
'lightbulb': '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/>',
'tag': '<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
'trending-up': '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'user': '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'
};
window.FD_ICONS = FD_ICONS;
window.fd_icon = function (name, size) {
size = size || 18;
var inner = FD_ICONS[name];
if (inner) return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + inner + '</svg>';
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 '<img src="' + v.replace(/"/g, '&quot;') + '" alt="" style="width:' + size + 'px;height:' + size + 'px;object-fit:contain;vertical-align:middle;display:inline-block;">';
}
if (FD_ICONS[v] || (window.getSvgIcon && getSvgIcon(v, size))) return window.fd_icon(v, size);
return v;
};
})();
+220
View File
@@ -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);
}
};
})();
+17
View File
@@ -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();
}
});
})();
File diff suppressed because it is too large Load Diff
+626
View File
@@ -0,0 +1,626 @@
document.addEventListener('alpine:init', () => {
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 = '<li class="sidebar-item empty-hint"><span class="page-icon">'+getSvgIcon('link',14)+'</span><span class="page-name text-dim">Connect Gitea to browse files</span></li>';
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 += '<li class="sidebar-item" data-gitea-path="' + item.path + '" data-gitea-type="' + (item.type === 'folder' ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '') + '" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
html += '<span class="page-icon">' + icon + '</span>';
html += '<span class="page-name">' + item.name + '</span>';
html += '</li>';
}
// Add Private Pages link
html += '<li style="border-top:1px solid var(--border);margin:8px 0;"></li>';
html += '<li class="sidebar-item" id="gitea-private-link" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
html += '<span class="page-icon">'+getSvgIcon('lock',14)+'</span><span class="page-name">Private Pages</span></li>';
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);
});
},
};
});
});
+158
View File
@@ -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<this.files.length;i++) {
const r = await fetch('/api/import/preview', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:this.buildForm(this.files[i])});
const d = await r.json();
if (!r.ok) { this.error = (this.files[i].name+': '+(d.detail||'Erreur')); continue; }
if (!merged) merged = {source_label:d.source_label, pages:[], warnings:[], stats:{}};
merged.pages.push(...(d.pages||[]));
merged.warnings.push(...(d.warnings||[]));
this.progress = Math.round(((i+1)/this.files.length)*100);
}
this.preview = merged;
this.mapping = {};
(merged ? merged.pages : []).forEach(p => (p.schema||[]).forEach(c => { this.mapping[c.name]=c.type; }));
} catch(e) { this.error = 'Erreur réseau'; }
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<this.files.length;i++) {
const d = await this.importOne(this.files[i]);
if (d) {
for (const k of ['pages_created','pages_updated','collections_created','rows_created','attachments','skipped'])
aggregate[k] += (d[k]||0);
aggregate.warnings.push(...(d.warnings||[]));
aggregate.errors.push(...(d.errors||[]));
aggregate.per_file.push({filename:this.files[i].name, report:d});
} else {
aggregate.errors.push({title:this.files[i].name, error:this.files[i].error||'Erreur'});
aggregate.per_file.push({filename:this.files[i].name, report:{status:'error'}});
}
this.progress = Math.round(((i+1)/this.files.length)*100);
}
this.report = aggregate;
this.busy = false;
},
async doUrl() {
this.busy = true; this.urlError = ''; this.report = null; this.progress = 40;
try {
const r = await fetch('/api/import/url', {
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
body: JSON.stringify({url:this.urlValue.trim()})
});
const d = await r.json();
if (!r.ok) { this.urlError = d.detail || 'Erreur'; return; }
this.report = d; this.progress = 100;
} catch(e) { this.urlError = 'Erreur réseau'; }
finally { this.busy = false; }
},
async doForge(kind) {
this.busy = true; this.forgeError = ''; this.report = null; this.progress = 30;
const endpoint = kind === 'repo' ? '/api/import/forge-repo' : '/api/import/forge';
try {
const r = await fetch(endpoint, {
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
body: JSON.stringify({
provider:this.forgeProvider, owner:this.forgeOwner.trim(),
repo:this.forgeRepo.trim(), state:this.forgeState
})
});
const d = await r.json();
if (!r.ok) { this.forgeError = d.detail || 'Erreur'; return; }
this.report = d; this.progress = 100;
} catch(e) { this.forgeError = 'Erreur réseau'; }
finally { this.busy = false; }
},
async pollJob(jobId, f) {
for (let i=0;i<600;i++) {
await new Promise(res => 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);
}
};
}
+1039
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+153
View File
@@ -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)}`;
}
}
};
}
+9 -2
View File
@@ -270,8 +270,15 @@ def _read_template(name: str) -> str:
return fh.read()
def _read_js(name: str) -> str:
"""A27 : le JS de l'éditeur vit dans static/js depuis l'extraction."""
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
with open(os.path.join(base, "static", "js", name), encoding="utf-8") as fh:
return fh.read()
def test_editor_has_ai_slash_commands(client):
src = _read_template("_page_editor_scripts.html")
src = _read_js("page_editor_scripts.js")
for cmd in ("ai_write", "ai_summarize", "ai_translate", "ai_continue"):
assert cmd in src
assert "AI writing" in src
@@ -280,7 +287,7 @@ def test_editor_has_ai_slash_commands(client):
def test_editor_has_autocomplete(client):
src = _read_template("_page_editor_scripts.html")
src = _read_js("page_editor_scripts.js")
assert "AIAC" in src
assert "autocomplete" in src
assert "AIAC.schedule" in src
+1 -1
View File
@@ -98,7 +98,7 @@ def test_workspace_offline_banner_shows_pending(client):
def test_editor_save_has_offline_hook():
src = (ROOT / "app" / "templates" / "_page_editor_scripts.html").read_text(encoding="utf-8")
src = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
assert "FlowOffline" in src
assert "savePageOffline" in src
assert "navigator.onLine" in src
+8 -1
View File
@@ -295,7 +295,14 @@ def test_page_editor_renders_page_is_shared(client):
r = client.get(f"/pages/{a}")
assert r.status_code == 200
assert "pageIsShared:true" in r.text
# A27 : la valeur n'est plus interpolée dans le JS inline mais exposée
# dans le JSON #page-data (le JS lit PD.is_shared)
import json as _json
import re as _re
m = _re.search(r'id="page-data"[^>]*>(.*?)</script>', r.text, _re.S)
assert m, "bloc #page-data absent de la page"
assert _json.loads(m.group(1))["is_shared"] is True
def test_tree_is_shared_includes_link_shared_pages(client):
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -391,7 +391,7 @@ def test_block_templates_service_shapes():
def test_editor_front_end_wired(client):
import pathlib
src = pathlib.Path("app/templates/_page_editor_scripts.html").read_text(encoding="utf-8")
src = pathlib.Path("static/js/page_editor_scripts.js").read_text(encoding="utf-8")
base = pathlib.Path("app/templates/base.html").read_text(encoding="utf-8")
assert "gtTok" in src and "fd-wiki-chip" in src # chip render + reader
assert "wiki/pages" in src and "wiki/titles" in src # picker + label resolution
+3 -3
View File
@@ -316,7 +316,7 @@ class TestBookmarkPersistence:
class TestLightbox:
def test_editor_lightbox_supports_keyboard_navigation(self):
tpl = Path("app/templates/_page_editor_scripts.html").read_text(encoding="utf-8")
tpl = Path("static/js/page_editor_scripts.js").read_text(encoding="utf-8")
assert "_openLightbox" in tpl
assert "fd-lightbox-nav" in tpl
assert "ArrowRight" in tpl and "ArrowLeft" in tpl
@@ -343,7 +343,7 @@ class TestLightbox:
class TestInlinePreviews:
def test_editor_renders_video_audio_pdf(self):
tpl = Path("app/templates/_page_editor_scripts.html").read_text(encoding="utf-8")
tpl = Path("static/js/page_editor_scripts.js").read_text(encoding="utf-8")
assert "<video controls" in tpl
assert "<audio controls" in tpl
assert "embed_type==='pdf'" in tpl
@@ -542,7 +542,7 @@ class TestCoverIcon:
class TestEditorEndpointPaths:
def test_editor_uses_board_prefixed_endpoints(self):
tpl = Path("app/templates/_page_editor_scripts.html").read_text(encoding="utf-8")
tpl = Path("static/js/page_editor_scripts.js").read_text(encoding="utf-8")
for endpoint in (
"/board/api/pages/'+this.pid+'/cover",
"/board/api/pages/'+this.pid+'/icon",