rajoute le support de l'utilisation de markdown (sur un sous-ensemble) dans la description des campagnes et des propositions
This commit is contained in:
10
README.md
10
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
|
||||
|
||||
26
package-lock.json
generated
26
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-slate-600 dark:text-slate-300 mb-2">Description</h3>
|
||||
<div className="text-slate-900 dark:text-slate-100 whitespace-pre-wrap leading-relaxed">
|
||||
{campaign?.description}
|
||||
</div>
|
||||
<MarkdownContent
|
||||
content={campaign?.description || ''}
|
||||
className="text-slate-900 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -231,20 +233,13 @@ export default function PublicProposePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="description" className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
Description *
|
||||
</label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
<MarkdownEditor
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, description: value }))}
|
||||
placeholder="Décrivez votre proposition en détail..."
|
||||
rows={6}
|
||||
required
|
||||
label="Description *"
|
||||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-6">
|
||||
<h3 className="text-lg font-medium text-slate-900 dark:text-slate-100 mb-4 flex items-center gap-2">
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 vote-page">
|
||||
{/* Header fixe avec le total et le bouton de validation */}
|
||||
<div className="sticky top-0 z-40 bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
@@ -401,15 +402,25 @@ export default function PublicVotePage() {
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-6">
|
||||
<div>
|
||||
<p className="mt-1 text-base font-medium text-gray-900 whitespace-pre-wrap leading-relaxed">{campaign?.description}</p>
|
||||
<MarkdownContent
|
||||
content={campaign?.description || ''}
|
||||
className="mt-1 text-base font-medium text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message discret sur l'ordre aléatoire */}
|
||||
{isRandomOrder && (
|
||||
<div className="mt-4 text-xs text-gray-500 italic">
|
||||
ℹ️ Les propositions sont affichées dans un ordre aléatoire pour éviter les biais liés à l'ordre de présentation.
|
||||
<div className="mb-6 text-center">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs text-gray-400 bg-gray-50 border border-gray-100">
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Les propositions sont affichées dans un ordre aléatoire pour éviter les biais liés à l'ordre de présentation
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Propositions */}
|
||||
{propositions.length === 0 ? (
|
||||
@@ -444,9 +455,10 @@ export default function PublicVotePage() {
|
||||
{proposition.title}
|
||||
</h3>
|
||||
{!isCompactView && (
|
||||
<p className="text-sm text-gray-600 mb-4 whitespace-pre-wrap">
|
||||
{proposition.description}
|
||||
</p>
|
||||
<MarkdownContent
|
||||
content={proposition.description}
|
||||
className="text-sm text-gray-600 mb-4"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 */}
|
||||
<div className="relative mt-3 mb-16" style={{ marginLeft: '12px', marginRight: '12px' }}>
|
||||
<div className="relative mt-3 mb-16" style={{ marginLeft: '12px', marginRight: '24px' }}>
|
||||
{/* Marqueur 0€ */}
|
||||
<div className="absolute text-center" style={{ left: '0%', transform: 'translateX(-12px)' }}>
|
||||
<div className="w-3 h-3 bg-gray-400 rounded-full mx-auto mb-2"></div>
|
||||
<span className="text-xs text-gray-600 font-medium">0€</span>
|
||||
<span className="text-xs text-gray-600 font-medium whitespace-nowrap">0€</span>
|
||||
</div>
|
||||
|
||||
{/* Marqueurs des paliers */}
|
||||
{spendingTiers.map((tier, index) => {
|
||||
// Calcul correct de la position pour correspondre au slider
|
||||
const position = ((index + 1) / spendingTiers.length) * 100;
|
||||
return (
|
||||
<div
|
||||
@@ -495,7 +508,7 @@ export default function PublicVotePage() {
|
||||
}}
|
||||
>
|
||||
<div className="w-3 h-3 bg-indigo-500 rounded-full mx-auto mb-2"></div>
|
||||
<span className="text-xs text-gray-600 font-medium">{tier}€</span>
|
||||
<span className="text-xs text-gray-600 font-medium whitespace-nowrap">{tier}€</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
<MarkdownEditor
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, description: value }))}
|
||||
placeholder="Décrivez votre proposition en détail..."
|
||||
rows={4}
|
||||
required
|
||||
label="Description *"
|
||||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<h3 className="text-sm font-medium text-slate-900 dark:text-slate-100 mb-3">
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
<MarkdownEditor
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, description: value }))}
|
||||
placeholder="Décrivez l'objectif de cette campagne..."
|
||||
rows={3}
|
||||
required
|
||||
label="Description *"
|
||||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="budget_per_user">Budget (€) *</Label>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
<MarkdownEditor
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, description: value }))}
|
||||
placeholder="Décrivez l'objectif de cette campagne..."
|
||||
rows={3}
|
||||
required
|
||||
label="Description *"
|
||||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Statut de la campagne</Label>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
<MarkdownEditor
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, description: value }))}
|
||||
placeholder="Décrivez votre proposition en détail..."
|
||||
rows={4}
|
||||
required
|
||||
label="Description *"
|
||||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<h3 className="text-sm font-medium text-slate-900 dark:text-slate-100 mb-3">
|
||||
|
||||
37
src/components/MarkdownContent.tsx
Normal file
37
src/components/MarkdownContent.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={`prose prose-sm max-w-none ${className}`}
|
||||
dangerouslySetInnerHTML={{ __html: parsedContent }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
166
src/components/MarkdownEditor.tsx
Normal file
166
src/components/MarkdownEditor.tsx
Normal file
@@ -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 (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="markdown-editor" className="text-sm font-medium">
|
||||
{label}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<HelpCircle className="h-3 w-3 mr-1" />
|
||||
Aide Markdown
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">{value.length}/{maxLength}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'edit' | 'preview')}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="edit" className="flex items-center gap-2">
|
||||
<Edit3 className="h-4 w-4" />
|
||||
Éditer
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="preview" className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
Prévisualiser
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="edit" className="space-y-4">
|
||||
<Textarea
|
||||
id="markdown-editor"
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="min-h-[200px] font-mono text-sm"
|
||||
/>
|
||||
|
||||
{/* Aide markdown (affichée conditionnellement) */}
|
||||
{showHelp && (
|
||||
<div className="rounded-lg border bg-muted/50 p-4 animate-in fade-in duration-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-medium">Syntaxe Markdown supportée</h4>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowHelp(false)}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-xs text-muted-foreground">
|
||||
<div>
|
||||
<p><strong>**gras**</strong> → <strong>gras</strong></p>
|
||||
<p><em>*italique*</em> → <em>italique</em></p>
|
||||
<p><u>__souligné__</u> → <u>souligné</u></p>
|
||||
<p><del>~~barré~~</del> → <del>barré</del></p>
|
||||
</div>
|
||||
<div>
|
||||
<p># Titre 1</p>
|
||||
<p>## Titre 2</p>
|
||||
<p>- Liste à puces</p>
|
||||
<p>[Lien](https://exemple.com)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="preview" className="space-y-4">
|
||||
<div className="min-h-[200px] rounded-lg border bg-background p-4">
|
||||
{value ? (
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: previewContent }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground italic">
|
||||
Aucun contenu à prévisualiser
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Messages d'erreur */}
|
||||
{!validation.isValid && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{validation.errors.map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Avertissement de longueur */}
|
||||
{value.length > maxLength * 0.9 && (
|
||||
<Alert variant={value.length > maxLength ? "destructive" : "default"}>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{value.length > maxLength
|
||||
? `Le contenu dépasse la limite de ${maxLength} caractères`
|
||||
: `Le contenu approche de la limite de ${maxLength} caractères`
|
||||
}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
src/lib/markdown.ts
Normal file
182
src/lib/markdown.ts
Normal file
@@ -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, '"')
|
||||
.replace(/'/g, ''')
|
||||
// Parser le markdown de base
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/__(.*?)__/g, '<u>$1</u>')
|
||||
.replace(/~~(.*?)~~/g, '<del>$1</del>')
|
||||
// Parser les liens avec validation
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
|
||||
if (isValidUrl(url)) {
|
||||
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
||||
}
|
||||
return text; // Retourner juste le texte si l'URL n'est pas valide
|
||||
})
|
||||
// Parser les titres
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
// Parser les listes
|
||||
.replace(/^- (.*$)/gim, '<li>$1</li>')
|
||||
.replace(/^\d+\. (.*$)/gim, '<li>$1</li>')
|
||||
// Parser les paragraphes
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>');
|
||||
|
||||
// Ajouter les balises de paragraphe si nécessaire
|
||||
if (!htmlContent.startsWith('<h') && !htmlContent.startsWith('<li')) {
|
||||
htmlContent = `<p>${htmlContent}</p>`;
|
||||
}
|
||||
|
||||
// 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, ''')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/__(.*?)__/g, '<u>$1</u>')
|
||||
.replace(/~~(.*?)~~/g, '<del>$1</del>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
.replace(/^- (.*$)/gim, '<li>$1</li>')
|
||||
.replace(/^\d+\. (.*$)/gim, '<li>$1</li>')
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
// 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 = ['<script', '<style', '<iframe', '<object', '<embed', '<form'];
|
||||
forbiddenTags.forEach(tag => {
|
||||
if (content.toLowerCase().includes(tag)) {
|
||||
errors.push(`Balise non autorisée détectée: ${tag}`);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
// Fonction pour obtenir un aperçu du contenu (sans HTML)
|
||||
export function getMarkdownPreview(content: string, maxLength: number = 150): string {
|
||||
if (!content) return '';
|
||||
|
||||
// Supprimer le markdown pour l'aperçu
|
||||
const plainText = content
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||
.replace(/\*(.*?)\*/g, '$1')
|
||||
.replace(/__(.*?)__/g, '$1')
|
||||
.replace(/~~(.*?)~~/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/^[#\-\d\.\s]+/gm, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (plainText.length <= maxLength) {
|
||||
return plainText;
|
||||
}
|
||||
|
||||
return plainText.substring(0, maxLength) + '...';
|
||||
}
|
||||
@@ -3,7 +3,7 @@ export type CampaignStatus = 'deposit' | 'voting' | 'closed';
|
||||
export interface Campaign {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
description: string; // Support markdown
|
||||
status: CampaignStatus;
|
||||
budget_per_user: number;
|
||||
spending_tiers: string; // Montants séparés par des virgules
|
||||
@@ -23,7 +23,7 @@ export interface Proposition {
|
||||
id: string;
|
||||
campaign_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
description: string; // Support markdown
|
||||
author_first_name: string;
|
||||
author_last_name: string;
|
||||
author_email: string;
|
||||
|
||||
Reference in New Issue
Block a user