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:
345
frontend/src/features/editor/PropertiesPanel.tsx
Normal file
345
frontend/src/features/editor/PropertiesPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user