 70ed93e88c
			
		
	
	
		70ed93e88c
		
	
	
	
		
			
	
		
	
	
		
			All checks were successful
		
		
	
	Deploy to Netlify / Deploy to Netlify (push) Successful in 1m55s
				
			
		
			
				
	
	
		
			444 lines
		
	
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			444 lines
		
	
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import "katex/dist/katex.min.css";
 | |
| import { DomUtils, parseDocument } from "htmlparser2";
 | |
| import Latex from "react-latex-next";
 | |
| import { useState } from "react";
 | |
| import { Link, Navigate, useNavigate, useParams } from "react-router";
 | |
| import { Button } from "~/components/ui/button";
 | |
| import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
 | |
| import {
 | |
|   ArrowLeft,
 | |
|   Clock,
 | |
|   User,
 | |
|   UserCheck,
 | |
|   MessageSquare,
 | |
|   Paperclip,
 | |
|   Star,
 | |
|   ChevronUp,
 | |
|   ChevronDown,
 | |
|   MapPinHouse,
 | |
|   RefreshCw,
 | |
|   Share2,
 | |
|   ExternalLink,
 | |
|   Users,
 | |
| } from "lucide-react";
 | |
| import { Separator } from "~/components/ui/separator";
 | |
| import ColleDetailsSkeleton from "~/components/details/skeleton-details";
 | |
| import AttachmentItem from "~/components/details/attachment";
 | |
| import Error from "~/components/error";
 | |
| import { Badge } from "~/components/ui/badge";
 | |
| import { AUTH_ERROR, refreshColle, useColle, useUser } from "~/lib/api";
 | |
| import { toast } from "sonner";
 | |
| import {
 | |
|   formatDate,
 | |
|   formatGrade,
 | |
|   formatTime,
 | |
|   getColorClass,
 | |
|   getSubjectColor,
 | |
|   getSubjectEmoji,
 | |
|   goBack,
 | |
| } from "~/lib/utils";
 | |
| import { useQueryClient } from "@tanstack/react-query";
 | |
| 
 | |
| // TODO: Move all code to components
 | |
