feat: Fusion bloc 1 — barre d'apps commune (ACC-1.1, #17) et références croisées external_ref (ACC-1.3, #18)
This commit is contained in:
+3
-1
@@ -24,6 +24,7 @@ from app.routers import (
|
||||
collections,
|
||||
dashboard,
|
||||
export,
|
||||
external_refs,
|
||||
library,
|
||||
my_tasks,
|
||||
notes,
|
||||
@@ -191,7 +192,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.71.5",
|
||||
version="7.72.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
@@ -228,6 +229,7 @@ app.include_router(projects.backups_router)
|
||||
app.include_router(api.router)
|
||||
app.include_router(webhooks.router)
|
||||
app.include_router(collections.router)
|
||||
app.include_router(external_refs.router) # Fusion ACC-1.3 — références croisées
|
||||
app.include_router(my_tasks.router)
|
||||
app.include_router(workspace.router)
|
||||
# Partage public :-exempt de la dépendance d'authentification du router.
|
||||
|
||||
@@ -2025,3 +2025,37 @@ def _migration_template_runs(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_template_runs_tpl "
|
||||
"ON template_runs(template_id, created_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(50, "fusion: références croisées `external_refs` (ACC-1.3)")
|
||||
def _migration_external_refs(conn: sqlite3.Connection) -> None:
|
||||
"""Migration 50 — table des références croisées entre objets.
|
||||
|
||||
Modèle figé par dracodev-commons (contrats/external_ref.schema.json,
|
||||
v0.1.0) : la référence vit chez le propriétaire de l'objet source
|
||||
(page ou projet/collection Flowdeck) ; une seule source de vérité
|
||||
par objet, des liens — jamais des copies du contenu cible.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS external_refs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
owner_type TEXT NOT NULL,
|
||||
owner_id INTEGER NOT NULL,
|
||||
target_app TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
target_url TEXT NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
domain TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'ok',
|
||||
created_by TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_external_refs_owner "
|
||||
"ON external_refs(owner_type, owner_id)"
|
||||
)
|
||||
|
||||
@@ -162,7 +162,10 @@ def view_page(request: Request, page_id: int):
|
||||
from app.routers.dashboard import _nav_breadcrumb
|
||||
with get_conn() as conn:
|
||||
nav_crumbs = _nav_breadcrumb(conn, page_id)
|
||||
ctx = {**sidebar, "page": page, "page_favorited": fav is not None,
|
||||
# Fusion ACC-1.3 — références croisées de la page (section « Liens »).
|
||||
from app.services import external_refs as _refs_svc
|
||||
page_refs = _refs_svc.list_refs("flowdeck.page", page_id)
|
||||
ctx = {**sidebar, "page": page, "page_refs": page_refs, "page_favorited": fav is not None,
|
||||
"page_share_mode": page.get("share_mode", "private"),
|
||||
"page_published": bool(page.get("published", 0)),
|
||||
"page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)),
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Endpoints des références croisées — Fusion ACC-1.3 (Flowdeck).
|
||||
|
||||
Création par collage d'une URL profonde d'une autre application
|
||||
(ObsiGate, TikTak, Kanbante, Flowdeck) — zéro couplage serveur :
|
||||
l'URL est reconnue par les formes figées du contrat
|
||||
``dracodev-commons/contrats/liens-profonds.md`` (voir
|
||||
``app.services.external_refs``). La route ``GET /collections/{id}``
|
||||
honore la forme canonique du même contrat en redirigeant vers la vue
|
||||
native de la base (``/db/{id}``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
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 import external_refs as refs_svc
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
logger = logging.getLogger("flowdeck.external_refs")
|
||||
|
||||
router = APIRouter(tags=["ExternalRefs"])
|
||||
|
||||
_FORMES_ACCEPTEES = (
|
||||
"URL non reconnue — formes acceptées : TikTak ?task= ou ?list=, "
|
||||
"ObsiGate #file=, Flowdeck /pages/ ou /collections/, "
|
||||
"issue Gitea (carte Kanbante)"
|
||||
)
|
||||
|
||||
|
||||
def _user(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Session requise")
|
||||
return user
|
||||
|
||||
|
||||
def _check(owner_type: str, owner_id: int, user: dict, edit: bool) -> None:
|
||||
pm = PermissionManager(user["id"])
|
||||
if owner_type == "flowdeck.page":
|
||||
ok = pm.can_edit_page(owner_id) if edit else pm.can_view_page(owner_id)
|
||||
elif owner_type == "flowdeck.project":
|
||||
ok = pm.can_edit_collection(owner_id) if edit else pm.can_view_collection(owner_id)
|
||||
else:
|
||||
raise HTTPException(400, "Type de propriétaire non géré ici")
|
||||
if not ok:
|
||||
raise HTTPException(403, "Accès refusé")
|
||||
|
||||
|
||||
def _list(owner_type: str, owner_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_check(owner_type, owner_id, user, edit=False)
|
||||
return {"refs": refs_svc.list_refs(owner_type, owner_id)}
|
||||
|
||||
|
||||
def _add(owner_type: str, owner_id: int, request: Request, payload: dict):
|
||||
user = _user(request)
|
||||
_check(owner_type, owner_id, user, edit=True)
|
||||
parsed = refs_svc.parse_deep_link(payload.get("url") or "")
|
||||
if parsed is None:
|
||||
raise HTTPException(422, _FORMES_ACCEPTEES)
|
||||
target_app, target_type, target_id, default_label = parsed
|
||||
label = (payload.get("label") or "").strip() or default_label
|
||||
ref = refs_svc.add_ref(
|
||||
user["id"], owner_type, owner_id, target_app, target_type, target_id,
|
||||
(payload.get("url") or "").strip(), label,
|
||||
)
|
||||
return JSONResponse(ref, status_code=201)
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/refs")
|
||||
def list_page_refs(page_id: int, request: Request):
|
||||
return _list("flowdeck.page", page_id, request)
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/refs")
|
||||
def add_page_ref(page_id: int, request: Request, payload: dict = Body(...)):
|
||||
return _add("flowdeck.page", page_id, request, payload)
|
||||
|
||||
|
||||
@router.get("/api/collections/{collection_id}/refs")
|
||||
def list_collection_refs(collection_id: int, request: Request):
|
||||
return _list("flowdeck.project", collection_id, request)
|
||||
|
||||
|
||||
@router.post("/api/collections/{collection_id}/refs")
|
||||
def add_collection_ref(collection_id: int, request: Request, payload: dict = Body(...)):
|
||||
return _add("flowdeck.project", collection_id, request, payload)
|
||||
|
||||
|
||||
@router.delete("/api/refs/{ref_id}")
|
||||
def delete_ref(ref_id: int, request: Request):
|
||||
user = _user(request)
|
||||
ref = refs_svc.get_ref(ref_id)
|
||||
if ref is None:
|
||||
raise HTTPException(404, "Lien introuvable")
|
||||
_check(ref["owner_type"], int(ref["owner_id"]), user, edit=True)
|
||||
refs_svc.delete_ref(ref_id)
|
||||
return {"deleted": ref_id}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}")
|
||||
def view_collection_deep_link(collection_id: int, request: Request):
|
||||
"""Forme canonique Fusion (ACC-0.1) : /collections/<id> → /db/<id>."""
|
||||
_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
# Réponse directe, comme view_page : le gestionnaire global de
|
||||
# l'app convertirait une HTTPException 404 en redirection 302.
|
||||
return HTMLResponse("<h2>Collection introuvable</h2>", status_code=404)
|
||||
return RedirectResponse(f"/db/{collection_id}", status_code=303)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Références croisées ``external_ref`` — Fusion ACC-1.3 (Flowdeck).
|
||||
|
||||
Modèle figé par le programme Fusion dans ``dracodev-commons``
|
||||
(``contrats/external_ref.schema.json``, v0.1.0) : la référence vit chez
|
||||
le propriétaire de l'objet source — ici une page ou un projet
|
||||
(collection) Flowdeck — et pointe vers un objet d'une autre application
|
||||
par son URL profonde (``contrats/liens-profonds.md``). Une seule source
|
||||
de vérité par objet : des références, jamais des copies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
APP_COLORS = {
|
||||
"obsigate": "#7a5af5",
|
||||
"flowdeck": "#2f6fed",
|
||||
"tiktak": "#e8762c",
|
||||
"kanbante": "#0d9488",
|
||||
}
|
||||
|
||||
|
||||
def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
|
||||
"""Reconnaît une URL profonde des applications dracodev.
|
||||
|
||||
Formes figées par ``dracodev-commons/contrats/liens-profonds.md``.
|
||||
Retourne ``(target_app, target_type, target_id, label_par_défaut)``
|
||||
ou ``None`` si l'URL ne correspond à aucune forme du contrat.
|
||||
"""
|
||||
try:
|
||||
u = urlparse((url or "").strip())
|
||||
except ValueError:
|
||||
return None
|
||||
host = (u.hostname or "").lower()
|
||||
if host == "tiktak.dracodev.net":
|
||||
q = parse_qs(u.query)
|
||||
if q.get("task"):
|
||||
return ("tiktak", "task", q["task"][0], f"TikTak · tâche {q['task'][0]}")
|
||||
if q.get("list"):
|
||||
return ("tiktak", "list", q["list"][0], f"TikTak · liste {q['list'][0]}")
|
||||
return None
|
||||
if host == "og.dracodev.net":
|
||||
frag = u.fragment or ""
|
||||
if frag.startswith("file="):
|
||||
cible = unquote(frag[5:])
|
||||
if ":" in cible:
|
||||
nom = cible.rsplit("/", 1)[-1]
|
||||
return ("obsigate", "note", cible, f"ObsiGate · {nom}")
|
||||
return None
|
||||
if host == "flowdeck.dracodev.net":
|
||||
m = re.fullmatch(r"/pages/(\d+)", u.path or "")
|
||||
if m:
|
||||
return ("flowdeck", "page", m.group(1), f"Flowdeck · page {m.group(1)}")
|
||||
m = re.fullmatch(r"/collections/(\d+)", u.path or "")
|
||||
if m:
|
||||
return ("flowdeck", "project", m.group(1), f"Flowdeck · projet {m.group(1)}")
|
||||
return None
|
||||
if host == "git.dracodev.net":
|
||||
m = re.fullmatch(r"/([^/]+)/([^/]+)/issues/(\d+)", u.path or "")
|
||||
if m:
|
||||
owner, repo, num = m.groups()
|
||||
return ("kanbante", "card", f"{owner}/{repo}#{num}", f"Kanbante · {repo} #{num}")
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _row_to_ref(row) -> dict:
|
||||
d = dict(row)
|
||||
d["app_color"] = APP_COLORS.get(d["target_app"], "#888")
|
||||
return d
|
||||
|
||||
|
||||
def list_refs(owner_type: str, owner_id: int) -> list[dict]:
|
||||
"""Références posées sur un objet Flowdeck, dans l'ordre de création."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM external_refs WHERE owner_type = ? AND owner_id = ? "
|
||||
"ORDER BY created_at, id",
|
||||
(owner_type, owner_id),
|
||||
).fetchall()
|
||||
return [_row_to_ref(r) for r in rows]
|
||||
|
||||
|
||||
def add_ref(
|
||||
user_id: int,
|
||||
owner_type: str,
|
||||
owner_id: int,
|
||||
target_app: str,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
target_url: str,
|
||||
label: str,
|
||||
) -> dict:
|
||||
"""Ajoute une référence à un objet et la retourne (avec son ``id``)."""
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO external_refs (user_id, owner_type, owner_id, target_app, "
|
||||
"target_type, target_id, target_url, label) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(user_id, owner_type, owner_id, target_app, target_type, target_id,
|
||||
target_url, label),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM external_refs WHERE id = ?", (cur.lastrowid,)
|
||||
).fetchone()
|
||||
return _row_to_ref(row)
|
||||
|
||||
|
||||
def get_ref(ref_id: int) -> dict | None:
|
||||
"""Une référence par son identifiant, ou ``None``."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM external_refs WHERE id = ?", (ref_id,)
|
||||
).fetchone()
|
||||
return _row_to_ref(row) if row else None
|
||||
|
||||
|
||||
def delete_ref(ref_id: int) -> bool:
|
||||
"""Retire une référence ; ``True`` si elle existait."""
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM external_refs WHERE id = ?", (ref_id,))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
@@ -0,0 +1,96 @@
|
||||
{# Barre d'apps commune dracodev — Fusion ACC-1.1 (issue #17).
|
||||
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.0
|
||||
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution
|
||||
passe par une nouvelle version de commons.
|
||||
CSS/JS : /static/css/draco-barre.css, /static/js/vendor/draco-barre.js. #}
|
||||
<header class="draco-bar" data-current="flowdeck">
|
||||
<div class="draco-bar-zone draco-bar-gauche">
|
||||
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
|
||||
<a class="draco-brand" href="/" aria-label="dracodev">
|
||||
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
|
||||
</a>
|
||||
<nav class="draco-apptabs" aria-label="Applications">
|
||||
<a class="draco-apptab" data-app="obsigate" href="https://og.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
|
||||
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="flowdeck" href="https://flowdeck.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
|
||||
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="tiktak" href="https://tiktak.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
|
||||
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
|
||||
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="kanbante" href="https://kanbante.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
|
||||
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
|
||||
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="draco-bar-zone draco-bar-centre">
|
||||
<button type="button" class="draco-search" data-act="palette" aria-label="Rechercher partout (Ctrl+K)">
|
||||
<span class="draco-search-ico">⌕</span>
|
||||
<span class="draco-search-txt">Rechercher partout…</span>
|
||||
<kbd>⌘K</kbd>
|
||||
</button>
|
||||
</div>
|
||||
<div class="draco-bar-zone draco-bar-droite">
|
||||
<div class="draco-domaines" role="group" aria-label="Domaine">
|
||||
<span class="draco-domaines-lbl">Domaine</span>
|
||||
<button type="button" data-act="domaine" data-dom="pro">Pro</button>
|
||||
<button type="button" data-act="domaine" data-dom="famille">Famille</button>
|
||||
<button type="button" data-act="domaine" data-dom="perso">Perso</button>
|
||||
</div>
|
||||
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
|
||||
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||||
<span class="draco-alertes-txt">Alertes</span>
|
||||
<span class="draco-badge" data-badge="alertes" hidden>0</span>
|
||||
</button>
|
||||
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
|
||||
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
|
||||
<span class="draco-badge" data-badge="spark" hidden>0</span>
|
||||
</button>
|
||||
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
|
||||
<span class="draco-avatar" title="{{ user.get('full_name') if user and user.get('full_name') else '' }}">{{ user.full_name[:1].upper() if user and user.get('full_name') else 'F' }}</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
/* Configuration Flowdeck de la barre commune (Fusion ACC-1.1).
|
||||
Phase 1 : navigation entre apps et domaine mémorisé localement ;
|
||||
le rail pilote le hamburger natif, les alertes ouvrent la cloche
|
||||
native de la page ; la recherche fédérée et Spark arrivent avec
|
||||
les phases suivantes du programme Fusion. */
|
||||
window.DRACO_BAR_CONFIG = {
|
||||
current: "flowdeck",
|
||||
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
|
||||
counts: {},
|
||||
onAction: function (act, el) {
|
||||
var dire = function (m) {
|
||||
var d = document.createElement("div");
|
||||
d.textContent = m;
|
||||
d.style.cssText = "position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:3000;background:#1f1f1f;color:#eee;padding:10px 16px;border-radius:10px;font:500 13.5px system-ui;box-shadow:0 6px 24px rgb(0 0 0/.35)";
|
||||
document.body.appendChild(d);
|
||||
setTimeout(function () { d.remove(); }, 3400);
|
||||
};
|
||||
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
|
||||
if (act === "rail") { var h = document.querySelector(".hamburger-btn"); if (h) h.click(); return; }
|
||||
if (act === "alertes") {
|
||||
var b = document.querySelector(".fd-notif-bell button");
|
||||
if (b) { b.click(); } else { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); }
|
||||
return;
|
||||
}
|
||||
if (act === "palette") { dire("Recherche fédérée ⌘K : arrive avec la fédération (programme Fusion)."); return; }
|
||||
if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; }
|
||||
if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; }
|
||||
if (act === "rappel-entete") {
|
||||
var bar = document.querySelector(".draco-bar");
|
||||
if (bar) bar.style.display = "";
|
||||
el.hidden = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -755,6 +755,32 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Liens — références croisées (Fusion ACC-1.3) ═══
|
||||
Liste rendue côté serveur ; ajout/retrait par
|
||||
static/js/draco-refs.js (API /api/pages/<id>/refs). -->
|
||||
<section class="draco-liens" data-refs data-owner-type="flowdeck.page" data-owner-id="{{ page.id }}">
|
||||
<h3 class="draco-liens-title">Liens</h3>
|
||||
<ul class="draco-liens-list">
|
||||
{% for r in page_refs %}
|
||||
<li class="draco-liens-item">
|
||||
<span class="draco-ref-dot" style="background:{{ r.app_color }}"></span>
|
||||
<a href="{{ r.target_url }}" target="_blank" rel="noopener">{{ r.label }}</a>
|
||||
{% if page_data.can_edit %}
|
||||
<button type="button" class="draco-liens-del" data-ref-del="{{ r.id }}" title="Retirer le lien">✕</button>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if page_data.can_edit %}
|
||||
<form class="draco-liens-form" data-refs-form>
|
||||
<input type="url" required placeholder="Coller une URL profonde (TikTak, ObsiGate, Gitea, Flowdeck)…">
|
||||
<input type="text" placeholder="Libellé (optionnel)">
|
||||
<button type="submit">Lier</button>
|
||||
</form>
|
||||
<p class="draco-liens-erreur" data-refs-erreur hidden></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="editor-statusbar">
|
||||
<span x-show="saving">Saving...</span>
|
||||
<span x-show="!saving&&dirty">Unsaved</span>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
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/draco-barre.css?v={{ asset_version }}">
|
||||
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
|
||||
<style>
|
||||
/* ── Mobile responsive (v4.0.2) ── */
|
||||
@@ -146,7 +147,8 @@
|
||||
<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 %}>
|
||||
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% else %} class="has-draco-bar"{% endif %}>
|
||||
{% if not embed_mode %}{% include "_draco_bar.html" %}{% endif %}
|
||||
<div class="app-layout" x-data="appState()" x-cloak>
|
||||
|
||||
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
|
||||
@@ -2867,6 +2869,7 @@
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/app.js?v={{ asset_version }}" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/vendor/draco-barre.js?v={{ asset_version }}" defer data-cfasync="false"></script>
|
||||
|
||||
<style>
|
||||
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
|
||||
|
||||
@@ -9,6 +9,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% include "_page_editor_content.html" %}
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/js/katex.min.js?v=0.16.11" defer></script>
|
||||
<script src="/static/js/draco-refs.js?v={{ asset_version }}" defer></script>
|
||||
{% include "_page_editor_scripts.html" %}
|
||||
{% include "_page_editor_realtime.html" %}
|
||||
{% include "_database_table_scripts.html" %}
|
||||
|
||||
Reference in New Issue
Block a user