From 85e2552db871ededd650483bba88bf708bedf4a6 Mon Sep 17 00:00:00 2001 From: Nathan Lamy Date: Tue, 29 Jul 2025 23:25:10 +0200 Subject: [PATCH] feat: add colle details page --- app/components/details/attachment.tsx | 52 +++ app/components/details/skeleton-details.tsx | 92 ++++ app/components/error.tsx | 6 +- app/components/home/colle-card.tsx | 39 +- app/components/home/date-picker.tsx | 2 + app/components/ui/separator.tsx | 26 ++ app/components/ui/sonner.tsx | 23 + app/components/user-dropdown.tsx | 2 +- app/lib/api.ts | 97 ++-- app/lib/client.ts | 123 +----- app/lib/latex.ts | 33 ++ app/lib/utils.ts | 58 +++ app/root.tsx | 17 +- app/routes.ts | 1 + app/routes/colles.tsx | 463 ++++++++++++++++++++ app/routes/home.tsx | 2 +- package.json | 9 +- pnpm-lock.yaml | 124 +++++- 18 files changed, 979 insertions(+), 190 deletions(-) create mode 100644 app/components/details/attachment.tsx create mode 100644 app/components/details/skeleton-details.tsx create mode 100644 app/components/ui/separator.tsx create mode 100644 app/components/ui/sonner.tsx create mode 100644 app/lib/latex.ts create mode 100644 app/routes/colles.tsx 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 - 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 && (
{colle.attachmentsCount}
- )} + )} */}
); } - -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 ( +
+ {/* */} + +
+ +
+ +
+
+ + +
+
+ + + +
+
+
+ Résumé de Colle + {colle.grade && ( +
+ {formatGrade(colle.grade)}/20 +
+ )} +
+
+ + {colle.subject.name} {subjectEmoji} + +
+
+ + {/* Desktop Action Buttons */} +
+ + + + + + + {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 && ( + <> + + +
+

+ + Sujet +

+ +
+ +
+
+ + )} + + {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: 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 && ( + + )} +
+ ); +} + +// 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