feat: add menus (repas)
All checks were successful
Deploy to Netlify / Deploy to Netlify (push) Successful in 1m54s

This commit is contained in:
Nathan Lamy 2025-08-24 00:34:09 +02:00
parent b42236e730
commit 052e59f1ac
12 changed files with 387 additions and 48 deletions

View file

@ -27,6 +27,7 @@ import TabContent from "~/components/home/tab-content";
import { MainLayout } from "~/layout";
import { forceReload } from "~/lib/utils";
import { SyncButton } from "../sync-status";
import WeekNavigation from "./week-navigation";
export default function Home({ user }: { user: User }) {
// Handle query parameters
@ -54,16 +55,6 @@ export default function Home({ user }: { user: User }) {
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,
@ -188,22 +179,7 @@ export default function Home({ user }: { user: User }) {
</Tabs>
{/* Week Navigation */}
<div className="mb-0">
<div className="flex flex-row items-center justify-between gap-2">
<Button variant="outline" size="sm" onClick={handlePreviousWeek}>
<ChevronLeft className="h-10 w-10" />
</Button>
<div className="flex-1">
<DatePickerWithRange
startDate={startDate}
setStartDate={setStartDate}
/>
</div>
<Button variant="outline" size="sm" onClick={handleNextWeek}>
<ChevronRight className="h-10 w-10" />
</Button>
</div>
</div>
<WeekNavigation startDate={startDate} setStartDate={setStartDate} />
{/* Filter component */}
<div className="flex gap-2 pb-0 pt-2">

View file

@ -0,0 +1,41 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "../ui/button";
import DatePickerWithRange from "./date-picker";
import type { DateTime } from "luxon";
export default function WeekNavigation({
startDate,
setStartDate,
}: {
startDate: DateTime;
setStartDate: (date: DateTime) => void;
}) {
const handlePreviousWeek = () => {
const previousWeek = startDate.minus({ weeks: 1 });
setStartDate(previousWeek);
};
const handleNextWeek = () => {
const nextWeek = startDate.plus({ weeks: 1 });
setStartDate(nextWeek);
};
return (
<div className="mb-0">
<div className="flex flex-row items-center justify-between gap-2">
<Button variant="outline" size="sm" onClick={handlePreviousWeek}>
<ChevronLeft className="h-10 w-10" />
</Button>
<div className="flex-1">
<DatePickerWithRange
startDate={startDate}
setStartDate={setStartDate}
/>
</div>
<Button variant="outline" size="sm" onClick={handleNextWeek}>
<ChevronRight className="h-10 w-10" />
</Button>
</div>
</div>
);
}

View file

@ -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;
}) {
function handleDateSelect(selectedDate?: Date) {
if (selectedDate) {
setStartDate(DateTime.fromJSDate(selectedDate));
}
}
return (
<div className={cn("grid gap-2 w-full", className)}>
<Popover>
<PopoverTrigger asChild>
<button
id="date"
className={
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-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 h-9 px-4 py-2 has-[>svg]:px-3 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:hover:bg-input/50 " +
cn(
"w-full justify-start text-left font-normal",
!startDate && "text-muted-foreground"
)
}
>
<CalendarIcon className="mr-2 h-4 w-4" />
Menu du {formatDate(startDate, true)}
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={startDate.toJSDate()}
defaultMonth={startDate.startOf("month").toJSDate()}
onSelect={handleDateSelect}
weekStartsOn={1}
locale={fr}
className="rounded-md border shadow-sm"
captionLayout="dropdown"
/>
</PopoverContent>
</Popover>
</div>
);
}
const formatDate = (date: DateTime, includeYear = false) => {
const localDate = date.setLocale("fr");
return includeYear
? localDate.toFormat("dd MMM yyyy")
: localDate.toFormat("dd MMM");
};

View file

@ -0,0 +1,41 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "../ui/button";
import type { DateTime } from "luxon";
import DatePickerWithRange from "./date-picker";
export default function DayNavigation({
startDate,
setStartDate,
}: {
startDate: DateTime;
setStartDate: (date: DateTime) => void;
}) {
const handlePreviousDay = () => {
const previousDay = startDate.minus({ days: 1 });
setStartDate(previousDay);
};
const handleNextDay = () => {
const nextDay = startDate.plus({ days: 1 });
setStartDate(nextDay);
};
return (
<div className="mb-2">
<div className="flex flex-row items-center justify-between gap-2">
<Button variant="outline" size="sm" onClick={handlePreviousDay}>
<ChevronLeft className="h-10 w-10" />
</Button>
<div className="flex-1">
<DatePickerWithRange
startDate={startDate}
setStartDate={setStartDate}
/>
</div>
<Button variant="outline" size="sm" onClick={handleNextDay}>
<ChevronRight className="h-10 w-10" />
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,67 @@
import { useState } from "react";
import { Tabs, TabsList, tabsStyle, TabsTrigger } from "~/components/ui/tabs";
import BottomNavigation from "~/components/bottom-nav";
import { CalendarCheck, ChefHat } from "lucide-react";
import WIP from "./wip";
import Menus from "./menus";
const tabs = [
{
value: "menu",
label: "Menus",
icon: <ChefHat className="h-4 w-4" />,
content: <Menus />,
},
{
value: "bjrepas",
label: "BJ Repas",
icon: <CalendarCheck className="h-4 w-4" />,
content: <WIP />,
},
];
export default function RepasPage() {
// user / notifications / preferences tabs
const [activeTab, setActiveTab] = useState(tabs[0].value);
return (
<div className="space-y-6 pb-20 md:pb-0">
{/* Tabs */}
<Tabs
defaultValue={tabs[0].value}
value={activeTab}
onValueChange={setActiveTab}
className="max-w-md w-full"
>
<TabsList className="w-full p-0 bg-background justify-start border-b rounded-none">
{tabs.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={tabsStyle}
>
{tab.icon}
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{/* Tab Content */}
<div className="pt-2">
{tabs.map((tab) => (
<div
key={tab.value}
className={`${
activeTab === tab.value ? "block" : "hidden"
} transition-all duration-300`}
>
{tab.content}
</div>
))}
</div>
<BottomNavigation activeId="repas" />
</div>
);
}

View file

@ -0,0 +1,81 @@
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Utensils, Salad, Cake, CookingPot, Icon, Carrot } from "lucide-react";
import { cheese } from "@lucide/lab";
interface Course {
name: string;
description: string;
}
interface Meal {
name: string;
courses: Course[];
}
const getCourseIcon = (courseName: string) => {
const name = courseName.toLowerCase();
if (name.includes("hors")) return <Salad className="h-6 w-6" />;
if (name.includes("plat")) return <CookingPot className="h-6 w-6" />;
if (name.includes("garniture")) return <Carrot className="h-6 w-6" />;
if (name.includes("fromage"))
return <Icon iconNode={cheese} className="h-6 w-6" />;
if (name.includes("dessert")) return <Cake className="h-6 w-6" />;
return <Utensils className="h-6 w-6" />;
};
export function DailyMenu({ meals }: { meals: Meal[] }) {
if (meals.length === 0) {
return (
<div className="flex items-center justify-center p-4">
<p className="text-muted-foreground">
Aucun menu disponible pour ce jour.
</p>
</div>
);
}
return (
<div className="px-4 py-2">
<h2 className="text-2xl font-bold mb-4">
{meals.length > 1 ? "Menus" : "Menu"} du jour
</h2>
{meals.map((meal, mealIndex) => (
<Card key={mealIndex} className="w-full mb-6">
<CardHeader className="pb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div>
<CardTitle className="text-xl2 font-bold text-foreground capitalize">
<Utensils className="inline h-5 w-5 mr-4 text-primary" />
{meal.name}
</CardTitle>
</div>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 pb-4">
<div className="space-y-4">
{meal.courses.map((course, courseIndex) => (
<div key={courseIndex} className="flex items-start gap-3">
<div className="p-2 rounded-full bg-primary/10">
{getCourseIcon(course.name)}
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-semibold text-primary mb-1">
{course.name}
</h4>
<p className="text-sm text-muted-foreground leading-relaxed">
{course.description}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
);
}

View file

@ -0,0 +1,31 @@
import { useMenus } from "~/lib/api";
import { DailyMenu } from "./menu-card";
import { DateTime } from "luxon";
import { useState } from "react";
import DayNavigation from "./day-navigation";
export default function Menus() {
const { isLoading, error, menus } = useMenus();
const [date, setDate] = useState(DateTime.now().startOf("day"));
const findMenuForDate = (date: DateTime) => {
return menus?.find((menu: any) => {
const menuDate = DateTime.fromISO(menu.date).startOf("day");
return menuDate.equals(date);
});
};
return (
<div className="space-y-4">
{isLoading && <p>Chargement des menus...</p>}
{error && (
<p className="text-red-500">Erreur lors du chargement des menus.</p>
)}
{menus && menus.length === 0 && <p>Aucun menu disponible.</p>}
{/* Menus */}
<DayNavigation startDate={date} setStartDate={setDate} />
<DailyMenu meals={findMenuForDate(date)?.meals || []} />
</div>
);
}

View file

@ -0,0 +1,23 @@
import { Github } from "lucide-react";
import { Button } from "../ui/button";
export default function WIP() {
return (
<div className="text-center mt-10">
<h2 className="text-xl font-semibold">
Cette fonctionnalité nest pas encore implémentée.
</h2>
<p className="mt-4">
Vous pouvez contribuer au développement du projet ici :
</p>
<Button
className="mt-4"
variant="default"
onClick={() => window.open(import.meta.env.VITE_GITHUB_URL, "_blank")}
>
<Github />
Contribuer sur GitHub
</Button>
</div>
);
}

View file

@ -457,3 +457,26 @@ export const useAverages = (period: string) => {
...props,
};
};
/**
* === REPAS API ===
*/
const fetchMenus = async () => {
return makeRequest(`/menus`, "Échec de la récupération des menus");
};
export const useMenus = () => {
const { data, ...props } = useQuery({
queryKey: ["menus"],
queryFn: fetchMenus,
staleTime: Duration.fromObject({
hours: 6, // 6 hours
}).toMillis(),
gcTime: Duration.fromObject({
days: 1, // 1 day
}).toMillis(),
});
return {
menus: data || [],
...props,
};
};

View file

@ -4,9 +4,7 @@ import Loader from "~/components/loader";
import { MainLayout } from "~/layout";
import { AUTH_ERROR, useUser } from "~/lib/api";
import { forceReload } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import { Github } from "lucide-react";
import BottomNavigation from "~/components/bottom-nav";
import RepasPage from "~/components/repas";
export default function Repas() {
const { user, isLoading, error } = useUser();
@ -29,25 +27,7 @@ export default function Repas() {
</h1>
}
>
{/* Under construction message */}
<div className="text-center mt-10">
<h2 className="text-xl font-semibold">
Cette fonctionnalité nest pas encore implémentée.
</h2>
<p className="mt-4">
Vous pouvez contribuer au développement du projet ici :
</p>
<Button
className="mt-4"
variant="default"
onClick={() => window.open(import.meta.env.VITE_GITHUB_URL, "_blank")}
>
<Github />
Contribuer sur GitHub
</Button>
</div>
<BottomNavigation activeId="repas" />
<RepasPage />
</MainLayout>
);
}

View file

@ -10,6 +10,7 @@
"deploy": "netlify deploy --prod --dir=./build/client --message=\"Deploy to Netlify from Forgejo\" --site=$NETLIFY_SITE_ID --auth=$NETLIFY_AUTH_TOKEN --no-build"
},
"dependencies": {
"@lucide/lab": "^0.1.2",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.10",

8
pnpm-lock.yaml generated
View file

@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@lucide/lab':
specifier: ^0.1.2
version: 0.1.2
'@marsidev/react-turnstile':
specifier: ^1.1.0
version: 1.1.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@ -1255,6 +1258,9 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@lucide/lab@0.1.2':
resolution: {integrity: sha512-VprF2BJa7ZuTGOhUd5cf8tHJXyL63wdxcGieAiVVoR9hO0YmPsnZO0AGqDiX2/br+/MC6n8BoJcmPilltOXIJA==}
'@lukeed/ms@2.0.2':
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'}
@ -7804,6 +7810,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.4
'@lucide/lab@0.1.2': {}
'@lukeed/ms@2.0.2': {}
'@mapbox/node-pre-gyp@2.0.0(supports-color@10.0.0)':