diff --git a/README.md b/README.md
index 19696f5..6a9f136 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@ Une application web moderne pour gérer des campagnes de budgets participatifs,
#### 🛠️ **Administration complète**
- **Gestion des campagnes** : Création, modification, suppression
+- **Support Markdown** : Éditeur avec prévisualisation pour les descriptions de campagnes
- **États de campagne** : Dépôt de propositions, vote, terminé
- **Statistiques en temps réel** : Nombre de propositions, participants, taux de participation
- **Recherche** : Filtrage des campagnes par titre ou description
@@ -42,6 +43,7 @@ Une application web moderne pour gérer des campagnes de budgets participatifs,
#### 📝 **Gestion des propositions**
- **Page dédiée** : Interface complète pour gérer les propositions par campagne
- **CRUD complet** : Création, lecture, modification, suppression
+- **Support Markdown** : Éditeur avec prévisualisation pour les descriptions
- **Informations détaillées** : Auteur, email, date de création
- **Interface moderne** : Cartes avec avatars et badges
@@ -55,10 +57,12 @@ Une application web moderne pour gérer des campagnes de budgets participatifs,
- **Dépôt de propositions** : Interface publique pour soumettre des propositions
- URL unique et partageable
- Formulaire avec validation
+ - Support Markdown pour les descriptions
- Informations d'auteur obligatoires
- **Vote public** : Interface de vote pour les participants
- Slider interactif pour les choix de budget
- Validation du budget total
+ - Affichage des descriptions avec support Markdown
- Sauvegarde des votes
#### 📧 **Système d'email**
@@ -75,6 +79,12 @@ Une application web moderne pour gérer des campagnes de budgets participatifs,
- **Icônes Lucide** : Icônes modernes et cohérentes
### 🔄 Fonctionnalités avancées
+- **Support Markdown** : Éditeur avec prévisualisation pour les descriptions
+ - **Formatage de texte** : Gras, italique, souligné, barré
+ - **Titres** : H1, H2, H3 pour structurer le contenu
+ - **Listes** : Listes à puces et numérotées
+ - **Liens** : URLs externes avec validation de sécurité
+ - **Validation** : ContrĂ´le de la longueur et des contenus dangereux
- **URLs publiques** : Liens partageables pour le dépôt et le vote
- **Copie de liens** : Boutons pour copier les URLs dans le presse-papiers
- **Validation en temps réel** : Vérification des budgets lors du vote
diff --git a/package-lock.json b/package-lock.json
index 0b2bd13..5798fa8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,10 +19,12 @@
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@supabase/supabase-js": "^2.56.0",
+ "@types/dompurify": "^3.0.5",
"@types/nodemailer": "^7.0.1",
"@types/xlsx": "^0.0.35",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "dompurify": "^3.2.6",
"dotenv": "^17.2.1",
"lucide-react": "^0.541.0",
"next": "15.5.0",
@@ -3560,6 +3562,15 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/dompurify": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
+ "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/trusted-types": "*"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3626,6 +3637,12 @@
"@types/react": "^19.0.0"
}
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT"
+ },
"node_modules/@types/uuid": {
"version": "9.0.8",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
@@ -4941,6 +4958,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dompurify": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
+ "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/dotenv": {
"version": "17.2.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz",
diff --git a/package.json b/package.json
index 7956b18..c7842e1 100644
--- a/package.json
+++ b/package.json
@@ -22,10 +22,12 @@
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@supabase/supabase-js": "^2.56.0",
+ "@types/dompurify": "^3.0.5",
"@types/nodemailer": "^7.0.1",
"@types/xlsx": "^0.0.35",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "dompurify": "^3.2.6",
"dotenv": "^17.2.1",
"lucide-react": "^0.541.0",
"next": "15.5.0",
diff --git a/src/app/campaigns/[id]/propose/page.tsx b/src/app/campaigns/[id]/propose/page.tsx
index dfac3bc..d61235c 100644
--- a/src/app/campaigns/[id]/propose/page.tsx
+++ b/src/app/campaigns/[id]/propose/page.tsx
@@ -7,10 +7,11 @@ import { Campaign } from '@/types';
import { campaignService, propositionService } from '@/lib/services';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, FileText, User, Mail, CheckCircle, AlertCircle } from 'lucide-react';
+import { MarkdownContent } from '@/components/MarkdownContent';
+import { MarkdownEditor } from '@/components/MarkdownEditor';
export const dynamic = 'force-dynamic';
@@ -192,9 +193,10 @@ export default function PublicProposePage() {
Description
-
- {campaign?.description}
-
+
@@ -231,20 +233,13 @@ export default function PublicProposePage() {
/>
-
-
- Description *
-
-
-
+ setFormData(prev => ({ ...prev, description: value }))}
+ placeholder="Décrivez votre proposition en détail..."
+ label="Description *"
+ maxLength={2000}
+ />
diff --git a/src/app/campaigns/[id]/vote/[participantId]/page.tsx b/src/app/campaigns/[id]/vote/[participantId]/page.tsx
index 3768f43..8e4ec79 100644
--- a/src/app/campaigns/[id]/vote/[participantId]/page.tsx
+++ b/src/app/campaigns/[id]/vote/[participantId]/page.tsx
@@ -5,6 +5,7 @@ import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { Campaign, Proposition, Participant, Vote, PropositionWithVote } from '@/types';
import { campaignService, participantService, propositionService, voteService, settingsService } from '@/lib/services';
+import { MarkdownContent } from '@/components/MarkdownContent';
// Force dynamic rendering to avoid SSR issues with Supabase
export const dynamic = 'force-dynamic';
@@ -344,7 +345,7 @@ export default function PublicVotePage() {
const spendingTiers = getSpendingTiers();
return (
-
+
{/* Header fixe avec le total et le bouton de validation */}
@@ -401,16 +402,26 @@ export default function PublicVotePage() {
-
{campaign?.description}
- {isRandomOrder && (
-
- ℹ️ Les propositions sont affichées dans un ordre aléatoire pour éviter les biais liés à l'ordre de présentation.
-
- )}
+
+ {/* Message discret sur l'ordre aléatoire */}
+ {isRandomOrder && (
+
+
+
+
+
+ Les propositions sont affichées dans un ordre aléatoire pour éviter les biais liés à l'ordre de présentation
+
+
+ )}
+
{/* Propositions */}
{propositions.length === 0 ? (
@@ -444,9 +455,10 @@ export default function PublicVotePage() {
{proposition.title}
{!isCompactView && (
-
- {proposition.description}
-
+
)}
@@ -465,7 +477,7 @@ export default function PublicVotePage() {
min="0"
max={spendingTiers.length}
step="1"
- value={localVotes[proposition.id] ? spendingTiers.indexOf(localVotes[proposition.id]) + 1 : 0}
+ value={localVotes[proposition.id] ? (localVotes[proposition.id] === 0 ? 0 : spendingTiers.indexOf(localVotes[proposition.id]) + 1) : 0}
onChange={(e) => {
const index = parseInt(e.target.value);
const amount = index === 0 ? 0 : spendingTiers[index - 1];
@@ -475,15 +487,16 @@ export default function PublicVotePage() {
/>
{/* Marqueurs des paliers */}
-
+
{/* Marqueur 0€ */}
{/* Marqueurs des paliers */}
{spendingTiers.map((tier, index) => {
+ // Calcul correct de la position pour correspondre au slider
const position = ((index + 1) / spendingTiers.length) * 100;
return (
);
})}
diff --git a/src/app/globals.css b/src/app/globals.css
index 7c7db3a..7519226 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,8 +1,52 @@
@import "tailwindcss";
-@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
+@theme {
+ --color-background: 0 0% 100%;
+ --color-foreground: 222.2 84% 4.9%;
+ --color-card: 0 0% 100%;
+ --color-card-foreground: 222.2 84% 4.9%;
+ --color-popover: 0 0% 100%;
+ --color-popover-foreground: 222.2 84% 4.9%;
+ --color-primary: 222.2 47.4% 11.2%;
+ --color-primary-foreground: 210 40% 98%;
+ --color-secondary: 210 40% 96%;
+ --color-secondary-foreground: 222.2 84% 4.9%;
+ --color-muted: 210 40% 96%;
+ --color-muted-foreground: 215.4 16.3% 46.9%;
+ --color-accent: 210 40% 96%;
+ --color-accent-foreground: 222.2 84% 4.9%;
+ --color-destructive: 0 84.2% 60.2%;
+ --color-destructive-foreground: 210 40% 98%;
+ --color-border: 214.3 31.8% 91.4%;
+ --color-input: 214.3 31.8% 91.4%;
+ --color-ring: 222.2 84% 4.9%;
+ --radius: 0.5rem;
+}
+
+@theme dark {
+ --color-background: 222.2 84% 4.9%;
+ --color-foreground: 210 40% 98%;
+ --color-card: 222.2 84% 4.9%;
+ --color-card-foreground: 210 40% 98%;
+ --color-popover: 222.2 84% 4.9%;
+ --color-popover-foreground: 210 40% 98%;
+ --color-primary: 210 40% 98%;
+ --color-primary-foreground: 222.2 47.4% 11.2%;
+ --color-secondary: 217.2 32.6% 17.5%;
+ --color-secondary-foreground: 210 40% 98%;
+ --color-muted: 217.2 32.6% 17.5%;
+ --color-muted-foreground: 215 20.2% 65.1%;
+ --color-accent: 217.2 32.6% 17.5%;
+ --color-accent-foreground: 210 40% 98%;
+ --color-destructive: 0 62.8% 30.6%;
+ --color-destructive-foreground: 210 40% 98%;
+ --color-border: 217.2 32.6% 17.5%;
+ --color-input: 217.2 32.6% 17.5%;
+ --color-ring: 212.7 26.8% 83.9%;
+}
+
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -181,7 +225,7 @@
@layer base {
* {
- @apply border-border outline-ring/50;
+ @apply border-border;
}
body {
@apply bg-background text-foreground;
@@ -202,3 +246,202 @@
linear-gradient(90deg, rgba(148, 163, 184, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
}
+
+/* Styles pour le support Markdown */
+.prose {
+ color: hsl(222.2 84% 4.9%);
+}
+
+.prose h1 {
+ font-size: 1.5rem;
+ font-weight: 700;
+ margin-bottom: 1rem;
+ margin-top: 1.5rem;
+ color: hsl(222.2 84% 4.9%);
+}
+
+.prose h2 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin-bottom: 0.75rem;
+ margin-top: 1.25rem;
+ color: hsl(222.2 84% 4.9%);
+}
+
+.prose h3 {
+ font-size: 1.125rem;
+ font-weight: 500;
+ margin-bottom: 0.5rem;
+ margin-top: 1rem;
+ color: hsl(222.2 84% 4.9%);
+}
+
+/* Styles spécifiques pour la page de vote - titres markdown plus petits */
+.vote-page .prose h1 {
+ font-size: 1.125rem;
+ font-weight: 600;
+ margin-bottom: 0.75rem;
+ margin-top: 1rem;
+}
+
+.vote-page .prose h2 {
+ font-size: 1rem;
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+ margin-top: 0.75rem;
+}
+
+.vote-page .prose h3 {
+ font-size: 0.875rem;
+ font-weight: 500;
+ margin-bottom: 0.375rem;
+ margin-top: 0.5rem;
+}
+
+.prose p {
+ margin-bottom: 0.75rem;
+ line-height: 1.6;
+}
+
+.prose strong {
+ font-weight: 600;
+ color: hsl(222.2 84% 4.9%);
+}
+
+.prose em {
+ font-style: italic;
+}
+
+.prose u {
+ text-decoration: underline;
+}
+
+.prose del {
+ text-decoration: line-through;
+ color: hsl(215.4 16.3% 46.9%);
+}
+
+.prose ul {
+ list-style-type: disc;
+ list-style-position: inside;
+ margin-bottom: 0.75rem;
+}
+
+.prose ol {
+ list-style-type: decimal;
+ list-style-position: inside;
+ margin-bottom: 0.75rem;
+}
+
+.prose li {
+ margin-bottom: 0.25rem;
+}
+
+.prose a {
+ color: hsl(222.2 47.4% 11.2%);
+ text-decoration: underline;
+ transition: color 0.2s;
+}
+
+.prose a:hover {
+ color: hsl(222.2 47.4% 11.2% / 0.8);
+}
+
+.prose hr {
+ border-color: hsl(214.3 31.8% 91.4%);
+ margin: 1rem 0;
+}
+
+.prose br {
+ display: block;
+}
+
+/* Styles pour l'éditeur markdown */
+.markdown-editor {
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
+ font-size: 0.875rem;
+}
+
+.markdown-editor:focus {
+ outline: none;
+ box-shadow: 0 0 0 2px hsl(222.2 84% 4.9%), 0 0 0 4px hsl(222.2 84% 4.9% / 0.1);
+}
+
+/* Styles pour la prévisualisation */
+.markdown-preview {
+ max-width: none;
+}
+
+/* Styles pour les onglets */
+.markdown-tabs {
+ border-bottom: 1px solid hsl(214.3 31.8% 91.4%);
+}
+
+.markdown-tab {
+ padding: 0.5rem 1rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ border-bottom: 2px solid transparent;
+ transition: all 0.2s;
+}
+
+.markdown-tab:hover {
+ border-color: hsl(214.3 31.8% 91.4%);
+}
+
+.markdown-tab.active {
+ border-color: hsl(222.2 47.4% 11.2%);
+ color: hsl(222.2 47.4% 11.2%);
+}
+
+/* Styles pour le mode sombre */
+.dark .prose {
+ color: hsl(210 40% 98%);
+}
+
+.dark .prose h1,
+.dark .prose h2,
+.dark .prose h3,
+.dark .prose strong {
+ color: hsl(210 40% 98%);
+}
+
+/* Styles spécifiques pour la page de vote en mode sombre */
+.dark .vote-page .prose h1,
+.dark .vote-page .prose h2,
+.dark .vote-page .prose h3 {
+ color: hsl(210 40% 98%);
+}
+
+.dark .prose del {
+ color: hsl(215 20.2% 65.1%);
+}
+
+.dark .prose a {
+ color: hsl(210 40% 98%);
+}
+
+.dark .prose a:hover {
+ color: hsl(210 40% 98% / 0.8);
+}
+
+.dark .prose hr {
+ border-color: hsl(217.2 32.6% 17.5%);
+}
+
+.dark .markdown-editor:focus {
+ box-shadow: 0 0 0 2px hsl(210 40% 98%), 0 0 0 4px hsl(210 40% 98% / 0.1);
+}
+
+.dark .markdown-tabs {
+ border-bottom: 1px solid hsl(217.2 32.6% 17.5%);
+}
+
+.dark .markdown-tab:hover {
+ border-color: hsl(217.2 32.6% 17.5%);
+}
+
+.dark .markdown-tab.active {
+ border-color: hsl(210 40% 98%);
+ color: hsl(210 40% 98%);
+}
diff --git a/src/components/AddPropositionModal.tsx b/src/components/AddPropositionModal.tsx
index 4fbdf6a..132e8e8 100644
--- a/src/components/AddPropositionModal.tsx
+++ b/src/components/AddPropositionModal.tsx
@@ -2,10 +2,10 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { propositionService } from '@/lib/services';
+import { MarkdownEditor } from '@/components/MarkdownEditor';
interface AddPropositionModalProps {
isOpen: boolean;
@@ -105,18 +105,13 @@ export default function AddPropositionModal({ isOpen, onClose, onSuccess, campai
/>
-
- Description *
-
-
+
setFormData(prev => ({ ...prev, description: value }))}
+ placeholder="Décrivez votre proposition en détail..."
+ label="Description *"
+ maxLength={2000}
+ />
diff --git a/src/components/CreateCampaignModal.tsx b/src/components/CreateCampaignModal.tsx
index 4373872..6e2e884 100644
--- a/src/components/CreateCampaignModal.tsx
+++ b/src/components/CreateCampaignModal.tsx
@@ -2,10 +2,10 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { campaignService } from '@/lib/services';
+import { MarkdownEditor } from '@/components/MarkdownEditor';
interface CreateCampaignModalProps {
isOpen: boolean;
@@ -160,18 +160,13 @@ export default function CreateCampaignModal({ isOpen, onClose, onSuccess }: Crea
/>
-
- Description *
-
-
+ setFormData(prev => ({ ...prev, description: value }))}
+ placeholder="Décrivez l'objectif de cette campagne..."
+ label="Description *"
+ maxLength={2000}
+ />
Budget (€) *
diff --git a/src/components/EditCampaignModal.tsx b/src/components/EditCampaignModal.tsx
index fecdefb..aea5ad3 100644
--- a/src/components/EditCampaignModal.tsx
+++ b/src/components/EditCampaignModal.tsx
@@ -2,12 +2,12 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { campaignService } from '@/lib/services';
import { Campaign, CampaignStatus } from '@/types';
+import { MarkdownEditor } from '@/components/MarkdownEditor';
interface EditCampaignModalProps {
isOpen: boolean;
@@ -109,18 +109,13 @@ export default function EditCampaignModal({ isOpen, onClose, onSuccess, campaign
/>
-
- Description *
-
-
+ setFormData(prev => ({ ...prev, description: value }))}
+ placeholder="Décrivez l'objectif de cette campagne..."
+ label="Description *"
+ maxLength={2000}
+ />
Statut de la campagne
diff --git a/src/components/EditPropositionModal.tsx b/src/components/EditPropositionModal.tsx
index 37fd05f..ebb0b36 100644
--- a/src/components/EditPropositionModal.tsx
+++ b/src/components/EditPropositionModal.tsx
@@ -2,11 +2,11 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { propositionService } from '@/lib/services';
import { Proposition } from '@/types';
+import { MarkdownEditor } from '@/components/MarkdownEditor';
interface EditPropositionModalProps {
isOpen: boolean;
@@ -102,18 +102,13 @@ export default function EditPropositionModal({ isOpen, onClose, onSuccess, propo
/>
-
- Description *
-
-
+ setFormData(prev => ({ ...prev, description: value }))}
+ placeholder="Décrivez votre proposition en détail..."
+ label="Description *"
+ maxLength={2000}
+ />
diff --git a/src/components/MarkdownContent.tsx b/src/components/MarkdownContent.tsx
new file mode 100644
index 0000000..c5e61e0
--- /dev/null
+++ b/src/components/MarkdownContent.tsx
@@ -0,0 +1,37 @@
+'use client';
+
+import React from 'react';
+import { parseMarkdown } from '@/lib/markdown';
+
+interface MarkdownContentProps {
+ content: string;
+ className?: string;
+ maxLength?: number;
+ showPreview?: boolean;
+}
+
+export function MarkdownContent({
+ content,
+ className = "",
+ maxLength,
+ showPreview = false
+}: MarkdownContentProps) {
+ if (!content) {
+ return null;
+ }
+
+ // Si showPreview est activé et qu'une longueur max est définie, tronquer
+ const displayContent = showPreview && maxLength && content.length > maxLength
+ ? content.substring(0, maxLength) + '...'
+ : content;
+
+ // Parser le markdown
+ const parsedContent = parseMarkdown(displayContent);
+
+ return (
+
+ );
+}
diff --git a/src/components/MarkdownEditor.tsx b/src/components/MarkdownEditor.tsx
new file mode 100644
index 0000000..b9dc917
--- /dev/null
+++ b/src/components/MarkdownEditor.tsx
@@ -0,0 +1,166 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { Button } from '@/components/ui/button';
+import { Eye, Edit3, AlertCircle, HelpCircle } from 'lucide-react';
+import { previewMarkdown, validateMarkdown } from '@/lib/markdown';
+
+interface MarkdownEditorProps {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ label?: string;
+ maxLength?: number;
+ className?: string;
+}
+
+export function MarkdownEditor({
+ value,
+ onChange,
+ placeholder = "Écrivez votre description...",
+ label = "Description",
+ maxLength = 5000,
+ className = ""
+}: MarkdownEditorProps) {
+ const [activeTab, setActiveTab] = useState<'edit' | 'preview'>('edit');
+ const [validation, setValidation] = useState<{ isValid: boolean; errors: string[] }>({ isValid: true, errors: [] });
+ const [showHelp, setShowHelp] = useState(false);
+
+ // Validation en temps réel
+ useEffect(() => {
+ const validationResult = validateMarkdown(value);
+ setValidation(validationResult);
+ }, [value]);
+
+ const handleChange = (newValue: string) => {
+ if (newValue.length <= maxLength) {
+ onChange(newValue);
+ }
+ };
+
+ const previewContent = previewMarkdown(value);
+
+ return (
+
+
+
+ {label}
+
+
+ setShowHelp(!showHelp)}
+ className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
+ >
+
+ Aide Markdown
+
+ {value.length}/{maxLength}
+
+
+
+
setActiveTab(value as 'edit' | 'preview')}>
+
+
+
+ Éditer
+
+
+
+ Prévisualiser
+
+
+
+
+
+
+
+
+ {value ? (
+
+ ) : (
+
+ Aucun contenu à prévisualiser
+
+ )}
+
+
+
+
+ {/* Messages d'erreur */}
+ {!validation.isValid && (
+
+
+
+
+ {validation.errors.map((error, index) => (
+ {error}
+ ))}
+
+
+
+ )}
+
+ {/* Avertissement de longueur */}
+ {value.length > maxLength * 0.9 && (
+
maxLength ? "destructive" : "default"}>
+
+
+ {value.length > maxLength
+ ? `Le contenu dépasse la limite de ${maxLength} caractères`
+ : `Le contenu approche de la limite de ${maxLength} caractères`
+ }
+
+
+ )}
+
+ );
+}
diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts
new file mode 100644
index 0000000..8f201c2
--- /dev/null
+++ b/src/lib/markdown.ts
@@ -0,0 +1,182 @@
+import DOMPurify from 'dompurify';
+
+// Fonction pour valider les URLs
+function isValidUrl(url: string): boolean {
+ try {
+ const urlObj = new URL(url);
+ // Autoriser seulement http et https
+ return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
+ } catch {
+ return false;
+ }
+}
+
+// Fonction pour nettoyer et valider le contenu markdown
+export function parseMarkdown(content: string): string {
+ if (!content) return '';
+
+ // Nettoyer le contenu avant parsing
+ const cleanContent = content.trim();
+
+ // Parser le markdown avec des regex simples et sécurisées
+ let htmlContent = cleanContent
+ // Échapper les caractères HTML
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ // Parser le markdown de base
+ .replace(/\*\*(.*?)\*\*/g, '$1 ')
+ .replace(/\*(.*?)\*/g, '$1 ')
+ .replace(/__(.*?)__/g, '$1 ')
+ .replace(/~~(.*?)~~/g, '$1')
+ // Parser les liens avec validation
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
+ if (isValidUrl(url)) {
+ return `${text} `;
+ }
+ return text; // Retourner juste le texte si l'URL n'est pas valide
+ })
+ // Parser les titres
+ .replace(/^### (.*$)/gim, '$1 ')
+ .replace(/^## (.*$)/gim, '$1 ')
+ .replace(/^# (.*$)/gim, '$1 ')
+ // Parser les listes
+ .replace(/^- (.*$)/gim, '$1 ')
+ .replace(/^\d+\. (.*$)/gim, '
$1 ')
+ // Parser les paragraphes
+ .replace(/\n\n/g, '
')
+ .replace(/\n/g, ' ');
+
+ // Ajouter les balises de paragraphe si nécessaire
+ if (!htmlContent.startsWith('${htmlContent}
`;
+ }
+
+ // Configurer DOMPurify pour autoriser seulement les éléments sécurisés
+ const cleanHtml = DOMPurify.sanitize(htmlContent, {
+ ALLOWED_TAGS: [
+ // Texte de base
+ 'p', 'br', 'strong', 'em', 'u', 'del',
+ // Listes
+ 'ul', 'ol', 'li',
+ // Liens (avec validation)
+ 'a',
+ // Titres (limités)
+ 'h1', 'h2', 'h3',
+ // Saut de ligne
+ 'hr'
+ ],
+ ALLOWED_ATTR: ['href', 'title', 'target'],
+ ALLOW_DATA_ATTR: false,
+ FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button'],
+ FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
+ });
+
+ // Valider et nettoyer les liens
+ const tempDiv = document.createElement('div');
+ tempDiv.innerHTML = cleanHtml;
+
+ // Valider tous les liens
+ const links = tempDiv.querySelectorAll('a');
+ links.forEach(link => {
+ const href = link.getAttribute('href');
+ if (href && !isValidUrl(href)) {
+ // Supprimer le lien si l'URL n'est pas valide
+ link.removeAttribute('href');
+ link.style.pointerEvents = 'none';
+ link.style.color = '#999';
+ } else if (href) {
+ // Ajouter target="_blank" et rel="noopener noreferrer" pour la sécurité
+ link.setAttribute('target', '_blank');
+ link.setAttribute('rel', 'noopener noreferrer');
+ }
+ });
+
+ return tempDiv.innerHTML;
+}
+
+// Fonction pour prévisualiser le markdown (version simplifiée)
+export function previewMarkdown(content: string): string {
+ if (!content) return '';
+
+ // Remplacer les caractères spéciaux pour la prévisualisation
+ return content
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ .replace(/\*\*(.*?)\*\*/g, '
$1 ')
+ .replace(/\*(.*?)\*/g, '
$1 ')
+ .replace(/__(.*?)__/g, '
$1 ')
+ .replace(/~~(.*?)~~/g, '
$1')
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '
$1 ')
+ .replace(/^### (.*$)/gim, '
$1 ')
+ .replace(/^## (.*$)/gim, '
$1 ')
+ .replace(/^# (.*$)/gim, '
$1 ')
+ .replace(/^- (.*$)/gim, '
$1 ')
+ .replace(/^\d+\. (.*$)/gim, '
$1 ')
+ .replace(/\n\n/g, '
')
+ .replace(/\n/g, ' ');
+}
+
+// Fonction pour valider le contenu markdown avant sauvegarde
+export function validateMarkdown(content: string): { isValid: boolean; errors: string[] } {
+ const errors: string[] = [];
+
+ if (!content) {
+ return { isValid: true, errors: [] };
+ }
+
+ // Vérifier la longueur
+ if (content.length > 5000) {
+ errors.push('Le contenu est trop long (maximum 5000 caractères)');
+ }
+
+ // Vérifier les liens malveillants
+ const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
+ let match;
+ while ((match = linkRegex.exec(content)) !== null) {
+ const url = match[2];
+ if (!isValidUrl(url)) {
+ errors.push(`URL invalide détectée: ${url}`);
+ }
+ }
+
+ // Vérifier les balises HTML non autorisées
+ const forbiddenTags = ['