Ajout paramètre message bas de page personnalisable

This commit is contained in:
Yannick Le Duc
2025-08-27 12:21:09 +02:00
parent 28df167fee
commit aa859a1e44
15 changed files with 580 additions and 207 deletions

View File

@@ -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">

View File

@@ -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 */}

View File

@@ -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);

View File

@@ -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">
{proposePageMessage}
</p> </p>
</div> </div>
</div> </div>
</div>
{/* Campaign Info */} {/* Campaign Description */}
<Card className="mb-8"> <div className="mb-8 max-w-3xl mx-auto">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
Informations sur la campagne
</CardTitle>
</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 <MarkdownContent
content={campaign?.description || ''} content={campaign?.description || ''}
className="text-slate-900 dark:text-slate-100" className="text-slate-700 dark:text-slate-300 text-base leading-relaxed"
/> />
</div> </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>
); );

View File

@@ -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 */}

View File

@@ -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;
}

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View 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>
);
}

View File

@@ -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,39 +49,65 @@ 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' && (
<div className="space-y-4">
<Textarea <Textarea
id="markdown-editor" id="markdown-editor"
value={value} value={value}
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder} placeholder={placeholder}
className="min-h-[200px] font-mono text-sm" className="min-h-[200px] max-h-[300px] font-mono text-sm overflow-y-auto"
tabIndex={0}
/> />
{/* Aide markdown (affichée conditionnellement) */} {/* Aide markdown (affichée conditionnellement) */}
@@ -91,15 +115,14 @@ export function MarkdownEditor({
<div className="rounded-lg border bg-muted/50 p-4 animate-in fade-in duration-200"> <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"> <div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium">Syntaxe Markdown supportée</h4> <h4 className="text-sm font-medium">Syntaxe Markdown supportée</h4>
<Button <button
type="button" type="button"
variant="ghost"
size="sm"
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> </button>
</div> </div>
<div className="grid grid-cols-2 gap-4 text-xs text-muted-foreground"> <div className="grid grid-cols-2 gap-4 text-xs text-muted-foreground">
<div> <div>
@@ -117,13 +140,15 @@ export function MarkdownEditor({
</div> </div>
</div> </div>
)} )}
</TabsContent> </div>
)}
<TabsContent value="preview" className="space-y-4"> {activeTab === 'preview' && (
<div className="min-h-[200px] rounded-lg border bg-background p-4"> <div className="space-y-4">
<div className="min-h-[200px] max-h-[300px] rounded-lg border bg-background p-4 overflow-y-auto">
{value ? ( {value ? (
<div <div
className="prose prose-sm max-w-none" className="prose prose-sm max-w-none [&_ul]:space-y-1 [&_ol]:space-y-1 [&_li]:my-0"
dangerouslySetInnerHTML={{ __html: previewContent }} dangerouslySetInnerHTML={{ __html: previewContent }}
/> />
) : ( ) : (
@@ -132,8 +157,8 @@ export function MarkdownEditor({
</p> </p>
)} )}
</div> </div>
</TabsContent> </div>
</Tabs> )}
{/* Messages d'erreur */} {/* Messages d'erreur */}
{!validation.isValid && ( {!validation.isValid && (

View File

@@ -18,15 +18,25 @@ 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');
const processedLines: string[] = [];
let inUnorderedList = false;
let inOrderedList = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Échapper les caractères HTML // Échapper les caractères HTML
let processedLine = line
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
.replace(/'/g, '&#39;') .replace(/'/g, '&#39;');
// Parser le markdown de base // Parser le markdown de base
processedLine = processedLine
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>') .replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/__(.*?)__/g, '<u>$1</u>') .replace(/__(.*?)__/g, '<u>$1</u>')
@@ -37,20 +47,66 @@ export function parseMarkdown(content: string): string {
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>`; return `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>`;
} }
return text; // Retourner juste le texte si l'URL n'est pas valide return text; // Retourner juste le texte si l'URL n'est pas valide
}) });
// Parser les titres
.replace(/^### (.*$)/gim, '<h3>$1</h3>') // Traiter les titres
.replace(/^## (.*$)/gim, '<h2>$1</h2>') if (processedLine.match(/^### /)) {
.replace(/^# (.*$)/gim, '<h1>$1</h1>') processedLine = processedLine.replace(/^### (.*$)/, '<h3>$1</h3>');
// Parser les listes } else if (processedLine.match(/^## /)) {
.replace(/^- (.*$)/gim, '<li>$1</li>') processedLine = processedLine.replace(/^## (.*$)/, '<h2>$1</h2>');
.replace(/^\d+\. (.*$)/gim, '<li>$1</li>') } else if (processedLine.match(/^# /)) {
// Parser les paragraphes processedLine = processedLine.replace(/^# (.*$)/, '<h1>$1</h1>');
.replace(/\n\n/g, '</p><p>') }
.replace(/\n/g, '<br>');
// 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');
const processedLines: string[] = [];
let inUnorderedList = false;
let inOrderedList = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Échapper les caractères HTML
let processedLine = line
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
.replace(/'/g, '&#39;') .replace(/'/g, '&#39;');
// Traiter le markdown de base
processedLine = processedLine
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>') .replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/__(.*?)__/g, '<u>$1</u>') .replace(/__(.*?)__/g, '<u>$1</u>')
.replace(/~~(.*?)~~/g, '<del>$1</del>') .replace(/~~(.*?)~~/g, '<del>$1</del>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>') .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>') // Traiter les titres
.replace(/^# (.*$)/gim, '<h1>$1</h1>') if (processedLine.match(/^### /)) {
.replace(/^- (.*$)/gim, '<li>$1</li>') processedLine = processedLine.replace(/^### (.*$)/, '<h3>$1</h3>');
.replace(/^\d+\. (.*$)/gim, '<li>$1</li>') } else if (processedLine.match(/^## /)) {
.replace(/\n\n/g, '</p><p>') processedLine = processedLine.replace(/^## (.*$)/, '<h2>$1</h2>');
.replace(/\n/g, '<br>'); } 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

View File

@@ -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')

View File

@@ -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 };
}