feat: add menus (repas)
All checks were successful
Deploy to Netlify / Deploy to Netlify (push) Successful in 1m54s
All checks were successful
Deploy to Netlify / Deploy to Netlify (push) Successful in 1m54s
This commit is contained in:
parent
b42236e730
commit
052e59f1ac
12 changed files with 387 additions and 48 deletions
|
|
@ -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">
|
||||
|
|
|
|||
41
app/components/home/week-navigation.tsx
Normal file
41
app/components/home/week-navigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
app/components/repas/date-picker.tsx
Normal file
67
app/components/repas/date-picker.tsx
Normal 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");
|
||||
};
|
||||
41
app/components/repas/day-navigation.tsx
Normal file
41
app/components/repas/day-navigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
app/components/repas/index.tsx
Normal file
67
app/components/repas/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
app/components/repas/menu-card.tsx
Normal file
81
app/components/repas/menu-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
app/components/repas/menus.tsx
Normal file
31
app/components/repas/menus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
app/components/repas/wip.tsx
Normal file
23
app/components/repas/wip.tsx
Normal 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é n’est 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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é n’est 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
8
pnpm-lock.yaml
generated
|
|
@ -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)':
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue