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,345 @@
import { useEffect, useState } from 'react';
import { type NodeShape, type TextAlign } from '@/types/map';
import { Button } from '@/components/Button';
import { IconButton } from '@/components/IconButton';
import { CloseIcon, LinkIcon, TrashIcon } from '@/components/Icons';
import { getTheme, paletteForDepth } from '@/features/editor/themes';
import { selectSelectedNode, useEditorStore } from '@/stores/editorStore';
import { useUiStore } from '@/stores/uiStore';
import { computeDepths } from '@/utils/tree';
import { sanitizeUrl } from '@/utils/url';
const SHAPES: Array<{ id: NodeShape; label: string }> = [
{ id: 'rectangle', label: 'Rectangle' },
{ id: 'rounded', label: 'Arrondi' },
{ id: 'capsule', label: 'Capsule' },
{ id: 'circle', label: 'Cercle' },
];
const ALIGNMENTS: Array<{ id: TextAlign; label: string }> = [
{ id: 'left', label: 'Gauche' },
{ id: 'center', label: 'Centre' },
{ id: 'right', label: 'Droite' },
];
const EMOJIS = ['', '💡', '✅', '📌', '⚠️', '🔒', '🌐', '🖥️', '📦', '📄', '🔗', '⭐', '🐳', '🗄️'];
/** Petit selecteur de couleur : palette native + valeur libre. */
function ColorField({
id,
label,
value,
fallback,
onChange,
}: {
id: string;
label: string;
value: string | undefined;
fallback: string;
onChange: (color: string | undefined) => void;
}) {
return (
<div>
<label className="field-label" htmlFor={id}>
{label}
</label>
<div className="flex items-center gap-2">
<input
id={id}
type="color"
value={value ?? normaliseHex(fallback)}
onChange={(event) => onChange(event.target.value)}
className="h-8 w-10 cursor-pointer rounded border border-zinc-200 bg-transparent p-0.5 dark:border-zinc-700"
/>
<span className="flex-1 truncate font-mono text-xs text-zinc-500">
{value ?? `theme (${fallback})`}
</span>
{value ? (
<IconButton
label={`Revenir au theme pour ${label.toLowerCase()}`}
icon={<CloseIcon className="h-3.5 w-3.5" />}
onClick={() => onChange(undefined)}
/>
) : null}
</div>
</div>
);
}
function normaliseHex(color: string): string {
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#ffffff';
}
export function PropertiesPanel() {
const map = useEditorStore((state) => state.map);
const node = useEditorStore(selectSelectedNode);
const selectedCount = useEditorStore((state) => state.selectedNodeIds.length);
const updateNode = useEditorStore((state) => state.updateNode);
const updateNodeText = useEditorStore((state) => state.updateNodeText);
const updateNodeStyle = useEditorStore((state) => state.updateNodeStyle);
const resetNodeStyle = useEditorStore((state) => state.resetNodeStyle);
const detachNode = useEditorStore((state) => state.detachNode);
const deleteNodes = useEditorStore((state) => state.deleteNodes);
const setPropertiesPanelOpen = useUiStore((state) => state.setPropertiesPanelOpen);
const pushToast = useUiStore((state) => state.pushToast);
const [text, setText] = useState('');
const [url, setUrl] = useState('');
const [note, setNote] = useState('');
useEffect(() => {
setText(node?.text ?? '');
setUrl(node?.url ?? '');
setNote(node?.note ?? '');
}, [node?.id, node?.text, node?.url, node?.note]);
const theme = getTheme(map?.settings.theme);
const depth = node && map ? (computeDepths(map.nodes).get(node.id) ?? 0) : 0;
const palette = paletteForDepth(theme, depth);
const style = node?.style ?? {};
const header = (
<header className="flex items-center justify-between border-b border-zinc-100 px-4 py-3 dark:border-zinc-800">
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">Proprietes</h2>
<IconButton
label="Masquer le panneau de proprietes"
icon={<CloseIcon />}
onClick={() => setPropertiesPanelOpen(false)}
/>
</header>
);
if (!node) {
return (
<aside
className="no-print flex h-full w-72 shrink-0 flex-col border-l border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"
aria-label="Panneau de proprietes"
>
{header}
<p className="px-4 py-6 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
{selectedCount > 1
? `${selectedCount} noeuds selectionnes. Selectionnez un seul noeud pour modifier son apparence.`
: 'Selectionnez un noeud pour modifier son texte, ses couleurs, sa forme ou y ajouter une note.'}
</p>
</aside>
);
}
const commitUrl = () => {
const trimmed = url.trim();
if (!trimmed) {
updateNode(node.id, { url: undefined });
return;
}
const safe = sanitizeUrl(trimmed);
if (!safe) {
pushToast('Cette adresse est invalide. Utilisez un lien http(s) ou mailto.', 'error');
setUrl(node.url ?? '');
return;
}
setUrl(safe);
updateNode(node.id, { url: safe });
};
return (
<aside
className="no-print flex h-full w-72 shrink-0 flex-col overflow-y-auto border-l border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"
aria-label="Panneau de proprietes"
>
{header}
<div className="space-y-5 px-4 py-4">
<div>
<label className="field-label" htmlFor="node-text">
Texte
</label>
<textarea
id="node-text"
className="field-input min-h-[64px] resize-y"
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={() => text !== node.text && updateNodeText(node.id, text)}
/>
</div>
<div>
<span className="field-label">Icone</span>
<div className="flex flex-wrap gap-1">
{EMOJIS.map((emoji) => (
<button
key={emoji || 'none'}
type="button"
aria-label={emoji ? `Icone ${emoji}` : 'Aucune icone'}
aria-pressed={(node.icon ?? '') === emoji}
onClick={() => updateNode(node.id, { icon: emoji || undefined })}
className={`h-8 w-8 rounded-md border text-base transition-colors ${
(node.icon ?? '') === emoji
? 'border-indigo-400 bg-indigo-50 dark:bg-indigo-950/50'
: 'border-zinc-200 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800'
}`}
>
{emoji || '-'}
</button>
))}
</div>
</div>
<ColorField
id="node-bg"
label="Fond"
value={style.backgroundColor}
fallback={palette.background}
onChange={(color) => updateNodeStyle(node.id, { backgroundColor: color })}
/>
<ColorField
id="node-text-color"
label="Texte"
value={style.textColor}
fallback={palette.text}
onChange={(color) => updateNodeStyle(node.id, { textColor: color })}
/>
<ColorField
id="node-border"
label="Bordure"
value={style.borderColor}
fallback={palette.border}
onChange={(color) => updateNodeStyle(node.id, { borderColor: color })}
/>
<div>
<label className="field-label" htmlFor="node-shape">
Forme
</label>
<select
id="node-shape"
className="field-input"
value={style.shape ?? theme.nodeShape}
onChange={(event) => updateNodeStyle(node.id, { shape: event.target.value as NodeShape })}
>
{SHAPES.map((shape) => (
<option key={shape.id} value={shape.id}>
{shape.label}
</option>
))}
</select>
</div>
<div>
<label className="field-label" htmlFor="node-size">
Taille du texte : {style.fontSize ?? (depth === 0 ? 16 : 14)} px
</label>
<input
id="node-size"
type="range"
min={10}
max={32}
step={1}
value={style.fontSize ?? (depth === 0 ? 16 : 14)}
onChange={(event) => updateNodeStyle(node.id, { fontSize: Number(event.target.value) })}
className="w-full accent-indigo-600"
/>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={style.bold ? 'primary' : 'secondary'}
aria-pressed={Boolean(style.bold)}
onClick={() => updateNodeStyle(node.id, { bold: !style.bold })}
className="font-bold"
>
G
</Button>
<Button
size="sm"
variant={style.italic ? 'primary' : 'secondary'}
aria-pressed={Boolean(style.italic)}
onClick={() => updateNodeStyle(node.id, { italic: !style.italic })}
className="italic"
>
I
</Button>
<div className="ml-auto flex gap-1">
{ALIGNMENTS.map((alignment) => (
<Button
key={alignment.id}
size="sm"
variant={(style.textAlign ?? 'left') === alignment.id ? 'primary' : 'secondary'}
aria-pressed={(style.textAlign ?? 'left') === alignment.id}
aria-label={`Alignement ${alignment.label}`}
onClick={() => updateNodeStyle(node.id, { textAlign: alignment.id })}
>
{alignment.label.charAt(0)}
</Button>
))}
</div>
</div>
<div>
<label className="field-label" htmlFor="node-url">
Lien
</label>
<div className="flex items-center gap-2">
<input
id="node-url"
type="url"
inputMode="url"
placeholder="https://exemple.fr"
className="field-input"
value={url}
onChange={(event) => setUrl(event.target.value)}
onBlur={commitUrl}
/>
{node.url ? (
<a
href={node.url}
target="_blank"
rel="noopener noreferrer nofollow"
title="Ouvrir dans un nouvel onglet"
aria-label="Ouvrir le lien dans un nouvel onglet"
className="rounded-lg border border-zinc-200 p-2 text-zinc-600 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
<LinkIcon />
</a>
) : null}
</div>
</div>
<div>
<label className="field-label" htmlFor="node-note">
Note
</label>
<textarea
id="node-note"
className="field-input min-h-[80px] resize-y"
placeholder="Visible ici uniquement ; une icone signale la note sur le canvas."
value={note}
onChange={(event) => setNote(event.target.value)}
onBlur={() => note !== (node.note ?? '') && updateNode(node.id, { note: note || undefined })}
/>
</div>
<div className="space-y-2 border-t border-zinc-100 pt-4 dark:border-zinc-800">
<Button size="sm" className="w-full" onClick={() => resetNodeStyle(node.id)}>
Revenir au style du theme
</Button>
{node.parentId ? (
<Button size="sm" className="w-full" onClick={() => detachNode(node.id)}>
Detacher de son parent
</Button>
) : null}
<Button
size="sm"
variant="danger"
className="w-full"
onClick={() => deleteNodes([node.id])}
>
<TrashIcon className="h-4 w-4" />
Supprimer ce noeud
</Button>
</div>
</div>
</aside>
);
}