Compare commits

...

6 commits

Author SHA1 Message Date
Nathan Lamy
0684a0649b perf: remove latex extraction from client
All checks were successful
Deploy to Netlify / Deploy to Netlify (push) Successful in 1m42s
2025-07-30 18:24:42 +02:00
Nathan Lamy
3549281b73 feat: rework registration form 2025-07-30 17:44:39 +02:00
Nathan Lamy
c7d7fd53cb fix: redirect unauthorized users 2025-07-30 17:05:07 +02:00
Nathan Lamy
ea585bd748 ui: add dark theme 2025-07-30 13:16:55 +02:00
Nathan Lamy
9ea54ed90f fix: invalidate user cache on logout 2025-07-30 13:08:43 +02:00
Nathan Lamy
ef2d65d261 feat: add logout 2025-07-30 13:01:43 +02:00
12 changed files with 293 additions and 153 deletions

View file

@ -22,7 +22,9 @@ export function Combobox({
defaultText = "Select value...",
placeholderText = "Search...",
emptyText = "No value found.",
current, setValue
current,
setValue,
disabled = false,
}: {
values?: { value: string; label: string }[]
emptyText?: string
@ -30,17 +32,19 @@ export function Combobox({
defaultText?: string
current?: string
setValue: (value: string) => void
disabled?: boolean
}) {
const [open, setOpen] = React.useState(false)
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open && !disabled} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled}
>
{current
? values.find((value) => value.value === current)?.label!

View file

@ -79,6 +79,17 @@ export default function Home() {
isLoading,
});
// Error handling (after all hooks)
if (error)
return (
<Error
title="Impossible de charger les colles"
message={error?.toString()}
code={500}
description="Une erreur s'est produite lors du chargement de la liste des colles."
/>
);
// TODO: FAVORITES
const useToggleStar = (auth: any) => {};
// TODO: FILTERS
@ -127,17 +138,6 @@ export default function Home() {
// setTimeout(restoreScrollPosition, 500);
// }, [location]);
// Error handling (after all hooks)
if (error)
return (
<Error
title="Impossible de charger les colles"
message={error?.toString()}
code={500}
description="Une erreur s'est produite lors du chargement de la liste des colles."
/>
);
return (
<div className="space-y-6 pb-20 md:pb-0">
{/* Week Navigation */}

20
app/components/loader.tsx Normal file
View file

@ -0,0 +1,20 @@
export default function Loader() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center space-y-4">
<div className="flex space-x-2">
<div className="w-3 h-3 bg-primary rounded-full animate-bounce"></div>
<div
className="w-3 h-3 bg-primary rounded-full animate-bounce"
style={{ animationDelay: "0.1s" }}
></div>
<div
className="w-3 h-3 bg-primary rounded-full animate-bounce"
style={{ animationDelay: "0.2s" }}
></div>
</div>
<h2 className="text-primary text-sm">Chargement...</h2>
</div>
</div>
);
}

View file

@ -0,0 +1,100 @@
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
const isLocalStorageAvailable = () => {
return (
typeof window !== "undefined" &&
typeof window.localStorage !== "undefined" &&
window.localStorage !== null
);
};
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(() => {
if (isLocalStorageAvailable()) {
const storedTheme = localStorage.getItem(storageKey) as Theme;
return storedTheme || defaultTheme;
}
return defaultTheme;
});
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};
export const useDisplayedTheme = () => {
const { theme, setTheme } = useTheme();
if (theme === "system") {
return {
theme: window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light",
setTheme,
};
}
return { theme, setTheme };
};

View file

@ -17,25 +17,32 @@ import {
Settings,
Sun,
} from "lucide-react";
// TODO: THEME
// import { useTheme } from "~/components/ui/theme-provider";
import { useDisplayedTheme } from "~/components/theme-provider";
import { useNavigate } from "react-router";
import { logout, type User } from "~/lib/api";
import { useQueryClient } from "@tanstack/react-query";
export default function UserDropdown({ user }: { user: User }) {
// TODO: const { theme, setTheme } = useTheme();
const [theme, setTheme] = useState("light");
const { theme, setTheme } = useDisplayedTheme();
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const handleLogout = async () => {
const useLogout = () => {
const queryClient = useQueryClient();
// Logout, invalidate user cache, and redirect to login
return async () => {
try {
await logout();
queryClient.removeQueries({ queryKey: ["user"] });
navigate("/login", { replace: true });
} catch (error) {
console.error("Logout failed:", error);
}
}
};
};
const handleLogout = useLogout();
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
@ -57,9 +64,7 @@ export default function UserDropdown({ user }: { user: User }) {
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{user.fullName}
</p>
<p className="text-sm font-medium leading-none">{user.fullName}</p>
<p className="text-xs leading-none text-muted-foreground">
{user.className}
</p>
@ -87,23 +92,20 @@ export default function UserDropdown({ user }: { user: User }) {
e.stopPropagation();
e.preventDefault();
navigate("/settings");
}}>
}}
>
<Settings className="mr-2 h-4 w-4" />
<span>
Paramètres
</span>
<span>Paramètres</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => {
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
navigate("/progress");
}}>
<LineChartIcon className="mr-2 h-4 w-4" />
<span
}}
>
Progression
</span>
<LineChartIcon className="mr-2 h-4 w-4" />
<span>Progression</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />

