Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3706689eca | ||
|
|
7dbaefb381 | ||
|
|
15b6b163f4 | ||
|
|
2e5efcfe07 | ||
|
|
1d1cdbd618 | ||
|
|
894004a1f5 | ||
|
|
6582df3bcb | ||
|
|
aa88cf6665 | ||
|
|
b2ea8ec537 | ||
|
|
1051a72b52 | ||
|
|
afbc236cc2 | ||
|
|
c20aeaada1 | ||
|
|
6d7af3fb64 | ||
|
|
b0af1bbfb6 | ||
|
|
de751ffe35 | ||
|
|
48b5551b93 | ||
|
|
e4552c3763 | ||
|
|
6914780f24 | ||
|
|
d7d9966edf | ||
|
|
3cab76fed5 | ||
|
|
6ff88237fc | ||
|
|
840d2b2615 | ||
|
|
ab6ac1e84c | ||
|
|
3a74ea8bbd |
@@ -1,6 +1,10 @@
|
||||
# ── Gitea ──
|
||||
GITEA_URL=https://git.dracodev.net
|
||||
GITEA_TOKEN=change-me
|
||||
# Laissez VIDES pour activer le login local seul : depuis v7.46.0 les valeurs de
|
||||
# substitution (`test-id`, `test-secret`, `change-me`) comptent comme « provider
|
||||
# non configuré » — avant, /auth/login redirigeait vers Gitea avec un client_id
|
||||
# invalide (authentification OAuth impossible).
|
||||
GITEA_OAUTH_CLIENT_ID=
|
||||
GITEA_OAUTH_CLIENT_SECRET=
|
||||
GITEA_WEBHOOK_SECRET=
|
||||
@@ -82,3 +86,15 @@ APP_BASE_URL=http://localhost:8080
|
||||
# SSO_ATTRIBUTE_MAPPING={"email":"email","full_name":"name","groups":"groups"}
|
||||
# SSO_GROUPS_MAPPING=[{"sso_group":"FlowDeck Admins","workspace_role":"admin","workspace_id":1}]
|
||||
# SSO_DEFAULT_WORKSPACE_ID=0
|
||||
|
||||
# ── Web tools de l'agent (v7.46.0) ──
|
||||
# Skills « recherche-marche », « veille-techno », « debug-web ».
|
||||
# web_search → EXA (recherche sémantique + extraits, ~10 $/mois offerts)
|
||||
# fetch_url → aucune config : lecture d'une page, garde-fou SSRF actif
|
||||
# search_code → GitHub public (10 req/min sans jeton, 30 avec)
|
||||
# Sans EXA_API_KEY, web_search bascule sur DuckDuckGo (repli dégradé, sans
|
||||
# compte mais parsé au HTML) : utilisable, moins fiable.
|
||||
WEB_SEARCH_PROVIDER=exa
|
||||
EXA_API_KEY=
|
||||
GITHUB_TOKEN=
|
||||
WEB_FETCH_MAX_CHARS=20000
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.13'
|
||||
- name: Install lint tools
|
||||
run: pip install -r requirements-dev.txt
|
||||
- name: Ruff (Python)
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.13'
|
||||
- name: Install system dependencies (WeasyPrint / emoji fonts)
|
||||
run: |-
|
||||
SUDO=""
|
||||
|
||||
+1193
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -3,7 +3,7 @@
|
||||
# Stage 1 "builder": build Python wheels once.
|
||||
# Stage 2 "runtime": minimal image with WeasyPrint system libs.
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
FROM python:3.12-slim AS builder
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -11,7 +11,7 @@ COPY requirements.txt .
|
||||
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
|
||||
|
||||
# ── runtime stage ───────────────────────────────────────────
|
||||
FROM python:3.12-slim AS runtime
|
||||
FROM python:3.13-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -73,9 +73,9 @@ docker compose up -d
|
||||
| Couche | Techno |
|
||||
|--------|--------|
|
||||
| Frontend | Jinja2 + HTMX + Alpine.js + SortableJS + CSS |
|
||||
| Backend | Python 3.12 + FastAPI + httpx |
|
||||
| Backend | Python 3.13 + FastAPI + httpx |
|
||||
| BDD | SQLite (WAL, 21 tables) — `/data/flowdeck.db` |
|
||||
| Déploiement | Docker (python:3.12-slim), docker-compose |
|
||||
| Déploiement | Docker (python:3.13-slim), docker-compose |
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
+143
-5
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.34.0 (audit — A38 phase 2 : 0 doublon de fonction globale, garde-fou testé | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.50.0 (side peek : document seul en mode édition, bouton Open calendrier/Kanban My Tasks) | **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
|
||||
|
||||
@@ -11,6 +11,17 @@ from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Valeurs de substitution livrées dans app/config.py : elles sont NON VIDES,
|
||||
# donc un simple `bool(client_id and client_secret)` les considérait comme
|
||||
# configurées et /auth/login redirigeait vers Gitea avec client_id=test-id
|
||||
# (échec d'authentification garanti). Un placeholder == provider non configuré.
|
||||
_PLACEHOLDER_CREDS = {"test-id", "test-secret", "change-me", "changeme", "your-client-id"}
|
||||
|
||||
|
||||
def _real(value: str | None) -> bool:
|
||||
"""True when ``value`` is a real credential (not empty, not a placeholder)."""
|
||||
return bool(value) and value.strip().lower() not in _PLACEHOLDER_CREDS
|
||||
|
||||
|
||||
class OAuthProvider(ABC):
|
||||
"""Abstract OAuth2 provider interface."""
|
||||
@@ -52,7 +63,7 @@ class GiteaProvider(OAuthProvider):
|
||||
self.redirect_uri = redirect_uri
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return bool(self.client_id and self.client_secret)
|
||||
return _real(self.client_id) and _real(self.client_secret)
|
||||
|
||||
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
||||
params = {
|
||||
@@ -144,7 +155,7 @@ class GitHubProvider(OAuthProvider):
|
||||
self.api_url = "https://api.github.com"
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return bool(self.client_id and self.client_secret)
|
||||
return _real(self.client_id) and _real(self.client_secret)
|
||||
|
||||
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
||||
return (
|
||||
|
||||
+15
-2
@@ -13,6 +13,19 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_serializer = URLSafeTimedSerializer(settings.app_secret_key)
|
||||
|
||||
# Colonnes de la table ``users`` qui ne doivent JAMAIS quitter le serveur :
|
||||
# le cookie de session est signé (HMAC) mais pas chiffré — son payload est
|
||||
# lisible en base64 par quiconque détient le cookie, et ``/auth/user`` le
|
||||
# renvoyait tel quel au client.
|
||||
_SECRET_USER_FIELDS = ("password_hash",)
|
||||
|
||||
|
||||
def public_user(user_data: dict | None) -> dict | None:
|
||||
"""Return a copy of ``user_data`` stripped of credential material."""
|
||||
if user_data is None:
|
||||
return None
|
||||
return {k: v for k, v in user_data.items() if k not in _SECRET_USER_FIELDS}
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Manages user sessions via signed cookies (v5.2.0: revocable).
|
||||
@@ -30,7 +43,7 @@ class SessionManager:
|
||||
``user_sessions`` table (ip + user agent) and becomes revocable.
|
||||
"""
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"user": public_user(user_data),
|
||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
@@ -93,7 +106,7 @@ class SessionManager:
|
||||
"""Re-sign a cookie keeping its session id (used after profile edits)."""
|
||||
sid = SessionManager.session_id(cookie) if cookie else None
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"user": public_user(user_data),
|
||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
|
||||
@@ -126,6 +126,14 @@ class Settings(BaseSettings):
|
||||
agent_max_tokens_budget: int = 500000
|
||||
agent_run_timeout_seconds: int = 300
|
||||
|
||||
# ── Web tools de l'agent (web_search / fetch_url / search_code) ──
|
||||
# `web_search_provider` : « exa » (recherche sémantique + snippets, clé
|
||||
# requise) ou « duckduckgo » (repli sans compte, parsing HTML — dégradé).
|
||||
web_search_provider: str = "exa"
|
||||
exa_api_key: str = ""
|
||||
github_token: str = "" # PAT GitHub : 30 req/min au lieu de 10
|
||||
web_fetch_max_chars: int = 20000 # texte renvoyé par fetch_url
|
||||
|
||||
@property
|
||||
def db_path(self) -> Path:
|
||||
if self.database_url == "sqlite:///:memory:":
|
||||
|
||||
@@ -348,6 +348,16 @@ def init_db():
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# v7.45.5 : réparation des pages restaurées marquées 'Trash' — l'ancien
|
||||
# soft-delete (éditeur) réécrivait parent_section='Trash' et la
|
||||
# restauration ne le remettait pas → page invisible en Library/Recents/
|
||||
# Private. Idempotent (WHERE restrictif), rejoué à chaque boot.
|
||||
# ponytail: un dossier restauré repasse en 'Private' (l'état d'origine
|
||||
# n'est pas stocké) → icône/dossier à remettre à 'Workspace' si besoin.
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_section='Private' "
|
||||
"WHERE parent_section='Trash' AND deleted_at IS NULL"
|
||||
)
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'private'")
|
||||
except sqlite3.OperationalError:
|
||||
|
||||
+3
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.34.0",
|
||||
version="7.50.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
@@ -224,6 +224,8 @@ app.include_router(webhooks.router)
|
||||
app.include_router(collections.router)
|
||||
app.include_router(my_tasks.router)
|
||||
app.include_router(workspace.router)
|
||||
# Partage public :-exempt de la dépendance d'authentification du router.
|
||||
app.include_router(workspace.public_router)
|
||||
app.include_router(library.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(gitea_router)
|
||||
|
||||
@@ -71,15 +71,15 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
|
||||
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
|
||||
# détaché de script-src (sinon le nonce les désactiverait aussi).
|
||||
# `unsafe-eval` reste : Alpine (x-data en string) et htmx (hx-vars) en ont
|
||||
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
|
||||
# A20 TERMINÉ : `unsafe-eval` retiré — Alpine tourne en build CSP
|
||||
# (static/js/alpine.csp.min.js, 0 eval) ; htmx allowEval=false.
|
||||
# 15 surfaces en csp_preview vert + scan statique 0 (board/gitea/cards
|
||||
# = props propres, gitea down empêche un gate dédié).
|
||||
CSP_VALUE = (
|
||||
"default-src 'self'; "
|
||||
# A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
|
||||
# (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
|
||||
# `unsafe-eval` reste : Alpine (x-data) + htmx (hx-on/hx-vars) en
|
||||
# ont besoin → retrait avec le build CSP d'Alpine (reste d'A20).
|
||||
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}'; "
|
||||
"script-src 'self' 'nonce-{nonce}'; "
|
||||
"script-src-attr 'unsafe-inline'; "
|
||||
# ponytail: aucun @font-face Google (grep négatif) → les deux
|
||||
# hôtes fonts étaient morts, supprimés.
|
||||
|
||||
@@ -1553,3 +1553,34 @@ def _migration_sso_enterprise_auth(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sso_requests_created ON sso_requests(created_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(30, "v7.47.0: My Tasks — mapping des propriétés de base de tâches")
|
||||
def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None:
|
||||
"""My Tasks façon Notion : une base ne diffuse ses tâches qu'après
|
||||
conversion explicite, et les trois propriétés requises sont **nommées**
|
||||
(pas devinées d'après leur type).
|
||||
|
||||
``collections.is_task`` existait déjà mais ne mémorisait rien : My Tasks
|
||||
devait deviner « la colonne personne = Assigné à » et n'avait aucun moyen
|
||||
de distinguer deux bases connectées. Trois colonnes nullable REFERENCES
|
||||
fixent le mapping ; suppression de la propriété → `SET NULL`, et la base
|
||||
redevient « non configurée » au lieu de pointer sur du vide.
|
||||
"""
|
||||
cols = columns(conn, "collections")
|
||||
for name, target in (
|
||||
("task_assignee_prop", "collection_properties"),
|
||||
("task_status_prop", "collection_properties"),
|
||||
("task_due_prop", "collection_properties"),
|
||||
):
|
||||
if name in cols:
|
||||
continue
|
||||
conn.execute(
|
||||
f"ALTER TABLE collections ADD COLUMN {name} INTEGER "
|
||||
f"REFERENCES {target}(id) ON DELETE SET NULL"
|
||||
)
|
||||
# Index de lecture : « les bases de tâches de cet utilisateur ».
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_collections_task "
|
||||
"ON collections(is_task, workspace_id)"
|
||||
)
|
||||
|
||||
@@ -104,6 +104,10 @@ def update_user(user_id: int, request: Request, _admin=Depends(admin_required),
|
||||
def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
"""Delete a user and cascade their data."""
|
||||
from app.db import get_conn
|
||||
from app.services.collection_lifecycle import (
|
||||
delete_collections,
|
||||
workspace_collection_ids,
|
||||
)
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not user:
|
||||
@@ -121,6 +125,9 @@ def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
# Delete workspaces owned by this user
|
||||
ws_rows = conn.execute("SELECT id FROM workspaces WHERE owner_id=?", (user_id,)).fetchall()
|
||||
for ws in ws_rows:
|
||||
# Les bases du workspace tombent avec lui (sinon : collections
|
||||
# orphelines listées par /db et My Tasks).
|
||||
delete_collections(conn, workspace_collection_ids(conn, ws["id"]))
|
||||
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws["id"],))
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws["id"],))
|
||||
conn.execute("DELETE FROM workspaces WHERE owner_id=?", (user_id,))
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est ut
|
||||
)
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
from app.services.collection_lifecycle import delete_collections
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
@@ -152,7 +153,7 @@ def delete_collection_v2(collection_id: int, request: Request, authorization: st
|
||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
||||
delete_collections(conn, [collection_id])
|
||||
conn.commit()
|
||||
audit_log(user, "collection.delete", "collection", collection_id, "", request)
|
||||
return {"id": collection_id, "status": "deleted"}
|
||||
|
||||
@@ -156,7 +156,10 @@ async def create_agent_v2(request: Request, authorization: str | None = Header(d
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/agents/{agent_id}")
|
||||
# v7.46.0 : `{agent_id:int}` — sans le contrainte de type, la routeparametrée
|
||||
# enregistree AVANT `/agents/conversations` capturait le segment « conversations »
|
||||
# et renvoyait 422 (int_parsing) au lieu de la liste des conversations.
|
||||
@router.get("/agents/{agent_id:int}")
|
||||
def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
@@ -166,7 +169,7 @@ def get_agent_v2(agent_id: int, request: Request, authorization: str | None = He
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/agents/{agent_id}")
|
||||
@router.put("/agents/{agent_id:int}")
|
||||
async def update_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
body = await _json_body(request)
|
||||
@@ -194,7 +197,7 @@ async def update_agent_v2(agent_id: int, request: Request, authorization: str |
|
||||
return {"id": agent_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/agents/{agent_id}")
|
||||
@router.delete("/agents/{agent_id:int}")
|
||||
def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
|
||||
+2
-1
@@ -475,10 +475,11 @@ def logout(request: Request):
|
||||
def current_user(request: Request):
|
||||
"""Return current user info as JSON."""
|
||||
from app.auth.session import get_current_user as gcu
|
||||
from app.auth.session import public_user
|
||||
user = gcu(request)
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
return {"authenticated": True, "user": user}
|
||||
return {"authenticated": True, "user": public_user(user)}
|
||||
|
||||
|
||||
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -485,6 +485,10 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
active_ws_name = row["name"]
|
||||
workspace_pages = _load_workspace_pages(ws_cookie)
|
||||
has_active_workspace = True
|
||||
# v7.46.0 : comme pour le sidebar dashboard, exposer l'ID de
|
||||
# l'espace ACTIF (le bouton Home de base.html pointait sinon
|
||||
# sur ``local_workspaces[0]``, premier espace trie par nom).
|
||||
local_ws_id = wsi
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
recent = []
|
||||
|
||||
@@ -12,6 +12,11 @@ from fastapi.responses import HTMLResponse
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.collection_lifecycle import (
|
||||
collections_hosted_by_page,
|
||||
collections_hosted_by_pages,
|
||||
delete_collections,
|
||||
)
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
from ._common import _sidebar_data
|
||||
@@ -125,21 +130,41 @@ def unpublish_page(request: Request, page_id: int):
|
||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||
|
||||
|
||||
def _trash_session(request: Request):
|
||||
"""Trash : session obligatoire (les 3 routes mutaient/ lisaient toutes les
|
||||
pages sans aucune vérification — le CSRF seul ne protège pas, le cookie est
|
||||
lisible par JS et un attaquant fixe cookie ET en-tête)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||
|
||||
@router.get("/api/trash")
|
||||
def list_trash(request: Request):
|
||||
_trash_session(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()
|
||||
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, parent_section, deleted_at FROM pages "
|
||||
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"name": r["title"] or "Untitled",
|
||||
"icon": "📁" if r["parent_section"] == "Workspace" else "📄",
|
||||
"path": r["workspace"] or "Private",
|
||||
"deleted_at": r["deleted_at"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/trash/{page_id}/restore")
|
||||
def restore_page(request: Request, page_id: int):
|
||||
_trash_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
@@ -154,11 +179,47 @@ def restore_page(request: Request, page_id: int):
|
||||
|
||||
@router.delete("/api/trash/{page_id}")
|
||||
def permanent_delete(request: Request, page_id: int):
|
||||
_trash_session(request)
|
||||
with get_conn() as conn:
|
||||
# La page hôte d'une base disparaît définitivement → sa base et ses
|
||||
# lignes aussi, sinon My Tasks (et /db) continuaient de lister des
|
||||
# tâches sans document.
|
||||
collections = collections_hosted_by_page(conn, page_id)
|
||||
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
|
||||
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
||||
delete_collections(conn, collections)
|
||||
conn.commit()
|
||||
return {"status": "ok", "deleted": page_id}
|
||||
return {"status": "ok", "deleted": page_id, "collections": len(collections)}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/trash/empty")
|
||||
def empty_trash(request: Request):
|
||||
"""Empty Trash : purge en masse (les enfants des pages supprimées passent
|
||||
en racine, comme permanent_delete — comportement historique conservé)."""
|
||||
_trash_session(request)
|
||||
with get_conn() as conn:
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM pages WHERE deleted_at IS NOT NULL"
|
||||
).fetchone()[0]
|
||||
# Bases portées par les pages purgées (les pages contenu de ligne
|
||||
# n'hébergent aucune base et ne remontent rien).
|
||||
trashed = [
|
||||
r["id"]
|
||||
for r in conn.execute(
|
||||
"SELECT id FROM pages WHERE deleted_at IS NOT NULL"
|
||||
).fetchall()
|
||||
]
|
||||
collections = collections_hosted_by_pages(conn, trashed)
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_id=NULL WHERE parent_id IN "
|
||||
"(SELECT id FROM pages WHERE deleted_at IS NOT NULL)"
|
||||
)
|
||||
conn.execute("DELETE FROM pages WHERE deleted_at IS NOT NULL")
|
||||
delete_collections(conn, collections)
|
||||
conn.commit()
|
||||
return {"status": "ok", "deleted": n, "collections": len(collections)}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -16,23 +16,41 @@ router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════
|
||||
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
||||
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
||||
|
||||
def _session_user(request: Request) -> dict:
|
||||
"""Session obligatoire — sans garde, un appel anonyme levait
|
||||
``AttributeError: 'NoneType' object has no attribute 'get'`` (HTTP 500)
|
||||
au lieu d'un 401."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
def _assert_can_edit_block(request: Request, sb: dict) -> None:
|
||||
"""Seul l'auteur du bloc (ou un admin global) peut le modifier/supprimer."""
|
||||
user = _session_user(request)
|
||||
if sb.get("created_by") in (None, user.get("id")):
|
||||
return
|
||||
if user.get("is_admin"):
|
||||
return
|
||||
raise HTTPException(403, "Not the author of this synced block")
|
||||
|
||||
|
||||
@router.get("/api/synced-blocks")
|
||||
def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
|
||||
"""List synced blocks for a workspace."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
user = _session_user(request)
|
||||
from app.services.synced_blocks import list_synced_blocks
|
||||
return {"synced_blocks": list_synced_blocks(workspace or user.get("login", ""))}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/synced-blocks")
|
||||
def create_synced_block_api(request: Request, body: dict = Body(...)):
|
||||
"""Create a new synced block."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
user = _session_user(request)
|
||||
from app.services.synced_blocks import create_synced_block
|
||||
sid = create_synced_block(
|
||||
workspace=body.get("workspace", ""),
|
||||
@@ -61,6 +79,7 @@ async def update_synced_block_api(request: Request, sid: int):
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
_assert_can_edit_block(request, sb)
|
||||
update_synced_block(sid, body.get("title", sb["title"]), body.get("content", []))
|
||||
# v6.5.0: rewrite every referencing page's stored content first (DB row
|
||||
# content pages included), THEN push the realtime update so open rooms
|
||||
@@ -86,6 +105,7 @@ async def delete_synced_block_api(request: Request, sid: int):
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
_assert_can_edit_block(request, sb)
|
||||
# v6.5.0: collect referencing pages BEFORE the FK cascade wipes the
|
||||
# refs, rewrite their stored content (deleted state), then broadcast.
|
||||
pids = page_ids_for_synced(sid)
|
||||
@@ -99,8 +119,9 @@ async def delete_synced_block_api(request: Request, sid: int):
|
||||
|
||||
|
||||
@router.get("/api/synced-blocks/{sid}")
|
||||
def get_synced_block_api(sid: int):
|
||||
def get_synced_block_api(request: Request, sid: int):
|
||||
"""Get a synced block by id."""
|
||||
_session_user(request)
|
||||
from app.services.synced_blocks import get_synced_block
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
|
||||
@@ -17,11 +17,13 @@ from . import ( # ordre = ordre d'enregistrement d'origine
|
||||
crud,
|
||||
dashboard_views,
|
||||
data_api,
|
||||
index_page,
|
||||
linked,
|
||||
meta,
|
||||
pages,
|
||||
properties,
|
||||
structure,
|
||||
task_db,
|
||||
views,
|
||||
)
|
||||
from ._common import _validate_page_properties # noqa: F401
|
||||
@@ -34,7 +36,14 @@ from ._renderers import ( # noqa: F401 — ré-exports tests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
# `task_db` est enregistré **en premier** : sa route statique
|
||||
# `GET /db/task-dbs/api` serait sinon capturée par `data_api`'s
|
||||
# `GET /db/{collection_id}/api` (`collection_id="task-dbs"` → 422).
|
||||
# `index_page` (la page HTML `/db`) vient ensuite, avant tout module
|
||||
# déclarant `/db/{collection_id}` — sinon la racine serait capturée en 422.
|
||||
for _mod in (
|
||||
task_db,
|
||||
index_page,
|
||||
crud,
|
||||
pages,
|
||||
boards,
|
||||
|
||||
@@ -55,17 +55,17 @@ h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
|
||||
</style></head><body>
|
||||
<h1>{icon} {title}</h1>
|
||||
<div class="view-tabs">
|
||||
<a class="tab{' active' if view_type=='table' else ''}" href="?view=table">📊 Table</a>
|
||||
<a class="tab{' active' if view_type=='board' else ''}" href="?view=board">📋 Board</a>
|
||||
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view=calendar">📅 Calendar</a>
|
||||
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view=gallery">🖼️ Gallery</a>
|
||||
<a class="tab{' active' if view_type=='list' else ''}" href="?view=list">📝 List</a>
|
||||
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view=timeline">📈 Timeline</a>
|
||||
<a class="tab{' active' if view_type=='gantt' else ''}" href="?view=gantt">📊 Gantt</a>
|
||||
<a class="tab{' active' if view_type=='chart' else ''}" href="?view=chart">📉 Chart</a>
|
||||
<a class="tab{' active' if view_type=='form' else ''}" href="?view=form">📋 Form</a>
|
||||
<a class="tab{' active' if view_type=='map' else ''}" href="?view=map">🗺️ Map</a>
|
||||
<a class="tab{' active' if view_type=='feed' else ''}" href="?view=feed">📰 Feed</a>
|
||||
<a class="tab{' active' if view_type=='table' else ''}" href="?view_type=table">📊 Table</a>
|
||||
<a class="tab{' active' if view_type=='board' else ''}" href="?view_type=board">📋 Board</a>
|
||||
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view_type=calendar">📅 Calendar</a>
|
||||
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view_type=gallery">🖼️ Gallery</a>
|
||||
<a class="tab{' active' if view_type=='list' else ''}" href="?view_type=list">📝 List</a>
|
||||
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view_type=timeline">📈 Timeline</a>
|
||||
<a class="tab{' active' if view_type=='gantt' else ''}" href="?view_type=gantt">📊 Gantt</a>
|
||||
<a class="tab{' active' if view_type=='chart' else ''}" href="?view_type=chart">📉 Chart</a>
|
||||
<a class="tab{' active' if view_type=='form' else ''}" href="?view_type=form">📋 Form</a>
|
||||
<a class="tab{' active' if view_type=='map' else ''}" href="?view_type=map">🗺️ Map</a>
|
||||
<a class="tab{' active' if view_type=='feed' else ''}" href="?view_type=feed">📰 Feed</a>
|
||||
</div>
|
||||
{body}
|
||||
</body></html>"""
|
||||
@@ -130,9 +130,9 @@ def _render_calendar(view_type: str, collection: dict, pages: list[dict], config
|
||||
.cal-nav span{{font-size:16px;font-weight:600}}
|
||||
</style>
|
||||
<div class="cal-nav">
|
||||
<a href="?view=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
|
||||
<a href="?view_type=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
|
||||
<span>{first.strftime('%B %Y')}</span>
|
||||
<a href="?view=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
|
||||
<a href="?view_type=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
|
||||
</div>
|
||||
<div class="calendar">
|
||||
<div class="cal-header">Mon</div><div class="cal-header">Tue</div><div class="cal-header">Wed</div>
|
||||
|
||||
@@ -8,10 +8,11 @@ import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.routers.dashboard._common import _get_active_workspace
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.collection_lifecycle import delete_collections
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
@@ -24,22 +25,10 @@ router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
# ── API: List & Create (no path params) ──
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def list_collections(request: Request):
|
||||
"""Page listing all collections in the workspace."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collections ORDER BY name"
|
||||
).fetchall()
|
||||
collections = [dict(r) for r in rows]
|
||||
return HTMLResponse(
|
||||
f"<div class='collections-list'>"
|
||||
f"<h2>Collections ({len(collections)})</h2>"
|
||||
f"<pre>{json.dumps(collections, indent=2, default=str)}</pre>"
|
||||
f"</div>"
|
||||
)
|
||||
#
|
||||
# `GET /db` (la page HTML) vit dans `index_page.py` : elle était auparavant
|
||||
# un dump JSON de debug, sans layout — alors qu'elle sert de destination au
|
||||
# bouton « Ouvrir mes bases » de My Tasks.
|
||||
|
||||
|
||||
|
||||
@@ -71,6 +60,24 @@ def create_collection_api(request: Request, body: dict = Body(default={})):
|
||||
schema = body.get("schema", [])
|
||||
is_locked = body.get("is_locked", False)
|
||||
|
||||
# Workspace d'appartenance : celui du body si fourni et autorisé, sinon le
|
||||
# workspace actif de l'utilisateur. Sans cela la collection reste orpheline
|
||||
# et n'apparaît dans aucune vue scopée (ex. /my-tasks).
|
||||
user = _session_user(request)
|
||||
workspace_id = body.get("workspace_id")
|
||||
if workspace_id is not None:
|
||||
try:
|
||||
workspace_id = int(workspace_id)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="workspace_id must be an integer") from None
|
||||
with get_conn() as conn:
|
||||
exists = conn.execute("SELECT 1 FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not exists:
|
||||
raise HTTPException(status_code=400, detail="workspace_id does not exist")
|
||||
else:
|
||||
active = _get_active_workspace(request, (user or {}).get("id"))
|
||||
workspace_id = active["id"] if active else None
|
||||
|
||||
with get_conn() as conn:
|
||||
# Apply a template if requested (provides schema + icon).
|
||||
tpl = _apply_template(conn, body.get("template"))
|
||||
@@ -88,9 +95,11 @@ def create_collection_api(request: Request, body: dict = Body(default={})):
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked)),
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
|
||||
workspace_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked),
|
||||
workspace_id),
|
||||
)
|
||||
collection_id = cur.lastrowid
|
||||
|
||||
@@ -171,7 +180,11 @@ def delete_collection_api(request: Request, collection_id: int):
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
||||
# `delete_collections` détache la page hôte et les vues liées avant la
|
||||
# suppression : `pages.collection_id` et
|
||||
# `collection_data_sources.source_collection_id` sont en NO ACTION, la
|
||||
# suppression brute levait un IntegrityError.
|
||||
delete_collections(conn, [collection_id])
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(fire_event("collection.deleted", {"collection_id": collection_id,
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"""FlowDeck — Collections : la page « /db » (liste des bases).
|
||||
|
||||
`GET /db` était un **dump JSON de debug** (le HTML renvoyé était un
|
||||
`<pre>` dans le vide, sans layout) : c'est pourtant la destination du bouton
|
||||
« Ouvrir mes bases » de My Tasks, et le point d'entrée pour convertir une base
|
||||
en base de tâches. On le remplace par une vraie page :
|
||||
|
||||
* bases **du workspace courant** d'abord (ce que voit l'utilisateur dans la
|
||||
sidebar), puis celles de ses **autres workspaces** dans une section séparée :
|
||||
sans cela, un utilisateur dont le workspace actif ne contient aucune base
|
||||
tombe sur une page vide alors qu'il en possède ailleurs ;
|
||||
* indicateur « Base de tâches » / « À configurer » par base ;
|
||||
* accès direct à la page de la base, où se trouve le bouton de conversion.
|
||||
|
||||
**Rattachement d'une base à un workspace.** `collections.workspace_id` n'est
|
||||
renseigné que pour les bases créées via `/db/api`. Une base née d'un document
|
||||
(page convertie en base, base inline) a `workspace_id` **NULL** et n'appartient
|
||||
qu'à travers sa page hôte : la résout par `COALESCE(workspace_id, page hôte)`
|
||||
(`app/services.collection_lifecycle.effective_workspace_sql`). Filtrer sur
|
||||
`workspace_id`, ou faire un `JOIN workspaces`, faisait disparaître ces bases de
|
||||
la page alors qu'elles sont listées dans la sidebar du workspace — l'utilisateur
|
||||
les voyait sans pouvoir les ouvrir.
|
||||
|
||||
Le filtre de vivacité (`live_collection_ids`) est appliqué : une base dont la
|
||||
page hôte est à la corbeille n'est pas proposée, puisqu'elle est inaccessible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.routers.dashboard._common import _get_active_workspace
|
||||
from app.services.collection_lifecycle import (
|
||||
effective_workspace_sql,
|
||||
live_collection_ids,
|
||||
user_workspace_ids,
|
||||
)
|
||||
from app.templating import ENV
|
||||
|
||||
from ._common import _session_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
#: Colonnes communes aux deux listes (carte de base). `c.` qualification
|
||||
#: obligatoire : le nom de la collection d'icônes homonyme rendrait `rows` et
|
||||
#: `id` ambigus.
|
||||
_CARD_COLUMNS = """
|
||||
c.id, c.name, c.icon, c.parent_page_id, c.workspace_id, c.is_task,
|
||||
c.task_assignee_prop, c.task_status_prop, c.task_due_prop,
|
||||
(SELECT COUNT(*) FROM collection_pages cp
|
||||
WHERE cp.collection_id = c.id AND cp.parent_id IS NULL) AS rows
|
||||
"""
|
||||
|
||||
|
||||
def _esc(value) -> str:
|
||||
return (
|
||||
str(value if value is not None else "")
|
||||
.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace('"', """).replace("'", "'")
|
||||
)
|
||||
|
||||
|
||||
def _card(r: dict) -> str:
|
||||
"""Une carte de base : état My Tasks + ce qu'il reste à faire."""
|
||||
target = (f"/pages/{r['parent_page_id']}" if r["parent_page_id"]
|
||||
else f"/db/{r['id']}")
|
||||
labels = {"assignee": "Assigné à", "status": "Statut", "due": "Échéance"}
|
||||
missing = [role for role in ("assignee", "status", "due")
|
||||
if not r.get(f"task_{role}_prop")]
|
||||
|
||||
if r["is_task"] and not missing:
|
||||
badge = '<span class="db-card-badge ok">Base de tâches</span>'
|
||||
hint = "Alimente My Tasks."
|
||||
elif r["is_task"]:
|
||||
badge = '<span class="db-card-badge warn">À configurer</span>'
|
||||
hint = ("Il manque : " + ", ".join(labels[m] for m in missing)
|
||||
+ ". Ouvrez la base puis « Configurer » dans sa barre.")
|
||||
else:
|
||||
badge = ""
|
||||
hint = "Ouvrez-la puis cliquez sur « Convertir en base de tâches »."
|
||||
|
||||
# `workspace_id` est NULL pour une base portée par une page : on affiche le
|
||||
# nom du workspace effectif plutôt que celui de la colonne brute.
|
||||
ws_label = r.get("ws_label") or ""
|
||||
meta = f'{r["rows"]} ligne(s)'
|
||||
if ws_label:
|
||||
meta += f" · {ws_label}"
|
||||
|
||||
return f"""<a class="db-card" href="{_esc(target)}">
|
||||
<div class="db-card-head">
|
||||
<span class="db-card-icon">{_esc(r["icon"] or "📋")}</span>
|
||||
<span class="db-card-name">{_esc(r["name"])}</span>
|
||||
{badge}
|
||||
</div>
|
||||
<div class="db-card-meta">{_esc(meta)}</div>
|
||||
<div class="db-card-hint">{_esc(hint)}</div>
|
||||
</a>"""
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def list_collections(request: Request):
|
||||
"""Liste des bases du workspace, dans le layout de l'application."""
|
||||
user = _session_user(request)
|
||||
|
||||
if not user:
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
|
||||
with get_conn() as conn:
|
||||
workspace = _get_active_workspace(request, user.get("id")) or {}
|
||||
ws_id = workspace.get("id")
|
||||
ws_name = workspace.get("name") or "Workspace"
|
||||
|
||||
rows = _collections_of(conn, ws_id) if ws_id else []
|
||||
# Bases des autres workspaces : la page ne doit jamais laisser
|
||||
# l'utilisateur sans piste s'il en possède ailleurs.
|
||||
current_ids = {r["id"] for r in rows}
|
||||
others = [r for r in _all_collections(conn, user.get("id"), ws_id)
|
||||
if r["id"] not in current_ids]
|
||||
|
||||
connected = sum(1 for r in rows if r["is_task"])
|
||||
|
||||
return HTMLResponse(_render(request, ws_name, rows, connected, bool(ws_id),
|
||||
others))
|
||||
|
||||
|
||||
def _collections_of(conn, ws_id) -> list[dict]:
|
||||
"""Bases d'un workspace, vivantes seulement.
|
||||
|
||||
`live_collection_ids` écarte les bases dont la page hôte a disparu (corbeille
|
||||
ou suppression) : elles sont inaccessibles, les proposer serait trompeur.
|
||||
|
||||
Le rattachement passe par le workspace **effectif** : une base créée depuis
|
||||
un document a `workspace_id` NULL et n'est repérée que via sa page hôte.
|
||||
"""
|
||||
live = set(live_collection_ids(conn, ws_id))
|
||||
rows = conn.execute(
|
||||
f"""SELECT {_CARD_COLUMNS}
|
||||
FROM collections c
|
||||
WHERE {effective_workspace_sql("c")} = ?
|
||||
ORDER BY c.name""",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows if r["id"] in live]
|
||||
|
||||
|
||||
def _all_collections(conn, user_id, current_ws_id=None) -> list[dict]:
|
||||
"""Toutes les bases accessibles à l'utilisateur, tous workspaces confondus.
|
||||
|
||||
Sert à la section « Autres workspaces » : l'utilisateur doit pouvoir
|
||||
retrouver une base même si elle n'est pas dans le workspace actif (My Tasks
|
||||
étant transverse, ces bases l'alimentent aussi).
|
||||
|
||||
Le périmètre est celui de `user_workspace_ids` (propriétaire **ou** membre) :
|
||||
sans ce filtre, la page exposait les bases de tous les utilisateurs de
|
||||
l'instance. Les bases sans workspace rattachable sont exclues — elles
|
||||
n'appartiennent à personne.
|
||||
"""
|
||||
ws_ids = user_workspace_ids(conn, user_id)
|
||||
if not ws_ids:
|
||||
return []
|
||||
qs = ",".join("?" * len(ws_ids))
|
||||
names = {
|
||||
r["id"]: r["name"]
|
||||
for r in conn.execute(
|
||||
f"SELECT id, name FROM workspaces WHERE id IN ({qs})", ws_ids
|
||||
).fetchall()
|
||||
}
|
||||
rows = conn.execute(
|
||||
f"""SELECT {_CARD_COLUMNS},
|
||||
{effective_workspace_sql("c")} AS ws_effective
|
||||
FROM collections c
|
||||
WHERE {effective_workspace_sql("c")} IN ({qs})
|
||||
ORDER BY c.name""",
|
||||
ws_ids,
|
||||
).fetchall()
|
||||
|
||||
live: dict[int, set[int]] = {}
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
ws = d["ws_effective"]
|
||||
if ws not in live:
|
||||
live[ws] = set(live_collection_ids(conn, ws))
|
||||
if d["id"] not in live[ws]:
|
||||
continue
|
||||
# Le workspace courant est déjà indiqué dans l'en-tête de la page.
|
||||
d["ws_label"] = "" if ws == current_ws_id else names.get(ws, "")
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _render(request: Request, ws_name: str, rows: list[dict], connected: int,
|
||||
has_ws: bool, others: list[dict] | None = None) -> str:
|
||||
"""Rendu de la page, dans le layout commun (sidebar + base.html)."""
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
|
||||
others = others or []
|
||||
blocks = [_card(r) for r in rows]
|
||||
other_blocks = [_card(r) for r in others]
|
||||
|
||||
if rows or others:
|
||||
total = len(rows) + len(others)
|
||||
summary = (f"{connected} base(s) de tâches connectée(s) sur {total}"
|
||||
if connected else
|
||||
f"Aucune base de tâches connectée sur {total}")
|
||||
sections = f"""<div class="db-grid">{''.join(blocks)}</div>"""
|
||||
if other_blocks:
|
||||
sections += ('<h2 class="db-section-title">Autres workspaces</h2>'
|
||||
'<p class="db-section-sub">Ces bases appartiennent à '
|
||||
"d'autres workspaces — elles alimentent tout de même "
|
||||
f'My Tasks.</p><div class="db-grid">'
|
||||
f"{''.join(other_blocks)}</div>")
|
||||
body = f"""<div class="db-index">
|
||||
<div class="db-index-head">
|
||||
<h1>📋 Bases de données</h1>
|
||||
<span class="db-index-ws">{_esc(ws_name)}</span>
|
||||
</div>
|
||||
<p class="db-index-sub">{summary} — les bases de tâches alimentent
|
||||
<a href="/my-tasks">My Tasks</a>.</p>
|
||||
{sections}
|
||||
</div>"""
|
||||
elif has_ws:
|
||||
body = """<div class="db-empty">
|
||||
<div class="db-empty-icon">📋</div>
|
||||
<h1>Aucune base dans ce workspace</h1>
|
||||
<p>Créez une page, puis convertissez-la en base de données — ou partez
|
||||
d'un modèle depuis le menu d'ajout.</p>
|
||||
<p class="db-empty-hint">Une base reliée à My Tasks apparaît ici avec son
|
||||
état de configuration.</p>
|
||||
</div>"""
|
||||
else:
|
||||
body = """<div class="db-empty">
|
||||
<div class="db-empty-icon">📋</div>
|
||||
<h1>Aucun workspace sélectionné</h1>
|
||||
<p>Créez ou ouvrez d'abord un workspace : vos bases y seront rattachées.</p>
|
||||
</div>"""
|
||||
|
||||
# `my_tasks.css` porte les styles `.db-*` de cette page (cartes, sections,
|
||||
# états vides) **et** ceux de My Tasks. Elle est liée par `base.html`
|
||||
# (le shell, jamais swappé) : la lier ici exposait la page à un <link>
|
||||
# retiré par htmx lors d'une navigation partielle.
|
||||
block_tpl = ENV.from_string(
|
||||
'{% extends "base.html" %}'
|
||||
"{% block content %}{{ content_html|safe }}{% endblock %}"
|
||||
)
|
||||
return block_tpl.render(
|
||||
**_sidebar_data(request, []),
|
||||
request=request,
|
||||
content_html=body,
|
||||
page_title="Bases de données",
|
||||
title_prefix="Bases de données",
|
||||
page_icon="grid",
|
||||
)
|
||||
@@ -211,14 +211,25 @@ def create_inline_database(request: Request, body: dict = Body(default={})):
|
||||
|
||||
@router.put("/{collection_id}/toggle-task/api")
|
||||
def toggle_task(request: Request, collection_id: int):
|
||||
"""API: toggle is_task flag on a collection (Turn into Tasks)."""
|
||||
"""API: toggle is_task flag on a collection (Turn into Tasks).
|
||||
|
||||
Rétrocompatible : l'activation passe désormais par la même conversion que
|
||||
``POST /db/{id}/task-db/api``, donc les trois colonnes requises sont liées
|
||||
(créées si besoin). Sans cela, cette route laissait une base « connectée »
|
||||
mais muette — elle n'émettait aucune tâche faute de mapping.
|
||||
"""
|
||||
from .task_db import disable_task_db_in_conn, enable_task_db_in_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
new_val = 0 if coll["is_task"] else 1
|
||||
conn.execute("UPDATE collections SET is_task=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_val, collection_id))
|
||||
conn.commit()
|
||||
if coll["is_task"]:
|
||||
disable_task_db_in_conn(conn, collection_id)
|
||||
new_val = 0
|
||||
else:
|
||||
enable_task_db_in_conn(conn, collection_id)
|
||||
new_val = 1
|
||||
return {"collection_id": collection_id, "is_task": bool(new_val), "mode": "tasks" if new_val else "standard"}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""FlowDeck — Collections : « base de tâches » (My Tasks façon Notion).
|
||||
|
||||
Unlike Notion — where any base can be added to the dashboard widget from
|
||||
scratch — a base doit **explicitement** alimenter My Tasks :
|
||||
``POST /db/{id}/task-db`` bascule ``collections.is_task`` et enregistre le
|
||||
mapping des trois propriétés requises (Assigné à / Statut / Échéance).
|
||||
Tant que le mapping est incomplet, la base est connectée mais **n'émet
|
||||
aucune tâche** (`task_databases.collect_tasks` la saute) — l'UI le signale
|
||||
plutôt que de deviner une colonne.
|
||||
|
||||
Limite : 10 bases connectées au tableau de bord (`MAX_TASK_SOURCES`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.task_databases import (
|
||||
MAX_TASK_SOURCES,
|
||||
ROLE_LABELS,
|
||||
ROLE_TYPES,
|
||||
TASK_ROLES,
|
||||
list_task_sources,
|
||||
source_state,
|
||||
)
|
||||
|
||||
from ._common import _require_edit, _session_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
#: Trio de statuts amorcé quand une colonne Statut est créée à la conversion.
|
||||
DEFAULT_STATUS_OPTIONS = [
|
||||
{"name": "À faire", "color": "gray"},
|
||||
{"name": "En cours", "color": "blue"},
|
||||
{"name": "Terminée", "color": "green"},
|
||||
]
|
||||
|
||||
|
||||
def _user_id(request: Request) -> int:
|
||||
"""ID de l'appelant — session obligatoire.
|
||||
|
||||
`_current_user` retombe sur l'admin (id 1) en cas d'absence de session :
|
||||
wrong pour une route qui liste *les bases de l'utilisateur* (un anonyme
|
||||
verrait le tableau de bord de l'admin). On passe donc par `_session_user`.
|
||||
"""
|
||||
user = _session_user(request)
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user["id"]
|
||||
|
||||
|
||||
@router.get("/task-dbs/api")
|
||||
def list_connected_task_databases(request: Request):
|
||||
"""Bases de tâches connectées au tableau de bord My Tasks."""
|
||||
uid = _user_id(request)
|
||||
with get_conn() as conn:
|
||||
sources = list_task_sources(conn, uid)
|
||||
return {
|
||||
"sources": [
|
||||
{k: s[k] for k in (
|
||||
"id", "name", "icon", "workspace_id", "workspace_name",
|
||||
"configured", "missing_roles", "status_options",
|
||||
)}
|
||||
for s in sources
|
||||
],
|
||||
"total": len(sources),
|
||||
"max_sources": MAX_TASK_SOURCES,
|
||||
"limit_reached": len(sources) >= MAX_TASK_SOURCES,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/task-db/api")
|
||||
def get_task_db_state(request: Request, collection_id: int):
|
||||
"""État de conversion + colonnes candidates pour la modale."""
|
||||
_require_edit(collection_id, _session_user(request))
|
||||
with get_conn() as conn:
|
||||
state = source_state(conn, collection_id)
|
||||
if not state["exists"]:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
return state
|
||||
|
||||
|
||||
@router.post("/{collection_id}/task-db/api")
|
||||
def enable_task_db(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Convertit la base en base de tâches (mapping explicite des 3 rôles).
|
||||
|
||||
``mapping`` : ``{"assignee": <prop_id>, "status": …, "due": …}``.
|
||||
``create_missing`` : ``{"status": {"name": "Statut", "options": [...]}}``
|
||||
pour qu'une colonne absente soit créée dans la transaction.
|
||||
"""
|
||||
_require_edit(collection_id, _session_user(request))
|
||||
|
||||
with get_conn() as conn:
|
||||
state = enable_task_db_in_conn(
|
||||
conn,
|
||||
collection_id,
|
||||
mapping=body.get("mapping") or {},
|
||||
create_missing=body.get("create_missing") or {},
|
||||
)
|
||||
|
||||
return {"status": "enabled", "collection_id": collection_id,
|
||||
"mapping": state["mapping"], "configured": state["configured"]}
|
||||
|
||||
|
||||
def enable_task_db_in_conn(conn, collection_id: int, *, mapping: dict | None = None,
|
||||
create_missing: dict | None = None) -> dict:
|
||||
"""Active ``is_task`` et enregistre le mapping — dans la transaction
|
||||
appelante.
|
||||
|
||||
Point d'entrée unique de la conversion : la route `/task-db/api` comme
|
||||
l'ancien `PUT /db/{id}/toggle-task/api` (qui aurait sinon laissé une base
|
||||
« connectée » sans aucune colonne liée, donc muette).
|
||||
"""
|
||||
state = source_state(conn, collection_id)
|
||||
if not state["exists"]:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
# ── Limite de sources (on recompte : la base elle-même peut déjà être
|
||||
# connectée, auquel cas on ne la compte pas deux fois).
|
||||
if not state["is_task"] and state["sources_count"] >= MAX_TASK_SOURCES:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"Limite de {MAX_TASK_SOURCES} bases de tâches atteinte. "
|
||||
"Déconnectez une base avant d'en ajouter une autre."
|
||||
),
|
||||
)
|
||||
|
||||
# ── Création des colonnes manquantes ──
|
||||
create_missing = create_missing or {}
|
||||
mapping = dict(mapping or {})
|
||||
by_name = {p["name"]: p for p in state["all_properties"]}
|
||||
for role in TASK_ROLES:
|
||||
if mapping.get(role):
|
||||
continue
|
||||
spec = create_missing.get(role) or {}
|
||||
name = (spec.get("name") or ROLE_LABELS[role]).strip()
|
||||
prop_type = spec.get("prop_type") or ROLE_TYPES[role][0]
|
||||
if prop_type not in ROLE_TYPES[role]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Type '{prop_type}' incompatible avec le rôle '{role}'",
|
||||
)
|
||||
if name in by_name:
|
||||
# La colonne existe déjà (nom saisi) → on la lie.
|
||||
mapping[role] = by_name[name]["id"]
|
||||
continue
|
||||
created = _create_property(conn, collection_id, name, prop_type,
|
||||
spec.get("options"))
|
||||
mapping[role] = created["id"]
|
||||
|
||||
# ── Validation : les 3 rôles pointent une colonne de la bonne base ──
|
||||
props = {
|
||||
p["id"]: p
|
||||
for p in conn.execute(
|
||||
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
}
|
||||
seen: dict[int, str] = {}
|
||||
for role in TASK_ROLES:
|
||||
prop_id = mapping.get(role)
|
||||
if not prop_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Colonne « {ROLE_LABELS[role]} » manquante",
|
||||
)
|
||||
try:
|
||||
prop_id = int(prop_id)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400,
|
||||
detail="Identifiant de propriété invalide") from None
|
||||
prop = props.get(prop_id)
|
||||
if prop is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"« {ROLE_LABELS[role]} » n'appartient pas à cette base",
|
||||
)
|
||||
if prop["prop_type"] not in ROLE_TYPES[role]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"« {prop['name']} » est de type {prop['prop_type']}, "
|
||||
f"incompatible avec « {ROLE_LABELS[role]} »"
|
||||
),
|
||||
)
|
||||
if prop_id in seen:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"« {prop['name']} » est déjà utilisé pour « {seen[prop_id]} »",
|
||||
)
|
||||
seen[prop_id] = ROLE_LABELS[role]
|
||||
mapping[role] = prop_id
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collections
|
||||
SET is_task = 1,
|
||||
task_assignee_prop = ?, task_status_prop = ?, task_due_prop = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?""",
|
||||
(mapping["assignee"], mapping["status"], mapping["due"], collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
return source_state(conn, collection_id)
|
||||
|
||||
|
||||
@router.delete("/{collection_id}/task-db/api")
|
||||
def disable_task_db(request: Request, collection_id: int):
|
||||
"""Déconnecte la base du tableau de bord (les tâches sont conservées)."""
|
||||
_require_edit(collection_id, _session_user(request))
|
||||
with get_conn() as conn:
|
||||
disable_task_db_in_conn(conn, collection_id)
|
||||
return {"status": "disabled", "collection_id": collection_id}
|
||||
|
||||
|
||||
def disable_task_db_in_conn(conn, collection_id: int) -> None:
|
||||
"""Déconnexion dans la transaction appelante : efface aussi le mapping,
|
||||
pour ne pas laisser des identifiants de colonnes mortes."""
|
||||
row = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
conn.execute(
|
||||
"""UPDATE collections
|
||||
SET is_task = 0, task_assignee_prop = NULL, task_status_prop = NULL,
|
||||
task_due_prop = NULL, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?""",
|
||||
(collection_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _create_property(conn, collection_id: int, name: str, prop_type: str,
|
||||
options=None) -> dict:
|
||||
"""Crée une propriété (à la position suivante) et la renvoie.
|
||||
|
||||
Une colonne Statut amorcée sans options reçoit le trio classique
|
||||
« À faire / En cours / Terminée » : sans options, le Kanban n'aurait
|
||||
aucune colonne à afficher.
|
||||
"""
|
||||
import json
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties "
|
||||
"WHERE collection_id = ?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
position = row[0] if row else 0
|
||||
|
||||
opts = []
|
||||
for opt in options or []:
|
||||
if isinstance(opt, str):
|
||||
opts.append({"name": opt, "color": "gray"})
|
||||
elif isinstance(opt, dict) and opt.get("name"):
|
||||
opts.append({"name": opt["name"], "color": opt.get("color") or "gray"})
|
||||
if not opts and prop_type in ("status", "select"):
|
||||
opts = list(DEFAULT_STATUS_OPTIONS)
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, position, required,
|
||||
visible_in_views)
|
||||
VALUES (?, ?, ?, ?, ?, 0, 1)""",
|
||||
(collection_id, name, prop_type, json.dumps(opts), position),
|
||||
)
|
||||
prop_id = cur.lastrowid
|
||||
conn.commit()
|
||||
return {"id": prop_id, "name": name, "prop_type": prop_type}
|
||||
@@ -174,6 +174,11 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
active_ws_name = row["name"]
|
||||
workspace_pages = _load_workspace_pages(ws_cookie)
|
||||
has_active_workspace = True
|
||||
# v7.46.0 : exposer l'ID de l'espace ACTIF. Avant, le sidebar
|
||||
# ne fournissait que son nom et le bouton Home de la sidebar
|
||||
# retombait sur ``local_workspaces[0]`` (premier espace TRIE
|
||||
# PAR NOM) → clic sur Home = changement d'espace surprise.
|
||||
local_ws_id = wsi
|
||||
# else: stale cookie from another user — ignore
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
@@ -199,8 +204,9 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Get local workspace ID for Gitea workspace mirror
|
||||
local_ws_id = 0
|
||||
# Get local workspace ID for Gitea workspace mirror (le miroir Gitea est un
|
||||
# espace DISTINCT : on ne doit pas écraser ``local_ws_id`` (espace actif).
|
||||
gitea_mirror_ws_id = 0
|
||||
if gitea_workspace and gitea_owner and gitea_repo:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
@@ -209,18 +215,18 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
|
||||
).fetchone()
|
||||
if row:
|
||||
local_ws_id = row["id"]
|
||||
gitea_mirror_ws_id = row["id"]
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Private pages for mirror workspace (when Gitea remote active)
|
||||
private_pages = []
|
||||
if gitea_workspace and local_ws_id:
|
||||
if gitea_workspace and gitea_mirror_ws_id:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
pp_rows = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE parent_section='Private' AND workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20",
|
||||
(local_ws_id,)
|
||||
(gitea_mirror_ws_id,)
|
||||
).fetchall()
|
||||
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
|
||||
except Exception:
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import (
|
||||
WORKSPACE_COOKIE,
|
||||
_build_breadcrumb,
|
||||
_build_tree_children,
|
||||
_file_page_disk_path,
|
||||
@@ -34,10 +35,16 @@ router = APIRouter(tags=["dashboard"])
|
||||
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
|
||||
|
||||
@router.get("/local-workspace", response_class=HTMLResponse)
|
||||
def local_workspace_page(request: Request, folder: int = None):
|
||||
def local_workspace_page(request: Request, folder: int = None, ws: int = None):
|
||||
"""Local workspace page with file/folder tree.
|
||||
|
||||
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
||||
If ?ws=ID is provided, shows that workspace — c'est ce que fait le bouton
|
||||
« Home » de la sidebar (``/local-workspace?ws=<id>``). Le paramètre était
|
||||
produit par le sidebar mais **ignoré** par la route : le contenu affiché
|
||||
restait celui du workspace *actif* (cookie), donc Home pouvait montrer un
|
||||
autre workspace que celui annoncé dans l'URL. Un ``ws`` qui n'appartient
|
||||
pas à l'utilisateur est ignoré (et le workspace actif conservé).
|
||||
"""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
@@ -46,13 +53,46 @@ def local_workspace_page(request: Request, folder: int = None):
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
|
||||
ws = _get_active_workspace(request, user_id=user["id"])
|
||||
ws_id = ws["id"] if ws else None
|
||||
ws_row = _get_active_workspace(request, user_id=user["id"])
|
||||
if ws is not None:
|
||||
# Workspace demandé par l'URL : on ne l'accepte que s'il appartient à
|
||||
# l'utilisateur (même règle de propriété que _get_active_workspace).
|
||||
with get_conn() as conn:
|
||||
requested = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(ws, user["id"]),
|
||||
).fetchone()
|
||||
if requested is not None:
|
||||
ws_row = dict(requested)
|
||||
# L'URL fait foi : on aligne le cookie pour que le reste de
|
||||
# l'application (sidebar, API tree, breadcrumbs) suive le même
|
||||
# workspace que celui affiché.
|
||||
response = _render_local_workspace(env, sidebar, user, ws_row, folder)
|
||||
response.set_cookie(WORKSPACE_COOKIE, str(ws_row["id"]),
|
||||
max_age=86400 * 30, httponly=True, path="/")
|
||||
return response
|
||||
|
||||
ws_id = ws_row["id"] if ws_row else None
|
||||
|
||||
# If no workspace exists for this user, redirect to workspaces page
|
||||
if not ws_id:
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
|
||||
return _render_local_workspace(env, sidebar, user, ws_row, folder)
|
||||
|
||||
|
||||
def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict,
|
||||
folder: int | None) -> HTMLResponse:
|
||||
"""Render the local-workspace page for ``ws_row`` (shared by both paths).
|
||||
|
||||
``sidebar`` a été calculé **avant** le éventuel changement de workspace
|
||||
(``?ws=``) : on force donc le nom affiché et la sidebar sur ``ws_row``,
|
||||
sinon la page afficherait le contenu d'un workspace sous le titre d'un
|
||||
autre.
|
||||
"""
|
||||
ws_id = ws_row["id"]
|
||||
ws_name = (ws_row.get("name") or sidebar.get("active_ws_name") or "My Workspace")
|
||||
|
||||
# Build breadcrumb if navigating into a folder
|
||||
breadcrumb = []
|
||||
current_folder_id = folder
|
||||
@@ -63,7 +103,9 @@ def local_workspace_page(request: Request, folder: int = None):
|
||||
ctx = {
|
||||
**sidebar,
|
||||
"user": user,
|
||||
"workspace_name": sidebar.get("active_ws_name", "My Workspace"),
|
||||
"workspace_name": ws_name,
|
||||
"active_ws_name": ws_name,
|
||||
"active_workspace_id": ws_id,
|
||||
"current_folder_id": current_folder_id or 0,
|
||||
"workspace_id": ws_id or 0,
|
||||
"nav_workspace_id": ws_id or 0,
|
||||
@@ -255,6 +297,7 @@ def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
|
||||
@router.post("/api/local-workspace/items")
|
||||
def create_local_workspace_item(request: Request, body: dict = Body(default={})):
|
||||
"""Create a new file in the active workspace."""
|
||||
_require_user_id(request) # A3/A4 : pas de creation de page anonyme
|
||||
name = (body.get("name") or "").strip() or "Untitled"
|
||||
item_type = body.get("type", "page")
|
||||
parent_id = body.get("parent_id")
|
||||
@@ -279,6 +322,7 @@ def create_local_workspace_item(request: Request, body: dict = Body(default={}))
|
||||
@router.put("/api/local-workspace/items/{item_id:int}")
|
||||
def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Rename a file."""
|
||||
_require_user_id(request)
|
||||
name = body.get("name", "Untitled").strip()
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
|
||||
@@ -291,6 +335,7 @@ def rename_local_workspace_item(request: Request, item_id: int, body: dict = Bod
|
||||
@router.delete("/api/local-workspace/items/{item_id:int}")
|
||||
def delete_local_workspace_item(request: Request, item_id: int):
|
||||
"""Soft-delete a file/folder (sets deleted_at)."""
|
||||
_require_user_id(request)
|
||||
from datetime import datetime
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -306,6 +351,7 @@ def delete_local_workspace_item(request: Request, item_id: int):
|
||||
@router.post("/api/local-workspace/items/{item_id:int}/restore")
|
||||
def restore_local_workspace_item(request: Request, item_id: int):
|
||||
"""Restore a soft-deleted file/folder."""
|
||||
_require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=NULL WHERE id=?",
|
||||
@@ -342,6 +388,7 @@ def serve_uploaded_file(ws_id: int, filename: str):
|
||||
@router.put("/api/local-workspace/items/{item_id:int}/move")
|
||||
def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Move an item to a new parent (drag & drop)."""
|
||||
_require_user_id(request)
|
||||
new_parent_id = body.get("parent_id") # None = move to root
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
|
||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -62,11 +62,20 @@ def api_rename_page(page_id: int, request: Request, body: dict = Body(default={}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/trash")
|
||||
def api_trash_page(page_id: int):
|
||||
"""Soft-delete a page (move to trash)."""
|
||||
def api_trash_page(page_id: int, request: Request):
|
||||
"""Soft-delete a page (move to trash).
|
||||
|
||||
v7.45.5 : ``parent_section`` n'est plus réécrit en 'Trash' — ``deleted_at``
|
||||
est la seule source de vérité. L'ancienne écriture marquait définitivement
|
||||
la page : à la restauration elle restait 'Trash' et disparaissait des
|
||||
listings Library / Recents / Private (``parent_section != 'Trash'``).
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_section='Trash', deleted_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
"UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -29,15 +29,16 @@ def trash_page(request: Request, owner: str = Query(default=""), repo: str = Que
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
with get_conn() as conn:
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
# Pages are soft-deleted via parent_section='Trash'
|
||||
# Pages are soft-deleted via deleted_at (parent_section n'est plus
|
||||
# touché par le trash → source de vérité unique, v7.45.5)
|
||||
if ws_key:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' AND workspace=? ORDER BY updated_at DESC",
|
||||
"SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL AND workspace=? ORDER BY updated_at DESC",
|
||||
(ws_key,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' ORDER BY updated_at DESC",
|
||||
"SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC",
|
||||
).fetchall()
|
||||
sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows]
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
@@ -216,8 +217,10 @@ def view_page_root(request: Request, page_id: int):
|
||||
"{% 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:
|
||||
# Select template: collection pages use database table view (aussi en
|
||||
# mode embed : `page_editor_collection.html` + body.embed-mode = le
|
||||
# tableau SANS sidebar ni barre — le peek d'une base reste le tableau).
|
||||
if page.get("content_format") == "collection":
|
||||
template = env.get_template("page_editor_collection.html")
|
||||
else:
|
||||
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
|
||||
|
||||
@@ -6,18 +6,47 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.collection_lifecycle import (
|
||||
delete_collections,
|
||||
workspace_collection_ids,
|
||||
)
|
||||
|
||||
from ._common import WORKSPACE_COOKIE, _get_active_workspace, _get_user_id, _sidebar_data
|
||||
from ._common import (
|
||||
WORKSPACE_COOKIE,
|
||||
_get_active_workspace,
|
||||
_get_user_id,
|
||||
_require_user_id,
|
||||
_sidebar_data,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
def _require_ws_owner(request: Request, ws_id: int) -> int:
|
||||
"""A3/A4 — session obligatoire + l'appelant doit posseder l'espace
|
||||
(ou être admin global). Sans ce garde, un POST/DELETE anonyme créait ou
|
||||
supprimait des espaces au nom de l'utilisateur 1."""
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT owner_id FROM workspaces WHERE id=?", (ws_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
is_admin = conn.execute(
|
||||
"SELECT is_admin FROM users WHERE id=?", (uid,)
|
||||
).fetchone()
|
||||
if row["owner_id"] != uid and not (is_admin and is_admin["is_admin"]):
|
||||
raise HTTPException(403, "Not your workspace")
|
||||
return uid
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/workspaces", response_class=HTMLResponse)
|
||||
@@ -42,8 +71,7 @@ def workspaces_page(request: Request):
|
||||
@router.get("/api/workspaces")
|
||||
def list_workspaces(request: Request):
|
||||
"""List all workspaces for the current user."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id AND collection_row_id IS NULL) as page_count "
|
||||
@@ -67,7 +95,7 @@ def create_workspace(request: Request, body: dict = Body(default={})):
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
# Ensure user exists (FK constraint)
|
||||
uid_ok = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
|
||||
@@ -99,6 +127,7 @@ def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
_require_ws_owner(request, ws_id)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE workspaces SET name=? WHERE id=?", (name, ws_id))
|
||||
conn.commit()
|
||||
@@ -109,13 +138,19 @@ def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})
|
||||
|
||||
@router.delete("/api/workspaces/{ws_id:int}")
|
||||
def delete_workspace(request: Request, ws_id: int):
|
||||
"""Delete a workspace and all its pages."""
|
||||
"""Delete a workspace, its pages and its databases."""
|
||||
_require_ws_owner(request, ws_id)
|
||||
with get_conn() as conn:
|
||||
# Les bases du workspace partaient avec ses pages : les laisser créait
|
||||
# des collections orphelines (workspace_id pointant sur rien) que
|
||||
# /db et My Tasks continuaient de lister.
|
||||
collections = workspace_collection_ids(conn, ws_id)
|
||||
delete_collections(conn, collections)
|
||||
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,))
|
||||
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
return {"status": "ok", "collections": len(collections)}
|
||||
|
||||
|
||||
|
||||
@@ -123,6 +158,7 @@ def delete_workspace(request: Request, ws_id: int):
|
||||
@router.post("/api/workspaces/{ws_id:int}/select")
|
||||
def select_workspace(request: Request, ws_id: int):
|
||||
"""Set the active workspace via cookie."""
|
||||
_require_ws_owner(request, ws_id)
|
||||
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
|
||||
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
|
||||
return response
|
||||
|
||||
@@ -9,7 +9,9 @@ See ``docs/V71_Calendar_Meetings.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -115,6 +117,72 @@ def freebusy(collection_id: int, request: Request):
|
||||
return out
|
||||
|
||||
|
||||
# ── upcoming (sidebar "Meeting" tab) ────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/meetings/upcoming")
|
||||
def upcoming_meetings(request: Request, days: int = 30):
|
||||
"""Upcoming calendar events for the sidebar Meeting tab.
|
||||
|
||||
Reads every calendar collection linked to the current user, extracts the
|
||||
date property of each linked row and returns the ones falling in the next
|
||||
``days`` days (today included), sorted chronologically. Rows imported from
|
||||
Google/CalDAV carry an ``external_event_id`` and are flagged ``synced``.
|
||||
"""
|
||||
user = _auth_user(request)
|
||||
horizon = max(1, min(days, 365))
|
||||
today = datetime.now(UTC).date()
|
||||
end = today + timedelta(days=horizon)
|
||||
events: list[dict] = []
|
||||
with get_conn() as conn:
|
||||
links = conn.execute(
|
||||
"SELECT id, collection_id, date_property, calendar_id, provider "
|
||||
"FROM calendar_links WHERE user_id=? ORDER BY id",
|
||||
(user["id"],),
|
||||
).fetchall()
|
||||
for link in links:
|
||||
collection_id = link["collection_id"]
|
||||
if not collection_id:
|
||||
continue
|
||||
date_prop = cal.date_prop_id(conn, collection_id,
|
||||
link["date_property"] or "")
|
||||
if not date_prop:
|
||||
continue
|
||||
prop_id, prop_name = date_prop
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, property_values_json, external_event_id "
|
||||
"FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
try:
|
||||
values = json.loads(row["property_values_json"] or "{}")
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
raw = cal.row_date(values, prop_id, prop_name)[:10]
|
||||
if len(raw) != 10:
|
||||
continue
|
||||
try:
|
||||
day = date.fromisoformat(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
if not (today <= day <= end):
|
||||
continue
|
||||
events.append({
|
||||
"id": row["id"],
|
||||
"title": row["title"] or "Untitled",
|
||||
"date": raw,
|
||||
"today": day == today,
|
||||
"collection_id": collection_id,
|
||||
"calendar_id": link["calendar_id"] or "primary",
|
||||
"provider": link["provider"],
|
||||
"synced": bool(row["external_event_id"]),
|
||||
"url": f"/db/{collection_id}",
|
||||
})
|
||||
events.sort(key=lambda e: (e["date"], e["title"].lower()))
|
||||
return {"today": today.isoformat(), "days": horizon,
|
||||
"events": events[:200], "count": len(events)}
|
||||
|
||||
|
||||
# ── meetings ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/meetings/transcribe")
|
||||
|
||||
+374
-98
@@ -1,147 +1,423 @@
|
||||
"""FlowDeck — My Tasks router (v1.9.0)."""
|
||||
"""FlowDeck — My Tasks (tableau de bord de tâches façon Notion).
|
||||
|
||||
Contrairement à Notion, où *My Tasks* vit dans l'onglet **Home** sans être
|
||||
rattaché à une page, cette page est un **tableau de bord transverse** : elle
|
||||
réunit toutes les tâches qui vous sont assignées, **tous workspaces
|
||||
confondus**. Le cookie `flowdeck_workspace` ne cadre donc plus cette vue (il
|
||||
reste utilisé par la sidebar).
|
||||
|
||||
Les sources sont opt-in : une base n'alimente My Tasks qu'après conversion
|
||||
explicite (`collections.is_task` + mapping des trois propriétés requises —
|
||||
`app/services/task_databases.py`).
|
||||
|
||||
Trois vues (tableau / kanban / calendrier), filtres globaux (échéance
|
||||
aujourd'hui, masquer les terminées), tri (échéance, statut, source), édition
|
||||
en direct du statut et de l'échéance, et création rapide dans n'importe
|
||||
quelle base connectée.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.property_types import apply_auto_properties
|
||||
from app.services.task_databases import (
|
||||
MAX_TASK_SOURCES,
|
||||
collect_tasks,
|
||||
filter_and_sort,
|
||||
list_task_sources,
|
||||
user_today,
|
||||
)
|
||||
from app.templating import ENV
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
|
||||
|
||||
VIEWS = ("table", "board", "calendar")
|
||||
DUE_FILTERS = ("all", "today", "overdue", "week")
|
||||
SORTS = ("due", "status", "source", "created")
|
||||
|
||||
|
||||
# ── Session ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_current_user(request: Request) -> dict | None:
|
||||
session = request.cookies.get("flowdeck_session", "")
|
||||
return SessionManager.decode_session(session)
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
|
||||
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
|
||||
def _require_user(request: Request) -> dict:
|
||||
"""Session obligatoire, sans repli « admin » (id 1) : ce tableau de bord
|
||||
est strictement celui de l'appelant."""
|
||||
user = _get_current_user(request)
|
||||
user_login = user.get("login", "admin") if user else "admin"
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
with get_conn() as conn:
|
||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||
grouped: dict[str, list[dict]] = {}
|
||||
|
||||
for col in collections:
|
||||
col_id = col["id"]
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||
(col_id,),
|
||||
).fetchall()
|
||||
# ── Données ──────────────────────────────────────────────────────────────
|
||||
|
||||
collection_tasks = []
|
||||
for p in pages:
|
||||
props = {}
|
||||
try:
|
||||
props = json.loads(p["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
due_date = props.get("due_date", "")
|
||||
status = props.get("status", "—")
|
||||
assignee = props.get("assignee", "")
|
||||
if view != "all" and assignee and assignee != user_login:
|
||||
continue
|
||||
collection_tasks.append({
|
||||
"id": p["id"],
|
||||
"title": p["title"] or "Untitled",
|
||||
"icon": p["icon"] or "📋",
|
||||
"status": status,
|
||||
"due_date": due_date,
|
||||
})
|
||||
if collection_tasks:
|
||||
grouped[col["name"]] = collection_tasks
|
||||
def _workspace_names(conn, user_id: int) -> dict[int, str]:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE owner_id = ?", (user_id,)
|
||||
).fetchall()
|
||||
return {r["id"]: r["name"] for r in rows}
|
||||
|
||||
total = sum(len(t) for t in grouped.values())
|
||||
|
||||
# Build content HTML
|
||||
sections = ""
|
||||
for col_name, tasks in grouped.items():
|
||||
items = ""
|
||||
for t in tasks:
|
||||
due_badge = f"<span class='mt-due'>{t['due_date']}</span>" if t.get("due_date") else ""
|
||||
status_cls = t['status'].lower().replace(' ', '-') if t.get('status') else 'none'
|
||||
status_badge = f"<span class='mt-status mt-{status_cls}'>{t.get('status', '—')}</span>"
|
||||
items += f"<div class='mt-item'><span class='mt-icon'>{t['icon']}</span><span class='mt-title'>{t['title']}</span>{status_badge}{due_badge}</div>"
|
||||
def _validated(view: str, due: str, sort: str) -> tuple[str, str, str]:
|
||||
"""Paramètres d'URL bornés aux valeurs réellement implémentées."""
|
||||
return (
|
||||
view if view in VIEWS else "table",
|
||||
due if due in DUE_FILTERS else "all",
|
||||
sort if sort in SORTS else "due",
|
||||
)
|
||||
|
||||
sections += f"<div class='mt-section'><div class='mt-section-header'>{col_name} <span class='mt-count'>{len(tasks)}</span></div>{items}</div>"
|
||||
|
||||
content_html = f"""<div style="max-width:800px;margin:0 auto;padding:40px 24px;">
|
||||
<h1 style="font-size:24px;margin:0 0 8px;">📋 My Tasks</h1>
|
||||
<p style="color:var(--text-dim);font-size:14px;margin-bottom:24px;">{total} tasks across {len(grouped)} collections</p>
|
||||
<div class="view-tabs" style="display:flex;gap:4px;margin-bottom:24px;border-bottom:1px solid var(--border);padding-bottom:12px;">
|
||||
<a class="tab{' active' if view=='all' else ''}" href="/my-tasks?view=all" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">All</a>
|
||||
<a class="tab{' active' if view=='today' else ''}" href="/my-tasks?view=today" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Today</a>
|
||||
<a class="tab{' active' if view=='overdue' else ''}" href="/my-tasks?view=overdue" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Overdue</a>
|
||||
<a class="tab{' active' if view=='upcoming' else ''}" href="/my-tasks?view=upcoming&days=7" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Next 7 days</a>
|
||||
</div>
|
||||
<style>
|
||||
.tab.active{{background:var(--accent);color:#fff!important;}}
|
||||
.tab:hover{{background:var(--bg-hover);color:var(--text);}}
|
||||
.mt-section{{margin-bottom:24px;}}
|
||||
.mt-section-header{{font-size:13px;font-weight:600;color:var(--text-dim);margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px;}}
|
||||
.mt-count{{color:var(--text-dim);font-weight:400;opacity:.5;}}
|
||||
.mt-item{{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg-card);border-radius:8px;margin-bottom:3px;border:1px solid var(--border);}}
|
||||
.mt-item:hover{{border-color:var(--accent);}}
|
||||
.mt-icon{{font-size:16px;}}
|
||||
.mt-title{{flex:1;font-size:14px;}}
|
||||
.mt-status{{font-size:11px;padding:2px 8px;border-radius:4px;}}
|
||||
.mt-due{{font-size:11px;color:var(--text-dim);}}
|
||||
.mt-todo,.mt-none{{background:rgba(255,255,255,.05);color:var(--text-dim);}}
|
||||
.mt-in-progress{{background:rgba(35,131,226,.15);color:#2C8CEB;}}
|
||||
.mt-done,.mt-completed,.mt-complete{{background:rgba(0,200,100,.15);color:#00CC66;}}
|
||||
</style>
|
||||
{sections}
|
||||
</div>"""
|
||||
def _public_source(s: dict) -> dict:
|
||||
return {
|
||||
"id": s["id"],
|
||||
"name": s["name"],
|
||||
"icon": s["icon"],
|
||||
"workspace_id": s["workspace_id"],
|
||||
"workspace_name": s.get("workspace_name") or "",
|
||||
"configured": s["configured"],
|
||||
"missing_roles": s["missing_roles"],
|
||||
"status_options": s["status_options"],
|
||||
}
|
||||
|
||||
|
||||
def _payload(conn, request: Request, user_id: int, *, due: str, sort: str,
|
||||
hide_done: bool) -> dict:
|
||||
"""Charge et normalise tout ce dont les trois vues ont besoin."""
|
||||
user = _get_current_user(request) or {}
|
||||
tasks = collect_tasks(conn, user_id, mine_only=True,
|
||||
login=user.get("login", ""))
|
||||
# Même jour de référence que `due_state` (fuseau de l'utilisateur).
|
||||
tasks = filter_and_sort(tasks, due=due, hide_done=hide_done, sort=sort,
|
||||
today=user_today(conn, user_id))
|
||||
sources = list_task_sources(conn, user_id)
|
||||
return {
|
||||
"tasks": tasks,
|
||||
"total": len(tasks),
|
||||
"sources": [_public_source(s) for s in sources],
|
||||
"sources_total": len(sources),
|
||||
"sources_configured": sum(1 for s in sources if s["configured"]),
|
||||
"max_sources": MAX_TASK_SOURCES,
|
||||
"filters": {"due": due, "sort": sort, "hide_done": hide_done},
|
||||
}
|
||||
|
||||
|
||||
# ── Rendu HTML ───────────────────────────────────────────────────────────
|
||||
|
||||
def _render_shell(request: Request, content_html: str, *, app_js: bool = True):
|
||||
"""Enveloppe le contenu dans le layout commun (sidebar + base.html).
|
||||
|
||||
`static/css/my_tasks.css` est lié par `base.html` (le shell, jamais swappé)
|
||||
: un `<link>` placé dans la zone swappée pouvait être retiré par htmx lors
|
||||
d'une navigation partielle, et la page revenait sans aucun style. Seul
|
||||
`static/js/my_tasks.js` reste conditionné à ``app_js`` : sans base
|
||||
connectée, il n'y a rien à monter côté client.
|
||||
"""
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
env = ENV
|
||||
|
||||
sidebar = _sidebar_data(request, [])
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
# `assets` est rendu par un mini-template : `asset_version` (cache-busting)
|
||||
# n'est pas substitutionné si on le concatène à la main.
|
||||
assets = ENV.from_string(
|
||||
"{% if app_js %}"
|
||||
'<script src="/static/js/my_tasks.js?v={{ asset_version }}" defer></script>'
|
||||
"{% endif %}"
|
||||
).render(app_js=app_js)
|
||||
|
||||
block_tpl = ENV.from_string(
|
||||
'{% extends "base.html" %}'
|
||||
"{% block content %}{{ assets|safe }}{{ content_html|safe }}{% endblock %}"
|
||||
)
|
||||
return block_tpl.render(
|
||||
**sidebar,
|
||||
request=request,
|
||||
content_html=content_html,
|
||||
assets=assets,
|
||||
page_title="My Tasks",
|
||||
title_prefix="My Tasks",
|
||||
page_icon="📋",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
def my_tasks_api(request: Request, view: str = "all", days: int = 7):
|
||||
"""API: return my tasks as JSON."""
|
||||
def _json_attr(obj) -> str:
|
||||
"""JSON échappé pour un attribut HTML."""
|
||||
return html.escape(json.dumps(obj, ensure_ascii=False), quote=True)
|
||||
|
||||
|
||||
def _render_app(data: dict, view: str) -> str:
|
||||
"""Coquille du tableau de bord : barre d'outils, filtres et zone de vue
|
||||
sont rendus côté client à partir de la configuration ci-dessous."""
|
||||
config = {
|
||||
"view": view,
|
||||
"filters": data["filters"],
|
||||
"sources": data["sources"],
|
||||
"maxSources": data["max_sources"],
|
||||
"views": list(VIEWS),
|
||||
}
|
||||
return ('<div id="my-tasks-app" class="my-tasks-app" '
|
||||
f'data-config="{_json_attr(config)}"></div>')
|
||||
|
||||
|
||||
def _render_empty(data: dict) -> str:
|
||||
"""État vide « aucune source » : explique la conversion en base de tâches.
|
||||
|
||||
Gabarit centré (`my_tasks-empty*` dans `static/css/my_tasks.css`), sans
|
||||
style inline : la mise en page suit le thème clair/sombre et reste
|
||||
modifiable en un seul endroit. Les autres états vides (filtres actifs,
|
||||
rien à faire) sont rendus par le client, qui connaît l'état des filtres.
|
||||
"""
|
||||
return f"""<div class="my-tasks-empty">
|
||||
<div class="my-tasks-empty-icon">📋</div>
|
||||
<h2>My Tasks</h2>
|
||||
<p>Aucune base n'alimente encore ce tableau de bord. Pour en ajouter une :</p>
|
||||
<ol class="my-tasks-empty-steps">
|
||||
<li><span class="n">1</span><span>Ouvrez une base de données depuis
|
||||
<a href="/db">la liste de vos bases</a>.</span></li>
|
||||
<li><span class="n">2</span><span>Dans la barre de la base, cliquez sur
|
||||
<b>« Convertir en base de tâches »</b> — il se trouve à gauche du bouton
|
||||
<b>New</b>.</span></li>
|
||||
<li><span class="n">3</span><span>Reliez les colonnes <b>Assigné à</b>,
|
||||
<b>Statut</b> et <b>Échéance</b>, puis validez.</span></li>
|
||||
</ol>
|
||||
<p class="my-tasks-empty-hint">Jusqu'à {data["max_sources"]} bases
|
||||
peuvent alimenter ce tableau de bord.</p>
|
||||
<a href="/db" class="my-tasks-empty-btn">Ouvrir mes bases</a>
|
||||
</div>"""
|
||||
|
||||
|
||||
def _render_no_workspace() -> str:
|
||||
return """<div class="my-tasks-empty">
|
||||
<div class="my-tasks-empty-icon">📋</div>
|
||||
<h2>My Tasks</h2>
|
||||
<p>Aucun workspace. Créez un workspace, puis une base de tâches : elle
|
||||
alimentera ce tableau de bord.</p>
|
||||
<a href="/workspaces" class="my-tasks-empty-btn">Créer un workspace</a>
|
||||
</div>"""
|
||||
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def my_tasks_dashboard(request: Request, view: str = "table", due: str = "all",
|
||||
sort: str = "due", hide_done: bool = False):
|
||||
"""My Tasks — toutes vos tâches, tous workspaces confondus."""
|
||||
user = _get_current_user(request)
|
||||
user.get("login", "admin") if user else "admin"
|
||||
if not user or not user.get("id"):
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
|
||||
view, due, sort = _validated(view, due, sort)
|
||||
with get_conn() as conn:
|
||||
if not _workspace_names(conn, user["id"]):
|
||||
return _render_shell(request, _render_no_workspace(), app_js=False)
|
||||
data = _payload(conn, request, user["id"], due=due, sort=sort,
|
||||
hide_done=hide_done)
|
||||
|
||||
# Dès qu'au moins une base alimente le tableau de bord, le rendu est pris
|
||||
# en charge par le client (3 vues + états vides contextualisés). Sans
|
||||
# aucune source, le serveur affiche l'invite à convertir une base — le
|
||||
# fichier JS n'est alors pas chargé.
|
||||
if data["total"] or data["sources_total"]:
|
||||
return _render_shell(request, _render_app(data, view))
|
||||
return _render_shell(request, _render_empty(data), app_js=False)
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
def my_tasks_api(request: Request, view: str = "table", due: str = "all",
|
||||
sort: str = "due", hide_done: bool = False):
|
||||
"""API: tâches normalisées + sources connectées (alimente les 3 vues)."""
|
||||
user = _require_user(request)
|
||||
view, due, sort = _validated(view, due, sort)
|
||||
with get_conn() as conn:
|
||||
return _payload(conn, request, user["id"], due=due, sort=sort,
|
||||
hide_done=hide_done)
|
||||
|
||||
|
||||
@router.post("/api/task")
|
||||
def my_tasks_create(request: Request, body: dict = Body(default={})):
|
||||
"""Création rapide multi-destinations : la tâche part dans la base choisie.
|
||||
|
||||
Elle est **auto-assignée** : sans colonne « Assigné à » renseignée, une
|
||||
tâche nouvelle n'apparaîtrait pas dans « mes tâches », ce qui serait
|
||||
illisible pour l'utilisateur qui vient de la créer.
|
||||
"""
|
||||
user = _require_user(request)
|
||||
collection_id = body.get("collection_id")
|
||||
title = str(body.get("title") or "").strip()
|
||||
if not collection_id:
|
||||
raise HTTPException(status_code=400, detail="collection_id is required")
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
|
||||
with get_conn() as conn:
|
||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||
result = {}
|
||||
coll = conn.execute(
|
||||
"SELECT id, task_assignee_prop, task_due_prop FROM collections "
|
||||
"WHERE id=? AND is_task=1",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
if coll is None:
|
||||
raise HTTPException(status_code=400,
|
||||
detail="This database does not feed My Tasks")
|
||||
|
||||
for col in collections:
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||
(col["id"],),
|
||||
props_meta = [
|
||||
dict(p) for p in conn.execute(
|
||||
"SELECT id, name, prop_type FROM collection_properties "
|
||||
"WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
tasks = []
|
||||
for p in pages:
|
||||
props = json.loads(p["property_values_json"])
|
||||
tasks.append({
|
||||
"id": p["id"], "title": p["title"], "icon": p["icon"],
|
||||
"properties": props,
|
||||
})
|
||||
properties: dict = {}
|
||||
title_prop = next((p for p in props_meta if p["prop_type"] == "title"), None)
|
||||
if title_prop:
|
||||
properties[str(title_prop["id"])] = title
|
||||
|
||||
if tasks:
|
||||
result[col["name"]] = tasks
|
||||
if body.get("due") and coll["task_due_prop"]:
|
||||
properties[str(coll["task_due_prop"])] = str(body["due"])[:10]
|
||||
|
||||
return {"tasks": result, "total": sum(len(t) for t in result.values())}
|
||||
if coll["task_assignee_prop"]:
|
||||
member = conn.execute(
|
||||
"SELECT id, login, full_name, avatar_url, avatar_color "
|
||||
"FROM users WHERE id=?",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
if member:
|
||||
properties[str(coll["task_assignee_prop"])] = [{
|
||||
"id": member["id"],
|
||||
"login": member["login"],
|
||||
"full_name": member["full_name"],
|
||||
"avatar_url": member["avatar_url"] or "",
|
||||
"avatar_color": member["avatar_color"] or "",
|
||||
}]
|
||||
|
||||
apply_auto_properties(props_meta, properties, user, is_create=True)
|
||||
|
||||
nxt = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages "
|
||||
"WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, icon, position, property_values_json)
|
||||
VALUES (?, ?, '📄', ?, ?)""",
|
||||
(collection_id, title, nxt, json.dumps(properties)),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
conn.commit()
|
||||
|
||||
if title_prop:
|
||||
from app.services.row_pages import sync_row_title_to_page
|
||||
|
||||
sync_row_title_to_page(conn, page_id)
|
||||
conn.commit()
|
||||
|
||||
logger.info("my-tasks: task %s created in collection %s", page_id, collection_id)
|
||||
return {"status": "created", "id": page_id, "collection_id": collection_id,
|
||||
"title": title}
|
||||
|
||||
|
||||
@router.put("/api/task/{page_id}")
|
||||
def my_tasks_update(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Édition en direct du statut / de l'échéance / du titre.
|
||||
|
||||
L'écriture passe par le **mapping enregistré de la base source** (et non
|
||||
par une devinette « première colonne de type X »), puis la tâche est relue
|
||||
et renvoyée normalisée : le front n'a rien à recalculer.
|
||||
"""
|
||||
user = _require_user(request)
|
||||
field = body.get("field")
|
||||
if field not in ("status", "due", "title", "assignee"):
|
||||
raise HTTPException(status_code=400, detail="Unsupported field")
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, collection_id, title, property_values_json "
|
||||
"FROM collection_pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
coll = conn.execute(
|
||||
"SELECT id, is_task, task_assignee_prop, task_status_prop, task_due_prop "
|
||||
"FROM collections WHERE id=?",
|
||||
(row["collection_id"],),
|
||||
).fetchone()
|
||||
if coll is None:
|
||||
raise HTTPException(status_code=404, detail="Source not found")
|
||||
if not coll["is_task"]:
|
||||
raise HTTPException(status_code=400,
|
||||
detail="This database no longer feeds My Tasks")
|
||||
|
||||
column = {"status": coll["task_status_prop"],
|
||||
"due": coll["task_due_prop"],
|
||||
"assignee": coll["task_assignee_prop"]}.get(field)
|
||||
if field != "title" and not column:
|
||||
raise HTTPException(status_code=400,
|
||||
detail=f"This database has no {field} column")
|
||||
|
||||
try:
|
||||
pv = json.loads(row["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pv = {}
|
||||
if not isinstance(pv, dict):
|
||||
pv = {}
|
||||
|
||||
new_title = None
|
||||
if field == "title":
|
||||
new_title = str(body.get("value") or "").strip()
|
||||
title_prop = conn.execute(
|
||||
"SELECT id FROM collection_properties "
|
||||
"WHERE collection_id=? AND prop_type='title' ORDER BY position LIMIT 1",
|
||||
(coll["id"],),
|
||||
).fetchone()
|
||||
if title_prop:
|
||||
pv[str(title_prop["id"])] = new_title
|
||||
elif field == "assignee":
|
||||
# La valeur est une liste de personnes : elle est écrite telle
|
||||
# quelle (booleenner écraserait la colonne).
|
||||
pv[str(column)] = body.get("value") or []
|
||||
elif field == "due":
|
||||
pv[str(column)] = str(body.get("value") or "")[:10]
|
||||
else:
|
||||
pv[str(column)] = str(body.get("value") or "")
|
||||
|
||||
if new_title is not None:
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET title=?, property_values_json=?, "
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_title, json.dumps(pv), page_id),
|
||||
)
|
||||
from app.services.row_pages import sync_row_title_to_page
|
||||
|
||||
sync_row_title_to_page(conn, page_id)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, "
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(pv), page_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
tasks = collect_tasks(conn, user["id"], mine_only=True,
|
||||
login=user.get("login", ""))
|
||||
updated = next((t for t in tasks if t["id"] == page_id), None)
|
||||
|
||||
if updated is None:
|
||||
# La tâche vient de cesser d'être « mienne » (désassignée) : le front
|
||||
# doit la retirer, pas la re-rendre à l'identique.
|
||||
return {"status": "updated", "task": None, "left_mytasks": True}
|
||||
|
||||
return {"status": "updated", "task": updated, "left_mytasks": False}
|
||||
|
||||
+50
-19
@@ -8,7 +8,7 @@ import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -16,14 +16,32 @@ from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["workspace"], prefix="/workspace")
|
||||
|
||||
ROLES = ["admin", "editor", "commenter", "viewer"]
|
||||
|
||||
|
||||
def _require_session(request: Request) -> dict:
|
||||
"""A3/A4 — session obligatoire sur TOUT le router `/workspace`.
|
||||
|
||||
Avant, ces routes s'exécutaient sans session : `GET /workspace/collections/
|
||||
{id}/export/csv` renvoyait le contenu réel d'une collection à un visiteur
|
||||
anonyme, et les POST créaient des données au nom de l'utilisateur 1.
|
||||
La seule route publique (`/workspace/public/...`) vit désormais dans
|
||||
``public_router``, sans dépendance.
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
router = APIRouter(tags=["workspace"], prefix="/workspace", dependencies=[Depends(_require_session)])
|
||||
# Route de partage public : AUCUNE dépendance d'authentification.
|
||||
public_router = APIRouter(tags=["workspace-public"], prefix="/workspace")
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
s = request.cookies.get("flowdeck_session", "")
|
||||
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
||||
"""Session obligatoire — plus de repli « admin implicite » (A3/A4)."""
|
||||
return _require_session(request)
|
||||
|
||||
|
||||
def _require_admin(request: Request) -> dict:
|
||||
@@ -209,45 +227,58 @@ def record_history(request: Request, page_id: int, body: dict = Body(default={})
|
||||
|
||||
@router.get("/favorites")
|
||||
def list_favorites(request: Request):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
"""List the caller's favorites.
|
||||
|
||||
v7.46.0 — aligne sur le schéma v2.2.0 (``app/db.py``) : la table
|
||||
``favorites`` n'a plus de colonne ``collection_id`` et ``page_id``
|
||||
référence ``pages(id)``. L'ancienne requête (jointure ``collection_pages``)
|
||||
levait ``sqlite3.OperationalError: no such column: f.collection_id`` → 500.
|
||||
"""
|
||||
user = _require_session(request)
|
||||
uid = user["id"]
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT f.*, cp.title as page_title, c.name as collection_name
|
||||
"""SELECT f.*, p.title AS page_title
|
||||
FROM favorites f
|
||||
LEFT JOIN collection_pages cp ON f.page_id=cp.id
|
||||
LEFT JOIN collections c ON f.collection_id=c.id
|
||||
LEFT JOIN pages p ON f.page_id=p.id
|
||||
WHERE f.user_id=? ORDER BY f.position""",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
return {"favorites": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
@router.post("/favorites")
|
||||
def add_favorite(request: Request, body: dict = Body(default={})):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
user = _require_session(request)
|
||||
uid = user["id"]
|
||||
page_id = body.get("page_id")
|
||||
collection_id = body.get("collection_id")
|
||||
if not page_id:
|
||||
raise HTTPException(400, "page_id required")
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO favorites (user_id, page_id, collection_id) VALUES (?,?,?)",
|
||||
(uid, page_id, collection_id),
|
||||
"INSERT OR IGNORE INTO favorites (user_id, page_id) VALUES (?,?)",
|
||||
(uid, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid}))
|
||||
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "user_id": uid}))
|
||||
except Exception:
|
||||
logger.exception("add_favorite")
|
||||
return {"status": "favorited"}
|
||||
|
||||
|
||||
|
||||
@router.delete("/favorites/{fav_id}")
|
||||
def remove_favorite(request: Request, fav_id: int):
|
||||
user = _require_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
|
||||
cur = conn.execute(
|
||||
"DELETE FROM favorites WHERE id=? AND user_id=?", (fav_id, user["id"])
|
||||
)
|
||||
conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(404, "Favorite not found")
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
@@ -714,7 +745,7 @@ def delete_webhook(request: Request, wh_id: int):
|
||||
|
||||
# ── Public Sharing ──
|
||||
|
||||
@router.get("/public/{collection_id}")
|
||||
@public_router.get("/public/{collection_id}")
|
||||
def public_view(request: Request, collection_id: int):
|
||||
"""Simple public read-only view — no auth required.
|
||||
|
||||
|
||||
@@ -278,6 +278,12 @@ def _row_date(values: dict, prop_id: str, prop_name: str) -> str:
|
||||
return str(raw or "")
|
||||
|
||||
|
||||
# Public aliases — read-only helpers reused by the router layer (sidebar
|
||||
# "Meeting" tab needs the date property + value of a collection row).
|
||||
date_prop_id = _date_prop_id
|
||||
row_date = _row_date
|
||||
|
||||
|
||||
def _to_epoch(value: str | None) -> float:
|
||||
if not value:
|
||||
return 0.0
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""FlowDeck — cohérence collections ↔ workspace.
|
||||
|
||||
Une database (`collections`) peut être :
|
||||
|
||||
* **de workspace** — créée via `/db/api`, rattachée par `workspace_id` seul ;
|
||||
* **de page** — page convertie en base (`collections.parent_page_id` +
|
||||
`pages.collection_id`) ou base inline insérée dans une page.
|
||||
|
||||
Ce module centralise les deux sens de la relation :
|
||||
|
||||
* **lecture** — quelles collections sont encore *vivantes* dans un workspace
|
||||
(leur page hôte n'est pas à la corbeille / n'a pas été supprimée) et
|
||||
quelles lignes sont encore vivantes (leur page contenu n'est pas à la
|
||||
corbeille). Sans ce filtre, ``/my-tasks`` continue d'afficher les tâches
|
||||
d'un workspace dont tous les documents ont été supprimés ;
|
||||
* **écriture** — la suppression en cascade d'une collection. L'ordre des
|
||||
écritures est dicté par le schéma SQLite :
|
||||
|
||||
- ``pages.collection_id`` référence ``collections(id)`` en ``NO ACTION`` :
|
||||
la page hôte doit être détachée **avant** la suppression ;
|
||||
- ``collection_data_sources.source_collection_id`` référence également en
|
||||
``NO ACTION`` : les vues liées qui pointent vers la collection doivent
|
||||
être détachées ;
|
||||
- ``collection_pages`` (lignes), ``collection_properties``,
|
||||
``collection_views``, ``collection_permissions`` et les pages contenu
|
||||
(``pages.collection_row_id``) partent en ``CASCADE``.
|
||||
|
||||
Sans ce détachement, ``DELETE FROM collections`` lève un ``IntegrityError``
|
||||
dès qu'une page hôte (ou une vue liée) existe encore.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Lecture ──────────────────────────────────────────────────────────────
|
||||
|
||||
#: SQL du workspace **effectif** d'une collection.
|
||||
#:
|
||||
#: ``collections.workspace_id`` est renseigné pour les databases créées via
|
||||
#: ``/db/api``, mais reste **NULL** pour une base portée par une page (page
|
||||
#: convertie en base, base inline insérée dans un document) : son workspace
|
||||
#: est alors celui de la page hôte. Sans ce repli, la requête ``c.workspace_id
|
||||
#: = ?`` de `live_collection_ids` ne trouve jamais ces bases — et un JOIN
|
||||
#: ``workspaces`` les exclut définitivement.
|
||||
EFFECTIVE_WORKSPACE_SQL = (
|
||||
"COALESCE({a}.workspace_id,"
|
||||
" (SELECT p.workspace_id FROM pages p WHERE p.id = {a}.parent_page_id))"
|
||||
)
|
||||
|
||||
|
||||
def effective_workspace_sql(alias: str = "c") -> str:
|
||||
"""Workspace effectif d'une collection (colonne ou ``NULL`` si orpheline)."""
|
||||
return EFFECTIVE_WORKSPACE_SQL.format(a=alias)
|
||||
|
||||
|
||||
def user_workspace_ids(conn, user_id: int) -> list[int]:
|
||||
"""IDs des workspaces accessibles à l'utilisateur.
|
||||
|
||||
Propriétaire **ou** membre : c'est le même périmètre que la recherche
|
||||
globale (`app/services/search._scope_where`), à la différence près qu'on
|
||||
n'inclut pas les éléments sans workspace — une base non rattachable
|
||||
n'appartient à personne et ne doit donc apparaître chez personne.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id = ?"
|
||||
" UNION SELECT workspace_id FROM workspace_members WHERE user_id = ?",
|
||||
(user_id, user_id),
|
||||
).fetchall()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def live_collection_ids(conn, workspace_id: int) -> list[int]:
|
||||
"""IDs des collections rattachées au workspace qui sont encore vivantes.
|
||||
|
||||
Une collection est exclue dès que sa page hôte a disparu :
|
||||
|
||||
* ``parent_page_id`` renseigné mais page absente ou à la corbeille ;
|
||||
* une page ``content_format='collection'`` qui pointe encore vers elle
|
||||
mais est à la corbeille.
|
||||
|
||||
Les collections sans page hôte (databases de workspace créées via
|
||||
``/db/api``) restent toujours visibles.
|
||||
|
||||
Le rattachement passe par le workspace **effectif** : une base créée depuis
|
||||
un document a ``workspace_id`` NULL et n'est repérée que via sa page hôte.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT c.id
|
||||
FROM collections c
|
||||
WHERE {effective_workspace_sql("c")} = ?
|
||||
AND (c.parent_page_id IS NULL
|
||||
OR EXISTS (SELECT 1 FROM pages p
|
||||
WHERE p.id = c.parent_page_id AND p.deleted_at IS NULL))
|
||||
AND NOT EXISTS (SELECT 1 FROM pages p
|
||||
WHERE p.collection_id = c.id AND p.deleted_at IS NOT NULL)
|
||||
ORDER BY c.name
|
||||
""",
|
||||
(workspace_id,),
|
||||
).fetchall()
|
||||
return [r["id"] for r in rows]
|
||||
|
||||
|
||||
def live_row_ids(conn, collection_id: int) -> list[int]:
|
||||
"""IDs des lignes racines d'une collection dont la page contenu vit.
|
||||
|
||||
La page contenu d'une ligne est créée paresseusement (``ensure_row_page``)
|
||||
: son absence ne dit rien, mais son passage à la corbeille signifie que
|
||||
l'utilisateur a supprimé le document → la ligne sort de My Tasks.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT cp.id
|
||||
FROM collection_pages cp
|
||||
WHERE cp.collection_id = ?
|
||||
AND cp.parent_id IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM pages p
|
||||
WHERE p.collection_row_id = cp.id AND p.deleted_at IS NOT NULL)
|
||||
ORDER BY cp.position
|
||||
""",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return [r["id"] for r in rows]
|
||||
|
||||
|
||||
def live_document_count(conn, workspace_id: int) -> int:
|
||||
"""Nombre de documents visibles du workspace (mêmes règles que l'arbre
|
||||
de la sidebar : racines, non supprimés, hors pages contenu de ligne)."""
|
||||
return conn.execute(
|
||||
"SELECT COUNT(*) FROM pages "
|
||||
"WHERE workspace_id = ? AND parent_id IS NULL "
|
||||
"AND deleted_at IS NULL AND collection_row_id IS NULL",
|
||||
(workspace_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
|
||||
def collections_hosted_by_pages(conn, page_ids) -> list[int]:
|
||||
"""IDs des collections dont l'une de ces pages est la page hôte.
|
||||
|
||||
Les deux relations sont couvertes : ``collections.parent_page_id`` (page
|
||||
convertie en base, base inline) et ``pages.collection_id`` (page hôte).
|
||||
Les pages contenu de ligne (``collection_row_id`` renseigné) n'hébergent
|
||||
aucune collection et ne remontent donc rien.
|
||||
|
||||
À appeler **avant** de supprimer les pages si l'on veut aussi capturer la
|
||||
relation ``pages.collection_id``.
|
||||
"""
|
||||
ids = [int(p) for p in page_ids if p is not None]
|
||||
if not ids:
|
||||
return []
|
||||
qs = ",".join("?" * len(ids))
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT id FROM collections WHERE parent_page_id IN ({qs})
|
||||
UNION
|
||||
SELECT collection_id FROM pages
|
||||
WHERE id IN ({qs}) AND collection_id IS NOT NULL
|
||||
""",
|
||||
[*ids, *ids],
|
||||
).fetchall()
|
||||
return [r["id"] for r in rows]
|
||||
|
||||
|
||||
def collections_hosted_by_page(conn, page_id: int) -> list[int]:
|
||||
"""``collections_hosted_by_pages`` pour une seule page."""
|
||||
return collections_hosted_by_pages(conn, [page_id])
|
||||
|
||||
|
||||
def workspace_collection_ids(conn, workspace_id: int) -> list[int]:
|
||||
"""IDs de **toutes** les collections du workspace (sans filtre de
|
||||
vivacité) — utilisé lors de la suppression du workspace."""
|
||||
rows = conn.execute(
|
||||
"SELECT id FROM collections WHERE workspace_id = ?", (workspace_id,)
|
||||
).fetchall()
|
||||
return [r["id"] for r in rows]
|
||||
|
||||
|
||||
# ── Écriture ─────────────────────────────────────────────────────────────
|
||||
|
||||
def delete_collections(conn, collection_ids) -> int:
|
||||
"""Supprime définitivement des collections et tout ce qu'elles portent.
|
||||
|
||||
Détache d'abord les références ``NO ACTION`` (pages hôtes, sources des
|
||||
vues liées) puis supprime : le reste part en ``CASCADE``.
|
||||
|
||||
Ne fait **pas** de ``commit`` — l'appelant garde la main sur la
|
||||
transaction. Retourne le nombre de collections supprimées.
|
||||
"""
|
||||
ids = [int(i) for i in collection_ids if i is not None]
|
||||
if not ids:
|
||||
return 0
|
||||
qs = ",".join("?" * len(ids))
|
||||
|
||||
# 1. Pages hôtes : `pages.collection_id` est en NO ACTION. Une page dont le
|
||||
# contenu EST la base redevient une page blocs normale.
|
||||
conn.execute(
|
||||
f"""UPDATE pages
|
||||
SET collection_id = NULL,
|
||||
content_format = CASE WHEN content_format = 'collection'
|
||||
THEN 'blocks' ELSE content_format END
|
||||
WHERE collection_id IN ({qs})""",
|
||||
ids,
|
||||
)
|
||||
# 2. Vues liées : `source_collection_id` est en NO ACTION également. La vue
|
||||
# liée perd sa source (elle n'a plus rien à afficher) mais survit.
|
||||
conn.execute(
|
||||
f"DELETE FROM collection_data_sources WHERE source_collection_id IN ({qs})",
|
||||
ids,
|
||||
)
|
||||
# 3. Suppression : lignes, propriétés, vues, permissions et pages contenu
|
||||
# suivent en CASCADE.
|
||||
cur = conn.execute(f"DELETE FROM collections WHERE id IN ({qs})", ids)
|
||||
return cur.rowcount or 0
|
||||
|
||||
|
||||
def delete_collections_hosted_by_pages(conn, page_ids) -> int:
|
||||
"""Supprime les databases portées par des pages définitivement supprimées
|
||||
(corbeille purgée, suppression définitive, workspace effacé)."""
|
||||
return delete_collections(conn, collections_hosted_by_pages(conn, page_ids))
|
||||
@@ -109,6 +109,201 @@ GALLERY: dict[str, dict] = {
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
|
||||
# ── Vague 1 : skills 100 % locaux (aucun nouveau tool requis) ──────────
|
||||
|
||||
"veille-documentaire": {
|
||||
"name": "Veille documentaire",
|
||||
"icon": "🔎",
|
||||
"description": "Repère les pages du workspace sans mise à jour récente et génère un digest d'entrée.",
|
||||
"prompt_template": (
|
||||
"Fais une veille documentaire du workspace :\n"
|
||||
"1. Repère les documents dont le titre évoque une décision, un chantier ou un arbitrage "
|
||||
"(search_workspace : « décision », « ADR », « roadmap », « spec », « bilan »).\n"
|
||||
"2. Lis les plus pertinents (read_document) et note pour chacun : date de mise à jour, "
|
||||
"statut (vivant / obsolète / contradictoire), propriétaire mentionné quand il existe.\n"
|
||||
"3. Crée un document « Veille — <date> » (create_document) en 3 sections : "
|
||||
"À valider (décisions non actées), À mettre à jour (docs dépassés par d'autres), "
|
||||
"Cohérences (points de vue contradictoires entre documents).\n"
|
||||
"4. Ne modifie aucun document : tu proposes, l'humain décide."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
"chasse-decisions": {
|
||||
"name": "Chasse aux décisions",
|
||||
"icon": "⚖️",
|
||||
"description": " Rassemble en une page toutes les décisions prises dans le workspace, avec leur statut.",
|
||||
"prompt_template": (
|
||||
"Construis le registre des décisions du workspace :\n"
|
||||
"1. Cherche les documents de décision (search_workspace : « décision », « arbitrage », "
|
||||
"« choix de », « remplacement de »).\n"
|
||||
"2. Lis-les (read_document) et extrais pour chacune : la décision, la date, la motivation, "
|
||||
"les options écartées.\n"
|
||||
"3. Crée un document « Registre des décisions » (create_document) en tableau : "
|
||||
"Décision · Date · Motivation · Statut (actif / révoqué / jamais tranché).\n"
|
||||
"4. Signale explicitement les sujets récurrents où aucune décision n'a été actée, "
|
||||
"plutôt que de combler les trous."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
"onboarding-nouveau": {
|
||||
"name": "Onboarding nouveau",
|
||||
"icon": "🧭",
|
||||
"description": "Génère un parcours de lecture ordonné pour une personne qui rejoint l'équipe.",
|
||||
"prompt_template": (
|
||||
"Prépare l'onboarding d'une nouvelle personne :\n"
|
||||
"1. Repère les documents d'entrée : onboarding existant, README, architecture, process, "
|
||||
"glossaire (search_workspace).\n"
|
||||
"2. Lis-les (read_document) et classe-les par jour d'arrivée : Jour 1, Semaine 1, "
|
||||
"Puis plus tard.\n"
|
||||
"3. Crée un document « Onboarding — <prénom> » (create_document) : pour chaque étape, "
|
||||
"le document à lire, ce qu'on doit en retenir, et 2 questions pour vérifier la compréhension.\n"
|
||||
"4. Termine par les 5 questions qu'une personne doit savoir répondre à la fin de la semaine 1."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
"audit-base": {
|
||||
"name": "Audit de base",
|
||||
"icon": "🧹",
|
||||
"description": "Audite une collection : colonnes vides, doublons, lignes sans propriétaire, propriétés inutilisées.",
|
||||
"prompt_template": (
|
||||
"Audite la collection demandée :\n"
|
||||
"1. Lis son schéma et ses lignes (read_collection).\n"
|
||||
"2. Détecte : propriétés jamais remplies, lignes sans propriétaire, doublons de titre, "
|
||||
"valeurs incohérentes dans une même colonne, colonnes ajoutées puis abandonnées.\n"
|
||||
"3. Crée un document « Audit — <collection> » (create_document) avec, par anomalie : "
|
||||
"l'ampleur (nombre de lignes), des exemples concrets, et la correction proposée.\n"
|
||||
"4. N'applique AUCUNE correction : liste d'abord, l'humain valide ensuite."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_collection", "read_page", "create_document", "write_blocks"],
|
||||
},
|
||||
"sprint-review": {
|
||||
"name": "Revue de sprint",
|
||||
"icon": "🏁",
|
||||
"description": "Construit une revue de sprint depuis les issues Gitea : accompli, en retard, risques.",
|
||||
"prompt_template": (
|
||||
"Prépare la revue de sprint à partir des issues Gitea :\n"
|
||||
"1. Lis les issues du dépôt (read_gitea_issues, state=all) et resynchronise si nécessaire (sync_gitea).\n"
|
||||
"2. Classe-les : accompli (fermées), en retard (ouvertes et dépassées), à trier (sans label).\n"
|
||||
"3. Croise avec les documents du workspace (search_workspace) pour voir si des décisions "
|
||||
"changent la priorisation.\n"
|
||||
"4. Crée un document « Revue de sprint — <période> » (create_document) : Accompli · En retard · "
|
||||
"Risques · Prochaines priorités, avec le numéro d'issue pour chaque affirmation.\n"
|
||||
"5. Ne ferme ni ne modifie aucune issue."
|
||||
),
|
||||
"allowed_tools": ["read_gitea_issues", "sync_gitea", "search_workspace",
|
||||
"create_document", "write_blocks"],
|
||||
},
|
||||
|
||||
# ── Vague 2 : skills web (web_search / fetch_url / search_code) ────────
|
||||
|
||||
"recherche-marche": {
|
||||
"name": "Recherche marché",
|
||||
"icon": "🌍",
|
||||
"description": "Étudie un sujet de marché sur le web et produit une note sourcée avec alternatives.",
|
||||
"prompt_template": (
|
||||
"Produis une étude de marché sourcée sur « <sujet> » :\n"
|
||||
"1. Cherche 3 requêtes distinctes (web_search) : le sujet lui-même, « <sujet> alternatives », "
|
||||
"« <sujet> prix tarifs 2026 ».\n"
|
||||
"2. Approfondis les 3 pages les plus prometteuses (fetch_url).\n"
|
||||
"3. Crée un document « Marché — <sujet> » (create_document) : acteurs et positionnement, "
|
||||
"grille tarifaire, forces/faiblesses, et 3 recommandations.\n"
|
||||
"4. OBLIGATION : chaque affirmation porte sa source au format [titre — URL]. "
|
||||
"Si une information n'est pas sourcée, écris « non vérifié » plutôt que d'inventer.\n"
|
||||
"5. Indique la date de la recherche : ces données périment vite."
|
||||
),
|
||||
"allowed_tools": ["web_search", "fetch_url", "create_document", "write_blocks"],
|
||||
},
|
||||
"veille-techno": {
|
||||
"name": "Veille technologique",
|
||||
"icon": "🛰️",
|
||||
"description": "Surveille l'actualité d'une techno : versions, ruptures, dépendances et advisories.",
|
||||
"prompt_template": (
|
||||
"Fais une veille technologique sur « <techno> » :\n"
|
||||
"1. Web : dernières annonces et sorties de version (web_search).\n"
|
||||
"2. GitHub : dépôts de référence et issues récentes résolues (search_code, kind=issues).\n"
|
||||
"3. Approfondis les annonces importantes (fetch_url).\n"
|
||||
"4. Crée un document « Veille — <techno> — <date> » (create_document) : "
|
||||
"Nouveautés depuis le dernier point · Risques de rupture · Actions recommandées (mettre à jour / "
|
||||
"surveiller / ignorer).\n"
|
||||
"5. Une ligne par fait, avec [titre — URL]. Ne présente jamais une rumeur comme une release."
|
||||
),
|
||||
"allowed_tools": ["web_search", "fetch_url", "search_code",
|
||||
"create_document", "write_blocks"],
|
||||
},
|
||||
"debug-web": {
|
||||
"name": "Debug web",
|
||||
"icon": "🐛",
|
||||
"description": "Diagnostique un bug technique en croisant les docs, le code GitHub et les discussions.",
|
||||
"prompt_template": (
|
||||
"Diagnostique le problème suivant : <erreur / symptôme>.\n"
|
||||
"1. Reformule le symptôme en une recherche web précise (web_search) : message d'erreur exact "
|
||||
"+ bibliothèque + version.\n"
|
||||
"2. Cherche si c'est un bug connu (search_code, kind=issues) et lis les discussions pertinentes (fetch_url).\n"
|
||||
"3. Crée un document « Debug — <symptôme> » (create_document) : Cause probable (classée par "
|
||||
"probabilité) · Vérifications à faire dans cet ordre · Correctif proposé.\n"
|
||||
"4. Distingue clairement ce qui est vérifié par une source de ce qui est ton hypothèse."
|
||||
),
|
||||
"allowed_tools": ["web_search", "search_code", "fetch_url",
|
||||
"create_document", "write_blocks"],
|
||||
},
|
||||
|
||||
# ── Vague 3 : skills « pouvoir » ──────────────────────────────────────
|
||||
|
||||
"triage-incident": {
|
||||
"name": "Triage d'incident",
|
||||
"icon": "🚨",
|
||||
"description": "Prend un incident décrit, ouvre un runbook pré-rempli et pose les premières questions de qualification.",
|
||||
"prompt_template": (
|
||||
"Traite l'incident suivant : <description>.\n"
|
||||
"1. Qualifie avant de chercher : quel service, depuis quand, quel périmètre d'impact, "
|
||||
"erreur observable. Les inconnues deviennent des questions, pas des suppositions.\n"
|
||||
"2. Cherche les incidents similaires déjà résolus dans le workspace (search_workspace) "
|
||||
"et les runbooks existants.\n"
|
||||
"3. Crée un document « Incident — <titre court> » (create_document) : Symptôme · Impact · "
|
||||
"Cause probable · Hypothèses classées · Étapes de résolution · Questions à poser à l'utilisateur.\n"
|
||||
"4. Ne modifie aucun code ni aucune configuration : tu produis un runbook, pas un correctif.\n"
|
||||
"5. Termine par les 3 informations qui feraient le plus progresser le diagnostic."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "web_search",
|
||||
"create_document", "write_blocks"],
|
||||
},
|
||||
"nettoyage-base": {
|
||||
"name": "Nettoyage de base",
|
||||
"icon": "🧽",
|
||||
"description": "Prépare un plan de nettoyage d'une collection (doublons, valeurs vides) avec aperçu avant écriture.",
|
||||
"prompt_template": (
|
||||
"Prépare le nettoyage de la collection « <collection> » :\n"
|
||||
"1. Lis son schéma et ses lignes (read_collection).\n"
|
||||
"2. Identifie les corrections : lignes vides à supprimer, doublons à fusionner, "
|
||||
"valeurs parasites à normaliser, propriétés obsolètes.\n"
|
||||
"3. PRODUIRE UN APERÇU D'AVANT : pour chaque catégorie de correction, le nombre de lignes "
|
||||
"concernées et 3 exemples concrets.\n"
|
||||
"4. Règle absolue : n'applique AUCUNE modification avant validation explicite de l'utilisateur. "
|
||||
"Le plan est le livrable.\n"
|
||||
"5. Crée le document « Plan de nettoyage — <collection> » (create_document) avec le plan, "
|
||||
"puis demande la validation."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_collection", "read_page",
|
||||
"create_document", "write_blocks"],
|
||||
},
|
||||
"briefing-quotidien": {
|
||||
"name": "Briefing quotidien",
|
||||
"icon": "🌅",
|
||||
"description": "Prépare le point du matin : activités de la veille, décisions en attente, blocages.",
|
||||
"prompt_template": (
|
||||
"Prépare le briefing du jour :\n"
|
||||
"1. Activités : ce qui a bougé dans le workspace et le dépôt (read_gitea_issues, "
|
||||
"search_workspace) — uniquement des faits datés.\n"
|
||||
"2. Décisions en attente : documents de décision sans statut clair.\n"
|
||||
"3. Blocages : tickets ouverts sans mouvement, sujets qui reviennent dans plusieurs documents.\n"
|
||||
"4. Crée un document « Briefing — <date> » (create_document) en moins d'une page : "
|
||||
"Hier en bref · À trancher aujourd'hui · Bloquants · Une chose à surveiller.\n"
|
||||
"5. Si une section n'a aucun élément, écris « rien à signaler » plutôt que d'inventer."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "read_gitea_issues",
|
||||
"create_document", "write_blocks"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import logging
|
||||
import time
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.collection_lifecycle import delete_collections
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -381,7 +382,7 @@ class SyncEngine:
|
||||
def _mut_collection_delete(self, user_id: int, payload: dict) -> dict:
|
||||
cid = payload.get("collection_id")
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
|
||||
delete_collections(conn, [cid])
|
||||
conn.commit()
|
||||
return {"collection_id": cid, "server_version": None}
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
"""FlowDeck — My Tasks : sources de tâches et normalisation.
|
||||
|
||||
Une base ne diffuse ses tâches dans My Tasks qu'après **conversion
|
||||
explicite** (``collections.is_task``) et le mapping explicite de ses trois
|
||||
propriétés requises :
|
||||
|
||||
=========================== ==========================================
|
||||
Rôle Colonne sur ``collections``
|
||||
=========================== ==========================================
|
||||
``assignee`` (Assigné à) ``task_assignee_prop``
|
||||
``status`` (Statut) ``task_status_prop``
|
||||
``due`` (Échéance) ``task_due_prop``
|
||||
=========================== ==========================================
|
||||
|
||||
Ce module est la lecture unique de ce mapping : My Tasks, les filtres, les
|
||||
vues Kanban/calendrier et l'ajout rapide s'appuient tous sur
|
||||
``collect_tasks`` — donc une même tâche ne peut pas être rendue
|
||||
différemment selon l'appelant.
|
||||
|
||||
Périmètre : **tous les workspaces de l'utilisateur** (mode Notion *Home*),
|
||||
pas seulement le workspace actif — le tableau de bord est un poste de
|
||||
travail transverse.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import unicodedata
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
from app.services.collection_lifecycle import (
|
||||
effective_workspace_sql,
|
||||
live_collection_ids,
|
||||
live_row_ids,
|
||||
user_workspace_ids,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Notion refuse au-delà de 10 bases connectées au widget My Tasks.
|
||||
MAX_TASK_SOURCES = 10
|
||||
|
||||
#: Rôles requis, dans l'ordre d'affichage de la modale de conversion.
|
||||
TASK_ROLES = ("assignee", "status", "due")
|
||||
|
||||
#: Types acceptés pour chaque rôle (une base « bien formée » est refusée
|
||||
#: sinon : une colonne Date n'a pas de sens comme Assigné à).
|
||||
ROLE_TYPES = {
|
||||
"assignee": ("person",),
|
||||
"status": ("status", "select"),
|
||||
"due": ("date",),
|
||||
}
|
||||
|
||||
ROLE_LABELS = {
|
||||
"assignee": "Assigné à",
|
||||
"status": "Statut",
|
||||
"due": "Date d'échéance",
|
||||
}
|
||||
|
||||
#: Premier mot d'un statut valant « terminé », une fois normalisé (minuscules,
|
||||
#: sans accents). On compare des formes normalisées et non des libellés exacts :
|
||||
#: « Terminée », « Terminé », « Complete » ou « Closed » doivent tous être
|
||||
#: reconnus, quelle que soit la casse et l'accentuation.
|
||||
#:
|
||||
#: On matche le **premier mot** et non un simple préfixe : « Clôture »
|
||||
#: commence comme « clos » mais n'est pas un statut terminé.
|
||||
DONE_WORDS = frozenset({
|
||||
# anglais
|
||||
"done", "complete", "completed", "closed", "closed?", "terminated", "finished",
|
||||
# français (masculin / féminin, singulier / pluriel)
|
||||
"clos", "close", "termine", "terminee", "termines", "terminees",
|
||||
"annule", "annulee", "annules", "annulees", "cancelled", "canceled",
|
||||
"resolu", "resolue", "resolus", "resolues", "acheve", "achevee",
|
||||
})
|
||||
|
||||
|
||||
def _normalize(value) -> str:
|
||||
"""Minuscules, accents retirés, espaces compactés."""
|
||||
text = unicodedata.normalize("NFKD", str(value or ""))
|
||||
text = "".join(c for c in text if not unicodedata.combining(c))
|
||||
return " ".join(text.lower().split())
|
||||
|
||||
|
||||
def is_done(status: str) -> bool:
|
||||
"""Le statut vaut-il « terminé » ?
|
||||
|
||||
Le premier mot suffit : « Terminée », « terminated », « Complete! » et
|
||||
« done (closed) » sont reconnus ; « Clôture » ne l'est pas.
|
||||
"""
|
||||
norm = _normalize(status)
|
||||
if not norm:
|
||||
return False
|
||||
first = norm.split()[0] if norm.split() else ""
|
||||
return first.strip("!?.,:;()") in DONE_WORDS
|
||||
|
||||
|
||||
def user_today(conn, user_id: int) -> date:
|
||||
"""« Aujourd'hui » dans le fuseau de l'utilisateur.
|
||||
|
||||
Le projet stocke déjà ce fuseau (`users.timezone`, cf. migration v5.8.0) et
|
||||
l'utilise déjà pour le calendrier des bases. My Tasks s'en sert aussi :
|
||||
sans cela, une tâche due « aujourd'hui » bascule dans « demain » (ou
|
||||
l'inverse) dès que l'utilisateur est loin d'UTC.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT timezone FROM users WHERE id=?", (user_id,)
|
||||
).fetchone()
|
||||
tz_name = (row["timezone"] if row and "timezone" in row.keys() else "") or ""
|
||||
if tz_name:
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
return datetime.now(ZoneInfo(tz_name)).date()
|
||||
except Exception: # fuseau inconnu / base tz absente → UTC
|
||||
logger.debug("timezone « %s » illisible, repli sur UTC", tz_name)
|
||||
return datetime.now(UTC).date()
|
||||
|
||||
|
||||
def task_source_columns() -> str:
|
||||
"""Colonnes de ``collections`` lues par les requêtes de sources.
|
||||
|
||||
Les 3 colonnes de mapping n'existent que depuis la migration 30 : sur une
|
||||
base antérieure elles vaudront NULL de toute façon.
|
||||
|
||||
Préfixées par ``c.`` car `list_task_sources` joint ``pages`` : sans
|
||||
qualification, ``id`` est ambigu.
|
||||
"""
|
||||
return ("c.id, c.name, c.icon, c.workspace_id, c.is_task, "
|
||||
"c.task_assignee_prop, c.task_status_prop, c.task_due_prop")
|
||||
|
||||
|
||||
def list_task_sources(conn, user_id: int) -> list[dict]:
|
||||
"""Bases de tâches configurées par l'utilisateur, tous workspaces
|
||||
confondus. Renvoie aussi ``configured`` (les 3 rôles mappés) et
|
||||
``missing_roles`` pour piloter l'invite de conversion.
|
||||
|
||||
Deux exclusions :
|
||||
|
||||
* une base dont la page hôte est à la corbeille (ou supprimée) est
|
||||
**retirée** : sans ce filtre, une base portée par un document supprimé
|
||||
continuerait d'alimenter My Tasks — le bug d'origine ;
|
||||
* une base dont le workspace n'est pas accessible à l'utilisateur n'est
|
||||
pas listée (une base sans workspace rattachable n'appartient à personne).
|
||||
|
||||
Le workspace retenu est l'**effectif** (`collections.workspace_id`, sinon
|
||||
celui de la page hôte) : une base créée depuis un document a
|
||||
``workspace_id`` NULL, et un ``JOIN workspaces`` la faisait disparaître de
|
||||
My Tasks alors qu'elle venait d'être convertie.
|
||||
"""
|
||||
ws_ids = user_workspace_ids(conn, user_id)
|
||||
if not ws_ids:
|
||||
return []
|
||||
qs = ",".join("?" * len(ws_ids))
|
||||
ws_sql = effective_workspace_sql("c")
|
||||
rows = conn.execute(
|
||||
f"""SELECT {task_source_columns()}, {ws_sql} AS ws_effective,
|
||||
(SELECT w.name FROM workspaces w WHERE w.id = {ws_sql})
|
||||
AS workspace_name
|
||||
FROM collections c
|
||||
WHERE c.is_task = 1 AND {ws_sql} IN ({qs})
|
||||
ORDER BY c.name""",
|
||||
[*ws_ids],
|
||||
).fetchall()
|
||||
|
||||
# Un workspace peut apparaître plusieurs fois : on ne recalcule le
|
||||
# jeu de bases vivantes qu'une fois par workspace.
|
||||
live_by_ws: dict[int, set[int]] = {}
|
||||
sources = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
ws = d.get("ws_effective")
|
||||
if ws not in live_by_ws:
|
||||
live_by_ws[ws] = set(live_collection_ids(conn, ws))
|
||||
if d["id"] not in live_by_ws[ws]:
|
||||
logger.debug("base %s exclue : sa page hôte n'est plus vivante", d["id"])
|
||||
continue
|
||||
|
||||
missing = [role for role in TASK_ROLES if not d.get(f"task_{role}_prop")]
|
||||
d["configured"] = not missing
|
||||
d["missing_roles"] = missing
|
||||
d["status_options"] = _status_options(conn, d.get("task_status_prop"))
|
||||
sources.append(d)
|
||||
return sources
|
||||
|
||||
|
||||
def _status_options(conn, prop_id) -> list[str]:
|
||||
"""Libellés du statut de la base source (colonnes du Kanban)."""
|
||||
if not prop_id:
|
||||
return []
|
||||
row = conn.execute(
|
||||
"SELECT options_json FROM collection_properties WHERE id=?", (prop_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return []
|
||||
try:
|
||||
opts = json.loads(row["options_json"] or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
return [o if isinstance(o, str) else (o or {}).get("name", "") for o in opts
|
||||
if isinstance(o, str) or isinstance(o, dict)]
|
||||
|
||||
|
||||
def source_state(conn, collection_id: int) -> dict:
|
||||
"""État de conversion d'une base pour la modale « convertir en base de
|
||||
tâches » : mapping courant + colonnes candidates pour chaque rôle."""
|
||||
coll = conn.execute(
|
||||
f"SELECT {task_source_columns()} FROM collections c WHERE c.id=?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
if coll is None:
|
||||
return {"exists": False}
|
||||
|
||||
d = dict(coll)
|
||||
props = conn.execute(
|
||||
"SELECT id, name, prop_type, options_json, position FROM collection_properties "
|
||||
"WHERE collection_id=? ORDER BY position, id",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
missing = [role for role in TASK_ROLES if not d.get(f"task_{role}_prop")]
|
||||
used = {d.get(f"task_{role}_prop") for role in TASK_ROLES if d.get(f"task_{role}_prop")}
|
||||
|
||||
return {
|
||||
"exists": True,
|
||||
"collection_id": collection_id,
|
||||
"name": d.get("name"),
|
||||
"is_task": bool(d.get("is_task")),
|
||||
"mapping": {role: d.get(f"task_{role}_prop") for role in TASK_ROLES},
|
||||
"missing_roles": missing,
|
||||
"configured": not missing,
|
||||
# Par rôle : les colonnes compatibles (une colonne déjà affectée à un
|
||||
# autre rôle est exclue — Notion n'autorise pas le double emploi).
|
||||
"candidates": {
|
||||
role: [
|
||||
{"id": p["id"], "name": p["name"], "prop_type": p["prop_type"]}
|
||||
for p in props
|
||||
if p["prop_type"] in ROLE_TYPES[role] and p["id"] not in used
|
||||
]
|
||||
for role in TASK_ROLES
|
||||
},
|
||||
"all_properties": [
|
||||
{"id": p["id"], "name": p["name"], "prop_type": p["prop_type"]}
|
||||
for p in props
|
||||
],
|
||||
"suggested_names": {role: ROLE_LABELS[role] for role in TASK_ROLES},
|
||||
"max_sources": MAX_TASK_SOURCES,
|
||||
"sources_count": _sources_count(conn, d.get("workspace_id"), collection_id),
|
||||
}
|
||||
|
||||
|
||||
def _sources_count(conn, workspace_id, exclude_id=None) -> int:
|
||||
"""Nombre de bases déjà connectées (pour la limite de 10)."""
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM collections WHERE is_task = 1 AND workspace_id = ?",
|
||||
(workspace_id,),
|
||||
).fetchone()
|
||||
n = row[0] if row else 0
|
||||
if exclude_id is not None:
|
||||
row = conn.execute(
|
||||
"SELECT is_task FROM collections WHERE id=?", (exclude_id,)
|
||||
).fetchone()
|
||||
if row and row[0]:
|
||||
n -= 1
|
||||
return max(n, 0)
|
||||
|
||||
|
||||
def _row_value(raw: dict, prop_id):
|
||||
"""Valeur d'une propriété de ligne, indexée par ID (ou par nom, pour les
|
||||
lignes historicisées écrites avant le passage aux clés indexées)."""
|
||||
if prop_id is None:
|
||||
return None
|
||||
if prop_id in raw:
|
||||
return raw[prop_id]
|
||||
return raw.get(str(prop_id))
|
||||
|
||||
|
||||
def _person_values(value) -> list:
|
||||
"""Aplatit une valeur `person` en liste d'éléments (dicts `person` ou
|
||||
chaînes), sans perdre l'id — c'est lui qui identifie l'assigné."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, dict):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
out: list = []
|
||||
for v in value:
|
||||
out.extend(_person_values(v))
|
||||
return out
|
||||
if isinstance(value, str) and value.strip():
|
||||
return [value]
|
||||
return []
|
||||
|
||||
|
||||
def _person_label(v) -> str:
|
||||
if isinstance(v, dict):
|
||||
return str(v.get("login") or v.get("full_name") or "")
|
||||
return str(v)
|
||||
|
||||
|
||||
def _assigned_to_me(values: list, user_id: int, login: str) -> bool:
|
||||
"""L'assigné est-il l'utilisateur courant ?
|
||||
|
||||
Une valeur `person` porte `{id, login, full_name}` : on compare l'id en
|
||||
priorité (stable), puis le login en repli pour les lignes historicisées
|
||||
qui ne stockent qu'un nom.
|
||||
"""
|
||||
if not login:
|
||||
return False
|
||||
for v in values:
|
||||
if isinstance(v, dict):
|
||||
if v.get("id") is not None and str(v["id"]) == str(user_id):
|
||||
return True
|
||||
if _person_label(v).strip().lower() == login.strip().lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def collect_tasks(conn, user_id: int, *, mine_only: bool = True,
|
||||
login: str = "") -> list[dict]:
|
||||
"""Tâches normalisées de toutes les bases connectées.
|
||||
|
||||
Chaque entrée porte sa source (``collection_id``/``collection_name``) et
|
||||
les identifiants des propriétés à écrire pour une édition en direct
|
||||
(``status_prop``/``due_prop``/``assignee_prop``) : le front n'a plus à
|
||||
deviner quoi écrire.
|
||||
"""
|
||||
tasks: list[dict] = []
|
||||
today = user_today(conn, user_id)
|
||||
|
||||
for src in list_task_sources(conn, user_id):
|
||||
if not src["configured"]:
|
||||
# Base connectée mais mapping incomplet : elle n'alimente pas
|
||||
# My Tasks (l'UI le signale), plutôt que d'inventer des colonnes.
|
||||
continue
|
||||
status_prop = src["task_status_prop"]
|
||||
due_prop = src["task_due_prop"]
|
||||
assignee_prop = src["task_assignee_prop"]
|
||||
|
||||
for row_id in live_row_ids(conn, src["id"]):
|
||||
row = conn.execute(
|
||||
"SELECT id, title, icon, created_at, property_values_json "
|
||||
"FROM collection_pages WHERE id=?",
|
||||
(row_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
continue
|
||||
try:
|
||||
raw = json.loads(row["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raw = {}
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
|
||||
persons = _person_values(_row_value(raw, assignee_prop))
|
||||
assignees = [_person_label(p) for p in persons if _person_label(p)]
|
||||
if mine_only and persons and not _assigned_to_me(persons, user_id, login):
|
||||
continue
|
||||
|
||||
status = _row_value(raw, status_prop)
|
||||
if isinstance(status, list):
|
||||
status = status[0] if status else ""
|
||||
status = str(status or "")
|
||||
|
||||
due_raw = _row_value(raw, due_prop)
|
||||
due = str(due_raw or "")[:10]
|
||||
|
||||
due_state = ""
|
||||
if due:
|
||||
try:
|
||||
d = date.fromisoformat(due)
|
||||
except ValueError:
|
||||
d = None
|
||||
if d:
|
||||
due_state = ("overdue" if d < today
|
||||
else "today" if d == today
|
||||
else "upcoming")
|
||||
|
||||
tasks.append({
|
||||
"id": row["id"],
|
||||
"title": row["title"] or "Untitled",
|
||||
"icon": row["icon"] or "📄",
|
||||
"created_at": row["created_at"],
|
||||
"collection_id": src["id"],
|
||||
"collection_name": src["name"],
|
||||
"collection_icon": src["icon"] or "📋",
|
||||
"workspace_id": src["workspace_id"],
|
||||
"workspace_name": src["workspace_name"],
|
||||
"assignees": assignees,
|
||||
"status": status,
|
||||
"status_options": src["status_options"],
|
||||
"done": is_done(status),
|
||||
"due": due,
|
||||
"due_state": due_state,
|
||||
"status_prop": status_prop,
|
||||
"due_prop": due_prop,
|
||||
"assignee_prop": assignee_prop,
|
||||
})
|
||||
|
||||
return tasks
|
||||
|
||||
|
||||
def filter_and_sort(tasks: list[dict], *, due: str = "all", hide_done: bool = False,
|
||||
sort: str = "due", today: date | None = None) -> list[dict]:
|
||||
"""Filtres et tri globaux de My Tasks (portés par l'URL).
|
||||
|
||||
``today`` est le jour de référence de l'utilisateur (fuseau compris) : il
|
||||
doit être celui utilisé par `collect_tasks`, sinon « aujourd'hui » et
|
||||
« cette semaine » ne cibleront pas le même jour que `due_state`.
|
||||
"""
|
||||
out = tasks
|
||||
if hide_done:
|
||||
out = [t for t in out if not t["done"]]
|
||||
if due and due != "all":
|
||||
if due == "today":
|
||||
out = [t for t in out if t["due_state"] == "today"]
|
||||
elif due == "overdue":
|
||||
out = [t for t in out if t["due_state"] in ("today", "overdue")]
|
||||
elif due == "week":
|
||||
# « Cette semaine » est **borné** à J+7 : sans borne, une tâche
|
||||
# datée dans six mois apparaîtrait sous « Cette semaine ».
|
||||
ref = today or datetime.now(UTC).date()
|
||||
limit = (ref + timedelta(days=7)).isoformat()
|
||||
out = [t for t in out
|
||||
if t["due"] and (t["due_state"] in ("today", "overdue")
|
||||
or t["due"] <= limit)]
|
||||
|
||||
if sort == "status":
|
||||
return sorted(out, key=lambda t: (t["done"], t["status"].lower(),
|
||||
(t["due"] or "9999")))
|
||||
if sort == "source":
|
||||
return sorted(out, key=lambda t: (t["collection_name"].lower(),
|
||||
(t["due"] or "9999"), t["title"].lower()))
|
||||
if sort == "created":
|
||||
return sorted(out, key=lambda t: str(t.get("created_at") or ""))
|
||||
# "due" : les tâches datées d'abord (les plus urgentes en tête), puis
|
||||
# les tâches sans date — comme une liste de rappels.
|
||||
return sorted(out, key=lambda t: ((t["due"] or "9999-12-31"),
|
||||
t["done"], t["title"].lower()))
|
||||
@@ -15,12 +15,14 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.collection_lifecycle import delete_collections
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -785,7 +787,7 @@ class DeleteCollection(Tool):
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
if not coll:
|
||||
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
|
||||
delete_collections(conn, [cid])
|
||||
conn.commit()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="collection", target_id=cid,
|
||||
@@ -877,6 +879,197 @@ class CreateGiteaIssue(Tool):
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════ Web tools ══════════════════════════
|
||||
# Outils réseau : recherche web, lecture d'une page, recherche GitHub.
|
||||
# Ils réutilisent les garde-fous SSRF déjà éprouvés par l'importeur URL
|
||||
# (``importers.url_fetch`` / ``og_fetcher``) — aucune adresse privée,
|
||||
# re-vérifiée à chaque saut de redirection.
|
||||
|
||||
|
||||
class WebSearch(Tool):
|
||||
name = "web_search"
|
||||
description = (
|
||||
"Recherche sur le web (actualités, docs, libs, comparatifs) et "
|
||||
"renvoie titre + URL + extrait pour chaque résultat. Utilise-la AVANT de "
|
||||
"répondre sur un fait récent ou technique, puis CITE les URL obtenues."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "requête de recherche"},
|
||||
"num_results": {"type": "integer",
|
||||
"description": "nombre de résultats (1-10, défaut 5)"},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
from app.services.web_search import available_providers, search_web
|
||||
|
||||
query = str(args.get("query") or "").strip()
|
||||
if not query:
|
||||
return ToolResult(status="error", tool=self.name, message="query vide")
|
||||
try:
|
||||
num = max(1, min(int(args.get("num_results") or 5), 10))
|
||||
except (TypeError, ValueError):
|
||||
num = 5
|
||||
try:
|
||||
results, provider = await search_web(query, num)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Recherche web indisponible: {exc}")
|
||||
if not results:
|
||||
hint = ""
|
||||
if not available_providers().get(provider, True):
|
||||
hint = " — configurez EXA_API_KEY dans les réglages FlowDeck"
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Aucun résultat pour « {query} » (provider: {provider}){hint}")
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="web_search",
|
||||
data={"query": query, "provider": provider, "results": results,
|
||||
"count": len(results)},
|
||||
message=f"{len(results)} résultat(s) via {provider}",
|
||||
)
|
||||
|
||||
|
||||
class FetchUrl(Tool):
|
||||
name = "fetch_url"
|
||||
description = (
|
||||
"Télécharge une page web et la convertit en Markdown (titres, listes, "
|
||||
"code, tableaux) pour pouvoir la lire et la citer. Utilise-la après "
|
||||
"web_search pour approfondir un résultat. Les URLs internes / privées "
|
||||
"sont refusées."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "URL http(s) de la page"},
|
||||
"max_chars": {"type": "integer",
|
||||
"description": "longueur max du markdown renvoyé (défaut 20000)"},
|
||||
},
|
||||
"required": ["url"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
from app.config import settings
|
||||
from app.services.http_client import shared_client
|
||||
from app.services.importers.html_notes import _html_to_markdown
|
||||
from app.services.importers.url_fetch import _validate_url
|
||||
from app.services.og_fetcher import parse_og
|
||||
|
||||
raw = str(args.get("url") or "").strip()
|
||||
if not raw:
|
||||
return ToolResult(status="error", tool=self.name, message="url vide")
|
||||
# « example.com/page » → https://example.com/page, mais un schéma
|
||||
# explicite (file:, ftp:, …) est conservé pour être REJETÉ par le
|
||||
# garde-fou, au lieu d'être transformé en requête réseau hasardeuse.
|
||||
if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://", raw):
|
||||
raw = "https://" + raw
|
||||
try:
|
||||
url = _validate_url(raw)
|
||||
except ValueError as exc:
|
||||
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||
|
||||
try:
|
||||
max_chars = max(500, min(int(args.get("max_chars")
|
||||
or settings.web_fetch_max_chars), 60000))
|
||||
except (TypeError, ValueError):
|
||||
max_chars = int(settings.web_fetch_max_chars or 20000)
|
||||
|
||||
current = url
|
||||
resp = None
|
||||
try:
|
||||
async with shared_client(timeout=15.0, follow_redirects=False,
|
||||
headers={"User-Agent": "FlowDeck-Agent/1.0 (fetch_url)"}) as client:
|
||||
for _ in range(6):
|
||||
resp = await client.get(current)
|
||||
if resp.status_code in (301, 302, 303, 307, 308):
|
||||
loc = resp.headers.get("location")
|
||||
if not loc:
|
||||
raise ValueError("redirection sans cible")
|
||||
from urllib.parse import urljoin
|
||||
|
||||
current = urljoin(current, loc)
|
||||
# re-validation : une URL publique peut rediriger vers 169.254.x
|
||||
current = _validate_url(current)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
break
|
||||
else:
|
||||
raise ValueError("trop de redirections")
|
||||
except ValueError as exc:
|
||||
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Page injoignable: {exc}")
|
||||
if resp is None:
|
||||
return ToolResult(status="error", tool=self.name, message="Aucune réponse")
|
||||
|
||||
ctype = (resp.headers.get("content-type") or "").lower()
|
||||
body = resp.text
|
||||
if "html" not in ctype and "xhtml" not in ctype:
|
||||
# JSON / texte brut : on le renvoie tel quel, c'est souvent une API.
|
||||
markdown = body
|
||||
meta: dict = {}
|
||||
else:
|
||||
meta = parse_og(body, current)
|
||||
markdown = _html_to_markdown(body)
|
||||
|
||||
truncated = len(markdown) > max_chars
|
||||
if truncated:
|
||||
markdown = markdown[:max_chars] + "\n\n[contenu tronqué]"
|
||||
|
||||
title = (meta.get("title") or "").strip() or current
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="web_page", target_id=current,
|
||||
data={"url": current, "title": title[:200], "markdown": markdown,
|
||||
"truncated": truncated, "chars": len(markdown)},
|
||||
message=f"Page lue : {title[:80]} ({len(markdown)} car.)",
|
||||
)
|
||||
|
||||
|
||||
class SearchCode(Tool):
|
||||
name = "search_code"
|
||||
description = (
|
||||
"Recherche GitHub : dépôts, code source ou issues (uniquement GitHub "
|
||||
"public, sans clé possible mais limitée à 10 requêtes/min). Sert à "
|
||||
"trouver une implémentation de référence, une lib ou l'historique d'un bug."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "termes GitHub (lang:python, repo:, etc.)"},
|
||||
"kind": {"type": "string",
|
||||
"description": "repositories | code | issues (défaut repositories)"},
|
||||
"limit": {"type": "integer", "description": "nombre de résultats (1-10, défaut 5)"},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
from app.services.web_search import search_github
|
||||
|
||||
query = str(args.get("query") or "").strip()
|
||||
if not query:
|
||||
return ToolResult(status="error", tool=self.name, message="query vide")
|
||||
kind = str(args.get("kind") or "repositories")
|
||||
try:
|
||||
limit = max(1, min(int(args.get("limit") or 5), 10))
|
||||
except (TypeError, ValueError):
|
||||
limit = 5
|
||||
try:
|
||||
results = await search_github(query, kind, limit)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return ToolResult(status="error", tool=self.name,
|
||||
message=f"Recherche GitHub impossible: {exc}")
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="github",
|
||||
target_id=f"{kind}:{query}",
|
||||
data={"query": query, "kind": kind, "results": results, "count": len(results)},
|
||||
message=f"{len(results)} résultat(s) GitHub ({kind})",
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════ Registry ══════════════════════════
|
||||
|
||||
TOOL_CLASSES = [
|
||||
@@ -887,6 +1080,7 @@ TOOL_CLASSES = [
|
||||
DeletePage, DeleteCollection,
|
||||
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
|
||||
DeleteDocument,
|
||||
WebSearch, FetchUrl, SearchCode,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.collection_lifecycle import (
|
||||
collections_hosted_by_page,
|
||||
delete_collections,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,6 +61,9 @@ def purge_expired(days: int = 30) -> dict:
|
||||
if dt is None or dt >= cutoff:
|
||||
continue
|
||||
page_id = row["id"]
|
||||
# A purged host page takes its database with it, otherwise the
|
||||
# workspace kept advertising tasks whose document is gone.
|
||||
collections = collections_hosted_by_page(conn, page_id)
|
||||
# Promote direct children to the deleted page's parent so no live
|
||||
# sub-tree is orphaned (matches the permanent-delete semantics).
|
||||
conn.execute(
|
||||
@@ -65,6 +72,7 @@ def purge_expired(days: int = 30) -> dict:
|
||||
(page_id, page_id),
|
||||
)
|
||||
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
|
||||
delete_collections(conn, collections)
|
||||
purged.append(page_id)
|
||||
conn.commit()
|
||||
if purged:
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"""FlowDeck — Web search + GitHub search pour les tools agent (v7.46.0).
|
||||
|
||||
Alimente les deux tools réseau qui manquaient au registre : la recherche
|
||||
**en ligne** (`web_search`) et la recherche **GitHub** (`search_code`).
|
||||
|
||||
Choix de conception :
|
||||
|
||||
* **Provider configurable** (``settings.web_search_provider``) : Exa
|
||||
(recherche sémantique + snippets, clé requise) en priorité, DuckDuckGo en
|
||||
repli sans compte. Le repli parse du HTML — c'est volontairement *dégradé* :
|
||||
il n'est jamais utilisé si une clé Exa est configurée, et sert surtout à
|
||||
garder le skill utilisable sur une instance sans compte externe.
|
||||
* **Aucune dépendance ajoutée** : httpx déjà présent, ``shared_client`` pour le
|
||||
pool de connexions (A42), et les garde-fous SSRF déjà éprouvés par
|
||||
``app.services.importers.url_fetch`` / ``og_fetcher``.
|
||||
* **Toujours des sources** : chaque résultat porte titre + URL + extrait, pour
|
||||
que l'IA puisse citer au lieu d'inventer.
|
||||
|
||||
La couche réseau est injectable (``transport``) pour que les tests n'aient
|
||||
jamais besoin du réseau.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html as htmlmod
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from app.config import settings
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
USER_AGENT = "FlowDeck-Agent/1.0 (+web_search)"
|
||||
|
||||
#: Domaines de repli interdits : réponses « sous conditions » / anti-bot qui
|
||||
#: pollueraient les résultats (DuckDuckGo sert parfois une page de challenge).
|
||||
_BLOCKED_RESULT_HOSTS = frozenset({
|
||||
"duckduckgo.com", "lite.duckduckgo.com", "duckduckgo.com.yahoo.net",
|
||||
})
|
||||
|
||||
EXA_ENDPOINT = "https://api.exa.ai/search"
|
||||
DDG_ENDPOINT = "https://lite.duckduckgo.com/lite/"
|
||||
GITHUB_API = "https://api.github.com"
|
||||
|
||||
MAX_RESULTS = 10
|
||||
_SNIPPET_CHARS = 300
|
||||
|
||||
|
||||
def _clean(text: str | None) -> str:
|
||||
return re.sub(r"\s+", " ", (text or "")).strip()
|
||||
|
||||
|
||||
def _clip(text: str, limit: int = _SNIPPET_CHARS) -> str:
|
||||
text = _clean(text)
|
||||
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _is_resultable(url: str) -> bool:
|
||||
"""Ignore les redirections vers le moteur lui-même et les URLs non http."""
|
||||
parsed = urlparse(url or "")
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
return False
|
||||
return parsed.hostname.lower() not in _BLOCKED_RESULT_HOSTS
|
||||
|
||||
|
||||
# ══════════════════════ Exa ══════════════════════
|
||||
|
||||
|
||||
async def _search_exa(query: str, num_results: int, transport=None) -> list[dict]:
|
||||
key = (settings.exa_api_key or "").strip()
|
||||
if not key:
|
||||
return []
|
||||
payload = {
|
||||
"query": query,
|
||||
"numResults": max(1, min(num_results, MAX_RESULTS)),
|
||||
"contents": {"text": {"maxCharacters": 1200}},
|
||||
}
|
||||
kwargs: dict[str, Any] = {"timeout": 15.0}
|
||||
if transport is not None:
|
||||
kwargs["transport"] = transport
|
||||
async with shared_client(**kwargs) as client:
|
||||
resp = await client.post(
|
||||
EXA_ENDPOINT,
|
||||
json=payload,
|
||||
headers={"x-api-key": key, "Content-Type": "application/json",
|
||||
"User-Agent": USER_AGENT},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"Exa HTTP {resp.status_code}")
|
||||
results = []
|
||||
for item in (resp.json() or {}).get("results", []):
|
||||
url = item.get("url") or ""
|
||||
if not _is_resultable(url):
|
||||
continue
|
||||
results.append({
|
||||
"title": _clip(item.get("title") or url, 160),
|
||||
"url": url,
|
||||
"snippet": _clip(item.get("text") or item.get("summary") or ""),
|
||||
"published": (item.get("publishedDate") or "")[:10],
|
||||
"source": "exa",
|
||||
})
|
||||
return results[:MAX_RESULTS]
|
||||
|
||||
|
||||
# ══════════════════════ DuckDuckGo (repli sans compte) ══════════════════════
|
||||
|
||||
_DDG_ROW_RE = re.compile(
|
||||
r"<a[^>]+class=\"[^\"]*result-link[^\"]*\"[^>]+href=\"(?P<href>[^\"]+)\"[^>]*>(?P<title>.*?)</a>",
|
||||
re.I | re.S,
|
||||
)
|
||||
_DDG_SNIPPET_RE = re.compile(
|
||||
r"class=\"[^\"]*result-snippet[^\"]*\"[^>]*>(?P<snippet>.*?)</td>", re.I | re.S
|
||||
)
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _unwrap_ddg(href: str) -> str:
|
||||
"""DuckDuckGo emballe les résultats dans ``/l/?uddg=<url encodé>``."""
|
||||
if not href:
|
||||
return ""
|
||||
parsed = urlparse(htmlmod.unescape(href))
|
||||
if "uddg" in (parsed.query or ""):
|
||||
values = parse_qs(parsed.query).get("uddg")
|
||||
if values:
|
||||
return values[0]
|
||||
return href
|
||||
|
||||
|
||||
async def _search_duckduckgo(query: str, num_results: int, transport=None) -> list[dict]:
|
||||
kwargs: dict[str, Any] = {"timeout": 12.0}
|
||||
if transport is not None:
|
||||
kwargs["transport"] = transport
|
||||
async with shared_client(**kwargs) as client:
|
||||
resp = await client.post(
|
||||
DDG_ENDPOINT,
|
||||
data={"q": query, "kl": "wt-wt"},
|
||||
headers={"User-Agent": USER_AGENT,
|
||||
"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"DuckDuckGo HTTP {resp.status_code}")
|
||||
body = resp.text
|
||||
links = list(_DDG_ROW_RE.finditer(body))
|
||||
snippets = [_clean(_TAG_RE.sub("", m.group("snippet"))) for m in _DDG_SNIPPET_RE.finditer(body)]
|
||||
results = []
|
||||
for idx, match in enumerate(links):
|
||||
url = _unwrap_ddg(match.group("href"))
|
||||
if not _is_resultable(url):
|
||||
continue
|
||||
results.append({
|
||||
"title": _clean(_TAG_RE.sub("", match.group("title"))),
|
||||
"url": url,
|
||||
"snippet": _clip(snippets[idx]) if idx < len(snippets) else "",
|
||||
"published": "",
|
||||
"source": "duckduckgo",
|
||||
})
|
||||
if len(results) >= min(num_results, MAX_RESULTS):
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
# ══════════════════════ API publique ══════════════════════
|
||||
|
||||
|
||||
async def search_web(query: str, num_results: int = 5,
|
||||
transport=None) -> tuple[list[dict], str]:
|
||||
"""Recherche web → ``(results, provider)``.
|
||||
|
||||
Essaie le provider configuré puis l'autre ; si aucun ne répond, renvoie
|
||||
une liste vide — c'est au tool de traduire ça en message actionnable pour
|
||||
le modèle (« configurez EXA_API_KEY ») plutôt qu'en erreur réseau opaque.
|
||||
"""
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return [], ""
|
||||
|
||||
provider = (settings.web_search_provider or "exa").strip().lower()
|
||||
order = ("exa", "duckduckgo") if provider == "exa" else ("duckduckgo", "exa")
|
||||
|
||||
errors: list[str] = []
|
||||
for name in order:
|
||||
try:
|
||||
if name == "exa":
|
||||
results = await _search_exa(q, num_results, transport)
|
||||
else:
|
||||
results = await _search_duckduckgo(q, num_results, transport)
|
||||
except Exception as exc: # noqa: BLE001 — un provider qui tombe ne doit pas tuer l'autre
|
||||
logger.info("web_search provider %s failed: %s", name, exc)
|
||||
errors.append(f"{name}: {exc}")
|
||||
continue
|
||||
if results:
|
||||
return results, name
|
||||
|
||||
if errors:
|
||||
logger.warning("web_search sans résultat — %s", "; ".join(errors))
|
||||
return [], order[0]
|
||||
return [], order[0]
|
||||
|
||||
|
||||
def available_providers() -> dict[str, bool]:
|
||||
"""Quel provider est réellement utilisable (affiché dans les settings)."""
|
||||
return {
|
||||
"exa": bool((settings.exa_api_key or "").strip()),
|
||||
"duckduckgo": True,
|
||||
"github": True, # sans PAT : 10 req/min suffisent pour un usage agent
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════ GitHub ══════════════════════
|
||||
|
||||
_GH_KINDS = {
|
||||
"repositories": "repos",
|
||||
"repos": "repos",
|
||||
"code": "code",
|
||||
"issues": "issues",
|
||||
}
|
||||
|
||||
|
||||
def _gh_headers() -> dict[str, str]:
|
||||
headers = {"Accept": "application/vnd.github+json",
|
||||
"User-Agent": USER_AGENT,
|
||||
"X-GitHub-Api-Version": "2022-11-28"}
|
||||
token = (settings.github_token or "").strip()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return headers
|
||||
|
||||
|
||||
def _gh_item_name(item: dict, kind: str) -> str:
|
||||
if kind == "repos":
|
||||
return item.get("full_name") or item.get("name") or ""
|
||||
if kind == "code":
|
||||
return item.get("path") or ""
|
||||
return item.get("title") or f"#{item.get('number')}"
|
||||
|
||||
|
||||
def _gh_item_url(item: dict, kind: str) -> str:
|
||||
if kind == "code":
|
||||
# L'API code search ne renvoie pas d'URL exploitable : on reconstruit.
|
||||
repo = (item.get("repository") or {}).get("full_name", "")
|
||||
return f"https://github.com/{repo}/blob/HEAD/{item.get('path', '')}" if repo else ""
|
||||
return item.get("html_url") or ""
|
||||
|
||||
|
||||
def _gh_item_snippet(item: dict, kind: str) -> str:
|
||||
if kind == "code":
|
||||
return ""
|
||||
if kind == "repos":
|
||||
return _clip(item.get("description") or "")
|
||||
body = _clean(item.get("body") or "")
|
||||
return _clip(body) if body else _clip(item.get("title") or "")
|
||||
|
||||
|
||||
async def search_github(query: str, kind: str = "repositories", limit: int = 5,
|
||||
transport=None) -> list[dict]:
|
||||
"""Recherche GitHub (repos | code | issues) → liste de résultats sourcés.
|
||||
|
||||
Fonctionne sans PAT (limite 10 req/min) ; avec ``GITHUB_TOKEN``, 30 req/min.
|
||||
"""
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
k = _GH_KINDS.get((kind or "").strip().lower(), "repos")
|
||||
per_page = max(1, min(int(limit or 5), MAX_RESULTS))
|
||||
|
||||
kwargs: dict[str, Any] = {"timeout": 15.0}
|
||||
if transport is not None:
|
||||
kwargs["transport"] = transport
|
||||
async with shared_client(**kwargs) as client:
|
||||
resp = await client.get(
|
||||
f"{GITHUB_API}/search/{k}",
|
||||
params={"q": q, "per_page": per_page},
|
||||
headers=_gh_headers(),
|
||||
)
|
||||
if resp.status_code == 403:
|
||||
raise RuntimeError("GitHub: quota dépassé (403) — configurez GITHUB_TOKEN")
|
||||
if resp.status_code == 422:
|
||||
raise RuntimeError("GitHub: requête de recherche invalide (422)")
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"GitHub HTTP {resp.status_code}")
|
||||
|
||||
items = (resp.json() or {}).get("items", [])
|
||||
results = []
|
||||
for item in items[:per_page]:
|
||||
results.append({
|
||||
"title": _gh_item_name(item, k),
|
||||
"url": _gh_item_url(item, k),
|
||||
"snippet": _gh_item_snippet(item, k),
|
||||
"stars": item.get("stargazers_count") if k == "repos" else None,
|
||||
"state": item.get("state") if k == "issues" else "",
|
||||
"source": f"github/{k}",
|
||||
})
|
||||
return results
|
||||
@@ -163,9 +163,9 @@
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;padding:0 4px;">
|
||||
<input class="ctx-input" x-ref="ctxTagInput" placeholder="Tag name…" @click.stop
|
||||
@keydown.enter.prevent="$store.fdCtx.addNewTag($event.target.value, $store.fdCtx.newTagColor); $event.target.value = ''"
|
||||
@keydown.enter.prevent="$store.fdCtx.addTagAndClear($event.target.value, $store.fdCtx.newTagColor, $event.target)"
|
||||
@keydown.escape.stop="$store.fdCtx.tagAdding = false">
|
||||
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.addNewTag($refs.ctxTagInput.value, $store.fdCtx.newTagColor); $refs.ctxTagInput.value = ''">Add</button>
|
||||
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.addTagAndClear($refs.ctxTagInput.value, $store.fdCtx.newTagColor, $refs.ctxTagInput)">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,13 +125,21 @@
|
||||
<div class="db-col-menu-item" id="db-col-insert-right"><span>→┤ Insert right</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Side Peek Panel for Database Pages -->
|
||||
<div id="db-side-peek" class="db-side-peek" style="display:none">
|
||||
<div class="db-side-peek-resize" id="db-side-peek-resize"></div>
|
||||
<div class="db-side-peek-header">
|
||||
<button id="db-side-peek-close" class="db-side-peek-close">×</button>
|
||||
<button id="db-side-peek-full" class="db-side-peek-full" title="Open full page">↗</button>
|
||||
</div>
|
||||
<iframe id="db-side-peek-iframe" src="" style="width:100%;height:100%;border:none"></iframe>
|
||||
<!-- Side Peek Panel for Database Pages (vanilla JS, piloté par database_table.js) -->
|
||||
<div id="db-side-peek" class="db-side-peek">
|
||||
<div class="peek-resize-handle" title="Glisser pour redimensionner — cliquer pour fermer"></div>
|
||||
<div class="db-side-peek-header">
|
||||
<button id="db-side-peek-close" title="Close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="db-side-peek-title" id="db-side-peek-title"></div>
|
||||
<button id="db-side-peek-full" title="Open full page">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="db-side-peek-body">
|
||||
<div class="db-side-peek-loading" id="db-side-peek-loading">Chargement…</div>
|
||||
<iframe id="db-side-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,4 +107,6 @@
|
||||
.db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px}
|
||||
</style>
|
||||
<script type="application/json" id="db-config" nonce="{{ csp_nonce() }}">{{ ({"collection": collection_data.collection, "properties": collection_data.properties, "pages": collection_data.pages, "views": collection_data.views if collection_data.views is defined else []} if collection_data else none) | tojson }}</script>
|
||||
<link rel="stylesheet" href="/static/css/my_tasks.css?v={{ asset_version }}">
|
||||
<script data-cfasync="false" src="/static/js/database_table.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" src="/static/js/task_db_link.js?v={{ asset_version }}" defer></script>
|
||||
|
||||
@@ -60,14 +60,14 @@
|
||||
{# ── Collapsed "…" segment ── #}
|
||||
<template x-if="crumb.ellipsis">
|
||||
<span class="crumb-anchor"
|
||||
@mouseenter="cancelCloseTimer(); ellipsisOpen = true"
|
||||
@mouseenter="hoverEllipsis()"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<button type="button" class="breadcrumb-link crumb-ellipsis">…</button>
|
||||
<div class="fd-nav-menu" x-show="ellipsisOpen" x-cloak x-transition.opacity
|
||||
@mouseenter="cancelCloseTimer()"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<template x-for="h in crumb.hidden" :key="h.id">
|
||||
<div class="fd-nav-item" @click.stop="go(h.url); closeAll()">
|
||||
<div class="fd-nav-item" @click.stop="goClose(h.url)">
|
||||
<span class="fd-nav-ico" :title="h.icon || 'file'"></span>
|
||||
<span class="fd-nav-label" x-text="h.label"></span>
|
||||
</div>
|
||||
@@ -91,7 +91,7 @@
|
||||
{# ── Interactive crumb with navigation dropdown (Notion-style hover) ── #}
|
||||
<template x-if="!crumb.ellipsis && crumb.menu">
|
||||
<span class="crumb-anchor"
|
||||
@mouseenter="cancelCloseTimer(); openMenu(crumb.origIndex)"
|
||||
@mouseenter="hoverMenu(crumb.origIndex)"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<button type="button" class="breadcrumb-link"
|
||||
:class="{ 'breadcrumb-current': crumb.origIndex === crumbs.length - 1 }"
|
||||
@@ -110,7 +110,7 @@
|
||||
<template x-for="item in activeItems" :key="item.id">
|
||||
<div class="fd-nav-item"
|
||||
@mouseenter="openSub(item)"
|
||||
@click.stop="go(item.url); closeAll()">
|
||||
@click.stop="goClose(item.url)">
|
||||
<span class="fd-nav-ico" :title="item.icon || 'file'"></span>
|
||||
<span class="fd-nav-label" x-text="item.name"></span>
|
||||
<span class="fd-nav-arrow" x-show="item.has_children">›</span>
|
||||
@@ -118,7 +118,7 @@
|
||||
@mouseenter="cancelCloseTimer()"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
<template x-for="s in subItems" :key="s.id">
|
||||
<div class="fd-nav-item" @click.stop="go(s.url); closeAll()">
|
||||
<div class="fd-nav-item" @click.stop="goClose(s.url)">
|
||||
<span class="fd-nav-ico" :title="s.icon || 'file'"></span>
|
||||
<span class="fd-nav-label" x-text="s.name"></span>
|
||||
<span class="fd-nav-arrow" x-show="s.has_children">›</span>
|
||||
@@ -152,6 +152,9 @@ document.addEventListener('alpine:init', function () {
|
||||
wsId: 0,
|
||||
openIdx: null,
|
||||
ellipsisOpen: false,
|
||||
hoverEllipsis(){ this.cancelCloseTimer(); this.ellipsisOpen = true; },
|
||||
hoverMenu(i){ this.cancelCloseTimer(); this.openMenu(i); },
|
||||
goClose(u){ this.go(u); this.closeAll(); },
|
||||
loading: false,
|
||||
cache: {},
|
||||
activeItems: [],
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
@keydown.enter.prevent="inviteEnter()"
|
||||
@keydown.down.prevent="inviteMove(1)"
|
||||
@keydown.up.prevent="inviteMove(-1)"
|
||||
@keydown.escape="inviteSuggestOpen = false; inviteUsers = []"
|
||||
@keydown.escape="closeInviteSuggest()"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button class="sd-btn-primary" @click="shareInvite()">
|
||||
@@ -169,7 +169,7 @@
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'edit' }"
|
||||
@click="updateShare(a.id, 'edit'); a.permOpen=false"
|
||||
@click="setSharePerm(a, 'edit')"
|
||||
>
|
||||
<span>Can edit</span>
|
||||
<span class="sd-perm-desc"
|
||||
@@ -179,7 +179,7 @@
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'comment' }"
|
||||
@click="updateShare(a.id, 'comment'); a.permOpen=false"
|
||||
@click="setSharePerm(a, 'comment')"
|
||||
>
|
||||
<span>Can comment</span>
|
||||
<span class="sd-perm-desc"
|
||||
@@ -189,7 +189,7 @@
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: a.permission === 'view' }"
|
||||
@click="updateShare(a.id, 'view'); a.permOpen=false"
|
||||
@click="setSharePerm(a, 'view')"
|
||||
>
|
||||
<span>Can view</span>
|
||||
<span class="sd-perm-desc"
|
||||
@@ -199,7 +199,7 @@
|
||||
<div class="sd-perm-sep"></div>
|
||||
<div
|
||||
class="sd-perm-option danger"
|
||||
@click="removeShare(a.id); a.permOpen=false"
|
||||
@click="removeShareAndClose(a)"
|
||||
>
|
||||
Remove
|
||||
</div>
|
||||
@@ -233,7 +233,7 @@
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: generalAccess === 'invited' }"
|
||||
@click="generalAccess='invited'; accessMenuOpen=false"
|
||||
@click="pickGeneralAccess('invited')"
|
||||
>
|
||||
<span>{{ fd_icon('lock',14) }} Only people invited</span>
|
||||
<span class="sd-perm-desc"
|
||||
@@ -243,7 +243,7 @@
|
||||
<div
|
||||
class="sd-perm-option"
|
||||
:class="{ active: generalAccess === 'anyone' }"
|
||||
@click="generalAccess='anyone'; accessMenuOpen=false"
|
||||
@click="pickGeneralAccess('anyone')"
|
||||
>
|
||||
<span>{{ fd_icon('globe',14) }} Anyone with the link</span>
|
||||
<span class="sd-perm-desc"
|
||||
@@ -312,7 +312,7 @@
|
||||
<template x-for="(ic, ici) in ['📄','📝','📋','📊','🗂️','📁','📂','🗒️','🗓️','📌','🔖','⭐','🚀','💡','🎯','🔑','📚','🧪','🌍','💰','📝','🧩','🎨','🔧','⚙️','🗃️','📦','🏷️','📍','🏠','👤']" :key="ici">
|
||||
<button type="button" class="sd-icon-btn" @click="setIcon(ic)" :class="{ active: iconValue === ic }" style="width:36px;height:36px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);font-size:18px;display:flex;align-items:center;justify-content:center;cursor:pointer;" x-text="ic"></button>
|
||||
</template>
|
||||
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="setIcon(iconValue);iconOpen=false;">
|
||||
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="applyIcon()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -498,16 +498,16 @@
|
||||
</div>
|
||||
<div class="more-menu-item" @click="movePage">↗ Move to</div>
|
||||
<div class="more-menu-sep"></div>
|
||||
<div class="more-menu-item" @click="moreOpen=false; toggleLock()">
|
||||
<div class="more-menu-item" @click="moreToggleLock()">
|
||||
<span x-text="isLocked ? '🔓 Unlock page' : '🔒 Lock page'"></span>
|
||||
</div>
|
||||
<div class="more-menu-item" @click="moreOpen=false; setPageOption('full_width', !fullWidth)">
|
||||
<div class="more-menu-item" @click="moreFullWidth()">
|
||||
↔ Full width <span x-show="fullWidth" style="margin-left:auto;font-size:11px">✓</span>
|
||||
</div>
|
||||
<div class="more-menu-item" @click="moreOpen=false; setPageOption('font_small', !fontSmall)">
|
||||
<div class="more-menu-item" @click="moreFontSmall()">
|
||||
Aa Small text <span x-show="fontSmall" style="margin-left:auto;font-size:11px">✓</span>
|
||||
</div>
|
||||
<div class="more-menu-item" @click="moreOpen=false; saveAsTemplate()">
|
||||
<div class="more-menu-item" @click="moreSaveTemplate()">
|
||||
📑 Save as template
|
||||
</div>
|
||||
<div class="more-menu-sep"></div>
|
||||
@@ -546,13 +546,13 @@
|
||||
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.6) 100%);z-index:1;"></div>
|
||||
</div>
|
||||
<div class="page-title-block">
|
||||
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-html="iconHtml()"></span>
|
||||
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-init="bindIconHtml($el)"></span>
|
||||
<div
|
||||
class="page-title-input"
|
||||
contenteditable="true"
|
||||
id="_titleEl"
|
||||
data-placeholder="New Page"
|
||||
@input="dirty=true;save()"
|
||||
@input="markAndSave()"
|
||||
@keydown.enter.prevent="focusBlock()"
|
||||
@paste.prevent="pastePlain($event)"
|
||||
spellcheck="false"
|
||||
@@ -583,7 +583,7 @@
|
||||
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
|
||||
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
|
||||
@click="window.E && window.E.commentOnSelection()">
|
||||
@click="edCall('commentOnSelection')">
|
||||
💬 Comment
|
||||
</button>
|
||||
|
||||
@@ -820,7 +820,7 @@
|
||||
<div style="padding:24px;text-align:center;color:var(--text-dim);">No pages link here</div>
|
||||
</template>
|
||||
<template x-for="b in backlinksList" :key="b.id">
|
||||
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="backlinksOpen=false;window.location.href='/pages/'+b.id">
|
||||
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="openBacklink(b)">
|
||||
<div style="font-weight:500;color:var(--text-primary);" x-text="b.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-top:2px;" x-text="b.workspace || ''"></div>
|
||||
</a>
|
||||
@@ -856,7 +856,7 @@
|
||||
<template x-if="importFile">
|
||||
<div style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
|
||||
<span x-text="importFile.name"></span>
|
||||
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
|
||||
<span x-text="fmtImportSize(importFile)"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div style="display:flex;gap:8px;margin-top:10px;">
|
||||
|
||||
@@ -105,6 +105,12 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: accountsData »).
|
||||
|
||||
if (window.Alpine) { Alpine.data('accountsData', accountsData); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('accountsData', accountsData); });
|
||||
function accountsData() {
|
||||
return {
|
||||
profile: { full_name: '', email: '' },
|
||||
|
||||
@@ -2,12 +2,19 @@
|
||||
<style>
|
||||
#fd-agent-fab{position:fixed;right:22px;bottom:22px;width:54px;height:54px;border-radius:50%;background:var(--text-primary,#37352F);color:var(--bg-primary,#fff);border:none;cursor:pointer;box-shadow:0 8px 26px rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:1700;transition:transform .15s,box-shadow .15s}
|
||||
#fd-agent-fab:hover{transform:scale(1.06);box-shadow:0 12px 34px rgba(0,0,0,.45)}
|
||||
#fd-agent-panel{position:fixed;top:0;right:0;bottom:0;width:470px;max-width:100vw;background:var(--bg-primary,#191919);border-left:1px solid var(--border,rgba(255,255,255,.1));z-index:1850;display:flex;flex-direction:column;box-shadow:-16px 0 48px rgba(0,0,0,.55);transform:translateX(102%);transition:transform .24s cubic-bezier(.16,1,.3,1)}
|
||||
#fd-agent-panel{position:fixed;top:0;right:0;bottom:0;width:var(--fd-agent-width,470px);max-width:100vw;background:var(--bg-primary,#191919);border-left:1px solid var(--border,rgba(255,255,255,.1));z-index:1850;display:flex;flex-direction:column;box-shadow:-16px 0 48px rgba(0,0,0,.55);transform:translateX(102%);transition:transform .24s cubic-bezier(.16,1,.3,1)}
|
||||
#fd-agent-panel.open{transform:translateX(0)}
|
||||
#fd-agent-panel ::-webkit-scrollbar{width:8px;height:8px}
|
||||
#fd-agent-panel ::-webkit-scrollbar-thumb{background:rgba(128,128,128,.35);border-radius:4px}
|
||||
#fd-agent-panel ::-webkit-scrollbar-track{background:transparent}
|
||||
|
||||
/* Resize handle (left edge) */
|
||||
.fd-ap-resize{position:absolute;top:0;bottom:0;left:0;width:6px;cursor:col-resize;z-index:5}
|
||||
.fd-ap-resize::before{content:"";position:absolute;top:0;bottom:0;left:2px;width:2px;background:transparent;border-radius:2px;transition:background .12s ease}
|
||||
.fd-ap-resize:hover::before,body.fd-ap-resizing .fd-ap-resize::before{background:var(--accent,#2383E2)}
|
||||
body.fd-ap-resizing{cursor:col-resize!important;user-select:none}
|
||||
body.fd-ap-resizing *{cursor:col-resize!important}
|
||||
|
||||
/* Header */
|
||||
.fd-ap-head{display:flex;align-items:center;gap:12px;padding:16px 18px 12px}
|
||||
.fd-ap-logo{width:36px;height:36px;border-radius:50%;background:var(--text-primary,#37352F);color:var(--bg-primary,#fff);display:flex;align-items:center;justify-content:center;flex-shrink:0;box-shadow:0 2px 8px rgba(0,0,0,.25)}
|
||||
@@ -189,7 +196,7 @@
|
||||
.fd-agent-msg-markdown ul,.fd-agent-msg-markdown ol{margin:0 0 8px;padding-left:20px}
|
||||
.fd-agent-msg-markdown li{margin:2px 0}
|
||||
.fd-agent-msg-markdown code{background:rgba(127,127,127,.18);border-radius:4px;padding:1px 5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}
|
||||
.fd-agent-msg-markdown pre{background:var(--bg-code,#111);border:1px solid var(--border,#333);border-radius:8px;padding:12px;overflow-x:auto;margin:8px 0}
|
||||
.fd-agent-msg-markdown pre{background:var(--bg-code,var(--bg-tertiary,#1f1f1f));border:1px solid var(--border,rgba(255,255,255,.12));border-radius:8px;padding:12px;overflow-x:auto;margin:8px 0;color:var(--text,#ececec)}
|
||||
.fd-agent-msg-markdown pre code{background:none;padding:0;font-size:12px;line-height:1.5}
|
||||
.fd-md-table-wrap{overflow-x:auto;margin:8px 0}
|
||||
.fd-md-table{border-collapse:collapse;width:100%;font-size:13px}
|
||||
@@ -211,6 +218,7 @@
|
||||
|
||||
{# ── Panel ── #}
|
||||
<div id="fd-agent-panel" class="fd-ap" :class="{open: open}" x-data="agentPanel()" @keydown.escape.window="close()" role="dialog" aria-label="Agent FlowDeck">
|
||||
<div class="fd-ap-resize" x-ref="resizeHandle" role="separator" aria-orientation="vertical" aria-label="Redimensionner le panneau" tabindex="0" title="Glisser pour redimensionner — double-clic pour réinitialiser"></div>
|
||||
<div class="fd-ap-head">
|
||||
<span class="fd-ap-logo"><svg viewBox="0 0 24 24" width="19" height="19" fill="currentColor" aria-hidden="true"><path d="M12 3l1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3L12 3Z"/></svg></span>
|
||||
<div class="fd-ap-head-main">
|
||||
@@ -231,7 +239,7 @@
|
||||
|
||||
<div class="fd-ap-tabs">
|
||||
<button class="fd-ap-tab" :class="{active: tab==='chat'}" @click="tab='chat'">Conversation</button>
|
||||
<button class="fd-ap-tab" :class="{active: tab==='history'}" @click="tab='history'; loadConversations()">Historique</button>
|
||||
<button class="fd-ap-tab" :class="{active: tab==='history'}" @click="showHistory()">Historique</button>
|
||||
</div>
|
||||
|
||||
<div class="fd-agent-body">
|
||||
@@ -305,7 +313,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">
|
||||
|
||||
+481
-35
@@ -13,6 +13,11 @@
|
||||
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
|
||||
<link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}">
|
||||
<link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}">
|
||||
<!-- My Tasks + page « /db » : lies dans le SHELL et non dans la zone
|
||||
swappée. Un <link> à l'intérieur de `.main-wrapper` peut être retiré par
|
||||
htmx lors d'une navigation partielle : la page revenait alors sans
|
||||
aucune feuille de style (composants blancs en thème sombre). -->
|
||||
<link rel="stylesheet" href="/static/css/my_tasks.css?v={{ asset_version }}">
|
||||
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
|
||||
<style>
|
||||
/* ── Mobile responsive (v4.0.2) ── */
|
||||
@@ -115,7 +120,7 @@
|
||||
</style>
|
||||
{# A20 : htmx copie les <script nonce="{{ csp_nonce() }}"> des réponses boostées — il remet leur nonce
|
||||
depuis cette config (le nonce de la réponse courante, pas celui du fetch). #}
|
||||
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}"}'>
|
||||
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}", "allowEval": false}'>
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
// A38 : helper CSRF unique — défini le plus tôt possible (head) pour
|
||||
// tous les scripts inline/externes de la page (welcome.html, isolé de
|
||||
@@ -126,11 +131,11 @@
|
||||
};
|
||||
</script>
|
||||
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
|
||||
</head>
|
||||
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% endif %}>
|
||||
<div class="app-layout" x-data="appState()">
|
||||
<div class="app-layout" x-data="appState()" x-cloak>
|
||||
|
||||
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
|
||||
<div class="sidebar-overlay"
|
||||
@@ -188,8 +193,8 @@
|
||||
:class="{ collapsed: sidebarCollapsed && !sidebarPeek, 'mobile-open': mobileSidebarOpen, peeking: sidebarPeek }"
|
||||
id="sidebar"
|
||||
@mouseleave="!sidebarResizing && (sidebarPeek = false)"
|
||||
x-effect="document.documentElement.classList.toggle('fd-sidebar-collapsed', sidebarCollapsed && !sidebarPeek)"
|
||||
x-init="$nextTick(() => { if(typeof initTreeSortable==='function') initTreeSortable(); })">
|
||||
x-effect="syncSidebarClass()"
|
||||
x-init="initSidebarSort()"
|
||||
<!-- Header + dropdown wrapper -->
|
||||
<div style="position:relative;">
|
||||
<div class="sidebar-workspace-header" @click="workspaceMenuOpen = !workspaceMenuOpen" @mouseenter="wsHeaderHover=true" @mouseleave="wsHeaderHover=false"
|
||||
@@ -241,24 +246,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation icons row -->
|
||||
<div class="sidebar-nav-row">
|
||||
<a href="{% if local_workspaces and local_workspaces|length > 0 %}/local-workspace?ws={{ local_workspaces[0].id }}{% else %}/workspaces{% endif %}" class="nav-icon-btn home-btn" :class="{ active: currentView === 'home' }" title="Home">
|
||||
<!-- Navigation tabs row — collapsible pills: the active tab expands with
|
||||
its label, the others collapse to their icon. Selecting a tab replaces
|
||||
the whole sidebar content (Home tree / Chat history / Meetings / Inbox). -->
|
||||
<div class="sidebar-nav-row" role="tablist" aria-label="Sidebar sections">
|
||||
<a href="{% if local_ws_id %}/local-workspace?ws={{ local_ws_id }}{% elif local_workspaces and local_workspaces|length > 0 %}/local-workspace?ws={{ local_workspaces[0].id }}{% else %}/workspaces{% endif %}" class="nav-icon-btn home-btn nav-tab-btn" data-tab="home" :class="{ active: sidebarTab === 'home' }" @click="setSidebarTab('home')" :aria-selected="sidebarTab === 'home'" role="tab" title="Home">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
|
||||
Home
|
||||
<span class="nav-tab-label" :style="sidebarTab === 'home' ? 'display:inline-block' : 'display:none'">Home</span>
|
||||
</a>
|
||||
<button class="nav-icon-btn" title="Chat (Ctrl+J)" onclick="window.fdAgent&&window.fdAgent.open()">
|
||||
<button type="button" class="nav-icon-btn nav-tab-btn" data-tab="chat" :class="{ active: sidebarTab === 'chat' }" @click="setSidebarTab('chat')" :aria-selected="sidebarTab === 'chat'" role="tab" title="Chat (Ctrl+J)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||
<span class="nav-tab-label" :style="sidebarTab === 'chat' ? 'display:inline-block' : 'display:none'">Chat</span>
|
||||
<span class="nav-tab-badge" x-show="chatHistory.length" x-cloak x-text="chatHistory.length" :style="sidebarTab === 'chat' ? 'display:none' : ''"></span>
|
||||
</button>
|
||||
<button class="nav-icon-btn" title="Inbox">
|
||||
<button type="button" class="nav-icon-btn nav-tab-btn" data-tab="meetings" :class="{ active: sidebarTab === 'meetings' }" @click="setSidebarTab('meetings')" :aria-selected="sidebarTab === 'meetings'" role="tab" title="Meetings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>
|
||||
<span class="nav-tab-label" :style="sidebarTab === 'meetings' ? 'display:inline-block' : 'display:none'">Meeting</span>
|
||||
<span class="nav-tab-badge" x-show="meetingToday.length" x-cloak x-text="meetingToday.length" :style="sidebarTab === 'meetings' ? 'display:none' : ''"></span>
|
||||
</button>
|
||||
<button type="button" class="nav-icon-btn nav-tab-btn" data-tab="inbox" :class="{ active: sidebarTab === 'inbox' }" @click="setSidebarTab('inbox')" :aria-selected="sidebarTab === 'inbox'" role="tab" title="Inbox">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/></svg>
|
||||
<span class="nav-tab-label" :style="sidebarTab === 'inbox' ? 'display:inline-block' : 'display:none'">Inbox</span>
|
||||
<span class="nav-tab-badge nav-tab-badge-accent" x-show="inboxUnread > 0" x-cloak x-text="inboxUnread > 99 ? '99+' : inboxUnread" :style="sidebarTab === 'inbox' ? 'display:none' : ''"></span>
|
||||
</button>
|
||||
<button class="nav-icon-btn" @click="openQuickFind" title="Search" style="margin-left:auto;">
|
||||
<button type="button" class="nav-icon-btn" @click="openQuickFind" title="Search" style="margin-left:auto;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-scroll">
|
||||
|
||||
<!-- ── TAB: Home (workspace tree, recents, favorites, libraries…) ── -->
|
||||
<div class="sidebar-tab-panel" x-show="sidebarTab === 'home'" x-cloak>
|
||||
<!-- Workspace / Private -->
|
||||
<div class="sidebar-section" x-show="isSectionVisible('workspace')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
||||
<div class="sidebar-section-header" @click="toggleSection('workspace')" @contextmenu.prevent="openSectionMenu($event, 'workspace')">
|
||||
@@ -272,8 +291,8 @@
|
||||
</div>
|
||||
{% if has_active_workspace %}
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" title="New Page" @click.stop="window.FlowDeck.createPage()">{{ fd_icon("file",16) }}</button>
|
||||
<button class="section-action-btn" title="New Folder" @click.stop="window.FlowDeck.showCreateFolderModal()">{{ fd_icon("folder",16) }}</button>
|
||||
<button class="section-action-btn" title="New Page" @click.stop="fdCreatePage()">{{ fd_icon("file",16) }}</button>
|
||||
<button class="section-action-btn" title="New Folder" @click.stop="fdCreateFolder()">{{ fd_icon("folder",16) }}</button>
|
||||
<a class="section-action-btn" href="/local-workspace" title="Open workspace page" style="text-decoration:none;">{{ fd_icon("external-link",16) }}</a>
|
||||
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'workspace')" @contextmenu.stop="openSectionMenu($event, 'workspace')">⋮</button>
|
||||
</div>
|
||||
@@ -308,7 +327,7 @@
|
||||
<span class="section-label">Repository (Gitea)</span>
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" title="Refresh" @click.stop="if(window._gwData)window._gwData.refreshTree()">{{ fd_icon("refresh",16) }}</button>
|
||||
<button class="section-action-btn" title="Refresh" @click.stop="fdGwRefresh()">{{ fd_icon("refresh",16) }}</button>
|
||||
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'gitea')">⋮</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,7 +349,7 @@
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
{% if has_active_workspace %}
|
||||
<button class="section-action-btn" title="Add" @click.stop="window.FlowDeck.createPage()">+</button>
|
||||
<button class="section-action-btn" title="Add" @click.stop="fdCreatePage()">+</button>
|
||||
{% endif %}
|
||||
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'meetings')">⋮</button>
|
||||
</div>
|
||||
@@ -407,7 +426,7 @@
|
||||
<ul class="sidebar-items" data-section="agents">
|
||||
<template x-for="a in agentList" :key="a.id">
|
||||
<li class="sidebar-item" @click="agentOpen(a.id)">
|
||||
<span class="page-icon page-icon-svg" x-html="a.icon || '🤖'"></span>
|
||||
<span class="page-icon page-icon-svg" x-init="bindAgentIcon($el, a)"></span>
|
||||
<span class="page-name" x-text="a.name"></span>
|
||||
</li>
|
||||
</template>
|
||||
@@ -541,12 +560,7 @@
|
||||
<button class="scp-done" @click="toggleCustomize()">Done</button>
|
||||
</div>
|
||||
<div class="scp-list">
|
||||
<template x-for="(cfg, key) in (Object.keys(sidebarConfig).length ? sidebarConfig : {
|
||||
workspace:{visible:true,order:0},teamspaces:{visible:true,order:1},
|
||||
meetings:{visible:true,order:2},recents:{visible:true,order:3},
|
||||
favorites:{visible:true,order:4},agents:{visible:true,order:5},
|
||||
shared:{visible:true,order:6},published:{visible:true,order:7}
|
||||
})" :key="key">
|
||||
<template x-for="(cfg, key) in sidebarSections()" :key="key">
|
||||
<div class="scp-item" @click="toggleSectionVisibility(key)">
|
||||
<div class="scp-item-left">
|
||||
<span class="scp-item-icon" x-text="getSectionIcon(key)"></span>
|
||||
@@ -576,6 +590,123 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div><!-- /sidebar-tab-panel home -->
|
||||
|
||||
<!-- ── TAB: Chat (agent shortcuts + conversation history) ── -->
|
||||
<div class="sidebar-tab-panel" x-show="sidebarTab === 'chat'" x-cloak>
|
||||
<div class="sb-panel-header">
|
||||
<span class="sb-panel-title">Chat</span>
|
||||
<button type="button" class="sb-panel-action" @click="chatNewConversation()" title="New conversation">{{ fd_icon("plus",14) }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Circular agent shortcuts -->
|
||||
<div class="sb-agent-row" x-show="agentList.length">
|
||||
<template x-for="a in agentList.slice(0, 8)" :key="a.id">
|
||||
<button type="button" class="sb-agent-avatar" :title="a.name" @click="agentOpen(a.id)">
|
||||
<span x-text="a.icon || '\u{1F916}'"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-subgroup">
|
||||
<div class="sidebar-subgroup-label">Past 30 days</div>
|
||||
<ul class="sidebar-items">
|
||||
<template x-for="c in chatRecent" :key="c.id">
|
||||
<li class="sidebar-item" @click="chatOpenConversation(c.id)">
|
||||
<span class="page-icon page-icon-svg">{{ fd_icon("message-square",14) }}</span>
|
||||
<span class="page-name" x-text="c.title || 'New conversation'"></span>
|
||||
</li>
|
||||
</template>
|
||||
<li class="sidebar-item empty-hint" x-show="!chatLoading && !chatRecent.length">
|
||||
<span class="page-name text-dim">No recent conversations</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-subgroup" x-show="chatOlder.length">
|
||||
<div class="sidebar-subgroup-label">Older</div>
|
||||
<ul class="sidebar-items">
|
||||
<template x-for="c in chatOlder" :key="c.id">
|
||||
<li class="sidebar-item" @click="chatOpenConversation(c.id)">
|
||||
<span class="page-icon page-icon-svg">{{ fd_icon("message-square",14) }}</span>
|
||||
<span class="page-name" x-text="c.title || 'New conversation'"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-item empty-hint" x-show="chatLoading" style="padding:8px;">
|
||||
<span class="page-name text-dim">Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── TAB: Meeting (upcoming events + quick actions) ── -->
|
||||
<div class="sidebar-tab-panel" x-show="sidebarTab === 'meetings'" x-cloak>
|
||||
<div class="sb-panel-header">
|
||||
<span class="sb-panel-title">Meeting</span>
|
||||
<button type="button" class="sb-panel-action" @click="loadMeetings(true)" title="Refresh">{{ fd_icon("refresh",14) }}</button>
|
||||
</div>
|
||||
|
||||
<div class="sb-filter-row">
|
||||
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'upcoming' }" @click="meetingFilter = 'upcoming'">Upcoming</button>
|
||||
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'today' }" @click="meetingFilter = 'today'">Today</button>
|
||||
</div>
|
||||
|
||||
<div class="sb-quick-actions">
|
||||
<button type="button" class="sb-quick-btn" @click="meetingTranscribe()">{{ fd_icon("upload",14) }} Transcribe a podcast</button>
|
||||
<button type="button" class="sb-quick-btn" @click="meetingNote()">{{ fd_icon("sparkles",14) }} New AI meeting note</button>
|
||||
<input type="file" x-ref="meetingAudio" accept="audio/*" class="sb-hidden-input" @change="meetingUpload($event)">
|
||||
</div>
|
||||
|
||||
<ul class="sidebar-items">
|
||||
<template x-for="ev in meetingVisible" :key="ev.collection_id + '-' + ev.id">
|
||||
<li class="sidebar-item" @click="meetingOpen(ev)">
|
||||
<span class="page-icon page-icon-svg">{{ fd_icon("calendar",14) }}</span>
|
||||
<span class="page-name" x-text="ev.title"></span>
|
||||
<span class="sb-item-meta" x-text="ev.today ? 'Today' : ev.date"></span>
|
||||
</li>
|
||||
</template>
|
||||
<li class="sidebar-item empty-hint" x-show="!meetingsLoading && !meetingVisible.length">
|
||||
<span class="page-name text-dim" x-text="meetingFilter === 'today' ? 'Nothing scheduled today' : 'No upcoming meetings'"></span>
|
||||
</li>
|
||||
<li class="sidebar-item empty-hint" x-show="meetingsLoading">
|
||||
<span class="page-name text-dim">Loading…</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- ── TAB: Inbox (notifications) ── -->
|
||||
<div class="sidebar-tab-panel" x-show="sidebarTab === 'inbox'" x-cloak>
|
||||
<div class="sb-panel-header">
|
||||
<span class="sb-panel-title">Inbox <span class="sb-panel-count" x-show="inboxUnread > 0" x-text="inboxUnread"></span></span>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button type="button" class="sb-panel-action" x-show="inboxUnread > 0" @click="inboxMarkAllRead()" title="Mark all read">{{ fd_icon("check-square",14) }}</button>
|
||||
<button type="button" class="sb-panel-action" @click="inboxFilter = (inboxFilter === 'all' ? 'unread' : 'all')"
|
||||
:title="inboxFilter === 'all' ? 'Show unread only' : 'Show all'" x-text="inboxFilter === 'all' ? 'All' : 'Unread'"></button>
|
||||
<button type="button" class="sb-panel-action" @click="loadInbox()" title="Refresh">{{ fd_icon("refresh",14) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="sidebar-items">
|
||||
<template x-for="n in inboxVisible" :key="n.id">
|
||||
<li class="sidebar-item" :class="{ 'sb-item-unread': !n.is_read }" @click="inboxOpen(n)">
|
||||
<span class="page-icon page-icon-svg">{{ fd_icon("bell",14) }}</span>
|
||||
<span style="flex:1;min-width:0;">
|
||||
<span class="page-name" style="display:block;" x-text="n.title"></span>
|
||||
<span class="sb-item-sub" x-text="n.message"></span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
<li class="sidebar-item empty-hint" x-show="!inboxLoading && !inboxVisible.length" style="flex-direction:column;align-items:center;gap:4px;padding:22px 8px;">
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-secondary);">Inbox Zero</span>
|
||||
<span class="page-name text-dim">You’re all caught up</span>
|
||||
</li>
|
||||
<li class="sidebar-item empty-hint" x-show="inboxLoading">
|
||||
<span class="page-name text-dim">Loading…</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="sidebar-footer">
|
||||
<div style="display:flex; gap:6px;">
|
||||
@@ -595,40 +726,40 @@
|
||||
<!-- Section options menu (⋮ dropdown) -->
|
||||
<div class="section-menu-overlay" x-show="sectionMenu.visible" @click="closeSectionMenu()" @contextmenu.prevent="closeSectionMenu()"></div>
|
||||
<div class="section-menu" x-show="sectionMenu.visible" x-cloak
|
||||
:style="{ top: sectionMenu.y + 'px', left: Math.min(sectionMenu.x, window.innerWidth - 220) + 'px' }"
|
||||
:style="sectionMenuPos()"
|
||||
@click.outside="closeSectionMenu()">
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 5)">
|
||||
<div class="section-menu-item" @click="closeAndSetCount(5)">
|
||||
<span class="smi-label">Show 5 items</span>
|
||||
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 5">✓</span>
|
||||
</div>
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 10)">
|
||||
<div class="section-menu-item" @click="closeAndSetCount(10)">
|
||||
<span class="smi-label">Show 10 items</span>
|
||||
<span class="smi-check" x-show="!sidebarConfig[sectionMenu.section] || sidebarConfig[sectionMenu.section].show_count === 10 || sidebarConfig[sectionMenu.section].show_count == null">✓</span>
|
||||
</div>
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 15)">
|
||||
<div class="section-menu-item" @click="closeAndSetCount(15)">
|
||||
<span class="smi-label">Show 15 items</span>
|
||||
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 15">✓</span>
|
||||
</div>
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 20)">
|
||||
<div class="section-menu-item" @click="closeAndSetCount(20)">
|
||||
<span class="smi-label">Show 20 items</span>
|
||||
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 20">✓</span>
|
||||
</div>
|
||||
<div class="section-menu-sep"></div>
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'up')">
|
||||
<div class="section-menu-item" @click="closeAndMove('up')">
|
||||
<span class="smi-icon">↑</span>
|
||||
<span class="smi-label">Move up</span>
|
||||
</div>
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'down')">
|
||||
<div class="section-menu-item" @click="closeAndMove('down')">
|
||||
<span class="smi-icon">↓</span>
|
||||
<span class="smi-label">Move down</span>
|
||||
</div>
|
||||
<div class="section-menu-sep"></div>
|
||||
<div class="section-menu-item" @click="closeSectionMenu(); toggleSectionVisibility(sectionMenu.section)">
|
||||
<div class="section-menu-item" @click="closeAndToggleVisibility()">
|
||||
<span class="smi-icon">👁</span>
|
||||
<span class="smi-label" x-text="isSectionVisible(sectionMenu.section) ? 'Hide section' : 'Show section'"></span>
|
||||
</div>
|
||||
<div class="section-menu-sep"></div>
|
||||
<div class="section-menu-item section-menu-customize" @click="closeSectionMenu(); toggleCustomize()">
|
||||
<div class="section-menu-item section-menu-customize" @click="menuCustomize()">
|
||||
<span class="smi-icon">⚙️</span>
|
||||
<span class="smi-label">Customize sidebar</span>
|
||||
</div>
|
||||
@@ -1127,6 +1258,21 @@
|
||||
}
|
||||
};
|
||||
|
||||
// A20 phase 3 : le build CSP ne résout que le registre Alpine.data
|
||||
// (probe : globale window → « Undefined variable: appState »).
|
||||
document.addEventListener('alpine:init', function () {
|
||||
Alpine.data('appState', appState);
|
||||
});
|
||||
|
||||
/** Parse a SQL/ISO timestamp ("2026-10-04 12:00:00") into a ms epoch.
|
||||
Shared by the sidebar Chat tab (grouping by recency). */
|
||||
window.fdTime = function(value) {
|
||||
if (!value) return 0;
|
||||
var t = new Date(String(value).replace(' ', 'T'));
|
||||
if (isNaN(t.getTime())) t = new Date(value);
|
||||
return isNaN(t.getTime()) ? 0 : t.getTime();
|
||||
};
|
||||
|
||||
function appState() {
|
||||
return {
|
||||
init() {
|
||||
@@ -1134,6 +1280,8 @@
|
||||
this.loadSidebarConfig();
|
||||
this.loadAgents();
|
||||
this.loadTeamspaces();
|
||||
// Badge counts on the collapsed tabs (lazy fetch, no blocking).
|
||||
this.loadInbox();
|
||||
this.initSidebarWidth();
|
||||
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
|
||||
this.startClipAutoRefresh();
|
||||
@@ -1239,6 +1387,176 @@
|
||||
giteaRepo: '{{ gitea_repo|default("") }}',
|
||||
giteaTree: [], // loaded client-side for sidebar
|
||||
currentView: 'home',
|
||||
|
||||
// ── Collapsible sidebar tabs (Home / Chat / Meeting / Inbox) ──
|
||||
// The active tab renders as a pill with its label, the others collapse
|
||||
// to an icon; selecting one replaces the whole sidebar content.
|
||||
sidebarTab: (function() {
|
||||
try {
|
||||
var saved = localStorage.getItem('fd_sidebar_tab');
|
||||
return ['home', 'chat', 'meetings', 'inbox'].indexOf(saved) >= 0 ? saved : 'home';
|
||||
} catch (e) { return 'home'; }
|
||||
})(),
|
||||
setSidebarTab(tab) {
|
||||
this.sidebarTab = tab;
|
||||
this.currentView = tab;
|
||||
try { localStorage.setItem('fd_sidebar_tab', tab); } catch (e) {}
|
||||
if (tab === 'chat') this.loadChatHistory();
|
||||
else if (tab === 'meetings') this.loadMeetings();
|
||||
else if (tab === 'inbox') this.loadInbox();
|
||||
},
|
||||
|
||||
// ── Chat tab ──
|
||||
chatHistory: [],
|
||||
chatLoading: false,
|
||||
get chatRecent() {
|
||||
var cut = Date.now() - 30 * 86400000;
|
||||
return this.chatHistory.filter(function(c) { return fdTime(c.updated_at) >= cut; });
|
||||
},
|
||||
get chatOlder() {
|
||||
var cut = Date.now() - 30 * 86400000;
|
||||
return this.chatHistory.filter(function(c) { return fdTime(c.updated_at) < cut; });
|
||||
},
|
||||
loadChatHistory() {
|
||||
var self = this;
|
||||
this.chatLoading = true;
|
||||
return fetch('/api/agent/conversations', { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
self.chatHistory = (d && d.conversations) || [];
|
||||
self.chatLoading = false;
|
||||
})
|
||||
.catch(function() { self.chatLoading = false; });
|
||||
},
|
||||
chatOpenConversation(id) {
|
||||
if (window.fdAgent && window.fdAgent.openConversation) window.fdAgent.openConversation(id);
|
||||
else this.agentOpen();
|
||||
},
|
||||
chatNewConversation() {
|
||||
if (window.fdAgent && window.fdAgent.open) window.fdAgent.open();
|
||||
else this.agentNew();
|
||||
},
|
||||
|
||||
// ── Meeting tab ──
|
||||
meetings: [],
|
||||
meetingsLoading: false,
|
||||
meetingFilter: 'upcoming',
|
||||
get meetingVisible() {
|
||||
if (this.meetingFilter === 'today') {
|
||||
return this.meetings.filter(function(e) { return e.today; });
|
||||
}
|
||||
return this.meetings.filter(function(e) { return !e.today; });
|
||||
},
|
||||
get meetingToday() { return this.meetings.filter(function(e) { return e.today; }); },
|
||||
loadMeetings(force) {
|
||||
var self = this;
|
||||
if (!force && this.meetings.length) return Promise.resolve();
|
||||
this.meetingsLoading = true;
|
||||
return fetch('/api/v2/meetings/upcoming?days=30', { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.ok ? r.json() : { events: [] }; })
|
||||
.then(function(d) {
|
||||
self.meetings = (d && d.events) || [];
|
||||
self.meetingsLoading = false;
|
||||
})
|
||||
.catch(function() { self.meetings = []; self.meetingsLoading = false; });
|
||||
},
|
||||
meetingOpen(ev) {
|
||||
var target = ev.url || ('/db/' + ev.collection_id);
|
||||
if (window.fdNavigate) window.fdNavigate(target);
|
||||
else window.location.href = target;
|
||||
},
|
||||
meetingTranscribe() {
|
||||
var input = this.$refs && this.$refs.meetingAudio;
|
||||
if (input) input.click();
|
||||
},
|
||||
meetingNote() {
|
||||
if (window.fdAgent && window.fdAgent.ask) {
|
||||
window.fdAgent.ask('/meeting-note');
|
||||
} else if (window.fdAgent && window.fdAgent.open) {
|
||||
window.fdAgent.open();
|
||||
}
|
||||
},
|
||||
meetingUpload(ev) {
|
||||
var self = this;
|
||||
var input = ev.target;
|
||||
var file = input && input.files && input.files[0];
|
||||
if (!file) return;
|
||||
var title = 'Podcast ' + new Date().toISOString().slice(0, 10);
|
||||
// A transcript row is attached to a page: create the meeting page first,
|
||||
// then upload the audio against it.
|
||||
fetch('/api/local-workspace/items', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||
body: JSON.stringify({ name: title, type: 'page' })
|
||||
})
|
||||
.then(function(r) { if (!r.ok) throw new Error('page'); return r.json(); })
|
||||
.then(function(page) {
|
||||
var fd = new FormData();
|
||||
fd.append('audio', file);
|
||||
fd.append('page_id', String(page.id));
|
||||
return fetch('/api/v2/meetings/transcribe', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-CSRF-Token': self.getCsrfToken() },
|
||||
body: fd
|
||||
}).then(function(r) {
|
||||
if (!r.ok) throw new Error('transcribe');
|
||||
return r.json();
|
||||
}).then(function() { return page; });
|
||||
})
|
||||
.then(function(page) {
|
||||
self.toast('Audio uploaded — transcription attached to "' + title + '"', 'success');
|
||||
window.location.href = '/pages/' + page.id;
|
||||
})
|
||||
.catch(function() { self.toast('Upload failed', 'error'); })
|
||||
.then(function() { if (input) input.value = ''; });
|
||||
},
|
||||
|
||||
// ── Inbox tab ──
|
||||
inboxItems: [],
|
||||
inboxUnread: 0,
|
||||
inboxLoading: false,
|
||||
inboxFilter: 'all',
|
||||
get inboxVisible() {
|
||||
if (this.inboxFilter === 'unread') {
|
||||
return this.inboxItems.filter(function(n) { return !n.is_read; });
|
||||
}
|
||||
return this.inboxItems;
|
||||
},
|
||||
loadInbox() {
|
||||
var self = this;
|
||||
this.inboxLoading = true;
|
||||
return fetch('/api/notifications?limit=50', { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.ok ? r.json() : { notifications: [], unread: 0 }; })
|
||||
.then(function(d) {
|
||||
self.inboxItems = (d && d.notifications) || [];
|
||||
self.inboxUnread = (d && d.unread) || 0;
|
||||
self.inboxLoading = false;
|
||||
})
|
||||
.catch(function() { self.inboxLoading = false; });
|
||||
},
|
||||
inboxOpen(n) {
|
||||
if (!n.is_read) {
|
||||
var self = this;
|
||||
fetch('/api/notifications/read', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||
body: JSON.stringify({ id: n.id })
|
||||
}).then(function() { return self.loadInbox(); });
|
||||
}
|
||||
if (n.url) window.location.href = n.url;
|
||||
},
|
||||
inboxMarkAllRead() {
|
||||
var self = this;
|
||||
fetch('/api/notifications/read', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||
body: JSON.stringify({})
|
||||
}).then(function() { return self.loadInbox(); });
|
||||
},
|
||||
contextMenu: { visible: false, x: 0, y: 0, pageId: null, pageName: '' },
|
||||
contextActions: {},
|
||||
showNewPageMenu: false,
|
||||
@@ -1462,6 +1780,10 @@
|
||||
this.sectionMenu = { section: section, visible: true, x: ev.clientX, y: ev.clientY };
|
||||
},
|
||||
|
||||
menuCustomize() { this.closeSectionMenu(); this.toggleCustomize(); },
|
||||
closeAndSetCount(n) { this.closeSectionMenu(); this.setShowCount(this.sectionMenu.section, n); },
|
||||
closeAndMove(dir) { this.closeSectionMenu(); this.moveSection(this.sectionMenu.section, dir); },
|
||||
closeAndToggleVisibility() { this.closeSectionMenu(); this.toggleSectionVisibility(this.sectionMenu.section); },
|
||||
closeSectionMenu() {
|
||||
this.sectionMenu.visible = false;
|
||||
},
|
||||
@@ -1568,6 +1890,55 @@
|
||||
else window.location.href = url;
|
||||
},
|
||||
|
||||
// ── A20 phase 3 : expressions → méthode (build CSP : appel seul ;
|
||||
// document/Math/window/FlowsDeck = JS réel, hors évaluateur) ──
|
||||
bindProjectIcon(el, p) {
|
||||
Alpine.effect(() => { el.innerHTML = getSvgIcon(p.icon || 'folder', 20); });
|
||||
},
|
||||
// ── A20 ph3 : délégués du topbar éditeur (window.E hors portée CSP,
|
||||
// scope du topbar = appState) — voir right_actions de page_editor ──
|
||||
edCall(name) {
|
||||
if (window.E && typeof window.E[name] === 'function') window.E[name]();
|
||||
},
|
||||
edTimeAgo() { return (window.E && window.E.timeAgo) || ''; },
|
||||
edCommentCount() {
|
||||
return (window.E && window.E.commentCount > 0) ? window.E.commentCount : '';
|
||||
},
|
||||
edShared() { return !!(window.E && window.E.pageIsShared); },
|
||||
// les 2 branches du ternaire favorited étaient identiques → rendu 1×
|
||||
bindStar(el) { Alpine.effect(() => { el.innerHTML = getSvgIcon('star', 14); }); },
|
||||
|
||||
bindAgentIcon(el, a) {
|
||||
Alpine.effect(() => { el.innerHTML = a.icon || '🤖'; });
|
||||
},
|
||||
syncSidebarClass() {
|
||||
document.documentElement.classList.toggle(
|
||||
'fd-sidebar-collapsed', this.sidebarCollapsed && !this.sidebarPeek);
|
||||
},
|
||||
initSidebarSort() {
|
||||
Alpine.nextTick(() => {
|
||||
if (typeof initTreeSortable === 'function') initTreeSortable();
|
||||
});
|
||||
},
|
||||
fdCreatePage() { window.FlowDeck.createPage(); },
|
||||
fdCreateFolder() { window.FlowDeck.showCreateFolderModal(); },
|
||||
fdGwRefresh() { if (window._gwData) window._gwData.refreshTree(); },
|
||||
sidebarSections() {
|
||||
if (Object.keys(this.sidebarConfig).length) return this.sidebarConfig;
|
||||
return {
|
||||
workspace: { visible: true, order: 0 }, teamspaces: { visible: true, order: 1 },
|
||||
meetings: { visible: true, order: 2 }, recents: { visible: true, order: 3 },
|
||||
favorites: { visible: true, order: 4 }, agents: { visible: true, order: 5 },
|
||||
shared: { visible: true, order: 6 }, published: { visible: true, order: 7 },
|
||||
};
|
||||
},
|
||||
sectionMenuPos() {
|
||||
return {
|
||||
top: this.sectionMenu.y + 'px',
|
||||
left: Math.min(this.sectionMenu.x, window.innerWidth - 220) + 'px',
|
||||
};
|
||||
},
|
||||
|
||||
toggleSidebar() {
|
||||
this.sidebarPeek = false;
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
@@ -1722,9 +2093,16 @@
|
||||
case 'openTab':
|
||||
window.open('/' + pageId, '_blank');
|
||||
break;
|
||||
case 'openPeek':
|
||||
this.toast('Side peek not available in browser', 'info');
|
||||
case 'openPeek': {
|
||||
// Route vers le panneau de peek DE LA PAGE courante (même
|
||||
// éditeur allégé `?embed=1` que le bouton « ↢ Open »), jamais une
|
||||
// navigation. En dehors de ces pages : repli sur un nouvel onglet.
|
||||
var num = pageId.replace('page/', '');
|
||||
if (window._libData && window._libData.openPeekById) window._libData.openPeekById(parseInt(num));
|
||||
else if (window._wsData && window._wsData.openSidePeek) window._wsData.openSidePeek(parseInt(num));
|
||||
else window.open('/pages/' + num, '_blank');
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.contextMenu.visible = false;
|
||||
},
|
||||
@@ -2184,6 +2562,14 @@
|
||||
.cmd-palette-overlay.open .cmd-palette{transform:translateY(0)}
|
||||
.cmd-palette-input{width:100%;box-sizing:border-box;padding:16px 18px;background:transparent;border:none;outline:none;color:var(--text,#fff);font-size:16px}
|
||||
.cmd-palette-input::placeholder{color:var(--text-dim,rgba(255,255,255,.4))}
|
||||
.cmd-palette-tabs{display:flex;gap:6px;padding:10px 16px 0}
|
||||
.cp-tab{background:none;border:1px solid var(--border,rgba(255,255,255,.12));color:var(--text-dim,rgba(255,255,255,.55));border-radius:999px;padding:4px 12px;font-size:12px;cursor:pointer}
|
||||
.cp-tab.active{background:var(--bg-hover,#2a2a2a);color:var(--text,#eee);border-color:var(--accent,#2383e2)}
|
||||
.cmd-palette-ai{padding:14px 18px;font-size:13.5px;line-height:1.55;white-space:pre-wrap;color:var(--text,#eee)}
|
||||
.cmd-palette-ai .cp-cites{margin-top:10px;display:flex;flex-direction:column;gap:4px;font-size:12.5px;white-space:normal}
|
||||
a.cp-cite{color:var(--accent,#529ffa);text-decoration:none}
|
||||
a.cp-cite:hover{text-decoration:underline}
|
||||
.cp-loading{color:var(--text-dim,rgba(255,255,255,.5))}
|
||||
.cmd-palette-list{overflow-y:auto;border-top:1px solid var(--border,rgba(255,255,255,.06))}
|
||||
.cmd-palette-group{display:flex;align-items:center;gap:8px;padding:10px 18px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-dim,rgba(255,255,255,.4))}
|
||||
.cmd-palette-item{display:flex;align-items:center;gap:10px;padding:9px 18px;cursor:pointer;font-size:14px;color:var(--text,#fff)}
|
||||
@@ -2205,6 +2591,10 @@
|
||||
<input id="fd-cp-input" class="cmd-palette-input" type="text"
|
||||
placeholder="Search pages, databases, or type a command…"
|
||||
autocomplete="off" spellcheck="false">
|
||||
<div class="cmd-palette-tabs" id="fd-cp-tabs" role="tablist">
|
||||
<button type="button" class="cp-tab active" data-tab="pages">Pages</button>
|
||||
<button type="button" class="cp-tab" data-tab="ai">✨ Réponses IA</button>
|
||||
</div>
|
||||
<div id="fd-cp-list" class="cmd-palette-list"></div>
|
||||
<div class="cmd-palette-footer">
|
||||
<span class="cpf-kbd"><b>↑</b> / <b>↓</b> navigate</span>
|
||||
@@ -2225,7 +2615,7 @@
|
||||
];
|
||||
|
||||
var overlay, input, list;
|
||||
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false };
|
||||
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false, tab:'pages', ai:null };
|
||||
|
||||
function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); }
|
||||
function highlight(text, q){
|
||||
@@ -2249,6 +2639,7 @@
|
||||
}
|
||||
|
||||
function render(){
|
||||
if(state.tab==='ai'){ renderAi(); return; }
|
||||
if(!state.open) return;
|
||||
if(!state.items.length){
|
||||
list.innerHTML = '<div class="cmd-palette-empty">No results for “'+esc(state.query)+'”</div>';
|
||||
@@ -2285,6 +2676,7 @@
|
||||
}
|
||||
|
||||
function runSearch(){
|
||||
if(state.tab==='ai'){ runAi(); return; }
|
||||
var q = input.value.trim();
|
||||
state.query = q;
|
||||
state.index = 0;
|
||||
@@ -2309,6 +2701,50 @@
|
||||
.catch(function(){ state.items=[]; render(); });
|
||||
}
|
||||
|
||||
function runAi(){
|
||||
var q = input.value.trim();
|
||||
state.ai = null;
|
||||
if(!q){ state.ai = {hint:1}; renderAi(); return; }
|
||||
state.ai = {loading:1}; renderAi();
|
||||
fetch('/api/v2/search/ask', {
|
||||
method:'POST',
|
||||
headers: {'Content-Type':'application/json', 'X-CSRF-Token': getCsrf()},
|
||||
body: JSON.stringify({question: q})
|
||||
}).then(function(r){ if(!r.ok) throw 0; return r.json(); })
|
||||
.then(function(d){
|
||||
if(state.tab!=='ai' || input.value.trim()!==q) return;
|
||||
state.ai = d; renderAi();
|
||||
})
|
||||
.catch(function(){
|
||||
if(state.tab!=='ai') return;
|
||||
state.ai = {error:1}; renderAi();
|
||||
});
|
||||
}
|
||||
function renderAi(){
|
||||
var a = state.ai;
|
||||
if(!a){ list.innerHTML=''; return; }
|
||||
if(a.loading){ list.innerHTML='<div class="cmd-palette-ai"><span class="cp-loading">Recherche de la réponse…</span></div>'; return; }
|
||||
if(a.error){ list.innerHTML='<div class="cmd-palette-ai">Demande impossible — réessayez.</div>'; return; }
|
||||
if(a.hint){ list.innerHTML='<div class="cmd-palette-ai">Posez une question sur votre espace : la réponse cite vos pages.</div>'; return; }
|
||||
var cits = a.citations || [], byId = {};
|
||||
cits.forEach(function(c){ byId[String(c.id)] = c; });
|
||||
// échappement AVANT injection des liens (ids = chiffres, type = [a-z]+)
|
||||
var md = esc(a.answer_markdown || '')
|
||||
.replace(/\[\[([a-z]+):(\d+)\]\]/g, function(m, type, id){
|
||||
var c = byId[id];
|
||||
var label = esc((c && (c.title || c.name)) || (type + ' #' + id));
|
||||
var href = type === 'collection' ? '/db/' + id : '/pages/' + id;
|
||||
return '<a class="cp-cite" href="' + href + '">' + label + '</a>';
|
||||
})
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
|
||||
var cites = cits.map(function(c){
|
||||
var href = c.type === 'collection' ? '/db/' + c.id : '/pages/' + c.id;
|
||||
return '<a class="cp-cite" href="' + href + '">' + esc(c.title || c.name || 'page #' + c.id) + '</a>';
|
||||
}).join('');
|
||||
list.innerHTML = '<div class="cmd-palette-ai">' + md +
|
||||
(cites ? '<div class="cp-cites"><b>Sources</b> ' + cites + '</div>' : '') + '</div>';
|
||||
}
|
||||
|
||||
function open(){
|
||||
if(state.open) return;
|
||||
state.open=true; overlay.classList.add('open');
|
||||
@@ -2363,6 +2799,16 @@
|
||||
list = document.getElementById('fd-cp-list');
|
||||
if(!overlay) return;
|
||||
input.addEventListener('input', function(){ clearTimeout(state.timer); state.timer=setTimeout(runSearch, 150); });
|
||||
var tabs = document.getElementById('fd-cp-tabs');
|
||||
if(tabs) tabs.addEventListener('click', function(e){
|
||||
var b = e.target && e.target.closest ? e.target.closest('.cp-tab') : null;
|
||||
if(!b) return;
|
||||
state.tab = b.getAttribute('data-tab');
|
||||
var all = tabs.querySelectorAll('.cp-tab');
|
||||
for(var i=0;i<all.length;i++) all[i].classList.toggle('active', all[i]===b);
|
||||
state.index = 0;
|
||||
runSearch();
|
||||
});
|
||||
document.addEventListener('keydown', onKey);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<template x-for="(f, i) in activeFilters" :key="i">
|
||||
<div class="filter-pill">
|
||||
<span x-text="f.property + ': ' + f.value"></span>
|
||||
<button class="remove-filter" @click="removeFilter(i); refreshView()">✕</button>
|
||||
<button class="remove-filter" @click="removeFilterAndRefresh(i)">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="filter-pill" style="cursor:pointer; position:relative;" @click="showStatusMenu = !showStatusMenu">
|
||||
@@ -84,7 +84,7 @@
|
||||
<div class="dropdown-panel" :class="{ visible: showStatusMenu }" style="top:100%; left:0;"
|
||||
@click.stop>
|
||||
<template x-for="s in statusOptions" :key="s.value">
|
||||
<div class="dropdown-option" @click="toggleStatus(s.value); showStatusMenu = false; refreshView()">
|
||||
<div class="dropdown-option" @click="pickStatus(s.value)">
|
||||
<span class="option-checkbox" :class="{ selected: statusFilters.includes(s.value) }">
|
||||
<span x-show="statusFilters.includes(s.value)">✓</span>
|
||||
</span>
|
||||
@@ -95,7 +95,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<button class="filter-add-btn" @click="addFilter()">+ Filter</button>
|
||||
<button class="filter-reset-btn" @click="resetFilters(); refreshView()" x-show="activeFilters.length > 0">Reset</button>
|
||||
<button class="filter-reset-btn" @click="resetFiltersAndRefresh()" x-show="activeFilters.length > 0">Reset</button>
|
||||
|
||||
<div class="view-toolbar-spacer"></div>
|
||||
|
||||
|
||||
@@ -95,11 +95,17 @@
|
||||
style="width:100%; min-height:60px; background:var(--bg-secondary); border:1px solid var(--border);
|
||||
border-radius:6px; padding:8px; color:var(--text-primary); font-family:inherit; font-size:13px;
|
||||
resize:vertical; margin-top:8px;"
|
||||
@keydown.ctrl.enter="addComment($event.target.value); $event.target.value=''"></textarea>
|
||||
@keydown.ctrl.enter="addCommentAndClear($event.target)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: cardDetail »).
|
||||
|
||||
if (window.Alpine) { Alpine.data('cardDetail', cardDetail); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); });
|
||||
function cardDetail() {
|
||||
return {
|
||||
updateField(field, value) {
|
||||
@@ -133,6 +139,7 @@
|
||||
})
|
||||
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
|
||||
},
|
||||
addCommentAndClear(el) { this.addComment(el.value); el.value = ''; },
|
||||
addComment(body) {
|
||||
if (!body.trim()) return;
|
||||
console.log('Add comment:', body);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block topbar %}
|
||||
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
|
||||
{% set page_icon = "link" %}
|
||||
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">' ~ fd_icon("file",14) ~ '+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">' ~ fd_icon("upload",14) ~ '</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">' ~ fd_icon("refresh",14) ~ '</button><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
||||
{% set right_actions %}<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">{{ fd_icon("file",14) }}+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">{{ fd_icon("upload",14) }}</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">{{ fd_icon("refresh",14) }}</button><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</style>
|
||||
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
|
||||
<div class="gw-main" x-data="giteaWorkspace">
|
||||
<div class="gw-main" x-data="giteaWorkspace()">
|
||||
<!-- File view -->
|
||||
<div x-show="showFile && !showEditor" x-transition>
|
||||
<div class="gw-file-toolbar">
|
||||
@@ -120,7 +120,7 @@
|
||||
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
|
||||
@contextmenu.prevent="openGwContext($event, item)"
|
||||
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
|
||||
<span x-html="item.type==='folder' ? getSvgIcon('folder',16) : getSvgIcon('file',16)" style="font-size:16px;"></span>
|
||||
<span x-init="bindGwIcon($el, item)" style="font-size:16px;"></span>
|
||||
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
|
||||
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
|
||||
</div>
|
||||
@@ -165,7 +165,7 @@
|
||||
@click="openPrivate(pp.id)">
|
||||
<div>
|
||||
<div style="font-weight:500;" x-text="pp.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);" x-text="fmtGwDate(pp)"></div>
|
||||
</div>
|
||||
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">{{ fd_icon('trash',14) }}</button>
|
||||
</div>
|
||||
@@ -180,7 +180,7 @@
|
||||
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" @click="savePrivate()">{{ fd_icon("save",14) }} Save</button>
|
||||
<button class="btn" @click="editingPrivate=null;editingPrivateTitle='';editingPrivateContent='';">Cancel</button>
|
||||
<button class="btn" @click="closePrivateEdit()">Cancel</button>
|
||||
</div>
|
||||
<div class="gw-content">
|
||||
<input x-model="editingPrivateTitle" placeholder="Page title" style="width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:16px;margin-bottom:12px;outline:none;">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Importer — FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<style>
|
||||
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;--warn:#D9730D;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
@@ -201,7 +201,7 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
|
||||
<template x-for="(e,i) in (report ? report.errors : [])" :key="i"><div x-text="'✕ '+e.title+' : '+e.error"></div></template>
|
||||
</div>
|
||||
<div class="warnings" x-show="report && report.relations && report.relations.relations_resolved">
|
||||
<div x-text="'🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)'"></div>
|
||||
<div x-text="report && report.relations ? '🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)' : ''"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost" @click="downloadReport()">Télécharger le rapport (JSON)</button>
|
||||
|
||||
+20
-14
@@ -18,7 +18,7 @@
|
||||
|
||||
<style>
|
||||
/* ── Library layout ── */
|
||||
.lib-container{max-width:1100px;margin:0 auto;padding:20px 24px;}
|
||||
/* .lib-container est défini dans app.css (largeur standard des pages) */
|
||||
.lib-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;}
|
||||
.lib-header h1{font-size:24px;font-weight:700;color:var(--text-primary);margin:0;}
|
||||
.lib-header-actions{display:flex;align-items:center;gap:8px;}
|
||||
@@ -233,7 +233,7 @@
|
||||
</div>
|
||||
<div class="lib-toolbar">
|
||||
<div class="lib-toolbar-wrap" x-show="tab !== 'repository'">
|
||||
<button class="lib-icon-btn" :class="{active: filterOpen}" title="Filter" @click.stop="filterOpen=!filterOpen; sortOpen=false; viewOpen=false">
|
||||
<button class="lib-icon-btn" :class="{active: filterOpen}" title="Filter" @click.stop="toggleFilterMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
||||
</button>
|
||||
<div class="lib-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition>
|
||||
@@ -245,7 +245,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-toolbar-wrap">
|
||||
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; filterOpen=false; viewOpen=false">
|
||||
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSortMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
|
||||
</button>
|
||||
<div class="lib-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition>
|
||||
@@ -255,11 +255,11 @@
|
||||
<div class="dd-item" :class="{active: sortBy==='author'}" @click="setSort('author')"><span class="check" x-text="sortBy==='author' ? '✓' : ''"></span> Created by</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="lib-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="searchOpen=!searchOpen; $nextTick(()=>{ if(searchOpen) document.getElementById('lib-search-input').focus(); })">
|
||||
<button class="lib-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
<div class="lib-toolbar-wrap">
|
||||
<button class="lib-icon-btn" :class="{active: viewOpen}" title="View options" @click.stop="viewOpen=!viewOpen; filterOpen=false; sortOpen=false">
|
||||
<button class="lib-icon-btn" :class="{active: viewOpen}" title="View options" @click.stop="toggleViewMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="9" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="9" cy="18" r="1.5" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<div class="lib-dropdown" x-show="viewOpen" @click.outside="viewOpen=false" x-transition>
|
||||
@@ -310,7 +310,7 @@
|
||||
<button @click="copyLinks()" title="Copy links">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
</button>
|
||||
<button @click="openMovePicker(Object.keys(selected).map(Number))" title="Move to">
|
||||
<button @click="openMoveSelected()" title="Move to">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||
</button>
|
||||
<div style="position:relative;margin-left:auto;">
|
||||
@@ -326,7 +326,7 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
Copy links to all
|
||||
</div>
|
||||
<div class="menu-item" @click="openMovePicker(Object.keys(selected).map(Number))">
|
||||
<div class="menu-item" @click="openMoveSelected()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||
Move to
|
||||
</div>
|
||||
@@ -378,7 +378,7 @@
|
||||
|
||||
<!-- ── Empty state ── -->
|
||||
<div class="lib-empty" id="lib-empty">
|
||||
<div class="empty-icon" x-html="getSvgIcon(emptyIcon,48)"></div>
|
||||
<div class="empty-icon" x-init="bindHtmlIcon($el)"></div>
|
||||
<h3 x-text="emptyTitle"></h3>
|
||||
<p x-text="emptyText"></p>
|
||||
</div>
|
||||
@@ -397,7 +397,7 @@
|
||||
<div class="move-modal-item" @click="confirmMove(0)">{{ fd_icon("file",14) }} <span>Root (no parent)</span></div>
|
||||
<template x-for="it in moveCandidates" :key="it.id">
|
||||
<div class="move-modal-item" @click="confirmMove(it.id)">
|
||||
<span x-html="_renderIcon(it)"></span> <span x-text="it.title"></span>
|
||||
<span x-init="bindHtmlItem($el, it)"></span> <span x-text="it.title"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -408,13 +408,13 @@
|
||||
<div class="peek-overlay" :class="{ open: peekItem !== null }" @click.outside="closePeek()">
|
||||
<div id="lib-peek-resize-handle" style="position:absolute;left:-4px;top:0;width:8px;height:100%;cursor:col-resize;z-index:501;" title="Drag to resize — click to close"></div>
|
||||
<template x-if="peekItem">
|
||||
<div style="display:flex;flex-direction:column;height:100%;">
|
||||
<div style="flex-shrink:0;">
|
||||
<div class="peek-header">
|
||||
<button @click="closePeek()" title="Close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="peek-title">
|
||||
<span x-html="_renderIcon(peekItem)"></span>
|
||||
<span x-init="bindHtmlItem($el, peekItem)"></span>
|
||||
<span x-text="peekItem.title"></span>
|
||||
</div>
|
||||
<button @click="openItem(peekItem)" title="Open full page">
|
||||
@@ -445,11 +445,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="peek-body" style="flex:1;display:flex;flex-direction:column;">
|
||||
<iframe id="lib-peek-iframe" :src="peekItem ? '/pages/' + peekItem.id + '?embed=1' : ''" style="width:100%;height:100%;border:none;flex:1;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Le corps et l'iframe sont HORS du <template x-if> : ils existent donc en
|
||||
permanence, ce qui permet de piloter `src` en JS (le build CSP
|
||||
d'Alpine n'evalue pas le binding `:src` — l'iframe restait donc vide).
|
||||
Charger `/pages/{id}?embed=1` : le peek affiche uniquement le
|
||||
document en mode edition, sans sidebar ni barre de navigation. -->
|
||||
<div class="peek-body" style="flex:1;display:flex;flex-direction:column;min-height:0;">
|
||||
<div class="peek-loading" id="lib-peek-loading">Chargement…</div>
|
||||
<iframe id="lib-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /x-data -->
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
{% block content %}
|
||||
<span id="fd-local-ws-id" hidden>{{ workspace_id }}</span>
|
||||
<div x-data="_wsInitData"
|
||||
<div x-data="wsInitData()"
|
||||
@dragover.prevent="onDragOver($event)"
|
||||
@dragleave="onDragLeave($event)"
|
||||
@drop.prevent="onDrop($event)"
|
||||
@@ -482,10 +482,10 @@
|
||||
|
||||
<script type="application/json" id="lw-config" nonce="{{ csp_nonce() }}">{{ {"current_folder_id": current_folder_id, "workspace_id": workspace_id} | tojson }}</script>
|
||||
<script data-cfasync="false" src="/static/js/local_workspace.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof _wsInitData !== 'undefined');</script>
|
||||
|
||||
<div class="ws-split"
|
||||
@contextmenu.prevent="_wsInitData.onContextMenu($event)"
|
||||
@contextmenu.prevent="onContextMenu($event)"
|
||||
@touchstart="onTouchStart($event)"
|
||||
@touchend="onTouchEnd($event)"
|
||||
@touchmove="onTouchMove($event)"
|
||||
@@ -553,7 +553,7 @@
|
||||
<div class="ws-toolbar" style="margin-left:auto;">
|
||||
<!-- View dropdown -->
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="viewMenuOpen=!viewMenuOpen; sortOpen=false; searchOpen=false">
|
||||
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="toggleViewMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="viewMenuOpen" @click.outside="viewMenuOpen=false" x-transition style="right:0;">
|
||||
@@ -567,7 +567,7 @@
|
||||
</div>
|
||||
<!-- Sort -->
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; viewMenuOpen=false">
|
||||
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSortMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition style="right:0;">
|
||||
@@ -581,7 +581,7 @@
|
||||
</div>
|
||||
<!-- Filter -->
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="filterOpen=!filterOpen; viewMenuOpen=false; sortOpen=false">
|
||||
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="toggleFilterMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition style="right:0;">
|
||||
@@ -597,7 +597,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Search toggle -->
|
||||
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="searchOpen=!searchOpen; if(searchOpen) $nextTick(()=>$refs.searchInput?.focus())">
|
||||
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
<!-- Expand/Collapse (tree only) -->
|
||||
@@ -650,12 +650,12 @@
|
||||
|
||||
<!-- Filter chips (visual indicator when filter is active — shown in all views) -->
|
||||
<div class="filter-row" x-show="filterType" style="padding:4px 0;margin-bottom:6px;">
|
||||
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders" x-html="getSvgIcon('folder',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images" x-html="getSvgIcon('image',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text" x-html="getSvgIcon('file',14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders" x-init="bindSvg($el, 'folder', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images" x-init="bindSvg($el, 'image', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text" x-init="bindSvg($el, 'file', 14)"></button>
|
||||
<span class="filter-clear" @click="filterType='', doFilter()">clear filter</span>
|
||||
</div>
|
||||
|
||||
@@ -723,7 +723,7 @@
|
||||
<span style="width:16px;flex-shrink:0;"></span>
|
||||
</template>
|
||||
|
||||
<span class="icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<span class="icon" x-init="bindFileIcon($el, node)"></span>
|
||||
|
||||
<template x-if="node.is_folder">
|
||||
<span class="name folder" @click.stop="navigateToFolder(node.id)"
|
||||
@@ -754,8 +754,8 @@
|
||||
<div class="actions">
|
||||
<template x-if="node.is_folder">
|
||||
<span style="display:flex;gap:2px">
|
||||
<button class="ws-act" @click.stop="window.FlowDeck.createPage(node.id)" title="New file">{{ fd_icon("file",14) }}</button>
|
||||
<button class="ws-act" @click.stop="window.FlowDeck.showCreateFolderModal(node.id)" title="New folder">{{ fd_icon("folder",14) }}</button>
|
||||
<button class="ws-act" @click.stop="createPageAt(node)" title="New file">{{ fd_icon("file",14) }}</button>
|
||||
<button class="ws-act" @click.stop="createFolderAt(node)" title="New folder">{{ fd_icon("folder",14) }}</button>
|
||||
<a class="ws-act" href="/local-workspace" title="Open workspace page" style="text-decoration:none;display:inline-flex;align-items:center;">{{ fd_icon("external-link",14) }}</a>
|
||||
</span>
|
||||
</template>
|
||||
@@ -765,7 +765,7 @@
|
||||
</div>
|
||||
<!-- Children -->
|
||||
<ul class="ws-tree" x-show="expanded[node.id]" x-transition
|
||||
x-html="renderChildren(node.children, (node.depth||0)+1, tagsVersion)">
|
||||
x-init="bindChildren($el, node)">
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
@@ -807,7 +807,7 @@
|
||||
<tr :class="{ selected: !!selectedIds[node.id] }">
|
||||
<td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></td>
|
||||
<td>
|
||||
<span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<span class="table-icon" x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
|
||||
<span class="col-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span>
|
||||
</td>
|
||||
@@ -864,7 +864,7 @@
|
||||
</tr>
|
||||
<template x-for="node in displayTree" :key="'l'+node.id">
|
||||
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer">
|
||||
<td><span x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<td><span x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
|
||||
<td class="col-name" x-text="node.name"></td>
|
||||
<td style="font-size:12px;color:var(--text-tertiary)" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
|
||||
@@ -902,7 +902,7 @@
|
||||
<template x-for="node in displayTree" :key="'d'+node.id">
|
||||
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer"
|
||||
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
|
||||
<td><span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<td><span class="table-icon" x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
|
||||
<td class="col-name" x-text="node.name"></td>
|
||||
<td class="col-path" x-text="node._path || '—'"></td>
|
||||
@@ -933,7 +933,7 @@
|
||||
<template x-for="node in displayTree" :key="'g'+node.id">
|
||||
<div class="title-card" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
|
||||
:class="{ 'selected-card': previewItem && previewItem.id === node.id }">
|
||||
<div class="title-card-icon" x-html="node.is_folder ? getSvgIcon('folder',24) : _fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></div>
|
||||
<div class="title-card-icon" x-init="bindNodeIcon($el, node)"></div>
|
||||
<div class="title-card-name">
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
|
||||
<span x-text="node.name"></span>
|
||||
@@ -960,7 +960,7 @@
|
||||
<div class="content-item" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
|
||||
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
|
||||
<div class="content-item-header">
|
||||
<span x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
|
||||
<span x-init="bindFileIcon($el, node)"></span>
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
|
||||
<span class="col-name" x-text="node.name"></span>
|
||||
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="_formatDate(node.updated_at)"></span>
|
||||
@@ -1023,7 +1023,7 @@
|
||||
<h3><span x-show="createType==='folder'">{{ fd_icon('folder',16) }}</span><span x-show="createType!='folder'">{{ fd_icon('file',16) }}</span> <span x-text="createType==='folder'?'New Folder':'New File'"></span></h3>
|
||||
<p class="modal-sub">
|
||||
In <strong>{{ workspace_name }}</strong> <span x-show="folderStack.length>1">/ <span x-text="(folderStack[folderStack.length-1]||{}).name||''"></span></span>
|
||||
<span x-show="parentFolder"> / <span x-text="parentFolder?.name"></span></span>
|
||||
<span x-show="parentFolder"> / <span x-text="parentFolder ? parentFolder.name : ''"></span></span>
|
||||
</p>
|
||||
<input class="modal-input" x-model="newName" :placeholder="createType==='folder'?'Folder name':'File name'" @keydown.enter="doCreate" x-ref="cinp">
|
||||
<div class="modal-actions">
|
||||
@@ -1037,7 +1037,7 @@
|
||||
<div class="modal-overlay" x-show="showRename" @click.outside="showRename=false" @keydown.escape="showRename=false" style="display:none">
|
||||
<div class="modal-box">
|
||||
<h3>{{ fd_icon("edit",14) }} Rename</h3>
|
||||
<p class="modal-sub">Rename <strong x-text="target?.name"></strong></p>
|
||||
<p class="modal-sub">Rename <strong x-text="target ? target.name : ''"></strong></p>
|
||||
<input class="modal-input" x-model="newName" @keydown.enter="doRename" x-ref="rinp">
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="showRename=false">Cancel</button>
|
||||
@@ -1050,7 +1050,7 @@
|
||||
<div class="modal-overlay" x-show="showDelete" @click.outside="showDelete=false" @keydown.escape="showDelete=false" style="display:none">
|
||||
<div class="modal-box">
|
||||
<h3>{{ fd_icon("trash",16) }} Delete</h3>
|
||||
<p class="modal-sub">Delete <strong x-text="target?.name"></strong>?</p>
|
||||
<p class="modal-sub">Delete <strong x-text="target ? target.name : ''"></strong>?</p>
|
||||
<div class="delete-confirm">This cannot be undone. Children will also be deleted.</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="showDelete=false">Cancel</button>
|
||||
@@ -1065,11 +1065,14 @@
|
||||
{% include "_ctx_menu.html" %}
|
||||
|
||||
<!-- Preview tooltip -->
|
||||
<div class="file-preview" x-show="previewVisible"
|
||||
<!-- ponytail: le parseur referme la div racine avant ce bloc (structure
|
||||
pré-existante, masquée par le fallback window d'Alpine standard) →
|
||||
wsPreview = wrapper déléguant vers _wsInitData (voir local_workspace.js) -->
|
||||
<div class="file-preview" x-data="wsPreview()" x-show="previewVisible"
|
||||
:style="{ left: previewX + 'px', top: previewY + 'px' }"
|
||||
@mouseenter="previewVisible = true" @mouseleave="previewVisible = false">
|
||||
<div class="file-preview-header" x-text="previewName"></div>
|
||||
<div class="file-preview-body" x-html="previewContent"></div>
|
||||
<div class="file-preview-body" x-init="bindPreview($el)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Panel — identical to Library peek-overlay -->
|
||||
|
||||
@@ -3,7 +3,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
|
||||
{% set page_title = page.title %}
|
||||
{% set nav_page_id = page.id %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn" @click="window.E && window.E.toggleComments()" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="window.E && window.E.commentCount>0 ? window.E.commentCount : \'\'"></span></button><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
|
||||
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn" @click="edCall('toggleComments')" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="edCommentCount()"></span></button><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %} {% block content %}
|
||||
{% include "_page_editor_content.html" %}
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% set page_icon = "file" %}
|
||||
{% set page_title = page.title %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
|
||||
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %} {% block content %}
|
||||
<h1 class="database-page-title">{{ page.title }}</h1>
|
||||
{% include "_database_table.html" %}
|
||||
{% endblock %} {% block scripts %}
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
|
||||
+146
-20
@@ -127,7 +127,7 @@
|
||||
@media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}}
|
||||
</style>
|
||||
|
||||
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
|
||||
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="historyBack()">
|
||||
<div class="settings-panel" style="position:relative;">
|
||||
<button class="settings-close" @click="closeSettings()" title="Close">×</button>
|
||||
|
||||
@@ -136,24 +136,24 @@
|
||||
<div class="settings-nav-header">Account</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='account' }" @click="activeSection='account'"><span class="nav-icon-inline">{{ fd_icon("user",14) }}</span> My account</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="activeSection='api-tokens'; loadApiTokens()"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="activeSection='sessions'; loadSessions()"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="activeSection='extensions'; loadClipperDevices()"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="navTo('api-tokens')"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="navTo('sessions')"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
|
||||
<div class="settings-nav-header">Workspace</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
|
||||
<div class="settings-nav-header">Features</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="activeSection='automations'; loadAutomations()">{{ fd_icon("zap",14) }} Automations</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent & IA</div>
|
||||
<!-- Admin nav: only visible to admins -->
|
||||
<template x-if="userIsAdmin">
|
||||
<div>
|
||||
<div class="settings-nav-header">Admin</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users & Roles</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="activeSection='admin-backups'; loadBackups()">{{ fd_icon("download",14) }} Backups</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="activeSection='admin-sso'; loadSsoConfig()">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="navTo('admin-users')">{{ fd_icon("users",14) }} Users & Roles</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="navTo('admin-audit')">{{ fd_icon("file-text",14) }} Audit Log</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="navTo('admin-backups')">{{ fd_icon("download",14) }} Backups</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="navTo('admin-sso')">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -515,9 +515,9 @@
|
||||
<div class="modal-box" style="max-width:360px;">
|
||||
<h3 style="margin:0 0 8px;">Delete tag</h3>
|
||||
<p style="color:var(--text-dim);margin:0 0 16px;">
|
||||
Are you sure you want to delete the tag "<strong x-text="deletingTag?.name"></strong>"?
|
||||
<span x-show="deletingTag?.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
|
||||
{{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag?.count"></span> item(s).
|
||||
Are you sure you want to delete the tag "<strong x-text="deletingTag ? deletingTag.name : ''"></strong>"?
|
||||
<span x-show="deletingTag && deletingTag.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
|
||||
{{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag ? deletingTag.count : 0"></span> item(s).
|
||||
</span>
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
@@ -531,7 +531,7 @@
|
||||
<div class="modal-box" style="max-width:360px;">
|
||||
<h3 style="margin:0 0 12px;">Rename tag</h3>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<div class="tag-color-dot" :style="{background: renamingTag?.color}" style="width:16px;height:16px;"></div>
|
||||
<div class="tag-color-dot" :style="{background: renamingTag ? renamingTag.color : ''}" style="width:16px;height:16px;"></div>
|
||||
<input type="text" class="settings-input" x-model="renameValue"
|
||||
@keydown.enter="confirmRenameTag()" @keydown.escape="renamingTag=null"
|
||||
style="flex:1;" autofocus>
|
||||
@@ -635,16 +635,142 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-row" x-show="!editSteps.length" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-label">Conditions (JSON — toutes AND)</div>
|
||||
<textarea class="settings-input" style="width:100%;min-height:60px;font-family:monospace;font-size:12px;" x-model="af.condition_json" placeholder='[{"property":"Status","op":"eq","value":"Done"}]'></textarea>
|
||||
<div class="setting-desc">Ops : eq, neq, contains, not_contains, is_empty, is_not_empty, changed</div>
|
||||
</div>
|
||||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-row" x-show="!editSteps.length" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-label">Actions (JSON)</div>
|
||||
<textarea class="settings-input" style="width:100%;min-height:80px;font-family:monospace;font-size:12px;" x-model="af.actions_json" placeholder='[{"type":"webhook","url":"https://..."}, {"type":"set_property","property":"Status","value":"Done"}, {"type":"create_page","collection_id":1,"title":"New task"}, {"type":"notify","message":"Automation fired"}]'></textarea>
|
||||
<div class="setting-desc">Types : webhook, set_property, create_page, notify</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline visuel (steps API v7.0) -->
|
||||
<div class="setting-row" x-show="editAutomationId" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||
<div class="setting-label">Pipeline visuel (steps)</div>
|
||||
<div class="setting-desc" x-show="!editSteps.length">Aucun step : automation en mode legacy (JSON ci-dessus). Ajoutez une étape (ou convertissez le JSON) pour basculer en pipeline.</div>
|
||||
<template x-for="(s, i) in editSteps" :key="s.id">
|
||||
<div style="border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin-bottom:6px;">
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<span class="setting-label" style="font-weight:500;" x-text="(i+1) + '. ' + stepSummary(s)"></span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="btn-sm" title="Monter" @click="moveStep(i, -1)">↑</button>
|
||||
<button class="btn-sm" title="Descendre" @click="moveStep(i, 1)">↓</button>
|
||||
<button class="btn-sm" @click="stepEdit = stepEdit===i ? -1 : i" x-text="stepEdit===i ? 'Fermer' : 'Modifier'"></button>
|
||||
<button class="btn-sm" style="color:#e5534b;" title="Supprimer" @click="delStep(i)">✕</button>
|
||||
</div>
|
||||
<div x-show="stepEdit===i" style="margin-top:8px;display:flex;flex-direction:column;gap:6px;">
|
||||
<template x-if="s.kind==='trigger'">
|
||||
<div>
|
||||
<div class="setting-label">Événement</div>
|
||||
<input class="settings-input" style="width:100%;" x-model="s.config.event" list="auto-events">
|
||||
<datalist id="auto-events">
|
||||
<option value="page.created"></option><option value="page.updated"></option><option value="page.deleted"></option>
|
||||
<option value="collection.created"></option><option value="collection.updated"></option><option value="collection.deleted"></option>
|
||||
<option value="form.submitted"></option><option value="meeting.summarized"></option><option value="site.viewed"></option>
|
||||
</datalist>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.kind==='condition'">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<input class="settings-input" placeholder="Propriété" x-model="s.config.property">
|
||||
<select class="settings-input" x-model="s.config.op">
|
||||
<option value="eq">=</option><option value="neq">≠</option>
|
||||
<option value="contains">contient</option><option value="not_contains">ne contient pas</option>
|
||||
<option value="is_empty">vide</option><option value="is_not_empty">non vide</option>
|
||||
<option value="changed">changé</option>
|
||||
</select>
|
||||
<input class="settings-input" placeholder="Valeur" x-model="s.config.value">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.kind==='delay'">
|
||||
<div>
|
||||
<div class="setting-label">Secondes (0-86400 · exécution plafonnée à 300 s)</div>
|
||||
<input class="settings-input" type="number" min="0" max="86400" x-model.number="s.config.seconds">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.kind==='action'">
|
||||
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||
<select class="settings-input" x-model="s.config.type">
|
||||
<option value="webhook">Webhook</option>
|
||||
<option value="set_property">Définir une propriété</option>
|
||||
<option value="create_page">Créer une page</option>
|
||||
<option value="notify">Notification</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="forge_issue">Issue Gitea/GitHub</option>
|
||||
<option value="agent_trigger">Déclencher un agent</option>
|
||||
</select>
|
||||
<template x-if="s.config.type==='webhook'">
|
||||
<input class="settings-input" placeholder="https://…" x-model="s.config.url">
|
||||
</template>
|
||||
<template x-if="s.config.type==='set_property'">
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input class="settings-input" placeholder="Propriété" x-model="s.config.property">
|
||||
<input class="settings-input" placeholder="Valeur" x-model="s.config.value">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.config.type==='create_page'">
|
||||
<div style="display:flex;gap:8px;">
|
||||
<select class="settings-input" x-model="s.config.collection_id">
|
||||
<option value="">Base (optionnel)</option>
|
||||
<template x-for="c in globalCollections" :key="c.id">
|
||||
<option x-bind:value="c.id" x-text="c.icon + ' ' + c.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input class="settings-input" placeholder="Titre" x-model="s.config.title">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.config.type==='notify'">
|
||||
<input class="settings-input" placeholder="Message" x-model="s.config.message">
|
||||
</template>
|
||||
<template x-if="s.config.type==='slack'">
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input class="settings-input" placeholder="Webhook URL (chiffrée)" x-model="s.config.webhook_url">
|
||||
<input class="settings-input" placeholder="Texte" x-model="s.config.text">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.config.type==='email'">
|
||||
<div style="display:flex;gap:8px;flex-direction:column;">
|
||||
<input class="settings-input" placeholder="À (to)" x-model="s.config.to">
|
||||
<input class="settings-input" placeholder="Sujet" x-model="s.config.subject">
|
||||
<textarea class="settings-input" style="min-height:56px;" placeholder="Corps" x-model="s.config.body"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.config.type==='forge_issue'">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<input class="settings-input" placeholder="owner" x-model="s.config.owner">
|
||||
<input class="settings-input" placeholder="repo" x-model="s.config.repo">
|
||||
<input class="settings-input" placeholder="Titre" x-model="s.config.title">
|
||||
<input class="settings-input" placeholder="Labels (virgules)" x-model="s.config.labels">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="s.config.type==='agent_trigger'">
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input class="settings-input" type="number" placeholder="Agent ID" x-model.number="s.config.agent_id">
|
||||
<input class="settings-input" placeholder="Message" x-model="s.config.message">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div><button class="btn-sm" @click="saveStep(s)">Enregistrer l'étape</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<select class="settings-input" style="min-width:150px;" x-model="stepNewKind">
|
||||
<option value="trigger">Déclencheur</option>
|
||||
<option value="condition">Condition</option>
|
||||
<option value="delay">Attente</option>
|
||||
<option value="action">Action</option>
|
||||
</select>
|
||||
<button class="btn-sm" @click="addStep()">+ Ajouter l'étape</button>
|
||||
<button class="btn-sm" style="color:var(--accent,#2383e2);" x-show="!editSteps.length && editAutomationId"
|
||||
@click="convertToSteps()">✨ Convertir le JSON en pipeline</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<button class="btn-sm" @click="saveAutomation()" x-text="editAutomationId ? 'Enregistrer' : 'Créer'"></button>
|
||||
<button class="btn-sm" style="color:var(--text-secondary);" x-show="editAutomationId" @click="cancelEditAutomation()">Annuler</button>
|
||||
@@ -770,11 +896,11 @@
|
||||
<td x-text="u.folder_count" style="text-align:center;"></td>
|
||||
<td x-text="u.total_mb + ' MB'" style="text-align:right;"></td>
|
||||
<td>
|
||||
<span style="font-size:11px;color:var(--text-dim);" x-text="u.last_login ? new Date(u.last_login*1000).toLocaleDateString() : 'Never'"></span>
|
||||
<span style="font-size:11px;color:var(--text-dim);" x-text="fmtLastLogin(u)"></span>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn-sm" @click="editingUser=u; editUserForm={login:u.login,name:u.full_name,email:u.email,is_admin:u.is_admin,is_active:u.is_active}" title="Edit">{{ fd_icon("edit",14) }}</button>
|
||||
<button class="btn-sm" @click="editUserRow(u)" title="Edit">{{ fd_icon("edit",14) }}</button>
|
||||
<button class="btn-sm btn-sm-danger" @click="adminDeleteUser(u.id)" title="Delete" x-show="adminUsers.length > 1">{{ fd_icon("trash",14) }}</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -813,7 +939,7 @@
|
||||
<p class="section-desc">Actions API, changements de permissions et connexions SSO (unifiés).</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center;">
|
||||
<template x-for="s in ['all','api','permissions','sso']" :key="s">
|
||||
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="auditSource=s; loadAuditLogs()" x-text="s"></button>
|
||||
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="setAuditSource(s)" x-text="s"></button>
|
||||
</template>
|
||||
<input type="text" placeholder="actor (user id)" x-model="auditActor" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
|
||||
<input type="text" placeholder="action (LIKE)" x-model="auditAction" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
|
||||
@@ -837,7 +963,7 @@
|
||||
<tbody>
|
||||
<template x-for="e in auditLogs" :key="e.at + '-' + e.source + '-' + e.actor">
|
||||
<tr>
|
||||
<td><span style="font-size:12px;" x-text="new Date(e.at).toLocaleString()"></span></td>
|
||||
<td><span style="font-size:12px;" x-text="fmtAuditDate(e)"></span></td>
|
||||
<td><span style="font-size:11px;" :class="'audit-src audit-src-'+e.source" x-text="e.source"></span></td>
|
||||
<td><code style="font-size:11px;" x-text="e.actor"></code></td>
|
||||
<td><code style="font-size:11px;" x-text="e.action"></code></td>
|
||||
@@ -853,7 +979,7 @@
|
||||
</table>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:center;margin-top:12px;">
|
||||
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditOffset+=auditPageSize; loadAuditLogs(false)">Load more</button>
|
||||
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditNext()">Load more</button>
|
||||
</div>
|
||||
<style>
|
||||
.audit-src{display:inline-block;padding:1px 7px;border-radius:8px;font-weight:600;}
|
||||
|
||||
@@ -100,6 +100,12 @@
|
||||
</div>
|
||||
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: tableView »).
|
||||
|
||||
if (window.Alpine) { Alpine.data('tableView', tableView); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); });
|
||||
function tableView() {
|
||||
return {
|
||||
sortField: '',
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
</div>
|
||||
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: teamLoad »).
|
||||
|
||||
if (window.Alpine) { Alpine.data('teamLoad', teamLoad); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); });
|
||||
function teamLoad() {
|
||||
return {};
|
||||
}
|
||||
|
||||
+273
-33
@@ -11,46 +11,94 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;">
|
||||
<!-- Search -->
|
||||
<div style="position:relative; margin-bottom:12px;">
|
||||
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
|
||||
<input type="text" placeholder="Search pages in Trash" x-model="search"
|
||||
style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;">
|
||||
<div x-data="trashData()" class="lib-container">
|
||||
<!-- Toolbar : recherche + tri + emplacement + purge -->
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:12px;">
|
||||
<div style="position:relative; flex:1;">
|
||||
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
|
||||
<input type="text" placeholder="Search pages in Trash" x-model="search"
|
||||
style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;">
|
||||
</div>
|
||||
|
||||
<!-- Tri -->
|
||||
<div class="lib-toolbar-wrap">
|
||||
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSort()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
|
||||
</button>
|
||||
<div class="lib-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition>
|
||||
<div class="dd-label">Sort by</div>
|
||||
<div class="dd-item" :class="{active: sort==='recent'}" @click="setSort('recent')"><span class="check" x-text="sort==='recent' ? '✓' : ''"></span> Recently deleted</div>
|
||||
<div class="dd-item" :class="{active: sort==='oldest'}" @click="setSort('oldest')"><span class="check" x-text="sort==='oldest' ? '✓' : ''"></span> Oldest first</div>
|
||||
<div class="dd-item" :class="{active: sort==='name'}" @click="setSort('name')"><span class="check" x-text="sort==='name' ? '✓' : ''"></span> Page name A → Z</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emplacement -->
|
||||
<div class="lib-toolbar-wrap" x-show="locations.length > 1">
|
||||
<button class="lib-icon-btn" :class="{active: locOpen || locFilter !== 'all'}" title="Filter by location" @click.stop="toggleLoc()">
|
||||
{{ fd_icon("folder",16) }}
|
||||
</button>
|
||||
<div class="lib-dropdown" x-show="locOpen" @click.outside="locOpen=false" x-transition>
|
||||
<div class="dd-label">Location</div>
|
||||
<div class="dd-item" :class="{active: locFilter==='all'}" @click="setLoc('all')"><span class="check" x-text="locFilter==='all' ? '✓' : ''"></span> All locations</div>
|
||||
<template x-for="l in locations" :key="l">
|
||||
<div class="dd-item" :class="{active: locFilter===l}" @click="setLoc(l)"><span class="check" x-text="locFilter===l ? '✓' : ''"></span> <span x-text="l"></span></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Purge totale -->
|
||||
<button class="btn-sm btn-sm-danger" x-show="items.length" @click="emptyTrash()">Empty Trash</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div style="display:flex; gap:8px; margin-bottom:16px;">
|
||||
<button class="trash-filter active">
|
||||
<span style="color:var(--accent);">{{ fd_icon("user",14) }}</span> Last edited by ▾
|
||||
</button>
|
||||
<button class="trash-filter">
|
||||
<span>{{ fd_icon("folder",14) }}</span> In ▾
|
||||
</button>
|
||||
<!-- Sélection + compteur + actions groupées -->
|
||||
<div style="display:flex; align-items:center; gap:14px; margin-bottom:8px; font-size:13px; color:var(--text-dim);" x-show="items.length">
|
||||
<label style="display:flex; align-items:center; gap:6px; cursor:pointer;">
|
||||
<input type="checkbox" style="accent-color:var(--accent); cursor:pointer;" :checked="allSelected" @change="toggleAll()">
|
||||
<span x-text="selected.length ? selected.length + ' selected' : 'Select all'"></span>
|
||||
</label>
|
||||
<span x-text="countLabel"></span>
|
||||
<div style="margin-left:auto; display:flex; gap:6px;" x-show="selected.length">
|
||||
<button class="btn-sm" @click="restoreSelected()">Restore</button>
|
||||
<button class="btn-sm btn-sm-danger" @click="deleteSelected()">Delete</button>
|
||||
<button class="btn-sm" @click="clearSel()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items -->
|
||||
<div style="min-height:200px;">
|
||||
<template x-for="item in filteredItems" :key="item.id">
|
||||
<div class="trash-item">
|
||||
<input type="checkbox" style="accent-color:var(--accent); cursor:pointer; flex-shrink:0;"
|
||||
:checked="selected.includes(item.id)" @change="toggle(item.id)">
|
||||
<span class="trash-item-icon" x-text="item.icon"></span>
|
||||
<div class="trash-item-info">
|
||||
<span class="trash-item-name" x-text="item.name"></span>
|
||||
<span class="trash-item-path" x-text="item.path"></span>
|
||||
<span class="trash-item-path">
|
||||
<span x-text="item.path"></span> · <span x-text="ago(item.deleted_at)"></span><span x-text="leftLabel(item.deleted_at)"></span>
|
||||
</span>
|
||||
</div>
|
||||
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">
|
||||
↩️
|
||||
</button>
|
||||
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">↩️</button>
|
||||
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
|
||||
{{ fd_icon("trash",14) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="filteredItems.length === 0" class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",14) }}</div>
|
||||
|
||||
<!-- État vide : aucun contenu -->
|
||||
<div x-show="items.length === 0" class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",24) }}</div>
|
||||
<h3>Trash is empty</h3>
|
||||
<p>Deleted pages will appear here for 30 days.</p>
|
||||
</div>
|
||||
|
||||
<!-- État vide : filtres sans résultat -->
|
||||
<div x-show="items.length > 0 && filteredItems.length === 0" class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("search",24) }}</div>
|
||||
<h3>No pages match</h3>
|
||||
<p x-text="items.length + ' page(s) in Trash — none matches the current search/location filter.'"></p>
|
||||
<button class="btn-sm" style="margin-top:12px;" @click="clearFilters()">Clear filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
@@ -58,37 +106,229 @@
|
||||
<span>Once a page has been in Trash for 30 days, it will be automatically deleted</span>
|
||||
<span style="font-size:16px;">ⓘ</span>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation thématique (remplace confirm() du navigateur) — classes
|
||||
flowdeck-modal définies dans base.html, thème variables du site -->
|
||||
<div id="fd-trash-confirm" class="flowdeck-modal-overlay" x-show="confirm.show" x-transition.opacity
|
||||
@click.self="cancelConfirm()" @keydown.escape.window="cancelConfirm()" style="display:none;">
|
||||
<div class="flowdeck-modal" x-transition role="alertdialog" aria-modal="true" aria-labelledby="fd-trash-confirm-title">
|
||||
<div class="flowdeck-modal-header">
|
||||
<h3 id="fd-trash-confirm-title" x-text="confirm.title"></h3>
|
||||
<button class="flowdeck-modal-close" @click="cancelConfirm()" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="flowdeck-modal-body">
|
||||
<p x-text="confirm.message"></p>
|
||||
</div>
|
||||
<div class="flowdeck-modal-footer">
|
||||
<button class="flowdeck-modal-btn cancel" @click="cancelConfirm()">Cancel</button>
|
||||
<button class="flowdeck-modal-btn confirm" @click="acceptConfirm()" x-text="confirm.okText"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
// globales window — probe « Undefined variable: trashData »).
|
||||
if (window.Alpine) { Alpine.data('trashData', trashData); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
|
||||
|
||||
function trashData() {
|
||||
// Rétention alignée sur app/services/trash.py (purge auto quotidienne).
|
||||
var RETENTION_DAYS = 30;
|
||||
return {
|
||||
search: '',
|
||||
sort: 'recent',
|
||||
locFilter: 'all',
|
||||
sortOpen: false,
|
||||
locOpen: false,
|
||||
selected: [],
|
||||
items: [],
|
||||
// Confirmation thématique (remplace confirm() du navigateur)
|
||||
confirm: { show: false, title: '', message: '', okText: 'Delete', action: null },
|
||||
|
||||
async init() {
|
||||
const csrf = getCsrf();;
|
||||
const token = csrf;
|
||||
try {
|
||||
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': token } });
|
||||
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': getCsrf() } });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
this.items = await r.json();
|
||||
} catch(e) { this.items = []; }
|
||||
} catch (e) {
|
||||
this.items = [];
|
||||
window.showToast('Could not load the Trash: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
get locations() {
|
||||
const set = {};
|
||||
this.items.forEach(function (i) { set[i.path || 'Private'] = true; });
|
||||
return Object.keys(set).sort();
|
||||
},
|
||||
|
||||
get filteredItems() {
|
||||
const q = this.search.toLowerCase();
|
||||
return this.items.filter(i => !q || i.name.toLowerCase().includes(q) || (i.path||'').toLowerCase().includes(q));
|
||||
const loc = this.locFilter;
|
||||
const out = this.items.filter(function (i) {
|
||||
const hitQ = !q || i.name.toLowerCase().includes(q) || (i.path || '').toLowerCase().includes(q);
|
||||
return hitQ && (loc === 'all' || (i.path || 'Private') === loc);
|
||||
});
|
||||
const key = function (i) { return i.deleted_at || ''; };
|
||||
if (this.sort === 'recent') out.sort(function (a, b) { return key(b).localeCompare(key(a)); });
|
||||
else if (this.sort === 'oldest') out.sort(function (a, b) { return key(a).localeCompare(key(b)); });
|
||||
else out.sort(function (a, b) { return a.name.localeCompare(b.name); });
|
||||
return out;
|
||||
},
|
||||
|
||||
get countLabel() {
|
||||
const n = this.filteredItems.length, t = this.items.length;
|
||||
return n === t ? t + ' page' + (t === 1 ? '' : 's') : n + ' of ' + t + ' pages';
|
||||
},
|
||||
|
||||
get allSelected() {
|
||||
const list = this.filteredItems;
|
||||
const self = this;
|
||||
return list.length > 0 && list.every(function (i) { return self.selected.includes(i.id); });
|
||||
},
|
||||
|
||||
toggle(id) {
|
||||
const i = this.selected.indexOf(id);
|
||||
if (i >= 0) this.selected.splice(i, 1); else this.selected.push(id);
|
||||
},
|
||||
toggleAll() {
|
||||
this.selected = this.allSelected ? [] : this.filteredItems.map(function (i) { return i.id; });
|
||||
},
|
||||
clearSel() { this.selected = []; },
|
||||
clearFilters() { this.search = ''; this.locFilter = 'all'; },
|
||||
toggleSort() { this.sortOpen = !this.sortOpen; },
|
||||
toggleLoc() { this.locOpen = !this.locOpen; },
|
||||
setSort(v) { this.sort = v; this.sortOpen = false; },
|
||||
setLoc(v) { this.locFilter = v; this.locOpen = false; },
|
||||
|
||||
// ── Confirmation (modal thème FlowDeck, classes flowdeck-modal de base) ──
|
||||
// Mutations de propriétés individuelles (remplacer l'objet entier tue la
|
||||
// réactivité Alpine — pitfall connu).
|
||||
askConfirm(title, message, okText, action) {
|
||||
this.confirm.title = title;
|
||||
this.confirm.message = message;
|
||||
this.confirm.okText = okText || 'Delete';
|
||||
this.confirm.action = action;
|
||||
this.confirm.show = true;
|
||||
},
|
||||
cancelConfirm() {
|
||||
this.confirm.show = false;
|
||||
this.confirm.action = null;
|
||||
},
|
||||
acceptConfirm() {
|
||||
const action = this.confirm.action;
|
||||
this.confirm.show = false;
|
||||
this.confirm.action = null;
|
||||
if (action) action();
|
||||
},
|
||||
|
||||
// ── API ──────────────────────────────────────────────────────────
|
||||
// getCsrf() renvoie la CHAÎNE du jeton (base.html) : l'ancien code faisait
|
||||
// csrf?.[1] → 2e caractère → 403 CSRF sur restore/delete, silencieux.
|
||||
_headers() { return { 'X-CSRF-Token': getCsrf() }; },
|
||||
_drop(ids) {
|
||||
this.items = this.items.filter(function (i) { return !ids.includes(i.id); });
|
||||
this.selected = this.selected.filter(function (id) { return !ids.includes(id); });
|
||||
},
|
||||
|
||||
async _restore(id) {
|
||||
const r = await fetch('/board/api/trash/' + id + '/restore', { method: 'POST', headers: this._headers() });
|
||||
if (r.ok) this._drop([id]);
|
||||
return r.ok;
|
||||
},
|
||||
async _delete(id) {
|
||||
const r = await fetch('/board/api/trash/' + id, { method: 'DELETE', headers: this._headers() });
|
||||
if (r.ok) this._drop([id]);
|
||||
return r.ok;
|
||||
},
|
||||
|
||||
async restore(id) {
|
||||
const csrf = getCsrf();;
|
||||
const r = await fetch(`/board/api/trash/${id}/restore`, { method: 'POST', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
|
||||
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
|
||||
try {
|
||||
if (await this._restore(id)) window.showToast('Page restored', 'success');
|
||||
else window.showToast('Restore failed', 'error');
|
||||
} catch (e) { window.showToast('Restore failed: ' + e.message, 'error'); }
|
||||
},
|
||||
async deleteForever(id) {
|
||||
if (!confirm('Permanently delete this page? This cannot be undone.')) return;
|
||||
const csrf = getCsrf();;
|
||||
const r = await fetch(`/board/api/trash/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
|
||||
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
|
||||
async restoreSelected() {
|
||||
const ids = this.selected.slice();
|
||||
let ok = 0;
|
||||
for (const id of ids) {
|
||||
try { if (await this._restore(id)) ok++; } catch (e) { /* compté en échec */ }
|
||||
}
|
||||
window.showToast(ok + ' of ' + ids.length + ' page(s) restored', ok === ids.length ? 'success' : 'error');
|
||||
},
|
||||
async _deleteAndToast(ids) {
|
||||
let ok = 0;
|
||||
for (const id of ids) {
|
||||
try { if (await this._delete(id)) ok++; } catch (e) { /* compté en échec */ }
|
||||
}
|
||||
const msg = ids.length === 1
|
||||
? (ok ? 'Page permanently deleted' : 'Delete failed')
|
||||
: ok + ' of ' + ids.length + ' page(s) deleted';
|
||||
window.showToast(msg, ok === ids.length ? 'success' : 'error');
|
||||
},
|
||||
deleteForever(id) {
|
||||
const self = this;
|
||||
const item = this.items.find(function (i) { return i.id === id; });
|
||||
const name = item ? item.name : 'this page';
|
||||
this.askConfirm('Delete permanently',
|
||||
'“' + name + '” will be deleted for good. This cannot be undone.',
|
||||
'Delete',
|
||||
function () { self._deleteAndToast([id]); });
|
||||
},
|
||||
deleteSelected() {
|
||||
const self = this;
|
||||
const ids = this.selected.slice();
|
||||
this.askConfirm('Delete ' + ids.length + ' page(s) for good?',
|
||||
'These pages will be permanently deleted. This cannot be undone.',
|
||||
'Delete all',
|
||||
function () { self._deleteAndToast(ids); });
|
||||
},
|
||||
async emptyTrash() {
|
||||
const self = this;
|
||||
const n = this.items.length;
|
||||
this.askConfirm('Empty Trash?',
|
||||
'All ' + n + ' page(s) in the Trash will be permanently deleted. This cannot be undone.',
|
||||
'Empty Trash',
|
||||
function () { self._emptyAndToast(n); });
|
||||
},
|
||||
async _emptyAndToast(n) {
|
||||
try {
|
||||
const r = await fetch('/board/api/trash/empty', { method: 'POST', headers: this._headers() });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
this.items = [];
|
||||
this.selected = [];
|
||||
window.showToast(n + ' page(s) purged', 'success');
|
||||
} catch (e) { window.showToast('Empty Trash failed: ' + e.message, 'error'); }
|
||||
},
|
||||
|
||||
// ── Dates (deleted_at : CURRENT_TIMESTAMP UTC ou ISO sans fuseau) ──
|
||||
_ts(s) {
|
||||
if (!s) return null;
|
||||
let t = String(s).replace(' ', 'T');
|
||||
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(t)) t += 'Z';
|
||||
const d = new Date(t);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
},
|
||||
ago(s) {
|
||||
const d = this._ts(s);
|
||||
if (!d) return 'Deleted';
|
||||
const sec = (Date.now() - d.getTime()) / 1000;
|
||||
if (sec < 60) return 'Deleted just now';
|
||||
if (sec < 3600) return 'Deleted ' + Math.floor(sec / 60) + ' min ago';
|
||||
if (sec < 86400) return 'Deleted ' + Math.floor(sec / 3600) + ' h ago';
|
||||
const days = Math.floor(sec / 86400);
|
||||
return 'Deleted ' + days + ' day' + (days === 1 ? '' : 's') + ' ago';
|
||||
},
|
||||
leftLabel(s) {
|
||||
const d = this._ts(s);
|
||||
if (!d) return '';
|
||||
const left = RETENTION_DAYS - Math.floor((Date.now() - d.getTime()) / 86400000);
|
||||
if (left <= 0) return ' · auto-delete soon';
|
||||
return ' · ' + left + ' day' + (left === 1 ? '' : 's') + ' left';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bienvenue sur FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<style>
|
||||
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
@@ -134,6 +134,12 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
|
||||
</div>
|
||||
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: onboarding »).
|
||||
|
||||
if (window.Alpine) { Alpine.data('onboarding', onboarding); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('onboarding', onboarding); });
|
||||
function onboarding() {
|
||||
return {
|
||||
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block topbar %}
|
||||
{% set page_icon = "home" %}
|
||||
{% set page_title = "Workspace — Projects" %}
|
||||
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
||||
{% set right_actions %}<a href="/auth/login?provider=local" class="topbar-btn" title="Login">{{ fd_icon("key",16) }}</a><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<template x-for="p in builtinProjects" :key="p.id">
|
||||
<div class="project-card" @click="openProject(p)">
|
||||
<div class="project-card-top">
|
||||
<span class="project-card-icon" x-html="getSvgIcon(p.icon || 'folder',20)"></span>
|
||||
<span class="project-card-icon" x-init="bindProjectIcon($el, p)"></span>
|
||||
<span class="forge-badge builtin">Built-in</span>
|
||||
</div>
|
||||
<div class="project-card-name" x-text="p.name"></div>
|
||||
@@ -141,6 +141,12 @@
|
||||
</div>
|
||||
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: workspacePage »).
|
||||
|
||||
if (window.Alpine) { Alpine.data('workspacePage', workspacePage); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('workspacePage', workspacePage); });
|
||||
function workspacePage() {
|
||||
return {
|
||||
builtinProjects: [],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block topbar %}
|
||||
{% set breadcrumb_items = [{"label": "Workspaces", "url": None}] %}
|
||||
{% set page_icon = "home" %}
|
||||
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
||||
{% set right_actions %}<a href="/auth/login?provider=local" class="topbar-btn" title="Login">{{ fd_icon("key",16) }}</a><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -159,12 +159,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Create/Rename dialog -->
|
||||
<div class="dialog-overlay" x-show="showCreate||showRename" @click.outside="showCreate=false;showRename=false">
|
||||
<div class="dialog-overlay" x-show="showCreate||showRename" @click.outside="closeCreateRename()">
|
||||
<div class="dialog-box">
|
||||
<h3 x-text="showRename?'Rename Workspace':'New Workspace'"></h3>
|
||||
<input class="dialog-input" x-model="wsName" placeholder="Workspace name" @keydown.enter="showRename?doRename():doCreate()">
|
||||
<div class="dialog-actions">
|
||||
<button class="btn btn-secondary" @click="showCreate=false;showRename=false">Cancel</button>
|
||||
<button class="btn btn-secondary" @click="closeCreateRename()">Cancel</button>
|
||||
<button class="btn btn-primary" @click="showRename?doRename():doCreate()" x-text="showRename?'Rename':'Create'"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+38
-1
@@ -6,10 +6,46 @@ les `|safe` du codebase étaient des no-op.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
from contextvars import ContextVar
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from jinja2 import Environment, FileSystemLoader, Undefined, select_autoescape
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoggingUndefined(Undefined):
|
||||
"""Undefined qui se rend comme une chaîne vide mais **journalise**.
|
||||
|
||||
v7.46.0 : avec l'`Undefined` par défaut, une variable mal orthographiée
|
||||
(ou absente du contexte de la route) rendait `""` sans aucune trace — c'est
|
||||
exactement ce qui a rendu le bug du bouton Home invisible (``local_workspaces``
|
||||
absent ⇒ `/workspaces`` en silence). ``StrictUndefined`` est inutilisable ici
|
||||
(40+ templates optionnelles, ex. ``embed_mode``), donc on garde le rendu et
|
||||
on rend le silence visible dans les logs.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def _warn(self) -> None:
|
||||
logger.warning("Jinja: variable non définie utilisée dans un template: %r", self._undefined_name)
|
||||
|
||||
def __str__(self) -> str:
|
||||
self._warn()
|
||||
return ""
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
self._warn()
|
||||
return False
|
||||
|
||||
def __iter__(self):
|
||||
self._warn()
|
||||
return iter(())
|
||||
|
||||
def __len__(self) -> int:
|
||||
self._warn()
|
||||
return 0
|
||||
|
||||
# A20 : nonce de script par requête, posé par le middleware CSP, lu par les
|
||||
# templates via `{{ csp_nonce() }}` (vide hors requête — pas de header CSP
|
||||
@@ -25,6 +61,7 @@ CSRF_TOKEN: ContextVar[str] = ContextVar("csrf_token", default="")
|
||||
ENV = Environment(
|
||||
loader=FileSystemLoader("app/templates"),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
undefined=LoggingUndefined,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+374
-19
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.34.0"
|
||||
"version": "7.50.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
@@ -1675,7 +1675,7 @@
|
||||
"dashboard"
|
||||
],
|
||||
"summary": "Local Workspace Page",
|
||||
"description": "Local workspace page with file/folder tree.\n\nIf ?folder=ID is provided, shows that folder's contents with breadcrumb.",
|
||||
"description": "Local workspace page with file/folder tree.\n\nIf ?folder=ID is provided, shows that folder's contents with breadcrumb.\nIf ?ws=ID is provided, shows that workspace — c'est ce que fait le bouton\n« Home » de la sidebar (``/local-workspace?ws=<id>``). Le paramètre était\nproduit par le sidebar mais **ignoré** par la route : le contenu affiché\nrestait celui du workspace *actif* (cookie), donc Home pouvait montrer un\nautre workspace que celui annoncé dans l'URL. Un ``ws`` qui n'appartient\npas à l'utilisateur est ignoré (et le workspace actif conservé).",
|
||||
"operationId": "local_workspace_page_local_workspace_get",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1686,6 +1686,15 @@
|
||||
"type": "integer",
|
||||
"title": "Folder"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ws",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"title": "Ws"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -2419,7 +2428,7 @@
|
||||
"dashboard"
|
||||
],
|
||||
"summary": "Delete Workspace",
|
||||
"description": "Delete a workspace and all its pages.",
|
||||
"description": "Delete a workspace, its pages and its databases.",
|
||||
"operationId": "delete_workspace_api_workspaces__ws_id__delete",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -3218,7 +3227,7 @@
|
||||
"dashboard"
|
||||
],
|
||||
"summary": "Api Trash Page",
|
||||
"description": "Soft-delete a page (move to trash).",
|
||||
"description": "Soft-delete a page (move to trash).\n\nv7.45.5 : ``parent_section`` n'est plus réécrit en 'Trash' — ``deleted_at``\nest la seule source de vérité. L'ancienne écriture marquait définitivement\nla page : à la restauration elle restait 'Trash' et disparaissait des\nlistings Library / Recents / Private (``parent_section != 'Trash'``).",
|
||||
"operationId": "api_trash_page_api_pages__page_id__trash_post",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -4082,6 +4091,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/board/api/trash/empty": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"board"
|
||||
],
|
||||
"summary": "Empty Trash",
|
||||
"description": "Empty Trash : purge en masse (les enfants des pages supprimées passent\nen racine, comme permanent_delete — comportement historique conservé).",
|
||||
"operationId": "empty_trash_board_api_trash_empty_post",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/board/trash": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -7375,13 +7404,163 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/db/task-dbs/api": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"collections"
|
||||
],
|
||||
"summary": "List Connected Task Databases",
|
||||
"description": "Bases de tâches connectées au tableau de bord My Tasks.",
|
||||
"operationId": "list_connected_task_databases_db_task_dbs_api_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/db/{collection_id}/task-db/api": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"collections"
|
||||
],
|
||||
"summary": "Get Task Db State",
|
||||
"description": "État de conversion + colonnes candidates pour la modale.",
|
||||
"operationId": "get_task_db_state_db__collection_id__task_db_api_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "collection_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"title": "Collection Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"collections"
|
||||
],
|
||||
"summary": "Enable Task Db",
|
||||
"description": "Convertit la base en base de tâches (mapping explicite des 3 rôles).\n\n``mapping`` : ``{\"assignee\": <prop_id>, \"status\": …, \"due\": …}``.\n``create_missing`` : ``{\"status\": {\"name\": \"Statut\", \"options\": [...]}}``\npour qu'une colonne absente soit créée dans la transaction.",
|
||||
"operationId": "enable_task_db_db__collection_id__task_db_api_post",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "collection_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"title": "Collection Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"collections"
|
||||
],
|
||||
"summary": "Disable Task Db",
|
||||
"description": "Déconnecte la base du tableau de bord (les tâches sont conservées).",
|
||||
"operationId": "disable_task_db_db__collection_id__task_db_api_delete",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "collection_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"title": "Collection Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/db": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"collections"
|
||||
],
|
||||
"summary": "List Collections",
|
||||
"description": "Page listing all collections in the workspace.",
|
||||
"description": "Liste des bases du workspace, dans le layout de l'application.",
|
||||
"operationId": "list_collections_db_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -9290,7 +9469,7 @@
|
||||
"collections"
|
||||
],
|
||||
"summary": "Toggle Task",
|
||||
"description": "API: toggle is_task flag on a collection (Turn into Tasks).",
|
||||
"description": "API: toggle is_task flag on a collection (Turn into Tasks).\n\nRétrocompatible : l'activation passe désormais par la même conversion que\n``POST /db/{id}/task-db/api``, donc les trois colonnes requises sont liées\n(créées si besoin). Sans cela, cette route laissait une base « connectée »\nmais muette — elle n'émettait aucune tâche faute de mapping.",
|
||||
"operationId": "toggle_task_db__collection_id__toggle_task_api_put",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -9810,7 +9989,7 @@
|
||||
"my-tasks"
|
||||
],
|
||||
"summary": "My Tasks Dashboard",
|
||||
"description": "My Tasks — aggregates all pages assigned to the current user across all collections.",
|
||||
"description": "My Tasks — toutes vos tâches, tous workspaces confondus.",
|
||||
"operationId": "my_tasks_dashboard_my_tasks_get",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -9819,18 +9998,38 @@
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "all",
|
||||
"default": "table",
|
||||
"title": "View"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "days",
|
||||
"name": "due",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 7,
|
||||
"title": "Days"
|
||||
"type": "string",
|
||||
"default": "all",
|
||||
"title": "Due"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sort",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "due",
|
||||
"title": "Sort"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "hide_done",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"title": "Hide Done"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -9864,7 +10063,7 @@
|
||||
"my-tasks"
|
||||
],
|
||||
"summary": "My Tasks Api",
|
||||
"description": "API: return my tasks as JSON.",
|
||||
"description": "API: tâches normalisées + sources connectées (alimente les 3 vues).",
|
||||
"operationId": "my_tasks_api_my_tasks_api_get",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -9873,18 +10072,38 @@
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "all",
|
||||
"default": "table",
|
||||
"title": "View"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "days",
|
||||
"name": "due",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 7,
|
||||
"title": "Days"
|
||||
"type": "string",
|
||||
"default": "all",
|
||||
"title": "Due"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sort",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "due",
|
||||
"title": "Sort"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "hide_done",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"title": "Hide Done"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -9910,6 +10129,99 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/my-tasks/api/task": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"my-tasks"
|
||||
],
|
||||
"summary": "My Tasks Create",
|
||||
"description": "Création rapide multi-destinations : la tâche part dans la base choisie.\n\nElle est **auto-assignée** : sans colonne « Assigné à » renseignée, une\ntâche nouvelle n'apparaîtrait pas dans « mes tâches », ce qui serait\nillisible pour l'utilisateur qui vient de la créer.",
|
||||
"operationId": "my_tasks_create_my_tasks_api_task_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"title": "Body",
|
||||
"default": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/my-tasks/api/task/{page_id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"my-tasks"
|
||||
],
|
||||
"summary": "My Tasks Update",
|
||||
"description": "Édition en direct du statut / de l'échéance / du titre.\n\nL'écriture passe par le **mapping enregistré de la base source** (et non\npar une devinette « première colonne de type X »), puis la tâche est relue\net renvoyée normalisée : le front n'a rien à recalculer.",
|
||||
"operationId": "my_tasks_update_my_tasks_api_task__page_id__put",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "page_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"title": "Page Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspace/{ws_id}/members": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -10341,6 +10653,7 @@
|
||||
"workspace"
|
||||
],
|
||||
"summary": "List Favorites",
|
||||
"description": "List the caller's favorites.\n\nv7.46.0 — aligne sur le schéma v2.2.0 (``app/db.py``) : la table\n``favorites`` n'a plus de colonne ``collection_id`` et ``page_id``\nréférence ``pages(id)``. L'ancienne requête (jointure ``collection_pages``)\nlevait ``sqlite3.OperationalError: no such column: f.collection_id`` → 500.",
|
||||
"operationId": "list_favorites_workspace_favorites_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -11559,7 +11872,7 @@
|
||||
"/workspace/public/{collection_id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"workspace"
|
||||
"workspace-public"
|
||||
],
|
||||
"summary": "Public View",
|
||||
"description": "Simple public read-only view — no auth required.\n\nA18 : les bases ``restricted``/``private`` (``permission_type``) restent\nmasquées (404) et toute interpolation part dans ``html.escape`` (XSS stocké\nsur le titre de la base ou d'une ligne).",
|
||||
@@ -27580,6 +27893,48 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v2/meetings/upcoming": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"calendar-meetings"
|
||||
],
|
||||
"summary": "Upcoming Meetings",
|
||||
"description": "Upcoming calendar events for the sidebar Meeting tab.\n\nReads every calendar collection linked to the current user, extracts the\ndate property of each linked row and returns the ones falling in the next\n``days`` days (today included), sorted chronologically. Rows imported from\nGoogle/CalDAV carry an ``external_event_id`` and are flagged ``synced``.",
|
||||
"operationId": "upcoming_meetings_api_v2_meetings_upcoming_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "days",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 30,
|
||||
"title": "Days"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v2/meetings/transcribe": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// CSP build : x-data="foo()" — globale window vs Alpine.data, lequel résout ?
|
||||
const { chromium } = require('playwright-core');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
function findChromium() {
|
||||
const root = path.join(process.env.LOCALAPPDATA, 'ms-playwright');
|
||||
const dirs = fs.readdirSync(root).filter((d) => d.startsWith('chromium-') && !d.includes('headless'));
|
||||
dirs.sort();
|
||||
return path.join(root, dirs[dirs.length - 1], 'chrome-win64', 'chrome.exe');
|
||||
}
|
||||
const ALPINE = fs.readFileSync(
|
||||
'C:/Users/bruno/AppData/Local/hermes/cache/scratch/alpine_csp.js',
|
||||
'utf-8'
|
||||
);
|
||||
const html = `<!DOCTYPE html><html><body>
|
||||
<div id="a" x-data="composantGlobal()" x-init="init()"><span id="s1" x-text="v"></span></div>
|
||||
<div id="b" x-data="composantAlpineData()" x-init="init()"><span id="s2" x-text="v"></span></div>
|
||||
<script>${ALPINE.replace(/<\/script>/g, '<\\/script>')}</script>
|
||||
<script>
|
||||
function composantGlobal() { return { v: '?', init() { this.v = 'glok'; } }; }
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('composantAlpineData', () => ({ v: '?', init() { this.v = 'adok'; } }));
|
||||
});
|
||||
</script></body></html>`;
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true, executablePath: findChromium() });
|
||||
const page = await browser.newPage();
|
||||
const errs = [];
|
||||
page.on('pageerror', (e) => errs.push(e.message.slice(0, 120)));
|
||||
await page.setContent(html, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(500);
|
||||
const res = await page.evaluate(() => ({
|
||||
global: document.querySelector('#s1').textContent,
|
||||
alpineData: document.querySelector('#s2').textContent,
|
||||
}));
|
||||
console.log('RESULTATS:', JSON.stringify(res), 'ERREURS:', errs.length ? errs : 'aucune');
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,237 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* Aperçu CSP strict (A20 phase 3) : charge la page avec le build CSP
|
||||
* d'Alpine (fichier officiel `@alpinejs/csp`, 0 eval) servi à la place de
|
||||
* alpine.min.js via interception — SANS déployer. Toute expression que le
|
||||
* parseur maison ne digère pas = pageerror « CSP Parser Error » (filet) ;
|
||||
* les x-html restants = directive interdite du build (console error).
|
||||
* Quand toutes les surfaces passent ici → bascule réelle + retrait
|
||||
* d'unsafe-eval (ROADMAP A20 phase 3).
|
||||
*/
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
const errors = [];
|
||||
let currentUrl = '';
|
||||
test.beforeEach(async ({ page }) => {
|
||||
errors.length = 0;
|
||||
currentUrl = '';
|
||||
await page.route('**/static/js/alpine.min.js', (route) =>
|
||||
route.fulfill({
|
||||
path: require('path').join(__dirname, 'fixtures', 'alpine.csp.js'),
|
||||
contentType: 'application/javascript',
|
||||
})
|
||||
);
|
||||
page.on('console', (m) => {
|
||||
if (m.type() !== 'error') return;
|
||||
if (/Failed to load resource/.test(m.text())) return;
|
||||
errors.push(m.text());
|
||||
});
|
||||
page.on('pageerror', (e) =>
|
||||
errors.push('pageerror@' + (currentUrl || '?') + ': ' + e.message)
|
||||
);
|
||||
});
|
||||
test.afterEach(() => expect(errors).toEqual([]));
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page
|
||||
.waitForURL('**/workspaces', { timeout: 10000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!ok) {
|
||||
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||
data: { email: USER, password: PASS, name: 'E2E' },
|
||||
});
|
||||
if (resp.status() === 409) throw new Error('compte e2e existant — FD_USER/FD_PASS incorrects');
|
||||
if (!resp.ok()) throw new Error(`register ${resp.status()}: ${await resp.text()}`);
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function assertBound(page) {
|
||||
return page.evaluate(() => {
|
||||
const el = document.querySelector('[x-data]');
|
||||
if (!el || !window.Alpine) return 'absent';
|
||||
try {
|
||||
const d = window.Alpine.$data(el);
|
||||
return d && typeof d === 'object' ? 'ok' : 'vide';
|
||||
} catch (e) {
|
||||
return 'throw:' + e.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('A20-ph3 : surfaces simples sous build CSP (welcome/trash/accounts/workspace)', async ({ page }) => {
|
||||
// scan statique = 0 expression/x-html sur ces gabarits → ici on traque
|
||||
// les échecs RUNTIME (globales, timing de registre, scope de structure)
|
||||
// /welcome est anonyme (avant login aussi) mais login() ne gêne pas
|
||||
await login(page);
|
||||
for (const url of ['/welcome', '/trash', '/accounts', '/workspace', '/import',
|
||||
'/gitea-workspace']) {
|
||||
currentUrl = url;
|
||||
await page.goto(FD_BASE + url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(500);
|
||||
expect(await assertBound(page), `x-data non lié sur ${url}`).toBe('ok');
|
||||
}
|
||||
|
||||
// panneau agent (composant de base, x-html markdown migré via bindMarkdown)
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(400);
|
||||
const agent = await page.evaluate(() => {
|
||||
const el = document.querySelector('#fd-agent-panel');
|
||||
if (!el || !window.Alpine) return 'absent';
|
||||
try {
|
||||
const d = window.Alpine.$data(el);
|
||||
return d && typeof d === 'object' ? 'ok' : 'vide';
|
||||
} catch (e) {
|
||||
return 'throw:' + e.message;
|
||||
}
|
||||
});
|
||||
expect(agent).toBe('ok');
|
||||
});
|
||||
|
||||
test('A20-ph3 : éditeur de page (right_actions) sous build CSP', async ({ page }) => {
|
||||
// le topbar vit dans le scope appState : les12 sites window.E ont été
|
||||
// remplacés par edCall/edTimeAgo/edCommentCount/edShared/bindStar —
|
||||
// toute expression non parsable = pageerror (filet).
|
||||
await login(page);
|
||||
const coll = await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/db/api', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'e2e-csp-editor' }),
|
||||
});
|
||||
return r.json();
|
||||
});
|
||||
expect(coll.id, JSON.stringify(coll)).toBeTruthy();
|
||||
try {
|
||||
await page.goto(`${FD_BASE}/pages/${coll.id}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
// le composant éditeur est lié
|
||||
const editor = await page.evaluate(() => {
|
||||
const el = document.querySelector('#page-editor, .page-editor, [x-data]');
|
||||
const root = document.querySelector('.app-layout');
|
||||
const d1 = root && window.Alpine ? window.Alpine.$data(root) : null;
|
||||
const hasEd = d1 && typeof d1.edCall === 'function';
|
||||
let ed = 'absent';
|
||||
try {
|
||||
const cand = Array.from(document.querySelectorAll('[x-data]'))
|
||||
.map((e) => e.getAttribute('x-data'))
|
||||
.filter((a) => a && a.startsWith('editorState'));
|
||||
ed = cand.length ? 'ok' : 'aucun-editorState';
|
||||
} catch (e) { ed = 'throw'; }
|
||||
return { ed: ed, delegates: hasEd ? 'ok' : 'absent', el: !!el };
|
||||
});
|
||||
expect(editor.delegates).toBe('ok');
|
||||
expect(editor.ed).toBe('ok');
|
||||
// les boutons du topbar sont réellement servis (bug A10 « ~ + Markup »
|
||||
// corrigé : block-set) ET leurs expressions passent le filet CSP
|
||||
await expect(page.locator('.star-btn').first()).toBeAttached();
|
||||
} finally {
|
||||
await page.evaluate(async (id) => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch(`/db/api/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
|
||||
}, coll.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('UI : éditeur visuel de steps automations (API v7.0)', async ({ page }) => {
|
||||
// crée une automation, ouvre l'édition, ajoute une étape (POST /steps),
|
||||
// vérifie la carte résumée — sous CSP réel (expressions du nouveau bloc)
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(500);
|
||||
await page.click('.settings-nav-item:has-text("Automations")');
|
||||
const name = 'e2e-steps-' + Date.now();
|
||||
await page.fill('input[placeholder*="Notifier le statut"]', name);
|
||||
await page.click('button:has-text("Créer")');
|
||||
await page.waitForTimeout(700);
|
||||
const row = page.locator('.setting-row', { hasText: name }).last();
|
||||
await row.locator('button:has-text("✎")').click();
|
||||
await page.waitForTimeout(700);
|
||||
await expect(page.locator('text=Pipeline visuel (steps)')).toBeVisible();
|
||||
await page.click('button:has-text("Ajouter l\'étape")');
|
||||
await page.waitForFunction(
|
||||
() => Array.from(document.querySelectorAll('.setting-label'))
|
||||
.some((e) => (e.textContent || '').includes('Action · webhook')),
|
||||
null,
|
||||
{ timeout: 8000 }
|
||||
);
|
||||
// nettoyage API
|
||||
await page.evaluate(async (n) => {
|
||||
const d = await (await fetch('/workspace/automations')).json();
|
||||
const a = (d.automations || []).find((x) => x.name === n);
|
||||
if (a) {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch('/workspace/automations/' + a.id,
|
||||
{ method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
|
||||
await fetch('/workspace/automations/' + a.id + '/steps', { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
}, name);
|
||||
});
|
||||
|
||||
test('A20-ph3 : settings sous build Alpine CSP', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
expect(await assertBound(page)).toBe('ok');
|
||||
await expect(page.locator('.settings-overlay')).toBeVisible();
|
||||
});
|
||||
|
||||
test('A20-ph3 : local workspace sous build Alpine CSP', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/local-workspace`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
expect(await assertBound(page)).toBe('ok');
|
||||
|
||||
// recherche : toggleSearch() (méthode réelle) + focus Alpine.nextTick
|
||||
await page.click('button.ws-icon-btn[title="Search"]');
|
||||
await page.waitForTimeout(400);
|
||||
const focused = await page.evaluate(
|
||||
() => document.activeElement && document.activeElement.getAttribute('x-ref') === 'searchInput'
|
||||
);
|
||||
expect(focused).toBe(true);
|
||||
|
||||
// chips filtre : bindSvg() via x-init (x-html interdit en CSP) —
|
||||
// x-init tourne même si la rangée est masquée (x-show=filterType)
|
||||
const svg = await page.evaluate(
|
||||
() => (document.querySelector('button.filter-chip[title="Folders"]') || {}).innerHTML || ''
|
||||
);
|
||||
expect(svg).toContain('<svg');
|
||||
});
|
||||
|
||||
test('A20-ph3 : library sous build Alpine CSP', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// le composant est lié par le registre Alpine.data (scope CSP)
|
||||
expect(await assertBound(page)).toBe('ok');
|
||||
|
||||
// icône du empty-state : x-html remplacé par x-init + Alpine.effect
|
||||
await expect(page.locator('#lib-empty .empty-icon')).toBeVisible({ timeout: 8000 });
|
||||
const svg = await page.evaluate(
|
||||
() => document.querySelector('#lib-empty .empty-icon').innerHTML
|
||||
);
|
||||
expect(svg).toContain('<svg');
|
||||
|
||||
// bouton recherche : @click.stop → toggleSearch() (méthode réelle,
|
||||
// Alpine.nextTick pour le focus) — l'expression inline arrow n'existe plus
|
||||
await page.click('.lib-icon-btn[title="Search"]');
|
||||
await expect(page.locator('#lib-search-input')).toBeVisible();
|
||||
await page.waitForTimeout(300);
|
||||
const focused = await page.evaluate(
|
||||
() => document.activeElement && document.activeElement.id === 'lib-search-input'
|
||||
);
|
||||
expect(focused).toBe(true);
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Probe de fluidité de navigation partielle (fdLoad) — mesure brute, pas un test gate.
|
||||
*
|
||||
* Pour chaque navigation il enregistre :
|
||||
* - les marques htmx (beforeRequest / beforeSwap / afterSwap / afterSettle)
|
||||
* - la fenêtre x-ignore (zone non montée Alpine)
|
||||
* - un échantillon par frame : nb d'éléments [x-show] ENCORE VISIBLES non montés
|
||||
* (= le contenu « brut » qui se voit avant qu'Alpine ne le masque → les flashes
|
||||
* de fenêtres) et nb d'éléments [x-cloak] encore masqués (= contenu qui pop)
|
||||
* - les layout-shift (CLS) et leur source
|
||||
*
|
||||
* Lancement : npx playwright test probe_nav_perf --reporter=list
|
||||
*/
|
||||
const { test } = require('@playwright/test');
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
|
||||
if (!ok) {
|
||||
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
// Instrumentation posée UNE fois dans la page (avant le clic).
|
||||
const INSTALL = () => {
|
||||
const P = (window.__probe = { t0: performance.now(), marks: [], frames: [], shifts: [], rawMax: 0, rawFrames: 0 });
|
||||
const mark = (n) => P.marks.push([n, Math.round(performance.now() - P.t0)]);
|
||||
|
||||
['htmx:beforeRequest', 'htmx:beforeSwap', 'htmx:afterSwap', 'htmx:afterSettle'].forEach((ev) =>
|
||||
document.addEventListener(ev, () => mark(ev))
|
||||
);
|
||||
|
||||
// Fenêtre x-ignore sur .main-wrapper (le nœud change à chaque swap → on observe tout le doc)
|
||||
new MutationObserver((muts) => {
|
||||
for (const m of muts) {
|
||||
if (m.type !== 'attributes' || m.attributeName !== 'x-ignore') continue;
|
||||
const t = m.target;
|
||||
if (!(t instanceof Element) || !t.classList.contains('main-wrapper')) continue;
|
||||
mark(t.hasAttribute('x-ignore') ? 'x-ignore ON' : 'x-ignore OFF');
|
||||
}
|
||||
}).observe(document.documentElement, { attributes: true, attributeFilter: ['x-ignore'], subtree: true });
|
||||
|
||||
new PerformanceObserver((list) => {
|
||||
for (const e of list.getEntries()) {
|
||||
P.shifts.push({
|
||||
v: +e.value.toFixed(4),
|
||||
t: Math.round(e.startTime - P.t0),
|
||||
sources: (e.sources || []).map((s) => {
|
||||
const n = s.node;
|
||||
return n ? `${n.nodeName}.${(n.className || '').toString().split(' ')[0]}` : '?';
|
||||
}),
|
||||
});
|
||||
}
|
||||
}).observe({ type: 'layout-shift', buffered: false });
|
||||
|
||||
// Échantillonnage par frame. « visible » = peint à l'écran : la zone peut
|
||||
// porter opacity:0 (fix v7.45.3) → rects toujours là, mais rien n'est peint.
|
||||
const zonePainted = (z) => z && parseFloat(getComputedStyle(z).opacity || '1') > 0;
|
||||
const tick = () => {
|
||||
let raw = 0, rawVisible = 0, cloak = 0;
|
||||
const zone = document.querySelector('.main-wrapper');
|
||||
const painted = zonePainted(zone);
|
||||
if (zone) {
|
||||
for (const el of zone.querySelectorAll('[x-show]')) {
|
||||
if (el._x_marker) continue; // monté par Alpine
|
||||
raw++;
|
||||
if (painted && el.getClientRects().length) rawVisible++; // peint ET en flow
|
||||
}
|
||||
cloak = zone.querySelectorAll('[x-cloak]').length;
|
||||
}
|
||||
P.rawMax = Math.max(P.rawMax, rawVisible);
|
||||
if (rawVisible) P.rawFrames++;
|
||||
P.frames.push([Math.round(performance.now() - P.t0), raw, rawVisible, cloak, painted ? 1 : 0]);
|
||||
if (performance.now() - P.t0 < 3000) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
return true;
|
||||
};
|
||||
|
||||
test('probe fluidité nav', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await login(page);
|
||||
|
||||
const NAVS = ['/library', '/settings', '/workspaces', '/trash', '/local-workspace'];
|
||||
const results = [];
|
||||
const SHOTS = require('path').join(__dirname, 'shots');
|
||||
require('fs').mkdirSync(SHOTS, { recursive: true });
|
||||
|
||||
for (const dest of NAVS) {
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(1200);
|
||||
await page.evaluate(INSTALL);
|
||||
await page.evaluate((p) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = p;
|
||||
a.textContent = 'probe';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
}, dest);
|
||||
// Filet : si du contenu brut est peint, on capture pour voir CE qui flash
|
||||
let shot = null;
|
||||
const tEnd = Date.now() + 3200;
|
||||
while (Date.now() < tEnd && !shot) {
|
||||
const last = await page.evaluate(() => {
|
||||
const f = window.__probe.frames;
|
||||
return f.length ? f[f.length - 1] : null;
|
||||
});
|
||||
if (last && last[2] > 0) {
|
||||
shot = `navflash_${dest.replace(/\W+/g, '')}.png`;
|
||||
await page.screenshot({ path: require('path').join(SHOTS, shot) });
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
const P = await page.evaluate(() => window.__probe);
|
||||
// État final : la zone doit être révélée (opacity>0) ET montée Alpine,
|
||||
// et .app-layout (x-cloak anti-FOUC) doit être retiré → pas de page blanche
|
||||
const final = await page.evaluate(() => {
|
||||
const z = document.querySelector('.main-wrapper');
|
||||
const a = document.querySelector('.app-layout');
|
||||
if (!z) return { opacity: 'NO-ZONE', unmounted: -1, xIgnore: null };
|
||||
return {
|
||||
opacity: getComputedStyle(z).opacity,
|
||||
pointerEvents: getComputedStyle(z).pointerEvents,
|
||||
unmounted: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length,
|
||||
xIgnore: z.hasAttribute('x-ignore'),
|
||||
layout: a ? { cloak: a.hasAttribute('x-cloak'), display: getComputedStyle(a).display } : 'ABSENT',
|
||||
};
|
||||
});
|
||||
|
||||
// Résumé : première et dernière frame où du contenu brut visible subsiste
|
||||
const rawFrames = P.frames.filter((f) => f[2] > 0);
|
||||
const results_summary = {
|
||||
dest,
|
||||
marks: Object.fromEntries(P.marks),
|
||||
rawVisibleFrames: rawFrames.length,
|
||||
rawFirst: rawFrames.length ? rawFrames[0] : null,
|
||||
rawLast: rawFrames.length ? rawFrames[rawFrames.length - 1] : null,
|
||||
rawMaxElements: P.rawMax,
|
||||
hiddenFrames: P.frames.filter((f) => f[4] === 0).length,
|
||||
cloakMax: Math.max(0, ...P.frames.map((f) => f[3])),
|
||||
shifts: P.shifts,
|
||||
final,
|
||||
frameCount: P.frames.length,
|
||||
};
|
||||
results.push(results_summary);
|
||||
console.log('NAV ' + dest + ' => ' + JSON.stringify(results_summary));
|
||||
}
|
||||
|
||||
// GATE : aucun contenu brut peint + zone révélée et montée à la fin
|
||||
const paintedRaw = results.filter((r) => r.rawVisibleFrames > 0);
|
||||
const notRevealed = results.filter((r) => parseFloat(r.final.opacity) !== 1 || r.final.unmounted !== 0 || r.final.xIgnore
|
||||
|| !r.final.layout || r.final.layout.cloak === true || r.final.layout.display === 'none');
|
||||
console.log('SOMMAIRE ' + JSON.stringify(results.map((r) => ({ d: r.dest, raw: r.rawVisibleFrames, hidden: r.hiddenFrames, op: r.final.opacity, un: r.final.unmounted, shift: r.shifts.length }))));
|
||||
if (paintedRaw.length) throw new Error('contenu brut peint: ' + paintedRaw.map((r) => `${r.dest} (${r.rawVisibleFrames} frames, max ${r.rawMaxElements})`).join(' ; '));
|
||||
if (notRevealed.length) throw new Error('zone non révélée/montée: ' + JSON.stringify(notRevealed.map((r) => [r.dest, r.final])));
|
||||
console.log('GATE OK — 0 frame de contenu brut, zone révélée et montée sur ' + results.length + ' navigations');
|
||||
});
|
||||
|
||||
// Le geste réel du rapport : clic sur le bouton Home de la sidebar (et non un
|
||||
// ancre synthétique) — on mesure ce qui peint et on garde une capture si flash.
|
||||
test('probe bouton Home', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
const SHOTS = require('path').join(__dirname, 'shots');
|
||||
require('fs').mkdirSync(SHOTS, { recursive: true });
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(1200);
|
||||
await page.evaluate(INSTALL);
|
||||
|
||||
const console_ = [];
|
||||
page.on('console', (m) => { if (m.type() !== 'log') console_.push(m.type() + ' | ' + m.text().slice(0, 180).replace(/\s+/g, ' ')); });
|
||||
page.on('pageerror', (e) => console_.push('PAGEERROR | ' + String(e).slice(0, 180)));
|
||||
|
||||
const before = await page.evaluate(() => ({ onLine: navigator.onLine, home: !!document.querySelector('.home-btn'), href: (document.querySelector('.home-btn') || {}).href }));
|
||||
console.log('HOME avant clic ' + JSON.stringify(before));
|
||||
|
||||
await page.click('.home-btn');
|
||||
let shot = null;
|
||||
const tEnd = Date.now() + 4000;
|
||||
while (Date.now() < tEnd && !shot) {
|
||||
const last = await page.evaluate(() => {
|
||||
const f = window.__probe.frames;
|
||||
return f.length ? f[f.length - 1] : null;
|
||||
});
|
||||
if (last && last[2] > 0) {
|
||||
shot = 'home_flash.png';
|
||||
await page.screenshot({ path: require('path').join(SHOTS, shot) });
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
await page.waitForTimeout(2000);
|
||||
const P = await page.evaluate(() => window.__probe);
|
||||
const rawFrames = P.frames.filter((f) => f[2] > 0);
|
||||
const after = await page.evaluate(() => {
|
||||
const b = document.querySelector('.offline-banner');
|
||||
const z = document.querySelector('.main-wrapper');
|
||||
return {
|
||||
url: location.pathname + location.search,
|
||||
onLine: navigator.onLine,
|
||||
banner: b ? { cloak: b.hasAttribute('x-cloak'), display: getComputedStyle(b).display, mounted: !!b._x_marker } : 'absente',
|
||||
zone: z ? { op: getComputedStyle(z).opacity, un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length, xIgnore: z.hasAttribute('x-ignore') } : null,
|
||||
mounts: typeof window.__fdLocalWorkspaceScriptsLoaded !== 'undefined' ? window.__fdLocalWorkspaceScriptsLoaded : 'n/a',
|
||||
};
|
||||
});
|
||||
const res = { marks: Object.fromEntries(P.marks), rawVisibleFrames: rawFrames.length, rawFirst: rawFrames[0] || null, rawMax: P.rawMax, shot, shifts: P.shifts, console: console_, after };
|
||||
console.log('HOME résultat => ' + JSON.stringify(res));
|
||||
if (res.rawVisibleFrames > 0) throw new Error(`contenu brut peint au clic Home: ${res.rawVisibleFrames} frames (max ${res.rawMax})`);
|
||||
console.log('GATE HOME OK — 0 frame de contenu brut');
|
||||
});
|
||||
|
||||
// Scénario du rapport : la bande rouge dit « hors ligne » → on rejoue le clic
|
||||
// Home AVEC le réseau coupé (SW actif) pour voir ce qui peint.
|
||||
test('probe bouton Home hors ligne', async ({ page, context }) => {
|
||||
test.setTimeout(120000);
|
||||
const SHOTS = require('path').join(__dirname, 'shots');
|
||||
require('fs').mkdirSync(SHOTS, { recursive: true });
|
||||
await login(page);
|
||||
await page.addInitScript(INSTALL);
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(1500); // laisse le SW s'installer et cacher les pages
|
||||
await page.evaluate(() => { window.__probe = null; window.__probe = undefined; });
|
||||
await page.evaluate(INSTALL);
|
||||
|
||||
await context.setOffline(true);
|
||||
await page.waitForTimeout(200);
|
||||
const onLine = await page.evaluate(() => navigator.onLine);
|
||||
|
||||
await page.click('.home-btn');
|
||||
const shots = [];
|
||||
for (const [delay, name] of [[700, 't700'], [2500, 't2500']]) {
|
||||
await page.waitForTimeout(delay === 700 ? 700 : 1800);
|
||||
const p = `home_offline_${name}.png`;
|
||||
await page.screenshot({ path: require('path').join(SHOTS, p) }).catch(() => {});
|
||||
shots.push(p);
|
||||
}
|
||||
const P = await page.evaluate(() => window.__probe || null).catch(() => null);
|
||||
const after = await page.evaluate(() => {
|
||||
const b = document.querySelector('.offline-banner');
|
||||
const z = document.querySelector('.main-wrapper');
|
||||
const visible = (sel) => Array.from(document.querySelectorAll(sel)).filter((e) => e.getClientRects().length && getComputedStyle(e).display !== 'none').map((e) => (e.className || '').toString().split(' ')[0]).slice(0, 12);
|
||||
return {
|
||||
url: location.pathname + location.search,
|
||||
onLine: navigator.onLine,
|
||||
banner: b ? { display: getComputedStyle(b).display, mounted: !!b._x_marker } : 'absente',
|
||||
zone: z ? { op: getComputedStyle(z).opacity, un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length, xIgnore: z.hasAttribute('x-ignore') } : null,
|
||||
overlays: visible('.modal-overlay, .ctx-menu, .context-menu, .user-menu-dropdown, .dropdown-menu, .popover'),
|
||||
};
|
||||
}).catch((e) => ({ err: String(e) }));
|
||||
const rawFrames = P ? P.frames.filter((f) => f[2] > 0) : [];
|
||||
const res = { onLineBefore: onLine, marks: P ? Object.fromEntries(P.marks) : null, rawVisibleFrames: rawFrames.length, rawFirst: rawFrames[0] || null, rawMax: P ? P.rawMax : null, shots, shifts: P ? P.shifts : null, after };
|
||||
console.log('HOME OFFLINE => ' + JSON.stringify(res));
|
||||
});
|
||||
|
||||
// Charge COMPLÈTE (F5 / premier visit / SW) : le masquage fdLoad ne s'applique
|
||||
// pas là, seul x-cloak protège — on mesure ce qui peint avant Alpine.start().
|
||||
test('probe fluidité plein chargement', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
const SHOTS = require('path').join(__dirname, 'shots');
|
||||
require('fs').mkdirSync(SHOTS, { recursive: true });
|
||||
await login(page);
|
||||
await page.addInitScript(INSTALL);
|
||||
|
||||
const OUT = [];
|
||||
for (const dest of ['/local-workspace', '/library', '/workspaces']) {
|
||||
await page.goto(FD_BASE + dest, { waitUntil: 'commit' });
|
||||
let shot = null;
|
||||
const tEnd = Date.now() + 4000;
|
||||
while (Date.now() < tEnd && !shot) {
|
||||
const last = await page.evaluate(() => {
|
||||
const f = window.__probe && window.__probe.frames;
|
||||
return f && f.length ? f[f.length - 1] : null;
|
||||
}).catch(() => null);
|
||||
if (last && last[2] > 0) {
|
||||
shot = `fullflash_${dest.replace(/\W+/g, '')}.png`;
|
||||
await page.screenshot({ path: require('path').join(SHOTS, shot) });
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
await page.waitForTimeout(1500);
|
||||
const P = await page.evaluate(() => window.__probe);
|
||||
const rawFrames = P.frames.filter((f) => f[2] > 0);
|
||||
const res = {
|
||||
dest,
|
||||
rawVisibleFrames: rawFrames.length,
|
||||
rawFirst: rawFrames[0] || null,
|
||||
rawLast: rawFrames[rawFrames.length - 1] || null,
|
||||
rawMax: P.rawMax,
|
||||
shot,
|
||||
shifts: P.shifts,
|
||||
final: await page.evaluate(() => {
|
||||
const z = document.querySelector('.main-wrapper');
|
||||
const a = document.querySelector('.app-layout');
|
||||
return z ? {
|
||||
op: getComputedStyle(z).opacity,
|
||||
un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length,
|
||||
layout: a ? { cloak: a.hasAttribute('x-cloak'), display: getComputedStyle(a).display } : 'ABSENT',
|
||||
} : null;
|
||||
}),
|
||||
};
|
||||
OUT.push(res);
|
||||
console.log('FULL ' + dest + ' => ' + JSON.stringify(res));
|
||||
}
|
||||
console.log('SOMMAIRE_FULL ' + JSON.stringify(OUT.map((r) => [r.dest, r.rawVisibleFrames, r.rawMax, r.shot])));
|
||||
const notRevealed = OUT.filter((r) => !r.final || parseFloat(r.final.op) !== 1 || r.final.un !== 0
|
||||
|| !r.final.layout || r.final.layout.cloak === true || r.final.layout.display === 'none');
|
||||
if (notRevealed.length) throw new Error('page blanche/non montée au chargement: ' + JSON.stringify(notRevealed.map((r) => [r.dest, r.final])));
|
||||
if (OUT.some((r) => r.rawVisibleFrames > 0)) {
|
||||
throw new Error('contenu brut peint au plein chargement: ' + OUT.filter((r) => r.rawVisibleFrames > 0).map((r) => `${r.dest} (${r.rawVisibleFrames} frames, max ${r.rawMax})`).join(' ; '));
|
||||
}
|
||||
console.log('GATE FULL OK — 0 frame de contenu brut au chargement complet');
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/** Diag temporaire : navigation partielle vers l'éditeur, erreurs par phase */
|
||||
const { test } = require('@playwright/test');
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
|
||||
if (!ok) {
|
||||
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
function alpineErrs(msgs) {
|
||||
return msgs.filter((m) => m.includes('Alpine Expression Error') || m.includes('PAGEERROR'));
|
||||
}
|
||||
|
||||
async function partialClick(page, pid) {
|
||||
await page.evaluate((id) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = '/pages/' + id;
|
||||
a.textContent = 'x';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
}, pid);
|
||||
}
|
||||
|
||||
test('diag phases', async ({ page }) => {
|
||||
test.setTimeout(90000);
|
||||
const msgs = [];
|
||||
page.on('console', (m) => msgs.push(m.type() + ' | ' + m.text().slice(0, 160).replace(/\s+/g, ' ')));
|
||||
page.on('pageerror', (e) => msgs.push('PAGEERROR | ' + String(e).slice(0, 200)));
|
||||
|
||||
await login(page);
|
||||
const pid = await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/api/local-workspace/items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'Diag2', type: 'page' }),
|
||||
});
|
||||
const d = await r.json();
|
||||
return d.id || (d.item && d.item.id);
|
||||
});
|
||||
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(800);
|
||||
msgs.length = 0;
|
||||
|
||||
// PHASE A : navigation partielle (clic intercepte par fdLoad)
|
||||
await partialClick(page, pid);
|
||||
await page.waitForTimeout(4000);
|
||||
const stackA = await page.evaluate(() => {
|
||||
const el = document.querySelector('[x-data="editorState()"]');
|
||||
const st = el && el._x_dataStack;
|
||||
return { stack: st ? st.map((o) => (o ? Object.keys(o).length : -1)) : 'none', refreshed: !!(el && el.__fdRefreshed) };
|
||||
});
|
||||
const trace = await page.evaluate(() => window.__fdTrace || []);
|
||||
stackA.trace = trace;
|
||||
console.log('PHASE A (partielle): alpineErrs=', alpineErrs(msgs).length, 'total=', msgs.length, JSON.stringify(stackA));
|
||||
console.log('TRACE:', JSON.stringify(trace));
|
||||
alpineErrs(msgs).slice(0, 6).forEach((m) => console.log(' A>', m));
|
||||
msgs.filter((m) => !m.includes('Alpine Expression')).slice(0, 5).forEach((m) => console.log(' A-other>', m));
|
||||
|
||||
// A2 : les erreurs s'arretent-elles apres le montage (fenetre transitoire) ?
|
||||
const a1 = alpineErrs(msgs).length;
|
||||
await page.waitForTimeout(3000);
|
||||
const a2 = alpineErrs(msgs).length;
|
||||
const ui = await page.evaluate(() => ({
|
||||
title: !!document.getElementById('_titleEl'),
|
||||
blocks: document.querySelectorAll('[data-bid]').length,
|
||||
editorVisible: !!document.querySelector('.page-editor-wrapper, .editor-wrap, [x-data^="editorState"]'),
|
||||
}));
|
||||
console.log('PHASE A2: erreurs a t0=', a1, '-> apres +3s=', a2, 'UI=', JSON.stringify(ui));
|
||||
|
||||
// PHASE B : rechargement complet de la meme page
|
||||
msgs.length = 0;
|
||||
await page.goto(`${FD_BASE}/pages/${pid}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(3000);
|
||||
console.log('PHASE B (complet): alpineErrs=', alpineErrs(msgs).length, 'total=', msgs.length);
|
||||
alpineErrs(msgs).slice(0, 6).forEach((m) => console.log(' B>', m));
|
||||
|
||||
// PHASE C : deuxieme navigation partielle vers la meme page
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
msgs.length = 0;
|
||||
await partialClick(page, pid);
|
||||
await page.waitForTimeout(3500);
|
||||
console.log('PHASE C (2e partielle): alpineErrs=', alpineErrs(msgs).length, 'total=', msgs.length);
|
||||
alpineErrs(msgs).slice(0, 6).forEach((m) => console.log(' C>', m));
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Régression — 2 bugs rapportés (post v7.45.0) :
|
||||
* 1. Logout : le SW servait sa page « hors ligne » sur TOUTE navigation
|
||||
* redirigée (fetch event en redirect:'manual' → opaqueredirect → rejet).
|
||||
* 2. Drag & drop de fichiers : POST /api/local-workspace/upload sans
|
||||
* X-CSRF-Token → 403 (A19 a retiré l'exemption sans équiper l'appel).
|
||||
*
|
||||
* Instance attendue sur FD_BASE_URL (défaut 8080), compte e2e documenté.
|
||||
*/
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page
|
||||
.waitForURL('**/workspaces', { timeout: 8000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!ok) {
|
||||
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||
data: { email: USER, password: PASS, name: 'E2E' },
|
||||
});
|
||||
if (!resp.ok() && resp.status() !== 409) {
|
||||
throw new Error(`register ${resp.status()}: ${await resp.text()}`);
|
||||
}
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
// workspace actif requis par /api/local-workspace/upload (A22)
|
||||
const ws = await page.evaluate(async () => (await fetch('/api/workspaces')).json());
|
||||
if (!ws.workspaces || ws.workspaces.length === 0) {
|
||||
await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'E2E workspace' }),
|
||||
});
|
||||
const w = await r.json();
|
||||
await fetch(`/api/workspaces/${w.id}/select`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf },
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('logout avec SW : atterrit sur la page login, pas hors ligne', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.evaluate(async () => {
|
||||
if ('serviceWorker' in navigator) await navigator.serviceWorker.ready;
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
expect(await page.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
|
||||
|
||||
await page.goto(`${FD_BASE}/auth/logout`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL(/\/auth\/login/, { timeout: 10000 });
|
||||
const body = await page.evaluate(() => document.body.innerText);
|
||||
expect(body).not.toContain('hors ligne');
|
||||
expect(page.url()).toContain('/auth/login');
|
||||
});
|
||||
|
||||
test('drop de fichier : upload accepté (CSRF), 200 et item créé', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/local-workspace`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const uploadResp = [];
|
||||
page.on('response', (r) => {
|
||||
if (r.url().includes('/api/local-workspace/upload')) {
|
||||
uploadResp.push(
|
||||
r.text().then((body) => ({ status: r.status(), body })).catch(() => ({ status: r.status(), body: '' }))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await page.evaluate(() => {
|
||||
const zone = document.querySelector('[x-data*="wsInitData"]') || document.body;
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File(['hello world'], 'e2e-drop.txt', { type: 'text/plain' }));
|
||||
zone.dispatchEvent(new DragEvent('dragover', { dataTransfer: dt, bubbles: true, cancelable: true }));
|
||||
zone.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
await expect.poll(() => uploadResp.length, { timeout: 15000 }).toBeGreaterThan(0);
|
||||
const resp = await uploadResp[0];
|
||||
expect(resp.status, resp.body).toBe(200);
|
||||
|
||||
// Nettoyage : l'upload crée une page fichier → on la supprime.
|
||||
const parsed = JSON.parse(resp.body || '{}');
|
||||
for (const item of parsed.items || []) {
|
||||
if (!item.id) continue;
|
||||
await page.evaluate(async (pid) => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch('/api/local-workspace/items/' + pid, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrf },
|
||||
});
|
||||
}, item.id);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/** Sweep diag : nav partielle vs complet sur N pages — erreurs + état fdCtx/appState */
|
||||
const { test } = require('@playwright/test');
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
|
||||
if (!ok) {
|
||||
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
const PAGES = ['/workspaces', '/library', '/local-workspace', '/settings', '/trash', '/accounts', '/import'];
|
||||
|
||||
test('sweep', async ({ page }) => {
|
||||
test.setTimeout(180000);
|
||||
const msgs = [];
|
||||
page.on('console', (m) => msgs.push(m.type() + ' | ' + m.text().slice(0, 200).replace(/\s+/g, ' ')));
|
||||
page.on('pageerror', (e) => msgs.push('PAGEERROR | ' + String(e).slice(0, 160)));
|
||||
|
||||
await login(page);
|
||||
|
||||
for (const path of PAGES) {
|
||||
// baseline : chargement complet
|
||||
msgs.length = 0;
|
||||
await page.goto(FD_BASE + path, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(2200);
|
||||
const fullErrs = msgs.filter((m) => m.includes('PAGEERROR') || m.includes('Alpine Expression Error') || m.includes('Uncaught'));
|
||||
const stFull = await page.evaluate(() => ({
|
||||
fdCtx: (() => { try { return typeof Alpine.store('fdCtx'); } catch (e) { return 'ERR'; } })(),
|
||||
fdCtxHas: (() => { try { var s = Alpine.store('fdCtx'); return s ? typeof s.has + '/' + typeof s.avail : 'no store'; } catch (e) { return 'ERR'; } })(),
|
||||
appState: typeof window.appState,
|
||||
}));
|
||||
|
||||
// nav partielle : depuis /workspaces
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(700);
|
||||
msgs.length = 0;
|
||||
await page.evaluate((p) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = p;
|
||||
a.textContent = 'x';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
}, path);
|
||||
await page.waitForTimeout(2500);
|
||||
const partErrs = msgs.filter((m) => m.includes('PAGEERROR') || m.includes('Alpine Expression Error') || m.includes('Uncaught'));
|
||||
// 2e visite partielle dans LE MEME document (declencheur du SyntaxError
|
||||
// « Identifier ... has already been declared » sur script sans garde)
|
||||
await page.evaluate(() => {
|
||||
const a = document.createElement('a');
|
||||
a.href = '/workspaces';
|
||||
a.textContent = 'x';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
});
|
||||
await page.waitForTimeout(900);
|
||||
msgs.length = 0;
|
||||
await page.evaluate((p) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = p;
|
||||
a.textContent = 'x';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
}, path);
|
||||
await page.waitForTimeout(2500);
|
||||
const part2Errs = msgs.filter((m) => m.includes('PAGEERROR') || m.includes('Alpine Expression Error') || m.includes('Uncaught') || m.includes('already been declared'));
|
||||
const stPart = await page.evaluate(() => ({
|
||||
fdCtx: (() => { try { return typeof Alpine.store('fdCtx'); } catch (e) { return 'ERR'; } })(),
|
||||
fdCtxHas: (() => { try { var s = Alpine.store('fdCtx'); return s ? typeof s.has + '/' + typeof s.avail : 'no store'; } catch (e) { return 'ERR'; } })(),
|
||||
appState: typeof window.appState,
|
||||
lwGuard: !!window.__fdLocalWorkspaceScriptsLoaded,
|
||||
}));
|
||||
|
||||
console.log(`PAGE ${path}`);
|
||||
console.log(` complet : errs=${fullErrs.length} ${JSON.stringify(stFull)}`);
|
||||
fullErrs.slice(0, 2).forEach((m) => console.log(' F>', m));
|
||||
console.log(` 1ere partiel : errs=${partErrs.length} ${JSON.stringify(stPart)}`);
|
||||
partErrs.slice(0, 3).forEach((m) => console.log(' P>', m));
|
||||
console.log(` 2e partiel : errs=${part2Errs.length}`);
|
||||
part2Errs.slice(0, 3).forEach((m) => console.log(' P2>', m));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* Sidebar — navigation par onglets compressibles (Home / Chat / Meeting / Inbox).
|
||||
*
|
||||
* - ordre exact des onglets ;
|
||||
* - l'onglet actif est une pilule avec son libellé, les autres sont réduits à
|
||||
* l'icône (largeur mesurée) ;
|
||||
* - chaque sélection remplace le contenu de la barre latérale ;
|
||||
* - les panneaux Chat / Meeting / Inbox interrogent leurs vraies API.
|
||||
*/
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
try {
|
||||
await page.waitForURL('**/workspaces', { timeout: 8000 });
|
||||
} catch (e) {
|
||||
// Compte absent de l'instance (harnais de test documenté du repo, comme
|
||||
// smoke.spec.js) : on le crée puis on rejoue la connexion.
|
||||
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||
data: { email: USER, password: PASS, name: 'E2E' },
|
||||
});
|
||||
if (!resp.ok()) throw new Error(`register ${resp.status()}: ${await resp.text()}`);
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
const panelText = (page, tab) =>
|
||||
page.evaluate(
|
||||
(t) => {
|
||||
const el = document.querySelector(`.sidebar-tab-panel[x-show*="'${t}'"]`);
|
||||
if (!el) return null;
|
||||
const hidden = el.offsetParent === null;
|
||||
return { hidden, text: (el.innerText || '').trim().slice(0, 200) };
|
||||
},
|
||||
tab
|
||||
);
|
||||
|
||||
test('les quatre onglets se succèdent et le contenu bascule', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.sidebar-nav-row');
|
||||
|
||||
// ordre des onglets
|
||||
const order = await page.$$eval('.sidebar-nav-row [data-tab]', (els) =>
|
||||
els.map((e) => e.getAttribute('data-tab'))
|
||||
);
|
||||
expect(order).toEqual(['home', 'chat', 'meetings', 'inbox']);
|
||||
|
||||
// Home actif par défaut : pilule (libellé visible), les autres réduite à l'icône
|
||||
let labels = await page.$$eval('.sidebar-nav-row [data-tab]', (els) =>
|
||||
els.map((e) => ({
|
||||
tab: e.getAttribute('data-tab'),
|
||||
active: e.classList.contains('active'),
|
||||
labelVisible: e.querySelector('.nav-tab-label').offsetParent !== null,
|
||||
width: Math.round(e.getBoundingClientRect().width),
|
||||
}))
|
||||
);
|
||||
expect(labels.find((l) => l.tab === 'home').labelVisible).toBe(true);
|
||||
labels.filter((l) => l.tab !== 'home').forEach((l) => {
|
||||
expect(l.active).toBe(false);
|
||||
expect(l.labelVisible).toBe(false);
|
||||
});
|
||||
const homeWidth = labels.find((l) => l.tab === 'home').width;
|
||||
const chatWidth = labels.find((l) => l.tab === 'chat').width;
|
||||
expect(homeWidth).toBeGreaterThan(chatWidth + 15);
|
||||
|
||||
// Chat : le panneau Home disparaît, celui de Chat est visible
|
||||
await page.click('.sidebar-nav-row [data-tab="chat"]');
|
||||
await page.waitForTimeout(400);
|
||||
expect((await panelText(page, 'home')).hidden).toBe(true);
|
||||
const chat = await panelText(page, 'chat');
|
||||
expect(chat.hidden).toBe(false);
|
||||
expect(chat.text).toMatch(/CHAT/i);
|
||||
|
||||
labels = await page.$$eval('.sidebar-nav-row [data-tab]', (els) =>
|
||||
els.map((e) => ({
|
||||
tab: e.getAttribute('data-tab'),
|
||||
active: e.classList.contains('active'),
|
||||
labelVisible: e.querySelector('.nav-tab-label').offsetParent !== null,
|
||||
}))
|
||||
);
|
||||
expect(labels.find((l) => l.tab === 'chat')).toEqual({
|
||||
tab: 'chat',
|
||||
active: true,
|
||||
labelVisible: true,
|
||||
});
|
||||
|
||||
// Meeting
|
||||
await page.click('.sidebar-nav-row [data-tab="meetings"]');
|
||||
await page.waitForTimeout(400);
|
||||
const meetings = await panelText(page, 'meetings');
|
||||
expect(meetings.hidden).toBe(false);
|
||||
expect(meetings.text).toMatch(/MEETING/i);
|
||||
expect(await page.locator('.sb-quick-btn').count()).toBe(2);
|
||||
|
||||
// Inbox
|
||||
await page.click('.sidebar-nav-row [data-tab="inbox"]');
|
||||
await page.waitForTimeout(400);
|
||||
const inbox = await panelText(page, 'inbox');
|
||||
expect(inbox.hidden).toBe(false);
|
||||
expect(inbox.text).toMatch(/INBOX/i);
|
||||
|
||||
// retour Home : l'arbre du workspace est de nouveau là, et UNIQUEMENT Home
|
||||
// porte son libellé (régression : clic Home → texte sur tous les boutons)
|
||||
await page.click('.sidebar-nav-row [data-tab="home"]');
|
||||
await page.waitForTimeout(300);
|
||||
expect((await panelText(page, 'home')).hidden).toBe(false);
|
||||
await expect(page.locator('#sidebar-workspace-items, .sidebar-static-links').first()).toBeVisible();
|
||||
labels = await page.$$eval('.sidebar-nav-row [data-tab]', (els) =>
|
||||
els.map((e) => ({
|
||||
tab: e.getAttribute('data-tab'),
|
||||
labelVisible: e.querySelector('.nav-tab-label').offsetParent !== null,
|
||||
}))
|
||||
);
|
||||
expect(labels.find((l) => l.tab === 'home').labelVisible).toBe(true);
|
||||
labels.filter((l) => l.tab !== 'home').forEach((l) => {
|
||||
expect(l.labelVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('le libellé suit l’onglet actif même avec une CSS périmée (cache)', async ({ page }) => {
|
||||
// ?v={{ asset_version }} = fichier VERSION : une feuille de style peut donc
|
||||
// être servie depuis le cache du navigateur. On rejoue ce cas en neutralisant
|
||||
// les règles .nav-tab-label de la feuille courante.
|
||||
await page.route('**/static/css/app.css*', async (route) => {
|
||||
const res = await route.fetch();
|
||||
let body = await res.text();
|
||||
body = body.replace(/\.nav-tab-btn \.nav-tab-label \{[^}]*\}/g, '');
|
||||
body = body.replace(/\.nav-tab-btn\.active \.nav-tab-label \{[^}]*\}/g, '');
|
||||
body = body.replace(/\.nav-tab-btn\.active \.nav-tab-badge \{[^}]*\}/g, '');
|
||||
await route.fulfill({ response: res, body });
|
||||
});
|
||||
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.sidebar-nav-row');
|
||||
const visible = async () =>
|
||||
page.$$eval('.sidebar-nav-row [data-tab]', (els) =>
|
||||
els.map((e) => ({
|
||||
tab: e.getAttribute('data-tab'),
|
||||
labelVisible: e.querySelector('.nav-tab-label').offsetParent !== null,
|
||||
}))
|
||||
);
|
||||
|
||||
// CSS sans les règles de libellé → seul l'onglet actif garde son texte
|
||||
for (const active of ['home', 'chat', 'meetings', 'inbox']) {
|
||||
await page.click(`.sidebar-nav-row [data-tab="${active}"]`);
|
||||
await page.waitForTimeout(250);
|
||||
const state = await visible();
|
||||
expect(state.filter((s) => s.labelVisible).map((s) => s.tab)).toEqual([active]);
|
||||
}
|
||||
});
|
||||
|
||||
test('le panneau Meeting appelle /api/v2/meetings/upcoming', async ({ page }) => {
|
||||
const calls = [];
|
||||
page.on('request', (r) => {
|
||||
if (r.url().includes('/api/v2/meetings/upcoming')) calls.push(r.url());
|
||||
});
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.click('.sidebar-nav-row [data-tab="meetings"]');
|
||||
await page.waitForResponse((r) => r.url().includes('/api/v2/meetings/upcoming'), { timeout: 10000 });
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const status = await page.evaluate(async () => {
|
||||
const r = await fetch('/api/v2/meetings/upcoming?days=30', { credentials: 'same-origin' });
|
||||
return r.status;
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
/**
|
||||
* Smoke E2E — fondations vérifiant les portes des reports d'audit :
|
||||
* - A39 : bascule de vues (création d'une vue Board depuis la barre de
|
||||
* vues d'une collection → rendu de la grille)
|
||||
* - A20 : Alpine + palette de commandes (Ctrl+K, recherche GET, fermeture)
|
||||
* - filet : 0 erreur console (les violations CSP atterrissent ici)
|
||||
*
|
||||
* READ-ONLY sur les données existantes : crée puis SUPPRIME sa collection
|
||||
* (répétable). Instance de test attendue sur FD_BASE_URL (défaut 8080).
|
||||
*/
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
// Service Workers BLOQUÉS : /sw.js sert sa page « hors ligne » quand la
|
||||
// réponse de navigation est une redirection (redirect:'manual' sur les
|
||||
// requêtes navigate) — bruit PWA hors sujet ici (pwa_offline.spec.js
|
||||
// couvre le SW). On interroge le serveur directement.
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
const consoleErrors = [];
|
||||
test.beforeEach(async ({ page }) => {
|
||||
consoleErrors.length = 0;
|
||||
page.on('console', (m) => {
|
||||
if (m.type() !== 'error') return;
|
||||
// les 401 de ressources (checks de session sur login) sont du bruit
|
||||
// navigateur, pas une erreur JS/CSP — le reste compte
|
||||
if (/Failed to load resource/.test(m.text())) return;
|
||||
consoleErrors.push(m.text());
|
||||
});
|
||||
page.on('pageerror', (e) => consoleErrors.push('pageerror: ' + e.message));
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
// Aucune erreur JS/CSP pendant le scénario
|
||||
expect(consoleErrors).toEqual([]);
|
||||
});
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await Promise.race([
|
||||
page
|
||||
.waitForURL('**/workspaces', { timeout: 8000 })
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
]);
|
||||
if (!ok) {
|
||||
// Compte absent de l'instance de test → création (bootstrap du harness,
|
||||
// pas un mot de passe deviné : c'est le compte e2e documenté du repo).
|
||||
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||
data: { email: USER, password: PASS, name: 'E2E' },
|
||||
});
|
||||
if (resp.status() === 409) {
|
||||
throw new Error(
|
||||
'compte e2e existant mais mot de passe refusé — définir FD_USER/FD_PASS'
|
||||
);
|
||||
}
|
||||
if (!resp.ok()) {
|
||||
throw new Error(`register ${resp.status()}: ${await resp.text()}`);
|
||||
}
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
// workspace requis pour créer une collection (compte neuf = aucun ws)
|
||||
const ws = await page.evaluate(async () => (await fetch('/api/workspaces')).json());
|
||||
if (!ws.workspaces || ws.workspaces.length === 0) {
|
||||
await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'E2E workspace' }),
|
||||
});
|
||||
const w = await r.json();
|
||||
await fetch(`/api/workspaces/${w.id}/select`, { method: 'POST' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('gate A39 : bascule de vues (table → Calendar, rendu par onglet)', async ({ page }) => {
|
||||
await login(page);
|
||||
|
||||
// collection jetable (créée puis supprimée = répétable). /db/{id} est une
|
||||
// page STANDALONE (hors base.html) : les onglets .view-tabs naviguent en
|
||||
// ?view=… et le corps est rendu côté serveur par _render_view().
|
||||
const coll = await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/db/api', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'e2e-view-switch' }),
|
||||
});
|
||||
return r.json();
|
||||
});
|
||||
expect(coll.id, `création collection: ${JSON.stringify(coll)}`).toBeTruthy();
|
||||
try {
|
||||
await page.goto(`${FD_BASE}/db/${coll.id}`, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.view-tabs a.tab')).toHaveCount(11);
|
||||
await expect(page.locator('.calendar')).toHaveCount(0); // vue table par défaut
|
||||
|
||||
// bascule réelle : clic sur l'onglet Calendar → navigation ?view=calendar
|
||||
await page.click('.view-tabs a.tab:has-text("Calendar")');
|
||||
await page.waitForURL(/view_type=calendar/, { timeout: 10000 });
|
||||
await expect(page.locator('.view-tabs a.tab.active')).toContainText('Calendar');
|
||||
// corps Calendar rendu par _render_calendar (grille 6×7)
|
||||
await expect(page.locator('.calendar')).toHaveCount(1);
|
||||
expect(await page.locator('.cal-header').count()).toBeGreaterThanOrEqual(7);
|
||||
} finally {
|
||||
await page.evaluate(async (id) => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch(`/db/api/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
|
||||
}, coll.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('gate A20 : palette Ctrl+K (Alpine + recherche GET)', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await page.keyboard.press('Control+k');
|
||||
await page.waitForTimeout(400);
|
||||
// overlay ouvert (classe .open pilotée par l'IIFE de base.html)
|
||||
const open = await page.evaluate(() => {
|
||||
const ov = document.querySelector('#fd-command-palette');
|
||||
return !!ov && ov.classList.contains('open');
|
||||
});
|
||||
expect(open).toBe(true);
|
||||
|
||||
// tape une requête → la recherche GET répond et rend des résultats
|
||||
await page.keyboard.type('a');
|
||||
await page.waitForTimeout(900);
|
||||
const items = await page.evaluate(
|
||||
() => document.querySelectorAll('.cmd-palette-item').length
|
||||
);
|
||||
expect(items).toBeGreaterThan(0);
|
||||
|
||||
// Alpine doit être lié (build CSP : le x-data + ses expressions évalués
|
||||
// SANS eval) sur un composant réel de la page
|
||||
const alpine = await page.evaluate(() => {
|
||||
const el = document.querySelector('[x-data]');
|
||||
if (!el || !window.Alpine) return 'absent';
|
||||
try {
|
||||
const data = window.Alpine.$data(el);
|
||||
return data && typeof data === 'object' ? 'ok:' + Object.keys(data).slice(0, 3).join(',') : 'vide';
|
||||
} catch (e) {
|
||||
return 'throw:' + e.message;
|
||||
}
|
||||
});
|
||||
expect(alpine).toMatch(/^ok:/);
|
||||
|
||||
// Échap ferme la palette
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
const closed = await page.evaluate(() => {
|
||||
const ov = document.querySelector('#fd-command-palette');
|
||||
return !ov || !ov.classList.contains('open');
|
||||
});
|
||||
expect(closed).toBe(true);
|
||||
|
||||
// onglet ✨ Réponses IA : POST /api/v2/search/ask (extractif hors-LM)
|
||||
await page.keyboard.press('Control+k');
|
||||
await page.waitForTimeout(300);
|
||||
await page.click('.cp-tab[data-tab="ai"]');
|
||||
await page.waitForSelector('.cmd-palette-ai', { timeout: 10000 });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const el = document.querySelector('.cmd-palette-ai');
|
||||
return el && !el.querySelector('.cp-loading');
|
||||
},
|
||||
null,
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
const ai = await page.evaluate(() => {
|
||||
const el = document.querySelector('.cmd-palette-ai');
|
||||
return { text: ((el && el.textContent) || '').trim() };
|
||||
});
|
||||
expect(ai.text.length).toBeGreaterThan(5);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Page /trash (v7.45.5) : rendu, dates, sélection groupée, restauration, purge.
|
||||
*
|
||||
* Couvre aussi le fix CSRF du front : l'ancien code faisait `csrf?.[1]` alors
|
||||
* que getCsrf() renvoie une CHAÎNE → 403 silencieux, les boutons ne marchaient
|
||||
* pas. Ici on clique pour de vrai et on vérifie l'état via l'API.
|
||||
*/
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
|
||||
if (!ok) {
|
||||
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
// Appel API depuis la page (cookies + jeton CSRF du document).
|
||||
async function api(page, method, url) {
|
||||
return page.evaluate(async ([m, u]) => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch(u, { method: m, headers: { 'X-CSRF-Token': csrf } });
|
||||
return { ok: r.ok, status: r.status, body: await r.json().catch(() => null) };
|
||||
}, [method, url]);
|
||||
}
|
||||
|
||||
async function mkTrashed(page, name) {
|
||||
const c = await api(page, 'POST', '/board/api/pages?title=' + encodeURIComponent(name));
|
||||
expect(c.ok, 'create page').toBeTruthy();
|
||||
const t = await api(page, 'POST', '/api/pages/' + c.body.id + '/trash');
|
||||
expect(t.ok, 'soft delete').toBeTruthy();
|
||||
return c.body.id;
|
||||
}
|
||||
|
||||
const trashIds = async (page) => {
|
||||
const r = await api(page, 'GET', '/board/api/trash');
|
||||
expect(r.ok).toBeTruthy();
|
||||
return (r.body || []).map((i) => i.id);
|
||||
};
|
||||
|
||||
test('trash : rendu, dates, sélection groupée, restauration, purge', async ({ page }) => {
|
||||
test.setTimeout(90000);
|
||||
const errs = [];
|
||||
page.on('pageerror', (e) => errs.push('PAGEERROR ' + String(e).slice(0, 160)));
|
||||
page.on('console', (m) => { if (m.type() === 'error') errs.push('CONSOLE ' + m.text().slice(0, 160)); });
|
||||
// Aucun dialog système acceptable : on échoue si le navigateur ouvre un
|
||||
// confirm()/alert() natif (c'est tout l'objet du changement).
|
||||
page.on('dialog', (d) => { errs.push('NATIVE_DIALOG ' + d.message()); d.dismiss().catch(() => {}); });
|
||||
|
||||
await login(page);
|
||||
|
||||
// DB persistante : nommage unique par exécution, on ne dépend jamais des
|
||||
// restes des runs précédents.
|
||||
const TAG = 'E2E Trash ' + Date.now().toString(36).toUpperCase();
|
||||
const nameA = TAG + ' A', nameB = TAG + ' B', nameC = TAG + ' C', nameD = TAG + ' D', nameE = TAG + ' E';
|
||||
|
||||
const idA = await mkTrashed(page, nameA);
|
||||
const idB = await mkTrashed(page, nameB);
|
||||
|
||||
await page.goto(`${FD_BASE}/trash`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// On ne travaille QUE sur nos pages : la poubelle partagée avec l'instance
|
||||
// de test peut contenir d'autres éléments (jamais on ne touche aux leurs).
|
||||
await page.fill('input[placeholder="Search pages in Trash"]', TAG);
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const rowA = page.locator('.trash-item', { hasText: nameA });
|
||||
const rowB = page.locator('.trash-item', { hasText: nameB });
|
||||
await expect(rowA).toHaveCount(1);
|
||||
await expect(rowB).toHaveCount(1);
|
||||
|
||||
// Date de suppression + jours restants affichés (standard « trash »)
|
||||
await expect(rowA.first()).toContainText('Deleted');
|
||||
await expect(rowA.first()).toContainText('left');
|
||||
// Compteur + purge totale présents
|
||||
await expect(page.locator('label input[type=checkbox]')).toHaveCount(1);
|
||||
await expect(page.getByRole('button', { name: 'Empty Trash' })).toBeVisible();
|
||||
|
||||
// Sélection groupée (portée au filtre courant) → restauration
|
||||
const rowCount = await page.locator('.trash-item').count();
|
||||
expect(rowCount).toBeGreaterThanOrEqual(2);
|
||||
await page.locator('label input[type=checkbox]').check();
|
||||
await expect(page.locator('span', { hasText: rowCount + ' selected' })).toHaveCount(1);
|
||||
await page.locator('button.btn-sm', { hasText: 'Restore' }).click();
|
||||
await page.waitForTimeout(900);
|
||||
|
||||
await expect(page.locator('.trash-item', { hasText: nameA })).toHaveCount(0);
|
||||
await expect(page.locator('.trash-item', { hasText: nameB })).toHaveCount(0);
|
||||
let ids = await trashIds(page);
|
||||
expect(ids).not.toContain(idA);
|
||||
expect(ids).not.toContain(idB);
|
||||
|
||||
// Restauration unitaire (le vrai test du fix CSRF : avant = 403 silencieux)
|
||||
const idC = await mkTrashed(page, nameC);
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
await page.waitForTimeout(900);
|
||||
const rowC = page.locator('.trash-item', { hasText: nameC });
|
||||
await expect(rowC).toHaveCount(1);
|
||||
await rowC.first().locator('button[title="Restore"]').click();
|
||||
await page.waitForTimeout(900);
|
||||
await expect(rowC).toHaveCount(0);
|
||||
ids = await trashIds(page);
|
||||
expect(ids).not.toContain(idC);
|
||||
|
||||
// Suppression définitive unitaire → fenêtre de confirmation THÉMATIQUE
|
||||
// (plus de confirm() du navigateur)
|
||||
const idE = await mkTrashed(page, nameE);
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
await page.waitForTimeout(900);
|
||||
const rowE = page.locator('.trash-item', { hasText: nameE });
|
||||
await expect(rowE).toHaveCount(1);
|
||||
await rowE.first().locator('button[title="Delete permanently"]').click();
|
||||
|
||||
const modal = page.locator('#fd-trash-confirm'); // id sinon 2e match #fd-global-folder-modal
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(modal.locator('h3')).toHaveText('Delete permanently');
|
||||
await expect(modal.locator('.flowdeck-modal-body p')).toContainText(nameE);
|
||||
|
||||
// Annulation → la page est toujours là
|
||||
await modal.locator('button.cancel').click();
|
||||
await expect(modal).toBeHidden();
|
||||
await expect(rowE).toHaveCount(1);
|
||||
|
||||
// Confirmation → suppression réelle (plus dans la poubelle, contenu effacé)
|
||||
await rowE.first().locator('button[title="Delete permanently"]').click();
|
||||
await expect(modal).toBeVisible();
|
||||
await modal.locator('button.confirm').click();
|
||||
await page.waitForTimeout(900);
|
||||
await expect(rowE).toHaveCount(0);
|
||||
expect(await trashIds(page)).not.toContain(idE);
|
||||
// Le SW transforme les réponses API non-ok (404 ici) en erreur réseau →
|
||||
// `api()` peut rejeter ; dans les deux cas la page n'est plus servable.
|
||||
const content = await api(page, 'GET', '/api/pages/' + idE + '/content').catch(() => ({ ok: false, status: 0 }));
|
||||
expect(content.ok).toBeFalsy();
|
||||
|
||||
// Purge totale — seulement si la poubelle ne contient QUE des pages E2E
|
||||
// (l'instance de test est partagée : on ne vide jamais la poubelle d'autrui).
|
||||
await mkTrashed(page, nameD);
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
await page.waitForTimeout(900);
|
||||
await expect(page.locator('.trash-item', { hasText: nameD })).toHaveCount(1);
|
||||
const all = (await api(page, 'GET', '/board/api/trash')).body || [];
|
||||
// Fenêtre Empty Trash : ouverture + titre + annulation — testée quel que soit
|
||||
// le contenu (l'instance est partagée, on n'a pas le droit de tout vider).
|
||||
await page.locator('button.btn-sm', { hasText: 'Empty Trash' }).click();
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(modal.locator('h3')).toHaveText('Empty Trash?');
|
||||
await expect(modal.locator('.flowdeck-modal-body p')).toContainText(String(all.length));
|
||||
await modal.locator('button.cancel').click();
|
||||
await expect(modal).toBeHidden();
|
||||
await expect(page.locator('.trash-item', { hasText: nameD })).toHaveCount(1);
|
||||
|
||||
if (all.every((i) => i.name.startsWith('E2E Trash'))) {
|
||||
await page.locator('button.btn-sm', { hasText: 'Empty Trash' }).click();
|
||||
await expect(modal).toBeVisible();
|
||||
await modal.locator('button.confirm').click();
|
||||
await page.waitForTimeout(900);
|
||||
expect(await trashIds(page)).toHaveLength(0);
|
||||
await expect(page.locator('.lib-empty', { hasText: 'Trash is empty' })).toBeVisible();
|
||||
} else {
|
||||
console.log('SKIP purge : la poubelle contient des pages non-E2E ' +
|
||||
JSON.stringify(all.filter((i) => !i.name.startsWith('E2E Trash')).map((i) => i.name)));
|
||||
}
|
||||
|
||||
const fatal = errs.filter((e) =>
|
||||
e.startsWith('PAGEERROR') || e.includes('Alpine Expression Error') || e.startsWith('NATIVE_DIALOG'));
|
||||
expect(fatal, fatal.join('\n')).toHaveLength(0);
|
||||
});
|
||||
@@ -40,6 +40,8 @@ const browserGlobals = {
|
||||
Alpine: "readonly", htmx: "readonly", Sortable: "readonly",
|
||||
// Globals exposed on window by app.js
|
||||
openModal: "readonly", closeModal: "readonly",
|
||||
// getCsrf : helper unique posé dans le <head> de base.html (A38 phase 1)
|
||||
getCsrf: "readonly",
|
||||
// getSvgIcon : script inline de base.html (nonce) ; Prism : CDN des vues ;
|
||||
// TextDecoder : API navigateur (ES2015)
|
||||
getSvgIcon: "readonly", Prism: "readonly", TextDecoder: "readonly",
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ pythonpath = ["."]
|
||||
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
target-version = "py313"
|
||||
line-length = 110
|
||||
exclude = [".venv", ".venv311", ".pytest_cache", "static/js/vendor"]
|
||||
|
||||
|
||||
+173
-16
@@ -3,7 +3,28 @@
|
||||
Référence: Notion Light Mode (par défaut)
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
/* Inter auto-hébergé — remplace l'@import Google Fonts que la CSP bloque
|
||||
(style-src sans fonts.googleapis depuis v7.27). Police variable v20 :
|
||||
une face par sous-ensemble, font-weight 100-900. */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* ===== LIGHT THEME (default) ===== */
|
||||
:root {
|
||||
@@ -412,9 +433,12 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
|
||||
padding: 6px 6px; margin: 0 2px;
|
||||
}
|
||||
.nav-icon-btn {
|
||||
position: relative;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px; border-radius: 6px;
|
||||
color: var(--text-secondary); transition: background 0.15s, color 0.15s;
|
||||
border: none; background: none; cursor: pointer; font-family: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-icon-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.nav-icon-btn.active { background: var(--bg-active); color: var(--text-primary); }
|
||||
@@ -426,6 +450,91 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
|
||||
/* Collapse button on the right */
|
||||
.nav-icon-btn.collapse-btn { margin-left: auto; }
|
||||
|
||||
/* ── Collapsible tabs (Home / Chat / Meeting / Inbox) ──
|
||||
The active tab expands into a pill showing icon + label, the inactive ones
|
||||
collapse to the bare icon so the row stays compact. */
|
||||
.nav-tab-btn {
|
||||
gap: 0;
|
||||
padding: 6px;
|
||||
min-width: 30px;
|
||||
justify-content: center;
|
||||
border-radius: 15px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-tab-btn .nav-tab-label {
|
||||
display: none;
|
||||
max-width: 110px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.nav-tab-btn.active {
|
||||
background: var(--bg-active);
|
||||
color: var(--text-primary);
|
||||
gap: 7px;
|
||||
padding: 6px 12px 6px 10px;
|
||||
}
|
||||
.nav-tab-btn.active .nav-tab-label { display: inline-block; }
|
||||
.nav-tab-badge {
|
||||
position: absolute;
|
||||
top: -1px; right: -1px;
|
||||
min-width: 15px; height: 15px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--text-dim);
|
||||
color: var(--bg-primary);
|
||||
font-size: 10px; font-weight: 700; line-height: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
.nav-tab-badge-accent { background: #E03E3E; color: #fff; }
|
||||
.nav-tab-btn.active .nav-tab-badge { display: none; }
|
||||
|
||||
/* Tab panels — one replaces the whole sidebar content at a time */
|
||||
.sidebar-tab-panel { display: flex; flex-direction: column; gap: 2px; }
|
||||
.sb-panel-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 6px 10px 4px;
|
||||
}
|
||||
.sb-panel-title { font-size: 12px; font-weight: 700; color: var(--text-dim); text-transform: uppercase; letter-spacing: .04em; }
|
||||
.sb-panel-count { color: var(--text-secondary); font-weight: 600; }
|
||||
.sb-panel-action {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 22px; height: 22px; padding: 0 5px;
|
||||
border: none; border-radius: 5px; background: none;
|
||||
color: var(--text-secondary); font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.sb-panel-action:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.sb-filter-row { display: flex; gap: 4px; padding: 2px 8px 6px; }
|
||||
.sb-filter-chip {
|
||||
padding: 3px 10px; border: 1px solid var(--border); border-radius: 12px;
|
||||
background: none; color: var(--text-secondary);
|
||||
font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.sb-filter-chip.active { background: var(--bg-active); color: var(--text-primary); border-color: transparent; }
|
||||
.sb-quick-actions { display: flex; flex-direction: column; gap: 2px; padding: 2px 6px 8px; }
|
||||
.sb-quick-btn {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 5px 8px; border: none; border-radius: 6px; background: none;
|
||||
color: var(--text-secondary); font-size: 12px; text-align: left; cursor: pointer;
|
||||
}
|
||||
.sb-quick-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.sb-hidden-input { display: none; }
|
||||
.sb-agent-row { display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 10px 10px; }
|
||||
.sb-agent-avatar {
|
||||
width: 30px; height: 30px; border-radius: 50%; border: 1px solid var(--border);
|
||||
background: var(--bg-hover); cursor: pointer; font-size: 15px; line-height: 1;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.sb-agent-avatar:hover { border-color: var(--accent); }
|
||||
.sb-item-meta { margin-left: auto; font-size: 11px; color: var(--text-dim); flex-shrink: 0; }
|
||||
.sb-item-sub {
|
||||
display: block; font-size: 11px; color: var(--text-dim);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.sb-item-unread { background: rgba(35, 131, 226, .10); }
|
||||
|
||||
/* Empty hint */
|
||||
.sidebar-item.empty-hint { color: var(--text-dim); font-size: 12px; cursor: default; }
|
||||
.sidebar-item.empty-hint:hover { background: none; }
|
||||
@@ -1021,6 +1130,11 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Conteneur standard des pages listes (Library, Trash, …) — largeur unique
|
||||
1100px, centrée. Défini ici (global) et non dans le <style> de
|
||||
library.html, sinon /trash rendait la classe sans style = pleine largeur. */
|
||||
.lib-container{max-width:1100px;margin:0 auto;padding:20px 24px;}
|
||||
|
||||
/* Topbar */
|
||||
.topbar {
|
||||
display: flex;
|
||||
@@ -2130,6 +2244,32 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
/* Page de base (premier bloc = embed collection) : le titre et le tableau
|
||||
s'alignent sur la marge gauche de la topbar (16px) au lieu d'être centrés
|
||||
dans la colonne 900px — le tableau démarre au ras du sidebar. */
|
||||
.page-editor-wrapper.db-first .page-cover-area,
|
||||
.page-editor-wrapper.db-first .page-title-block,
|
||||
.page-editor-wrapper.db-first .blocks-container {
|
||||
max-width: 1100px;
|
||||
margin: 0;
|
||||
padding-left: var(--space-lg);
|
||||
}
|
||||
|
||||
/* Une base embarquée ne doit jamais déborder de sa colonne de blocs :
|
||||
.db-table-wrapper interne gère déjà le scroll horizontal. */
|
||||
.block-embed-collection { max-width: 100%; overflow: hidden; }
|
||||
|
||||
/* Titre des pages de base dédiées (page_editor_collection.html) — même
|
||||
liseré gauche que .database-table-container. */
|
||||
.database-page-title {
|
||||
max-width: 1100px;
|
||||
margin: 0;
|
||||
padding: 28px var(--space-lg) 0;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.blocks-empty {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
@@ -3758,11 +3898,16 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
Database Table View (Notion-style)
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Base = viewport du contenu ; les vues tabulaires larges (bases de
|
||||
données) s'alignent à gauche, au ras de la marge, à la même largeur
|
||||
que Library (1100px). */
|
||||
.database-table-container {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 0 96px;
|
||||
margin-top: 24px;
|
||||
max-width: 1100px;
|
||||
margin: 0;
|
||||
/* var(--space-lg) = padding de .topbar : le bord gauche du tableau
|
||||
s'aligne sur le titre du fil d'ariane, au ras de la marge. */
|
||||
padding: 0 var(--space-lg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -4276,6 +4421,7 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 768px) {
|
||||
.database-table-container { padding: 0 16px; }
|
||||
.db-side-peek { width: 100vw; max-width: 100vw; }
|
||||
.db-th-property { min-width: 120px; }
|
||||
.db-td-cell { min-width: 120px; }
|
||||
}
|
||||
@@ -4283,22 +4429,27 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
/* ── Database Side Peek Panel ── */
|
||||
.db-side-peek {
|
||||
position: fixed; top: 0; right: 0;
|
||||
width: 560px; height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
box-shadow: -4px 0 20px rgba(0,0,0,0.12);
|
||||
z-index: 900;
|
||||
width: 560px; max-width: 90vw; height: 100vh;
|
||||
background: var(--bg-modal);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: -8px 0 32px rgba(0,0,0,0.25);
|
||||
z-index: 500;
|
||||
display: flex; flex-direction: column;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.2s cubic-bezier(0.16,1,0.3,1);
|
||||
}
|
||||
.db-side-peek-resize {
|
||||
position: absolute; top: 0; left: 0;
|
||||
width: 6px; height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
.db-side-peek.open { transform: translateX(0); }
|
||||
/* Poignée partagée des side peeks (bases) — les autres peeks portent la
|
||||
même géométrie en style en ligne. */
|
||||
.peek-resize-handle {
|
||||
position: absolute; left: -4px; top: 0;
|
||||
width: 8px; height: 100%; cursor: col-resize;
|
||||
z-index: 501;
|
||||
}
|
||||
.db-side-peek-resize:hover { background: var(--accent, #2383E2); }
|
||||
.peek-resize-handle:hover { background: var(--accent, #2383E2); }
|
||||
.db-side-peek-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
||||
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.db-side-peek-close, .db-side-peek-full {
|
||||
@@ -4308,7 +4459,13 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
font-size: 18px; color: var(--text-dim);
|
||||
}
|
||||
.db-side-peek-close:hover, .db-side-peek-full:hover { background: var(--bg-hover); }
|
||||
.db-side-peek iframe { flex: 1; }
|
||||
.db-side-peek-body { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
.db-side-peek-loading { text-align: center; padding: 40px; color: var(--text-dim); }
|
||||
.db-side-peek iframe { flex: 1; min-height: 0; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.db-side-peek { width: 100vw; max-width: 100vw; }
|
||||
}
|
||||
|
||||
/* ─── Simple Table block (v4.8.0) ─── */
|
||||
.ftable-editor { flex: 1; min-width: 0; min-height: 0; padding: 4px 2px; }
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
* FlowDeck — My Tasks (tableau de bord transverse façon Notion)
|
||||
* Préfixe `mt-`, conteneur `#my-tasks-app` (rendu par app/routers/my_tasks.py,
|
||||
* comportement par static/js/my_tasks.js).
|
||||
* ══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.my-tasks-app { max-width: 1100px; margin: 0 auto; padding: 20px 24px 64px; }
|
||||
|
||||
/* ── En-tête ──────────────────────────────────────────────────────────── */
|
||||
.mt-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; margin-bottom: 16px;
|
||||
}
|
||||
.mt-head h1 {
|
||||
margin: 0; font-size: 24px; font-weight: 700; color: var(--text-primary);
|
||||
}
|
||||
.mt-count-total { font-size: 13px; color: var(--text-secondary); }
|
||||
|
||||
/* ── Barre d'outils ─────────────────────────────────────────────────── */
|
||||
.mt-toolbar {
|
||||
display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
|
||||
justify-content: space-between; margin-bottom: 10px;
|
||||
}
|
||||
.mt-toolbar-left,
|
||||
.mt-toolbar-right {
|
||||
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
|
||||
}
|
||||
.mt-view-tabs { display: flex; gap: 2px; align-items: center; }
|
||||
.mt-view-tab {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px; font-size: 13px; font-weight: 500;
|
||||
color: var(--text-secondary); background: transparent;
|
||||
border: none; border-radius: 6px; cursor: pointer;
|
||||
text-decoration: none; white-space: nowrap; transition: all .12s;
|
||||
}
|
||||
.mt-view-tab:hover { color: var(--text-primary); background: var(--bg-hover); }
|
||||
.mt-view-tab.active { color: var(--text-primary); background: var(--bg-active); }
|
||||
.mt-select {
|
||||
font: inherit; font-size: 13px; padding: 5px 8px; border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-primary); color: var(--text-primary);
|
||||
}
|
||||
.mt-toggle {
|
||||
font-size: 13px; color: var(--text-secondary);
|
||||
display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
|
||||
}
|
||||
.mt-new-btn,
|
||||
.mt-empty-btn {
|
||||
font: inherit; font-size: 13px; font-weight: 600;
|
||||
padding: 6px 14px; border-radius: 6px; border: none; cursor: pointer;
|
||||
text-decoration: none; display: inline-block;
|
||||
background: var(--accent, #2383e2); color: #fff;
|
||||
}
|
||||
.mt-new-btn:hover,
|
||||
.mt-empty-btn:hover { filter: brightness(1.08); }
|
||||
|
||||
/* ── Sources connectées ─────────────────────────────────────────────── */
|
||||
.mt-sources {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
|
||||
padding: 8px 0 14px; margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border, #eee);
|
||||
}
|
||||
.mt-sources-label { font-size: 12px; color: var(--text-dim, #999); margin-right: 4px; }
|
||||
.mt-source-chip {
|
||||
font-size: 12px; padding: 3px 9px; border-radius: 20px; text-decoration: none;
|
||||
border: 1px solid var(--border, #e3e3e0);
|
||||
color: var(--text, #333); background: var(--bg-primary, #fff);
|
||||
}
|
||||
.mt-source-chip.pending { border-style: dashed; color: var(--text-dim, #999); }
|
||||
.mt-source-chip:hover { border-color: var(--accent, #2383e2); }
|
||||
.mt-sources-warn { font-size: 12px; color: #c9820a; }
|
||||
|
||||
/* ── Vue tableau ──────────────────────────────────────────────────────────
|
||||
* Lignes en `div` (et non `<table>`) : meme choix que Library, qui le permet
|
||||
* d'afficher les actions de ligne UNIQUEMENT au survol (`.hover-only`).
|
||||
*/
|
||||
.mt-table { width: 100%; }
|
||||
.mt-thead {
|
||||
display: flex; align-items: center; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mt-th {
|
||||
display: flex; align-items: center; padding: 6px 8px;
|
||||
font-size: 12px; font-weight: 500; color: var(--text-dim);
|
||||
white-space: nowrap; user-select: none;
|
||||
}
|
||||
.mt-row {
|
||||
display: flex; align-items: center; height: 38px;
|
||||
border-bottom: 1px solid var(--border); transition: background .1s;
|
||||
}
|
||||
.mt-row:last-child { border-bottom: none; }
|
||||
.mt-row:hover { background: var(--bg-hover); }
|
||||
.mt-cell {
|
||||
display: flex; align-items: center; padding: 0 8px;
|
||||
height: 100%; overflow: hidden;
|
||||
}
|
||||
.mt-cell-name { flex: 1; min-width: 0; gap: 6px; }
|
||||
.mt-cell-status,
|
||||
.mt-cell-due { width: 165px; flex-shrink: 0; }
|
||||
.mt-cell-src { width: 210px; flex-shrink: 0; }
|
||||
.mt-icon {
|
||||
font-size: 15px; flex-shrink: 0; width: 20px; text-align: center;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.mt-title-text {
|
||||
font-size: 13px; color: var(--text-primary); cursor: text;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
min-width: 0; flex: 1 1 auto;
|
||||
}
|
||||
.mt-title-text:hover { text-decoration: underline; }
|
||||
.mt-title-input {
|
||||
font: inherit; width: 100%; padding: 3px 6px; border-radius: 4px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--bg-primary); color: var(--text-primary);
|
||||
}
|
||||
.mt-status-select,
|
||||
.mt-due-input {
|
||||
font: inherit; font-size: 13px; padding: 4px 6px; border-radius: 5px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-primary); color: var(--text-primary);
|
||||
max-width: 100%; width: 100%;
|
||||
}
|
||||
.mt-due-overdue { border-color: #e05252; color: #e05252; }
|
||||
.mt-due-today { border-color: #c9820a; color: #c9820a; font-weight: 600; }
|
||||
.mt-chip {
|
||||
font-size: 12px; padding: 3px 8px; border-radius: 5px; text-decoration: none;
|
||||
background: var(--bg-hover); color: var(--text-secondary);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.mt-chip:hover { color: var(--accent); }
|
||||
.mt-static { font-size: 13px; color: var(--text-secondary); }
|
||||
|
||||
/* ── Bouton « Open » + actions visibles au survol ──────────────────────────
|
||||
* Mêmes valeurs que `.btn-open` / `.hover-only` de Library
|
||||
* (`app/templates/library.html`) : même aspect, même révélation au survol.
|
||||
*/
|
||||
.mt-hover-only { opacity: 0; transition: opacity .12s; flex-shrink: 0; }
|
||||
.mt-row:hover .mt-hover-only,
|
||||
.mt-row:focus-within .mt-hover-only { opacity: 1; }
|
||||
.mt-open-btn {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 3px 8px; border: 1px solid var(--border); border-radius: 4px;
|
||||
font-size: 11px; font-weight: 500; color: var(--text-primary);
|
||||
background: var(--bg-tertiary); cursor: pointer; white-space: nowrap;
|
||||
transition: all .1s; flex-shrink: 0; text-decoration: none; margin-left: auto;
|
||||
}
|
||||
.mt-open-btn:hover { background: var(--bg-active); }
|
||||
.mt-open-btn svg { width: 12px; height: 12px; }
|
||||
@media (hover: none) {
|
||||
/* Écrans tactiles : pas de survol, l'action doit rester visible. */
|
||||
.mt-hover-only { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Side peek (panneau latéral, sur le modèle de Library) ───────────────
|
||||
* `?embed=1` charge l'éditeur du document dans l'iframe, comme le peek de
|
||||
* Library : on lit la tâche sans quitter le tableau de bord.
|
||||
*/
|
||||
.mt-peek {
|
||||
position: fixed; top: 0; right: 0; width: 560px; max-width: 90vw;
|
||||
height: 100vh; z-index: 500; display: flex; flex-direction: column;
|
||||
background: var(--bg-modal); border-left: 1px solid var(--border);
|
||||
box-shadow: -8px 0 32px rgba(0, 0, 0, .25);
|
||||
transform: translateX(100%); transition: transform .2s cubic-bezier(.16, 1, .3, 1);
|
||||
}
|
||||
.mt-peek.open { transform: translateX(0); }
|
||||
.mt-peek-handle {
|
||||
position: absolute; left: -4px; top: 0; width: 8px; height: 100%;
|
||||
cursor: col-resize; z-index: 501;
|
||||
}
|
||||
.mt-peek-head {
|
||||
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
|
||||
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mt-peek-title {
|
||||
flex: 1; min-width: 0; font-size: 15px; font-weight: 600;
|
||||
color: var(--text-primary); display: flex; align-items: center; gap: 8px;
|
||||
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
|
||||
}
|
||||
.mt-peek-btn {
|
||||
background: transparent; border: none; color: var(--text-secondary);
|
||||
cursor: pointer; padding: 6px; border-radius: 4px;
|
||||
display: inline-flex; align-items: center;
|
||||
}
|
||||
.mt-peek-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.mt-peek-btn svg { width: 15px; height: 15px; }
|
||||
.mt-peek-body { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.mt-peek-body iframe { flex: 1; width: 100%; border: none; min-height: 0; }
|
||||
.mt-peek-loading {
|
||||
padding: 40px; text-align: center; color: var(--text-dim); font-size: 13px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.mt-peek { width: 100vw; max-width: 100vw; }
|
||||
}
|
||||
|
||||
/* ── Vue kanban ─────────────────────────────────────────────────────── */
|
||||
.mt-board { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 12px; }
|
||||
.mt-col {
|
||||
min-width: 240px; flex: 1 0 240px; padding: 10px; border-radius: 10px;
|
||||
background: var(--bg-secondary, #f7f7f5);
|
||||
}
|
||||
.mt-col-head { font-size: 12px; font-weight: 600; color: var(--text-dim, #777); margin-bottom: 8px; }
|
||||
.mt-count { opacity: .6; font-weight: 400; }
|
||||
.mt-col-body { display: flex; flex-direction: column; gap: 6px; min-height: 60px; }
|
||||
.mt-card {
|
||||
display: flex; flex-direction: column; gap: 4px; padding: 9px 10px;
|
||||
border: 1px solid var(--border, #e3e3e0); border-radius: 7px;
|
||||
background: var(--bg-primary, #fff); font-size: 13px; cursor: grab;
|
||||
}
|
||||
.mt-card:active { cursor: grabbing; opacity: .7; }
|
||||
.mt-card-title { font-weight: 500; }
|
||||
/* Bouton « Open » en overlay au survol de la tuile (la carte est une
|
||||
colonne flex : une place en flux déformerait la tuile). */
|
||||
.mt-card { position: relative; }
|
||||
.mt-card .mt-open-btn {
|
||||
position: absolute; top: 6px; right: 6px; margin-left: 0;
|
||||
}
|
||||
.mt-card:hover .mt-hover-only,
|
||||
.mt-card:focus-within .mt-hover-only { opacity: 1; }
|
||||
.mt-card-due,
|
||||
.mt-card-src { font-size: 11px; color: var(--text-dim, #999); }
|
||||
.mt-card-src { opacity: .8; }
|
||||
|
||||
/* ── Vue calendrier ─────────────────────────────────────────────────── */
|
||||
.mt-cal {
|
||||
border: 1px solid var(--border, #eee); border-radius: 10px; overflow: hidden;
|
||||
}
|
||||
.mt-cal-head {
|
||||
display: flex; align-items: center; justify-content: center; gap: 12px;
|
||||
padding: 10px; border-bottom: 1px solid var(--border, #eee);
|
||||
}
|
||||
.mt-cal-title { font-size: 14px; font-weight: 600; text-transform: capitalize; }
|
||||
.mt-cal-nav {
|
||||
padding: 2px 9px; border: 1px solid var(--border, #e3e3e0); border-radius: 5px;
|
||||
background: var(--bg-primary, #fff); color: var(--text, #333);
|
||||
font-size: 15px; cursor: pointer;
|
||||
}
|
||||
.mt-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); }
|
||||
.mt-cal-dow {
|
||||
padding: 6px 0; text-align: center; font-size: 11px;
|
||||
color: var(--text-dim, #999); border-bottom: 1px solid var(--border, #eee);
|
||||
}
|
||||
.mt-cal-cell {
|
||||
min-height: 92px; padding: 5px 6px; font-size: 12px;
|
||||
border-right: 1px solid var(--border, #f0f0f0);
|
||||
border-bottom: 1px solid var(--border, #f0f0f0);
|
||||
}
|
||||
.mt-cal-cell.out { opacity: .4; }
|
||||
.mt-cal-cell.today { background: var(--bg-hover, rgba(35, 131, 226, .07)); }
|
||||
.mt-cal-day { margin-bottom: 4px; font-size: 11px; color: var(--text-dim, #999); }
|
||||
.mt-cal-ev:hover { background: var(--bg-hover); }
|
||||
.mt-cal-ev:hover .mt-cal-open { opacity: 1; }
|
||||
|
||||
/* ── Calendrier : sélecteur de vue + 4 vues supplémentaires ───────────── */
|
||||
.mt-cal-body { padding: 8px; }
|
||||
.mt-cal-tabs {
|
||||
display: flex; gap: 2px; padding: 6px 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mt-cal-tab {
|
||||
padding: 5px 11px; font-size: 12.5px; font-weight: 500;
|
||||
color: var(--text-secondary); background: transparent;
|
||||
border: none; border-bottom: 2px solid transparent;
|
||||
cursor: pointer; transition: all .12s;
|
||||
}
|
||||
.mt-cal-tab:hover { color: var(--text-primary); }
|
||||
.mt-cal-tab.active { color: var(--text-primary); border-bottom-color: var(--accent); }
|
||||
|
||||
/* Événement : titre + bouton d'ouverture révélé au survol. */
|
||||
.mt-cal-ev {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
margin-bottom: 3px; padding: 2px 5px; border-radius: 4px;
|
||||
background: var(--bg-hover); font-size: 12px; min-width: 0;
|
||||
}
|
||||
.mt-cal-ev.drag { cursor: grab; }
|
||||
.mt-cal-ev-text {
|
||||
flex: 1; min-width: 0; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.mt-cal-open {
|
||||
flex-shrink: 0; display: inline-flex; align-items: center;
|
||||
padding: 1px; border: none; border-radius: 3px; cursor: pointer;
|
||||
background: transparent; color: var(--text-secondary);
|
||||
}
|
||||
.mt-cal-open svg { width: 11px; height: 11px; display: block; }
|
||||
.mt-cal-open:hover { background: var(--bg-active); color: var(--text-primary); }
|
||||
|
||||
/* Vue année : 12 mini-mois. */
|
||||
.mt-cal-year {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px; padding: 10px;
|
||||
}
|
||||
.mt-cal-mini-title {
|
||||
font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
||||
text-transform: capitalize; margin-bottom: 5px;
|
||||
}
|
||||
.mt-cal-mini-grid {
|
||||
display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px;
|
||||
}
|
||||
.mt-cal-mini-dow {
|
||||
font-size: 9px; color: var(--text-dim); text-align: center; padding: 1px 0;
|
||||
}
|
||||
.mt-cal-mini-day {
|
||||
font: inherit; font-size: 10.5px; padding: 3px 0; border: none;
|
||||
border-radius: 4px; cursor: pointer; position: relative;
|
||||
background: transparent; color: var(--text-primary);
|
||||
}
|
||||
.mt-cal-mini-day:hover { background: var(--bg-hover); }
|
||||
.mt-cal-mini-day.out { visibility: hidden; }
|
||||
.mt-cal-mini-day.today { background: var(--accent); color: #fff; }
|
||||
.mt-cal-mini-day.busy::after {
|
||||
content: ''; position: absolute; left: 50%; bottom: 1px;
|
||||
width: 3px; height: 3px; margin-left: -1.5px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
.mt-cal-mini-day.today.busy::after { background: #fff; }
|
||||
|
||||
/* Vue semaine : 7 colonnes. */
|
||||
.mt-cal-week {
|
||||
display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px;
|
||||
background: var(--border); min-height: 180px;
|
||||
}
|
||||
.mt-cal-wcol { background: var(--bg-primary); padding: 6px; min-width: 0; }
|
||||
.mt-cal-wcol.today { background: var(--bg-hover); }
|
||||
.mt-cal-wcol-head {
|
||||
font-size: 11px; font-weight: 600; color: var(--text-secondary);
|
||||
margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mt-cal-wcol-body { display: flex; flex-direction: column; gap: 3px; }
|
||||
.mt-cal-wempty { font-size: 12px; color: var(--text-dim); text-align: center; }
|
||||
|
||||
/* Vue jour : liste détaillée. */
|
||||
.mt-cal-dayview { padding: 10px 12px 14px; }
|
||||
.mt-cal-dayview-head {
|
||||
font-size: 12px; color: var(--text-secondary); margin-bottom: 10px;
|
||||
}
|
||||
.mt-cal-dayview-empty {
|
||||
font-size: 13px; color: var(--text-dim); padding: 26px 0; text-align: center;
|
||||
}
|
||||
.mt-cal-task {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 7px 8px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mt-cal-task:hover { background: var(--bg-hover); }
|
||||
.mt-cal-task-title {
|
||||
flex: 1; min-width: 0; font-size: 13px; color: var(--text-primary);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.mt-cal-task-src, .mt-cal-task-status { font-size: 12px; color: var(--text-dim); }
|
||||
.mt-cal-task-status { width: 130px; flex-shrink: 0; }
|
||||
|
||||
/* Vue planning : colonnes = statuts, lignes = jours. */
|
||||
.mt-cal-plan { overflow-x: auto; max-height: 60vh; overflow-y: auto; }
|
||||
.mt-cal-plan-row { display: flex; align-items: stretch; min-width: 520px; }
|
||||
.mt-cal-plan-row.head { position: sticky; top: 0; z-index: 2; background: var(--bg-primary); }
|
||||
.mt-cal-plan-gutter {
|
||||
width: 44px; flex-shrink: 0; padding: 4px 6px;
|
||||
font-size: 11px; color: var(--text-dim); text-align: right;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mt-cal-plan-col-title {
|
||||
flex: 1; min-width: 0; padding: 4px 8px;
|
||||
font-size: 11px; font-weight: 600; color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.mt-cal-plan-cell {
|
||||
flex: 1; min-width: 0; padding: 3px 5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.mt-cal-plan-row.today .mt-cal-plan-gutter {
|
||||
color: var(--accent); font-weight: 700;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.mt-cal-year { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* ── États vides ──────────────────────────────────────────────────────────
|
||||
* Bloc centré dans la surface disponible : invite de conversion
|
||||
* (`.my-tasks-empty`, rendu serveur) comme états vides du tableau de bord
|
||||
* (`.mt-empty`, rendu client). Même gabarit pour que les deux se ressemblent.
|
||||
*/
|
||||
.my-tasks-empty,
|
||||
.mt-empty {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
text-align: center; padding: 72px 24px 80px;
|
||||
max-width: 620px; margin: 0 auto;
|
||||
}
|
||||
.my-tasks-empty-icon,
|
||||
.mt-empty-icon {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 64px; height: 64px; margin-bottom: 20px; font-size: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-hover, rgba(0, 0, 0, .04));
|
||||
border: 1px solid var(--border, #e3e3e0);
|
||||
}
|
||||
.my-tasks-empty h2,
|
||||
.mt-empty h2 {
|
||||
margin: 0 0 10px; font-size: 19px; font-weight: 600; color: var(--text-primary, #333);
|
||||
}
|
||||
.my-tasks-empty p,
|
||||
.mt-empty p {
|
||||
margin: 0 0 8px; font-size: 14px; line-height: 1.6;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
.my-tasks-empty-hint,
|
||||
.mt-empty-hint {
|
||||
font-size: 13px; color: var(--text-dim, #888);
|
||||
}
|
||||
.my-tasks-empty-steps,
|
||||
.mt-empty-steps {
|
||||
margin: 20px 0 0; padding: 14px 18px; list-style: none;
|
||||
display: inline-flex; flex-direction: column; gap: 8px; text-align: left;
|
||||
border: 1px solid var(--border, #e3e3e0);
|
||||
border-radius: 10px; background: var(--bg-secondary, #fafafa);
|
||||
font-size: 13px; color: var(--text-secondary, #666);
|
||||
}
|
||||
.my-tasks-empty-steps li,
|
||||
.mt-empty-steps li { display: flex; align-items: flex-start; gap: 9px; }
|
||||
.my-tasks-empty-steps .n,
|
||||
.mt-empty-steps .n {
|
||||
flex-shrink: 0; width: 20px; height: 20px; margin-top: -1px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 50%; background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-strong, #d3d3d0);
|
||||
font-size: 11px; font-weight: 700; color: var(--text-dim, #999);
|
||||
}
|
||||
.my-tasks-empty-btn,
|
||||
.mt-empty-btn {
|
||||
display: inline-block; margin-top: 22px; padding: 9px 20px; border-radius: 8px;
|
||||
background: var(--accent, #2383e2); color: #fff;
|
||||
font-size: 14px; font-weight: 600; text-decoration: none; border: none;
|
||||
cursor: pointer; font-family: inherit;
|
||||
}
|
||||
.my-tasks-empty-btn:hover,
|
||||
.mt-empty-btn:hover { background: var(--accent-hover, #1a6bc0); filter: none; }
|
||||
|
||||
/* ── Modale « nouvelle tâche » (multi-destinations) ─────────────────── */
|
||||
.mt-modal-overlay {
|
||||
position: fixed; inset: 0; z-index: 1000; padding-top: 12vh;
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
background: rgba(0, 0, 0, .4);
|
||||
}
|
||||
.mt-modal {
|
||||
width: 460px; max-width: 92vw; overflow: hidden; border-radius: 12px;
|
||||
background: var(--bg-primary, #fff); box-shadow: 0 18px 60px rgba(0, 0, 0, .25);
|
||||
}
|
||||
.mt-modal-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 16px; font-size: 14px; font-weight: 600;
|
||||
border-bottom: 1px solid var(--border, #eee);
|
||||
}
|
||||
.mt-modal-close {
|
||||
border: none; background: none; color: var(--text-dim, #888);
|
||||
font-size: 14px; cursor: pointer;
|
||||
}
|
||||
.mt-modal-body {
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
max-height: 55vh; overflow-y: auto; padding: 16px;
|
||||
}
|
||||
.mt-field {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
font-size: 12px; color: var(--text-dim, #888);
|
||||
}
|
||||
.mt-field input {
|
||||
font: inherit; font-size: 14px; padding: 7px 9px; border-radius: 6px;
|
||||
border: 1px solid var(--border, #e3e3e0);
|
||||
background: var(--bg-primary, #fff); color: var(--text, #333);
|
||||
}
|
||||
.mt-modal-group {
|
||||
margin-top: 4px; font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: .5px; color: var(--text-dim, #999);
|
||||
}
|
||||
.mt-dest {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 8px;
|
||||
border-radius: 6px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.mt-dest:hover { background: var(--bg-hover, rgba(0, 0, 0, .04)); }
|
||||
.mt-modal-foot {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 12px 16px; border-top: 1px solid var(--border, #eee);
|
||||
}
|
||||
.mt-btn-cancel,
|
||||
.mt-btn-primary {
|
||||
font: inherit; font-size: 13px; padding: 7px 16px; border-radius: 6px;
|
||||
border: 1px solid var(--border, #e3e3e0); background: transparent;
|
||||
color: var(--text, #333); cursor: pointer;
|
||||
}
|
||||
.mt-btn-primary {
|
||||
border-color: transparent; background: var(--accent, #2383e2);
|
||||
color: #fff; font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Modale « Convertir en base de tâches » (task_db_link.js) ─────────── */
|
||||
.tdb-overlay {
|
||||
position: fixed; inset: 0; z-index: 1000; padding-top: 10vh;
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
background: rgba(0, 0, 0, .4);
|
||||
}
|
||||
.tdb-modal {
|
||||
width: 520px; max-width: 94vw; overflow: hidden; border-radius: 12px;
|
||||
background: var(--bg-modal, var(--bg-primary, #fff));
|
||||
box-shadow: 0 18px 60px rgba(0, 0, 0, .25); color: var(--text-primary, #333);
|
||||
}
|
||||
.tdb-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 16px; font-size: 14px; font-weight: 600;
|
||||
border-bottom: 1px solid var(--border, #eee);
|
||||
}
|
||||
.tdb-close {
|
||||
border: none; background: none; color: var(--text-dim, #888);
|
||||
font-size: 14px; cursor: pointer;
|
||||
}
|
||||
.tdb-intro {
|
||||
padding: 12px 16px 0; font-size: 12.5px; line-height: 1.5;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
.tdb-intro a { color: var(--accent, #2383e2); }
|
||||
.tdb-body { display: flex; flex-direction: column; gap: 14px; padding: 14px 16px; }
|
||||
.tdb-role-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 5px; }
|
||||
.tdb-role-head label { font-size: 13px; font-weight: 600; }
|
||||
.tdb-role-hint { font-size: 11.5px; color: var(--text-dim, #999); }
|
||||
.tdb-role-body { display: flex; gap: 8px; }
|
||||
.tdb-select,
|
||||
.tdb-new-name {
|
||||
font: inherit; font-size: 13px; padding: 6px 8px; border-radius: 6px;
|
||||
border: 1px solid var(--border, #e3e3e0);
|
||||
background: var(--bg-primary, #fff); color: var(--text-primary, #333);
|
||||
}
|
||||
.tdb-select { flex: 1 1 auto; min-width: 0; }
|
||||
.tdb-new-name { flex: 1 1 auto; min-width: 0; }
|
||||
.tdb-warn {
|
||||
margin: 0 16px 4px; padding: 8px 10px; border-radius: 6px; font-size: 12px;
|
||||
background: rgba(201, 130, 10, .12); color: #c9820a;
|
||||
}
|
||||
.tdb-foot {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 12px 16px; border-top: 1px solid var(--border, #eee);
|
||||
}
|
||||
.tdb-spacer { flex: 1; }
|
||||
.tdb-btn,
|
||||
.tdb-btn-primary,
|
||||
.tdb-btn-danger {
|
||||
font: inherit; font-size: 13px; padding: 7px 16px; border-radius: 6px;
|
||||
border: 1px solid var(--border, #e3e3e0); background: transparent;
|
||||
color: var(--text-primary, #333); cursor: pointer;
|
||||
}
|
||||
.tdb-btn-primary {
|
||||
border-color: transparent; background: var(--accent, #2383e2);
|
||||
color: #fff; font-weight: 600;
|
||||
}
|
||||
.tdb-btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.tdb-btn-danger { color: #e5484d; border-color: rgba(229, 72, 77, .4); }
|
||||
|
||||
/* ── Page « /db » : liste des bases du workspace ─────────────────────────
|
||||
* En-tête recentré (page de destination du bouton « Ouvrir mes bases »),
|
||||
* grille de cartes à gauche pour rester lisible sur les longues listes.
|
||||
*/
|
||||
.db-index { padding: 40px 24px 64px; max-width: 1100px; margin: 0 auto; }
|
||||
.db-index-head {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-wrap: wrap; gap: 10px; margin-bottom: 6px; text-align: center;
|
||||
}
|
||||
.db-index-head h1 { margin: 0; font-size: 24px; }
|
||||
.db-index-ws {
|
||||
font-size: 12px; color: var(--text-dim, #999);
|
||||
padding: 2px 9px; border-radius: 20px;
|
||||
border: 1px solid var(--border, #e3e3e0);
|
||||
}
|
||||
.db-index-sub {
|
||||
max-width: 640px; margin: 0 auto 26px; text-align: center;
|
||||
font-size: 13.5px; line-height: 1.6; color: var(--text-secondary, #666);
|
||||
}
|
||||
.db-index-sub a { color: var(--accent, #2383e2); }
|
||||
.db-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px;
|
||||
}
|
||||
.db-card {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
padding: 16px 16px; border-radius: 12px; text-decoration: none;
|
||||
border: 1px solid var(--border, #e3e3e0);
|
||||
background: var(--bg-primary, #fff); color: var(--text, #333);
|
||||
transition: border-color .12s, box-shadow .12s, transform .12s;
|
||||
}
|
||||
.db-card:hover {
|
||||
border-color: var(--accent, #2383e2);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, .09);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.db-card-head { display: flex; align-items: center; gap: 8px; }
|
||||
.db-card-icon { font-size: 19px; }
|
||||
.db-card-name {
|
||||
flex: 1; min-width: 0; font-size: 14px; font-weight: 600;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.db-card-badge {
|
||||
flex-shrink: 0; font-size: 11px; font-weight: 600;
|
||||
padding: 2px 8px; border-radius: 20px;
|
||||
}
|
||||
.db-card-badge.ok { background: rgba(0, 200, 100, .14); color: #00a35a; }
|
||||
.db-card-badge.warn { background: rgba(201, 130, 10, .14); color: #c9820a; }
|
||||
.db-card-meta { font-size: 11.5px; color: var(--text-dim, #999); }
|
||||
.db-card-hint { font-size: 12px; line-height: 1.45; color: var(--text-secondary, #666); }
|
||||
|
||||
/* État vide de `/db` : bloc centré dans la surface disponible. */
|
||||
.db-empty {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
text-align: center; padding: 72px 24px 80px;
|
||||
max-width: 620px; margin: 0 auto;
|
||||
}
|
||||
.db-empty-icon {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 64px; height: 64px; margin-bottom: 20px; font-size: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-hover, rgba(0, 0, 0, .04));
|
||||
border: 1px solid var(--border, #e3e3e0);
|
||||
}
|
||||
.db-empty h1 { margin: 0 0 10px; font-size: 19px; font-weight: 600; }
|
||||
.db-empty p {
|
||||
margin: 0 0 8px; font-size: 14px; line-height: 1.6;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
.db-empty-hint { max-width: 520px; font-size: 13px; }
|
||||
.db-empty-hint code {
|
||||
padding: 1px 5px; border-radius: 4px; font-size: 12px;
|
||||
background: var(--bg-hover, rgba(0, 0, 0, .05));
|
||||
}
|
||||
|
||||
/* ── Bouton « Convertir en base de tâches » (barre de la base) ────────── */
|
||||
.db-task-db-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 5px 11px; border-radius: 6px;
|
||||
border: 1px solid var(--border, #e3e3e0);
|
||||
background: transparent; color: var(--text-secondary, #555);
|
||||
font-size: 12.5px; font-weight: 500; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.db-task-db-btn:hover {
|
||||
border-color: var(--accent, #2383e2);
|
||||
color: var(--accent, #2383e2);
|
||||
background: var(--bg-hover, rgba(35, 131, 226, .07));
|
||||
}
|
||||
.mt-source-fix {
|
||||
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px;
|
||||
border: 1px solid rgba(201, 130, 10, .45); background: transparent;
|
||||
color: #c9820a; cursor: pointer;
|
||||
}
|
||||
.mt-source-fix:hover { background: rgba(201, 130, 10, .12); }
|
||||
.db-section-title {
|
||||
margin: 38px 0 4px; font-size: 15px; font-weight: 600; color: var(--text, #333);
|
||||
text-align: center;
|
||||
}
|
||||
.db-section-sub {
|
||||
max-width: 640px; margin: 0 auto 14px; text-align: center;
|
||||
font-size: 12.5px; line-height: 1.55; color: var(--text-dim, #888);
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -21,6 +21,10 @@
|
||||
window.Alpine.__fdCtxStore = true;
|
||||
|
||||
Alpine.store('fdCtx', {
|
||||
addTagAndClear(value, color, el){
|
||||
this.addNewTag(value, color);
|
||||
if (el) el.value = '';
|
||||
},
|
||||
open: false, x: 0, y: 0, node: null, page: null,
|
||||
maxH: 0, _cx: 0, _cy: 0, _ro: null,
|
||||
handlers: {},
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
});
|
||||
|
||||
this._applyState = applyState;
|
||||
this._initResize();
|
||||
this.fetchProviders();
|
||||
this.fetchAgents();
|
||||
this.fetchSkills();
|
||||
@@ -86,6 +87,11 @@
|
||||
self.setComposerText(t);
|
||||
setTimeout(function(){ self.send(); }, 60);
|
||||
};
|
||||
// Open the panel on a given conversation (sidebar Chat history).
|
||||
window.fdAgent.openConversation = function(id){
|
||||
self.openAgent();
|
||||
self.loadConversation(id);
|
||||
};
|
||||
// Headless text generation (no tools) via /agent/generate — the clean
|
||||
// path used by "Ask AI" / "AI meeting note" / summarization. Sending a
|
||||
// plain write request through the tool-enabled conversation engine sends
|
||||
@@ -108,6 +114,79 @@
|
||||
};
|
||||
},
|
||||
|
||||
// ── Resizable panel width (drag handle on the left edge, persisted) ──
|
||||
_apMinWidth(){ return Math.min(360, window.innerWidth); },
|
||||
_apMaxWidth(){ return Math.max(this._apMinWidth(), Math.min(Math.round(window.innerWidth * 0.9), 1200)); },
|
||||
_apClampWidth(w){
|
||||
return Math.round(Math.max(this._apMinWidth(), Math.min(this._apMaxWidth(), w || 0)));
|
||||
},
|
||||
_apApplyWidth(w){
|
||||
var panel = document.getElementById('fd-agent-panel');
|
||||
if(!panel) return;
|
||||
var width = this._apClampWidth(w);
|
||||
panel.style.setProperty('--fd-agent-width', width + 'px');
|
||||
return width;
|
||||
},
|
||||
_initResize(){
|
||||
var self = this;
|
||||
var panel = document.getElementById('fd-agent-panel');
|
||||
var handle = panel && panel.querySelector('.fd-ap-resize');
|
||||
if(!panel || !handle) return;
|
||||
|
||||
// Restore the user width (clamped to the current viewport).
|
||||
try {
|
||||
var saved = parseInt(localStorage.getItem('fd_agent_width') || '', 10);
|
||||
if(saved) this._apApplyWidth(saved);
|
||||
} catch{ /* stockage indisponible */ }
|
||||
|
||||
var startX = 0, startWidth = 0, dragging = false;
|
||||
var persist = function(){
|
||||
try { localStorage.setItem('fd_agent_width', panel.style.getPropertyValue('--fd-agent-width')); } catch{ /* ignoré */ }
|
||||
};
|
||||
|
||||
handle.addEventListener('pointerdown', function(e){
|
||||
if(e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
try { handle.setPointerCapture(e.pointerId); } catch{ /* ignoré */ }
|
||||
startX = e.clientX;
|
||||
startWidth = panel.offsetWidth;
|
||||
dragging = true;
|
||||
document.body.classList.add('fd-ap-resizing');
|
||||
});
|
||||
handle.addEventListener('pointermove', function(e){
|
||||
if(!dragging) return;
|
||||
// The panel is anchored to the right edge → dragging left widens it.
|
||||
self._apApplyWidth(startWidth - (e.clientX - startX));
|
||||
});
|
||||
var endDrag = function(e){
|
||||
if(!dragging) return;
|
||||
dragging = false;
|
||||
document.body.classList.remove('fd-ap-resizing');
|
||||
persist();
|
||||
try { handle.releasePointerCapture(e.pointerId); } catch{ /* ignoré */ }
|
||||
};
|
||||
handle.addEventListener('pointerup', endDrag);
|
||||
handle.addEventListener('pointercancel', endDrag);
|
||||
handle.addEventListener('dblclick', function(){
|
||||
self._apApplyWidth(470);
|
||||
persist();
|
||||
});
|
||||
// Keyboard a11y: ← / → adjust by 20px, Home resets to the default.
|
||||
handle.addEventListener('keydown', function(e){
|
||||
var step = e.shiftKey ? 60 : 20, cur = panel.offsetWidth;
|
||||
if(e.key === 'ArrowLeft'){ self._apApplyWidth(cur + step); }
|
||||
else if(e.key === 'ArrowRight'){ self._apApplyWidth(cur - step); }
|
||||
else if(e.key === 'Home'){ self._apApplyWidth(470); }
|
||||
else { return; }
|
||||
e.preventDefault();
|
||||
persist();
|
||||
});
|
||||
// Keep the width usable when the window shrinks / grows.
|
||||
window.addEventListener('resize', function(){
|
||||
self._apApplyWidth(panel.offsetWidth);
|
||||
});
|
||||
},
|
||||
|
||||
// ── Open / close (robust) ──
|
||||
openAgent(){
|
||||
if(this.open) { this.refreshContext(); return; }
|
||||
@@ -265,6 +344,10 @@
|
||||
});
|
||||
}).catch(function(){});
|
||||
},
|
||||
// ── A20 ph3 : x-html (markdown) → x-init + effet réactif ──
|
||||
bindMarkdown(el, m){
|
||||
Alpine.effect(() => { el.innerHTML = this.renderMarkdown(m.content); });
|
||||
},
|
||||
approveProposal(m){
|
||||
if(!m || m.applied || m.offline) return;
|
||||
var ok = window.fdApplyDocument ? window.fdApplyDocument(m.content, m.mode) : false;
|
||||
@@ -390,6 +473,7 @@
|
||||
this._insertToken({token:pin.token, label:pin.label, icon:pin.icon, kind:'skill'});
|
||||
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
|
||||
},
|
||||
showHistory(){ this.tab = 'history'; this.loadConversations(); },
|
||||
loadConversations(){
|
||||
var self = this;
|
||||
fetch('/api/agent/conversations').then(function(r){return r.json()}).then(function(d){
|
||||
@@ -1583,7 +1667,9 @@
|
||||
delete_page:'Page supprimée', delete_collection:'Collection supprimée',
|
||||
apply_template:'Template appliqué', create_sub_item:'Sous-élément créé',
|
||||
add_dependency:'Dépendance ajoutée', read_gitea_issues:'Issues Gitea lues',
|
||||
create_gitea_issue:'Issue Gitea créée', sync_gitea:'Issues Gitea synchronisées'
|
||||
create_gitea_issue:'Issue Gitea créée', sync_gitea:'Issues Gitea synchronisées',
|
||||
web_search:'Recherche web effectuée', fetch_url:'Page web lue',
|
||||
search_code:'Recherche GitHub effectuée'
|
||||
};
|
||||
// Build an action step object from an SSE event or a persisted action row.
|
||||
agentPanel._actionStep = function(ev){
|
||||
|
||||
Vendored
+23
File diff suppressed because one or more lines are too long
+145
-3
@@ -1,5 +1,6 @@
|
||||
// FlowDeck Notion UI — Client-side logic
|
||||
// Alpine.js + SortableJS + HTMX + Mobile support
|
||||
/* exported openCardDetail */ /* global owner, repo */
|
||||
|
||||
// ── Frontend Error Capture ──────────────────────────────────
|
||||
// Intercepte TOUTES les erreurs JS et les envoie au backend.
|
||||
@@ -251,6 +252,12 @@
|
||||
// managed by Alpine or HTMX keep their default behaviour.
|
||||
function shouldIntercept(a, evt) {
|
||||
if (!a || !a.getAttribute || !isPlainLeftClick(evt)) return false;
|
||||
// Un handler plus profond a déjà traité le clic et appelé preventDefault
|
||||
// (onglets de vue, lien interne…). Sans ce test, le navigateur AND ce
|
||||
// navigateur SPA traitent le même clic : le composant rend son état, puis
|
||||
// `fdLoad` remplace la zone `.main-wrapper` par la réponse serveur — le
|
||||
// composant perd son DOM et la page finit vide.
|
||||
if (evt.defaultPrevented) return false;
|
||||
if (a.hasAttribute('data-no-nav')) return false;
|
||||
var target = (a.getAttribute('target') || '').toLowerCase();
|
||||
if (target && target !== '_self') return false;
|
||||
@@ -277,16 +284,30 @@
|
||||
}
|
||||
|
||||
var loading = false;
|
||||
var LOAD_WATCHDOG_MS = 15000;
|
||||
|
||||
function fdLoad(url, push) {
|
||||
if (!window.htmx) { window.location.href = url; return; }
|
||||
if (loading) return;
|
||||
// Une navigation est déjà en vol : on l'ABANDONNE au profit d'un chargement
|
||||
// complet. Avant, ce clic etait simplement ignore (aucune URL, aucun
|
||||
// changement) — et si la promesse htmx ne se resolvait jamais, `loading`
|
||||
// restait a `true` indefiniment : plus aucune navigation ne repartait, la
|
||||
// page restait figee (ou a moitie peinte) jusqu'au hard refresh.
|
||||
if (loading) { window.location.href = url; return; }
|
||||
loading = true;
|
||||
document.documentElement.classList.add('fd-navigating');
|
||||
var settled = false;
|
||||
function done() {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
loading = false;
|
||||
document.documentElement.classList.remove('fd-navigating');
|
||||
clearTimeout(watchdog);
|
||||
}
|
||||
// Filet de securite : `loading` ne doit jamais pouvoir rester bloque.
|
||||
var watchdog = setTimeout(function () {
|
||||
if (settled) return;
|
||||
done();
|
||||
window.location.href = url;
|
||||
}, LOAD_WATCHDOG_MS);
|
||||
var p;
|
||||
try {
|
||||
p = window.htmx.ajax('GET', url, {
|
||||
@@ -325,6 +346,89 @@
|
||||
fdLoad(window.location.href, false);
|
||||
});
|
||||
|
||||
// ── Swap partiel : monter proprement la zone swapée sous Alpine ──
|
||||
// Lors d'une navigation partielle (fdLoad), les <script src> de la page
|
||||
// s'exécutent APRÈS le microtask MutationObserver d'Alpine : toute la zone
|
||||
// .main-wrapper serait initialisée avant que composants ET stores (menu
|
||||
// contextuel $store.fdCtx, …) existent → « Undefined variable: … » puis
|
||||
// « reading 'has' » sur les stores. On pose x-ignore sur TOUTE la zone
|
||||
// pendant le chargement des scripts, puis on démarle quand tous les
|
||||
// <script src> ont exécuté (load/error) — filet de sécurité 4 s.
|
||||
// (initTree est idempotent : le _x_marker posé au premier passage évite
|
||||
// tout double montage.)
|
||||
var fdLastMw = document.querySelector('.main-wrapper'); // nœud du chargement complet
|
||||
window.fdRefreshXData = function (name) {
|
||||
document.querySelectorAll('[x-data="' + name + '()"]').forEach(function (el) {
|
||||
if (el.__fdRefreshed) return;
|
||||
var st = el._x_dataStack;
|
||||
if (!st) return; // sous x-ignore → done() montera
|
||||
if (st[0] && Object.keys(st[0]).length) return; // déjà monté correctement
|
||||
el.__fdRefreshed = true; // monté cassé (hors watcher) → on remonte
|
||||
var fresh = el.cloneNode(true);
|
||||
el.parentNode.replaceChild(fresh, el);
|
||||
if (window.htmx) { try { htmx.process(fresh); } catch { /* volontaire */ } }
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('htmx:afterSwap', function () {
|
||||
var mw = document.querySelector('.main-wrapper');
|
||||
// nœud inchangé = swap hors navigation (sidebar, vues) : rien à faire
|
||||
if (!mw || mw === fdLastMw || mw.__fdSwapWatch) return;
|
||||
fdLastMw = mw;
|
||||
mw.__fdSwapWatch = true;
|
||||
// Avant tout paint (même task que le swap) : la zone est x-ignore, donc non
|
||||
// montée Alpine — tous ses [x-show] seraient peints à leur valeur brute
|
||||
// (rafale de menus/panneaux/états visibles une fraction de seconde). On ne
|
||||
// la peint qu'une fois initTree passé.
|
||||
mw.style.opacity = '0';
|
||||
mw.style.pointerEvents = 'none';
|
||||
mw.setAttribute('x-ignore', '');
|
||||
var scripts = Array.prototype.slice.call(mw.querySelectorAll('script[src]'));
|
||||
var pending = scripts.length;
|
||||
var reveal = function () {
|
||||
if (!mw.isConnected) return;
|
||||
mw.style.opacity = '';
|
||||
mw.style.pointerEvents = '';
|
||||
};
|
||||
var done = function () {
|
||||
reveal(); // idempotent : appelé même si la branche suivante retourne
|
||||
if (!mw.__fdSwapWatch) return;
|
||||
mw.__fdSwapWatch = false;
|
||||
document.removeEventListener('load', onScript, true);
|
||||
document.removeEventListener('error', onScript, true);
|
||||
if (!mw.isConnected || !mw.hasAttribute('x-ignore')) return;
|
||||
mw.removeAttribute('x-ignore');
|
||||
mw._x_ignore = false; // propriété posée par Alpine au checkpoint
|
||||
try { Alpine.initTree(mw); } catch (e) { console.warn('[FlowDeck] mount', e); }
|
||||
reveal();
|
||||
};
|
||||
var onScript = function (e) {
|
||||
var node = e.target;
|
||||
// `load`/`error` en capture sur `document` voit TOUS les <script> de la
|
||||
// page, pas seulement ceux de la zone swappée : compter les uns et les
|
||||
// autres décomptait trop vite et montait l'arbre Alpine avant que les
|
||||
// scripts de la page n'aient tourné (page à moitié montée, composants
|
||||
// affiché à l'état brut). On ne compte donc que les noeuds attendus.
|
||||
if (!node || node.tagName !== 'SCRIPT') return;
|
||||
if (scripts.indexOf(node) === -1) return;
|
||||
if (--pending <= 0) done();
|
||||
};
|
||||
if (!pending) {
|
||||
// Avant le microtask MutationObserver d'Alpine : retirer x-ignore suffit,
|
||||
// le MO initialisera au checkpoint (initTree de sécurité = no-op).
|
||||
mw.removeAttribute('x-ignore');
|
||||
mw.__fdSwapWatch = false;
|
||||
setTimeout(function () {
|
||||
if (mw.isConnected) { try { Alpine.initTree(mw); } catch (e) { console.warn('[FlowDeck] mount', e); } }
|
||||
reveal(); // après initTree : un paint max de blanc, jamais de contenu brut
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
document.addEventListener('load', onScript, true);
|
||||
document.addEventListener('error', onScript, true);
|
||||
setTimeout(done, 4000);
|
||||
});
|
||||
|
||||
// Ctrl/Cmd + \ : toggle the sidebar (Notion-style).
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === '\\' || e.code === 'Backslash')) {
|
||||
@@ -407,3 +511,41 @@ function openCardDetail(id) {
|
||||
document.getElementById('card-modal-title').textContent = 'Issue #' + id;
|
||||
}
|
||||
|
||||
|
||||
// ── Side peek : poignée de redimensionnement partagée (v7.49.1) ──
|
||||
// Une seule implémentation pour les peeks de Library, du workspace local,
|
||||
// de My Tasks et des bases de données (le code copié trois fois dérivait :
|
||||
// celui de My Tasks ne capturait pas le pointeur et ne persistait rien).
|
||||
// La largeur choisie est commune à tous les peeks (localStorage fd_peek_width),
|
||||
// comme l'utilisateur ne voit qu'un seul panneau à la fois.
|
||||
window.fdWirePeekResize = function(panel, handle, onClose) {
|
||||
if (!panel || !handle) return;
|
||||
var startX, startWidth, dragging = false;
|
||||
handle.addEventListener('pointerdown', function(e) {
|
||||
e.preventDefault();
|
||||
try { handle.setPointerCapture(e.pointerId); } catch { /* pointeur fantôme */ }
|
||||
startX = e.clientX;
|
||||
startWidth = panel.offsetWidth;
|
||||
dragging = true;
|
||||
document.body.style.userSelect = 'none';
|
||||
});
|
||||
handle.addEventListener('pointermove', function(e) {
|
||||
if (!dragging) return;
|
||||
var w = Math.max(300, Math.min(window.innerWidth * 0.9, startWidth + (startX - e.clientX)));
|
||||
panel.style.width = w + 'px';
|
||||
});
|
||||
function stop(e) {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
document.body.style.userSelect = '';
|
||||
try { handle.releasePointerCapture(e.pointerId); } catch { /* déjà relâché */ }
|
||||
if (e && Math.abs(e.clientX - startX) < 3 && typeof onClose === 'function') onClose();
|
||||
try { localStorage.setItem('fd_peek_width', panel.style.width); } catch { /* navigation privée */ }
|
||||
}
|
||||
handle.addEventListener('pointerup', stop);
|
||||
handle.addEventListener('pointercancel', function() { dragging = false; document.body.style.userSelect = ''; });
|
||||
try {
|
||||
var saved = localStorage.getItem('fd_peek_width');
|
||||
if (saved) panel.style.width = saved;
|
||||
} catch { /* navigation privée */ }
|
||||
};
|
||||
|
||||
+28
-2
@@ -1,3 +1,5 @@
|
||||
if (!window.__fdBoardScriptsLoaded) {
|
||||
window.__fdBoardScriptsLoaded = true;
|
||||
/* exported setActiveTab, kanbanBoard, filterSystem, sortSystem, newIssueForm, showNewIssue -- appeles depuis les attributs HTML des templates */
|
||||
const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
@@ -18,6 +20,11 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
el.classList.add('active');
|
||||
}
|
||||
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: kanbanBoard »).
|
||||
|
||||
if (window.Alpine) { Alpine.data('kanbanBoard', kanbanBoard); fdRefreshXData('kanbanBoard'); } else document.addEventListener('alpine:init', function () { Alpine.data('kanbanBoard', kanbanBoard); });
|
||||
function kanbanBoard() {
|
||||
return {
|
||||
collapsedGroups: [],
|
||||
@@ -33,6 +40,11 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
};
|
||||
}
|
||||
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: filterSystem »).
|
||||
|
||||
if (window.Alpine) { Alpine.data('filterSystem', filterSystem); fdRefreshXData('filterSystem'); } else document.addEventListener('alpine:init', function () { Alpine.data('filterSystem', filterSystem); });
|
||||
function filterSystem() {
|
||||
return {
|
||||
activeFilters: [],
|
||||
@@ -60,6 +72,9 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
},
|
||||
removeFilter(i) { this.activeFilters.splice(i, 1); },
|
||||
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
|
||||
removeFilterAndRefresh(i) { this.removeFilter(i); this.refreshView(); },
|
||||
resetFiltersAndRefresh() { this.resetFilters(); this.refreshView(); },
|
||||
pickStatus(v) { this.toggleStatus(v); this.showStatusMenu = false; this.refreshView(); },
|
||||
refreshView() {
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
@@ -67,6 +82,11 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
};
|
||||
}
|
||||
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: sortSystem »).
|
||||
|
||||
if (window.Alpine) { Alpine.data('sortSystem', sortSystem); fdRefreshXData('sortSystem'); } else document.addEventListener('alpine:init', function () { Alpine.data('sortSystem', sortSystem); });
|
||||
function sortSystem() {
|
||||
return {
|
||||
sorts: [],
|
||||
@@ -89,12 +109,17 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
};
|
||||
}
|
||||
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: newIssueForm »).
|
||||
|
||||
if (window.Alpine) { Alpine.data('newIssueForm', newIssueForm); fdRefreshXData('newIssueForm'); } else document.addEventListener('alpine:init', function () { Alpine.data('newIssueForm', newIssueForm); });
|
||||
function newIssueForm() {
|
||||
return {
|
||||
title: '', status: 'todo',
|
||||
create() {
|
||||
if (!this.title.trim()) return;
|
||||
const csrf = getCsrf();;
|
||||
const csrf = getCsrf();
|
||||
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf }
|
||||
@@ -131,7 +156,7 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
onEnd: function(evt) {
|
||||
const cardId = evt.item.dataset.cardId;
|
||||
const toStatus = evt.to.dataset.status;
|
||||
const csrf = getCsrf();;
|
||||
const csrf = getCsrf();
|
||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf }
|
||||
@@ -146,3 +171,4 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+98
-40
@@ -1,3 +1,5 @@
|
||||
if (!window.__fdDbTableScriptsLoaded) {
|
||||
window.__fdDbTableScriptsLoaded = true;
|
||||
const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=el?JSON.parse(el.textContent):null;return v===undefined?null:v}catch{return null}})();
|
||||
|
||||
(function() {
|
||||
@@ -236,6 +238,23 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=
|
||||
return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'+(VIEW_ICONS[t]||VIEW_ICONS.table)+'</svg>';
|
||||
}
|
||||
|
||||
function taskDbButton(collection) {
|
||||
// Le libellé suit l'état réel de la base : convertie, à
|
||||
// configurer, ou pas encore convertie. Un simple ✓ était
|
||||
// introuvable pour l'utilisateur.
|
||||
var isTask = !!(collection && collection.is_task);
|
||||
var mapped = !!(collection && collection.task_assignee_prop &&
|
||||
collection.task_status_prop && collection.task_due_prop);
|
||||
var label = isTask ? (mapped ? '✓ Base de tâches' : '⚠ Configurer')
|
||||
: 'Convertir en base de tâches';
|
||||
var title = isTask
|
||||
? (mapped ? 'Cette base alimente My Tasks'
|
||||
: 'Base connectée mais mapping incomplet — reenregistrer')
|
||||
: 'Relier Assigné à / Statut / Échéance pour alimenter My Tasks';
|
||||
return '<button class="db-task-db-btn" data-task-db="' + collectionId +
|
||||
'" title="' + escHtml(title) + '">' + label + '</button>';
|
||||
}
|
||||
|
||||
function renderViewBar() {
|
||||
var h = '<div class="db-view-bar" style="padding:4px 0 8px"><div class="db-view-tabs">';
|
||||
if (!state.views.length) {
|
||||
@@ -249,7 +268,8 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=
|
||||
});
|
||||
h += '<button class="db-view-add" data-view-add="1" title="Add a view">+</button>';
|
||||
h += '</div>';
|
||||
h += '<div class="db-view-actions"><button class="db-btn-new db-add-page-btn">+ New</button></div>';
|
||||
h += '<div class="db-view-actions">' + taskDbButton(state.collection) +
|
||||
'<button class="db-btn-new db-add-page-btn">+ New</button></div>';
|
||||
h += '</div>';
|
||||
return h;
|
||||
}
|
||||
@@ -1262,50 +1282,88 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global Side Peek for Database Pages (auto-creates panel) ──
|
||||
function openPageInSidePeek(pageId) {
|
||||
var panel = document.getElementById('db-side-peek');
|
||||
if (!panel) {
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'db-side-peek';
|
||||
panel.style.cssText = 'position:fixed;top:0;right:0;width:560px;height:100vh;background:var(--bg-primary);box-shadow:-4px 0 20px rgba(0,0,0,0.12);z-index:900;display:flex;flex-direction:column';
|
||||
panel.innerHTML = '<div id="db-side-peek-resize" style="position:absolute;top:0;left:0;width:6px;height:100%;cursor:col-resize;z-index:2"></div>'+
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">'+
|
||||
'<button id="db-side-peek-close" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;font-size:18px;color:var(--text-dim)">×</button>'+
|
||||
'<button id="db-side-peek-full" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;font-size:18px;color:var(--text-dim)" title="Open full page">↗</button>'+
|
||||
'</div>'+
|
||||
'<iframe id="db-side-peek-iframe" src="" style="width:100%;height:100%;border:none;flex:1"></iframe>';
|
||||
document.body.appendChild(panel);
|
||||
document.getElementById('db-side-peek-close').onclick = function(){ panel.style.display='none'; document.getElementById('db-side-peek-iframe').src=''; };
|
||||
document.getElementById('db-side-peek-full').onclick = function(){
|
||||
var ifr = document.getElementById('db-side-peek-iframe');
|
||||
if (ifr && ifr.src) window.open(ifr.src.replace('?embed=1',''),'_blank');
|
||||
};
|
||||
var rh = document.getElementById('db-side-peek-resize');
|
||||
rh.onpointerdown = function(e) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
var sx = e.clientX, sw = panel.offsetWidth;
|
||||
function mv(ev) { var w = Math.max(300, Math.min(window.innerWidth*0.9, sw-(ev.clientX-sx))); panel.style.width = w+'px'; }
|
||||
function up() { document.removeEventListener('pointermove',mv); document.removeEventListener('pointerup',up); document.body.style.cursor=''; }
|
||||
document.body.style.cursor='col-resize';
|
||||
document.addEventListener('pointermove',mv); document.addEventListener('pointerup',up);
|
||||
};
|
||||
// ── Side peek : panneau latéral des lignes de base (vanilla JS) ──
|
||||
// Le panneau vit dans _database_table.html (page de base ET bases
|
||||
// embarquées inline). Il doit être câblé SUR le document, jamais via
|
||||
// container.__x : le conteneur n'a pas de x-data (les bindings Alpine
|
||||
// du panneau restaient inertes — clic sur une ligne = rien).
|
||||
var peekEl = document.getElementById('db-side-peek');
|
||||
// Sans panneau (collections embarquées inline dans l'éditeur), le clic ne
|
||||
// doit pas lever de ReferenceError : repli discret, comme l'ancien code.
|
||||
if (!peekEl) window.openPageInSidePeek = function() {};
|
||||
if (peekEl) {
|
||||
var peekFrame = document.getElementById('db-side-peek-iframe');
|
||||
var peekTitle = document.getElementById('db-side-peek-title');
|
||||
var peekLoading = document.getElementById('db-side-peek-loading');
|
||||
function closeDbPeek() {
|
||||
peekEl.classList.remove('open');
|
||||
document.getElementById('db-side-peek-iframe').src='about:blank';
|
||||
if (peekTitle) { peekTitle.textContent = ''; }
|
||||
}
|
||||
var iframe = document.getElementById('db-side-peek-iframe');
|
||||
// v6.5.0: a database row id is NOT a pages id (raw /pages/{rowId}
|
||||
// opened an unrelated page with the same id) — resolve the row's
|
||||
// content page first (lazy-created), then load the real editor.
|
||||
resolveRowContentPage(pageId).then(function(pid){
|
||||
var fr = document.getElementById('db-side-peek-iframe');
|
||||
if (fr) { fr.src = '/pages/'+pid+'?embed=1'; panel.style.display = 'flex'; }
|
||||
window.openPageInSidePeek = function(pageId) {
|
||||
if (!pageId) return;
|
||||
peekEl.classList.add('open');
|
||||
if (peekLoading) peekLoading.style.display = '';
|
||||
var iframe = document.getElementById('db-side-peek-iframe');
|
||||
if (iframe) iframe.src='about:blank';
|
||||
if (peekTitle) peekTitle.textContent = '';
|
||||
resolveRowContentPage(pageId).then(function(pid){
|
||||
if (!peekEl.classList.contains('open')) return;
|
||||
if (peekTitle) peekTitle.textContent = '';
|
||||
if (peekLoading) peekLoading.style.display = 'none';
|
||||
// `?embed=1` : uniquement le document edite, pas l'habillage
|
||||
// (sidebar + barre) — la pleine page reste via le bouton « full ».
|
||||
if (iframe) { iframe.dataset.page = pid; iframe.src = '/pages/'+pid+'?embed=1'; }
|
||||
})
|
||||
.catch(function(){
|
||||
// L'échec est rendu DANS le panneau (srcdoc) : sous le build
|
||||
// CSP d'Alpine les toasts ne s'affichent pas partout.
|
||||
if (iframe) {
|
||||
iframe.srcdoc = '<p style="font-family:sans-serif;color:#888;padding:40px;text-align:center">Impossible d’ouvrir le contenu de cette ligne</p>';
|
||||
}
|
||||
peekEl.classList.add('open');
|
||||
toast('Impossible d’ouvrir le contenu de cette ligne', 'error');
|
||||
});
|
||||
};
|
||||
var pc = document.getElementById('db-side-peek-close');
|
||||
if (pc) pc.addEventListener('click', closeDbPeek);
|
||||
var pf = document.getElementById('db-side-peek-full');
|
||||
if (pf) pf.addEventListener('click', function() {
|
||||
// `dataset.page` et non `iframe.src` : le src porte `?embed=1`,
|
||||
// la pleine page doit garder sa sidebar et sa barre.
|
||||
if (peekFrame && peekFrame.dataset.page) {
|
||||
window.location.href = '/pages/' + peekFrame.dataset.page;
|
||||
}
|
||||
});
|
||||
if (iframe) panel.style.display = 'flex';
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && peekEl.classList.contains('open')) closeDbPeek();
|
||||
});
|
||||
document.addEventListener('pointerdown', function(e) {
|
||||
if (!peekEl.classList.contains('open')) return;
|
||||
if (peekEl.contains(e.target)) return;
|
||||
if (e.target.closest('#db-table-container, .database-table-container, [id^="inline-db-"]')) return;
|
||||
closeDbPeek();
|
||||
});
|
||||
function wireDbPeekResize() {
|
||||
if (window.fdWirePeekResize) {
|
||||
window.fdWirePeekResize(peekEl, peekEl.querySelector('.peek-resize-handle'), closeDbPeek);
|
||||
} else if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', wireDbPeekResize);
|
||||
}
|
||||
}
|
||||
wireDbPeekResize();
|
||||
}
|
||||
|
||||
function resolveRowContentPage(rowId){
|
||||
return fetch('/db/pages/'+rowId+'/open/api')
|
||||
.then(function(r){ return r.ok ? r.json() : null; })
|
||||
.then(function(d){ return (d && d.page_id) ? d.page_id : rowId; })
|
||||
.catch(function(){ return rowId; });
|
||||
.then(function(r){
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function(d){
|
||||
if (!d || !d.page_id) throw new Error('page de contenu introuvable');
|
||||
return d.page_id;
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
var _regGiteaWs = () => {
|
||||
Alpine.data('giteaWorkspace', () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const owner = params.get('owner') || '';
|
||||
@@ -367,6 +367,20 @@ document.addEventListener('alpine:init', () => {
|
||||
this.loadSidebarTree();
|
||||
},
|
||||
|
||||
// ── A20 ph3 : new Date / x-html interdits sous build CSP ──
|
||||
closePrivateEdit() {
|
||||
this.editingPrivate = null;
|
||||
this.editingPrivateTitle = '';
|
||||
this.editingPrivateContent = '';
|
||||
},
|
||||
fmtGwDate(pp) {
|
||||
return pp.updated_at ? new Date(pp.updated_at + 'Z').toLocaleString() : '';
|
||||
},
|
||||
bindGwIcon(el, item) {
|
||||
Alpine.effect(() => {
|
||||
el.innerHTML = item.type === 'folder' ? getSvgIcon('folder', 16) : getSvgIcon('file', 16);
|
||||
});
|
||||
},
|
||||
formatSize(bytes) {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
@@ -623,4 +637,10 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
if (window.Alpine) {
|
||||
_regGiteaWs();
|
||||
fdRefreshXData('giteaWorkspace');
|
||||
} else {
|
||||
document.addEventListener('alpine:init', _regGiteaWs);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/* exported importWizard -- appeles depuis les attributs HTML des templates */
|
||||
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: importWizard »).
|
||||
|
||||
if (window.Alpine) { Alpine.data('importWizard', importWizard); fdRefreshXData('importWizard'); } else document.addEventListener('alpine:init', function () { Alpine.data('importWizard', importWizard); });
|
||||
function importWizard() {
|
||||
return {
|
||||
sources: [],
|
||||
|
||||
+74
-38
@@ -1,5 +1,10 @@
|
||||
/* exported libraryPage -- appeles depuis les attributs HTML des templates */
|
||||
|
||||
// A20 phase 3 : enregistrement Alpine.data — le build CSP ne résout que le
|
||||
// registre (probe : globale window → « Undefined variable: libraryPage »).
|
||||
if (window.Alpine) { Alpine.data('libraryPage', libraryPage); fdRefreshXData('libraryPage'); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('libraryPage', libraryPage); });
|
||||
|
||||
function libraryPage() {
|
||||
return {
|
||||
tab: 'recents',
|
||||
@@ -160,7 +165,42 @@ function libraryPage() {
|
||||
|
||||
_escHtml(s) { var d=document.createElement('div'); d.textContent = s||''; return d.innerHTML; },
|
||||
_escAttr(s) { return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); },
|
||||
_renderIcon(item) {
|
||||
openMoveSelected() {
|
||||
this.openMovePicker(Object.keys(this.selected).map(Number));
|
||||
},
|
||||
// ── A20 phase 3 (surface library) : formes compatibles build CSP ──
|
||||
// $nextTick / x-html ne passent pas par l'évaluateur maison du build CSP
|
||||
// (arrow inline = parse error ; x-html = interdit) → méthodes JS réelles,
|
||||
// réactivité via Alpine.effect — valable sous les deux builds.
|
||||
toggleFilterMenu() {
|
||||
this.filterOpen = !this.filterOpen;
|
||||
this.sortOpen = false;
|
||||
this.viewOpen = false;
|
||||
},
|
||||
toggleSortMenu() {
|
||||
this.sortOpen = !this.sortOpen;
|
||||
this.filterOpen = false;
|
||||
this.viewOpen = false;
|
||||
},
|
||||
toggleViewMenu() {
|
||||
this.viewOpen = !this.viewOpen;
|
||||
this.filterOpen = false;
|
||||
this.sortOpen = false;
|
||||
},
|
||||
toggleSearch() {
|
||||
this.searchOpen = !this.searchOpen;
|
||||
if (this.searchOpen) Alpine.nextTick(() => {
|
||||
var el = document.getElementById('lib-search-input');
|
||||
if (el) el.focus();
|
||||
});
|
||||
},
|
||||
bindHtmlIcon(el) {
|
||||
Alpine.effect(() => { el.innerHTML = getSvgIcon(this.emptyIcon, 48); });
|
||||
},
|
||||
bindHtmlItem(el, item) {
|
||||
Alpine.effect(() => { el.innerHTML = this._renderIcon(item); });
|
||||
},
|
||||
_renderIcon(item) {
|
||||
if (!item) return getSvgIcon('file', 14);
|
||||
// Custom emoji (image URL), icon name, or unicode emoji
|
||||
if (item.page_icon) return '<span style="font-size:14px;line-height:1;display:inline-flex;align-items:center;">' + (window.fdIconHtml ? window.fdIconHtml(item.page_icon, 14) : this._escHtml(item.page_icon)) + '</span>';
|
||||
@@ -363,7 +403,7 @@ function libraryPage() {
|
||||
},
|
||||
|
||||
_getCsrf() {
|
||||
var m = getCsrf();;
|
||||
var m = getCsrf();
|
||||
return m;
|
||||
},
|
||||
|
||||
@@ -925,13 +965,33 @@ function libraryPage() {
|
||||
newPage() { this.addNewPage(); },
|
||||
|
||||
// ── Side peek ──
|
||||
async openPeek(item) {
|
||||
// L'iframe est pilotée en JS : le build CSP d'Alpine n'évalue pas le
|
||||
// binding `:src` (l'iframe restait vide et aucun document n'apparaissait).
|
||||
// `about:blank` ferme explicitement le document précédent avant de charger
|
||||
// le nouveau : sans cela, même URL = pas de rechargement et l'ancien
|
||||
// document restait affiché.
|
||||
openPeek(item) {
|
||||
this.peekItem = item;
|
||||
this.peekFavorited = item.favorited || false;
|
||||
var frame = document.getElementById('lib-peek-iframe');
|
||||
var loading = document.getElementById('lib-peek-loading');
|
||||
if (!frame) return;
|
||||
if (loading) loading.style.display = '';
|
||||
frame.onload = function () { if (loading) loading.style.display = 'none'; };
|
||||
frame.src = 'about:blank';
|
||||
// `?embed=1` : le peek affiche SEULEMENT le document en mode édition
|
||||
// (ni sidebar ni barre de navigation) — le cadre du panneau suffit.
|
||||
frame.src = '/pages/' + item.id + '?embed=1';
|
||||
},
|
||||
|
||||
closePeek() {
|
||||
this.peekItem = null;
|
||||
var frame = document.getElementById('lib-peek-iframe');
|
||||
// On décharge l'éditeur : il resterait en mémoire (et son autosave
|
||||
// tournerait) alors qu'il n'est plus visible.
|
||||
if (frame) { frame.onload = null; frame.src = 'about:blank'; }
|
||||
var loading = document.getElementById('lib-peek-loading');
|
||||
if (loading) { loading.style.display = ''; loading.textContent = 'Chargement…'; }
|
||||
},
|
||||
|
||||
async toggleFavoritePeek() {
|
||||
@@ -1000,40 +1060,16 @@ function libraryPage() {
|
||||
}
|
||||
};
|
||||
}
|
||||
// Init peek resize handle
|
||||
// Init peek resize handle — helper partagé fdWirePeekResize (app.js, defer :
|
||||
// câblage différé jusqu'à sa disponibilité, ou immédiat si on est déjà prêt).
|
||||
(function() {
|
||||
var panel = document.querySelector('.peek-overlay');
|
||||
var handle = document.getElementById('lib-peek-resize-handle');
|
||||
if (!panel || !handle) return;
|
||||
var startX, startWidth, dragging = false;
|
||||
handle.addEventListener('pointerdown', function(e) {
|
||||
e.preventDefault();
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
startX = e.clientX;
|
||||
startWidth = panel.offsetWidth;
|
||||
dragging = true;
|
||||
document.body.style.userSelect = 'none';
|
||||
});
|
||||
handle.addEventListener('pointermove', function(e) {
|
||||
if (!dragging) return;
|
||||
var deltaX = startX - e.clientX;
|
||||
var newWidth = Math.max(300, Math.min(window.innerWidth * 0.9, startWidth + deltaX));
|
||||
panel.style.width = newWidth + 'px';
|
||||
});
|
||||
handle.addEventListener('pointerup', function(e) {
|
||||
if (!dragging) return;
|
||||
document.body.style.userSelect = '';
|
||||
var moved = Math.abs(e.clientX - startX);
|
||||
if (moved < 3) {
|
||||
var el = document.querySelector('.peek-overlay');
|
||||
if (el && el.__x) { el.__x.$data.peekItem = null; }
|
||||
}
|
||||
dragging = false;
|
||||
try { localStorage.setItem('fd_peek_width', panel.style.width); } catch { /* volontaire */ }
|
||||
handle.releasePointerCapture(e.pointerId);
|
||||
});
|
||||
try {
|
||||
var saved = localStorage.getItem('fd_peek_width');
|
||||
if (saved) panel.style.width = saved;
|
||||
} catch { /* volontaire */ }
|
||||
function wire() {
|
||||
var panel = document.querySelector('.peek-overlay');
|
||||
if (!panel || !window.fdWirePeekResize) return;
|
||||
window.fdWirePeekResize(panel, panel.querySelector('#lib-peek-resize-handle'), function() {
|
||||
if (panel.__x) panel.__x.$data.peekItem = null;
|
||||
});
|
||||
}
|
||||
if (window.fdWirePeekResize || document.readyState !== 'loading') wire();
|
||||
else document.addEventListener('DOMContentLoaded', wire);
|
||||
})();
|
||||
|
||||
+102
-44
@@ -1,7 +1,10 @@
|
||||
if (!window.__fdLocalWorkspaceScriptsLoaded) {
|
||||
window.__fdLocalWorkspaceScriptsLoaded = true;
|
||||
const LW=(()=>{try{const el=document.getElementById('lw-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
|
||||
window._wsInitData = (function() {
|
||||
const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
||||
// invisible au snapshot ji du build CSP (valeur bannie sinon)
|
||||
return {
|
||||
tree:[],
|
||||
displayTree:[],
|
||||
@@ -197,39 +200,11 @@ window._wsInitData = (function() {
|
||||
},
|
||||
|
||||
_initResizeHandle() {
|
||||
var panel = document.getElementById('ws-preview-panel');
|
||||
var handle = document.getElementById('ws-preview-resize-handle');
|
||||
if (!panel || !handle) return;
|
||||
var startX, startWidth, dragging = false;
|
||||
handle.addEventListener('pointerdown', function(e) {
|
||||
e.preventDefault();
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
startX = e.clientX;
|
||||
startWidth = panel.offsetWidth;
|
||||
dragging = true;
|
||||
document.body.style.userSelect = 'none';
|
||||
});
|
||||
handle.addEventListener('pointermove', function(e) {
|
||||
if (!dragging) return;
|
||||
var deltaX = startX - e.clientX;
|
||||
var newWidth = Math.max(300, Math.min(window.innerWidth * 0.9, startWidth + deltaX));
|
||||
panel.style.width = newWidth + 'px';
|
||||
});
|
||||
handle.addEventListener('pointerup', function(e) {
|
||||
if (!dragging) return;
|
||||
document.body.style.userSelect = '';
|
||||
var moved = Math.abs(e.clientX - startX);
|
||||
if (moved < 3) {
|
||||
window._wsData.closePreviewPanel();
|
||||
}
|
||||
dragging = false;
|
||||
try { localStorage.setItem('fd_peek_width', panel.style.width); } catch { /* volontaire */ }
|
||||
handle.releasePointerCapture(e.pointerId);
|
||||
});
|
||||
try {
|
||||
var saved = localStorage.getItem('fd_peek_width');
|
||||
if (saved) panel.style.width = saved;
|
||||
} catch { /* volontaire */ }
|
||||
// Helper partagé (app.js) — pointer capture, persistance fd_peek_width.
|
||||
window.fdWirePeekResize(
|
||||
document.getElementById('ws-preview-panel'),
|
||||
document.getElementById('ws-preview-resize-handle'),
|
||||
function () { window._wsData.closePreviewPanel(); });
|
||||
},
|
||||
|
||||
_recompute() {
|
||||
@@ -400,7 +375,7 @@ window._wsInitData = (function() {
|
||||
return null;
|
||||
},
|
||||
|
||||
// ── Preview panel (loads editor in iframe via /pages/{id}?embed=1) ──
|
||||
// ── Preview panel (loads the full-screen editor in an iframe) ──
|
||||
selectForPreview(node) {
|
||||
var panel = document.getElementById('ws-preview-panel');
|
||||
if (!panel) return;
|
||||
@@ -420,9 +395,12 @@ window._wsInitData = (function() {
|
||||
window.showToast('Failed to copy', 'error');
|
||||
});
|
||||
};
|
||||
// Load editor in iframe
|
||||
// Load ONLY the editable document in the iframe (`?embed=1`: no
|
||||
// sidebar, no navbar). `about:blank` first, so opening another
|
||||
// document really reloads the frame instead of keeping the previous
|
||||
// one when the URL happens to be identical.
|
||||
var iframe = document.getElementById('ws-preview-iframe');
|
||||
if (iframe) iframe.src = '/pages/' + node.id + '?embed=1';
|
||||
if (iframe) { iframe.src = 'about:blank'; iframe.src = '/pages/' + node.id + '?embed=1'; }
|
||||
// Show panel
|
||||
panel.style.display = 'flex';
|
||||
requestAnimationFrame(function() {
|
||||
@@ -627,7 +605,7 @@ window._wsInitData = (function() {
|
||||
if (!node) return;
|
||||
try {
|
||||
var method = node.favorited ? 'DELETE' : 'POST';
|
||||
var r = await fetch('/board/api/favorites/' + node.id, { method: method });
|
||||
var r = await fetch('/board/api/favorites/' + node.id, { method: method, headers: {'X-CSRF-Token': getCsrf()} });
|
||||
if (r.ok) {
|
||||
node.favorited = !node.favorited;
|
||||
if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites();
|
||||
@@ -1762,7 +1740,7 @@ window._wsInitData = (function() {
|
||||
var url = folderHandled ? '/api/local-workspace/upload-folder' : '/api/local-workspace/upload';
|
||||
|
||||
try {
|
||||
var r = await fetch(url, { method: 'POST', body: formData });
|
||||
var r = await fetch(url, { method: 'POST', headers: {'X-CSRF-Token': getCsrf()}, body: formData });
|
||||
this.uploadProgress = 80;
|
||||
var d = await r.json();
|
||||
if (r.ok) {
|
||||
@@ -2007,13 +1985,93 @@ window._wsInitData = (function() {
|
||||
this.clearSelection();
|
||||
this._reloadAfterAction();
|
||||
if (window.showToast) window.showToast('Item' + (ids.length > 1 ? 's' : '') + ' moved', 'success');
|
||||
}
|
||||
},
|
||||
|
||||
};
|
||||
// ── A20 ph3 : x-html interdit par le build CSP → x-init + Alpine.effect
|
||||
// (réactivité conservée : lecture des données réactives dans l'effect) ──
|
||||
toggleViewMenu() {
|
||||
this.viewMenuOpen = !this.viewMenuOpen;
|
||||
this.sortOpen = false;
|
||||
this.searchOpen = false;
|
||||
},
|
||||
toggleSortMenu() {
|
||||
this.sortOpen = !this.sortOpen;
|
||||
this.viewMenuOpen = false;
|
||||
},
|
||||
toggleFilterMenu() {
|
||||
this.filterOpen = !this.filterOpen;
|
||||
this.viewMenuOpen = false;
|
||||
this.sortOpen = false;
|
||||
},
|
||||
toggleSearch() {
|
||||
this.searchOpen = !this.searchOpen;
|
||||
if (this.searchOpen) Alpine.nextTick(() => {
|
||||
const el = document.querySelector('[x-ref="searchInput"]');
|
||||
if (el) el.focus();
|
||||
});
|
||||
},
|
||||
createPageAt(node) { window.FlowDeck.createPage(node.id); },
|
||||
createFolderAt(node) { window.FlowDeck.showCreateFolderModal(node.id); },
|
||||
bindSvg(el, name, size) { el.innerHTML = getSvgIcon(name, size); },
|
||||
bindFileIcon(el, node) {
|
||||
Alpine.effect(() => {
|
||||
el.innerHTML = this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
|
||||
});
|
||||
},
|
||||
bindNodeIcon(el, node) {
|
||||
Alpine.effect(() => {
|
||||
el.innerHTML = node.is_folder
|
||||
? getSvgIcon('folder', 24)
|
||||
: this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
|
||||
});
|
||||
},
|
||||
bindChildren(el, node) {
|
||||
Alpine.effect(() => {
|
||||
el.innerHTML = this.renderChildren(node.children, (node.depth || 0) + 1, this.tagsVersion);
|
||||
});
|
||||
},
|
||||
bindPreview(el) { Alpine.effect(() => { el.innerHTML = this.previewContent; }); },
|
||||
};
|
||||
})();
|
||||
// Injecter toutes les props comme globales pour Alpine (résout les 36 erreurs de scope)
|
||||
var _wsKeys = Object.keys(window._wsInitData);
|
||||
var _wsKeys = Object.keys(_wsInitData);
|
||||
for (var _i = 0; _i < _wsKeys.length; _i++) {
|
||||
try { window[_wsKeys[_i]] = window._wsInitData[_wsKeys[_i]]; } catch { /* volontaire */ }
|
||||
try { window[_wsKeys[_i]] = _wsInitData[_wsKeys[_i]]; } catch { /* volontaire */ }
|
||||
}
|
||||
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(_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).
|
||||
var _regWs = function () {
|
||||
// objet partagé JS↔Alpine via closure LEXICALE (const hors window →
|
||||
// non snapshoté par ji ; probe4 : factory retournant l'objet = accepté).
|
||||
Alpine.data('wsInitData', function () { return _wsInitData; });
|
||||
// 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;
|
||||
});
|
||||
};
|
||||
if (window.Alpine) {
|
||||
_regWs();
|
||||
fdRefreshXData('wsInitData');
|
||||
fdRefreshXData('wsPreview');
|
||||
} else {
|
||||
document.addEventListener('alpine:init', _regWs);
|
||||
}
|
||||
}
|
||||
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
|
||||
|
||||
@@ -0,0 +1,976 @@
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
* FlowDeck — My Tasks (tableau de bord façon Notion)
|
||||
*
|
||||
* Trois vues sur les mêmes tâches normalisées par `/my-tasks/api` :
|
||||
* • tableau — liste dense, statut / échéance éditables au clic ;
|
||||
* • kanban — colonnes = options de statut de la base d'origine, glisser
|
||||
* d'une colonne à l'autre écrit le statut ;
|
||||
* • calendrier — mois courant, tâches placées sur leur date d'échéance.
|
||||
*
|
||||
* Tout passe par le mapping enregistré côté serveur
|
||||
* (`collections.task_*_prop`) : le front envoie un champ (`status`, `due`,
|
||||
* `title`), jamais un identifiant de colonne.
|
||||
* ══════════════════════════════════════════════════════════════════════ */
|
||||
/* Le tableau de bord est monté par `flowDeckMyTasks()` pour **chaque** noeud
|
||||
* `#my-tasks-app` : la navigation interne de `app.js` (htmx) remplace
|
||||
* `.main-wrapper` sans recharger le document — le <script> est bien rejoué,
|
||||
* mais son garde global faisait sortir le IIFE et la nouvelle coquille restait
|
||||
* vide jusqu'au hard refresh. Le drapeau est désormais porté par l'élément
|
||||
* (`__fdMounted`), pas par `window`.
|
||||
*
|
||||
* Filet supplémentaire : une coquille montée mais **restée vide** est le
|
||||
* symptôme d'un swap interrompu. Plutôt que d'afficher du vide, on remonte
|
||||
* (les delegations d'événements, elles, ne sont posées qu'une fois par
|
||||
* noeud grâce à `__fdWired`). */
|
||||
function flowDeckMyTasks() {
|
||||
'use strict';
|
||||
|
||||
var app = document.getElementById('my-tasks-app');
|
||||
if (!app) return;
|
||||
if (app.__fdMounted && app.innerHTML.trim()) return;
|
||||
app.__fdMounted = true;
|
||||
var wire = !app.__fdWired; // écoutes posées une seule fois par noeud
|
||||
app.__fdWired = true;
|
||||
|
||||
var cfg = {};
|
||||
try { cfg = JSON.parse(app.getAttribute('data-config') || '{}'); } catch (e) { cfg = {}; }
|
||||
|
||||
var state = {
|
||||
view: cfg.view || 'table',
|
||||
due: (cfg.filters && cfg.filters.due) || 'all',
|
||||
sort: (cfg.filters && cfg.filters.sort) || 'due',
|
||||
hide_done: !!(cfg.filters && cfg.filters.hide_done),
|
||||
sources: cfg.sources || [],
|
||||
maxSources: cfg.maxSources || 10,
|
||||
tasks: [],
|
||||
month: null,
|
||||
calView: 'month',
|
||||
calDay: null
|
||||
};
|
||||
|
||||
var MONTHS = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet',
|
||||
'août', 'septembre', 'octobre', 'novembre', 'décembre'];
|
||||
var DAYS = ['lu', 'ma', 'me', 'je', 've', 'sa', 'di'];
|
||||
|
||||
function csrf() {
|
||||
return (typeof window.getCsrf === 'function') ? window.getCsrf() : '';
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// `collection_pages.icon` vaut la chaine sentinelle "file" pour une ligne
|
||||
// sans icône (héritage de l'import, cf. `database_table.js`). L'afficher
|
||||
// telle quel collait le mot "file" devant chaque titre.
|
||||
function rowIcon(icon) {
|
||||
return (!icon || icon === 'file') ? '📄' : icon;
|
||||
}
|
||||
|
||||
// Un identifiant de ligne de base n'est PAS un identifiant de page :
|
||||
// /db/pages/{id}/open/api renvoie la page de contenu (créée à la volée)
|
||||
// qui porte les blocs de la tâche. On ne navigue JAMAIS vers
|
||||
// /pages/{rowId} : cet id désigne une page sans rapport (cf.
|
||||
// database_table.js) — d'où le panneau latéral, comme dans Library.
|
||||
var PEEK_SVG_CLOSE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" ' +
|
||||
'x2="18" y2="18"/></svg>';
|
||||
var PEEK_SVG_FULL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/>' +
|
||||
'<polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
|
||||
|
||||
// Le panneau vit HORS de `#my-tasks-app` : `render()` remplace le contenu
|
||||
// de l'app à chaque filtre, le panneau doit survivre.
|
||||
function ensurePeek() {
|
||||
var p = document.getElementById('mt-peek');
|
||||
if (p) return p;
|
||||
p = document.createElement('div');
|
||||
p.className = 'mt-peek';
|
||||
p.id = 'mt-peek';
|
||||
p.setAttribute('aria-hidden', 'true');
|
||||
p.innerHTML = '<div class="mt-peek-handle" id="mt-peek-handle"></div>' +
|
||||
'<div class="mt-peek-head">' +
|
||||
'<button class="mt-peek-btn" id="mt-peek-close" title="Close" ' +
|
||||
'aria-label="Fermer">' + PEEK_SVG_CLOSE + '</button>' +
|
||||
'<div class="mt-peek-title" id="mt-peek-title"></div>' +
|
||||
'<button class="mt-peek-btn" id="mt-peek-full" title="Open full page" ' +
|
||||
'aria-label="Ouvrir dans une page entière">' + PEEK_SVG_FULL + '</button>' +
|
||||
'</div>' +
|
||||
'<div class="mt-peek-body" id="mt-peek-body">' +
|
||||
'<div class="mt-peek-loading" id="mt-peek-loading">Chargement…</div>' +
|
||||
'<iframe id="mt-peek-iframe" title="Contenu de la tâche" ' +
|
||||
'style="display:none"></iframe></div>';
|
||||
document.body.appendChild(p);
|
||||
|
||||
document.getElementById('mt-peek-close').addEventListener('click', closePeek);
|
||||
document.getElementById('mt-peek-full').addEventListener('click', function () {
|
||||
var f = document.getElementById('mt-peek-iframe');
|
||||
if (f && f.dataset.page) window.location.href = '/pages/' + f.dataset.page;
|
||||
});
|
||||
wirePeekResize(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
function closePeek() {
|
||||
var p = document.getElementById('mt-peek');
|
||||
if (!p) return;
|
||||
p.classList.remove('open');
|
||||
p.setAttribute('aria-hidden', 'true');
|
||||
var f = document.getElementById('mt-peek-iframe');
|
||||
// On décharge l'iframe : elle garde sinon en mémoire tout l'éditeur
|
||||
// d'un document que l'on vient de quitter.
|
||||
if (f) { f.src = ''; delete f.dataset.page; f.style.display = 'none'; }
|
||||
var l = document.getElementById('mt-peek-loading');
|
||||
if (l) l.style.display = '';
|
||||
}
|
||||
|
||||
function wirePeekResize(panel) {
|
||||
// Helper partagé (app.js) : pointer capture, borne 300–90vw, clic =
|
||||
// fermer, largeur persistée (localStorage fd_peek_width) — comme Library.
|
||||
window.fdWirePeekResize(panel, document.getElementById('mt-peek-handle'), closePeek);
|
||||
}
|
||||
|
||||
function openRowContent(rowId, title) {
|
||||
var panel = ensurePeek();
|
||||
document.getElementById('mt-peek-title').textContent = title || '';
|
||||
var loading = document.getElementById('mt-peek-loading');
|
||||
var frame = document.getElementById('mt-peek-iframe');
|
||||
if (loading) loading.style.display = '';
|
||||
if (frame) { frame.style.display = 'none'; frame.src = ''; delete frame.dataset.page; }
|
||||
panel.classList.add('open');
|
||||
panel.setAttribute('aria-hidden', 'false');
|
||||
|
||||
api('/db/pages/' + rowId + '/open/api').then(function (d) {
|
||||
var pid = d && d.page_id;
|
||||
if (!pid) throw new Error('page de contenu introuvable');
|
||||
var f = document.getElementById('mt-peek-iframe');
|
||||
if (!f || !document.getElementById('mt-peek').classList.contains('open')) return;
|
||||
if (loading) loading.style.display = 'none';
|
||||
f.dataset.page = pid;
|
||||
f.style.display = '';
|
||||
// `about:blank` d'abord : ouvrir une autre tâche doit REcharger
|
||||
// l'éditeur, sinon l'URL identique laisse le document précédent
|
||||
// affiché. `?embed=1` = le document en édition seule, sans
|
||||
// sidebar ni barre de navigation (le cadre du panneau suffit).
|
||||
f.src = 'about:blank';
|
||||
f.src = '/pages/' + pid + '?embed=1';
|
||||
}).catch(function (e) {
|
||||
if (loading) {
|
||||
loading.textContent = 'Impossible d’ouvrir le contenu : ' + e.message;
|
||||
loading.style.display = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function syncUrl() {
|
||||
// `replaceState` peut être refusé (iframe sandbox, `file://`,
|
||||
// navigation cross-origin) : l'URL n'est qu'un confort, jamais
|
||||
// l'interface — on ne laisse jamais une exception casser l'action.
|
||||
try {
|
||||
history.replaceState(null, '', '/my-tasks?' + qs());
|
||||
} catch (e) { /* ignoré */ }
|
||||
}
|
||||
|
||||
function qs() {
|
||||
var p = new URLSearchParams();
|
||||
p.set('view', state.view);
|
||||
p.set('due', state.due);
|
||||
p.set('sort', state.sort);
|
||||
if (state.hide_done) p.set('hide_done', 'true');
|
||||
return p.toString();
|
||||
}
|
||||
|
||||
function api(path, opts) {
|
||||
opts = opts || {};
|
||||
var headers = { 'X-CSRF-Token': csrf() };
|
||||
if (opts.body) headers['Content-Type'] = 'application/json';
|
||||
return fetch(path, {
|
||||
method: opts.method || 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: headers,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined
|
||||
}).then(function (r) {
|
||||
return r.json().catch(function () { return {}; }).then(function (d) {
|
||||
if (!r.ok) throw new Error(d.detail || ('Erreur ' + r.status));
|
||||
return d;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Données ───────────────────────────────────────────────────── */
|
||||
|
||||
function load() {
|
||||
return api('/my-tasks/api?' + qs()).then(function (d) {
|
||||
state.tasks = d.tasks || [];
|
||||
state.sources = d.sources || [];
|
||||
render();
|
||||
}).catch(function (e) {
|
||||
app.innerHTML = '<div class="my-tasks-error">' + esc(e.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function configuredSources() {
|
||||
return state.sources.filter(function (s) { return s.configured; });
|
||||
}
|
||||
|
||||
/* ── Barre d'outils ────────────────────────────────────────────── */
|
||||
|
||||
function toolbar() {
|
||||
var h = '<div class="mt-toolbar">';
|
||||
h += '<div class="mt-toolbar-left">';
|
||||
h += viewTabs();
|
||||
h += '</div><div class="mt-toolbar-right">';
|
||||
h += select('mt-due-filter', 'due', [
|
||||
['all', 'Toutes les échéances'],
|
||||
['today', 'Échéance aujourd\'hui'],
|
||||
['overdue', 'En retard + aujourd\'hui'],
|
||||
['week', 'Cette semaine']
|
||||
]);
|
||||
h += select('mt-sort', 'sort', [
|
||||
['due', 'Tri : échéance'],
|
||||
['status', 'Tri : statut'],
|
||||
['source', 'Tri : source'],
|
||||
['created', 'Tri : création']
|
||||
]);
|
||||
h += '<label class="mt-toggle"><input type="checkbox" id="mt-hide-done"' +
|
||||
(state.hide_done ? ' checked' : '') + '> Masquer terminées</label>';
|
||||
h += '<button class="mt-new-btn" id="mt-new-btn">+ Nouvelle tâche</button>';
|
||||
h += '</div></div>';
|
||||
h += sourcesBar();
|
||||
return h;
|
||||
}
|
||||
|
||||
function viewTabs() {
|
||||
var labels = { table: 'Tableau', board: 'Kanban', calendar: 'Calendrier' };
|
||||
var h = '<div class="mt-view-tabs">';
|
||||
Object.keys(labels).forEach(function (k) {
|
||||
// `data-no-nav` : le changement de vue est traité ici, en JS.
|
||||
// Sans lui, l'intercepteur de liens de `app.js` rejoue un
|
||||
// chargement complet de la page et remplace le DOM rendu.
|
||||
// Le `href` reste présent : le lien reste partageable et
|
||||
// ouvrable dans un onglet.
|
||||
h += '<a class="mt-view-tab' + (state.view === k ? ' active' : '') +
|
||||
'" data-no-nav href="/my-tasks?' + withView(k) + '">' +
|
||||
labels[k] + '</a>';
|
||||
});
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
function withView(v) {
|
||||
var p = new URLSearchParams();
|
||||
p.set('view', v);
|
||||
p.set('due', state.due);
|
||||
p.set('sort', state.sort);
|
||||
if (state.hide_done) p.set('hide_done', 'true');
|
||||
return p.toString();
|
||||
}
|
||||
|
||||
function select(cls, key, opts) {
|
||||
var s = '<select class="mt-select ' + cls + '" data-key="' + key + '">';
|
||||
opts.forEach(function (o) {
|
||||
s += '<option value="' + esc(o[0]) + '"' +
|
||||
(state[key] === o[0] ? ' selected' : '') + '>' + esc(o[1]) + '</option>';
|
||||
});
|
||||
return s + '</select>';
|
||||
}
|
||||
|
||||
function sourcesBar() {
|
||||
var n = state.sources.length;
|
||||
var ok = configuredSources().length;
|
||||
var pend = state.sources.filter(function (s) { return !s.configured; });
|
||||
var h = '<div class="mt-sources">';
|
||||
h += '<span class="mt-sources-label">Sources : <b>' + ok + '/' + state.maxSources +
|
||||
'</b> base(s) connectée(s)</span>';
|
||||
state.sources.forEach(function (s) {
|
||||
h += '<a class="mt-source-chip' + (s.configured ? '' : ' pending') +
|
||||
'" href="/db/' + s.id + '" title="' + esc(s.workspace_name) + '">' +
|
||||
esc(s.icon || '📋') + ' ' + esc(s.name) + '</a>';
|
||||
// Une base connectée au mapping incomplet se corrige d'ici.
|
||||
if (!s.configured) {
|
||||
h += '<button class="mt-source-fix" data-fix-source="' + s.id +
|
||||
'" title="Relier les colonnes manquantes : ' +
|
||||
esc((s.missing_roles || []).join(', ')) + '">Configurer</button>';
|
||||
}
|
||||
});
|
||||
if (pend.length) {
|
||||
h += '<span class="mt-sources-warn" title="Ces bases n’émettent pas de tâche ' +
|
||||
'tant que les 3 colonnes ne sont pas liées">⚠ ' + pend.length +
|
||||
' base(s) à configurer</span>';
|
||||
}
|
||||
if (n >= state.maxSources) {
|
||||
h += '<span class="mt-sources-warn">Limite de ' + state.maxSources +
|
||||
' bases atteinte</span>';
|
||||
}
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
/* ── Vue tableau ──────────────────────────────────────────────────────
|
||||
* Lignes en `div`, comme Library : c'est ce qui permet de n'afficher le
|
||||
* bouton « Open » qu'au survol de la ligne (`.mt-hover-only`).
|
||||
*/
|
||||
|
||||
// Bouton « Open » : SVG et libellé identiques à `.btn-open` de Library,
|
||||
// pour que les deux listes se ressemblent exactement.
|
||||
function openButton(t) {
|
||||
return '<button class="mt-open-btn mt-hover-only" data-open-row="' + t.id +
|
||||
'" data-open-title="' + esc(t.title) + '" ' +
|
||||
'title="Open in side peek" aria-label="Ouvrir dans le panneau latéral">' +
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
|
||||
'<rect x="3" y="3" width="18" height="18" rx="2"/>' +
|
||||
'<line x1="15" y1="3" x2="15" y2="21"/></svg> Open</button>';
|
||||
}
|
||||
|
||||
function viewTable() {
|
||||
if (!state.tasks.length) return emptyState();
|
||||
var h = '<div class="mt-table">' +
|
||||
'<div class="mt-thead">' +
|
||||
'<div class="mt-th mt-cell-name">Tâche</div>' +
|
||||
'<div class="mt-th mt-cell-status">Statut</div>' +
|
||||
'<div class="mt-th mt-cell-due">Échéance</div>' +
|
||||
'<div class="mt-th mt-cell-src">Source</div>' +
|
||||
'</div>';
|
||||
state.tasks.forEach(function (t) {
|
||||
h += '<div class="mt-row" data-id="' + t.id + '">';
|
||||
h += '<div class="mt-cell mt-cell-name">' +
|
||||
'<span class="mt-icon">' + esc(rowIcon(t.icon)) + '</span>' +
|
||||
'<span class="mt-title-text" data-edit="title" title="Cliquer pour renommer">' +
|
||||
esc(t.title) + '</span>' + openButton(t) + '</div>';
|
||||
h += '<div class="mt-cell mt-cell-status">' + statusCell(t) + '</div>';
|
||||
h += '<div class="mt-cell mt-cell-due">' + dueCell(t) + '</div>';
|
||||
h += '<div class="mt-cell mt-cell-src"><a class="mt-chip" href="/db/' +
|
||||
t.collection_id + '" title="' + esc(t.workspace_name) +
|
||||
' · ouvrir la base">' + esc(t.collection_icon) + ' ' +
|
||||
esc(t.collection_name) + '</a></div>';
|
||||
h += '</div>';
|
||||
});
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
function statusCell(t) {
|
||||
if (!t.status_options || !t.status_options.length) {
|
||||
return '<span class="mt-static">' + esc(t.status || '—') + '</span>';
|
||||
}
|
||||
return '<select class="mt-status-select" data-id="' + t.id +
|
||||
'" data-field="status">' + t.status_options.map(function (o) {
|
||||
return '<option value="' + esc(o) + '"' +
|
||||
(o === t.status ? ' selected' : '') + '>' + esc(o) + '</option>';
|
||||
}).join('') + '</select>';
|
||||
}
|
||||
|
||||
function dueCell(t) {
|
||||
var cls = t.due_state ? ' mt-due-' + t.due_state : '';
|
||||
return '<input type="date" class="mt-due-input' + cls + '" data-id="' + t.id +
|
||||
'" data-field="due" value="' + esc(t.due || '') + '">';
|
||||
}
|
||||
|
||||
/* ── Vue kanban ────────────────────────────────────────────────── */
|
||||
|
||||
function viewBoard() {
|
||||
var cols = [], seen = {};
|
||||
state.tasks.forEach(function (t) {
|
||||
(t.status_options || []).forEach(function (o) {
|
||||
if (!seen[o]) { seen[o] = 1; cols.push(o); }
|
||||
});
|
||||
if (!t.status || seen[t.status] === undefined) {
|
||||
if (t.status && !seen[t.status]) { seen[t.status] = 1; cols.push(t.status); }
|
||||
}
|
||||
});
|
||||
if (!cols.length) cols = ['À faire'];
|
||||
|
||||
var h = '<div class="mt-board">';
|
||||
cols.forEach(function (col) {
|
||||
var items = state.tasks.filter(function (t) { return (t.status || '') === col; });
|
||||
h += '<div class="mt-col" data-status="' + esc(col) + '">';
|
||||
h += '<div class="mt-col-head">' + esc(col) + ' <span class="mt-count">' +
|
||||
items.length + '</span></div>';
|
||||
h += '<div class="mt-col-body">';
|
||||
items.forEach(function (t) {
|
||||
h += '<div class="mt-card" draggable="true" data-id="' + t.id + '">' +
|
||||
'<span class="mt-icon">' + esc(rowIcon(t.icon)) + '</span>' +
|
||||
'<span class="mt-card-title">' + esc(t.title) + '</span>' +
|
||||
openButton(t) +
|
||||
(t.due ? '<span class="mt-card-due mt-due-' + esc(t.due_state) + '">📅 ' +
|
||||
esc(t.due) + '</span>' : '') +
|
||||
'<span class="mt-card-src">' + esc(t.collection_icon) + ' ' +
|
||||
esc(t.collection_name) + '</span></div>';
|
||||
});
|
||||
h += '</div></div>';
|
||||
});
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
/* ── Vue calendrier (5 vues) ─────────────────────────────────────── */
|
||||
|
||||
var CAL_VIEWS = [
|
||||
['month', 'Mois'], ['year', 'Année'], ['week', 'Semaine'],
|
||||
['day', 'Jour'], ['planning', 'Planning']
|
||||
];
|
||||
var OPEN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/>' +
|
||||
'<line x1="15" y1="3" x2="15" y2="21"/></svg>';
|
||||
|
||||
function calEnsure() {
|
||||
if (!state.month) {
|
||||
var n = new Date();
|
||||
state.month = { y: n.getFullYear(), m: n.getMonth() };
|
||||
}
|
||||
if (!state.calDay) {
|
||||
var t = new Date();
|
||||
state.calDay = t.getFullYear() + '-' +
|
||||
('0' + (t.getMonth() + 1)).slice(-2) + '-' +
|
||||
('0' + t.getDate()).slice(-2);
|
||||
}
|
||||
}
|
||||
|
||||
function isoDate(d) {
|
||||
return d.getFullYear() + '-' +
|
||||
('0' + (d.getMonth() + 1)).slice(-2) + '-' +
|
||||
('0' + d.getDate()).slice(-2);
|
||||
}
|
||||
|
||||
function calDate(iso) {
|
||||
var p = String(iso || '').split('-');
|
||||
return new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]));
|
||||
}
|
||||
|
||||
// Tâches groupées par jour d'échéance (celles sans échéance sont ignorées).
|
||||
function tasksByDay() {
|
||||
var byDay = {};
|
||||
state.tasks.forEach(function (t) {
|
||||
if (!t.due) return;
|
||||
(byDay[t.due] = byDay[t.due] || []).push(t);
|
||||
});
|
||||
return byDay;
|
||||
}
|
||||
|
||||
// Décalage du calendrier, la granularité dépendant de la vue courante.
|
||||
function calShift(delta) {
|
||||
calEnsure();
|
||||
var v = state.calView;
|
||||
if (v === 'year') {
|
||||
state.month = { y: state.month.y + delta, m: state.month.m };
|
||||
} else if (v === 'week') {
|
||||
var w = calDate(state.calDay);
|
||||
w.setDate(w.getDate() + delta * 7);
|
||||
state.calDay = isoDate(w);
|
||||
} else if (v === 'day') {
|
||||
var d = calDate(state.calDay);
|
||||
d.setDate(d.getDate() + delta);
|
||||
state.calDay = isoDate(d);
|
||||
state.month = { y: d.getFullYear(), m: d.getMonth() };
|
||||
} else {
|
||||
var m = state.month.m + delta;
|
||||
state.month = {
|
||||
m: ((m % 12) + 12) % 12,
|
||||
y: state.month.y + Math.floor(m / 12)
|
||||
};
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function calTitle() {
|
||||
calEnsure();
|
||||
var y = state.month.y, m = state.month.m;
|
||||
if (state.calView === 'year') return String(y);
|
||||
if (state.calView === 'planning') return 'Planning — ' + MONTHS[m] + ' ' + y;
|
||||
if (state.calView === 'day') {
|
||||
var d = calDate(state.calDay);
|
||||
return DAYS[(d.getDay() + 6) % 7] + ' ' + d.getDate() + ' ' +
|
||||
MONTHS[d.getMonth()] + ' ' + d.getFullYear();
|
||||
}
|
||||
if (state.calView === 'week') {
|
||||
var s = calDate(state.calDay);
|
||||
var lead = (s.getDay() + 6) % 7; // lundi = 0
|
||||
s.setDate(s.getDate() - lead);
|
||||
var e = new Date(s.getFullYear(), s.getMonth(), s.getDate() + 6);
|
||||
return s.getDate() + ' – ' + e.getDate() + ' ' + MONTHS[e.getMonth()] +
|
||||
' ' + e.getFullYear();
|
||||
}
|
||||
return MONTHS[m] + ' ' + y;
|
||||
}
|
||||
|
||||
function calTabs() {
|
||||
var h = '<div class="mt-cal-tabs">';
|
||||
CAL_VIEWS.forEach(function (v) {
|
||||
h += '<button class="mt-cal-tab' + (state.calView === v[0] ? ' active' : '') +
|
||||
'" data-cal-view="' + v[0] + '">' + v[1] + '</button>';
|
||||
});
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
// Événement de calendrier : titre + bouton d'ouverture au survol.
|
||||
function calEvHtml(t, draggable) {
|
||||
return '<div class="mt-cal-ev' + (draggable ? ' drag' : '') + '"' +
|
||||
(draggable ? ' draggable="true" data-id="' + t.id + '"' : '') +
|
||||
' title="' + esc(t.collection_name) + ' · ' + esc(t.status || '') +
|
||||
' · ' + esc(t.due || '') + '">' +
|
||||
'<span class="mt-cal-ev-text">' + esc(t.title) + '</span>' +
|
||||
'<button class="mt-cal-open mt-hover-only" data-open-row="' + t.id +
|
||||
'" data-open-title="' + esc(t.title) + '" title="Open in side peek" ' +
|
||||
'aria-label="Ouvrir dans le panneau latéral">' + OPEN_SVG + '</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function viewCalendar() {
|
||||
calEnsure();
|
||||
var h = '<div class="mt-cal">' +
|
||||
'<div class="mt-cal-head">' +
|
||||
'<button class="mt-cal-nav" data-nav="-1" aria-label="Précédent">‹</button>' +
|
||||
'<span class="mt-cal-title">' + esc(calTitle()) + '</span>' +
|
||||
'<button class="mt-cal-nav" data-nav="1" aria-label="Suivant">›</button>' +
|
||||
'</div>' + calTabs() + '<div class="mt-cal-body">';
|
||||
if (state.calView === 'year') h += calYear();
|
||||
else if (state.calView === 'week') h += calWeek();
|
||||
else if (state.calView === 'day') h += calDay();
|
||||
else if (state.calView === 'planning') h += calPlanning();
|
||||
else h += calMonth();
|
||||
return h + '</div></div>';
|
||||
}
|
||||
|
||||
/* Mois : grille 7 × 6, glisser-déposer pour changer l'échéance. */
|
||||
function calMonth() {
|
||||
calEnsure();
|
||||
var y = state.month.y, m = state.month.m;
|
||||
var byDay = tasksByDay();
|
||||
var first = new Date(y, m, 1);
|
||||
var lead = (first.getDay() + 6) % 7; // lundi = 0
|
||||
var start = new Date(y, m, 1 - lead);
|
||||
var todayIso = isoDate(new Date());
|
||||
|
||||
var h = '<div class="mt-cal-grid">';
|
||||
DAYS.forEach(function (d) { h += '<div class="mt-cal-dow">' + d + '</div>'; });
|
||||
for (var i = 0; i < 42; i++) {
|
||||
var d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
|
||||
var key = isoDate(d);
|
||||
var evs = byDay[key] || [];
|
||||
h += '<div class="mt-cal-cell' + (d.getMonth() !== m ? ' out' : '') +
|
||||
(key === todayIso ? ' today' : '') + '" data-day="' + key + '">';
|
||||
h += '<div class="mt-cal-day">' + d.getDate() + '</div>';
|
||||
evs.forEach(function (t) { h += calEvHtml(t, true); });
|
||||
h += '</div>';
|
||||
}
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
/* Année : 12 mini-mois ; un point signale un jour chargé. */
|
||||
function calYear() {
|
||||
calEnsure();
|
||||
var byDay = tasksByDay();
|
||||
var todayIso = isoDate(new Date());
|
||||
var h = '<div class="mt-cal-year">';
|
||||
for (var mo = 0; mo < 12; mo++) {
|
||||
var y = state.month.y;
|
||||
var first = new Date(y, mo, 1);
|
||||
var lead = (first.getDay() + 6) % 7;
|
||||
var start = new Date(y, mo, 1 - lead);
|
||||
var dim = new Date(y, mo + 1, 0).getDate();
|
||||
h += '<div class="mt-cal-mini">';
|
||||
h += '<div class="mt-cal-mini-title">' + MONTHS[mo] + '</div>';
|
||||
h += '<div class="mt-cal-mini-grid">';
|
||||
DAYS.forEach(function (d) {
|
||||
h += '<span class="mt-cal-mini-dow">' + d.charAt(0) + '</span>';
|
||||
});
|
||||
for (var i = 0; i < lead; i++) h += '<span class="mt-cal-mini-day out"></span>';
|
||||
for (var dd = 1; dd <= dim; dd++) {
|
||||
var key = y + '-' + ('0' + (mo + 1)).slice(-2) + '-' +
|
||||
('0' + dd).slice(-2);
|
||||
var n = (byDay[key] || []).length;
|
||||
h += '<button class="mt-cal-mini-day' + (key === todayIso ? ' today' : '') +
|
||||
(n ? ' busy' : '') + '" data-cal-day="' + key + '" ' +
|
||||
'title="' + dd + ' ' + MONTHS[mo] + (n ? ' — ' + n + ' tâche(s)' : '') + '">' +
|
||||
dd + '</button>';
|
||||
}
|
||||
h += '</div></div>';
|
||||
}
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
/* Semaine : 7 colonnes, une par jour. */
|
||||
function calWeek() {
|
||||
calEnsure();
|
||||
var byDay = tasksByDay();
|
||||
var s = calDate(state.calDay);
|
||||
var lead = (s.getDay() + 6) % 7; // lundi = 0
|
||||
s.setDate(s.getDate() - lead);
|
||||
var todayIso = isoDate(new Date());
|
||||
var h = '<div class="mt-cal-week">';
|
||||
for (var i = 0; i < 7; i++) {
|
||||
var d = new Date(s.getFullYear(), s.getMonth(), s.getDate() + i);
|
||||
var key = isoDate(d);
|
||||
var evs = byDay[key] || [];
|
||||
h += '<div class="mt-cal-wcol' + (key === todayIso ? ' today' : '') + '">';
|
||||
h += '<div class="mt-cal-wcol-head">' + DAYS[i] + ' ' + d.getDate() + '</div>';
|
||||
h += '<div class="mt-cal-wcol-body">';
|
||||
if (!evs.length) h += '<div class="mt-cal-wempty">—</div>';
|
||||
evs.forEach(function (t) { h += calEvHtml(t, false); });
|
||||
h += '</div></div>';
|
||||
}
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
/* Jour : la liste complète des tâches du jour sélectionné. */
|
||||
function calDay() {
|
||||
calEnsure();
|
||||
var byDay = tasksByDay();
|
||||
var key = state.calDay;
|
||||
var evs = byDay[key] || [];
|
||||
var h = '<div class="mt-cal-dayview">';
|
||||
h += '<div class="mt-cal-dayview-head">' + esc(calTitle()) +
|
||||
' · <b>' + evs.length + '</b> tâche(s)</div>';
|
||||
if (!evs.length) {
|
||||
h += '<div class="mt-cal-dayview-empty">Aucune tâche pour ce jour.</div>';
|
||||
return h + '</div>';
|
||||
}
|
||||
h += '<div class="mt-cal-dayview-list">';
|
||||
evs.forEach(function (t) {
|
||||
h += '<div class="mt-cal-task" data-id="' + t.id + '">' +
|
||||
'<span class="mt-icon">' + esc(rowIcon(t.icon)) + '</span>' +
|
||||
'<span class="mt-cal-task-title">' + esc(t.title) + '</span>' +
|
||||
'<span class="mt-cal-task-src">' + esc(t.collection_icon) + ' ' +
|
||||
esc(t.collection_name) + '</span>' +
|
||||
'<span class="mt-cal-task-status">' + esc(t.status || '—') + '</span>' +
|
||||
'<button class="mt-open-btn" data-open-row="' + t.id +
|
||||
'" data-open-title="' + esc(t.title) + '" title="Open in side peek" ' +
|
||||
'aria-label="Ouvrir dans le panneau latéral">' + OPEN_SVG + ' Open</button>' +
|
||||
'</div>';
|
||||
});
|
||||
return h + '</div></div>';
|
||||
}
|
||||
|
||||
/* Planning : colonnes = statuts, lignes = jours du mois (tâches placées
|
||||
à leur date d'échéance). C'est la vue « planning » de Notion. */
|
||||
function calPlanning() {
|
||||
calEnsure();
|
||||
var byDay = tasksByDay();
|
||||
var y = state.month.y, m = state.month.m;
|
||||
var statuses = [], seen = {};
|
||||
state.tasks.forEach(function (t) {
|
||||
if (!t.due) return;
|
||||
var s = t.status || 'Sans statut';
|
||||
if (!seen[s]) { seen[s] = 1; statuses.push(s); }
|
||||
});
|
||||
if (!statuses.length) statuses = ['Sans statut'];
|
||||
var dim = new Date(y, m + 1, 0).getDate();
|
||||
var todayIso = isoDate(new Date());
|
||||
|
||||
var h = '<div class="mt-cal-plan">';
|
||||
h += '<div class="mt-cal-plan-row head"><div class="mt-cal-plan-gutter"></div>';
|
||||
statuses.forEach(function (s) {
|
||||
h += '<div class="mt-cal-plan-col-title">' + esc(s) + '</div>';
|
||||
});
|
||||
h += '</div>';
|
||||
for (var d = 1; d <= dim; d++) {
|
||||
var key = y + '-' + ('0' + (m + 1)).slice(-2) + '-' + ('0' + d).slice(-2);
|
||||
var evs = byDay[key] || [];
|
||||
h += '<div class="mt-cal-plan-row' + (key === todayIso ? ' today' : '') + '">' +
|
||||
'<div class="mt-cal-plan-gutter">' + d + '</div>';
|
||||
statuses.forEach(function (s) {
|
||||
h += '<div class="mt-cal-plan-cell">';
|
||||
evs.filter(function (t) {
|
||||
return (t.status || 'Sans statut') === s;
|
||||
}).forEach(function (t) { h += calEvHtml(t, false); });
|
||||
h += '</div>';
|
||||
});
|
||||
h += '</div>';
|
||||
}
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
/* ── États vides ───────────────────────────────────────────────── */
|
||||
|
||||
function emptyState() {
|
||||
var title, msg, hint, act;
|
||||
var connected = configuredSources().length;
|
||||
if (!state.sources.length) {
|
||||
title = 'Aucune base connectée';
|
||||
msg = "Aucune base de tâches n'alimente ce tableau de bord.";
|
||||
hint = 'Ouvrez une base, puis cliquez sur « Convertir en base de tâches » ' +
|
||||
'dans sa barre (à gauche du bouton New) et reliez les colonnes ' +
|
||||
'Assigné à, Statut et Échéance.';
|
||||
act = '<a class="mt-empty-btn" href="/db">Ouvrir mes bases</a>';
|
||||
} else if (!connected) {
|
||||
title = 'Mapping incomplet';
|
||||
msg = 'Bases connectées mais aucune configurée.';
|
||||
hint = 'Il manque le mapping Assigné à / Statut / Échéance. Ouvrez la ' +
|
||||
'base et cliquez sur « Configurer » dans sa barre.';
|
||||
act = '<a class="mt-empty-btn" href="/db">Ouvrir mes bases</a>';
|
||||
} else if (state.due !== 'all' || state.hide_done || state.sort !== 'due') {
|
||||
title = 'Aucun résultat';
|
||||
msg = 'Aucune tâche ne correspond aux filtres actifs.';
|
||||
hint = '';
|
||||
act = '<a class="mt-empty-btn" href="/my-tasks">Réinitialiser les filtres</a>';
|
||||
} else {
|
||||
title = 'Rien à faire';
|
||||
msg = "Aucune tâche ne vous est assignée pour le moment.";
|
||||
hint = 'Ajoutez-en une, ou branchesz une autre base pour voir plus large.';
|
||||
act = '<button class="mt-empty-btn" id="mt-new-btn">Créer une tâche</button>';
|
||||
}
|
||||
// Même gabarit centré que l'état vide rendu par le serveur
|
||||
// (`_render_empty`), pour que les deux se ressemblent.
|
||||
return '<div class="mt-empty"><div class="mt-empty-icon">📋</div>' +
|
||||
'<h2>' + esc(title) + '</h2><p>' + esc(msg) + '</p>' +
|
||||
(hint ? '<p class="mt-empty-hint">' + esc(hint) + '</p>' : '') +
|
||||
act + '</div>';
|
||||
}
|
||||
|
||||
/* ── Modale « nouvelle tâche » (multi-destinations) ────────────── */
|
||||
|
||||
// Ouvre la modale de conversion pour une base donnée (les bases
|
||||
// connectées mais mal configurées ne peuvent pas recevoir de tâche).
|
||||
function openConvertModal(collectionId) {
|
||||
if (collectionId && window.FlowDeckTaskDb) {
|
||||
window.FlowDeckTaskDb.open(Number(collectionId));
|
||||
}
|
||||
}
|
||||
|
||||
function openNewModal() {
|
||||
var srcs = configuredSources();
|
||||
if (!srcs.length) {
|
||||
alert("Aucune base de tâches connectée.\n\nConvertissez d'abord une base : " +
|
||||
"ouvrez-la, puis cliquez sur « Convertir en base de tâches » " +
|
||||
"dans la barre de la base, à gauche du bouton New.");
|
||||
return;
|
||||
}
|
||||
var groups = {};
|
||||
srcs.forEach(function (s) {
|
||||
(groups[s.workspace_name || 'Autres'] = groups[s.workspace_name || 'Autres'] || [])
|
||||
.push(s);
|
||||
});
|
||||
var h = '<div class="mt-modal-overlay" id="mt-modal-overlay"><div class="mt-modal">';
|
||||
h += '<div class="mt-modal-head"><span>Nouvelle tâche</span>' +
|
||||
'<button class="mt-modal-close" id="mt-modal-close">✕</button></div>';
|
||||
h += '<div class="mt-modal-body">';
|
||||
h += '<label class="mt-field">Titre<input id="mt-new-title" type="text" ' +
|
||||
'placeholder="Ex. Envoyer la maquette"></label>';
|
||||
h += '<label class="mt-field">Échéance<input id="mt-new-due" type="date"></label>';
|
||||
Object.keys(groups).forEach(function (ws) {
|
||||
h += '<div class="mt-modal-group">' + esc(ws) + '</div>';
|
||||
groups[ws].forEach(function (s) {
|
||||
h += '<label class="mt-dest"><input type="radio" name="mt-dest" value="' +
|
||||
s.id + '"><span class="mt-icon">' + esc(s.icon || '📋') + '</span>' +
|
||||
esc(s.name) + '</label>';
|
||||
});
|
||||
});
|
||||
h += '</div><div class="mt-modal-foot">' +
|
||||
'<button class="mt-btn-cancel" id="mt-new-cancel">Annuler</button>' +
|
||||
'<button class="mt-btn-primary" id="mt-new-create">Créer</button></div>';
|
||||
h += '</div></div>';
|
||||
document.body.insertAdjacentHTML('beforeend', h);
|
||||
|
||||
var first = document.querySelector('input[name="mt-dest"]');
|
||||
if (first) first.checked = true;
|
||||
document.getElementById('mt-new-title').focus();
|
||||
|
||||
var close = function () {
|
||||
var ov = document.getElementById('mt-modal-overlay');
|
||||
if (ov) ov.remove();
|
||||
};
|
||||
// `Escape` ferme aussi la modale.
|
||||
document.addEventListener('keydown', function onEsc(e) {
|
||||
if (e.key !== 'Escape') return;
|
||||
document.removeEventListener('keydown', onEsc);
|
||||
close();
|
||||
});
|
||||
document.getElementById('mt-modal-close').onclick = close;
|
||||
document.getElementById('mt-new-cancel').onclick = close;
|
||||
document.getElementById('mt-modal-overlay').onclick = function (e) {
|
||||
if (e.target === this) close();
|
||||
};
|
||||
document.getElementById('mt-new-create').onclick = function () {
|
||||
var title = document.getElementById('mt-new-title').value.trim();
|
||||
var due = document.getElementById('mt-new-due').value;
|
||||
var dest = document.querySelector('input[name="mt-dest"]:checked');
|
||||
if (!title) { alert('Donnez un titre à la tâche.'); return; }
|
||||
if (!dest) { alert('Choisissez une base de destination.'); return; }
|
||||
api('/my-tasks/api/task', {
|
||||
method: 'POST',
|
||||
body: { collection_id: Number(dest.value), title: title, due: due || null }
|
||||
}).then(function () { close(); load(); })
|
||||
.catch(function (e) { close(); alert(e.message); });
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Écriture en direct ────────────────────────────────────────── */
|
||||
|
||||
function patch(id, field, value) {
|
||||
return api('/my-tasks/api/task/' + id, { method: 'PUT', body: { field: field, value: value } })
|
||||
.then(function (d) {
|
||||
if (d.left_mytasks) {
|
||||
state.tasks = state.tasks.filter(function (t) { return t.id !== id; });
|
||||
render();
|
||||
} else if (d.task) {
|
||||
state.tasks = state.tasks.map(function (t) {
|
||||
return t.id === d.task.id ? d.task : t;
|
||||
});
|
||||
render();
|
||||
}
|
||||
})
|
||||
.catch(function (e) { alert(e.message); load(); });
|
||||
}
|
||||
|
||||
function startTitleEdit(span) {
|
||||
if (span.querySelector('input')) return;
|
||||
var tr = span.closest('.mt-row');
|
||||
var id = Number(tr.dataset.id);
|
||||
var old = span.textContent;
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'mt-title-input';
|
||||
input.value = old;
|
||||
span.textContent = '';
|
||||
span.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
var done = false;
|
||||
var commit = function () {
|
||||
if (done) return;
|
||||
done = true;
|
||||
var v = input.value.trim();
|
||||
if (v && v !== old) patch(id, 'title', v); else render();
|
||||
};
|
||||
input.onblur = commit;
|
||||
input.onkeydown = function (e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
||||
if (e.key === 'Escape') { done = true; render(); }
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Rendu + délégation d'événements ───────────────────────────── */
|
||||
|
||||
function render() {
|
||||
var body = state.view === 'board' ? viewBoard()
|
||||
: state.view === 'calendar' ? viewCalendar()
|
||||
: viewTable();
|
||||
app.innerHTML = '<div class="mt-head"><h1>📋 My Tasks</h1>' +
|
||||
'<span class="mt-count-total">' + state.tasks.length + ' tâche(s)</span></div>' +
|
||||
toolbar() + body;
|
||||
wireDnD();
|
||||
}
|
||||
|
||||
function wireDnD() {
|
||||
var cards = app.querySelectorAll('[draggable="true"]');
|
||||
cards.forEach(function (c) {
|
||||
c.addEventListener('dragstart', function (e) {
|
||||
e.dataTransfer.setData('text/plain', c.dataset.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
});
|
||||
app.querySelectorAll('.mt-col').forEach(function (col) {
|
||||
col.addEventListener('dragover', function (e) { e.preventDefault(); });
|
||||
col.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
var id = Number(e.dataTransfer.getData('text/plain'));
|
||||
if (id && col.dataset.status) patch(id, 'status', col.dataset.status);
|
||||
});
|
||||
});
|
||||
// Calendrier : glisser une tâche sur un jour change son échéance.
|
||||
app.querySelectorAll('.mt-cal-cell').forEach(function (cell) {
|
||||
cell.addEventListener('dragover', function (e) { e.preventDefault(); });
|
||||
cell.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
var id = Number(e.dataTransfer.getData('text/plain'));
|
||||
if (id && cell.dataset.day) patch(id, 'due', cell.dataset.day);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (wire) app.addEventListener('click', function (e) {
|
||||
var t = e.target;
|
||||
|
||||
if (t.id === 'mt-new-btn') { openNewModal(); return; }
|
||||
var opener = t.closest ? t.closest('[data-open-row]') : null;
|
||||
if (opener) {
|
||||
// `closest` : dans le calendrier le bouton ne contient que son
|
||||
// SVG — un clic sur l'icone donnait `e.target` = <svg> sans
|
||||
// dataset, et le bouton ne faisait rien.
|
||||
e.preventDefault();
|
||||
// Le titre vient de l'attribut : il est disponible aussi bien dans
|
||||
// le tableau que dans le calendrier ou la vue Jour.
|
||||
var label = opener.dataset.openTitle;
|
||||
if (!label) {
|
||||
var row = opener.closest('.mt-row');
|
||||
var cell = row && row.querySelector('.mt-title-text');
|
||||
label = cell ? cell.textContent : '';
|
||||
}
|
||||
openRowContent(Number(opener.dataset.openRow), label);
|
||||
return;
|
||||
}
|
||||
if (t.dataset && t.dataset.fixSource) {
|
||||
e.preventDefault();
|
||||
openConvertModal(t.dataset.fixSource);
|
||||
return;
|
||||
}
|
||||
if (t.classList.contains('mt-cal-nav')) {
|
||||
calShift(Number(t.dataset.nav));
|
||||
return;
|
||||
}
|
||||
if (t.dataset && t.dataset.calView) {
|
||||
state.calView = t.dataset.calView;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (t.dataset && t.dataset.calDay) {
|
||||
// Un jour de la vue Année ouvre la vue Jour dessus.
|
||||
state.calDay = t.dataset.calDay;
|
||||
var cd = calDate(state.calDay);
|
||||
state.month = { y: cd.getFullYear(), m: cd.getMonth() };
|
||||
state.calView = 'day';
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (t.dataset && t.dataset.edit === 'title') { startTitleEdit(t); return; }
|
||||
|
||||
var link = t.closest ? t.closest('a.mt-view-tab') : null;
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
var p = new URLSearchParams(link.getAttribute('href').split('?')[1] || '');
|
||||
state.view = p.get('view') || 'table';
|
||||
syncUrl();
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
if (wire) app.addEventListener('change', function (e) {
|
||||
var t = e.target;
|
||||
if (t.classList.contains('mt-select')) {
|
||||
state[t.dataset.key] = t.value;
|
||||
syncUrl();
|
||||
load();
|
||||
} else if (t.id === 'mt-hide-done') {
|
||||
state.hide_done = t.checked;
|
||||
syncUrl();
|
||||
load();
|
||||
} else if (t.classList.contains('mt-status-select')) {
|
||||
patch(Number(t.dataset.id), 'status', t.value);
|
||||
} else if (t.classList.contains('mt-due-input')) {
|
||||
patch(Number(t.dataset.id), 'due', t.value);
|
||||
}
|
||||
});
|
||||
|
||||
load();
|
||||
|
||||
/* Une modale ou un panneau restés ouverts pendant une navigation
|
||||
* masqueraient la page suivante : on referme au remontage. */
|
||||
if (wire) {
|
||||
var staleModal = document.getElementById('mt-modal-overlay');
|
||||
if (staleModal) staleModal.remove();
|
||||
closePeek();
|
||||
document.addEventListener('keydown', function (e) {
|
||||
// `Escape` ferme le panneau latéral, comme dans Library.
|
||||
if (e.key === 'Escape') closePeek();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// htmx ré-exécute les <script> de la zone swappée : on remonte à chaque
|
||||
// navigation interne. `popstate` couvre le bouton « retour », où le <script>
|
||||
// n'est pas forcément rejoué.
|
||||
document.addEventListener('htmx:afterSwap', flowDeckMyTasks);
|
||||
window.addEventListener('popstate', flowDeckMyTasks);
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', flowDeckMyTasks);
|
||||
} else {
|
||||
flowDeckMyTasks();
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
if (!window.__fdRtScriptsLoaded) {
|
||||
window.__fdRtScriptsLoaded = true;
|
||||
const RT=(()=>{try{const el=document.getElementById('rt-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
/* eslint-disable */
|
||||
@@ -530,3 +532,4 @@ window.__fdRT = (function () {
|
||||
|
||||
return { start, syncNow };
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -1124,7 +1124,21 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
if(!wrap)return;
|
||||
wrap.classList.toggle('full-width',!!this.fullWidth);
|
||||
wrap.classList.toggle('small-text',!!this.fontSmall);
|
||||
// Page dont le premier bloc est une base : layout « page de base »
|
||||
// Notion — titre et tableau calés à gauche, pas centrés.
|
||||
const b0=this.blocks&&this.blocks[0];
|
||||
wrap.classList.toggle('db-first',!!(b0&&b0.type==='embed'&&b0.embed_type==='collection'));
|
||||
},
|
||||
closeInviteSuggest(){ this.inviteSuggestOpen = false; this.inviteUsers = []; },
|
||||
setSharePerm(a, perm){ this.updateShare(a.id, perm); a.permOpen = false; },
|
||||
removeShareAndClose(a){ this.removeShare(a.id); a.permOpen = false; },
|
||||
pickGeneralAccess(v){ this.generalAccess = v; this.accessMenuOpen = false; },
|
||||
applyIcon(){ this.setIcon(this.iconValue); this.iconOpen = false; },
|
||||
moreToggleLock(){ this.moreOpen = false; this.toggleLock(); },
|
||||
moreFullWidth(){ this.moreOpen = false; this.setPageOption('full_width', !this.fullWidth); },
|
||||
moreFontSmall(){ this.moreOpen = false; this.setPageOption('font_small', !this.fontSmall); },
|
||||
moreSaveTemplate(){ this.moreOpen = false; this.saveAsTemplate(); },
|
||||
markAndSave(){ this.dirty = true; this.save(); },
|
||||
async toggleLock(){
|
||||
if(!this.canEdit){this.showToast&&this.showToast('Only the person who locked this page (or an admin) can unlock it','error');return;}
|
||||
const next=!this.isLocked;
|
||||
@@ -1421,6 +1435,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';
|
||||
@@ -1846,7 +1869,11 @@ applyAIBlocks(text){
|
||||
})
|
||||
.catch(function(){ self.showToast('Move failed'); });
|
||||
},
|
||||
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf = getCsrf();;fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
|
||||
// Le route était /board/api/pages/{id}/trash → 404 permanent (le .then
|
||||
// naviguait quand même : la page n'allait JAMAIS à la poubelle depuis
|
||||
// l'éditeur). Route réelle : /api/pages/{id}/trash, et on ne navigue
|
||||
// plus si la réponse n'est pas ok.
|
||||
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf = getCsrf();fetch(`/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf}}).then(r=>{if(!r.ok)throw new Error('HTTP '+r.status);window.location.href='/';}).catch(()=>{window.showToast('Move to Trash failed','error');});},
|
||||
downloadFile(){this.moreOpen=false;if(!this.fileUrl)return;var a=document.createElement('a');a.href=this.fileUrl;a.download='';document.body.appendChild(a);a.click();document.body.removeChild(a);this.showToast('Download started','success');},
|
||||
async copyFileContent(){this.moreOpen=false;if(!this.fileUrl)return;try{var r=await fetch('/api/pages/'+this.pid+'/file-content');var d=await r.json();if(d.ok&&d.content!==undefined){await navigator.clipboard.writeText(d.content);this.showToast('Content copied to clipboard','success');}else{this.showToast('Not a text file','error');}}catch(e){this.showToast('Copy failed','error');}},
|
||||
|
||||
@@ -2081,9 +2108,9 @@ applyAIBlocks(text){
|
||||
async _pickSyncedBlock(idx){
|
||||
const block=this.blocks[idx];if(!block)return;
|
||||
try{
|
||||
const r=await fetch('/api/synced-blocks');const d=await r.json();
|
||||
const r=await fetch('/board/api/synced-blocks');const d=await r.json();
|
||||
const items=d.synced_blocks||[];
|
||||
if(!items.length){this.showToast('No synced blocks yet. Create one first in Settings → Synced Blocks.');return;}
|
||||
if(!items.length){this.showToast('No synced blocks yet — create one from a Board page first.');return;}
|
||||
const names=items.map(function(s){return (s.title||'Untitled')+' (id:'+s.id+')';});
|
||||
const choice=window.prompt('Choose a synced block:\n\n'+names.join('\n'));
|
||||
if(!choice)return;
|
||||
@@ -2091,7 +2118,7 @@ applyAIBlocks(text){
|
||||
if(!match)return;
|
||||
const sid=parseInt(match[1]);
|
||||
if(!sid){this.showToast('Invalid synced block','error');return;}
|
||||
const sr=await fetch('/api/synced-blocks/'+sid);const sd=await sr.json();
|
||||
const sr=await fetch('/board/api/synced-blocks/'+sid);const sd=await sr.json();
|
||||
if(!sd||!sd.content){this.showToast('Synced block not found','error');return;}
|
||||
block.synced_id=sid;
|
||||
block._synced_content=sd.content||[];
|
||||
@@ -2512,5 +2539,44 @@ applyAIBlocks(text){
|
||||
default: return c;
|
||||
}
|
||||
}
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: editorState »).
|
||||
|
||||
// Navigation partielle (fdLoad) : Alpine démarre déjà → 'alpine:init'
|
||||
// ne sera plus émis, on enregistre tout de suite (pattern _ctx_menu.js)
|
||||
// et on remonte la racine montée avant l'enregistrement.
|
||||
if (window.Alpine) { Alpine.data('editorState', editorState); fdRefreshXData('editorState'); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('editorState', editorState); });
|
||||
window.editorState = editorState;
|
||||
|
||||
// ── Ctrl/Cmd + S : sauvegarder le document ────────────────────────
|
||||
// L'autosave (1,5 s) suffit pour la frappe au quotidien, mais on ne peut
|
||||
// pas fermer l'onglet « au bout d'un moment » en etant sur que tout est
|
||||
// parti. Le raccourci est attendu par tout le monde : il appelle le même
|
||||
// `save()` que l'autosave (meme endpoint, meme file hors-ligne) et rend
|
||||
// la main explicitement.
|
||||
// `window.E` survit aux navigations partielles : on exige donc que
|
||||
// l'editeur soit reellement monte (`#_blocksCt`) avant d'agir, sinon
|
||||
// Ctrl+S sauvegarderait un editeur detache depuis une autre page.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return;
|
||||
if (String(e.key).toLowerCase() !== 's') return;
|
||||
var E = window.E;
|
||||
if (!E || !document.getElementById('_blocksCt') || typeof E.save !== 'function') return;
|
||||
e.preventDefault(); // sinon le navigateur ouvre « Enregistrer la page »
|
||||
if (E.canEdit === false) {
|
||||
if (E.showToast) E.showToast('This page is locked (read-only)', 'error');
|
||||
return;
|
||||
}
|
||||
if (E.saving) { if (E.showToast) E.showToast('Saving…'); return; }
|
||||
E.dirty = true;
|
||||
var notify = function () {
|
||||
if (E.showToast) E.showToast('Saved', 'success');
|
||||
else if (window.showToast) window.showToast('Saved', 'success');
|
||||
};
|
||||
try { E.save(notify); } catch (err) {
|
||||
if (E.showToast) E.showToast('Save failed', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
if (!window.__fdSettingsScriptsLoaded) {
|
||||
window.__fdSettingsScriptsLoaded = true;
|
||||
/* exported settingsInit -- appeles depuis les attributs HTML des templates */
|
||||
const ST=(()=>{try{const el=document.getElementById('st-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: settingsInit »).
|
||||
|
||||
if (window.Alpine) { Alpine.data('settingsInit', settingsInit); fdRefreshXData('settingsInit'); } else document.addEventListener('alpine:init', function () { Alpine.data('settingsInit', settingsInit); });
|
||||
function settingsInit() {
|
||||
return {
|
||||
// ── A20 ph3 : expressions hostiles au parseur CSP (window/Date) ──
|
||||
historyBack() { window.history.back(); },
|
||||
fmtLastLogin(u) {
|
||||
return u.last_login ? new Date(u.last_login * 1000).toLocaleDateString() : 'Never';
|
||||
},
|
||||
fmtAuditDate(e) { return new Date(e.at).toLocaleString(); },
|
||||
|
||||
activeSection: 'account',
|
||||
allTags: [],
|
||||
newTagName: '',
|
||||
@@ -63,6 +77,9 @@ function settingsInit() {
|
||||
automations: [],
|
||||
autoRuns: [],
|
||||
editAutomationId: null,
|
||||
editSteps: [],
|
||||
stepEdit: -1,
|
||||
stepNewKind: 'action',
|
||||
globalCollections: [],
|
||||
af: { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' },
|
||||
|
||||
@@ -761,6 +778,128 @@ function settingsInit() {
|
||||
if (r.ok) { this.githubLinked = false; }
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
// ── Éditeur visuel de steps (API v7.0 : /steps CRUD) ──
|
||||
async loadSteps() {
|
||||
if (!this.editAutomationId) { this.editSteps = []; return; }
|
||||
try {
|
||||
var r = await fetch('/workspace/automations/' + this.editAutomationId + '/steps',
|
||||
{credentials:'same-origin'});
|
||||
var d = await r.json();
|
||||
this.editSteps = Array.isArray(d) ? d : (d.steps || []);
|
||||
} catch { this.editSteps = []; }
|
||||
},
|
||||
stepSummary(s) {
|
||||
var c = s.config || {};
|
||||
if (s.kind === 'trigger') return '\ud83d\udce1 D\u00e9clencheur \u00b7 ' + (c.event || '?');
|
||||
if (s.kind === 'condition') return '\u2696 Condition \u00b7 ' + (c.property || '?') + ' ' + (c.op || 'eq') + ' ' + (c.value || '');
|
||||
if (s.kind === 'delay') return '\u23f3 Attente \u00b7 ' + (c.seconds || 0) + ' s';
|
||||
var extra = c.url || c.message || c.text || c.to || (c.owner ? c.owner + '/' + c.repo : '') || c.title || '';
|
||||
return '\u26a1 Action \u00b7 ' + (c.type || '?') + (extra ? ' \u2192 ' + String(extra).slice(0, 60) : '');
|
||||
},
|
||||
defaultStepConfig(kind) {
|
||||
if (kind === 'trigger') return { event: 'page.created' };
|
||||
if (kind === 'condition') return { property: '', op: 'eq', value: '' };
|
||||
if (kind === 'delay') return { seconds: 60 };
|
||||
return { type: 'webhook', url: '' };
|
||||
},
|
||||
async addStep() {
|
||||
if (!this.editAutomationId) return;
|
||||
try {
|
||||
var r = await fetch('/workspace/automations/' + this.editAutomationId + '/steps', {
|
||||
method: 'POST', credentials:'same-origin',
|
||||
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify({ kind: this.stepNewKind, config: this.defaultStepConfig(this.stepNewKind) })
|
||||
});
|
||||
var d = await r.json();
|
||||
if (!r.ok) { window.showToast && window.showToast(d.detail || '\u00c9chec', 'error'); return; }
|
||||
await this.loadSteps();
|
||||
this.stepEdit = this.editSteps.length - 1;
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
async saveStep(s) {
|
||||
var cfg = s.config || {};
|
||||
// forge_issue : labels en cha\u00eene s\u00e9par\u00e9e par virgules → liste
|
||||
if (cfg.type === 'forge_issue' && typeof cfg.labels === 'string') {
|
||||
cfg.labels = cfg.labels.split(',').map(function(x){ return x.trim(); }).filter(Boolean);
|
||||
}
|
||||
try {
|
||||
var r = await fetch('/workspace/automations/steps/' + s.id, {
|
||||
method:'PUT', credentials:'same-origin',
|
||||
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify({ kind: s.kind, config: cfg })
|
||||
});
|
||||
var d = await r.json();
|
||||
if (!r.ok) { window.showToast && window.showToast(d.detail || '\u00c9chec', 'error'); return; }
|
||||
window.showToast && window.showToast('\u00c9tape enregistr\u00e9e');
|
||||
await this.loadSteps();
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
async delStep(i) {
|
||||
var s = this.editSteps[i]; if (!s) return;
|
||||
try {
|
||||
await fetch('/workspace/automations/steps/' + s.id, {
|
||||
method:'DELETE', credentials:'same-origin',
|
||||
headers:{'X-CSRF-Token': this.getCsrfToken()} });
|
||||
this.stepEdit = -1;
|
||||
await this.loadSteps();
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
async moveStep(i, dir) {
|
||||
var j = i + dir;
|
||||
if (j < 0 || j >= this.editSteps.length) return;
|
||||
var a = this.editSteps[i], b = this.editSteps[j];
|
||||
var h = {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()};
|
||||
try {
|
||||
await fetch('/workspace/automations/steps/' + a.id, {method:'PUT', headers:h,
|
||||
credentials:'same-origin', body: JSON.stringify({position: b.position})});
|
||||
await fetch('/workspace/automations/steps/' + b.id, {method:'PUT', headers:h,
|
||||
credentials:'same-origin', body: JSON.stringify({position: a.position})});
|
||||
await this.loadSteps();
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
async convertToSteps() {
|
||||
// JSON legacy → pipeline ordonn\u00e9 (trigger, conditions, actions)
|
||||
if (!this.editAutomationId) return;
|
||||
var h = {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()};
|
||||
var base = '/workspace/automations/' + this.editAutomationId + '/steps';
|
||||
var order = [];
|
||||
if (this.af.trigger_type !== 'cron') {
|
||||
var tcfg = {event: this.af.event || 'page.created'};
|
||||
if (this.af.collection_id) tcfg.collection_id = parseInt(this.af.collection_id, 10);
|
||||
order.push({kind:'trigger', config: tcfg});
|
||||
}
|
||||
try {
|
||||
JSON.parse(this.af.condition_json || '[]').forEach(function(c){ order.push({kind:'condition', config:c}); });
|
||||
} catch { /* volontaire */ }
|
||||
try {
|
||||
JSON.parse(this.af.actions_json || '[]').forEach(function(a){ order.push({kind:'action', config:a}); });
|
||||
} catch { /* volontaire */ }
|
||||
for (var i = 0; i < order.length; i++) {
|
||||
var r = await fetch(base, {method:'POST', headers:h, credentials:'same-origin',
|
||||
body: JSON.stringify(order[i])});
|
||||
if (!r.ok) {
|
||||
var d = await r.json().catch(function(){ return {}; });
|
||||
window.showToast && window.showToast(d.detail || 'Conversion \u00e9chou\u00e9e', 'error');
|
||||
break;
|
||||
}
|
||||
}
|
||||
await this.loadSteps();
|
||||
},
|
||||
navTo(sec) {
|
||||
this.activeSection = sec;
|
||||
var loaders = {'api-tokens':'loadApiTokens', 'sessions':'loadSessions',
|
||||
'extensions':'loadClipperDevices', 'automations':'loadAutomations',
|
||||
'admin-users':'loadAdminUsers', 'admin-audit':'loadAdminAudit',
|
||||
'admin-backups':'loadBackups', 'admin-sso':'loadSsoConfig'};
|
||||
if (loaders[sec] && this[loaders[sec]]) this[loaders[sec]]();
|
||||
},
|
||||
setAuditSource(s) { this.auditSource = s; this.loadAuditLogs(); },
|
||||
auditNext() { this.auditOffset += this.auditPageSize; this.loadAuditLogs(false); },
|
||||
editUserRow(u) {
|
||||
this.editingUser = u;
|
||||
this.editUserForm = { login: u.login, name: u.full_name, email: u.email,
|
||||
is_admin: u.is_admin, is_active: u.is_active };
|
||||
},
|
||||
async loadAutomations() {
|
||||
try { await this.loadCollectionsForForm(); } catch { /* volontaire */ }
|
||||
try {
|
||||
@@ -814,9 +953,13 @@ function settingsInit() {
|
||||
condition_json: a.condition_json || '[]',
|
||||
actions_json: a.actions_json || '[]'
|
||||
};
|
||||
this.stepEdit = -1;
|
||||
this.loadSteps();
|
||||
},
|
||||
cancelEditAutomation() {
|
||||
this.editAutomationId = null;
|
||||
this.editSteps = [];
|
||||
this.stepEdit = -1;
|
||||
this.af = { name:'', trigger_type:'event', event:'page.created', cron_expression:'*/15 * * * *', collection_id:'', condition_json:'[]', actions_json:'[]' };
|
||||
},
|
||||
async toggleAutomation(id, currentlyEnabled) {
|
||||
@@ -1092,3 +1235,4 @@ function settingsInit() {
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
* FlowDeck — « Convertir en base de tâches »
|
||||
*
|
||||
* Modale de linkage : pour les trois rôles requis (Assigné à / Statut /
|
||||
* Échéance) on choisit soit une colonne existante compatible, soit on en
|
||||
* crée une nouvelle avec le bon type. Le serveur fait foi
|
||||
* (`POST /db/{id}/task-db/api`) : il valide les types, refuse le double
|
||||
* emploi d'une colonne et applique la limite de 10 bases connectées.
|
||||
* ══════════════════════════════════════════════════════════════════════ */
|
||||
(function () {
|
||||
'use strict';
|
||||
if (window.__fdTaskDbLinkLoaded) return;
|
||||
window.__fdTaskDbLinkLoaded = true;
|
||||
|
||||
var ROLE_LABELS = { assignee: 'Assigné à', status: 'Statut', due: "Date d'échéance" };
|
||||
var ROLE_HINTS = {
|
||||
assignee: 'Colonne « personne » utilisée pour filtrer vos tâches.',
|
||||
status: 'Colonne statut ou sélection (À faire / En cours / Terminée).',
|
||||
due: 'Colonne de type date, utilisée par les vues et le calendrier.'
|
||||
};
|
||||
var DEFAULT_NAMES = { assignee: 'Assigné à', status: 'Statut', due: "Échéance" };
|
||||
var TYPE_LABELS = {
|
||||
person: 'personne', status: 'statut', select: 'sélection', date: 'date',
|
||||
text: 'texte', number: 'nombre', checkbox: 'case à cocher'
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function csrf() {
|
||||
return (typeof window.getCsrf === 'function') ? window.getCsrf() : '';
|
||||
}
|
||||
|
||||
function api(path, opts) {
|
||||
opts = opts || {};
|
||||
var headers = { 'X-CSRF-Token': csrf() };
|
||||
if (opts.body) headers['Content-Type'] = 'application/json';
|
||||
return fetch(path, {
|
||||
method: opts.method || 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: headers,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined
|
||||
}).then(function (r) {
|
||||
return r.json().catch(function () { return {}; }).then(function (d) {
|
||||
if (!r.ok) throw new Error(d.detail || ('Erreur ' + r.status));
|
||||
return d;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Modale ─────────────────────────────────────────────────────── */
|
||||
|
||||
function roleRow(state, role) {
|
||||
var selected = state.mapping[role];
|
||||
var current = state.candidates[role] || [];
|
||||
// La colonne déjà mappée doit rester sélectionnable même si elle est
|
||||
// de type compatible mais exclue des candidats (déjà affectée).
|
||||
var all = state.all_properties || [];
|
||||
if (selected && !current.some(function (p) { return p.id === selected; })) {
|
||||
var kept = all.filter(function (p) { return p.id === selected; })[0];
|
||||
if (kept) current = [kept].concat(current);
|
||||
}
|
||||
|
||||
var h = '<div class="tdb-role" data-role="' + role + '">';
|
||||
h += '<div class="tdb-role-head"><label>' + esc(ROLE_LABELS[role]) + '</label>';
|
||||
h += '<span class="tdb-role-hint">' + esc(ROLE_HINTS[role]) + '</span></div>';
|
||||
h += '<div class="tdb-role-body">';
|
||||
h += '<select class="tdb-select" data-tdb-role="' + role + '">';
|
||||
h += '<option value="">— créer une nouvelle colonne —</option>';
|
||||
current.forEach(function (p) {
|
||||
h += '<option value="' + p.id + '"' +
|
||||
(String(selected) === String(p.id) ? ' selected' : '') + '>' +
|
||||
esc(p.name) + ' (' + esc(TYPE_LABELS[p.prop_type] || p.prop_type) +
|
||||
')</option>';
|
||||
});
|
||||
h += '</select>';
|
||||
h += '<input class="tdb-new-name" data-tdb-new="' + role + '" type="text" ' +
|
||||
'placeholder="' + esc(DEFAULT_NAMES[role]) + '" ' +
|
||||
(selected ? 'style="display:none"' : '') + '>';
|
||||
h += '</div></div>';
|
||||
return h;
|
||||
}
|
||||
|
||||
function openModal(collectionId) {
|
||||
closeModal();
|
||||
api('/db/' + collectionId + '/task-db/api').then(function (state) {
|
||||
if (!state.exists) return;
|
||||
|
||||
var roles = ['assignee', 'status', 'due'];
|
||||
var h = '<div class="tdb-overlay" id="tdb-overlay"><div class="tdb-modal">';
|
||||
h += '<div class="tdb-head"><span>' +
|
||||
(state.is_task ? 'Base de tâches' : 'Convertir en base de tâches') +
|
||||
'</span><button class="tdb-close" id="tdb-close">✕</button></div>';
|
||||
h += '<div class="tdb-intro">Pour alimenter <a href="/my-tasks">My Tasks</a>, ' +
|
||||
'reliez les trois colonnes ci-dessous. Une colonne absente sera créée ' +
|
||||
'avec le bon type.</div>';
|
||||
h += '<div class="tdb-body">';
|
||||
roles.forEach(function (r) { h += roleRow(state, r); });
|
||||
h += '</div>';
|
||||
if (state.sources_count >= state.max_sources) {
|
||||
h += '<div class="tdb-warn">⚠ Limite de ' + state.max_sources +
|
||||
' bases connectées atteinte : déconnectez-en une avant.</div>';
|
||||
}
|
||||
h += '<div class="tdb-foot">';
|
||||
if (state.is_task) {
|
||||
h += '<button class="tdb-btn-danger" id="tdb-disable">' +
|
||||
'Disconnecter</button>';
|
||||
}
|
||||
h += '<span class="tdb-spacer"></span>';
|
||||
h += '<button class="tdb-btn" id="tdb-cancel">Annuler</button>';
|
||||
h += '<button class="tdb-btn-primary" id="tdb-save"' +
|
||||
(state.sources_count >= state.max_sources && !state.is_task
|
||||
? ' disabled' : '') + '>' +
|
||||
(state.is_task ? 'Mettre à jour' : 'Convertir') + '</button>';
|
||||
h += '</div></div></div>';
|
||||
document.body.insertAdjacentHTML('beforeend', h);
|
||||
wire(state, collectionId);
|
||||
}).catch(function (e) { alert(e.message); });
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
var ov = document.getElementById('tdb-overlay');
|
||||
if (ov) ov.remove();
|
||||
}
|
||||
|
||||
function wire(state, collectionId) {
|
||||
var overlay = document.getElementById('tdb-overlay');
|
||||
overlay.addEventListener('click', function (e) {
|
||||
if (e.target === overlay) closeModal();
|
||||
});
|
||||
document.getElementById('tdb-close').onclick = closeModal;
|
||||
document.getElementById('tdb-cancel').onclick = closeModal;
|
||||
|
||||
// Choisir une colonne masque le champ « nouvelle colonne ».
|
||||
overlay.addEventListener('change', function (e) {
|
||||
var role = e.target.dataset.tdbRole;
|
||||
if (!role) return;
|
||||
var input = overlay.querySelector('[data-tdb-new="' + role + '"]');
|
||||
if (input) input.style.display = e.target.value ? 'none' : '';
|
||||
});
|
||||
|
||||
var disable = document.getElementById('tdb-disable');
|
||||
if (disable) {
|
||||
disable.onclick = function () {
|
||||
if (!confirm('Disconnecter cette base de My Tasks ?\n\n' +
|
||||
'Les tâches restent dans la base.')) return;
|
||||
api('/db/' + collectionId + '/task-db/api', { method: 'DELETE' })
|
||||
.then(function () { closeModal(); toast('Base déconnectée de My Tasks'); })
|
||||
.catch(function (e) { alert(e.message); });
|
||||
};
|
||||
}
|
||||
|
||||
document.getElementById('tdb-save').onclick = function () {
|
||||
var mapping = {}, create_missing = {};
|
||||
['assignee', 'status', 'due'].forEach(function (role) {
|
||||
var sel = overlay.querySelector('[data-tdb-role="' + role + '"]');
|
||||
var val = sel ? sel.value : '';
|
||||
if (val) {
|
||||
mapping[role] = Number(val);
|
||||
} else {
|
||||
var input = overlay.querySelector('[data-tdb-new="' + role + '"]');
|
||||
create_missing[role] = {
|
||||
name: (input && input.value.trim()) || DEFAULT_NAMES[role]
|
||||
};
|
||||
}
|
||||
});
|
||||
api('/db/' + collectionId + '/task-db/api',
|
||||
{ method: 'POST', body: { mapping: mapping, create_missing: create_missing } })
|
||||
.then(function (d) {
|
||||
closeModal();
|
||||
toast(d.configured
|
||||
? '✅ Base connectée à My Tasks'
|
||||
: '⚠ Mapping incomplet — des colonnes manquent');
|
||||
})
|
||||
.catch(function (e) { alert(e.message); });
|
||||
};
|
||||
}
|
||||
|
||||
function toast(msg) {
|
||||
var t = document.createElement('div');
|
||||
t.textContent = msg;
|
||||
t.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);' +
|
||||
'z-index:1200;background:var(--bg-modal,#fff);color:var(--text-primary,#333);' +
|
||||
'border:1px solid var(--border,#e3e3e0);border-radius:8px;padding:9px 16px;' +
|
||||
'font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.16)';
|
||||
document.body.appendChild(t);
|
||||
setTimeout(function () { t.remove(); }, 3200);
|
||||
}
|
||||
|
||||
/* ── Point d'entrée depuis la barre de la base ──────────────────── */
|
||||
|
||||
window.FlowDeckTaskDb = { open: openModal, close: closeModal };
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var trigger = e.target.closest ? e.target.closest('[data-task-db]') : null;
|
||||
if (!trigger) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var id = trigger.dataset.taskDb;
|
||||
if (id) openModal(Number(id));
|
||||
});
|
||||
})();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user