EduLab / frontend /components /QuestionCard.tsx
rinogeek's picture
Initial deploy to Hugging Face
062d102
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; // Callback pour recharger les questions
}
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);
// Nouveaux états pour la réponse inline
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 {
// Appel API pour créer la réponse
const answer = await forumService.createAnswer(question.id, replyContent);
// Gamification : Répondre à une question
gainPoints(20);
unlockBadge('b2'); // Badge "Savant"
// Stocker et afficher la réponse localement
setNewAnswer(answer);
setShowNewAnswer(true);
setAnswersCount(prev => prev + 1);
// Reset formulaire
setIsReplying(false);
setReplyContent('');
// NOTE: On n'appelle PAS onAnswerAdded ici pour éviter le rechargement de la liste
// qui ferait disparaître notre affichage local de la réponse.
} 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;