Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6914780f24 | ||
|
|
d7d9966edf | ||
|
|
3cab76fed5 |
+107
@@ -1,5 +1,112 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.41.0 (2026-10-01) — A20 phase 3 LOT 3b : gitea + agent + éditeur verts
|
||||
|
||||
### Changed
|
||||
|
||||
- **gitea_workspace** : `x-data="giteaWorkspace"` → appel `giteaWorkspace()`,
|
||||
`new Date(…)` → `fmtGwDate(pp)`, x-html icône d'arbre → `bindGwIcon`
|
||||
(x-init + `Alpine.effect`).
|
||||
- **agent_panel** : x-html markdown → `bindMarkdown($el, m)` (effet réactif
|
||||
sur `m.content`).
|
||||
- **page_editor (les 12 sites `window.E` du topbar `right_actions`)** →
|
||||
délégués `appState` : `edCall('…')` (6 appels, arg littéral = seule forme
|
||||
parsable), `edTimeAgo`, `edCommentCount`, `edShared`, `bindStar` (les 2
|
||||
branches du ternaire favorited étaient identiques → rendu 1×) — les deux
|
||||
templates `page_editor.html` + `page_editor_collection.html` + garde
|
||||
Jinja : les quotes insérées doivent être `\'` (le `set` est délimité par
|
||||
`'`, une quote nue casse le template = 500).
|
||||
- **_page_editor_content** : +3 sites (`window.E.commentOnSelection` →
|
||||
`edCall`, `backlinksOpen…location.href` → `openBacklink(b)`,
|
||||
`Math.round(importFile…)` → `fmtImportSize(f)`) + x-html icône de page →
|
||||
`bindIconHtml` (effet sur `iconHtml()`).
|
||||
- **Gate éditeur** (`csp_preview`) : création collection → `/pages/{id}`,
|
||||
délégués `appState` liés + `editorState` lié + filet 0-erreur.
|
||||
|
||||
### Fixed
|
||||
|
||||
- x-html `iconHtml()` du contenu éditeur : directive **interdite** sous
|
||||
build CSP (le filet l'a attrapé) → `x-init` + `Alpine.effect`.
|
||||
|
||||
### ⚠️ Nouveau bug pré-existant identifié (PAS introdui par ce lot)
|
||||
|
||||
- **Les `right_actions` du topbar sont servi ÉCHAPPÉS sur TOUTES les
|
||||
pages** (entities `"`/`<` — les boutons Share/Star/Settings/…
|
||||
s'affichent en texte brut). Reproductible : `curl /workspaces` →
|
||||
`"topbar-btn"`. Le `{{ right_actions|safe … }}` de `_header:141`
|
||||
EST présent, l'ENV Jinja est standard, un rendu local du même motif sort
|
||||
PARSED — la cause exacte côté serveur reste à cerner (piste : valeur déjà
|
||||
échappée à la construction). Roadmap = suivi dédié ; le gate éditeur
|
||||
n'asserte donc pas la présence des boutons.
|
||||
|
||||
## v7.40.0 (2026-10-01) — A20 phase 3 LOT 3a : 5 surfaces de plus vertes
|
||||
|
||||
### Added
|
||||
|
||||
- **Gate `csp_preview` « surfaces simples »** : `/welcome`, `/trash`,
|
||||
`/accounts`, `/workspace`, `/import` — **0 modification de code
|
||||
nécessaire** sur les 4 premières (scan statique 0 expression/x-html +
|
||||
registres Alpine.data posés au lot 1). **8 surfaces couvertes** au total
|
||||
(base shell, library, settings, local workspace + celles-ci).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bug pré-existant sur `/import` (visible sous les DEUX builds)** :
|
||||
`x-text="'🔗 '+report.relations…"` évalué alors que `report = null`
|
||||
(le `x-show` parent ne masque pas, il initialise quand même) → pageerror
|
||||
« Cannot read property … 'relations' » — garde `report && report.relations`.
|
||||
(Le probe montrait aussi un 401 console pré-existant sur la page — hors
|
||||
périmètre, non touché.)
|
||||
|
||||
### Notes
|
||||
|
||||
- Reste ph3 : page_editor (12 sites `window.E` dans `right_actions`),
|
||||
gitea_workspace (`new Date`), agent_panel (site `x-text` markdown +
|
||||
1 x-html), board + table_view/teamload/card_detail (gabarits liés au
|
||||
contexte Gitea, scan statique propre) → bascule réelle ensuite.
|
||||
|
||||
## v7.39.0 (2026-10-01) — A20 phase 3 LOT 2 : settings + local workspace verts
|
||||
|
||||
### Added
|
||||
|
||||
- **2 gates `csp_preview` de plus** : `settings` (composant lié, overlay
|
||||
visible, 0 erreur) et `local workspace` (recherche focalisée via
|
||||
`Alpine.nextTick`, chips filtre en SVG via `bindSvg`, 0 erreur).
|
||||
**3 surfaces vertes** sous build CSP : library, settings, local workspace.
|
||||
|
||||
### Changed
|
||||
|
||||
- **settings** : `window.history.back()`/`new Date(…)` → méthodes
|
||||
`historyBack`/`fmtLastLogin`/`fmtAuditDate` ; `?.` → ternaires.
|
||||
- **local workspace** : `x-data="_wsInitData"` → registre `wsInitData()`
|
||||
(le build CSP ne résout que le registre) ; 14 `x-html` → `x-init` +
|
||||
`Alpine.effect` (`bindSvg`/`bindFileIcon`/`bindNodeIcon`/`bindChildren`/
|
||||
`bindPreview`) ; `$nextTick`+`$refs` arrow → `toggleSearch()` ;
|
||||
`window.FlowDeck.*` → `createPageAt`/`createFolderAt` ; `?.` → ternaires ;
|
||||
`@contextmenu="_wsInitData.…"` → appel de méthode.
|
||||
- **Partage d'état JS↔Alpine (piège du build CSP)** : `ji` = snapshot des
|
||||
**valeurs** de toutes les propriétés `globalThis` au boot → l'objet mis
|
||||
sur `window` avant Alpine est **banni** (« Accessing global variables is
|
||||
prohibited »). Fix : objet porté par une **const lexicale** (non propriété
|
||||
`globalThis`) + factory Alpine.data qui le retourne → **même objet**
|
||||
partagé, réactivité intacte (une copie `Object.assign` aurait coupé les
|
||||
mises à jour JS : preview, uploads, isDragging).
|
||||
- **Bloc preview hors div racine** (structure pré-existante : le parseur
|
||||
referme la racine avant, masquée par le fallback window d'Alpine
|
||||
standard) → composant `wsPreview` **déléguant** vers `_wsInitData`
|
||||
(`Alpine.reactive` pour la réactivité ; wrapper = objet unique, les
|
||||
magics `$nextTick` ne sont redéfinissables qu'une fois — deuxième
|
||||
montage du même objet = « Cannot redefine property »).
|
||||
- **`.env` local : `RATE_LIMIT_REQUESTS=600`** — les rafales E2E
|
||||
Playwright (5 tests × ~25 requêtes) butaient sur le 60/min par IP ;
|
||||
défaut produit inchangé.
|
||||
|
||||
### Notes
|
||||
|
||||
- Reste ph3 : page_editor, board, agent_panel, import, gitea_workspace,
|
||||
welcome/accounts/trash/team_load/workspace/table_view/card_detail →
|
||||
puis bascule réelle (retrait `unsafe-eval`).
|
||||
|
||||
## v7.38.0 (2026-10-01) — A20 phase 3 LOT 1 : shell + library migres
|
||||
|
||||
### Added
|
||||
|
||||
+5
-1
@@ -1139,7 +1139,11 @@ Quality DB views, Agent IA Palette → Realtime + E
|
||||
- [x] **A19 — Liste CSRF trop large (34 préfixes) — TERMINÉ 2026-09-30 : 17 préfixes retirés, aucun préfixe cookie-auth n'est plus exempté** (12 en v7.3.5 après scan + 5 en v7.3.6 après équipement des 46 call sites) (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) puis les 5 derniers en v7.3.6 (46 call sites équipés de `X-CSRF-Token` dans agent_panel, settings, local_workspace, library, gitea_workspace, workspace, workspaces, welcome) — il ne reste que du Bearer/callback/public/infra : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.*
|
||||
- [x] **A20 — CSP sans filet — PARTIEL : `script-src 'unsafe-inline' 'unsafe-eval'`** (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'`, resserrer `img-src`/`connect-src`.* — **fait 2026-10-01 (nonce)** : `script-src 'self' 'unsafe-eval' 'nonce-<per-request>'` — `unsafe-inline` retiré de script-src (fin des XSS injectés en JS) ; ContextVar `CSP_NONCE` posée par le middleware avant `call_next`, lue par `{{ csp_nonce() }}` (38 tags inline dans les templates + `_with_nonce()` pour la constante `LOCAL_LOGIN_HTML` + 3 scripts Python dans collections.py) ; htmx re-çoit le nonce via `<meta name="htmx-config">` (réponses boostées) ; les 74 handlers `onclick=` restent couverts par `script-src-attr 'unsafe-inline'` ; chart.js/leaflet (CDN, déjà utilisés par les vues chart/map et BLOQUÉS par CSP depuis toujours) ajoutés à `script-src`/`style-src` avec commentaire `ponytail:` (upgrade : vendoriser). **Phase 2 faite 2026-10-01** : **CDN vendorisé + connect-src fermé**. chart.js 4.5.1, leaflet 1.9 (js/css + 5 png) téléchargés vers `static/js/vendor/` (déjà ignoré par eslint) ; les 3 URL de `collections.py` pointent en local ; **`script-src` n'a plus aucun hôte tiers** (jsdelivr/unpkg retirés), idem `style-src` ; **`connect-src` = `'self' ws://{host} wss://{host}`** (Host de la requête, caractères filtrés — uvicorn rejette déjà les Host invalides) : le `https:` universel (canal d'exfil) et les `ws:`/`wss:` tout-hôtes disparaissent ; grep négatif = **0 fetch cross-origin côté front** ; Google Fonts étaient **morts** dans la CSP (0 ref) → retirés ; `img-src https:` **gardé volontairement** (unfurls YouTube/Vimeo… + tuiles OSM inénumérables, `ponytail:` commenté dans le code). Test `test_csp_no_cdn_and_vendor` (CSP sans CDN, connect-src exact, 4 assets vendor 200, source collections.py) + `test_view_chart_renders` mis à jour (vendor path) → 1090/1090. **Reste A20 → phase 3 scopée par probes (2026-10-01)** : htmx = **FINI** (`allowEval:false` dans le meta htmx-config — 0 `hx-on`/`hx-vars`/`hx-vals` grep) ; **`unsafe-eval` reste uniquement pour Alpine standard**. Le build `@alpinejs/csp` a été téléchargé et **testé** (0 `eval`/`new Function`, parseur maison, tourne sous CSP strict) mais est **bloqué** sur FlowDeck : (a) **13 expressions non parsables** par la grammaire restreinte (arrows ×2, `typeof` ×1, `new Date` ×4, optional-chaining ×6 — base, library, local_workspace, settings, gitea_workspace), (b) **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`, markdown agent, preview) = **interdits** par le build CSP (innerHTML), (c) le scope des expressions CSP = **données du composant uniquement** (0 variable globale ni `document` — probe : `Undefined variable: fmtDate`) → chaque site devient une méthode enregistrée via `Alpine.data`. **Plan** : migration composant par composant avec gate E2E dédiée par surface, puis retrait `unsafe-eval`.
|
||||
**LOT 1 fait 2026-10-01 (v7.38.0)** : (1) **harnais `e2e/csp_preview.spec.js`** — sert le build CSP à la place de `alpine.min.js` par interception Playwright (0 déploiement, tout parse-error = pageerror) ; (2) **les 16 composants `x-data="fn()"` non enregistrés → `Alpine.data`** (registre = seule résolution du build CSP) ; (3) **shell base.html migré** : x-effect document → `syncSidebarClass()`, $nextTick arrow → `initSidebarSort()`, `window.FlowDeck.*` → méthodes, Object/Math/window dans x-for/:style → `sidebarSections()`/`sectionMenuPos()` ; (4) **x-html du shell migrés** → `x-init`+`Alpine.effect` (agent, carte projet, library ×3) + recherche library (`toggleSearch`) ; (5) **eslint 0/0 restauré** (globals `getCsrf` A38 + `;;` résiduels) ; **gate library VERT** (0 pageerror, icônes + recherche vérifiées) → 1093/1093.
|
||||
**Reste ph3** : surfaces settings, local_workspace, gitea_workspace, page_editor, board, agent_panel, import, welcome/accounts/trash/team_load/workspace/table_view/card_detail (partiels) → csp_preview vert partout, puis bascule réelle. `img-src` : si un proxy d'images local arrive. Effort : **L** (reste : L, plan ci-dessus).
|
||||
**LOT 2 fait 2026-10-01 (v7.39.0)** : **settings + local_workspace VERTS** en csp_preview (3/5 surfaces) — voir CHANGELOG pour les 2 pièges structurels résolus (snapshot `ji` = objet `window` pré-boot banni → const lexicale partagée ; bloc preview hors racine → composant `wsPreview` déléguant, wrapper unique à cause des magics) + `RATE_LIMIT_REQUESTS=600` en `.env` local (rafales E2E vs 60/min).
|
||||
**LOT 3a fait 2026-10-01 (v7.40.0)** : gate « surfaces simples » = **welcome, trash, accounts, workspace, import VERTS** (4 sans aucune modification — scan propre + registres lot 1) + **fix bug pré-existant `/import`** (x-text `report.relations` non garde → pageerror sous les 2 builds). **8 surfaces couvertes** au total.
|
||||
**LOT 3b fait 2026-10-01 (v7.41.0)** : **gitea_workspace + agent_panel + page_editor VERTS** (éditeur = 15 sites : 12 `window.E` → délégués `edCall`/`edTimeAgo`/`edCommentCount`/`edShared`/`bindStar` dans appState + 3 dans `_page_editor_content` → `openBacklink`/`fmtImportSize`/`bindIconHtml` ; piège Jinja : quotes `\'` dans le `set`). **12 surfaces vertes** au total (7 gates).
|
||||
**Reste ph3** : board/table_view/teamload/card_detail (scan propre, gates liés au contexte Gitea — gitea actuellement down) → csp_preview vert partout, puis bascule réelle (retrait unsafe-eval).
|
||||
**🐛 BUG pré-existant à analyser (trouvé v7.41.0)** : **`right_actions` du topbar servi ÉCHAPPÉ sur TOUTES les pages** (entities `"`/`<`, boutons Share/Star/Settings en texte brut — repro `curl /workspaces`); `_header:141` a bien `|safe`, l'ENV est standard, rendu local du même motif = PARSED → cause serveur à cerner (piste : valeur échappée à la construction du set). Effort : **M**. `img-src` : si un proxy d'images local arrive. Effort : **L** (reste : L, plan ci-dessus).
|
||||
- [x] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 2026-09-30 → phase 1 le 2026-10-01 : `PRAGMA busy_timeout=5000` ajouté au point d'entrée unique `get_conn()` (db.py)** ; reste le wrapper async `anyio.to_thread` + la migration des 510 call sites : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`.* — **phase 1 faite 2026-10-01** : **352 routes `async def` SANS aucun `await`** converties en `def` (scan corps par corps : ni `await`/`async with`/`async for`, ni `asyncio`) → FastAPI les exécute dans son threadpool, donc tout leur travail SQLite quitte l'event loop, sans changer une ligne de logique (api_v2 : 60, dashboard : 40, collections : 25, board : 23, + main.py : 6 ; aucune occurrence `asyncio`/`run_coroutine` dans les corps convertis). **Phase 2a faite 2026-10-01 (api_v2)** : les routes dont le SEUL await était `body = await request.json()` (36) → paramètre FastAPI `body: dict = Body(default={})` (parsing fait par FastAPI avant l'appel, équivalences vérifiées : corps absent → `{}` comme le try/except d'avant, JSON invalide → 422 au lieu d'un silencieux `{}`) + conversion en `def` → **api_v2 passe à 96/115 routes hors loop**. **Phase 2b faite 2026-10-01 (api_v2 bouclé)** : helper `run_event_sync(coro)` (automations.py) — `asyncio.run` sur une boucle dédiée dans le worker threadpool : l'événement est EXÉCUTÉ ET ATTENDU avant la réponse (déterministe comme l'await) mais ne bloque jamais la boucle ; les 15 routes dont les seuls awaits étaient `json`/`_fire_event`/`fire_published`/`fire_unpublished` passent en `def`. **api_v2 : 111/115 routes hors loop**, les 4 restantes ont de vrais awaits réseau (`import_csv_v2` multipart, `project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`). **Phase 2c faite 2026-10-01 (repo-wide)** : **283 → 93 routes async** (**86 % des 667 routes hors loop**, avant 61 %) en 4 passes — (A) racine auth : `get_current_user` (session.py) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) étaient `async` **sans aucun await** → `def`, **47 `await` supprimés** dont 3 via l'alias `gcu` (piège : grep littéral aveugle, rattrapé par la suite) ; (B) re-scan → 19 routes flipées ; (C/D) **155 routes** `request.json`/événements → `Body(default={})` (3 formes : try/except `body = {}` intact, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, **0 test sur le 400**), forme conditionnelle content-type ×54 → défaut `{}`) + `run_event_sync` → `def`. **Reste async (93, justifié)** : form/upload/file (22), gitea/llm/oidc réseau (~25), `_json_body` 9, 2 JSON inline en argument, 1 fallback logique, 1 lecture conditionnelle web_clipper, + mixtes json+réseau. **A21 : fait** (sauf l'idée initiale d'`anyio.to_thread` par bloc DB — **inutile** : les routes sont DÉJÀ hors loop, le SQLite synchrone n'est plus sur la boucle). Effort : **L** (fait).
|
||||
- [x] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.*
|
||||
- [x] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.*
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.38.0 (A20 ph3 LOT 1 : shell base + library migres Alpine.data, harness csp_preview vert, eslint 0/0) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.41.0 (A20 ph3 LOT 3b : gitea + agent + éditeur verts — 12 sites window.E délégués; BUG pré-existant : right_actions servi échappé partout) | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||
|
||||
## Avancement Global
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.38.0",
|
||||
version="7.41.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -546,7 +546,7 @@
|
||||
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.6) 100%);z-index:1;"></div>
|
||||
</div>
|
||||
<div class="page-title-block">
|
||||
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-html="iconHtml()"></span>
|
||||
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-init="bindIconHtml($el)"></span>
|
||||
<div
|
||||
class="page-title-input"
|
||||
contenteditable="true"
|
||||
@@ -583,7 +583,7 @@
|
||||
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
|
||||
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
|
||||
@click="window.E && window.E.commentOnSelection()">
|
||||
@click="edCall('commentOnSelection')">
|
||||
💬 Comment
|
||||
</button>
|
||||
|
||||
@@ -820,7 +820,7 @@
|
||||
<div style="padding:24px;text-align:center;color:var(--text-dim);">No pages link here</div>
|
||||
</template>
|
||||
<template x-for="b in backlinksList" :key="b.id">
|
||||
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="backlinksOpen=false;window.location.href='/pages/'+b.id">
|
||||
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="openBacklink(b)">
|
||||
<div style="font-weight:500;color:var(--text-primary);" x-text="b.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-top:2px;" x-text="b.workspace || ''"></div>
|
||||
</a>
|
||||
@@ -856,7 +856,7 @@
|
||||
<template x-if="importFile">
|
||||
<div style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
|
||||
<span x-text="importFile.name"></span>
|
||||
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
|
||||
<span x-text="fmtImportSize(importFile)"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div style="display:flex;gap:8px;margin-top:10px;">
|
||||
|
||||
@@ -305,7 +305,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="fd-agent-msg-content" x-show="m.generating" x-cloak>Génération…</div>
|
||||
<div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-html="renderMarkdown(m.content)"></div>
|
||||
<div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-init="bindMarkdown($el, m)"></div>
|
||||
<div class="fd-agent-msg-content" x-show="!m.generating && m.role!=='assistant' && m.content" x-text="m.content"></div>
|
||||
<template x-if="m.proposal">
|
||||
<div class="fd-proposal-bar">
|
||||
|
||||
@@ -1574,6 +1574,19 @@
|
||||
bindProjectIcon(el, p) {
|
||||
Alpine.effect(() => { el.innerHTML = getSvgIcon(p.icon || 'folder', 20); });
|
||||
},
|
||||
// ── A20 ph3 : délégués du topbar éditeur (window.E hors portée CSP,
|
||||
// scope du topbar = appState) — voir right_actions de page_editor ──
|
||||
edCall(name) {
|
||||
if (window.E && typeof window.E[name] === 'function') window.E[name]();
|
||||
},
|
||||
edTimeAgo() { return (window.E && window.E.timeAgo) || ''; },
|
||||
edCommentCount() {
|
||||
return (window.E && window.E.commentCount > 0) ? window.E.commentCount : '';
|
||||
},
|
||||
edShared() { return !!(window.E && window.E.pageIsShared); },
|
||||
// les 2 branches du ternaire favorited étaient identiques → rendu 1×
|
||||
bindStar(el) { Alpine.effect(() => { el.innerHTML = getSvgIcon('star', 14); }); },
|
||||
|
||||
bindAgentIcon(el, a) {
|
||||
Alpine.effect(() => { el.innerHTML = a.icon || '🤖'; });
|
||||
},
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</style>
|
||||
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
|
||||
<div class="gw-main" x-data="giteaWorkspace">
|
||||
<div class="gw-main" x-data="giteaWorkspace()">
|
||||
<!-- File view -->
|
||||
<div x-show="showFile && !showEditor" x-transition>
|
||||
<div class="gw-file-toolbar">
|
||||
@@ -120,7 +120,7 @@
|
||||
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
|
||||
@contextmenu.prevent="openGwContext($event, item)"
|
||||
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
|
||||
<span x-html="item.type==='folder' ? getSvgIcon('folder',16) : getSvgIcon('file',16)" style="font-size:16px;"></span>
|
||||
<span x-init="bindGwIcon($el, item)" style="font-size:16px;"></span>
|
||||
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
|
||||
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
|
||||
</div>
|
||||
@@ -165,7 +165,7 @@
|
||||
@click="openPrivate(pp.id)">
|
||||
<div>
|
||||
<div style="font-weight:500;" x-text="pp.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);" x-text="fmtGwDate(pp)"></div>
|
||||
</div>
|
||||
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">{{ fd_icon('trash',14) }}</button>
|
||||
</div>
|
||||
|
||||
@@ -201,7 +201,7 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
|
||||
<template x-for="(e,i) in (report ? report.errors : [])" :key="i"><div x-text="'✕ '+e.title+' : '+e.error"></div></template>
|
||||
</div>
|
||||
<div class="warnings" x-show="report && report.relations && report.relations.relations_resolved">
|
||||
<div x-text="'🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)'"></div>
|
||||
<div x-text="report && report.relations ? '🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)' : ''"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost" @click="downloadReport()">Télécharger le rapport (JSON)</button>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
{% block content %}
|
||||
<span id="fd-local-ws-id" hidden>{{ workspace_id }}</span>
|
||||
<div x-data="_wsInitData"
|
||||
<div x-data="wsInitData()"
|
||||
@dragover.prevent="onDragOver($event)"
|
||||
@dragleave="onDragLeave($event)"
|
||||
@drop.prevent="onDrop($event)"
|
||||
@@ -482,10 +482,10 @@
|
||||
|
||||
<script type="application/json" id="lw-config" nonce="{{ csp_nonce() }}">{{ {"current_folder_id": current_folder_id, "workspace_id": workspace_id} | tojson }}</script>
|
||||
<script data-cfasync="false" src="/static/js/local_workspace.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof _wsInitData !== 'undefined');</script>
|
||||
|
||||
<div class="ws-split"
|
||||
@contextmenu.prevent="_wsInitData.onContextMenu($event)"
|
||||
@contextmenu.prevent="onContextMenu($event)"
|
||||
@touchstart="onTouchStart($event)"
|
||||
@touchend="onTouchEnd($event)"
|
||||
@touchmove="onTouchMove($event)"
|
||||
@@ -597,7 +597,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Search toggle -->
|
||||
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="searchOpen=!searchOpen; if(searchOpen) $nextTick(()=>$refs.searchInput?.focus())">
|
||||
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
<!-- Expand/Collapse (tree only) -->
|
||||
@@ -650,12 +650,12 @@
|
||||
|
||||
<!-- Filter chips (visual indicator when filter is active — shown in all views) -->
|
||||
<div class="filter-row" x-show="filterType" style="padding:4px 0;margin-bottom:6px;">
|
||||
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders" x-html="getSvgIcon('folder',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images" x-html="getSvgIcon('image',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders" x-init="bindSvg($el, 'folder', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images" x-init="bindSvg($el, 'image', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<span class="filter-clear" @click="filterType='', doFilter()">clear filter</span>
|
||||
</div>
|
||||
|
||||
@@ -723,7 +723,7 @@
|
||||
<span style="width:16px;flex-shrink:0;"></span>
|
||||
</template>
|
||||
|
||||
<span class="icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<span class="icon" x-init="bindFileIcon($el, node)"></span>
|
||||
|
||||
<template x-if="node.is_folder">
|
||||
<span class="name folder" @click.stop="navigateToFolder(node.id)"
|
||||
@@ -754,8 +754,8 @@
|
||||
<div class="actions">
|
||||
<template x-if="node.is_folder">
|
||||
<span style="display:flex;gap:2px">
|
||||
<button class="ws-act" @click.stop="window.FlowDeck.createPage(node.id)" title="New file">{{ fd_icon("file",14) }}</button>
|
||||
<button class="ws-act" @click.stop="window.FlowDeck.showCreateFolderModal(node.id)" title="New folder">{{ fd_icon("folder",14) }}</button>
|
||||
<button class="ws-act" @click.stop="createPageAt(node)" title="New file">{{ fd_icon("file",14) }}</button>
|
||||
<button class="ws-act" @click.stop="createFolderAt(node)" title="New folder">{{ fd_icon("folder",14) }}</button>
|
||||
<a class="ws-act" href="/local-workspace" title="Open workspace page" style="text-decoration:none;display:inline-flex;align-items:center;">{{ fd_icon("external-link",14) }}</a>
|
||||
</span>
|
||||
</template>
|
||||
@@ -765,7 +765,7 @@
|
||||
</div>
|
||||
<!-- Children -->
|
||||
<ul class="ws-tree" x-show="expanded[node.id]" x-transition
|
||||
x-html="renderChildren(node.children, (node.depth||0)+1, tagsVersion)">
|
||||
x-init="bindChildren($el, node)">
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
@@ -807,7 +807,7 @@
|
||||
<tr :class="{ selected: !!selectedIds[node.id] }">
|
||||
<td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></td>
|
||||
<td>
|
||||
<span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<span class="table-icon" x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
|
||||
<span class="col-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span>
|
||||
</td>
|
||||
@@ -864,7 +864,7 @@
|
||||
</tr>
|
||||
<template x-for="node in displayTree" :key="'l'+node.id">
|
||||
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer">
|
||||
<td><span x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<td><span x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
|
||||
<td class="col-name" x-text="node.name"></td>
|
||||
<td style="font-size:12px;color:var(--text-tertiary)" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
|
||||
@@ -902,7 +902,7 @@
|
||||
<template x-for="node in displayTree" :key="'d'+node.id">
|
||||
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer"
|
||||
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
|
||||
<td><span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<td><span class="table-icon" x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
|
||||
<td class="col-name" x-text="node.name"></td>
|
||||
<td class="col-path" x-text="node._path || '—'"></td>
|
||||
@@ -933,7 +933,7 @@
|
||||
<template x-for="node in displayTree" :key="'g'+node.id">
|
||||
<div class="title-card" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
|
||||
:class="{ 'selected-card': previewItem && previewItem.id === node.id }">
|
||||
<div class="title-card-icon" x-html="node.is_folder ? getSvgIcon('folder',24) : _fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></div>
|
||||
<div class="title-card-icon" x-init="bindNodeIcon($el, node)"></div>
|
||||
<div class="title-card-name">
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
|
||||
<span x-text="node.name"></span>
|
||||
@@ -960,7 +960,7 @@
|
||||
<div class="content-item" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
|
||||
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
|
||||
<div class="content-item-header">
|
||||
<span x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<span x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
|
||||
<span class="col-name" x-text="node.name"></span>
|
||||
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="_formatDate(node.updated_at)"></span>
|
||||
@@ -1023,7 +1023,7 @@
|
||||
<h3><span x-show="createType==='folder'">{{ fd_icon('folder',16) }}</span><span x-show="createType!='folder'">{{ fd_icon('file',16) }}</span> <span x-text="createType==='folder'?'New Folder':'New File'"></span></h3>
|
||||
<p class="modal-sub">
|
||||
In <strong>{{ workspace_name }}</strong> <span x-show="folderStack.length>1">/ <span x-text="(folderStack[folderStack.length-1]||{}).name||''"></span></span>
|
||||
<span x-show="parentFolder"> / <span x-text="parentFolder?.name"></span></span>
|
||||
<span x-show="parentFolder"> / <span x-text="parentFolder ? parentFolder.name : ''"></span></span>
|
||||
</p>
|
||||
<input class="modal-input" x-model="newName" :placeholder="createType==='folder'?'Folder name':'File name'" @keydown.enter="doCreate" x-ref="cinp">
|
||||
<div class="modal-actions">
|
||||
@@ -1037,7 +1037,7 @@
|
||||
<div class="modal-overlay" x-show="showRename" @click.outside="showRename=false" @keydown.escape="showRename=false" style="display:none">
|
||||
<div class="modal-box">
|
||||
<h3>{{ fd_icon("edit",14) }} Rename</h3>
|
||||
<p class="modal-sub">Rename <strong x-text="target?.name"></strong></p>
|
||||
<p class="modal-sub">Rename <strong x-text="target ? target.name : ''"></strong></p>
|
||||
<input class="modal-input" x-model="newName" @keydown.enter="doRename" x-ref="rinp">
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="showRename=false">Cancel</button>
|
||||
@@ -1050,7 +1050,7 @@
|
||||
<div class="modal-overlay" x-show="showDelete" @click.outside="showDelete=false" @keydown.escape="showDelete=false" style="display:none">
|
||||
<div class="modal-box">
|
||||
<h3>{{ fd_icon("trash",16) }} Delete</h3>
|
||||
<p class="modal-sub">Delete <strong x-text="target?.name"></strong>?</p>
|
||||
<p class="modal-sub">Delete <strong x-text="target ? target.name : ''"></strong>?</p>
|
||||
<div class="delete-confirm">This cannot be undone. Children will also be deleted.</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="showDelete=false">Cancel</button>
|
||||
@@ -1065,11 +1065,14 @@
|
||||
{% include "_ctx_menu.html" %}
|
||||
|
||||
<!-- Preview tooltip -->
|
||||
<div class="file-preview" x-show="previewVisible"
|
||||
<!-- ponytail: le parseur referme la div racine avant ce bloc (structure
|
||||
pré-existante, masquée par le fallback window d'Alpine standard) →
|
||||
wsPreview = wrapper déléguant vers _wsInitData (voir local_workspace.js) -->
|
||||
<div class="file-preview" x-data="wsPreview()" x-show="previewVisible"
|
||||
:style="{ left: previewX + 'px', top: previewY + 'px' }"
|
||||
@mouseenter="previewVisible = true" @mouseleave="previewVisible = false">
|
||||
<div class="file-preview-header" x-text="previewName"></div>
|
||||
<div class="file-preview-body" x-html="previewContent"></div>
|
||||
<div class="file-preview-body" x-init="bindPreview($el)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Panel — identical to Library peek-overlay -->
|
||||
|
||||
@@ -3,7 +3,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
|
||||
{% set page_title = page.title %}
|
||||
{% set nav_page_id = page.id %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn" @click="window.E && window.E.toggleComments()" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="window.E && window.E.commentCount>0 ? window.E.commentCount : \'\'"></span></button><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="edCall(\'toggleActivityOpen\')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn" @click="edCall(\'toggleComments\')" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="edCommentCount()"></span></button><button class="topbar-btn share-btn" @click="edCall(\'toggleShareOpen\')"><span x-show="!edShared()">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall(\'copyPageLink\')" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="edCall(\'toggleFavorite\')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall(\'toggleMoreOpen\')">⋯</button>' %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %} {% block content %}
|
||||
{% include "_page_editor_content.html" %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% set page_icon = "file" %}
|
||||
{% set page_title = page.title %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="edCall(\'toggleActivityOpen\')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn share-btn" @click="edCall(\'toggleShareOpen\')"><span x-show="!edShared()">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall(\'copyPageLink\')" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="edCall(\'toggleFavorite\')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall(\'toggleMoreOpen\')">⋯</button>' %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %} {% block content %}
|
||||
{% include "_database_table.html" %}
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
@media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}}
|
||||
</style>
|
||||
|
||||
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
|
||||
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="historyBack()">
|
||||
<div class="settings-panel" style="position:relative;">
|
||||
<button class="settings-close" @click="closeSettings()" title="Close">×</button>
|
||||
|
||||
@@ -515,9 +515,9 @@
|
||||
<div class="modal-box" style="max-width:360px;">
|
||||
<h3 style="margin:0 0 8px;">Delete tag</h3>
|
||||
<p style="color:var(--text-dim);margin:0 0 16px;">
|
||||
Are you sure you want to delete the tag "<strong x-text="deletingTag?.name"></strong>"?
|
||||
<span x-show="deletingTag?.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
|
||||
{{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag?.count"></span> item(s).
|
||||
Are you sure you want to delete the tag "<strong x-text="deletingTag ? deletingTag.name : ''"></strong>"?
|
||||
<span x-show="deletingTag && deletingTag.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
|
||||
{{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag ? deletingTag.count : 0"></span> item(s).
|
||||
</span>
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
@@ -531,7 +531,7 @@
|
||||
<div class="modal-box" style="max-width:360px;">
|
||||
<h3 style="margin:0 0 12px;">Rename tag</h3>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<div class="tag-color-dot" :style="{background: renamingTag?.color}" style="width:16px;height:16px;"></div>
|
||||
<div class="tag-color-dot" :style="{background: renamingTag ? renamingTag.color : ''}" style="width:16px;height:16px;"></div>
|
||||
<input type="text" class="settings-input" x-model="renameValue"
|
||||
@keydown.enter="confirmRenameTag()" @keydown.escape="renamingTag=null"
|
||||
style="flex:1;" autofocus>
|
||||
@@ -770,7 +770,7 @@
|
||||
<td x-text="u.folder_count" style="text-align:center;"></td>
|
||||
<td x-text="u.total_mb + ' MB'" style="text-align:right;"></td>
|
||||
<td>
|
||||
<span style="font-size:11px;color:var(--text-dim);" x-text="u.last_login ? new Date(u.last_login*1000).toLocaleDateString() : 'Never'"></span>
|
||||
<span style="font-size:11px;color:var(--text-dim);" x-text="fmtLastLogin(u)"></span>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;">
|
||||
@@ -837,7 +837,7 @@
|
||||
<tbody>
|
||||
<template x-for="e in auditLogs" :key="e.at + '-' + e.source + '-' + e.actor">
|
||||
<tr>
|
||||
<td><span style="font-size:12px;" x-text="new Date(e.at).toLocaleString()"></span></td>
|
||||
<td><span style="font-size:12px;" x-text="fmtAuditDate(e)"></span></td>
|
||||
<td><span style="font-size:11px;" :class="'audit-src audit-src-'+e.source" x-text="e.source"></span></td>
|
||||
<td><code style="font-size:11px;" x-text="e.actor"></code></td>
|
||||
<td><code style="font-size:11px;" x-text="e.action"></code></td>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.38.0"
|
||||
"version": "7.41.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Où atterrit right_actions sur /pages/{id} ? (parsé ou texte ?)
|
||||
const { chromium } = require('playwright-core');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
function findChromium() {
|
||||
const root = path.join(process.env.LOCALAPPDATA, 'ms-playwright');
|
||||
const dirs = fs.readdirSync(root).filter((d) => d.startsWith('chromium-') && !d.includes('headless'));
|
||||
dirs.sort();
|
||||
return path.join(root, dirs[dirs.length - 1], 'chrome-win64', 'chrome.exe');
|
||||
}
|
||||
const BASE = 'http://localhost:8080';
|
||||
const CSP_JS = path.join(__dirname, 'fixtures', 'alpine.csp.js');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true, executablePath: findChromium() });
|
||||
const ctx = await browser.newContext({ serviceWorkers: 'block' });
|
||||
const page = await ctx.newPage();
|
||||
const errs = [];
|
||||
page.on('pageerror', (e) => errs.push(e.message.slice(0, 110)));
|
||||
await page.goto(`${BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
if (await page.locator('#email').count()) {
|
||||
await page.fill('#email', process.env.FD_USER || '[email protected]');
|
||||
await page.fill('#password', process.env.FD_PASS || 'e2e-secret-123');
|
||||
await page.click('.btn-primary');
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 }).catch(() => {});
|
||||
}
|
||||
await page.route('**/static/js/alpine.min.js', (r) =>
|
||||
r.fulfill({ path: CSP_JS, contentType: 'application/javascript' })
|
||||
);
|
||||
const coll = await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/db/api', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'e2e-probe-editor' }),
|
||||
});
|
||||
return r.json();
|
||||
});
|
||||
await page.goto(`${BASE}/pages/${coll.id}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(800);
|
||||
const info = await page.evaluate(() => {
|
||||
const star = document.querySelector('.star-btn');
|
||||
const anyText = document.body.innerHTML.includes('edCall(');
|
||||
const parsed = document.querySelector('button.star-btn') ? 'parse' : 'pas-de-bouton';
|
||||
const parent = star ? star.parentElement.className : null;
|
||||
const txt = document.body.innerHTML.indexOf('toggleActivityOpen');
|
||||
const ctx = txt >= 0 ? document.body.innerHTML.slice(Math.max(0, txt - 160), txt + 60) : null;
|
||||
return { parsed: parsed, starParent: parent, edCallInHTML: anyText, ctx: ctx };
|
||||
});
|
||||
console.log(JSON.stringify(info, null, 1));
|
||||
console.log('erreurs:', errs.length ? errs : 'aucune');
|
||||
// cleanup
|
||||
await page.evaluate(async (id) => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch(`/db/api/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
|
||||
}, coll.id);
|
||||
await browser.close();
|
||||
})();
|
||||
+122
-9
@@ -16,8 +16,10 @@ const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
const errors = [];
|
||||
let currentUrl = '';
|
||||
test.beforeEach(async ({ page }) => {
|
||||
errors.length = 0;
|
||||
currentUrl = '';
|
||||
await page.route('**/static/js/alpine.min.js', (route) =>
|
||||
route.fulfill({
|
||||
path: require('path').join(__dirname, 'fixtures', 'alpine.csp.js'),
|
||||
@@ -29,7 +31,9 @@ test.beforeEach(async ({ page }) => {
|
||||
if (/Failed to load resource/.test(m.text())) return;
|
||||
errors.push(m.text());
|
||||
});
|
||||
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
|
||||
page.on('pageerror', (e) =>
|
||||
errors.push('pageerror@' + (currentUrl || '?') + ': ' + e.message)
|
||||
);
|
||||
});
|
||||
test.afterEach(() => expect(errors).toEqual([]));
|
||||
|
||||
@@ -53,13 +57,8 @@ async function login(page) {
|
||||
}
|
||||
}
|
||||
|
||||
test('A20-ph3 : library sous build Alpine CSP', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// le composant est lié par le registre Alpine.data (scope CSP)
|
||||
const bound = await page.evaluate(() => {
|
||||
async function assertBound(page) {
|
||||
return page.evaluate(() => {
|
||||
const el = document.querySelector('[x-data]');
|
||||
if (!el || !window.Alpine) return 'absent';
|
||||
try {
|
||||
@@ -69,7 +68,121 @@ test('A20-ph3 : library sous build Alpine CSP', async ({ page }) => {
|
||||
return 'throw:' + e.message;
|
||||
}
|
||||
});
|
||||
expect(bound).toBe('ok');
|
||||
}
|
||||
|
||||
test('A20-ph3 : surfaces simples sous build CSP (welcome/trash/accounts/workspace)', async ({ page }) => {
|
||||
// scan statique = 0 expression/x-html sur ces gabarits → ici on traque
|
||||
// les échecs RUNTIME (globales, timing de registre, scope de structure)
|
||||
// /welcome est anonyme (avant login aussi) mais login() ne gêne pas
|
||||
await login(page);
|
||||
for (const url of ['/welcome', '/trash', '/accounts', '/workspace', '/import',
|
||||
'/gitea-workspace']) {
|
||||
currentUrl = url;
|
||||
await page.goto(FD_BASE + url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(500);
|
||||
expect(await assertBound(page), `x-data non lié sur ${url}`).toBe('ok');
|
||||
}
|
||||
|
||||
// panneau agent (composant de base, x-html markdown migré via bindMarkdown)
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(400);
|
||||
const agent = await page.evaluate(() => {
|
||||
const el = document.querySelector('#fd-agent-panel');
|
||||
if (!el || !window.Alpine) return 'absent';
|
||||
try {
|
||||
const d = window.Alpine.$data(el);
|
||||
return d && typeof d === 'object' ? 'ok' : 'vide';
|
||||
} catch (e) {
|
||||
return 'throw:' + e.message;
|
||||
}
|
||||
});
|
||||
expect(agent).toBe('ok');
|
||||
});
|
||||
|
||||
test('A20-ph3 : éditeur de page (right_actions) sous build CSP', async ({ page }) => {
|
||||
// le topbar vit dans le scope appState : les12 sites window.E ont été
|
||||
// remplacés par edCall/edTimeAgo/edCommentCount/edShared/bindStar —
|
||||
// toute expression non parsable = pageerror (filet).
|
||||
await login(page);
|
||||
const coll = await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/db/api', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'e2e-csp-editor' }),
|
||||
});
|
||||
return r.json();
|
||||
});
|
||||
expect(coll.id, JSON.stringify(coll)).toBeTruthy();
|
||||
try {
|
||||
await page.goto(`${FD_BASE}/pages/${coll.id}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
// le composant éditeur est lié
|
||||
const editor = await page.evaluate(() => {
|
||||
const el = document.querySelector('#page-editor, .page-editor, [x-data]');
|
||||
const root = document.querySelector('.app-layout');
|
||||
const d1 = root && window.Alpine ? window.Alpine.$data(root) : null;
|
||||
const hasEd = d1 && typeof d1.edCall === 'function';
|
||||
let ed = 'absent';
|
||||
try {
|
||||
const cand = Array.from(document.querySelectorAll('[x-data]'))
|
||||
.map((e) => e.getAttribute('x-data'))
|
||||
.filter((a) => a && a.startsWith('editorState'));
|
||||
ed = cand.length ? 'ok' : 'aucun-editorState';
|
||||
} catch (e) { ed = 'throw'; }
|
||||
return { ed: ed, delegates: hasEd ? 'ok' : 'absent', el: !!el };
|
||||
});
|
||||
expect(editor.delegates).toBe('ok');
|
||||
expect(editor.ed).toBe('ok');
|
||||
// (le rendu des boutons right_actions est cassé côté SERVEUR —
|
||||
// entities " sur TOUTES les pages, régression pré-existante
|
||||
// documentée au ROADMAP — donc on n'asserte pas leur présence ;
|
||||
// le filet 0-erreur = les expressions évaluées sont parsables.)
|
||||
} finally {
|
||||
await page.evaluate(async (id) => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch(`/db/api/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
|
||||
}, coll.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('A20-ph3 : settings sous build Alpine CSP', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
expect(await assertBound(page)).toBe('ok');
|
||||
await expect(page.locator('.settings-overlay')).toBeVisible();
|
||||
});
|
||||
|
||||
test('A20-ph3 : local workspace sous build Alpine CSP', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/local-workspace`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
expect(await assertBound(page)).toBe('ok');
|
||||
|
||||
// recherche : toggleSearch() (méthode réelle) + focus Alpine.nextTick
|
||||
await page.click('button.ws-icon-btn[title="Search"]');
|
||||
await page.waitForTimeout(400);
|
||||
const focused = await page.evaluate(
|
||||
() => document.activeElement && document.activeElement.getAttribute('x-ref') === 'searchInput'
|
||||
);
|
||||
expect(focused).toBe(true);
|
||||
|
||||
// chips filtre : bindSvg() via x-init (x-html interdit en CSP) —
|
||||
// x-init tourne même si la rangée est masquée (x-show=filterType)
|
||||
const svg = await page.evaluate(
|
||||
() => (document.querySelector('button.filter-chip[title="Folders"]') || {}).innerHTML || ''
|
||||
);
|
||||
expect(svg).toContain('<svg');
|
||||
});
|
||||
|
||||
test('A20-ph3 : library sous build Alpine CSP', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// le composant est lié par le registre Alpine.data (scope CSP)
|
||||
expect(await assertBound(page)).toBe('ok');
|
||||
|
||||
// icône du empty-state : x-html remplacé par x-init + Alpine.effect
|
||||
await expect(page.locator('#lib-empty .empty-icon')).toBeVisible({ timeout: 8000 });
|
||||
|
||||
@@ -265,6 +265,10 @@
|
||||
});
|
||||
}).catch(function(){});
|
||||
},
|
||||
// ── A20 ph3 : x-html (markdown) → x-init + effet réactif ──
|
||||
bindMarkdown(el, m){
|
||||
Alpine.effect(() => { el.innerHTML = this.renderMarkdown(m.content); });
|
||||
},
|
||||
approveProposal(m){
|
||||
if(!m || m.applied || m.offline) return;
|
||||
var ok = window.fdApplyDocument ? window.fdApplyDocument(m.content, m.mode) : false;
|
||||
|
||||
@@ -367,6 +367,15 @@ document.addEventListener('alpine:init', () => {
|
||||
this.loadSidebarTree();
|
||||
},
|
||||
|
||||
// ── A20 ph3 : new Date / x-html interdits sous build CSP ──
|
||||
fmtGwDate(pp) {
|
||||
return pp.updated_at ? new Date(pp.updated_at + 'Z').toLocaleString() : '';
|
||||
},
|
||||
bindGwIcon(el, item) {
|
||||
Alpine.effect(() => {
|
||||
el.innerHTML = item.type === 'folder' ? getSvgIcon('folder', 16) : getSvgIcon('file', 16);
|
||||
});
|
||||
},
|
||||
formatSize(bytes) {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const LW=(()=>{try{const el=document.getElementById('lw-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
|
||||
window._wsInitData = (function() {
|
||||
const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
||||
// invisible au snapshot ji du build CSP (valeur bannie sinon)
|
||||
return {
|
||||
tree:[],
|
||||
displayTree:[],
|
||||
@@ -2007,13 +2008,71 @@ window._wsInitData = (function() {
|
||||
this.clearSelection();
|
||||
this._reloadAfterAction();
|
||||
if (window.showToast) window.showToast('Item' + (ids.length > 1 ? 's' : '') + ' moved', 'success');
|
||||
}
|
||||
},
|
||||
|
||||
};
|
||||
// ── A20 ph3 : x-html interdit par le build CSP → x-init + Alpine.effect
|
||||
// (réactivité conservée : lecture des données réactives dans l'effect) ──
|
||||
toggleSearch() {
|
||||
this.searchOpen = !this.searchOpen;
|
||||
if (this.searchOpen) Alpine.nextTick(() => {
|
||||
const el = document.querySelector('[x-ref="searchInput"]');
|
||||
if (el) el.focus();
|
||||
});
|
||||
},
|
||||
createPageAt(node) { window.FlowDeck.createPage(node.id); },
|
||||
createFolderAt(node) { window.FlowDeck.showCreateFolderModal(node.id); },
|
||||
bindSvg(el, name, size) { el.innerHTML = getSvgIcon(name, size); },
|
||||
bindFileIcon(el, node) {
|
||||
Alpine.effect(() => {
|
||||
el.innerHTML = this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
|
||||
});
|
||||
},
|
||||
bindNodeIcon(el, node) {
|
||||
Alpine.effect(() => {
|
||||
el.innerHTML = node.is_folder
|
||||
? getSvgIcon('folder', 24)
|
||||
: this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
|
||||
});
|
||||
},
|
||||
bindChildren(el, node) {
|
||||
Alpine.effect(() => {
|
||||
el.innerHTML = this.renderChildren(node.children, (node.depth || 0) + 1, this.tagsVersion);
|
||||
});
|
||||
},
|
||||
bindPreview(el) { Alpine.effect(() => { el.innerHTML = this.previewContent; }); },
|
||||
};
|
||||
})();
|
||||
// Injecter toutes les props comme globales pour Alpine (résout les 36 erreurs de scope)
|
||||
var _wsKeys = Object.keys(window._wsInitData);
|
||||
var _wsKeys = Object.keys(_wsInitData);
|
||||
for (var _i = 0; _i < _wsKeys.length; _i++) {
|
||||
try { window[_wsKeys[_i]] = window._wsInitData[_wsKeys[_i]]; } catch { /* volontaire */ }
|
||||
try { window[_wsKeys[_i]] = _wsInitData[_wsKeys[_i]]; } catch { /* volontaire */ }
|
||||
}
|
||||
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
|
||||
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(_wsInitData).length);
|
||||
|
||||
// A20 ph3 : registre Alpine.data (le build CSP ne résout que le registre ;
|
||||
// x-data="wsInitData()" au lieu de l'identifiant global _wsInitData).
|
||||
document.addEventListener('alpine:init', function () {
|
||||
// objet partagé JS↔Alpine via closure LEXICALE (const hors window →
|
||||
// non snapshoté par ji ; probe4 : factory retournant l'objet = accepté).
|
||||
Alpine.data('wsInitData', function () { return _wsInitData; });
|
||||
// Bloc preview : le parseur le place HORS de la div racine (structure
|
||||
// pré-existante) → montage distinct DÉLÉGUANT vers l'objet partagé.
|
||||
// Wrapper = objet unique (les magics $nextTick… ne sont redéfinissables
|
||||
// qu'une fois) + lecture/écriture via Alpine.reactive(_wsInitData) pour
|
||||
// garder la réactivité JS↔Alpine.
|
||||
Alpine.data('wsPreview', function () {
|
||||
const R = Alpine.reactive(_wsInitData);
|
||||
const t = {};
|
||||
['previewVisible', 'previewX', 'previewY', 'previewName'].forEach(function (k) {
|
||||
Object.defineProperty(t, k, {
|
||||
get: function () { return R[k]; },
|
||||
set: function (v) { R[k] = v; },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
t.bindPreview = function (el) {
|
||||
Alpine.effect(function () { el.innerHTML = R.previewContent; });
|
||||
};
|
||||
return t;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1421,6 +1421,15 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
}catch(e){this.showToast('Remove failed','error');}
|
||||
},
|
||||
toggleIcon(){this.iconOpen=!this.iconOpen;},
|
||||
openBacklink(b){
|
||||
this.backlinksOpen = false;
|
||||
window.location.href = '/pages/' + b.id;
|
||||
},
|
||||
fmtImportSize(f){ return Math.round(f.size / 1024) + ' KB'; },
|
||||
// A20 ph3 : x-html interdit sous build CSP → effet réactif
|
||||
bindIconHtml(el){
|
||||
Alpine.effect(() => { el.innerHTML = this.iconHtml(); });
|
||||
},
|
||||
iconHtml(){
|
||||
var v=this.iconValue;
|
||||
if(!v)v=(this.contentFormat==='file')?'paperclip':'file';
|
||||
|
||||
@@ -8,6 +8,13 @@ const ST=(()=>{try{const el=document.getElementById('st-config');return el?JSON.
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('settingsInit', settingsInit); });
|
||||
function settingsInit() {
|
||||
return {
|
||||
// ── A20 ph3 : expressions hostiles au parseur CSP (window/Date) ──
|
||||
historyBack() { window.history.back(); },
|
||||
fmtLastLogin(u) {
|
||||
return u.last_login ? new Date(u.last_login * 1000).toLocaleDateString() : 'Never';
|
||||
},
|
||||
fmtAuditDate(e) { return new Date(e.at).toLocaleString(); },
|
||||
|
||||
activeSection: 'account',
|
||||
allTags: [],
|
||||
newTagName: '',
|
||||
|
||||
Reference in New Issue
Block a user