feat: add colle details page
All checks were successful
Deploy to Netlify / Deploy to Netlify (push) Successful in 1m39s
All checks were successful
Deploy to Netlify / Deploy to Netlify (push) Successful in 1m39s
This commit is contained in:
parent
f988f8b7e7
commit
85e2552db8
18 changed files with 979 additions and 190 deletions
52
app/components/details/attachment.tsx
Normal file
52
app/components/details/attachment.tsx
Normal 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("_");
|
||||||
|
};
|
||||||
92
app/components/details/skeleton-details.tsx
Normal file
92
app/components/details/skeleton-details.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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.",
|
description = "La page que vous recherchez a peut-être été supprimée, son nom a été modifié ou est temporairement indisponible.",
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<Card className="max-w-md w-full">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
|
|
@ -36,9 +36,9 @@ export default function Error({
|
||||||
<ChevronLeft />
|
<ChevronLeft />
|
||||||
<Link to="/">Retour</Link>
|
<Link to="/">Retour</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={forceReload}>
|
<Button variant="destructive" className="text-white" onClick={forceReload}>
|
||||||
<RotateCw />
|
<RotateCw />
|
||||||
Réessayer
|
Recharger la page
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import type { Colle } from "~/lib/api";
|
import type { Colle } from "~/lib/api";
|
||||||
|
|
||||||
import { DateTime } from "luxon";
|
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -12,7 +11,7 @@ import {
|
||||||
import { User, UserCheck, Paperclip, Star, MapPinHouse } from "lucide-react";
|
import { User, UserCheck, Paperclip, Star, MapPinHouse } from "lucide-react";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { titleCase } from "~/lib/utils";
|
import { formatDate, formatGrade, formatTime } from "~/lib/utils";
|
||||||
|
|
||||||
// TODO: Preferences for subject colors
|
// TODO: Preferences for subject colors
|
||||||
const getSubjectColor = (_: string) => {
|
const getSubjectColor = (_: string) => {
|
||||||
|
|
@ -132,47 +131,15 @@ export default function ColleCard({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* TODO: Attachments */}
|
{/* TODO: Attachments */}
|
||||||
{colle.attachmentsCount > 0 && (
|
{/* {colle.attachmentsCount > 0 && (
|
||||||
<div className="flex items-center gap-1 text-muted-foreground">
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
<Paperclip className="h-3.5 w-3.5" />
|
<Paperclip className="h-3.5 w-3.5" />
|
||||||
<span className="text-xs">{colle.attachmentsCount}</span>
|
<span className="text-xs">{colle.attachmentsCount}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} */}
|
||||||
</div>
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</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
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ export default function DatePickerWithRange({
|
||||||
onSelect={handleDateSelect}
|
onSelect={handleDateSelect}
|
||||||
weekStartsOn={1}
|
weekStartsOn={1}
|
||||||
locale={fr}
|
locale={fr}
|
||||||
|
className="rounded-md border shadow-sm"
|
||||||
|
captionLayout="dropdown"
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
|
||||||
26
app/components/ui/separator.tsx
Normal file
26
app/components/ui/separator.tsx
Normal 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 };
|
||||||
23
app/components/ui/sonner.tsx
Normal file
23
app/components/ui/sonner.tsx
Normal 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;
|
||||||
|
|
@ -22,7 +22,7 @@ import {
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { logout, type User } from "~/lib/api";
|
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();
|
// TODO: const { theme, setTheme } = useTheme();
|
||||||
const [theme, setTheme] = useState("light");
|
const [theme, setTheme] = useState("light");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -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 ===
|
* === COLLES API ===
|
||||||
*/
|
*/
|
||||||
|
|
@ -161,49 +205,46 @@ export const useColles = (startDate: DateTime) => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...mergedData,
|
...mergedData,
|
||||||
...props
|
...props,
|
||||||
}
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const fetchColle = async (id: number) => {
|
||||||
* === USER API ===
|
return makeRequest(`/colles/${id}`, "Échec de la récupération de la colle");
|
||||||
*/
|
|
||||||
const fetchUser = async () => {
|
|
||||||
return makeRequest(
|
|
||||||
"/users/@me",
|
|
||||||
"Échec de la récupération des informations utilisateur"
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultUser = {
|
const defaultColle = {
|
||||||
id: 0,
|
id: 0,
|
||||||
firstName: "",
|
date: "",
|
||||||
lastName: "",
|
subject: {
|
||||||
fullName: "",
|
id: 0,
|
||||||
email: "",
|
name: "",
|
||||||
className: "",
|
},
|
||||||
|
examiner: {
|
||||||
|
id: 0,
|
||||||
|
name: "",
|
||||||
|
},
|
||||||
|
room: {
|
||||||
|
id: 0,
|
||||||
|
name: "",
|
||||||
|
},
|
||||||
|
student: defaultUser,
|
||||||
|
attachments: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export type User = typeof defaultUser;
|
export const useColle = (id: number) => {
|
||||||
|
|
||||||
export const useUser = () => {
|
|
||||||
const { data, ...props } = useQuery({
|
const { data, ...props } = useQuery({
|
||||||
queryKey: ["user"],
|
queryKey: ["colle", id],
|
||||||
queryFn: fetchUser,
|
queryFn: () => fetchColle(id),
|
||||||
staleTime: Duration.fromObject({
|
staleTime: Duration.fromObject({
|
||||||
minutes: 5, // 5 minutes
|
seconds: 30, // 30 seconds
|
||||||
}).toMillis(),
|
}).toMillis(),
|
||||||
gcTime: Duration.fromObject({
|
gcTime: Duration.fromObject({
|
||||||
days: 3, // 3 days
|
days: 3, // 3 days
|
||||||
}).toMillis(),
|
}).toMillis(),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
user: (data ? Object.assign(defaultUser, data) : defaultUser) as User,
|
colle: (data || defaultColle) as Colle,
|
||||||
...props,
|
...props,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logout = async () => {
|
|
||||||
// TODO: POST
|
|
||||||
// TODO: Invalidate user query (cache)
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,109 +1,17 @@
|
||||||
import { QueryClient } from '@tanstack/react-query';
|
import { get, set, del } from "idb-keyval";
|
||||||
import { persistQueryClient } from '@tanstack/query-persist-client-core';
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { get, set, del } from 'idb-keyval';
|
import { persistQueryClient } from "@tanstack/react-query-persist-client";
|
||||||
import LZString from 'lz-string';
|
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
|
// Check if we're in a browser environment with IndexedDB support
|
||||||
const isIndexedDBAvailable = () => {
|
const isIndexedDBAvailable = () => {
|
||||||
return typeof window !== 'undefined' &&
|
return (
|
||||||
typeof window.indexedDB !== 'undefined' &&
|
typeof window !== "undefined" &&
|
||||||
window.indexedDB !== null;
|
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create QueryClient with persistence
|
// Create QueryClient with persistence
|
||||||
|
|
@ -123,12 +31,15 @@ const createQueryClient = () => {
|
||||||
if (isIndexedDBAvailable()) {
|
if (isIndexedDBAvailable()) {
|
||||||
persistQueryClient({
|
persistQueryClient({
|
||||||
queryClient,
|
queryClient,
|
||||||
persister: createIDBPersister(),
|
persister: createAsyncStoragePersister({
|
||||||
|
storage: { getItem: get, setItem: set, removeItem: del },
|
||||||
|
key: CACHE_KEY,
|
||||||
|
}),
|
||||||
maxAge: 1000 * 60 * 60 * 24, // 24 hours
|
maxAge: 1000 * 60 * 60 * 24, // 24 hours
|
||||||
buster: 'v1', // Change this to invalidate cache
|
buster: "v1", // Change this to invalidate cache
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
console.warn('Cache persistence disabled - IndexedDB not available');
|
console.warn("Cache persistence disabled - IndexedDB not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
return queryClient;
|
return queryClient;
|
||||||
|
|
|
||||||
33
app/lib/latex.ts
Normal file
33
app/lib/latex.ts
Normal 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, " ");
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
import { type ClassValue, clsx } from "clsx";
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import type { NavigateFunction } from "react-router";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* === STRING UTILS ===
|
||||||
|
*/
|
||||||
export function capitalizeFirstLetter(str: string): string {
|
export function capitalizeFirstLetter(str: string): string {
|
||||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
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
|
// Force a reload of the page
|
||||||
export function forceReload() {
|
export function forceReload() {
|
||||||
window.location.reload();
|
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
|
||||||
|
};
|
||||||
|
|
|
||||||
17
app/root.tsx
17
app/root.tsx
|
|
@ -10,7 +10,8 @@ import {
|
||||||
import type { Route } from "./+types/root";
|
import type { Route } from "./+types/root";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
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 = () => [
|
export const links: Route.LinksFunction = () => [
|
||||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||||
|
|
@ -31,10 +32,19 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
{/* Favicon */}
|
{/* 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="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<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 name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links />
|
<Links />
|
||||||
|
|
@ -43,6 +53,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{children}
|
{children}
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
<Toaster />
|
||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,5 @@ export default [
|
||||||
route("/login", "routes/login.tsx"),
|
route("/login", "routes/login.tsx"),
|
||||||
route("/verify", "routes/verify.tsx"),
|
route("/verify", "routes/verify.tsx"),
|
||||||
route("/register", "routes/register.tsx"),
|
route("/register", "routes/register.tsx"),
|
||||||
|
route("/colles/:colleId", "routes/colles.tsx"),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|
|
||||||
463
app/routes/colles.tsx
Normal file
463
app/routes/colles.tsx
Normal 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();
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import HomePage from "~/components/home";
|
import HomePage from "~/components/home";
|
||||||
import { UserDropdown } from "~/components/user-dropdown";
|
import UserDropdown from "~/components/user-dropdown";
|
||||||
import { MainLayout } from "~/layout";
|
import { MainLayout } from "~/layout";
|
||||||
import { useUser } from "~/lib/api";
|
import { useUser } from "~/lib/api";
|
||||||
import { forceReload } from "~/lib/utils";
|
import { forceReload } from "~/lib/utils";
|
||||||
|
|
|
||||||
|
|
@ -18,27 +18,32 @@
|
||||||
"@radix-ui/react-label": "^2.1.6",
|
"@radix-ui/react-label": "^2.1.6",
|
||||||
"@radix-ui/react-popover": "^1.1.14",
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@react-router/node": "^7.5.3",
|
"@react-router/node": "^7.5.3",
|
||||||
"@react-router/serve": "^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": "^5.83.0",
|
||||||
|
"@tanstack/react-query-persist-client": "^5.83.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"htmlparser2": "^10.0.0",
|
||||||
"idb-keyval": "^6.2.2",
|
"idb-keyval": "^6.2.2",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"isbot": "^5.1.27",
|
"isbot": "^5.1.27",
|
||||||
|
"katex": "^0.16.22",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"luxon": "^3.7.1",
|
"luxon": "^3.7.1",
|
||||||
"lz-string": "^1.5.0",
|
|
||||||
"netlify-cli": "^22.1.3",
|
"netlify-cli": "^22.1.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-day-picker": "^9.8.1",
|
"react-day-picker": "^9.8.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-latex-next": "^3.0.0",
|
||||||
"react-router": "^7.5.3",
|
"react-router": "^7.5.3",
|
||||||
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.3.0",
|
"tailwind-merge": "^3.3.0",
|
||||||
"tw-animate-css": "^1.3.0"
|
"tw-animate-css": "^1.3.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
124
pnpm-lock.yaml
generated
124
pnpm-lock.yaml
generated
|
|
@ -32,6 +32,9 @@ importers:
|
||||||
'@radix-ui/react-select':
|
'@radix-ui/react-select':
|
||||||
specifier: ^2.2.5
|
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)
|
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':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.2.3
|
specifier: ^1.2.3
|
||||||
version: 1.2.3(@types/react@19.1.8)(react@19.1.1)
|
version: 1.2.3(@types/react@19.1.8)(react@19.1.1)
|
||||||
|
|
@ -44,12 +47,15 @@ importers:
|
||||||
'@react-router/serve':
|
'@react-router/serve':
|
||||||
specifier: ^7.5.3
|
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)
|
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
|
specifier: ^5.83.0
|
||||||
version: 5.83.0
|
version: 5.83.0
|
||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.83.0
|
specifier: ^5.83.0
|
||||||
version: 5.83.0(react@19.1.1)
|
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:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
|
|
@ -62,6 +68,9 @@ importers:
|
||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
|
htmlparser2:
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0
|
||||||
idb-keyval:
|
idb-keyval:
|
||||||
specifier: ^6.2.2
|
specifier: ^6.2.2
|
||||||
version: 6.2.2
|
version: 6.2.2
|
||||||
|
|
@ -71,15 +80,15 @@ importers:
|
||||||
isbot:
|
isbot:
|
||||||
specifier: ^5.1.27
|
specifier: ^5.1.27
|
||||||
version: 5.1.28
|
version: 5.1.28
|
||||||
|
katex:
|
||||||
|
specifier: ^0.16.22
|
||||||
|
version: 0.16.22
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.511.0
|
specifier: ^0.511.0
|
||||||
version: 0.511.0(react@19.1.1)
|
version: 0.511.0(react@19.1.1)
|
||||||
luxon:
|
luxon:
|
||||||
specifier: ^3.7.1
|
specifier: ^3.7.1
|
||||||
version: 3.7.1
|
version: 3.7.1
|
||||||
lz-string:
|
|
||||||
specifier: ^1.5.0
|
|
||||||
version: 1.5.0
|
|
||||||
netlify-cli:
|
netlify-cli:
|
||||||
specifier: ^22.1.3
|
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)
|
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:
|
react-dom:
|
||||||
specifier: ^19.1.0
|
specifier: ^19.1.0
|
||||||
version: 19.1.1(react@19.1.1)
|
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:
|
react-router:
|
||||||
specifier: ^7.5.3
|
specifier: ^7.5.3
|
||||||
version: 7.7.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
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:
|
tailwind-merge:
|
||||||
specifier: ^3.3.0
|
specifier: ^3.3.0
|
||||||
version: 3.3.1
|
version: 3.3.1
|
||||||
|
|
@ -1488,6 +1503,19 @@ packages:
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
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':
|
'@radix-ui/react-slot@1.2.3':
|
||||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -1875,12 +1903,21 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^5.2.0 || ^6 || ^7
|
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':
|
'@tanstack/query-core@5.83.0':
|
||||||
resolution: {integrity: sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==}
|
resolution: {integrity: sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==}
|
||||||
|
|
||||||
'@tanstack/query-persist-client-core@5.83.0':
|
'@tanstack/query-persist-client-core@5.83.0':
|
||||||
resolution: {integrity: sha512-hdKgHkr1MYnwZX+QHj/9JjXZx9gL2RUCD5xSX0EHZiqUQhMk4Gcryq9xosn8LmYRMlhkjk7n9uV+X4UXRvgoIg==}
|
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':
|
'@tanstack/react-query@5.83.0':
|
||||||
resolution: {integrity: sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ==}
|
resolution: {integrity: sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -2428,6 +2465,10 @@ packages:
|
||||||
commander@2.20.3:
|
commander@2.20.3:
|
||||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
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:
|
commander@9.5.0:
|
||||||
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
|
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
|
||||||
engines: {node: ^12.20.0 || >=14}
|
engines: {node: ^12.20.0 || >=14}
|
||||||
|
|
@ -2777,6 +2818,10 @@ packages:
|
||||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
entities@6.0.1:
|
||||||
|
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
env-paths@3.0.0:
|
env-paths@3.0.0:
|
||||||
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
|
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
|
@ -3229,6 +3274,9 @@ packages:
|
||||||
resolution: {integrity: sha512-D4iAs/145g7EJ/wIzBLVANEpysTPthUy/K+4EUIw02YJQTqvzD1vUpYiM3vwR0qPAQj4FhQpQz8wBpY8KDcM0g==}
|
resolution: {integrity: sha512-D4iAs/145g7EJ/wIzBLVANEpysTPthUy/K+4EUIw02YJQTqvzD1vUpYiM3vwR0qPAQj4FhQpQz8wBpY8KDcM0g==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
|
htmlparser2@10.0.0:
|
||||||
|
resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==}
|
||||||
|
|
||||||
http-cache-semantics@4.2.0:
|
http-cache-semantics@4.2.0:
|
||||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||||
|
|
||||||
|
|
@ -3550,6 +3598,10 @@ packages:
|
||||||
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
|
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
katex@0.16.22:
|
||||||
|
resolution: {integrity: sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
keep-func-props@6.0.0:
|
keep-func-props@6.0.0:
|
||||||
resolution: {integrity: sha512-XDYA44ccm6W2MXZeQcDZykS5srkTpPf6Z59AEuOFbfuqdQ5TVxhAjxgzAEFBpr8XpsCEgr/XeCBFAmc9x6wRmQ==}
|
resolution: {integrity: sha512-XDYA44ccm6W2MXZeQcDZykS5srkTpPf6Z59AEuOFbfuqdQ5TVxhAjxgzAEFBpr8XpsCEgr/XeCBFAmc9x6wRmQ==}
|
||||||
engines: {node: '>=16.17.0'}
|
engines: {node: '>=16.17.0'}
|
||||||
|
|
@ -3735,10 +3787,6 @@ packages:
|
||||||
resolution: {integrity: sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==}
|
resolution: {integrity: sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
lz-string@1.5.0:
|
|
||||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
macos-release@3.4.0:
|
macos-release@3.4.0:
|
||||||
resolution: {integrity: sha512-wpGPwyg/xrSp4H4Db4xYSeAr6+cFQGHfspHzDUdYxswDnUW0L5Ov63UuJiSr8NMSpyaChO4u1n0MXUvVPtrN6A==}
|
resolution: {integrity: sha512-wpGPwyg/xrSp4H4Db4xYSeAr6+cFQGHfspHzDUdYxswDnUW0L5Ov63UuJiSr8NMSpyaChO4u1n0MXUvVPtrN6A==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
|
@ -4394,6 +4442,13 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.1.1
|
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:
|
react-refresh@0.14.2:
|
||||||
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
|
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -4681,6 +4736,12 @@ packages:
|
||||||
sonic-boom@4.2.0:
|
sonic-boom@4.2.0:
|
||||||
resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
|
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:
|
sort-keys-length@1.0.1:
|
||||||
resolution: {integrity: sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==}
|
resolution: {integrity: sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -6777,6 +6838,15 @@ snapshots:
|
||||||
'@types/react': 19.1.8
|
'@types/react': 19.1.8
|
||||||
'@types/react-dom': 19.1.6(@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)':
|
'@radix-ui/react-slot@1.2.3(@types/react@19.1.8)(react@19.1.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.1)
|
'@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
|
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)
|
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-core@5.83.0': {}
|
||||||
|
|
||||||
'@tanstack/query-persist-client-core@5.83.0':
|
'@tanstack/query-persist-client-core@5.83.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/query-core': 5.83.0
|
'@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)':
|
'@tanstack/react-query@5.83.0(react@19.1.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/query-core': 5.83.0
|
'@tanstack/query-core': 5.83.0
|
||||||
|
|
@ -7732,6 +7812,8 @@ snapshots:
|
||||||
|
|
||||||
commander@2.20.3: {}
|
commander@2.20.3: {}
|
||||||
|
|
||||||
|
commander@8.3.0: {}
|
||||||
|
|
||||||
commander@9.5.0: {}
|
commander@9.5.0: {}
|
||||||
|
|
||||||
comment-json@4.2.5:
|
comment-json@4.2.5:
|
||||||
|
|
@ -8060,6 +8142,8 @@ snapshots:
|
||||||
|
|
||||||
entities@4.5.0: {}
|
entities@4.5.0: {}
|
||||||
|
|
||||||
|
entities@6.0.1: {}
|
||||||
|
|
||||||
env-paths@3.0.0: {}
|
env-paths@3.0.0: {}
|
||||||
|
|
||||||
envinfo@7.14.0: {}
|
envinfo@7.14.0: {}
|
||||||
|
|
@ -8598,6 +8682,13 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
unix-dgram: 2.0.6
|
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-cache-semantics@4.2.0: {}
|
||||||
|
|
||||||
http-errors@1.8.1:
|
http-errors@1.8.1:
|
||||||
|
|
@ -8907,6 +8998,10 @@ snapshots:
|
||||||
|
|
||||||
jwt-decode@4.0.0: {}
|
jwt-decode@4.0.0: {}
|
||||||
|
|
||||||
|
katex@0.16.22:
|
||||||
|
dependencies:
|
||||||
|
commander: 8.3.0
|
||||||
|
|
||||||
keep-func-props@6.0.0:
|
keep-func-props@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
mimic-fn: 4.0.0
|
mimic-fn: 4.0.0
|
||||||
|
|
@ -9082,8 +9177,6 @@ snapshots:
|
||||||
|
|
||||||
luxon@3.7.1: {}
|
luxon@3.7.1: {}
|
||||||
|
|
||||||
lz-string@1.5.0: {}
|
|
||||||
|
|
||||||
macos-release@3.4.0: {}
|
macos-release@3.4.0: {}
|
||||||
|
|
||||||
magic-string@0.30.17:
|
magic-string@0.30.17:
|
||||||
|
|
@ -9810,6 +9903,12 @@ snapshots:
|
||||||
react: 19.1.1
|
react: 19.1.1
|
||||||
scheduler: 0.26.0
|
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-refresh@0.14.2: {}
|
||||||
|
|
||||||
react-remove-scroll-bar@2.3.8(@types/react@19.1.8)(react@19.1.1):
|
react-remove-scroll-bar@2.3.8(@types/react@19.1.8)(react@19.1.1):
|
||||||
|
|
@ -10142,6 +10241,11 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
atomic-sleep: 1.0.0
|
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:
|
sort-keys-length@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
sort-keys: 1.1.2
|
sort-keys: 1.1.2
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue