feat: add colle details page
All checks were successful
Deploy to Netlify / Deploy to Netlify (push) Successful in 1m39s

This commit is contained in:
Nathan Lamy 2025-07-29 23:25:10 +02:00
parent f988f8b7e7
commit 85e2552db8
18 changed files with 979 additions and 190 deletions

View file

@ -0,0 +1,52 @@
import { FileText, Image, File } from "lucide-react";
export default function AttachmentItem({ attachment }: { attachment: string }) {
return (
<a
// TODO: BAD: hardcoded URL, should be dynamic (environment variable or config)
href={"https://bjcolle.fr/" + attachment}
target="_blank"
className="flex items-center gap-2 p-3 border rounded-md hover:bg-muted transition-colors cursor-pointer"
>
{getIcon(attachment)}
<span className="font-medium truncate">
{getName(attachment) || "Sans Nom"}
</span>
</a>
);
}
const getType = (attachment: string) => {
const ext = attachment.split(".").pop();
if (ext === "pdf") {
return "pdf";
} else if (["jpg", "jpeg", "png", "gif"].includes(ext!)) {
return "image";
} else {
console.error(`Unknown attachment type: ${ext}`);
return "other";
}
};
const getIcon = (attachment: string) => {
switch (getType(attachment)) {
case "pdf":
return <FileText className="h-5 w-5 text-red-500" />;
case "image":
return <Image className="h-5 w-5 text-blue-500" />;
// case "document":
// return <FileText className="h-5 w-5 text-blue-500" />
// case "spreadsheet":
// return <FileText className="h-5 w-5 text-green-500" />
// case "code":
// return <FileText className="h-5 w-5 text-purple-500" />
default:
return <File className="h-5 w-5 text-gray-500" />;
}
};
const getName = (attachment: string) => {
const parts = attachment.replace("pj_doc", "").split("_");
const nameParts = parts.slice(2); // remove the first two parts
return nameParts.join("_");
};

View file