View file

@ -10,7 +10,7 @@ import {
import { Button } from "./components/ui/button";
import { Link } from "react-router";
const SUPPORT_MAIL = "mailto:" + import.meta.env.VITE_SUPPORT_EMAIL;
export const SUPPORT_MAIL = "mailto:" + import.meta.env.VITE_SUPPORT_EMAIL;
export function AuthLayout({
children,

View file

@ -70,18 +70,23 @@ export const getClasses = async () => {
};
export const registerUser = async (
firstName: string,
lastName: string,
name: string,
className: string,
token: string
) => {
return makePostRequest(
"/auth/register",
{ firstName, lastName, className, token },
{ name, className, token },
"Échec de l'inscription"
);
};
export const getNames = async (className: string) => {
return makeRequest(
`/auth/autocomplete?className=${encodeURIComponent(className)}`,
"Échec de la récupération des noms"
);
};
/**
* === USER API ===
@ -104,6 +109,8 @@ const defaultUser = {
export type User = typeof defaultUser;
export const AUTH_ERROR = "Unauthorized access"
export const useUser = () => {
const { data, ...props } = useQuery({
queryKey: ["user"],
@ -122,8 +129,7 @@ export const useUser = () => {
};
export const logout = async () => {
// TODO: POST
// TODO: Invalidate user query (cache)
return makePostRequest("/auth/logout", {}, "Échec de la déconnexion", "POST");
};
/**

View file

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

View file

@ -12,6 +12,7 @@ import "./app.css";
import { QueryClientProvider } from "@tanstack/react-query";
import queryClient from "~/lib/client";
import Toaster from "~/components/ui/sonner";
import { ThemeProvider } from "./components/theme-provider";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
@ -51,7 +52,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
</head>
<body>
<QueryClientProvider client={queryClient}>
{children}
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
<Toaster />
<ScrollRestoration />

View file

@ -1,13 +1,5 @@
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";
@ -37,7 +29,7 @@ import AttachmentItem from "~/components/details/attachment";
// import { ScrollToTopOnMount } from "~/components/noscroll";
import Error from "~/components/error";
import { Badge } from "~/components/ui/badge";
import { useColle, useUser } from "~/lib/api";
import { AUTH_ERROR, useColle, useUser } from "~/lib/api";
import { toast } from "sonner";
import { formatDate, formatGrade, formatTime, goBack } from "~/lib/utils";
@ -60,10 +52,14 @@ export default function ColleDetailPage() {
const [isReloading, setIsReloading] = useState(false);
// TODO: Handle user loading state
if (isUserLoading) {
return <ColleDetailsSkeleton />;
}
if (userError?.message === AUTH_ERROR) {
return <Navigate to="/login" replace />;
}
if (userError) {
console.error(userError);
return <Navigate to="/login" />;
return <Error message={userError.message} />;
}
// TODO: Favorite toggle function
@ -84,7 +80,6 @@ export default function ColleDetailPage() {
description="Une erreur s'est produite lors du chargement de la colle."
/>
);
if (isLoading || !colle) return <ColleDetailsSkeleton />;
const handleToggleFavorite = () => {};
@ -432,7 +427,7 @@ function ExpandableComment({ comment }: { comment: string }) {
return (
<div>
<Latex delimiters={[{ left: "$$", right: "$$", display: false }]}>
{renderLatex(displayedComment)}
{displayedComment}
</Latex>
{isLongComment && (
<Button

View file

@ -1,21 +1,30 @@
import { Navigate } from "react-router";
import Error from "~/components/error";
import HomePage from "~/components/home";
import Loader from "~/components/loader";
import UserDropdown from "~/components/user-dropdown";
import { MainLayout } from "~/layout";
import { useUser } from "~/lib/api";
import { AUTH_ERROR, useUser } from "~/lib/api";
import { forceReload } from "~/lib/utils";
export default function Home() {
const { user, isLoading, error } = useUser();
if (error) {
console.error(error);
// TODO: handle error (redirect to login or show error message)
if (isLoading) {
return <Loader />;
}
if (error?.message === AUTH_ERROR) {
return <Navigate to="/login" replace />;
}
if (error) {
return <Error message={error.message} />;
}
return (
<MainLayout page={<HomePage />}>
<h1 className="text-2xl font-bold" onClick={forceReload}>
Khollis&eacute; - {user.className}
</h1>
{/* TODO: isLoading to display skeleton */}
<UserDropdown user={user} />
</MainLayout>
);

View file

@ -1,14 +1,20 @@
import { Alert, AlertDescription } from "~/components/ui/alert";
import { AlertCircleIcon, IdCardIcon, LoaderCircle, LogIn, MailIcon } from "lucide-react";
import {
AlertCircleIcon,
ArrowRight,
LoaderCircle,
LogIn,
MailIcon,
} from "lucide-react";
import { Label } from "~/components/ui/label";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { useEffect, useState } from "react";
import { AuthLayout } from "~/layout";
import { AuthLayout, SUPPORT_MAIL } from "~/layout";
import { useNavigate, useSearchParams } from "react-router";
import { capitalizeFirstLetter } from "~/lib/utils";
import { Combobox } from "~/components/combobox";
import { getClasses, registerUser } from "~/lib/api";
import { getClasses, getNames, registerUser } from "~/lib/api";
export function meta() {
return [
@ -23,9 +29,8 @@ export default function Register() {
const [searchParams] = useSearchParams();
const email = searchParams.get("email")!;
const [emailFirst, emailLast] = getNameFromEmail(email);
const [firstName, setFirstName] = useState(emailFirst);
const [lastName, setLastName] = useState(emailLast);
const [className, setClassName] = useState("");
const [name, setName] = useState("");
const token = searchParams.get("token");
useEffect(() => {
@ -35,40 +40,69 @@ export default function Register() {
}
}, [email, token]);
const [classes, setClasses] = useState<{ value: string; label: string }[]>([]);
const [classes, setClasses] = useState<{ value: string; label: string }[]>(
[]
);
useEffect(() => {
getClasses().then((data) => {
setClasses(data);
})
});
}, []);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [names, setNames] = useState<{ value: string; label: string }[]>([]);
const hasSubmitted = names.length > 0;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!hasSubmitted) {
// Validate inputs
if (!firstName || !lastName) return setError("Veuillez compléter votre prénom et nom.");
if (!className) return setError("Veuillez sélectionner votre classe.");
setIsLoading(true);
await registerUser(firstName, lastName, className, token!)
.then(() => {
await getNames(className)
.then((names) => {
setNames(names);
setIsLoading(false);
navigate("/", {
replace: true,
})
// Try to default name from email
const possibleName = names.find(
(n: { label: string }) => n.label === `${emailFirst} ${emailLast}`
);
if (possibleName) {
setName(possibleName.value);
}
})
.catch((err) => {
setIsLoading(false);
setError(err.message);
});
} else {
// Register user
if (!name) return setError("Veuillez compléter votre nom.");
await registerUser(name, className, token!)
.then(() => {
setIsLoading(false);
navigate("/", {
replace: true,
});
})
.catch((err) => {
setIsLoading(false);
setError(err.message);
});
}
};
return (
<AuthLayout title="Inscription" description="Créez votre compte pour accéder à Khollisé.">
<AuthLayout
title="Inscription"
description="Créez votre compte pour accéder à Khollisé."
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
@ -87,32 +121,6 @@ export default function Register() {
</div>
</div>
<div className="space-y-2 flex gap-4">
<div>
<Label htmlFor="password">Prénom</Label>
<Input
id="firstName"
type="text"
placeholder="Claire"
value={firstName}
onChange={(e) => setFirstName(capitalizeFirstLetter(e.target.value))}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Nom</Label>
<Input
id="lastName"
type="text"
placeholder="DUPONT"
value={lastName}
onChange={(e) => setLastName(e.target.value.toUpperCase())}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Classe</Label>
<div className="block">
@ -123,9 +131,33 @@ export default function Register() {
placeholderText="Rechercher"
current={className}
setValue={setClassName}
disabled={names.length > 0}
/>
</div>
<p className="text-xs text-muted-foreground">
Si votre classe n'est pas dans la liste, contactez-nous par{" "}
<a href={SUPPORT_MAIL} className="text-blue-500 hover:underline">
mail
</a>{" "}
pour l'ajouter.
</p>
</div>
{hasSubmitted && (
<div className="space-y-2">
<Label htmlFor="names">Prénom et Nom</Label>
<div className="block">
<Combobox
defaultText="Sélectionnez votre nom..."
values={names}
emptyText="Aucun nom trouvé"
placeholderText="Rechercher"
current={name}
setValue={setName}
/>
</div>
</div>
)}
{error && (
<Alert variant="destructive" className="pb-2">
@ -138,12 +170,16 @@ export default function Register() {
{isLoading ? (
<>
<LoaderCircle className="h-4 w-4 animate-spin mr-2" />
Inscription en cours...
Validation en cours...
</>
) : (
<>
{hasSubmitted ? (
<LogIn className="h-4 w-4 mr-2" />
S'inscrire
) : (
<ArrowRight className="h-4 w-4 mr-2" />
)}
{hasSubmitted ? "S'inscrire" : "Continuer"}
</>
)}
</Button>