|
|
import React, { useState } from 'react'; |
|
|
import { useNavigate } from 'react-router-dom'; |
|
|
import { MessageSquare, ArrowUp, CheckCircle2, Bold, Italic, List, Link2, Image as ImageIcon, Send, CornerDownRight } from 'lucide-react'; |
|
|
import { Question, Answer } from '../types'; |
|
|
import { useAuth } from '../context/AuthContext'; |
|
|
import { forumService } from '../services'; |
|
|
import MarkdownContent from './MarkdownContent'; |
|
|
|
|
|
interface Props { |
|
|
question: Question; |
|
|
onAnswerAdded?: () => void; |
|
|
} |
|
|
|
|
|
const QuestionCard: React.FC<Props> = ({ question, onAnswerAdded }) => { |
|
|
const [isReplying, setIsReplying] = useState(false); |
|
|
const [replyContent, setReplyContent] = useState(''); |
|
|
const [isSubmitting, setIsSubmitting] = useState(false); |
|
|
const [error, setError] = useState<string | null>(null); |
|
|
const [votes, setVotes] = useState(question.votes); |
|
|
const [hasVoted, setHasVoted] = useState(question.userVote === 1); |
|
|
const [isVoting, setIsVoting] = useState(false); |
|
|
|
|
|
|
|
|
const [newAnswer, setNewAnswer] = useState<Answer | null>(null); |
|
|
const [showNewAnswer, setShowNewAnswer] = useState(false); |
|
|
const [answersCount, setAnswersCount] = useState(question.answers); |
|
|
|
|
|
const { isAuthenticated, gainPoints, unlockBadge } = useAuth(); |
|
|
const navigate = useNavigate(); |
|
|
|
|
|
const handleVote = async () => { |
|
|
if (!isAuthenticated) { |
|
|
navigate('/login'); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (isVoting) return; |
|
|
|
|
|
setIsVoting(true); |
|
|
try { |
|
|
const result = await forumService.voteQuestion(question.id, 1); |
|
|
setVotes(result.votes); |
|
|
setHasVoted(!hasVoted); |
|
|
} catch (err) { |
|
|
console.error('Failed to vote:', err); |
|
|
} finally { |
|
|
setIsVoting(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleSubmit = async () => { |
|
|
if (!replyContent.trim()) return; |
|
|
|
|
|
if (!isAuthenticated) { |
|
|
navigate('/login'); |
|
|
return; |
|
|
} |
|
|
|
|
|
setIsSubmitting(true); |
|
|
setError(null); |
|
|
|
|
|
try { |
|
|
|
|
|
const answer = await forumService.createAnswer(question.id, replyContent); |
|
|
|
|
|
|
|
|
gainPoints(20); |
|
|
unlockBadge('b2'); |
|
|
|
|
|
|
|
|
setNewAnswer(answer); |
|
|
setShowNewAnswer(true); |
|
|
setAnswersCount(prev => prev + 1); |
|
|
|
|
|
|
|
|
setIsReplying(false); |
|
|
setReplyContent(''); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} catch (err: any) { |
|
|
console.error('Erreur lors de la création de la réponse:', err); |
|
|
setError( |
|
|
err.response?.data?.message || |
|
|
err.response?.data?.detail || |
|
|
'Impossible de publier votre réponse. Veuillez réessayer.' |
|
|
); |
|
|
} finally { |
|
|
setIsSubmitting(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm border border-gray-100 dark:border-gray-700 hover:shadow-md transition-shadow"> |
|
|
<div className="flex items-start space-x-4"> |
|
|
{/* Vote Section */} |
|
|
<div className="flex flex-col items-center space-y-1 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 p-2 rounded-lg min-w-[3rem]"> |
|
|
<button |
|
|
onClick={handleVote} |
|
|
disabled={isVoting} |
|
|
className={`hover:text-edu-accent transition-colors ${hasVoted ? 'text-edu-secondary' : ''} ${isVoting ? 'opacity-50 cursor-not-allowed' : ''}`} |
|
|
> |
|
|
<ArrowUp size={20} className={hasVoted ? 'fill-current' : ''} /> |
|
|
</button> |
|
|
<span className="font-bold text-lg">{votes}</span> |
|
|
</div> |
|
|
|
|
|
{/* Content Section */} |
|
|
<div className="flex-grow"> |
|
|
<div className="flex justify-between items-start"> |
|
|
<h3 |
|
|
onClick={() => navigate(`/questions/${question.id}`)} |
|
|
className="text-lg font-semibold text-edu-primary dark:text-white hover:text-edu-secondary cursor-pointer mb-2 line-clamp-2" |
|
|
> |
|
|
{question.title} |
|
|
</h3> |
|
|
{question.isSolved && ( |
|
|
<div className="flex items-center text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20 px-2 py-1 rounded text-xs font-medium shrink-0 ml-2"> |
|
|
<CheckCircle2 size={12} className="mr-1" /> |
|
|
Résolu |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
|
|
|
<div className="text-gray-600 dark:text-gray-300 text-sm line-clamp-3 mb-4"> |
|
|
<MarkdownContent content={question.content} /> |
|
|
</div> |
|
|
|
|
|
<div className="flex flex-wrap gap-2 mb-4"> |
|
|
{question.tags.map((tag, index) => { |
|
|
const isLevel = ['université', 'lycée', 'collège', 'licence', 'master', 'doctorat', 'primaire', 'l1', 'l2', 'l3', 'm1', 'm2', '6ème', '5ème', '4ème', '3ème', '2nde', '1ère', 'tle', 'terminale'].includes(tag.toLowerCase()); |
|
|
const tagClass = isLevel |
|
|
? 'bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 border-amber-100 dark:border-amber-800' |
|
|
: 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-300 border-blue-100 dark:border-blue-800'; |
|
|
|
|
|
return ( |
|
|
<span key={`${tag}-${index}`} className={`px-2.5 py-0.5 text-xs rounded-full font-medium border transition-colors ${tagClass}`}> |
|
|
{tag} |
|
|
</span> |
|
|
); |
|
|
})} |
|
|
</div> |
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between text-xs text-gray-500 dark:text-gray-400 border-t border-gray-100 dark:border-gray-700 pt-3 gap-4 sm:gap-0"> |
|
|
<div className="flex items-center space-x-2"> |
|
|
<img src={question.author.avatar} alt={question.author.name} className="w-5 h-5 rounded-full" /> |
|
|
<span className="font-medium dark:text-gray-300">{question.author.name}</span> |
|
|
<span className="text-gray-300">•</span> |
|
|
<span>{question.author.country}</span> |
|
|
<span className="text-gray-300 hidden sm:inline">•</span> |
|
|
<span className="hidden sm:inline">{question.createdAt}</span> |
|
|
</div> |
|
|
<div className="flex items-center gap-3"> |
|
|
<div |
|
|
onClick={() => navigate(`/questions/${question.id}`)} |
|
|
className="flex items-center space-x-1 cursor-pointer hover:text-edu-secondary transition-colors" |
|
|
> |
|
|
<MessageSquare size={14} /> |
|
|
<span>{answersCount} réponses</span> |
|
|
</div> |
|
|
<button |
|
|
onClick={() => setIsReplying(!isReplying)} |
|
|
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg font-medium transition-colors ${isReplying ? 'bg-edu-secondary text-white' : 'bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300'}`} |
|
|
> |
|
|
<CornerDownRight size={14} /> |
|
|
Répondre |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* WYSIWYG Editor Area */} |
|
|
{isReplying && ( |
|
|
<div className="mt-4 animate-in fade-in slide-in-from-top-2 duration-200"> |
|
|
{/* Message d'erreur */} |
|
|
{error && ( |
|
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 px-4 py-3 rounded-xl text-sm mb-3 animate-in slide-in-from-top-2"> |
|
|
<strong className="font-bold">Erreur: </strong> |
|
|
<span>{error}</span> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
<div className="border border-gray-200 dark:border-gray-600 rounded-xl overflow-hidden bg-white dark:bg-gray-800 focus-within:ring-2 focus-within:ring-edu-secondary/50 transition-all shadow-sm"> |
|
|
{/* Toolbar */} |
|
|
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 flex gap-1 border-b border-gray-200 dark:border-gray-600 overflow-x-auto"> |
|
|
<button type="button" className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-600 rounded text-gray-600 dark:text-gray-300" title="Gras" disabled={isSubmitting}><Bold size={16} /></button> |
|
|
<button type="button" className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-600 rounded text-gray-600 dark:text-gray-300" title="Italique" disabled={isSubmitting}><Italic size={16} /></button> |
|
|
<button type="button" className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-600 rounded text-gray-600 dark:text-gray-300" title="Liste" disabled={isSubmitting}><List size={16} /></button> |
|
|
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1"></div> |
|
|
<button type="button" className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-600 rounded text-gray-600 dark:text-gray-300" title="Lien" disabled={isSubmitting}><Link2 size={16} /></button> |
|
|
<button type="button" className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-600 rounded text-gray-600 dark:text-gray-300" title="Image" disabled={isSubmitting}><ImageIcon size={16} /></button> |
|
|
</div> |
|
|
|
|
|
{/* Textarea */} |
|
|
<textarea |
|
|
value={replyContent} |
|
|
onChange={(e) => setReplyContent(e.target.value)} |
|
|
disabled={isSubmitting} |
|
|
placeholder="Écrivez votre réponse ici. Soyez précis et bienveillant..." |
|
|
className="w-full p-4 min-h-[120px] bg-transparent border-none outline-none text-sm text-gray-800 dark:text-gray-200 resize-y disabled:opacity-50 disabled:cursor-not-allowed" |
|
|
/> |
|
|
</div> |
|
|
|
|
|
{/* Actions */} |
|
|
<div className="flex justify-end gap-2 mt-3"> |
|
|
<button |
|
|
onClick={() => { setIsReplying(false); setError(null); }} |
|
|
disabled={isSubmitting} |
|
|
className="px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" |
|
|
> |
|
|
Annuler |
|
|
</button> |
|
|
<button |
|
|
onClick={handleSubmit} |
|
|
disabled={!replyContent.trim() || isSubmitting} |
|
|
className="px-4 py-2 text-sm font-bold text-white bg-edu-secondary hover:bg-edu-primary rounded-lg transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" |
|
|
> |
|
|
{isSubmitting ? ( |
|
|
<> |
|
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> |
|
|
Publication... |
|
|
</> |
|
|
) : ( |
|
|
<> |
|
|
<Send size={16} /> |
|
|
Publier la réponse |
|
|
</> |
|
|
)} |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* Affichage de la nouvelle réponse */} |
|
|
{newAnswer && showNewAnswer && ( |
|
|
<div className="mt-6 pt-4 border-t border-gray-100 dark:border-gray-700 animate-in fade-in slide-in-from-top-4 duration-500"> |
|
|
<div className="flex justify-between items-center mb-3"> |
|
|
<h4 className="font-bold text-edu-primary dark:text-white flex items-center gap-2"> |
|
|
<div className="w-2 h-2 rounded-full bg-green-500"></div> |
|
|
Votre réponse |
|
|
</h4> |
|
|
<button |
|
|
onClick={() => setShowNewAnswer(false)} |
|
|
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 underline" |
|
|
> |
|
|
Masquer |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-xl p-4 border border-gray-200 dark:border-gray-700"> |
|
|
<div className="flex items-center gap-2 mb-3"> |
|
|
<img src={newAnswer.author.avatar} alt={newAnswer.author.name} className="w-6 h-6 rounded-full" /> |
|
|
<span className="font-bold text-sm text-gray-900 dark:text-white">{newAnswer.author.name}</span> |
|
|
<span className="text-xs text-gray-500 dark:text-gray-400">• À l'instant</span> |
|
|
</div> |
|
|
|
|
|
<div className="text-sm text-gray-700 dark:text-gray-300 prose dark:prose-invert max-w-none"> |
|
|
<MarkdownContent content={newAnswer.content} /> |
|
|
</div> |
|
|
|
|
|
<div className="mt-3 flex justify-end"> |
|
|
<button |
|
|
onClick={() => navigate(`/questions/${question.id}`)} |
|
|
className="text-xs font-medium text-edu-secondary hover:text-edu-primary transition-colors" |
|
|
> |
|
|
Voir toutes les réponses → |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* Bouton pour réafficher la réponse si elle est masquée */} |
|
|
{newAnswer && !showNewAnswer && ( |
|
|
<div className="mt-3 flex justify-end"> |
|
|
<button |
|
|
onClick={() => setShowNewAnswer(true)} |
|
|
className="text-xs font-medium text-gray-500 hover:text-edu-secondary transition-colors flex items-center gap-1" |
|
|
> |
|
|
<CornerDownRight size={12} /> |
|
|
Afficher votre réponse |
|
|
</button> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
export default QuestionCard; |
|
|
|
|
|
|
|
|
|