@ -0,0 +1,92 @@
import { Button } from "~/components/ui/button"
import { Card, CardContent, CardHeader } from "~/components/ui/card"
import { Skeleton } from "~/components/ui/skeleton"
import { Separator } from "~/components/ui/separator"
import { ArrowLeft } from "lucide-react"
export default function DetailsSkeleton() {
return (
<div className="container mx-auto py-8 px-4">
<div className="flex justify-between items-center mb-4">
<Button variant="outline" disabled>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<Skeleton className="h-9 w-9 rounded-md" />
</div>
<Card className="max-w-3xl mx-auto">
<CardHeader className="pb-2">
<div className="flex justify-between items-center">
<Skeleton className="h-8 w-48" />
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-6 w-20 rounded-md" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<Skeleton className="h-4 w-24 mb-1" />
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-4 w-40" />
</div>
</div>
<div>
<Skeleton className="h-4 w-24 mb-1" />
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-4 w-32" />
</div>
</div>
</div>
<div className="space-y-4">
<div>
<Skeleton className="h-4 w-24 mb-1" />
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-4 w-28" />
</div>
</div>
<div>
<Skeleton className="h-4 w-24 mb-1" />
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-4 w-36" />
</div>
</div>
</div>
</div>
<Separator />
<div>
<div className="flex items-center gap-2 mb-2">
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="h-6 w-40" />
</div>
<Skeleton className="h-24 w-full rounded-lg" />
</div>
<div>
<div className="flex items-center gap-2 mb-3">
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="h-6 w-32" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Skeleton className="h-14 w-full rounded-md" />
<Skeleton className="h-14 w-full rounded-md" />
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View file

@ -18,7 +18,7 @@ export default function Error({
description = "La page que vous recherchez a peut-être été supprimée, son nom a été modifié ou est temporairement indisponible.",
}) {
return (
<div className="container flex items-center justify-center min-h-[80vh]">
<div className="flex items-center justify-center h-full w-full">
<Card className="max-w-md w-full">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
@ -36,9 +36,9 @@ export default function Error({
<ChevronLeft />
<Link to="/">Retour</Link>
</Button>
<Button variant="destructive" onClick={forceReload}>
<Button variant="destructive" className="text-white" onClick={forceReload}>
<RotateCw />
Réessayer
Recharger la page
</Button>
</CardFooter>
</Card>

View file

@ -1,7 +1,6 @@
import type React from "react";
import type { Colle } from "~/lib/api";
import { DateTime } from "luxon";
import { Link, useNavigate } from "react-router";
import {
Card,
@ -12,7 +11,7 @@ import {
import { User, UserCheck, Paperclip, Star, MapPinHouse } from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { titleCase } from "~/lib/utils";
import { formatDate, formatGrade, formatTime } from "~/lib/utils";
// TODO: Preferences for subject colors
const getSubjectColor = (_: string) => {
@ -132,47 +131,15 @@ export default function ColleCard({
)}
</div>
{/* TODO: Attachments */}
{colle.attachmentsCount > 0 && (
{/* {colle.attachmentsCount > 0 && (
<div className="flex items-center gap-1 text-muted-foreground">
<Paperclip className="h-3.5 w-3.5" />
<span className="text-xs">{colle.attachmentsCount}</span>
</div>
)}
)} */}
</div>
</CardFooter>
</Card>
</Link>
);
}
const formatDate = (date: string) => {
const dt = DateTime.fromISO(date).setLocale("fr");
const str = dt.toLocaleString({
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
return titleCase(str);
};
const formatTime = (date: string) => {
const dt = DateTime.fromISO(date).setLocale("fr");
return dt.toLocaleString({
hour: "2-digit",
minute: "2-digit",
});
};
const formatGrade = (grade?: number) => {
if (grade === undefined || grade === null || grade < 0 || grade > 20)
return "N/A";
const rounded = Math.round(grade * 10) / 10;
const str =
rounded % 1 === 0
? rounded.toFixed(0) // no decimals if .0
: rounded.toFixed(1); // one decimal otherwise
return str.replace(".", ",").padStart(2, "0"); // pad with zero if needed
};

View file

@ -52,6 +52,8 @@ export default function DatePickerWithRange({
onSelect={handleDateSelect}
weekStartsOn={1}
locale={fr}
className="rounded-md border shadow-sm"
captionLayout="dropdown"
/>
</PopoverContent>
</Popover>

View file

@ -0,0 +1,26 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "~/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
);
}
export { Separator };

View file

@ -0,0 +1,23 @@
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
// TODO: Use theme hook
const { theme = "system" } = {};
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
);
};
export default Toaster;

View file

@ -22,7 +22,7 @@ import {
import { useNavigate } from "react-router";
import { logout, type User } from "~/lib/api";
export function UserDropdown({ user }: { user: User }) {
export default function UserDropdown({ user }: { user: User }) {
// TODO: const { theme, setTheme } = useTheme();
const [theme, setTheme] = useState("light");
const [open, setOpen] = useState(false);

View file

@ -82,6 +82,50 @@ export const registerUser = async (
);
};
/**
* === USER API ===
*/
const fetchUser = async () => {
return makeRequest(
"/users/@me",
"Échec de la récupération des informations utilisateur"
);
};
const defaultUser = {
id: 0,
firstName: "",
lastName: "",
fullName: "",
email: "",
className: "",
};
export type User = typeof defaultUser;
export const useUser = () => {
const { data, ...props } = useQuery({
queryKey: ["user"],
queryFn: fetchUser,
staleTime: Duration.fromObject({
minutes: 5, // 5 minutes
}).toMillis(),
gcTime: Duration.fromObject({
days: 3, // 3 days
}).toMillis(),
});
return {
user: (data ? Object.assign(defaultUser, data) : defaultUser) as User,
...props,
};
};
export const logout = async () => {
// TODO: POST
// TODO: Invalidate user query (cache)
};
/**
* === COLLES API ===
*/
@ -161,49 +205,46 @@ export const useColles = (startDate: DateTime) => {
return {
...mergedData,
...props
}
...props,
};
};
/**
* === USER API ===
*/
const fetchUser = async () => {
return makeRequest(
"/users/@me",
"Échec de la récupération des informations utilisateur"
);
const fetchColle = async (id: number) => {
return makeRequest(`/colles/${id}`, "Échec de la récupération de la colle");
};
const defaultUser = {
const defaultColle = {
id: 0,
firstName: "",
lastName: "",
fullName: "",
email: "",
className: "",
date: "",
subject: {
id: 0,
name: "",
},
examiner: {
id: 0,
name: "",
},
room: {
id: 0,
name: "",
},
student: defaultUser,
attachments: [],
};
export type User = typeof defaultUser;
export const useUser = () => {
export const useColle = (id: number) => {
const { data, ...props } = useQuery({
queryKey: ["user"],
queryFn: fetchUser,
queryKey: ["colle", id],
queryFn: () => fetchColle(id),
staleTime: Duration.fromObject({
minutes: 5, // 5 minutes
seconds: 30, // 30 seconds
}).toMillis(),
gcTime: Duration.fromObject({
days: 3, // 3 days
}).toMillis(),
});
return {
user: (data ? Object.assign(defaultUser, data) : defaultUser) as User,
colle: (data || defaultColle) as Colle,
...props,
};
};
export const logout = async () => {
// TODO: POST
// TODO: Invalidate user query (cache)
};

View file

@ -1,109 +1,17 @@
import { QueryClient } from '@tanstack/react-query';
import { persistQueryClient } from '@tanstack/query-persist-client-core';
import { get, set, del } from 'idb-keyval';
import LZString from 'lz-string';
import { get, set, del } from "idb-keyval";
import { QueryClient } from "@tanstack/react-query";
import { persistQueryClient } from "@tanstack/react-query-persist-client";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
const CACHE_KEY = 'khollise-cache'; // Key for IndexedDB storage
const CACHE_KEY = "khollise-cache"; // Key for IndexedDB storage
// Check if we're in a browser environment with IndexedDB support
const isIndexedDBAvailable = () => {
return typeof window !== 'undefined' &&
typeof window.indexedDB !== 'undefined' &&
window.indexedDB !== null;
};
// Custom IndexedDB persister with LZ-string compression
const createIDBPersister = () => {
// Return a no-op persister if IndexedDB is not available
if (!isIndexedDBAvailable()) {
console.warn('IndexedDB not available - cache persistence disabled');
return {
persistClient: async () => { },
restoreClient: async () => undefined,
removeClient: async () => { },
};
}
return {
persistClient: async (client: any) => {
try {
// Double-check IndexedDB availability before operation
if (!isIndexedDBAvailable()) {
console.warn('IndexedDB not available during persist operation');
return;
}
// Serialize the client data
const serializedClient = JSON.stringify(client);
// Compress the serialized data
const compressedData = LZString.compress(serializedClient);
// Store compressed data in IndexedDB
await set(CACHE_KEY, compressedData);
} catch (error) {
console.error('Failed to persist client cache:', error);
}
},
restoreClient: async () => {
try {
// Double-check IndexedDB availability before operation
if (!isIndexedDBAvailable()) {
console.warn('IndexedDB not available during restore operation');
return undefined;
}
// Get compressed data from IndexedDB
const compressedData = await get(CACHE_KEY);
if (!compressedData) {
console.log('No cached data found in IndexedDB');
return undefined;
}
// Decompress the data
const decompressedData = LZString.decompress(compressedData);
if (!decompressedData) {
console.warn('Failed to decompress cached data');
return undefined;
}
// Parse and return the client data
const client = JSON.parse(decompressedData);
console.log('Cache restored from IndexedDB');
return client;
} catch (error) {
console.error('Failed to restore client cache:', error);
// Clear corrupted cache if IndexedDB is available
if (isIndexedDBAvailable()) {
try {
await del(CACHE_KEY);
} catch (delError) {
console.error('Failed to clear corrupted cache:', delError);
}
}
return undefined;
}
},
removeClient: async () => {
try {
// Double-check IndexedDB availability before operation
if (!isIndexedDBAvailable()) {
console.warn('IndexedDB not available during remove operation');
return;
}
await del(CACHE_KEY);
console.log('Cache cleared from IndexedDB');
} catch (error) {
console.error('Failed to remove client cache:', error);
}
},
};
return (
typeof window !== "undefined" &&
typeof window.indexedDB !== "undefined" &&
window.indexedDB !== null
);
};
// Create QueryClient with persistence
@ -123,12 +31,15 @@ const createQueryClient = () => {
if (isIndexedDBAvailable()) {
persistQueryClient({
queryClient,
persister: createIDBPersister(),
persister: createAsyncStoragePersister({
storage: { getItem: get, setItem: set, removeItem: del },
key: CACHE_KEY,
}),
maxAge: 1000 * 60 * 60 * 24, // 24 hours
buster: 'v1', // Change this to invalidate cache
})
buster: "v1", // Change this to invalidate cache
});
} else {
console.warn('Cache persistence disabled - IndexedDB not available');
console.warn("Cache persistence disabled - IndexedDB not available");
}
return queryClient;

33
app/lib/latex.ts Normal file
View file

@ -0,0 +1,33 @@
export function extractLatexImages(html: string) {
const imgRegex =
/<img[^>]+src="(https:\/\/latex\.codecogs\.com\/gif\.latex\?(=?.*?))"[^>]*>/g;
let parts = [];
let latexMatches: string[] = [];
let lastIndex = 0;
html.replace(imgRegex, (match, _, latex, index) => {
parts.push(html.slice(lastIndex, index)); // Add HTML before image
latexMatches.push(decodeURIComponent(latex)); // Extract and decode LaTeX
lastIndex = index + match.length;
return "";
});
parts.push(html.slice(lastIndex)); // Add remaining HTML after last image
return { parts, latexMatches };
}
export function renderLatex(html: string) {
const { parts, latexMatches } = extractLatexImages(html);
const outputHtml = parts
.map((part, i) => {
if (!latexMatches[i]) {
return part;
}
return `${part}$$${latexMatches[i]}$$`;
})
.join("");
// Remove all "\," from string
const regex = /\\,/g;
return outputHtml.replace(regex, " ");
}

View file

@ -1,10 +1,15 @@
import { type ClassValue, clsx } from "clsx";
import { DateTime } from "luxon";
import type { NavigateFunction } from "react-router";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* === STRING UTILS ===
*/
export function capitalizeFirstLetter(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
@ -16,7 +21,60 @@ export function titleCase(str: string) {
);
}
/**
* === NAVIGATION UTILS ===
*/
// Force a reload of the page
export function forceReload() {
window.location.reload();
}
export function goBack(navigate: NavigateFunction) {
let canGoBack = false;
// Check if there is a history stack to go back to
try {
canGoBack = window.history.length > 1;
} catch (e) {
canGoBack = false;
}
if (!canGoBack) {
return navigate("/");
} else {
return navigate(-1);
}
}
/**
* === COLLES UTILS ===
*/
export const formatDate = (date: string) => {
const dt = DateTime.fromISO(date).setLocale("fr");
const str = dt.toLocaleString({
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
return titleCase(str);
};
export const formatTime = (date: string) => {
const dt = DateTime.fromISO(date).setLocale("fr");
return dt.toLocaleString({
hour: "2-digit",
minute: "2-digit",
});
};
export const formatGrade = (grade?: number) => {
if (grade === undefined || grade === null || grade < 0 || grade > 20)
return "N/A";
const rounded = Math.round(grade * 10) / 10;
const str =
rounded % 1 === 0
? rounded.toFixed(0) // no decimals if .0
: rounded.toFixed(1); // one decimal otherwise
return str.replace(".", ",").padStart(2, "0"); // pad with zero if needed
};

View file

@ -10,7 +10,8 @@ import {
import type { Route } from "./+types/root";
import "./app.css";
import { QueryClientProvider } from "@tanstack/react-query";
import queryClient from "./lib/client";
import queryClient from "~/lib/client";
import Toaster from "~/components/ui/sonner";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
@ -31,10 +32,19 @@ export function Layout({ children }: { children: React.ReactNode }) {
<head>
<meta charSet="utf-8" />
{/* Favicon */}
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link
rel="icon"
type="image/png"
href="/favicon-96x96.png"
sizes="96x96"
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<Meta />
<Links />
@ -43,6 +53,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
<Toaster />
<ScrollRestoration />
<Scripts />
</body>

View file

@ -5,4 +5,5 @@ export default [
route("/login", "routes/login.tsx"),
route("/verify", "routes/verify.tsx"),
route("/register", "routes/register.tsx"),
route("/colles/:colleId", "routes/colles.tsx"),
] satisfies RouteConfig;

463
app/routes/colles.tsx Normal file
View file

@ -0,0 +1,463 @@
import "katex/dist/katex.min.css";
import { DomUtils, parseDocument } from "htmlparser2";
// TODO: API - remove trailing lines from HTML comment/content
// TODO: Server side image extraction and latex rendering
// TEMP SOLUTION
import { renderLatex } from "~/lib/latex"; // Custom LaTeX rendering function
// function removeTrailingLines(htmlString: string) {
// return htmlString.replace(/(<br\s*\/?>\s*)+$/gi, "").trim();
// }
import Latex from "react-latex-next";
import { useState } from "react";
import { 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,
Users,
RefreshCw,
Share2,
ExternalLink,
} from "lucide-react";
import { Separator } from "~/components/ui/separator";
import UserDropdown from "~/components/user-dropdown";
import ColleDetailsSkeleton from "~/components/details/skeleton-details";
import AttachmentItem from "~/components/details/attachment";
// TODO: Scroll restoration
// import { ScrollToTopOnMount } from "~/components/noscroll";
import Error from "~/components/error";
import { Badge } from "~/components/ui/badge";
import { useColle, useUser } from "~/lib/api";
import { toast } from "sonner";
import { formatDate, formatGrade, formatTime, goBack } from "~/lib/utils";
// TODO: Preferences for subject colors
const getSubjectColor = (_: string) => {
// Mock placeholder function
return "bg-blue-100 text-blue-800"; // Default color
};
const getSubjectEmoji = (_: string) => {
// Mock placeholder function
return "📚"; // Default emoji
};
// TODO: Move all code to components
export default function ColleDetailPage() {
const { user, isLoading: isUserLoading, error: userError } = useUser();
const navigate = useNavigate();
const params = useParams<{ colleId: string }>();
const [isReloading, setIsReloading] = useState(false);
// TODO: Handle user loading state
if (userError) {
console.error(userError);
return <Navigate to="/login" />;
}
// TODO: Favorite toggle function
const toggleStar = () => {};
const colleId = parseInt(params.colleId!);
if (isNaN(colleId)) {
return <Navigate to="/" />;
}
const { colle, error, isLoading } = useColle(colleId);
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);
// TODO: HARD RELOAD
};
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 = getSubjectColor(colle.subject.name);
const subjectEmoji = getSubjectEmoji(colle.subject.name);
return (
<div className="container mx-auto py-6 px-4 pb-20 md:pb-6 md:py-8">
{/* <ScrollToTopOnMount /> */}
<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="hidden md:block">
<UserDropdown user={user} />
</div>
<div className="flex md:hidden items-center gap-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>
<UserDropdown user={user} />
</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-2xl">Résumé de Colle</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-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-4">
<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-4">
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-1">
Étudiant
</h3>
<div className="flex items-center gap-2">
<User className="h-4 w-4" />
<span>{colle.student.fullName}</span>
</div>
</div>
{/* TODO: Colles groups - others students */}
{/* {colle.group?.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-1">
Autres élèves
</h3>
{colle.group.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}
</Link>
</div>
))}
</div>
)} */}
</div>
{colle.room && (
<div className="space-y-4">
<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>
</>
)}
{/* TODO: Attachments */}
{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" />
Attachments
</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>
{/* Mobile Action Bar - Fixed at Bottom */}
<div className="fixed bottom-0 left-0 right-0 bg-background border-t border-border md:hidden z-50">
<div className="container mx-auto px-4 py-2 flex items-center justify-around">
<Button
variant="ghost"
size="lg"
className={`flex-1 h-12 ${
// TODO: Use a proper favorite state
false ? "text-yellow-500" : "text-muted-foreground"
}`}
onClick={handleToggleFavorite}
>
<Star
className={`h-5 w-5 mr-2 ${
// TODO: Use a proper favorite state
false ? "fill-yellow-500" : ""
}`}
/>
{/* TODO: */}
{false ? "Favori" : "Ajouter"}
</Button>
<Separator orientation="vertical" className="h-8" />
<Button
variant="ghost"
size="lg"
className="flex-1 h-12"
onClick={handleShare}
>
<Share2 className="h-5 w-5 mr-2" />
Partager
</Button>
</div>
</div>
</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 }]}>
{renderLatex(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();
}

View file

@ -1,5 +1,5 @@
import HomePage from "~/components/home";
import { UserDropdown } from "~/components/user-dropdown";
import UserDropdown from "~/components/user-dropdown";
import { MainLayout } from "~/layout";
import { useUser } from "~/lib/api";
import { forceReload } from "~/lib/utils";

View file

@ -18,27 +18,32 @@
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"@react-router/node": "^7.5.3",
"@react-router/serve": "^7.5.3",
"@tanstack/query-persist-client-core": "^5.83.0",
"@tanstack/query-async-storage-persister": "^5.83.0",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-query-persist-client": "^5.83.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"htmlparser2": "^10.0.0",
"idb-keyval": "^6.2.2",
"input-otp": "^1.4.2",
"isbot": "^5.1.27",
"katex": "^0.16.22",
"lucide-react": "^0.511.0",
"luxon": "^3.7.1",
"lz-string": "^1.5.0",
"netlify-cli": "^22.1.3",
"react": "^19.1.0",
"react-day-picker": "^9.8.1",
"react-dom": "^19.1.0",
"react-latex-next": "^3.0.0",
"react-router": "^7.5.3",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.0",
"tw-animate-css": "^1.3.0"
},

124
pnpm-lock.yaml generated
View file

@ -32,6 +32,9 @@ importers:
'@radix-ui/react-select':
specifier: ^2.2.5
version: 2.2.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-separator':
specifier: ^1.1.7
version: 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-slot':
specifier: ^1.2.3
version: 1.2.3(@types/react@19.1.8)(react@19.1.1)
@ -44,12 +47,15 @@ importers:
'@react-router/serve':
specifier: ^7.5.3
version: 7.7.1(react-router@7.7.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(typescript@5.8.3)
'@tanstack/query-persist-client-core':
'@tanstack/query-async-storage-persister':
specifier: ^5.83.0
version: 5.83.0
'@tanstack/react-query':
specifier: ^5.83.0
version: 5.83.0(react@19.1.1)
'@tanstack/react-query-persist-client':
specifier: ^5.83.0
version: 5.83.0(@tanstack/react-query@5.83.0(react@19.1.1))(react@19.1.1)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@ -62,6 +68,9 @@ importers:
date-fns:
specifier: ^4.1.0
version: 4.1.0
htmlparser2:
specifier: ^10.0.0
version: 10.0.0
idb-keyval:
specifier: ^6.2.2
version: 6.2.2
@ -71,15 +80,15 @@ importers:
isbot:
specifier: ^5.1.27
version: 5.1.28
katex:
specifier: ^0.16.22
version: 0.16.22
lucide-react:
specifier: ^0.511.0
version: 0.511.0(react@19.1.1)
luxon:
specifier: ^3.7.1
version: 3.7.1
lz-string:
specifier: ^1.5.0
version: 1.5.0
netlify-cli:
specifier: ^22.1.3
version: 22.4.0(@types/node@20.19.9)(idb-keyval@6.2.2)(picomatch@4.0.3)(rollup@4.46.1)
@ -92,9 +101,15 @@ importers:
react-dom:
specifier: ^19.1.0
version: 19.1.1(react@19.1.1)
react-latex-next:
specifier: ^3.0.0
version: 3.0.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react-router:
specifier: ^7.5.3
version: 7.7.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
sonner:
specifier: ^2.0.6
version: 2.0.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
tailwind-merge:
specifier: ^3.3.0
version: 3.3.1
@ -1488,6 +1503,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-separator@1.1.7':
resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
@ -1875,12 +1903,21 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@tanstack/query-async-storage-persister@5.83.0':
resolution: {integrity: sha512-kONVCkTndW+UDspJUQKbd4hCdun+uIn2RXxpzlXYWxlefIfNjcIvefJ0+7xpap6nDqRLXIuaMaCZmFFw56uE+Q==}
'@tanstack/query-core@5.83.0':
resolution: {integrity: sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==}
'@tanstack/query-persist-client-core@5.83.0':
resolution: {integrity: sha512-hdKgHkr1MYnwZX+QHj/9JjXZx9gL2RUCD5xSX0EHZiqUQhMk4Gcryq9xosn8LmYRMlhkjk7n9uV+X4UXRvgoIg==}
'@tanstack/react-query-persist-client@5.83.0':
resolution: {integrity: sha512-uEqJnSbqlvzlhYJ+RU+2c2DmbbT7cw6eFjiewEXZFXaSGWNjvUG02LePrwL8cdLlRQFcZKas30IdckboOoVg9Q==}
peerDependencies:
'@tanstack/react-query': ^5.83.0
react: ^18 || ^19
'@tanstack/react-query@5.83.0':
resolution: {integrity: sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ==}
peerDependencies:
@ -2428,6 +2465,10 @@ packages:
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
commander@8.3.0:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
commander@9.5.0:
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
engines: {node: ^12.20.0 || >=14}
@ -2777,6 +2818,10 @@ packages:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
env-paths@3.0.0:
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@ -3229,6 +3274,9 @@ packages:
resolution: {integrity: sha512-D4iAs/145g7EJ/wIzBLVANEpysTPthUy/K+4EUIw02YJQTqvzD1vUpYiM3vwR0qPAQj4FhQpQz8wBpY8KDcM0g==}
engines: {node: '>=10.0.0'}
htmlparser2@10.0.0:
resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==}
http-cache-semantics@4.2.0:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
@ -3550,6 +3598,10 @@ packages:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
katex@0.16.22:
resolution: {integrity: sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==}
hasBin: true
keep-func-props@6.0.0:
resolution: {integrity: sha512-XDYA44ccm6W2MXZeQcDZykS5srkTpPf6Z59AEuOFbfuqdQ5TVxhAjxgzAEFBpr8XpsCEgr/XeCBFAmc9x6wRmQ==}
engines: {node: '>=16.17.0'}
@ -3735,10 +3787,6 @@ packages:
resolution: {integrity: sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==}
engines: {node: '>=12'}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
macos-release@3.4.0:
resolution: {integrity: sha512-wpGPwyg/xrSp4H4Db4xYSeAr6+cFQGHfspHzDUdYxswDnUW0L5Ov63UuJiSr8NMSpyaChO4u1n0MXUvVPtrN6A==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@ -4394,6 +4442,13 @@ packages:
peerDependencies:
react: ^19.1.1
react-latex-next@3.0.0:
resolution: {integrity: sha512-x70f1b1G7TronVigsRgKHKYYVUNfZk/3bciFyYX1lYLQH2y3/TXku3+5Vap8MDbJhtopePSYBsYWS6jhzIdz+g==}
engines: {node: '>=12', npm: '>=5'}
peerDependencies:
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
react-refresh@0.14.2:
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
engines: {node: '>=0.10.0'}
@ -4681,6 +4736,12 @@ packages:
sonic-boom@4.2.0:
resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
sonner@2.0.6:
resolution: {integrity: sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
sort-keys-length@1.0.1:
resolution: {integrity: sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==}
engines: {node: '>=0.10.0'}
@ -6777,6 +6838,15 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-separator@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-slot@1.2.3(@types/react@19.1.8)(react@19.1.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.1)
@ -7109,12 +7179,22 @@ snapshots:
tailwindcss: 4.1.11
vite: 6.3.5(@types/node@20.19.9)(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.0)
'@tanstack/query-async-storage-persister@5.83.0':
dependencies:
'@tanstack/query-persist-client-core': 5.83.0
'@tanstack/query-core@5.83.0': {}
'@tanstack/query-persist-client-core@5.83.0':
dependencies:
'@tanstack/query-core': 5.83.0
'@tanstack/react-query-persist-client@5.83.0(@tanstack/react-query@5.83.0(react@19.1.1))(react@19.1.1)':
dependencies:
'@tanstack/query-persist-client-core': 5.83.0
'@tanstack/react-query': 5.83.0(react@19.1.1)
react: 19.1.1
'@tanstack/react-query@5.83.0(react@19.1.1)':
dependencies:
'@tanstack/query-core': 5.83.0
@ -7732,6 +7812,8 @@ snapshots:
commander@2.20.3: {}
commander@8.3.0: {}
commander@9.5.0: {}
comment-json@4.2.5:
@ -8060,6 +8142,8 @@ snapshots:
entities@4.5.0: {}
entities@6.0.1: {}
env-paths@3.0.0: {}
envinfo@7.14.0: {}
@ -8598,6 +8682,13 @@ snapshots:
optionalDependencies:
unix-dgram: 2.0.6
htmlparser2@10.0.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 6.0.1
http-cache-semantics@4.2.0: {}
http-errors@1.8.1:
@ -8907,6 +8998,10 @@ snapshots:
jwt-decode@4.0.0: {}
katex@0.16.22:
dependencies:
commander: 8.3.0
keep-func-props@6.0.0:
dependencies:
mimic-fn: 4.0.0
@ -9082,8 +9177,6 @@ snapshots:
luxon@3.7.1: {}
lz-string@1.5.0: {}
macos-release@3.4.0: {}
magic-string@0.30.17:
@ -9810,6 +9903,12 @@ snapshots:
react: 19.1.1
scheduler: 0.26.0
react-latex-next@3.0.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
katex: 0.16.22
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
react-refresh@0.14.2: {}
react-remove-scroll-bar@2.3.8(@types/react@19.1.8)(react@19.1.1):
@ -10142,6 +10241,11 @@ snapshots:
dependencies:
atomic-sleep: 1.0.0
sonner@2.0.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
sort-keys-length@1.0.1:
dependencies:
sort-keys: 1.1.2