| export default function ColleDetailPage() {
 | |
|   const { user, isLoading: isUserLoading, error: userError } = useUser();
 | |
|   const queryClient = useQueryClient();
 | |
| 
 | |
|   const navigate = useNavigate();
 | |
|   const params = useParams<{ colleId: string }>();
 | |
| 
 | |
|   const colleId = parseInt(params.colleId!);
 | |
|   if (isNaN(colleId)) {
 | |
|     return <Navigate to="/" />;
 | |
|   }
 | |
|   const { colle, error, isLoading } = useColle(colleId);
 | |
| 
 | |
|   const [isReloading, setIsReloading] = useState(false);
 | |
| 
 | |
|   if (isUserLoading) {
 | |
|     return <ColleDetailsSkeleton />;
 | |
|   }
 | |
|   if (userError?.message === AUTH_ERROR) {
 | |
|     return <Navigate to="/login" replace />;
 | |
|   }
 | |
|   if (userError) {
 | |
|     return <Error message={userError.message} />;
 | |
|   }
 | |
| 
 | |
|   // TODO: Favorite toggle function
 | |
|   const toggleStar = () => {};
 | |
| 
 | |
|   if (error)
 | |
|     return (
 | |
|       <Error
 | |
|         title="Impossible de charger cette colle"
 | |
|         message={error?.toString()}
 | |
|         code={500}
 | |
|         description="Une erreur s'est produite lors du chargement de la colle."
 | |
|       />
 | |
|     );
 | |
|   if (isLoading || !colle) return <ColleDetailsSkeleton />;
 | |
| 
 | |
|   const handleToggleFavorite = () => {};
 | |
| 
 | |
|   const handleReload = () => {
 | |
|     setIsReloading(true);
 | |
|     refreshColle(colle.id)
 | |
|       .then(() => {
 | |
|         toast("Les données de cette colle vont être mises à jour", {
 | |
|           icon: "🔄",
 | |
|           description: "Rafraîchissement en cours...",
 | |
|         });
 | |
|       })
 | |
|       .catch((error) => {
 | |
|         console.error("Error refreshing colle:", error);
 | |
|         toast.error("Échec du rafraîchissement de la colle", {
 | |
|           icon: "❌",
 | |
|           description: "Veuillez réessayer plus tard.",
 | |
|         });
 | |
|       })
 | |
|       .finally(() => {
 | |
|         setIsReloading(false);
 | |
|         queryClient.invalidateQueries({
 | |
|           queryKey: ["colle", colle.id],
 | |
|         });
 | |
|       });
 | |
|   };
 | |
| 
 | |
|   const handleShare = async () => {
 | |
|     const shareUrl = window.location.href;
 | |
|     const shareTitle = `Colle de ${colle.subject.name} - ${colle.student.firstName}`;
 | |
|     const shareText = `Colle de ${colle.subject.name} du ${formatDate(
 | |
|       colle.date
 | |
|     )} à ${formatTime(colle.date)} - ${colle.student} avec ${
 | |
|       colle.examiner
 | |
|     }.\n\nConsultez-le résumé ici :`;
 | |
|     try {
 | |
|       if (navigator.share) {
 | |
|         await navigator.share({
 | |
|           title: shareTitle,
 | |
|           text: shareText,
 | |
|           url: shareUrl,
 | |
|         });
 | |
|       } else {
 | |
|         // Fallback to copying the URL
 | |
|         await navigator.clipboard.writeText(shareUrl);
 | |
|         toast("Lien copié dans le presse-papiers", {
 | |
|           icon: "📋",
 | |
|           description: "Vous pouvez le partager avec vos amis !",
 | |
|         });
 | |
|       }
 | |
|     } catch (error) {
 | |
|       console.error("Error sharing:", error);
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   const handleOpenBJColle = () => {
 | |
|     const url = `https://bjcolle.fr/students_oral_disp.php?colle=${colle.bjid}&hgfebrgl8ri3h=${colle.bjsecret}`;
 | |
|     window.open(url, "_blank");
 | |
|     toast("Ouverture de BJColle", {
 | |
|       icon: "🔗",
 | |
|       description: "Redirection vers BJColle...",
 | |
|     });
 | |
|   };
 | |
| 
 | |
|   const subjectColor = getColorClass(
 | |
|     getSubjectColor(colle.subject.name, user.preferences)
 | |
|   );
 | |
|   const subjectEmoji = getSubjectEmoji(colle.subject.name, user.preferences);
 | |
| 
 | |
|   return (
 | |
|     <div className="container mx-auto py-6 px-4 pb-20 md:pb-6 md:py-8">
 | |
|       <div className="flex justify-between items-center mb-4">
 | |
|         <Button variant="outline" onClick={() => goBack(navigate)}>
 | |
|           <ArrowLeft className="h-4 w-4 mr-2" />
 | |
|           Retour
 | |
|         </Button>
 | |
|         <div className="flex md:hidden items-center gap-2">
 | |
|           <div className="space-x-2">
 | |
|             <Button
 | |
|               variant="outline"
 | |
|               size="icon"
 | |
|               className="h-10 w-10"
 | |
|               onClick={handleReload}
 | |
|               disabled={isReloading}
 | |
|             >
 | |
|               <RefreshCw
 | |
|                 className={`h-5 w-5 ${isReloading ? "animate-spin" : ""}`}
 | |
|               />
 | |
|               <span className="sr-only">Recharger</span>
 | |
|             </Button>
 | |
|             <Button
 | |
|               variant="outline"
 | |
|               size="icon"
 | |
|               className="h-10 w-10"
 | |
|               onClick={handleShare}
 | |
|             >
 | |
|               <Share2 className="h-5 w-5" />
 | |
|             </Button>
 | |
|           </div>
 | |
|         </div>
 | |
|       </div>
 | |
| 
 | |
|       <Card className="max-w-3xl mx-auto">
 | |
|         <CardHeader className="pb-2">
 | |
|           <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
 | |
|             <div className="flex flex-col">
 | |
|               <div className="flex items-center justify-between">
 | |
|                 <CardTitle className="text-xl">
 | |
|                   Colle de {colle.subject.name}
 | |
|                 </CardTitle>
 | |
|                 {colle.grade && (
 | |
|                   <div
 | |
|                     className={`md:hidden px-3 py-1 rounded-md text-sm font-medium ${subjectColor}`}
 | |
|                   >
 | |
|                     {formatGrade(colle.grade)}/20
 | |
|                   </div>
 | |
|                 )}
 | |
|               </div>
 | |
|               <div className="flex items-center gap-2 mt-2">
 | |
|                 <Badge className={subjectColor}>
 | |
|                   {colle.subject.name} {subjectEmoji}
 | |
|                 </Badge>
 | |
|               </div>
 | |
|             </div>
 | |
| 
 | |
|             {/* Desktop Action Buttons */}
 | |
|             <div className="hidden md:flex items-center gap-2">
 | |
|               <Button
 | |
|                 variant="outline"
 | |
|                 size="sm"
 | |
|                 className="h-9 w-9"
 | |
|                 onClick={handleShare}
 | |
|                 title="Partager"
 | |
|               >
 | |
|                 <Share2 className="h-4 w-4" />
 | |
|                 <span className="sr-only">Partager</span>
 | |
|               </Button>
 | |
| 
 | |
|               <Button
 | |
|                 variant="outline"
 | |
|                 size="sm"
 | |
|                 className="h-9 w-9"
 | |
|                 onClick={handleReload}
 | |
|                 disabled={isReloading}
 | |
|                 title="Recharger"
 | |
|               >
 | |
|                 <RefreshCw
 | |
|                   className={`h-4 w-4 ${isReloading ? "animate-spin" : ""}`}
 | |
|                 />
 | |
|                 <span className="sr-only">Recharger</span>
 | |
|               </Button>
 | |
| 
 | |
|               {/* <Button
 | |
|                 variant="ghost"
 | |
|                 size="sm"
 | |
|                 className={`h-9 w-9 ${
 | |
|                   // TODO: Use a proper favorite state
 | |
|                   false ? "text-yellow-500" : "text-muted-foreground"
 | |
|                 }`}
 | |
|                 onClick={handleToggleFavorite}
 | |
|               >
 | |
|                 <Star
 | |
|                   className={`h-5 w-5 ${
 | |
|                     // TODO: Use a proper favorite state
 | |
|                     false ? "fill-yellow-500" : ""
 | |
|                   }`}
 | |
|                 />
 | |
|                 <span className="sr-only">Ajouter aux favoris</span>
 | |
|               </Button> */}
 | |
| 
 | |
|               {colle.grade && (
 | |
|                 <div
 | |
|                   className={`px-3 py-1 rounded-md text-sm font-medium ${subjectColor}`}
 | |
|                 >
 | |
|                   {formatGrade(colle.grade)}/20
 | |
|                 </div>
 | |
|               )}
 | |
|             </div>
 | |
|           </div>
 | |
|         </CardHeader>
 | |
| 
 | |
|         <CardContent className="space-y-4">
 | |
|           <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
 | |
|             <div className="space-y-2">
 | |
|               <div>
 | |
|                 <h3 className="text-sm font-medium text-muted-foreground mb-1">
 | |
|                   Date
 | |
|                 </h3>
 | |
|                 <div className="flex items-center gap-2">
 | |
|                   <Clock className="h-4 w-4" />
 | |
|                   <span>
 | |
|                     {formatDate(colle.date)}, à {formatTime(colle.date)}
 | |
|                   </span>
 | |
|                 </div>
 | |
|               </div>
 | |
| 
 | |
|               <div>
 | |
|                 <h3 className="text-sm font-medium text-muted-foreground mb-1">
 | |
|                   Colleur
 | |
|                 </h3>
 | |
|                 <div className="flex items-center gap-2">
 | |
|                   <UserCheck className="h-4 w-4" />
 | |
|                   <span>{colle.examiner.name}</span>
 | |
|                 </div>
 | |
|               </div>
 | |
|             </div>
 | |
| 
 | |
|             <div className="space-y-2">
 | |
|               <div>
 | |
|                 <h3 className="text-sm font-medium text-muted-foreground mb-1">
 | |
|                   Élève
 | |
|                 </h3>
 | |
|                 <div className="flex items-center gap-2">
 | |
|                   <User className="h-4 w-4" />
 | |
|                   <span>{colle.student.fullName}</span>
 | |
|                 </div>
 | |
|               </div>
 | |
| 
 | |
|               {colle.relatives?.length > 0 && (
 | |
|                 <div>
 | |
|                   <h3 className="text-sm font-medium text-muted-foreground mb-1">
 | |
|                     Autres élèves
 | |
|                   </h3>
 | |
|                   {colle.relatives.map((linkedColle) => (
 | |
|                     <div className="flex items-center gap-2">
 | |
|                       <Users className="h-4 w-4" />
 | |
|                       <Link
 | |
|                         to={`/colles/${linkedColle.id}`}
 | |
|                         key={linkedColle.id}
 | |
|                       >
 | |
|                         {linkedColle.student.fullName}
 | |
|                       </Link>
 | |
|                     </div>
 | |
|                   ))}
 | |
|                 </div>
 | |
|               )}
 | |
|             </div>
 | |
| 
 | |
|             {colle.room && (
 | |
|               <div className="space-y-2">
 | |
|                 <div>
 | |
|                   <h3 className="text-sm font-medium text-muted-foreground mb-1">
 | |
|                     Salle
 | |
|                   </h3>
 | |
|                   <div className="flex items-center gap-2">
 | |
|                     <MapPinHouse className="h-4 w-4" />
 | |
|                     <span>{colle.room.name}</span>
 | |
|                   </div>
 | |
|                 </div>
 | |
|               </div>
 | |
|             )}
 | |
|           </div>
 | |
| 
 | |
|           {colle.content && (
 | |
|             <>
 | |
|               <Separator />
 | |
| 
 | |
|               <div>
 | |
|                 <h3 className="text-lg font-medium mb-2 flex items-center gap-2">
 | |
|                   <MessageSquare className="h-5 w-5" />
 | |
|                   Sujet
 | |
|                 </h3>
 | |
| 
 | |
|                 <div className="p-4 bg-muted rounded-lg">
 | |
|                   <ExpandableComment comment={colle.content} />
 | |
|                 </div>
 | |
|               </div>
 | |
|             </>
 | |
|           )}
 | |
| 
 | |
|           {colle.comment && (
 | |
|             <>
 | |
|               <Separator />
 | |
| 
 | |
|               <div>
 | |
|                 <h3 className="text-lg font-medium mb-2 flex items-center gap-2">
 | |
|                   <MessageSquare className="h-5 w-5" />
 | |
|                   Commentaires
 | |
|                 </h3>
 | |
|                 <div className="p-4 bg-muted rounded-lg">
 | |
|                   <ExpandableComment comment={colle.comment} />
 | |
|                 </div>
 | |
|               </div>
 | |
|             </>
 | |
|           )}
 | |
| 
 | |
|           {colle.attachments && colle.attachments?.length > 0 && (
 | |
|             <div>
 | |
|               <h3 className="text-lg font-medium mb-3 flex items-center gap-2">
 | |
|                 <Paperclip className="h-5 w-5" />
 | |
|                 Pièces jointes
 | |
|               </h3>
 | |
|               <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
 | |
|                 {colle.attachments.map((attachment, index) => (
 | |
|                   <AttachmentItem key={index} attachment={attachment} />
 | |
|                 ))}
 | |
|               </div>
 | |
|             </div>
 | |
|           )}
 | |
| 
 | |
|           {/* BJColle External Link */}
 | |
|           <div className="mt-8 pt-4 border-t border-dashed">
 | |
|             <div className="flex justify-end items-center space-x-1 mt-1 text-xs text-muted-foreground">
 | |
|               <ExternalLink className="h-4 w-4" />
 | |
|               <span onClick={handleOpenBJColle}>
 | |
|                 Accéder à la colle depuis BJColle
 | |
|               </span>
 | |
|             </div>
 | |
|           </div>
 | |
|         </CardContent>
 | |
|       </Card>
 | |
|     </div>
 | |
|   );
 | |
| }
 | |
| 
 | |
| // TODO: Custom render component for LaTeX rendering
 | |
| // Component for expandable comments
 | |
| function ExpandableComment({ comment }: { comment: string }) {
 | |
|   // Crop comments longer than 750 characters
 | |
|   const [isExpanded, setIsExpanded] = useState(false);
 | |
|   const commentLimit = 750; // Character limit before truncating
 | |
|   const isLongComment = removeHtmlElements(comment).length > commentLimit;
 | |
| 
 | |
|   const toggleExpand = () => {
 | |
|     setIsExpanded(!isExpanded);
 | |
|   };
 | |
| 
 | |
|   const displayedComment =
 | |
|     isExpanded || !isLongComment
 | |
|       ? comment
 | |
|       : `${comment.substring(0, commentLimit)}...`;
 | |
| 
 | |
|   return (
 | |
|     <div>
 | |
|       <Latex delimiters={[{ left: "$$", right: "$$", display: false }]}>
 | |
|         {displayedComment}
 | |
|       </Latex>
 | |
|       {isLongComment && (
 | |
|         <Button
 | |
|           variant="ghost"
 | |
|           size="sm"
 | |
|           className="mt-1 h-7 px-2 text-primary"
 | |
|           onClick={toggleExpand}
 | |
|         >
 | |
|           {isExpanded ? (
 | |
|             <span className="flex items-center gap-1">
 | |
|               Afficher moins <ChevronUp className="h-3 w-3" />
 | |
|             </span>
 | |
|           ) : (
 | |
|             <span className="flex items-center gap-1">
 | |
|               Afficher plus <ChevronDown className="h-3 w-3" />
 | |
|             </span>
 | |
|           )}
 | |
|         </Button>
 | |
|       )}
 | |
|     </div>
 | |
|   );
 | |
| }
 | |
| 
 | |
| // Remove HTML elements and return plain text (for text length calculation and cropping)
 | |
| function removeHtmlElements(htmlString: string) {
 | |
|   const document = parseDocument(htmlString);
 | |
|   return DomUtils.textContent(document).trim();
 | |
| }
 |