OpenMind v1 : cartes mentales libres, local-first et dockerisees

Premiere version fonctionnelle, demarrable avec `docker compose up -d --build`
sur http://localhost:8080.

Architecture
- Frontend React 18 / TypeScript strict / Vite / Tailwind, canvas @xyflow/react,
  etat Zustand, persistance IndexedDB via Dexie, auto-layout dagre.
- Backend FastAPI / SQLAlchemy 2 / Pydantic v2 / SQLite, qui sert aussi les
  fichiers statiques du frontend compile.
- Image Docker multi-stage, un seul service, bind mount ./data pour SQLite.

Local-first
Les cartes restent dans le navigateur. Le serveur ne recoit une carte que sur
demande explicite de partage.

Partage
Deux secrets distincts : share_token public en lecture seule dans l'URL, et
management_token conserve uniquement cote client, transmis en en-tete pour les
modifications et stocke hashe en base.

Fonctionnalites
Tableau de bord, editeur complet (undo/redo, copier/coller, raccourcis,
branches repliables, recherche), 9 themes, 4 dispositions automatiques,
panneau de proprietes, import/export .mmap, export PNG/SVG/PDF, impression,
page publique en lecture seule, PWA.

Qualite
25 tests pytest et 43 tests vitest au vert, ruff et ESLint sans erreur,
TypeScript strict sans erreur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 22:19:18 +02:00
commit 0b2e5e326d
113 changed files with 14576 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
"""Agregation des routeurs sous le prefixe /api."""
from fastapi import APIRouter
from app.api import health, shares
api_router = APIRouter(prefix="/api")
api_router.include_router(health.router)
api_router.include_router(shares.router)
__all__ = ["api_router"]

29
backend/app/api/deps.py Normal file
View File

@@ -0,0 +1,29 @@
"""Dependances communes aux routes."""
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, Header
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.db.session import get_db
from app.services.errors import ShareForbiddenError
MANAGEMENT_TOKEN_HEADER = "X-Management-Token"
DbSession = Annotated[Session, Depends(get_db)]
AppSettings = Annotated[Settings, Depends(get_settings)]
def require_management_token(
x_management_token: Annotated[str | None, Header(alias=MANAGEMENT_TOKEN_HEADER)] = None,
) -> str:
"""Recupere le secret de gestion depuis l'en-tete (jamais depuis l'URL)."""
if not x_management_token:
raise ShareForbiddenError("Jeton de gestion manquant.")
return x_management_token
ManagementToken = Annotated[str, Depends(require_management_token)]

25
backend/app/api/health.py Normal file
View File

@@ -0,0 +1,25 @@
"""Endpoints de sante et de configuration publique."""
from __future__ import annotations
from fastapi import APIRouter
from app.api.deps import AppSettings
router = APIRouter(tags=["system"])
@router.get("/health", summary="Etat du service")
def health() -> dict[str, str]:
return {"status": "ok"}
@router.get("/config", summary="Configuration publique exposee au frontend")
def public_config(settings: AppSettings) -> dict[str, object]:
"""Aucun secret ici : uniquement ce dont l'interface a besoin."""
return {
"appName": settings.app_name,
"maxNodes": settings.max_nodes,
"maxMapSizeMb": settings.max_map_size_mb,
"sharingEnabled": True,
}

98
backend/app/api/shares.py Normal file
View File

@@ -0,0 +1,98 @@
"""API REST de partage de cartes."""
from __future__ import annotations
from fastapi import APIRouter, Response, status
from app.api.deps import DbSession, ManagementToken
from app.schemas.share import (
ShareCreatedOut,
ShareCreateIn,
SharePublicOut,
ShareUpdatedOut,
)
from app.services import shares as share_service
router = APIRouter(prefix="/shares", tags=["shares"])
def _share_path(share_token: str) -> str:
return f"/s/{share_token}"
@router.post(
"",
response_model=ShareCreatedOut,
status_code=status.HTTP_201_CREATED,
summary="Publier un snapshot de carte et obtenir un lien non repertorie",
)
def create_share(payload: ShareCreateIn, session: DbSession) -> ShareCreatedOut:
share, management_token = share_service.create_share(
session, title=payload.title, document=payload.map
)
return ShareCreatedOut(
share_token=share.share_token,
management_token=management_token,
share_path=_share_path(share.share_token),
title=share.title,
created_at=share.created_at,
updated_at=share.updated_at,
)
@router.get(
"/{share_token}",
response_model=SharePublicOut,
summary="Lire une carte partagee (public, lecture seule)",
)
def read_share(share_token: str, session: DbSession) -> SharePublicOut:
share = share_service.get_share(session, share_token)
return SharePublicOut(
share_token=share.share_token,
title=share.title,
map_version=share.map_version,
map=share_service.load_map_data(share),
created_at=share.created_at,
updated_at=share.updated_at,
)
@router.put(
"/{share_token}",
response_model=ShareUpdatedOut,
summary="Mettre a jour le snapshot partage (necessite le jeton de gestion)",
)
def update_share(
share_token: str,
payload: ShareCreateIn,
session: DbSession,
management_token: ManagementToken,
) -> ShareUpdatedOut:
share = share_service.update_share(
session,
share_token,
management_token=management_token,
title=payload.title,
document=payload.map,
)
return ShareUpdatedOut(
share_token=share.share_token,
share_path=_share_path(share.share_token),
title=share.title,
updated_at=share.updated_at,
)
@router.delete(
"/{share_token}",
status_code=status.HTTP_204_NO_CONTENT,
response_class=Response,
summary="Supprimer le partage (necessite le jeton de gestion)",
)
def delete_share(
share_token: str,
session: DbSession,
management_token: ManagementToken,
) -> Response:
share_service.delete_share(session, share_token, management_token=management_token)
return Response(status_code=status.HTTP_204_NO_CONTENT)