Ajout paramètre message bas de page personnalisable
This commit is contained in:
@@ -15,6 +15,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|||||||
import Navigation from '@/components/Navigation';
|
import Navigation from '@/components/Navigation';
|
||||||
import AuthGuard from '@/components/AuthGuard';
|
import AuthGuard from '@/components/AuthGuard';
|
||||||
import { FileText, Calendar, Mail, Upload } from 'lucide-react';
|
import { FileText, Calendar, Mail, Upload } from 'lucide-react';
|
||||||
|
import { MarkdownContent } from '@/components/MarkdownContent';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -196,7 +197,7 @@ function CampaignPropositionsPageContent() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<CardTitle className="text-xl mb-2">{proposition.title}</CardTitle>
|
<CardTitle className="text-xl mb-2">{proposition.title}</CardTitle>
|
||||||
<CardDescription className="text-base">
|
<CardDescription className="text-base">
|
||||||
{proposition.description}
|
<MarkdownContent content={proposition.description} />
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import AuthGuard from '@/components/AuthGuard';
|
import AuthGuard from '@/components/AuthGuard';
|
||||||
import { FolderOpen, Users, FileText, Plus, BarChart3, Settings, Check, Copy } from 'lucide-react';
|
import { FolderOpen, Users, FileText, Plus, BarChart3, Settings, Check, Copy } from 'lucide-react';
|
||||||
import StatusSwitch from '@/components/StatusSwitch';
|
import StatusSwitch from '@/components/StatusSwitch';
|
||||||
|
import { MarkdownContent } from '@/components/MarkdownContent';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -255,7 +256,7 @@ function AdminPageContent() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed mb-4">
|
<CardDescription className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed mb-4">
|
||||||
{campaign.description}
|
<MarkdownContent content={campaign.description} />
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|
||||||
{/* Status Switch */}
|
{/* Status Switch */}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import Navigation from '@/components/Navigation';
|
import Navigation from '@/components/Navigation';
|
||||||
import AuthGuard from '@/components/AuthGuard';
|
import AuthGuard from '@/components/AuthGuard';
|
||||||
import SmtpSettingsForm from '@/components/SmtpSettingsForm';
|
import SmtpSettingsForm from '@/components/SmtpSettingsForm';
|
||||||
import { Settings, Monitor, Save, CheckCircle, Mail } from 'lucide-react';
|
import { Settings, Monitor, Save, CheckCircle, Mail, FileText } from 'lucide-react';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -19,6 +19,8 @@ function SettingsPageContent() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [randomizePropositions, setRandomizePropositions] = useState(false);
|
const [randomizePropositions, setRandomizePropositions] = useState(false);
|
||||||
|
const [proposePageMessage, setProposePageMessage] = useState('');
|
||||||
|
const [footerMessage, setFooterMessage] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
@@ -33,6 +35,14 @@ function SettingsPageContent() {
|
|||||||
// Charger la valeur du paramètre d'ordre aléatoire
|
// Charger la valeur du paramètre d'ordre aléatoire
|
||||||
const randomizeValue = await settingsService.getBooleanValue('randomize_propositions', false);
|
const randomizeValue = await settingsService.getBooleanValue('randomize_propositions', false);
|
||||||
setRandomizePropositions(randomizeValue);
|
setRandomizePropositions(randomizeValue);
|
||||||
|
|
||||||
|
// Charger le message de la page de dépôt de propositions
|
||||||
|
const messageValue = await settingsService.getStringValue('propose_page_message', 'Partagez votre vision et proposez des projets qui feront la différence dans votre collectif. Votre voix compte pour façonner l\'avenir de votre communauté.');
|
||||||
|
setProposePageMessage(messageValue);
|
||||||
|
|
||||||
|
// Charger le message du bas de page
|
||||||
|
const footerValue = await settingsService.getStringValue('footer_message', 'Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL) et transparent pour tous');
|
||||||
|
setFooterMessage(footerValue);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors du chargement des paramètres:', error);
|
console.error('Erreur lors du chargement des paramètres:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -48,6 +58,8 @@ function SettingsPageContent() {
|
|||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
await settingsService.setBooleanValue('randomize_propositions', randomizePropositions);
|
await settingsService.setBooleanValue('randomize_propositions', randomizePropositions);
|
||||||
|
await settingsService.setStringValue('propose_page_message', proposePageMessage);
|
||||||
|
await settingsService.setStringValue('footer_message', footerMessage);
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
setTimeout(() => setSaved(false), 2000);
|
setTimeout(() => setSaved(false), 2000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -148,6 +160,62 @@ function SettingsPageContent() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Textes Category */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-green-100 dark:bg-green-900 rounded-lg flex items-center justify-center">
|
||||||
|
<FileText className="w-5 h-5 text-green-600 dark:text-green-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl">Textes</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Personnalisez les textes affichés dans l'application
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Propose Page Message Setting */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="propose-page-message" className="text-base font-medium">
|
||||||
|
Message d'invitation - Page de dépôt de propositions
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-300 mt-1">
|
||||||
|
Ce texte apparaît sous le titre de la campagne pour inviter les utilisateurs à déposer des propositions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="propose-page-message"
|
||||||
|
value={proposePageMessage}
|
||||||
|
onChange={(e) => setProposePageMessage(e.target.value)}
|
||||||
|
className="w-full min-h-[100px] p-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 resize-y"
|
||||||
|
placeholder="Entrez votre message d'invitation..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Message Setting */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="footer-message" className="text-base font-medium">
|
||||||
|
Message du bas de page
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-300 mt-1">
|
||||||
|
Ce texte apparaît en bas des pages publiques. Vous pouvez utiliser <code className="bg-slate-100 dark:bg-slate-700 px-1 rounded text-xs">[texte du lien](GITURL)</code> pour insérer un lien vers le repository Git.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="footer-message"
|
||||||
|
value={footerMessage}
|
||||||
|
onChange={(e) => setFooterMessage(e.target.value)}
|
||||||
|
className="w-full min-h-[80px] p-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 resize-y"
|
||||||
|
placeholder="Entrez votre message de bas de page..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Email Category */}
|
{/* Email Category */}
|
||||||
<SmtpSettingsForm onSave={() => {
|
<SmtpSettingsForm onSave={() => {
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Campaign } from '@/types';
|
import { Campaign } from '@/types';
|
||||||
import { campaignService, propositionService } from '@/lib/services';
|
import { campaignService, propositionService, settingsService } from '@/lib/services';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -13,6 +13,7 @@ import { ArrowLeft, FileText, User, Mail, CheckCircle, AlertCircle } from 'lucid
|
|||||||
import { MarkdownContent } from '@/components/MarkdownContent';
|
import { MarkdownContent } from '@/components/MarkdownContent';
|
||||||
import { MarkdownEditor } from '@/components/MarkdownEditor';
|
import { MarkdownEditor } from '@/components/MarkdownEditor';
|
||||||
import { PROJECT_CONFIG } from '@/lib/project.config';
|
import { PROJECT_CONFIG } from '@/lib/project.config';
|
||||||
|
import Footer from '@/components/Footer';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -25,13 +26,16 @@ export default function PublicProposePage() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [startTime] = useState(Date.now()); // Validation temporelle
|
||||||
|
const [proposePageMessage, setProposePageMessage] = useState('');
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
author_first_name: '',
|
author_first_name: '',
|
||||||
author_last_name: '',
|
author_last_name: '',
|
||||||
author_email: ''
|
author_email: '',
|
||||||
|
website: '' // Honeypot field
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,8 +47,12 @@ export default function PublicProposePage() {
|
|||||||
const loadCampaign = async () => {
|
const loadCampaign = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const campaigns = await campaignService.getAll();
|
const [campaigns, messageValue] = await Promise.all([
|
||||||
const campaignData = campaigns.find(c => c.id === campaignId);
|
campaignService.getAll(),
|
||||||
|
settingsService.getStringValue('propose_page_message', 'Partagez votre vision et proposez des projets qui feront la différence dans votre collectif. Votre voix compte pour façonner l\'avenir de votre communauté.')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const campaignData = campaigns.find((c: Campaign) => c.id === campaignId);
|
||||||
|
|
||||||
if (!campaignData) {
|
if (!campaignData) {
|
||||||
setError('Campagne non trouvée');
|
setError('Campagne non trouvée');
|
||||||
@@ -57,6 +65,7 @@ export default function PublicProposePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setCampaign(campaignData);
|
setCampaign(campaignData);
|
||||||
|
setProposePageMessage(messageValue);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors du chargement de la campagne:', error);
|
console.error('Erreur lors du chargement de la campagne:', error);
|
||||||
setError('Erreur lors du chargement de la campagne');
|
setError('Erreur lors du chargement de la campagne');
|
||||||
@@ -70,6 +79,21 @@ export default function PublicProposePage() {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
|
// Validation temporelle - détecte les soumissions trop rapides
|
||||||
|
const timeSpent = Date.now() - startTime;
|
||||||
|
if (timeSpent < 5000) { // Moins de 5 secondes
|
||||||
|
setError('Veuillez prendre le temps de bien rédiger votre proposition');
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation honeypot - détecte les bots
|
||||||
|
if (formData.website) {
|
||||||
|
setError('Soumission invalide');
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await propositionService.create({
|
await propositionService.create({
|
||||||
campaign_id: campaignId,
|
campaign_id: campaignId,
|
||||||
@@ -86,7 +110,8 @@ export default function PublicProposePage() {
|
|||||||
description: '',
|
description: '',
|
||||||
author_first_name: '',
|
author_first_name: '',
|
||||||
author_last_name: '',
|
author_last_name: '',
|
||||||
author_email: ''
|
author_email: '',
|
||||||
|
website: ''
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err?.message || err?.details || 'Erreur lors de la soumission de la proposition';
|
const errorMessage = err?.message || err?.details || 'Erreur lors de la soumission de la proposition';
|
||||||
@@ -172,44 +197,28 @@ export default function PublicProposePage() {
|
|||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="text-center">
|
||||||
<div>
|
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100 mb-4">
|
||||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">Déposer une proposition</h1>
|
{campaign?.title}
|
||||||
<p className="text-slate-600 dark:text-slate-300 mt-2">
|
</h1>
|
||||||
Campagne : <span className="font-medium">{campaign?.title}</span>
|
<p className="text-lg text-slate-600 dark:text-slate-300 max-w-2xl mx-auto">
|
||||||
</p>
|
{proposePageMessage}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Campaign Info */}
|
{/* Campaign Description */}
|
||||||
<Card className="mb-8">
|
<div className="mb-8 max-w-3xl mx-auto">
|
||||||
<CardHeader>
|
<MarkdownContent
|
||||||
<CardTitle className="flex items-center gap-2">
|
content={campaign?.description || ''}
|
||||||
<FileText className="w-5 h-5" />
|
className="text-slate-700 dark:text-slate-300 text-base leading-relaxed"
|
||||||
Informations sur la campagne
|
/>
|
||||||
</CardTitle>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-slate-600 dark:text-slate-300 mb-2">Description</h3>
|
|
||||||
<MarkdownContent
|
|
||||||
content={campaign?.description || ''}
|
|
||||||
className="text-slate-900 dark:text-slate-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<Card>
|
<Card className="max-w-3xl mx-auto">
|
||||||
<CardHeader>
|
<CardHeader className="text-center">
|
||||||
<CardTitle>Votre proposition</CardTitle>
|
<CardTitle className="text-2xl">Votre proposition</CardTitle>
|
||||||
<CardDescription>
|
|
||||||
Remplissez le formulaire ci-dessous pour soumettre votre proposition.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -220,6 +229,25 @@ export default function PublicProposePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Honeypot field - caché pour détecter les bots */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="website"
|
||||||
|
value={formData.website}
|
||||||
|
onChange={handleChange}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '-9999px',
|
||||||
|
width: '1px',
|
||||||
|
height: '1px',
|
||||||
|
opacity: 0,
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="title" className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
<label htmlFor="title" className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
Titre de la proposition *
|
Titre de la proposition *
|
||||||
@@ -305,20 +333,7 @@ export default function PublicProposePage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Footer discret */}
|
{/* Footer discret */}
|
||||||
<div className="text-center mt-16 pb-8">
|
<Footer />
|
||||||
<p className="text-slate-400 dark:text-slate-500 text-sm">
|
|
||||||
Développé avec ❤️ pour faciliter la démocratie participative -{' '}
|
|
||||||
<a
|
|
||||||
href={PROJECT_CONFIG.repository.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 underline"
|
|
||||||
>
|
|
||||||
Logiciel libre
|
|
||||||
</a>{' '}
|
|
||||||
et transparent pour tous
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Campaign, Proposition, Participant, Vote, PropositionWithVote } from '@
|
|||||||
import { campaignService, participantService, propositionService, voteService, settingsService } from '@/lib/services';
|
import { campaignService, participantService, propositionService, voteService, settingsService } from '@/lib/services';
|
||||||
import { MarkdownContent } from '@/components/MarkdownContent';
|
import { MarkdownContent } from '@/components/MarkdownContent';
|
||||||
import { PROJECT_CONFIG } from '@/lib/project.config';
|
import { PROJECT_CONFIG } from '@/lib/project.config';
|
||||||
|
import Footer from '@/components/Footer';
|
||||||
|
|
||||||
// Force dynamic rendering to avoid SSR issues with Supabase
|
// Force dynamic rendering to avoid SSR issues with Supabase
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -539,20 +540,7 @@ export default function PublicVotePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer discret */}
|
{/* Footer discret */}
|
||||||
<div className="text-center mt-16 pb-20">
|
<Footer />
|
||||||
<p className="text-gray-400 text-sm">
|
|
||||||
Développé avec ❤️ pour faciliter la démocratie participative -{' '}
|
|
||||||
<a
|
|
||||||
href={PROJECT_CONFIG.repository.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-gray-500 hover:text-gray-700 underline"
|
|
||||||
>
|
|
||||||
Logiciel libre
|
|
||||||
</a>{' '}
|
|
||||||
et transparent pour tous
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Barre fixe en bas */}
|
{/* Barre fixe en bas */}
|
||||||
|
|||||||
@@ -255,24 +255,24 @@
|
|||||||
.prose h1 {
|
.prose h1 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0;
|
||||||
margin-top: 1.5rem;
|
margin-top: 0.75rem;
|
||||||
color: hsl(222.2 84% 4.9%);
|
color: hsl(222.2 84% 4.9%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose h2 {
|
.prose h2 {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0;
|
||||||
margin-top: 1.25rem;
|
margin-top: 0.5rem;
|
||||||
color: hsl(222.2 84% 4.9%);
|
color: hsl(222.2 84% 4.9%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose h3 {
|
.prose h3 {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0;
|
||||||
margin-top: 1rem;
|
margin-top: 0.375rem;
|
||||||
color: hsl(222.2 84% 4.9%);
|
color: hsl(222.2 84% 4.9%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,22 +280,22 @@
|
|||||||
.vote-page .prose h1 {
|
.vote-page .prose h1 {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0;
|
||||||
margin-top: 1rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vote-page .prose h2 {
|
.vote-page .prose h2 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vote-page .prose h3 {
|
.vote-page .prose h3 {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 0.375rem;
|
margin-bottom: 0;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose p {
|
.prose p {
|
||||||
@@ -334,7 +334,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose li {
|
.prose li {
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose a {
|
.prose a {
|
||||||
@@ -445,3 +445,9 @@
|
|||||||
border-color: hsl(210 40% 98%);
|
border-color: hsl(210 40% 98%);
|
||||||
color: hsl(210 40% 98%);
|
color: hsl(210 40% 98%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Styles simples pour réduire l'espacement des listes */
|
||||||
|
.prose ul li,
|
||||||
|
.prose ol li {
|
||||||
|
margin-bottom: 0.25rem !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { PROJECT_CONFIG } from '@/lib/project.config';
|
import { PROJECT_CONFIG } from '@/lib/project.config';
|
||||||
|
import Footer from '@/components/Footer';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
@@ -154,11 +155,7 @@ export default function HomePage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="text-center mt-16 pb-8">
|
<Footer variant="home" />
|
||||||
<p className="text-slate-600 dark:text-slate-400 text-lg">
|
|
||||||
Développé avec ❤️ pour faciliter la démocratie participative
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function AddPropositionModal({ isOpen, onClose, onSuccess, campai
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Ajouter une proposition</DialogTitle>
|
<DialogTitle>Ajouter une proposition</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
|||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
import { campaignService } from '@/lib/services';
|
import { campaignService } from '@/lib/services';
|
||||||
import { Campaign } from '@/types';
|
import { Campaign } from '@/types';
|
||||||
|
import { MarkdownContent } from '@/components/MarkdownContent';
|
||||||
|
|
||||||
interface DeleteCampaignModalProps {
|
interface DeleteCampaignModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -64,7 +65,7 @@ export default function DeleteCampaignModal({ isOpen, onClose, onSuccess, campai
|
|||||||
<strong>Titre :</strong> {campaign.title}
|
<strong>Titre :</strong> {campaign.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||||
<strong>Description :</strong> {campaign.description}
|
<strong>Description :</strong> <MarkdownContent content={campaign.description} />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export default function EditPropositionModal({ isOpen, onClose, onSuccess, propo
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Modifier la proposition</DialogTitle>
|
<DialogTitle>Modifier la proposition</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
100
src/components/Footer.tsx
Normal file
100
src/components/Footer.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { PROJECT_CONFIG } from '@/lib/project.config';
|
||||||
|
import { settingsService } from '@/lib/services';
|
||||||
|
import { parseFooterMessage } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface FooterProps {
|
||||||
|
className?: string;
|
||||||
|
variant?: 'home' | 'public';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Footer({ className = '', variant = 'public' }: FooterProps) {
|
||||||
|
const [footerMessage, setFooterMessage] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadFooterMessage = async () => {
|
||||||
|
try {
|
||||||
|
const message = await settingsService.getStringValue(
|
||||||
|
'footer_message',
|
||||||
|
'Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL) et transparent pour tous'
|
||||||
|
);
|
||||||
|
setFooterMessage(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement du message du bas de page:', error);
|
||||||
|
// Utiliser le message par défaut en cas d'erreur
|
||||||
|
setFooterMessage('Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL) et transparent pour tous');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadFooterMessage();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return null; // Ne pas afficher le bas de page pendant le chargement
|
||||||
|
}
|
||||||
|
|
||||||
|
const { text: processedText, links } = parseFooterMessage(footerMessage, PROJECT_CONFIG.repository.url);
|
||||||
|
|
||||||
|
// Pour la page d'accueil, utiliser un style plus simple
|
||||||
|
if (variant === 'home') {
|
||||||
|
return (
|
||||||
|
<div className={`text-center mt-16 pb-8 ${className}`}>
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 text-lg">
|
||||||
|
{processedText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pour les pages publiques, utiliser un style plus discret avec liens
|
||||||
|
const renderFooterText = () => {
|
||||||
|
if (links.length === 0) {
|
||||||
|
return processedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer un tableau d'éléments avec les liens
|
||||||
|
const elements: React.ReactNode[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
links.forEach((link, index) => {
|
||||||
|
// Ajouter le texte avant le lien
|
||||||
|
if (link.start > lastIndex) {
|
||||||
|
elements.push(processedText.slice(lastIndex, link.start));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter le lien
|
||||||
|
elements.push(
|
||||||
|
<a
|
||||||
|
key={`link-${index}`}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-500 hover:text-gray-700 underline"
|
||||||
|
>
|
||||||
|
{link.text}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
|
lastIndex = link.end;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ajouter le texte restant
|
||||||
|
if (lastIndex < processedText.length) {
|
||||||
|
elements.push(processedText.slice(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`text-center mt-16 pb-20 ${className}`}>
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
{renderFooterText()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,9 +3,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Eye, Edit3, AlertCircle, HelpCircle } from 'lucide-react';
|
import { Eye, Edit3, AlertCircle, HelpCircle } from 'lucide-react';
|
||||||
import { previewMarkdown, validateMarkdown } from '@/lib/markdown';
|
import { previewMarkdown, validateMarkdown } from '@/lib/markdown';
|
||||||
|
|
||||||
@@ -51,89 +49,116 @@ export function MarkdownEditor({
|
|||||||
{label}
|
{label}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowHelp(!showHelp)}
|
onClick={() => setShowHelp(!showHelp)}
|
||||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground rounded border-0 bg-transparent cursor-pointer flex items-center gap-1"
|
||||||
|
tabIndex={-1}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowHelp(!showHelp);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<HelpCircle className="h-3 w-3 mr-1" />
|
<HelpCircle className="h-3 w-3" />
|
||||||
Aide Markdown
|
Aide Markdown
|
||||||
</Button>
|
</button>
|
||||||
<span className="text-sm text-muted-foreground">{value.length}/{maxLength}</span>
|
<span className="text-sm text-muted-foreground">{value.length}/{maxLength}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'edit' | 'preview')}>
|
<div className="flex items-center justify-center mb-4">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<div className="flex rounded-lg border bg-muted p-1">
|
||||||
<TabsTrigger value="edit" className="flex items-center gap-2">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('edit')}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center gap-2 ${
|
||||||
|
activeTab === 'edit'
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
<Edit3 className="h-4 w-4" />
|
<Edit3 className="h-4 w-4" />
|
||||||
Éditer
|
Éditer
|
||||||
</TabsTrigger>
|
</button>
|
||||||
<TabsTrigger value="preview" className="flex items-center gap-2">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('preview')}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center gap-2 ${
|
||||||
|
activeTab === 'preview'
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
Prévisualiser
|
Prévisualiser
|
||||||
</TabsTrigger>
|
</button>
|
||||||
</TabsList>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<TabsContent value="edit" className="space-y-4">
|
{activeTab === 'edit' && (
|
||||||
<Textarea
|
<div className="space-y-4">
|
||||||
id="markdown-editor"
|
<Textarea
|
||||||
value={value}
|
id="markdown-editor"
|
||||||
onChange={(e) => handleChange(e.target.value)}
|
value={value}
|
||||||
placeholder={placeholder}
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
className="min-h-[200px] font-mono text-sm"
|
placeholder={placeholder}
|
||||||
/>
|
className="min-h-[200px] max-h-[300px] font-mono text-sm overflow-y-auto"
|
||||||
|
tabIndex={0}
|
||||||
{/* Aide markdown (affichée conditionnellement) */}
|
/>
|
||||||
{showHelp && (
|
|
||||||
<div className="rounded-lg border bg-muted/50 p-4 animate-in fade-in duration-200">
|
{/* Aide markdown (affichée conditionnellement) */}
|
||||||
<div className="flex items-center justify-between mb-2">
|
{showHelp && (
|
||||||
<h4 className="text-sm font-medium">Syntaxe Markdown supportée</h4>
|
<div className="rounded-lg border bg-muted/50 p-4 animate-in fade-in duration-200">
|
||||||
<Button
|
<div className="flex items-center justify-between mb-2">
|
||||||
type="button"
|
<h4 className="text-sm font-medium">Syntaxe Markdown supportée</h4>
|
||||||
variant="ghost"
|
<button
|
||||||
size="sm"
|
type="button"
|
||||||
onClick={() => setShowHelp(false)}
|
onClick={() => setShowHelp(false)}
|
||||||
className="h-6 px-2 text-xs"
|
className="h-6 px-2 text-xs rounded border-0 bg-transparent cursor-pointer hover:bg-muted"
|
||||||
>
|
tabIndex={-1}
|
||||||
×
|
>
|
||||||
</Button>
|
×
|
||||||
</div>
|
</button>
|
||||||
<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>
|
||||||
<div>
|
<div className="grid grid-cols-2 gap-4 text-xs text-muted-foreground">
|
||||||
<p># Titre 1</p>
|
<div>
|
||||||
<p>## Titre 2</p>
|
<p><strong>**gras**</strong> → <strong>gras</strong></p>
|
||||||
<p>- Liste à puces</p>
|
<p><em>*italique*</em> → <em>italique</em></p>
|
||||||
<p>[Lien](https://exemple.com)</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>
|
||||||
</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>
|
</div>
|
||||||
</TabsContent>
|
)}
|
||||||
</Tabs>
|
|
||||||
|
{activeTab === 'preview' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="min-h-[200px] max-h-[300px] rounded-lg border bg-background p-4 overflow-y-auto">
|
||||||
|
{value ? (
|
||||||
|
<div
|
||||||
|
className="prose prose-sm max-w-none [&_ul]:space-y-1 [&_ol]:space-y-1 [&_li]:my-0"
|
||||||
|
dangerouslySetInnerHTML={{ __html: previewContent }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground italic">
|
||||||
|
Aucun contenu à prévisualiser
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Messages d'erreur */}
|
{/* Messages d'erreur */}
|
||||||
{!validation.isValid && (
|
{!validation.isValid && (
|
||||||
|
|||||||
@@ -18,39 +18,95 @@ export function parseMarkdown(content: string): string {
|
|||||||
// Nettoyer le contenu avant parsing
|
// Nettoyer le contenu avant parsing
|
||||||
const cleanContent = content.trim();
|
const cleanContent = content.trim();
|
||||||
|
|
||||||
// Parser le markdown avec des regex simples et sécurisées
|
// Diviser le contenu en lignes pour traiter les listes correctement
|
||||||
let htmlContent = cleanContent
|
const lines = cleanContent.split('\n');
|
||||||
// Échapper les caractères HTML
|
const processedLines: string[] = [];
|
||||||
.replace(/&/g, '&')
|
let inUnorderedList = false;
|
||||||
.replace(/</g, '<')
|
let inOrderedList = false;
|
||||||
.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>');
|
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
// Échapper les caractères HTML
|
||||||
|
let processedLine = line
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
|
// Parser le markdown de base
|
||||||
|
processedLine = processedLine
|
||||||
|
.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
|
||||||
|
});
|
||||||
|
|
||||||
|
// Traiter les titres
|
||||||
|
if (processedLine.match(/^### /)) {
|
||||||
|
processedLine = processedLine.replace(/^### (.*$)/, '<h3>$1</h3>');
|
||||||
|
} else if (processedLine.match(/^## /)) {
|
||||||
|
processedLine = processedLine.replace(/^## (.*$)/, '<h2>$1</h2>');
|
||||||
|
} else if (processedLine.match(/^# /)) {
|
||||||
|
processedLine = processedLine.replace(/^# (.*$)/, '<h1>$1</h1>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traiter les listes à puce
|
||||||
|
if (processedLine.match(/^- /)) {
|
||||||
|
if (!inUnorderedList) {
|
||||||
|
processedLine = '<ul>' + processedLine.replace(/^- (.*$)/, '<li>$1</li>');
|
||||||
|
inUnorderedList = true;
|
||||||
|
} else {
|
||||||
|
processedLine = processedLine.replace(/^- (.*$)/, '<li>$1</li>');
|
||||||
|
}
|
||||||
|
} else if (processedLine.match(/^\d+\. /)) {
|
||||||
|
if (!inOrderedList) {
|
||||||
|
processedLine = '<ol>' + processedLine.replace(/^\d+\. (.*$)/, '<li>$1</li>');
|
||||||
|
inOrderedList = true;
|
||||||
|
} else {
|
||||||
|
processedLine = processedLine.replace(/^\d+\. (.*$)/, '<li>$1</li>');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ligne normale - fermer les listes si nécessaire
|
||||||
|
if (inUnorderedList) {
|
||||||
|
processedLine = '</ul>' + processedLine;
|
||||||
|
inUnorderedList = false;
|
||||||
|
}
|
||||||
|
if (inOrderedList) {
|
||||||
|
processedLine = '</ol>' + processedLine;
|
||||||
|
inOrderedList = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traiter les paragraphes
|
||||||
|
if (processedLine.trim() === '') {
|
||||||
|
processedLine = '</p><p>';
|
||||||
|
} else {
|
||||||
|
processedLine = processedLine + '<br>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processedLines.push(processedLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fermer les listes ouvertes à la fin
|
||||||
|
if (inUnorderedList) {
|
||||||
|
processedLines.push('</ul>');
|
||||||
|
}
|
||||||
|
if (inOrderedList) {
|
||||||
|
processedLines.push('</ol>');
|
||||||
|
}
|
||||||
|
|
||||||
|
let htmlContent = processedLines.join('\n');
|
||||||
|
|
||||||
// Ajouter les balises de paragraphe si nécessaire
|
// Ajouter les balises de paragraphe si nécessaire
|
||||||
if (!htmlContent.startsWith('<h') && !htmlContent.startsWith('<li')) {
|
if (!htmlContent.startsWith('<h') && !htmlContent.startsWith('<ul') && !htmlContent.startsWith('<ol')) {
|
||||||
htmlContent = `<p>${htmlContent}</p>`;
|
htmlContent = `<p>${htmlContent}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,25 +157,93 @@ export function parseMarkdown(content: string): string {
|
|||||||
export function previewMarkdown(content: string): string {
|
export function previewMarkdown(content: string): string {
|
||||||
if (!content) return '';
|
if (!content) return '';
|
||||||
|
|
||||||
// Remplacer les caractères spéciaux pour la prévisualisation
|
// Diviser le contenu en lignes pour traiter les listes correctement
|
||||||
return content
|
const lines = content.split('\n');
|
||||||
.replace(/&/g, '&')
|
const processedLines: string[] = [];
|
||||||
.replace(/</g, '<')
|
let inUnorderedList = false;
|
||||||
.replace(/>/g, '>')
|
let inOrderedList = false;
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
for (let i = 0; i < lines.length; i++) {
|
||||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
const line = lines[i];
|
||||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
|
||||||
.replace(/__(.*?)__/g, '<u>$1</u>')
|
// Échapper les caractères HTML
|
||||||
.replace(/~~(.*?)~~/g, '<del>$1</del>')
|
let processedLine = line
|
||||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
|
.replace(/&/g, '&')
|
||||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
.replace(/</g, '<')
|
||||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
.replace(/>/g, '>')
|
||||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
.replace(/"/g, '"')
|
||||||
.replace(/^- (.*$)/gim, '<li>$1</li>')
|
.replace(/'/g, ''');
|
||||||
.replace(/^\d+\. (.*$)/gim, '<li>$1</li>')
|
|
||||||
.replace(/\n\n/g, '</p><p>')
|
// Traiter le markdown de base
|
||||||
.replace(/\n/g, '<br>');
|
processedLine = processedLine
|
||||||
|
.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>');
|
||||||
|
|
||||||
|
// Traiter les titres
|
||||||
|
if (processedLine.match(/^### /)) {
|
||||||
|
processedLine = processedLine.replace(/^### (.*$)/, '<h3>$1</h3>');
|
||||||
|
} else if (processedLine.match(/^## /)) {
|
||||||
|
processedLine = processedLine.replace(/^## (.*$)/, '<h2>$1</h2>');
|
||||||
|
} else if (processedLine.match(/^# /)) {
|
||||||
|
processedLine = processedLine.replace(/^# (.*$)/, '<h1>$1</h1>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traiter les listes à puce
|
||||||
|
if (processedLine.match(/^- /)) {
|
||||||
|
if (!inUnorderedList) {
|
||||||
|
processedLine = '<ul>' + processedLine.replace(/^- (.*$)/, '<li>$1</li>');
|
||||||
|
inUnorderedList = true;
|
||||||
|
} else {
|
||||||
|
processedLine = processedLine.replace(/^- (.*$)/, '<li>$1</li>');
|
||||||
|
}
|
||||||
|
} else if (processedLine.match(/^\d+\. /)) {
|
||||||
|
if (!inOrderedList) {
|
||||||
|
processedLine = '<ol>' + processedLine.replace(/^\d+\. (.*$)/, '<li>$1</li>');
|
||||||
|
inOrderedList = true;
|
||||||
|
} else {
|
||||||
|
processedLine = processedLine.replace(/^\d+\. (.*$)/, '<li>$1</li>');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ligne normale - fermer les listes si nécessaire
|
||||||
|
if (inUnorderedList) {
|
||||||
|
processedLine = '</ul>' + processedLine;
|
||||||
|
inUnorderedList = false;
|
||||||
|
}
|
||||||
|
if (inOrderedList) {
|
||||||
|
processedLine = '</ol>' + processedLine;
|
||||||
|
inOrderedList = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traiter les paragraphes
|
||||||
|
if (processedLine.trim() === '') {
|
||||||
|
processedLine = '</p><p>';
|
||||||
|
} else {
|
||||||
|
processedLine = processedLine + '<br>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processedLines.push(processedLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fermer les listes ouvertes à la fin
|
||||||
|
if (inUnorderedList) {
|
||||||
|
processedLines.push('</ul>');
|
||||||
|
}
|
||||||
|
if (inOrderedList) {
|
||||||
|
processedLines.push('</ol>');
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = processedLines.join('\n');
|
||||||
|
|
||||||
|
// Ajouter les balises de paragraphe si nécessaire
|
||||||
|
if (!result.startsWith('<h') && !result.startsWith('<ul') && !result.startsWith('<ol')) {
|
||||||
|
result = '<p>' + result + '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fonction pour valider le contenu markdown avant sauvegarde
|
// Fonction pour valider le contenu markdown avant sauvegarde
|
||||||
|
|||||||
@@ -578,6 +578,14 @@ export const settingsService = {
|
|||||||
return this.setValue(key, value.toString());
|
return this.setValue(key, value.toString());
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getStringValue(key: string, defaultValue: string = ''): Promise<string> {
|
||||||
|
return this.getValue(key, defaultValue);
|
||||||
|
},
|
||||||
|
|
||||||
|
async setStringValue(key: string, value: string): Promise<Setting> {
|
||||||
|
return this.setValue(key, value);
|
||||||
|
},
|
||||||
|
|
||||||
async delete(key: string): Promise<void> {
|
async delete(key: string): Promise<void> {
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('settings')
|
.from('settings')
|
||||||
|
|||||||
@@ -4,3 +4,42 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traite le message du footer en remplaçant [LINK] par le lien vers le repository
|
||||||
|
*/
|
||||||
|
export function processFooterMessage(message: string, repositoryUrl: string): string {
|
||||||
|
return message.replace(/\[LINK\]/g, repositoryUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traite le message du footer et retourne le texte avec les liens Markdown remplacés
|
||||||
|
*/
|
||||||
|
export function parseFooterMessage(message: string, repositoryUrl: string): { text: string; links: Array<{ text: string; url: string; start: number; end: number }> } {
|
||||||
|
const links: Array<{ text: string; url: string; start: number; end: number }> = [];
|
||||||
|
let processedText = message;
|
||||||
|
|
||||||
|
// Remplacer [texte](GITURL) par le texte du lien
|
||||||
|
const linkRegex = /\[([^\]]+)\]\(GITURL\)/g;
|
||||||
|
let match;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while ((match = linkRegex.exec(message)) !== null) {
|
||||||
|
const linkText = match[1]; // Le texte entre crochets
|
||||||
|
const fullMatch = match[0]; // Le match complet [texte](GITURL)
|
||||||
|
const start = match.index + offset;
|
||||||
|
const end = start + linkText.length;
|
||||||
|
|
||||||
|
links.push({
|
||||||
|
text: linkText,
|
||||||
|
url: repositoryUrl,
|
||||||
|
start,
|
||||||
|
end
|
||||||
|
});
|
||||||
|
|
||||||
|
processedText = processedText.replace(fullMatch, linkText);
|
||||||
|
offset += linkText.length - fullMatch.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: processedText, links };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user