diff --git a/app/components/details/attachment.tsx b/app/components/details/attachment.tsx
new file mode 100644
index 0000000..c81e126
--- /dev/null
+++ b/app/components/details/attachment.tsx
@@ -0,0 +1,52 @@
+import { FileText, Image, File } from "lucide-react";
+
+export default function AttachmentItem({ attachment }: { attachment: string }) {
+ return (
+
+ {getIcon(attachment)}
+
+ {getName(attachment) || "Sans Nom"}
+
+
+ );
+}
+
+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 ;
+ case "image":
+ return ;
+ // case "document":
+ // return
+ // case "spreadsheet":
+ // return
+ // case "code":
+ // return
+ default:
+ return ;
+ }
+};
+
+const getName = (attachment: string) => {
+ const parts = attachment.replace("pj_doc", "").split("_");
+ const nameParts = parts.slice(2); // remove the first two parts
+ return nameParts.join("_");
+};
diff --git a/app/components/details/skeleton-details.tsx b/app/components/details/skeleton-details.tsx
new file mode 100644
index 0000000..539dcd8
--- /dev/null
+++ b/app/components/details/skeleton-details.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/components/error.tsx b/app/components/error.tsx
index 0e1e609..8177c4c 100644
--- a/app/components/error.tsx
+++ b/app/components/error.tsx
@@ -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 (
-
+
@@ -36,9 +36,9 @@ export default function Error({
Retour
-
+
- Réessayer
+ Recharger la page
diff --git a/app/components/home/colle-card.tsx b/app/components/home/colle-card.tsx
index 93c1824..3658f89 100644
--- a/app/components/home/colle-card.tsx
+++ b/app/components/home/colle-card.tsx
@@ -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({
)}
{/* TODO: Attachments */}
- {colle.attachmentsCount > 0 && (
+ {/* {colle.attachmentsCount > 0 && (
- )}
+ )} */}
);
}
-
-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
-};
diff --git a/app/components/home/date-picker.tsx b/app/components/home/date-picker.tsx
index 7e1c1cb..f44eb0b 100644
--- a/app/components/home/date-picker.tsx
+++ b/app/components/home/date-picker.tsx
@@ -52,6 +52,8 @@ export default function DatePickerWithRange({
onSelect={handleDateSelect}
weekStartsOn={1}
locale={fr}
+ className="rounded-md border shadow-sm"
+ captionLayout="dropdown"
/>
diff --git a/app/components/ui/separator.tsx b/app/components/ui/separator.tsx
new file mode 100644
index 0000000..6e33a89
--- /dev/null
+++ b/app/components/ui/separator.tsx
@@ -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
) {
+ return (
+
+ );
+}
+
+export { Separator };
diff --git a/app/components/ui/sonner.tsx b/app/components/ui/sonner.tsx
new file mode 100644
index 0000000..d00fe49
--- /dev/null
+++ b/app/components/ui/sonner.tsx
@@ -0,0 +1,23 @@
+import { Toaster as Sonner, type ToasterProps } from "sonner";
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ // TODO: Use theme hook
+ const { theme = "system" } = {};
+
+ return (
+
+ );
+};
+
+export default Toaster;
diff --git a/app/components/user-dropdown.tsx b/app/components/user-dropdown.tsx
index cb81ebe..4e68464 100644
--- a/app/components/user-dropdown.tsx
+++ b/app/components/user-dropdown.tsx
@@ -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);
diff --git a/app/lib/api.ts b/app/lib/api.ts
index 92af104..4282a85 100644
--- a/app/lib/api.ts
+++ b/app/lib/api.ts
@@ -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)
-};
diff --git a/app/lib/client.ts b/app/lib/client.ts
index d9bad41..3409aef 100644
--- a/app/lib/client.ts
+++ b/app/lib/client.ts
@@ -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;
diff --git a/app/lib/latex.ts b/app/lib/latex.ts
new file mode 100644
index 0000000..1c8c7a2
--- /dev/null
+++ b/app/lib/latex.ts
@@ -0,0 +1,33 @@
+export function extractLatexImages(html: string) {
+ const imgRegex =
+ / ]+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, " ");
+}
diff --git a/app/lib/utils.ts b/app/lib/utils.ts
index 4c4f2d6..3db2c85 100644
--- a/app/lib/utils.ts
+++ b/app/lib/utils.ts
@@ -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
+};
diff --git a/app/root.tsx b/app/root.tsx
index d758e6f..358e28b 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -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 }) {
{/* Favicon */}
-
+
-
+
@@ -43,6 +53,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
{children}
+
diff --git a/app/routes.ts b/app/routes.ts
index c6ddef9..6bebb05 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -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;
diff --git a/app/routes/colles.tsx b/app/routes/colles.tsx
new file mode 100644
index 0000000..e0bef16
--- /dev/null
+++ b/app/routes/colles.tsx
@@ -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(/( \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 ;
+ }
+
+ // TODO: Favorite toggle function
+ const toggleStar = () => {};
+
+ const colleId = parseInt(params.colleId!);
+ if (isNaN(colleId)) {
+ return ;
+ }
+
+ const { colle, error, isLoading } = useColle(colleId);
+ if (error)
+ return (
+
+ );
+
+ if (isLoading || !colle) return ;
+
+ 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 (
+
+ {/*
*/}
+
+
+
goBack(navigate)}>
+
+ Retour
+
+
+
+
+
+
+
+ Recharger
+
+
+
+
+
+
+
+
+
+
+
Résumé de Colle
+ {colle.grade && (
+
+ {formatGrade(colle.grade)}/20
+
+ )}
+
+
+
+ {colle.subject.name} {subjectEmoji}
+
+
+
+
+ {/* Desktop Action Buttons */}
+
+
+
+ Partager
+
+
+
+
+ Recharger
+
+
+
+
+ Ajouter aux favoris
+
+
+ {colle.grade && (
+
+ {formatGrade(colle.grade)}/20
+
+ )}
+
+
+
+
+
+
+
+
+
+ Date
+
+
+
+
+ {formatDate(colle.date)}, à {formatTime(colle.date)}
+
+
+
+
+
+
+ Colleur
+
+
+
+ {colle.examiner.name}
+
+
+
+
+
+
+
+ Étudiant
+
+
+
+ {colle.student.fullName}
+
+
+
+ {/* TODO: Colles groups - others students */}
+ {/* {colle.group?.length > 0 && (
+
+
+ Autres élèves
+
+ {colle.group.map((linkedColle) => (
+
+
+
+ {linkedColle.student}
+
+
+ ))}
+
+ )} */}
+
+
+ {colle.room && (
+
+
+
+ Salle
+
+
+
+ {colle.room.name}
+
+
+
+ )}
+
+
+ {colle.content && (
+ <>
+
+
+
+ >
+ )}
+
+ {colle.comment && (
+ <>
+
+
+
+
+
+ Commentaires
+
+
+
+
+
+ >
+ )}
+
+ {/* TODO: Attachments */}
+ {colle.attachments && colle.attachments?.length > 0 && (
+
+
+
+ Attachments
+
+
+ {colle.attachments.map((attachment, index) => (
+
+ ))}
+
+
+ )}
+
+ {/* BJColle External Link */}
+
+
+
+
+ Accéder à la colle depuis BJColle
+
+
+
+
+
+
+ {/* Mobile Action Bar - Fixed at Bottom */}
+
+
+
+
+ {/* TODO: */}
+ {false ? "Favori" : "Ajouter"}
+
+
+
+
+
+
+ Partager
+
+
+
+
+ );
+}
+
+// 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 (
+
+
+ {renderLatex(displayedComment)}
+
+ {isLongComment && (
+
+ {isExpanded ? (
+
+ Afficher moins
+
+ ) : (
+
+ Afficher plus
+
+ )}
+
+ )}
+
+ );
+}
+
+// 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();
+}
diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index af065c7..5b673a6 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -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";
diff --git a/package.json b/package.json
index 943e7f2..1ebba88 100644
--- a/package.json
+++ b/package.json
@@ -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"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 41dc219..f8a30be 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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