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

140
scripts/generate_icons.py Normal file
View File

@@ -0,0 +1,140 @@
"""Genere les icones PNG de la PWA sans dependance externe.
Usage : python scripts/generate_icons.py
Les fichiers sont ecrits dans frontend/public/icons/.
"""
from __future__ import annotations
import struct
import zlib
from pathlib import Path
OUTPUT_DIR = Path(__file__).resolve().parent.parent / "frontend" / "public" / "icons"
INDIGO = (79, 70, 229, 255)
WHITE = (255, 255, 255, 255)
LIGHT = (199, 210, 254, 255)
MID = (165, 180, 252, 255)
SUPERSAMPLE = 3
class Canvas:
def __init__(self, size: int) -> None:
self.size = size
self.buffer = bytearray(size * size * 4)
def set_pixel(self, x: int, y: int, color: tuple[int, int, int, int]) -> None:
if 0 <= x < self.size and 0 <= y < self.size:
offset = (y * self.size + x) * 4
self.buffer[offset : offset + 4] = bytes(color)
def fill_rounded_rect(
self, x0: float, y0: float, x1: float, y1: float, radius: float, color
) -> None:
for y in range(int(y0), int(y1)):
for x in range(int(x0), int(x1)):
cx = min(max(x, x0 + radius), x1 - radius)
cy = min(max(y, y0 + radius), y1 - radius)
if (x - cx) ** 2 + (y - cy) ** 2 <= radius * radius:
self.set_pixel(x, y, color)
def fill_circle(self, cx: float, cy: float, radius: float, color) -> None:
for y in range(int(cy - radius), int(cy + radius) + 1):
for x in range(int(cx - radius), int(cx + radius) + 1):
if (x - cx) ** 2 + (y - cy) ** 2 <= radius * radius:
self.set_pixel(x, y, color)
def draw_segment(self, x0, y0, x1, y1, width, color) -> None:
half = width / 2
dx, dy = x1 - x0, y1 - y0
length_sq = dx * dx + dy * dy or 1
min_x, max_x = int(min(x0, x1) - half - 1), int(max(x0, x1) + half + 1)
min_y, max_y = int(min(y0, y1) - half - 1), int(max(y0, y1) + half + 1)
for y in range(min_y, max_y + 1):
for x in range(min_x, max_x + 1):
t = max(0.0, min(1.0, ((x - x0) * dx + (y - y0) * dy) / length_sq))
px, py = x0 + t * dx, y0 + t * dy
if (x - px) ** 2 + (y - py) ** 2 <= half * half:
self.set_pixel(x, y, color)
def downsample(self, factor: int) -> "Canvas":
target = Canvas(self.size // factor)
area = factor * factor
for y in range(target.size):
for x in range(target.size):
totals = [0, 0, 0, 0]
for sy in range(factor):
row = (y * factor + sy) * self.size
for sx in range(factor):
offset = (row + x * factor + sx) * 4
for channel in range(4):
totals[channel] += self.buffer[offset + channel]
target.set_pixel(x, y, tuple(value // area for value in totals))
return target
def to_png(self, path: Path) -> None:
rows = bytearray()
stride = self.size * 4
for y in range(self.size):
rows.append(0) # filtre "None"
rows.extend(self.buffer[y * stride : (y + 1) * stride])
def chunk(tag: bytes, data: bytes) -> bytes:
return (
struct.pack(">I", len(data))
+ tag
+ data
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
)
header = struct.pack(">IIBBBBB", self.size, self.size, 8, 6, 0, 0, 0)
png = (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", header)
+ chunk(b"IDAT", zlib.compress(bytes(rows), 9))
+ chunk(b"IEND", b"")
)
path.write_bytes(png)
def render(size: int, content_scale: float, corner_ratio: float) -> Canvas:
"""Dessine l'icone : un noeud racine relie a trois branches."""
canvas = Canvas(size)
canvas.fill_rounded_rect(0, 0, size, size, size * corner_ratio, INDIGO)
unit = size / 512 * content_scale
center = size / 2
offset = center - 256 * unit
def point(x: float, y: float) -> tuple[float, float]:
return (offset + x * unit, offset + y * unit)
root = point(168, 256)
targets = [point(336, 160), point(336, 256), point(336, 352)]
for target in targets:
canvas.draw_segment(root[0], root[1], target[0], target[1], 18 * unit, WHITE)
canvas.fill_circle(root[0], root[1], 46 * unit, WHITE)
for index, target in enumerate(targets):
canvas.fill_circle(target[0], target[1], 30 * unit, MID if index == 1 else LIGHT)
return canvas
def build(size: int, filename: str, content_scale: float, corner_ratio: float) -> None:
large = render(size * SUPERSAMPLE, content_scale, corner_ratio)
large.downsample(SUPERSAMPLE).to_png(OUTPUT_DIR / filename)
print(f"ecrit {filename} ({size}x{size})")
def main() -> None:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
build(192, "icon-192.png", 1.0, 0.22)
build(512, "icon-512.png", 1.0, 0.22)
# Icone maskable : contenu reduit pour rester dans la zone sure.
build(512, "icon-maskable-512.png", 0.62, 0.5)
if __name__ == "__main__":
main()