diff --git a/app/app.css b/app/app.css
index 3b5012d..28942ec 100644
--- a/app/app.css
+++ b/app/app.css
@@ -119,6 +119,7 @@
}
body {
@apply bg-background text-foreground;
+ font-family: "Inter";
}
}
diff --git a/app/components/error.tsx b/app/components/error.tsx
new file mode 100644
index 0000000..0e1e609
--- /dev/null
+++ b/app/components/error.tsx
@@ -0,0 +1,47 @@
+import { Button } from "~/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "~/components/ui/card";
+import { ChevronLeft, FileQuestion, RotateCw } from "lucide-react";
+import { Link } from "react-router";
+import { forceReload } from "~/lib/utils";
+
+export default function Error({
+ title = "Page introuvable",
+ message = "Nous n'avons pas pu trouver la page que vous recherchiez.",
+ code = 404,
+ description = "La page que vous recherchez a peut-être été supprimée, son nom a été modifié ou est temporairement indisponible.",
+}) {
+ return (
+
+
+
+
+
+
+ {title}
+ {message}
+
+
+ {code}
+ {description}
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/home/bottom-nav.tsx b/app/components/home/bottom-nav.tsx
new file mode 100644
index 0000000..8aacbb4
--- /dev/null
+++ b/app/components/home/bottom-nav.tsx
@@ -0,0 +1,55 @@
+import { Star, User, Users } from "lucide-react"
+import { cn } from "~/lib/utils"
+
+interface BottomNavigationProps {
+ activeTab: string
+ onTabChange: (tab: string) => void
+ favoriteCount: number
+}
+
+export default function BottomNavigation({ activeTab, onTabChange, favoriteCount }: BottomNavigationProps) {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/components/home/colle-card.tsx b/app/components/home/colle-card.tsx
new file mode 100644
index 0000000..93c1824
--- /dev/null
+++ b/app/components/home/colle-card.tsx
@@ -0,0 +1,178 @@
+import type React from "react";
+import type { Colle } from "~/lib/api";
+
+import { DateTime } from "luxon";
+import { Link, useNavigate } from "react-router";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardFooter,
+} from "~/components/ui/card";
+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";
+
+// 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
+};
+
+type ColleCardProps = {
+ colle: Colle;
+ onToggleFavorite: (id: number, favorite: boolean) => void;
+ beforeClick: () => void;
+ isFavorite: boolean;
+};
+
+export default function ColleCard({
+ colle,
+ onToggleFavorite,
+ beforeClick,
+ isFavorite,
+}: ColleCardProps) {
+ const navigate = useNavigate();
+
+ // TODO: Remove this if scroll restoration is not needed (test first)
+ const handleCardClick = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ beforeClick();
+ setTimeout(() => navigate(`/colles/${colle.id}`), 100);
+ };
+
+ // TODO: Favorites
+ const handleToggleFavorite = (e: React.MouseEvent) => {
+ e.stopPropagation(); // Prevent card click
+ e.preventDefault();
+ const newValue = !isFavorite;
+ onToggleFavorite(colle.id, newValue);
+ };
+
+ const subjectColor = getSubjectColor(colle.subject.name);
+ const subjectEmoji = getSubjectEmoji(colle.subject.name);
+
+ return (
+
+
+
+
+
{formatDate(colle.date)}
+
+ {formatTime(colle.date)}
+
+
+ {colle.grade && (
+
+
+
+ {formatGrade(colle.grade)}/20
+
+
+ )}
+
+
+
+
+
+
+
+ {colle.student.fullName}
+
+
+
+
+ {colle.examiner.name}
+
+
+ {colle.room && (
+
+
+ {colle.room.name}
+
+ )}
+
+
+
+
+
+
+
+
+ {colle.subject.name + " " + subjectEmoji}
+
+ {isFavorite && (
+
+
+ Favori
+
+ )}
+
+ {/* TODO: Attachments */}
+ {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
new file mode 100644
index 0000000..7e1c1cb
--- /dev/null
+++ b/app/components/home/date-picker.tsx
@@ -0,0 +1,67 @@
+import { DateTime } from "luxon";
+import { CalendarIcon } from "lucide-react";
+import { fr } from "date-fns/locale";
+import { Calendar } from "~/components/ui/calendar";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "~/components/ui/popover";
+import { cn } from "~/lib/utils";
+
+export default function DatePickerWithRange({
+ className,
+ startDate,
+ setStartDate,
+}: {
+ className?: string;
+ startDate: DateTime;
+ setStartDate: (date: DateTime) => void;
+}) {
+ const endDate = startDate.endOf("week");
+
+ function handleDateSelect(selectedDate?: Date) {
+ if (selectedDate) {
+ setStartDate(DateTime.fromJSDate(selectedDate));
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const formatDate = (date: DateTime, includeYear = false) => {
+ const localDate = date.setLocale("fr");
+ return includeYear
+ ? localDate.toFormat("dd MMM yyyy")
+ : localDate.toFormat("dd MMM");
+};
diff --git a/app/components/home/index.tsx b/app/components/home/index.tsx
new file mode 100644
index 0000000..3876b5f
--- /dev/null
+++ b/app/components/home/index.tsx
@@ -0,0 +1,352 @@
+import { DateTime } from "luxon";
+import { useState, useEffect } from "react";
+import { Button } from "~/components/ui/button";
+import {
+ ChevronLeft,
+ ChevronRight,
+ Star,
+ Filter,
+ X,
+ Users,
+ User,
+ ArrowUpDown,
+} from "lucide-react";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "~/components/ui/select";
+import { Label } from "~/components/ui/label";
+import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "~/components/ui/collapsible";
+import DatePickerWithRange from "~/components/home/date-picker";
+import BottomNavigation from "~/components/home/bottom-nav";
+import Error from "~/components/error";
+import { useSearchParams } from "react-router";
+import { useColles } from "~/lib/api";
+import TabContent from "~/components/home/tab-content";
+
+export default function Home() {
+ // Handle query parameters
+ const [query, setQuery] = useSearchParams();
+ const updateQuery = (key: string, value: string) => {
+ if (query.get(key) !== value) {
+ setQuery((prev) => {
+ const newQuery = new URLSearchParams(prev);
+ newQuery.set(key, value);
+ return newQuery;
+ });
+ }
+ };
+
+ // Tabs
+ const activeTab = query.get("view") || "you";
+ const setActiveTab = (tab: string) => updateQuery("view", tab);
+
+ // Date
+ const rawStartDate = query.get("start");
+ const startDate = rawStartDate
+ ? DateTime.fromISO(rawStartDate, { zone: "local" })
+ : DateTime.now().startOf("week");
+
+ const setStartDate = (date: DateTime) =>
+ updateQuery("start", date.startOf("week").toISODate()!);
+
+ const handlePreviousWeek = () => {
+ const previousWeek = startDate.minus({ weeks: 1 });
+ setStartDate(previousWeek);
+ };
+
+ const handleNextWeek = () => {
+ const nextWeek = startDate.plus({ weeks: 1 });
+ setStartDate(nextWeek);
+ };
+
+ // Fetch colles from API
+ const { studentColles, classColles, favoriteColles, error, isLoading } =
+ useColles(startDate);
+ console.log("Colles loaded:", {
+ studentColles,
+ classColles,
+ favoriteColles,
+ error,
+ isLoading,
+ });
+
+ // TODO: FAVORITES
+ const useToggleStar = (auth: any) => {};
+ // TODO: FILTERS
+ const getSessionFilter = (key: string) => {
+ const filter = sessionStorage.getItem(key);
+ if (filter) {
+ return filter;
+ }
+ return "all";
+ };
+ const [subjectFilter, setSubjectFilter] = useState(
+ getSessionFilter("subject")
+ );
+ const [examinerFilter, setExaminerFilter] = useState(
+ getSessionFilter("examiner")
+ );
+ // const [studentFilter, setStudentFilter] = useState("all")
+ const [isFilterOpen, setIsFilterOpen] = useState(
+ getSessionFilter("subject") !== "all" ||
+ getSessionFilter("examiner") !== "all"
+ );
+ useEffect(() => {
+ sessionStorage.setItem("subject", subjectFilter);
+ sessionStorage.setItem("examiner", examinerFilter);
+ // sessionStorage.setItem('student', studentFilter)
+ }, [subjectFilter, examinerFilter]);
+
+ // Restore scroll position
+ // TODO: Test and check if needed!!
+ // // SCROLL
+ // const setScrollPosition = (colleId: number) => {
+ // sessionStorage.setItem("colles_position", colleId.toString());
+ // };
+ // const restoreScrollPosition = () => {
+ // const position = sessionStorage.getItem("colles_position");
+ // if (position) {
+ // const element = document.getElementById(`colle-${position}`);
+ // if (element) {
+ // element.scrollIntoView({ behavior: "smooth", block: "center" });
+ // sessionStorage.removeItem("colles_position");
+ // }
+ // }
+ // };
+ // const location = useLocation();
+ // useEffect(() => {
+ // setTimeout(restoreScrollPosition, 500);
+ // }, [location]);
+
+ // Error handling (after all hooks)
+ if (error)
+ return (
+
+ );
+
+ return (
+
+ {/* Week Navigation */}
+
+
+
+
+
+
+
+ Vos colles ({studentColles.length})
+
+
+
+ Vos favoris ({0 /* TODO: stars.length */})
+
+
+
+ Votre classe ({classColles.length})
+
+
+
+
+
+ {/* TODO: Filter component */}
+
+
+
+ {activeTab === "all" && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+ {/* TODO: DEBUG */}
+ {/* {activeTab === "all" &&
+ (getWeekStart().getTime() != startDate.getTime() ? (
+
+ ) : (
+
+ ))} */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+
+
+
*/}
+
+
+
+
+
+
+
+
+ {/* Tab Content */}
+
+ {/* Your Colles Tab */}
+ {activeTab === "you" && (
+
+ )}
+
+ {/* Favorites Tab */}
+ {activeTab === "favorites" && (
+
+ )}
+
+
+ {/* Class Colles Tab */}
+ {activeTab === "class" && (
+
+ )}
+
+ {/* Bottom Navigation for Mobile */}
+
+
+ );
+}
diff --git a/app/components/home/skeleton-card.tsx b/app/components/home/skeleton-card.tsx
new file mode 100644
index 0000000..9e3b808
--- /dev/null
+++ b/app/components/home/skeleton-card.tsx
@@ -0,0 +1,55 @@
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardFooter,
+} from "~/components/ui/card";
+import { Skeleton } from "~/components/ui/skeleton";
+
+export default function ColleCardSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/home/tab-content.tsx b/app/components/home/tab-content.tsx
new file mode 100644
index 0000000..d3a45dc
--- /dev/null
+++ b/app/components/home/tab-content.tsx
@@ -0,0 +1,104 @@
+import { DateTime } from "luxon";
+import type { Colle } from "~/lib/api";
+import { Badge } from "~/components/ui/badge";
+import ColleCard from "~/components/home/colle-card";
+import ColleCardSkeleton from "~/components/home/skeleton-card";
+
+type TabContentProps = {
+ tabTitle: string;
+ emptyCollesText: string;
+ isLoading: boolean;
+ colles: Colle[];
+};
+
+const WEEK_DAYS = [
+ "Lundi",
+ "Mardi",
+ "Mercredi",
+ "Jeudi",
+ "Vendredi",
+ "Samedi",
+ "Dimanche",
+];
+
+export default function TabContent({
+ tabTitle,
+ emptyCollesText,
+ isLoading,
+ colles,
+}: TabContentProps) {
+ const collesByDay: Record = {};
+ colles.forEach((colle) => {
+ const date = DateTime.fromISO(colle.date);
+ const dayName = WEEK_DAYS[date.weekday - 1]; // Luxon weekday is 1-7 (Mon-Sun)
+ if (!collesByDay[dayName]) {
+ collesByDay[dayName] = [];
+ }
+ collesByDay[dayName].push(colle);
+ });
+
+ const days = WEEK_DAYS.filter(
+ (day) => collesByDay[day] && collesByDay[day].length > 0
+ );
+
+ return (
+ <>
+ {tabTitle}
+ {isLoading ? (
+
+ {WEEK_DAYS.slice(0, 2).map((day) => (
+
+
+
{day}
+
+ Chargement...
+
+
+
+ {Array(3)
+ .fill(0)
+ .map((_, i) => (
+
+ ))}
+
+
+ ))}
+
+ ) : colles.length === 0 ? (
+
+ {emptyCollesText}
+
+ ) : (
+
+ {days.map((day) => (
+
+
+
{day}
+
+ {collesByDay[day].length} colle
+ {collesByDay[day].length !== 1 ? "s" : ""}
+
+
+
+ {collesByDay[day].map((colle) => (
+ {}}
+ onToggleFavorite={() => {}}
+ isFavorite={false}
+ // TODO: Implement scroll position handling
+ // beforeClick={() => setScrollPosition(colle.id)}
+ // TODO: Implement favorite toggle
+ // onToggleFavorite={handleToggleFavorite}
+ // isFavorite={isFavorite(colle)}
+ />
+ ))}
+
+
+ ))}
+
+ )}
+ >
+ );
+}
diff --git a/app/components/ui/avatar.tsx b/app/components/ui/avatar.tsx
new file mode 100644
index 0000000..205a1a1
--- /dev/null
+++ b/app/components/ui/avatar.tsx
@@ -0,0 +1,51 @@
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "~/lib/utils"
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/app/components/ui/badge.tsx b/app/components/ui/badge.tsx
new file mode 100644
index 0000000..fc40406
--- /dev/null
+++ b/app/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "~/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span"
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/app/components/ui/calendar.tsx b/app/components/ui/calendar.tsx
new file mode 100644
index 0000000..052772f
--- /dev/null
+++ b/app/components/ui/calendar.tsx
@@ -0,0 +1,211 @@
+import * as React from "react";
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from "lucide-react";
+import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
+
+import { cn } from "~/lib/utils";
+import { Button, buttonVariants } from "~/components/ui/button";
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"];
+}) {
+ const defaultClassNames = getDefaultClassNames();
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString("default", { month: "short" }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn(
+ "flex gap-4 flex-col md:flex-row relative",
+ defaultClassNames.months
+ ),
+ month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
+ nav: cn(
+ "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
+ defaultClassNames.nav
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
+ defaultClassNames.button_previous
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
+ defaultClassNames.button_next
+ ),
+ month_caption: cn(
+ "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
+ defaultClassNames.month_caption
+ ),
+ dropdowns: cn(
+ "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
+ defaultClassNames.dropdowns
+ ),
+ dropdown_root: cn(
+ "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
+ defaultClassNames.dropdown_root
+ ),
+ dropdown: cn(
+ "absolute bg-popover inset-0 opacity-0",
+ defaultClassNames.dropdown
+ ),
+ caption_label: cn(
+ "select-none font-medium",
+ captionLayout === "label"
+ ? "text-sm"
+ : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
+ defaultClassNames.caption_label
+ ),
+ table: "w-full border-collapse",
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
+ defaultClassNames.weekday
+ ),
+ week: cn("flex w-full mt-2", defaultClassNames.week),
+ week_number_header: cn(
+ "select-none w-(--cell-size)",
+ defaultClassNames.week_number_header
+ ),
+ week_number: cn(
+ "text-[0.8rem] select-none text-muted-foreground",
+ defaultClassNames.week_number
+ ),
+ day: cn(
+ "relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
+ defaultClassNames.day
+ ),
+ range_start: cn(
+ "rounded-l-md bg-accent",
+ defaultClassNames.range_start
+ ),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
+ today: cn(
+ "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
+ defaultClassNames.today
+ ),
+ outside: cn(
+ "text-muted-foreground aria-selected:text-muted-foreground",
+ defaultClassNames.outside
+ ),
+ disabled: cn(
+ "text-muted-foreground opacity-50",
+ defaultClassNames.disabled
+ ),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ );
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return (
+
+ );
+ }
+
+ if (orientation === "right") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+ |
+ );
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ );
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames();
+
+ const ref = React.useRef(null);
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus();
+ }, [modifiers.focused]);
+
+ return (
+