Compare commits

...

4 commits

Author SHA1 Message Date
Nathan Lamy
d4f1bb5dcd feat: add install prompt
All checks were successful
Deploy to Netlify / Deploy to Netlify (push) Successful in 1m52s
2025-08-19 21:59:56 +02:00
Nathan Lamy
6428126514 feat: add attachments 2025-08-19 20:07:32 +02:00
Nathan Lamy
a89546c54e feat: add last sync and health 2025-08-19 19:32:03 +02:00
Nathan Lamy
74f31a723a feat: add sync status 2025-08-19 18:36:07 +02:00
19 changed files with 998 additions and 309 deletions

View file

@ -1,16 +1,16 @@
import { FileText, Image, File } from "lucide-react"; import { FileText, Image, File } from "lucide-react";
import type { Attachment } from "~/lib/api";
export default function AttachmentItem({ attachment }: { attachment: string }) { export default function AttachmentItem({ attachment }: { attachment: Attachment }) {
return ( return (
<a <a
// TODO: BAD: hardcoded URL, should be dynamic (environment variable or config) href={"https://bjcolle.fr/" + attachment.path}
href={"https://bjcolle.fr/" + attachment}
target="_blank" target="_blank"
className="flex items-center gap-2 p-3 border rounded-md hover:bg-muted transition-colors cursor-pointer" className="flex items-center gap-2 p-3 border rounded-md hover:bg-muted transition-colors cursor-pointer"
> >
{getIcon(attachment)} {getIcon(attachment.name)}
<span className="font-medium truncate"> <span className="font-medium truncate">
{getName(attachment) || "Sans Nom"} {attachment.name}
</span> </span>
</a> </a>
); );
@ -44,9 +44,3 @@ const getIcon = (attachment: string) => {
return <File className="h-5 w-5 text-gray-500" />; return <File className="h-5 w-5 text-gray-500" />;
} }
}; };
const getName = (attachment: string) => {
const parts = attachment.replace("pj_doc", "").split("_");
const nameParts = parts.slice(2); // remove the first two parts
return nameParts.join("_");
};

View file

@ -2,7 +2,7 @@ import type { Colle, UserPreferences } from "~/lib/api";
import { Link } from "react-router"; import { Link } from "react-router";
import { Card } from "~/components/ui/card"; import { Card } from "~/components/ui/card";
import { User, Star, CalendarDays, MapPin } from "lucide-react"; import { User, Star, CalendarDays, MapPin, Paperclip } from "lucide-react";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { import {
cn, cn,
@ -35,8 +35,11 @@ export default function ColleCard({
// onToggleFavorite(colle.id, newValue); // onToggleFavorite(colle.id, newValue);
// }; // };
const subjectColor = getColorClass(getSubjectColor(colle.subject.name, preferences)); const subjectColor = getColorClass(
getSubjectColor(colle.subject.name, preferences)
);
const subjectEmoji = getSubjectEmoji(colle.subject.name, preferences); const subjectEmoji = getSubjectEmoji(colle.subject.name, preferences);
const attachmentsCount = colle.attachments?.length || 0;
return ( return (
<Link to={`/colles/${colle.id}`}> <Link to={`/colles/${colle.id}`}>
@ -114,13 +117,12 @@ export default function ColleCard({
</Badge> </Badge>
)} )}
</div> </div>
{/* TODO: Attachments */} {attachmentsCount > 0 && (
{/* {colle.attachmentsCount > 0 && ( <div className="flex items-center gap-1 text-muted-foreground">
<div className="flex items-center gap-1 text-muted-foreground"> <Paperclip className="h-3.5 w-3.5" />
<Paperclip className="h-3.5 w-3.5" /> <span className="text-xs">{attachmentsCount}</span>
<span className="text-xs">{colle.attachmentsCount}</span> </div>
</div> )}
)} */}
</div> </div>
</div> </div>
</div> </div>

View file

@ -24,6 +24,9 @@ import Error from "~/components/error";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import { useColles, type User } from "~/lib/api"; import { useColles, type User } from "~/lib/api";
import TabContent from "~/components/home/tab-content"; import TabContent from "~/components/home/tab-content";
import { MainLayout } from "~/layout";
import { forceReload } from "~/lib/utils";
import { SyncButton } from "../sync-status";
export default function Home({ user }: { user: User }) { export default function Home({ user }: { user: User }) {
// Handle query parameters // Handle query parameters
@ -62,8 +65,15 @@ export default function Home({ user }: { user: User }) {
}; };
// Fetch colles from API // Fetch colles from API
const { studentColles, classColles, favoriteColles, error, isLoading } = const {
useColles(startDate); studentColles,
classColles,
favoriteColles,
healthyUntil,
lastSync,
error,
isLoading,
} = useColles(startDate);
// Error handling (after all hooks) // Error handling (after all hooks)
if (error) if (error)
@ -143,106 +153,116 @@ export default function Home({ user }: { user: User }) {
}; };
return ( return (
<div className="space-y-6 pb-20 md:pb-0"> <MainLayout
{/* Tabs */} header={
<Tabs <>
defaultValue="all" <h1 className="text-2xl font-bold" onClick={forceReload}>
value={activeTab} Khollis&eacute; - {user.className}
onValueChange={setActiveTab} </h1>
className="max-w-md w-full" <SyncButton healthyUntil={healthyUntil} lastSync={lastSync} />
> </>
<TabsList className="w-full p-0 bg-background justify-start border-b rounded-none"> }
<TabsTrigger value="you" className={tabsStyle}> >
<UserIcon className="h-4 w-4" /> <div className="space-y-6 pb-20 md:pb-0">
Vous {/* Tabs */}
</TabsTrigger> <Tabs
{/* <TabsTrigger value="favorites" className={tabsStyle}> defaultValue="all"
value={activeTab}
onValueChange={setActiveTab}
className="max-w-md w-full"
>
<TabsList className="w-full p-0 bg-background justify-start border-b rounded-none">
<TabsTrigger value="you" className={tabsStyle}>
<UserIcon className="h-4 w-4" />
Vous
</TabsTrigger>
{/* <TabsTrigger value="favorites" className={tabsStyle}>
<Star className="h-4 w-4" /> <Star className="h-4 w-4" />
Favoris Favoris
</TabsTrigger> */} </TabsTrigger> */}
<TabsTrigger value="class" className={tabsStyle}> <TabsTrigger value="class" className={tabsStyle}>
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
Classe Classe
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
{/* Week Navigation */} {/* Week Navigation */}
<div className="mb-0"> <div className="mb-0">
<div className="flex flex-row items-center justify-between gap-2"> <div className="flex flex-row items-center justify-between gap-2">
<Button variant="outline" size="sm" onClick={handlePreviousWeek}> <Button variant="outline" size="sm" onClick={handlePreviousWeek}>
<ChevronLeft className="h-10 w-10" /> <ChevronLeft className="h-10 w-10" />
</Button> </Button>
<div className="flex-1"> <div className="flex-1">
<DatePickerWithRange <DatePickerWithRange
startDate={startDate} startDate={startDate}
setStartDate={setStartDate} setStartDate={setStartDate}
/> />
</div>
<Button variant="outline" size="sm" onClick={handleNextWeek}>
<ChevronRight className="h-10 w-10" />
</Button>
</div> </div>
<Button variant="outline" size="sm" onClick={handleNextWeek}> </div>
<ChevronRight className="h-10 w-10" />
{/* Filter component */}
<div className="flex gap-2 pb-0 pt-2">
<Select value={subjectFilter} onValueChange={setSubject}>
<SelectTrigger className="rounded-full data-[placeholder]:text-primary">
<SelectValue placeholder="Matière" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes</SelectItem>
{subjects.map((subject) => (
<SelectItem key={subject} value={subject}>
{subject}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={examinerFilter} onValueChange={setExaminer}>
<SelectTrigger className="rounded-full data-[placeholder]:text-primary">
<SelectValue placeholder="Colleur" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous</SelectItem>
{examiners.map((examiner) => (
<SelectItem key={examiner} value={examiner}>
{examiner}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
className="rounded-full dark:bg-input/30 text-primary font-normal"
onClick={toggleSort}
>
{sorted == "asc" ? (
<SortAsc className="h-5 w-5" />
) : (
<SortDesc className="h-5 w-5" />
)}
Trier
</Button> </Button>
</div> </div>
</div>
{/* Filter component */} {/* Your Colles Tab */}
<div className="flex gap-1 pb-0 pt-2"> {activeTab === "you" && (
<Select value={subjectFilter} onValueChange={setSubject}> <TabContent
<SelectTrigger className="rounded-full data-[placeholder]:text-primary"> tabTitle="Vos colles"
<SelectValue placeholder="Matière" /> emptyCollesText="Vous n'avez pas encore de colle cette semaine."
</SelectTrigger> isLoading={isLoading}
<SelectContent> isSorted={sorted === "desc"}
<SelectItem value="all">Toutes</SelectItem> colles={applyFilters(studentColles)}
{subjects.map((subject) => ( preferences={user.preferences}
<SelectItem key={subject} value={subject}> />
{subject} )}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={examinerFilter} onValueChange={setExaminer}> {/* Favorites Tab
<SelectTrigger className="rounded-full data-[placeholder]:text-primary">
<SelectValue placeholder="Colleur" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous</SelectItem>
{examiners.map((examiner) => (
<SelectItem key={examiner} value={examiner}>
{examiner}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
className="rounded-full dark:bg-input/30 text-primary font-normal"
onClick={toggleSort}
>
{sorted == "asc" ? (
<SortAsc className="h-5 w-5" />
) : (
<SortDesc className="h-5 w-5" />
)}
Trier
</Button>
</div>
{/* Your Colles Tab */}
{activeTab === "you" && (
<TabContent
tabTitle="Vos colles"
emptyCollesText="Vous n'avez pas encore de colle cette semaine."
isLoading={isLoading}
isSorted={sorted === "desc"}
colles={applyFilters(studentColles)}
preferences={user.preferences}
/>
)}
{/* Favorites Tab
{activeTab === "favorites" && ( {activeTab === "favorites" && (
<TabContent <TabContent
tabTitle="Vos favoris" tabTitle="Vos favoris"
@ -252,20 +272,21 @@ export default function Home({ user }: { user: User }) {
/> />
)} */} )} */}
{/* Class Colles Tab */} {/* Class Colles Tab */}
{activeTab === "class" && ( {activeTab === "class" && (
<TabContent <TabContent
tabTitle="Les colles de la classe" tabTitle="Les colles de la classe"
emptyCollesText="Aucune colle trouvée." emptyCollesText="Aucune colle trouvée."
isLoading={isLoading} isLoading={isLoading}
isSorted={sorted === "desc"} isSorted={sorted === "desc"}
colles={applyFilters(classColles)} colles={applyFilters(classColles)}
preferences={user.preferences} preferences={user.preferences}
/> />
)} )}
{/* Bottom Navigation for Mobile */} {/* Bottom Navigation for Mobile */}
<BottomNavigation activeId="colles" /> <BottomNavigation activeId="colles" />
</div> </div>
</MainLayout>
); );
} }

View file

@ -5,6 +5,7 @@ import Preferences from "./preferences";
import BottomNavigation from "~/components/bottom-nav"; import BottomNavigation from "~/components/bottom-nav";
import Profile from "./profile"; import Profile from "./profile";
import type { User } from "~/lib/api"; import type { User } from "~/lib/api";
import NotificationSettings from "./notifications";
export default function SettingsPage({ user }: { user: User }) { export default function SettingsPage({ user }: { user: User }) {
const tabs = [ const tabs = [
@ -12,7 +13,7 @@ export default function SettingsPage({ user }: { user: User }) {
value: "user", value: "user",
label: "Profil", label: "Profil",
icon: <UserIcon className="h-4 w-4" />, icon: <UserIcon className="h-4 w-4" />,
content: <Profile />, content: <Profile user={user} />,
}, },
{ {
value: "preferences", value: "preferences",
@ -24,7 +25,7 @@ export default function SettingsPage({ user }: { user: User }) {
value: "notifications", value: "notifications",
label: "Notifications", label: "Notifications",
icon: <Bell className="h-4 w-4" />, icon: <Bell className="h-4 w-4" />,
content: <div>WIP</div>, content: <NotificationSettings user={user} />,
}, },
]; ];

View file

@ -0,0 +1,120 @@
import {
ChevronDown,
Chrome,
Info,
MoreVertical,
Share,
Smartphone,
} from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import { Button } from "../ui/button";
import { CardDescription } from "../ui/card";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@radix-ui/react-accordion";
import { Separator } from "../ui/separator";
import { useEffect, useState } from "react";
export default function InstallApp() {
const [supportsPWA, setSupportsPWA] = useState(false);
const [promptInstall, setPromptInstall] = useState<any>(null);
useEffect(() => {
const handler = (e: any) => {
e.preventDefault();
console.log("we are being triggered :D");
setSupportsPWA(true);
setPromptInstall(e);
};
window.addEventListener("beforeinstallprompt", handler);
return () => window.removeEventListener("transitionend", handler);
}, []);
const onClick = (evt: React.MouseEvent<HTMLButtonElement>) => {
evt.preventDefault();
if (!promptInstall) {
return;
}
promptInstall.prompt();
};
return (
<>
<Alert className="bg-blue-50 border border-blue-200 dark:bg-blue-900 dark:border-blue-700">
<Info className="text-blue-600! dark:text-blue-400! h-5 w-5" />
<AlertTitle className="text-blue-900 dark:text-white">
Informations importantes
</AlertTitle>
<AlertDescription>
<ul className="space-y-1 text-blue-800 dark:text-blue-300">
<li>
Pour recevoir des notifications, vous devez installer notre
application sur votre appareil.
</li>
<li>
Vous devrez autoriser les notifications lorsque l'application
vous le demandera.
</li>
</ul>
</AlertDescription>
</Alert>
{/* Install PWA button */}
<Button
className="w-full"
onClick={onClick}
disabled={!supportsPWA}
>
<Smartphone className="h-4 w-4 sm:h-5 sm:w-5 mr-2" />
Installer l'application
</Button>
<Separator />
<CardDescription>
Si le bouton ci-dessus ne fonctionne pas, suivez ces étapes :
</CardDescription>
<Accordion type="multiple" className="w-full space-y-4">
<AccordionItem value="android">
<AccordionTrigger className="flex items-center gap-1 group">
<h4>Instructions Android (Chrome)</h4>
<ChevronDown className="w-4 h-4 text-primary transition-transform group-data-[state=open]:rotate-180" />
</AccordionTrigger>
<AccordionContent>
<ol className="space-y-2 list-decimal list-inside pl-6 text-muted-foreground text-sm">
<li>Ouvrez ce site avec Google Chrome</li>
<li>Appuyez sur les trois points en haut à droite</li>
<li>Sélectionnez "Ajouter à l'écran d'accueil"</li>
<li>Confirmez pour installer l'application</li>
</ol>
</AccordionContent>
</AccordionItem>
<AccordionItem value="ios">
<AccordionTrigger className="flex items-center gap-1 group">
<h4>Instructions iOS (Safari)</h4>
<ChevronDown className="w-4 h-4 text-primary transition-transform group-data-[state=open]:rotate-180" />
</AccordionTrigger>
<AccordionContent>
<ol className="space-y-2 list-decimal list-inside pl-6 text-muted-foreground text-sm">
<li>Ouvrez ce site avec Safari</li>
<li>
Appuyez sur l'icône de partage en bas (le carré avec une flèche)
</li>
<li>
Faites défiler vers le bas et sélectionnez "Sur l'écran
d'accueil"
</li>
<li>Confirmez pour installer l'application</li>
</ol>
</AccordionContent>
</AccordionItem>
</Accordion>
</>
);
}

View file

@ -0,0 +1,247 @@
import { useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Switch } from "~/components/ui/switch";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import {
Bell,
Smartphone,
Monitor,
Trash2,
Send,
AlertTriangle,
Info,
} from "lucide-react";
import InstallApp from "./install-app";
export default function NotificationSettings() {
// TODO: Replace with actual user data
const [pushEnabled, setPushEnabled] = useState(false);
const [events, setEvents] = useState([
{ id: "new-message", label: "New Message", enabled: true },
{ id: "comment-reply", label: "Comment Reply", enabled: true },
{ id: "app-update", label: "App Update", enabled: false },
{ id: "weekly-digest", label: "Weekly Digest", enabled: true },
{ id: "security-alert", label: "Security Alert", enabled: true },
{ id: "friend-request", label: "Friend Request", enabled: false },
]);
const [subscriptions] = useState([
{
id: "1",
name: "iPhone 15 Pro",
type: "mobile",
status: "active",
lastSeen: "2 hours ago",
},
{
id: "2",
name: "MacBook Pro",
type: "desktop",
status: "active",
lastSeen: "5 minutes ago",
},
{
id: "3",
name: "Chrome on Windows",
type: "desktop",
status: "revoked",
lastSeen: "3 days ago",
},
]);
const toggleEvent = (eventId: string) => {
setEvents(
events.map((event) =>
event.id === eventId ? { ...event, enabled: !event.enabled } : event
)
);
};
return (
<div className="max-w-4xl mx-auto space-y-6 sm:space-y-8">
<div className="space-y-2">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">
Paramètres de notifications
</h1>
<p className="text-sm sm:text-base text-muted-foreground">
Gérez vos préférences de notification et vos appareils abonnés.
</p>
</div>
{/* Enable Push Notifications Section */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Bell className="h-5 w-5" />
<CardTitle>Activer les notifications</CardTitle>
</div>
<CardDescription>
Autorisez cette application à vous envoyer des notifications push.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* <div className="flex items-start sm:items-center justify-between gap-4">
<div className="space-y-1 flex-1">
<p className="font-medium text-sm sm:text-base">
Notifications
</p>
<p className="text-xs sm:text-sm text-muted-foreground">
Recevez des notifications même lorsque lapplication est fermée.
</p>
</div>
<Switch
checked={pushEnabled}
onCheckedChange={setPushEnabled}
aria-label="Enable push notifications"
className="flex-shrink-0"
/>
</div> */}
<InstallApp />
</CardContent>
</Card>
{/* Select Notification Events Section */}
<Card>
<CardHeader>
<CardTitle>Select Notification Events</CardTitle>
<CardDescription>
Choose which events you want to receive notifications for
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3 sm:space-y-4">
{events.map((event, index) => (
<div key={event.id}>
<div className="flex items-start sm:items-center justify-between py-2 gap-4">
<div className="space-y-1 flex-1 min-w-0">
<p className="font-medium text-sm sm:text-base">
{event.label}
</p>
<p className="text-xs sm:text-sm text-muted-foreground leading-relaxed">
{event.id === "new-message" &&
"Get notified when you receive a new message"}
{event.id === "comment-reply" &&
"Get notified when someone replies to your comment"}
{event.id === "app-update" &&
"Get notified when a new app version is available"}
{event.id === "weekly-digest" &&
"Receive a weekly summary of your activity"}
{event.id === "security-alert" &&
"Important security notifications and alerts"}
{event.id === "friend-request" &&
"Get notified when someone sends you a friend request"}
</p>
</div>
<Switch
checked={event.enabled}
onCheckedChange={() => toggleEvent(event.id)}
aria-label={`Toggle ${event.label} notifications`}
className="flex-shrink-0"
/>
</div>
{index < events.length - 1 && (
<Separator className="mt-3 sm:mt-4" />
)}
</div>
))}
</div>
</CardContent>
</Card>
{/* Manage Subscriptions Section */}
<Card>
<CardHeader>
<CardTitle>Manage Subscriptions</CardTitle>
<CardDescription>
View and manage devices that can receive notifications
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3 sm:space-y-4">
{subscriptions.map((subscription, index) => (
<div key={subscription.id}>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex-shrink-0">
{subscription.type === "mobile" ? (
<Smartphone className="h-4 w-4 sm:h-5 sm:w-5 text-muted-foreground" />
) : (
<Monitor className="h-4 w-4 sm:h-5 sm:w-5 text-muted-foreground" />
)}
</div>
<div className="space-y-1 flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-sm sm:text-base truncate">
{subscription.name}
</p>
{subscription.status === "revoked" && (
<Badge
variant="destructive"
className="flex items-center gap-1 text-xs"
>
<AlertTriangle className="h-3 w-3" />
Revoked
</Badge>
)}
{subscription.status === "active" && (
<Badge variant="secondary" className="text-xs">
Active
</Badge>
)}
</div>
<p className="text-xs sm:text-sm text-muted-foreground">
Last seen {subscription.lastSeen}
</p>
</div>
</div>
<div className="flex items-center gap-2 sm:gap-2 self-start sm:self-center">
<Button
variant="outline"
size="sm"
disabled={subscription.status === "revoked"}
className="flex items-center gap-1 bg-transparent text-xs sm:text-sm px-2 sm:px-3"
>
<Send className="h-3 w-3 sm:h-4 sm:w-4" />
<span className="hidden xs:inline">Test</span>
</Button>
<Button
variant="outline"
size="sm"
className="flex items-center gap-1 text-destructive hover:text-destructive bg-transparent text-xs sm:text-sm px-2 sm:px-3"
>
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
<span className="hidden xs:inline">Delete</span>
</Button>
</div>
</div>
{index < subscriptions.length - 1 && (
<Separator className="mt-3 sm:mt-4" />
)}
</div>
))}
{subscriptions.length === 0 && (
<div className="text-center py-6 sm:py-8 text-muted-foreground">
<Bell className="h-10 w-10 sm:h-12 sm:w-12 mx-auto mb-3 sm:mb-4 opacity-50" />
<p className="text-sm sm:text-base">
No subscribed devices found
</p>
<p className="text-xs sm:text-sm">
Enable push notifications to add this device
</p>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { Palette, Save, Undo } from "lucide-react"; import { Moon, Palette, Save, Sun, Undo } from "lucide-react";
import { import {
Card, Card,
CardHeader, CardHeader,
@ -24,10 +24,13 @@ import EmojiInput from "./emoji-input";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useDisplayedTheme } from "../theme-provider";
export default function Preferences({ user }: { user: User }) { export default function Preferences({ user }: { user: User }) {
const [preferences, setPreferences] = useState(user.preferences || []); const [preferences, setPreferences] = useState(user.preferences || []);
const [unsavedChanges, setUnsavedChanges] = useState(false); const [unsavedChanges, setUnsavedChanges] = useState(false);
const { theme, setTheme } = useDisplayedTheme();
// TODO: Add a loading state and error handling // TODO: Add a loading state and error handling
const { subjects, isLoading, isError } = useSubjects(); const { subjects, isLoading, isError } = useSubjects();
@ -72,7 +75,7 @@ export default function Preferences({ user }: { user: User }) {
// Invalidate the user query to refresh the data // Invalidate the user query to refresh the data
const queryClient = useQueryClient(); const queryClient = useQueryClient();
queryClient.removeQueries({ queryKey: ["user"] }); queryClient.removeQueries({ queryKey: ["user"] });
toast.success("Vos préférences ont été sauvegardé avec succès !"); toast.success("Vos préférences ont été sauvegardé avec succès !");
setUnsavedChanges(false); setUnsavedChanges(false);
}) })
@ -87,6 +90,41 @@ export default function Preferences({ user }: { user: User }) {
return ( return (
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
{/* Theme card: darmode light mode */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Moon className="h-5 w-5" />
Thème
</CardTitle>
<CardDescription>
Personnalisez l'apparence de l'application.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* TODO: Save user theme */}
{/* Theme toggle button */}
<Button
variant="default"
size="sm"
className="w-full"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
{theme === "dark" ? (
<>
<Sun className="mr-2 h-4 w-4" />
<span>Activer le mode clair</span>
</>
) : (
<>
<Moon className="mr-2 h-4 w-4" />
<span>Activer le mode sombre</span>
</>
)}
</Button>
</CardContent>
</Card>
{/* Subject Customization Section */} {/* Subject Customization Section */}
<Card> <Card>
<CardHeader> <CardHeader>

View file

@ -1,14 +1,88 @@
import { Trash } from "lucide-react"; import { LogOut, Trash } from "lucide-react";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { clearCache } from "~/lib/utils"; import { clearCache } from "~/lib/utils";
import { Card, CardContent } from "../ui/card";
import { Avatar, AvatarFallback } from "../ui/avatar";
import { logout, type User } from "~/lib/api";
import { Label } from "../ui/label";
import { Badge } from "../ui/badge";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
export default function Profile({ user }: { user: User }) {
const navigate = useNavigate();
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();
export default function Profile() {
return ( return (
<div> <Card className="w-full max-w-2xl mx-auto shadow-lg py-6 pb-0">
<Button variant="destructive" className="w-full text-white" onClick={clearCache}> <CardContent className="space-y-6">
<Trash className="mr-2 h-4 w-4" /> {/* Avatar Section */}
Vider le cache <div className="flex flex-col sm:flex-row items-center gap-4">
</Button> <div className="relative">
</div> <Avatar className="h-20 w-20 sm:h-24 sm:w-24">
{/* <AvatarImage src={profile.avatar || "/placeholder.svg"} alt={profile.name} /> */}
<AvatarFallback className="text-lg">
{user.fullName
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
</div>
<div className="text-center sm:text-left">
<h3 className="text-xl font-semibold">{user.fullName}</h3>
<p className="text-muted-foreground">{user.email}</p>
<Badge className="mt-2">{user.className}</Badge>
</div>
</div>
{/* Form Fields */}
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="name">Nom & Prénom</Label>
<p className="px-3 py-2 bg-muted rounded-md">{user.fullName}</p>
</div>
<div className="grid gap-2">
<Label htmlFor="email">Adresse Email</Label>
<p className="px-3 py-2 bg-muted rounded-md">{user.email}</p>
</div>
</div>
{/* Logout Button */}
<Button
variant="destructive"
className="w-full text-white"
onClick={logout}
>
<LogOut className="mr-2 h-4 w-4" />
Se déconnecter
</Button>
<Button
variant="outline"
className="w-full text-white"
onClick={clearCache}
>
<Trash className="mr-2 h-4 w-4" />
Vider le cache
</Button>
</CardContent>
</Card>
); );
} }

View file

@ -0,0 +1,105 @@
import { useState } from "react";
import { Button } from "~/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { Cloud, CloudOff, Wifi, WifiOff } from "lucide-react";
import { cn } from "~/lib/utils";
export function SyncButton({
healthyUntil,
lastSync,
}: {
healthyUntil: Date;
lastSync: Date;
}) {
const healthyUntilDate = new Date(healthyUntil) || new Date(0);
const lastSyncDate = new Date(lastSync) || new Date(0);
// Determine if the sync is healthy based on the healthyUntil date
const isSync = healthyUntilDate > new Date();
const [isOpen, setIsOpen] = useState(false);
const getIcon = () => {
if (isSync) return <Cloud className="w-6! h-6!" />;
return <CloudOff className="w-6! h-6!" />;
};
const getStatusColor = () => {
if (isSync) return "text-primary";
return "text-muted-foreground";
};
const formatLastSync = (date: Date | null) => {
if (!date) return "N/A";
const now = new Date();
const diff = now.getTime() - date.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (minutes < 1) return `il y a ${seconds}s`;
if (minutes < 60) return `il y a ${minutes}m`;
if (hours < 24) return `il y a ${hours}h`;
return `il y a ${days}j`;
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn("h-9 w-9 p-0 transition-colors", getStatusColor())}
// TODO: Implement sync functionality
onClick={() => {}}
>
{getIcon()}
<span className="sr-only">{isSync ? "Sync" : "Offline"}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className={cn("flex items-center gap-2", getStatusColor())}>
{isSync ? (
<Wifi className="h-4 w-4" />
) : (
<WifiOff className="h-4 w-4" />
)}
<span className="font-medium">Status</span>
</div>
<div
className={cn(
"ml-auto px-2 py-1 rounded-full text-xs font-medium",
isSync
? "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400"
: "bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400",
)}
>
{isSync ? "Connecté" : "Hors ligne"}
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">
Dernière mis à jour :
</span>
<span>{formatLastSync(lastSyncDate)}</span>
</div>
</div>
{/* TODO:
{syncStatus.isOnline && syncStatus.status !== "syncing" && (
<Button size="sm" className="w-full" onClick={handleSync}>
Sync Now
</Button>
)} */}
</div>
</PopoverContent>
</Popover>
);
}

View file

@ -0,0 +1,64 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View file

@ -0,0 +1,29 @@
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "~/lib/utils";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

View file

@ -1,127 +0,0 @@
import { useState } from "react";
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import {
ChevronDown,
LineChartIcon,
LogOut,
Moon,
Settings,
Sun,
} from "lucide-react";
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 }) {
const { theme, setTheme } = useDisplayedTheme();
const [open, setOpen] = useState(false);
const navigate = useNavigate();
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}>
<DropdownMenuTrigger asChild>
<button
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 hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 flex items-center gap-2 px-2"
}
>
<Avatar className="h-10 w-10">
<AvatarFallback>{getAvatar(user.fullName)}</AvatarFallback>
</Avatar>
<span className="hidden md:inline-block font-medium">
{user.fullName}
</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<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-xs leading-none text-muted-foreground">
{user.className}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
{theme === "dark" ? (
<>
<Sun className="mr-2 h-4 w-4" />
<span>Mode clair</span>
</>
) : (
<>
<Moon className="mr-2 h-4 w-4" />
<span>Mode sombre</span>
</>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
navigate("/settings");
}}
>
<Settings className="mr-2 h-4 w-4" />
<span>Paramètres</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
navigate("/progress");
}}
>
<LineChartIcon className="mr-2 h-4 w-4" />
<span>Progression</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
<span>Se déconnecter</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
const getAvatar = (name: string) => {
return name
.split(" ")
.map((word) => word.charAt(0))
.join("")
.toUpperCase();
};

View file

@ -51,18 +51,18 @@ export function AuthLayout({
export function MainLayout({ export function MainLayout({
children, children,
page header
}: { }: {
children: React.ReactNode; children: React.ReactNode;
page: React.ReactNode; header: React.ReactNode;
}) { }) {
return ( return (
<main className="container mx-auto py-8 px-4"> <main className="container mx-auto py-8 px-4">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-4">
{children} {header}
</div> </div>
{page} {children}
</main> </main>
); );
} }

View file

@ -182,13 +182,15 @@ export interface Colle {
bjid?: string; // Optional field bjid?: string; // Optional field
content?: string; // Optional field content?: string; // Optional field
comment?: string; // Optional field comment?: string; // Optional field
attachments?: string[]; // Optional field, array of attachment URLs attachments?: Attachment[]; // Optional field, array of attachment URLs
} }
interface CollePayload { interface CollePayload {
classColles: Colle[]; classColles: Colle[];
studentColles: Colle[]; studentColles: Colle[];
favoriteColles: Colle[]; favoriteColles: Colle[];
healthyUntil: Date;
lastSync: Date;
} }
export const useColles = (startDate: DateTime) => { export const useColles = (startDate: DateTime) => {
@ -206,7 +208,9 @@ export const useColles = (startDate: DateTime) => {
gcTime: 0, gcTime: 0,
} }
: { : {
staleTime: 0, staleTime: Duration.fromObject({
hours: 1, // 1 hour
}).toMillis(),
gcTime: Duration.fromObject({ gcTime: Duration.fromObject({
days: 3, // 3 days days: 3, // 3 days
}).toMillis(), }).toMillis(),
@ -222,6 +226,8 @@ export const useColles = (startDate: DateTime) => {
classColles: [], classColles: [],
studentColles: [], studentColles: [],
favoriteColles: [], favoriteColles: [],
healthyUntil: new Date(0),
lastSync: new Date(0),
}, },
data || {} data || {}
) as CollePayload; ) as CollePayload;
@ -272,6 +278,20 @@ export const useColle = (id: number) => {
}; };
}; };
export const refreshColle = async (id: number) => {
return makePostRequest(
`/colles/${id}/refresh`,
{},
"Échec de la demande de rafraîchissement de la colle",
"POST"
);
};
export interface Attachment {
path: string;
name: string;
}
/** /**
* === SUBJECTS API === * === SUBJECTS API ===
*/ */

View file

@ -16,20 +16,19 @@ import {
ChevronUp, ChevronUp,
ChevronDown, ChevronDown,
MapPinHouse, MapPinHouse,
Users,
RefreshCw, RefreshCw,
Share2, Share2,
ExternalLink, ExternalLink,
} from "lucide-react"; } from "lucide-react";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import UserDropdown from "~/components/user-dropdown";
import ColleDetailsSkeleton from "~/components/details/skeleton-details"; import ColleDetailsSkeleton from "~/components/details/skeleton-details";
import AttachmentItem from "~/components/details/attachment"; import AttachmentItem from "~/components/details/attachment";
import Error from "~/components/error"; import Error from "~/components/error";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { AUTH_ERROR, useColle, useUser } from "~/lib/api"; import { AUTH_ERROR, refreshColle, useColle, useUser } from "~/lib/api";
import { toast } from "sonner"; import { toast } from "sonner";
import { formatDate, formatGrade, formatTime, goBack } from "~/lib/utils"; import { formatDate, formatGrade, formatTime, goBack } from "~/lib/utils";
import { useQueryClient } from "@tanstack/react-query";
// TODO: Preferences for subject colors // TODO: Preferences for subject colors
const getSubjectColor = (_: string) => { const getSubjectColor = (_: string) => {
@ -44,10 +43,17 @@ const getSubjectEmoji = (_: string) => {
// TODO: Move all code to components // TODO: Move all code to components
export default function ColleDetailPage() { export default function ColleDetailPage() {
const { user, isLoading: isUserLoading, error: userError } = useUser(); const { user, isLoading: isUserLoading, error: userError } = useUser();
const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const params = useParams<{ colleId: string }>(); const params = useParams<{ colleId: string }>();
const colleId = parseInt(params.colleId!);
if (isNaN(colleId)) {
return <Navigate to="/" />;
}
const { colle, error, isLoading } = useColle(colleId);
const [isReloading, setIsReloading] = useState(false); const [isReloading, setIsReloading] = useState(false);
if (isUserLoading) { if (isUserLoading) {
@ -63,12 +69,6 @@ export default function ColleDetailPage() {
// TODO: Favorite toggle function // TODO: Favorite toggle function
const toggleStar = () => {}; const toggleStar = () => {};
const colleId = parseInt(params.colleId!);
if (isNaN(colleId)) {
return <Navigate to="/" />;
}
const { colle, error, isLoading } = useColle(colleId);
if (error) if (error)
return ( return (
<Error <Error
@ -84,7 +84,26 @@ export default function ColleDetailPage() {
const handleReload = () => { const handleReload = () => {
setIsReloading(true); setIsReloading(true);
// TODO: HARD RELOAD refreshColle(colle.id)
.then(() => {
toast("Les données de cette colle vont être mises à jour", {
icon: "🔄",
description: "Rafraîchissement en cours...",
});
})
.catch((error) => {
console.error("Error refreshing colle:", error);
toast.error("Échec du rafraîchissement de la colle", {
icon: "❌",
description: "Veuillez réessayer plus tard.",
});
})
.finally(() => {
setIsReloading(false);
queryClient.invalidateQueries({
queryKey: ["colle", colle.id],
});
});
}; };
const handleShare = async () => { const handleShare = async () => {
@ -134,9 +153,6 @@ export default function ColleDetailPage() {
<ArrowLeft className="h-4 w-4 mr-2" /> <ArrowLeft className="h-4 w-4 mr-2" />
Retour Retour
</Button> </Button>
<div className="hidden md:block">
<UserDropdown user={user} />
</div>
<div className="flex md:hidden items-center gap-2"> <div className="flex md:hidden items-center gap-2">
<Button <Button
variant="outline" variant="outline"
@ -150,7 +166,6 @@ export default function ColleDetailPage() {
/> />
<span className="sr-only">Recharger</span> <span className="sr-only">Recharger</span>
</Button> </Button>
<UserDropdown user={user} />
</div> </div>
</div> </div>
@ -337,12 +352,11 @@ export default function ColleDetailPage() {
</> </>
)} )}
{/* TODO: Attachments */}
{colle.attachments && colle.attachments?.length > 0 && ( {colle.attachments && colle.attachments?.length > 0 && (
<div> <div>
<h3 className="text-lg font-medium mb-3 flex items-center gap-2"> <h3 className="text-lg font-medium mb-3 flex items-center gap-2">
<Paperclip className="h-5 w-5" /> <Paperclip className="h-5 w-5" />
Attachments Pièces jointes
</h3> </h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{colle.attachments.map((attachment, index) => ( {colle.attachments.map((attachment, index) => (

View file

@ -2,14 +2,11 @@ import { Navigate } from "react-router";
import Error from "~/components/error"; import Error from "~/components/error";
import HomePage from "~/components/home"; import HomePage from "~/components/home";
import Loader from "~/components/loader"; import Loader from "~/components/loader";
import UserDropdown from "~/components/user-dropdown";
import { MainLayout } from "~/layout";
import { AUTH_ERROR, useUser } from "~/lib/api"; import { AUTH_ERROR, useUser } from "~/lib/api";
import { forceReload } from "~/lib/utils";
export default function Home() { export default function Home() {
const { user, isLoading, error } = useUser(); const { user, isLoading, error } = useUser();
if (isLoading) { if (isLoading) {
return <Loader />; return <Loader />;
} }
@ -20,12 +17,5 @@ export default function Home() {
return <Error message={error.message} />; return <Error message={error.message} />;
} }
return ( return <HomePage user={user} />;
<MainLayout page={<HomePage user={user} />}>
<h1 className="text-2xl font-bold" onClick={forceReload}>
Khollis&eacute; - {user.className}
</h1>
<UserDropdown user={user} />
</MainLayout>
);
} }

View file

@ -2,14 +2,13 @@ import { Navigate } from "react-router";
import Error from "~/components/error"; import Error from "~/components/error";
import SettingsPage from "~/components/settings"; import SettingsPage from "~/components/settings";
import Loader from "~/components/loader"; import Loader from "~/components/loader";
import UserDropdown from "~/components/user-dropdown";
import { MainLayout } from "~/layout"; import { MainLayout } from "~/layout";
import { AUTH_ERROR, useUser } from "~/lib/api"; import { AUTH_ERROR, useUser } from "~/lib/api";
import { forceReload } from "~/lib/utils"; import { forceReload } from "~/lib/utils";
export default function Home() { export default function Home() {
const { user, isLoading, error } = useUser(); const { user, isLoading, error } = useUser();
if (isLoading) { if (isLoading) {
return <Loader />; return <Loader />;
} }
@ -21,11 +20,14 @@ export default function Home() {
} }
return ( return (
<MainLayout page={<SettingsPage user={user} />}> <MainLayout
<h1 className="text-2xl font-bold" onClick={forceReload}> header={
Khollis&eacute; - {user.className} <h1 className="text-2xl font-bold" onClick={forceReload}>
</h1> Khollis&eacute; - {user.className}
<UserDropdown user={user} /> </h1>
}
>
<SettingsPage user={user} />
</MainLayout> </MainLayout>
); );
} }

View file

@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@marsidev/react-turnstile": "^1.1.0", "@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
@ -21,6 +22,7 @@
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",
"@react-router/node": "^7.5.3", "@react-router/node": "^7.5.3",
"@react-router/serve": "^7.5.3", "@react-router/serve": "^7.5.3",

93
pnpm-lock.yaml generated
View file

@ -11,6 +11,9 @@ importers:
'@marsidev/react-turnstile': '@marsidev/react-turnstile':
specifier: ^1.1.0 specifier: ^1.1.0
version: 1.1.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1) version: 1.1.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-accordion':
specifier: ^1.2.12
version: 1.2.12(@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-avatar': '@radix-ui/react-avatar':
specifier: ^1.1.10 specifier: ^1.1.10
version: 1.1.10(@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) version: 1.1.10(@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)
@ -41,6 +44,9 @@ importers:
'@radix-ui/react-slot': '@radix-ui/react-slot':
specifier: ^1.2.3 specifier: ^1.2.3
version: 1.2.3(@types/react@19.1.8)(react@19.1.1) version: 1.2.3(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-switch':
specifier: ^1.2.6
version: 1.2.6(@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-tabs': '@radix-ui/react-tabs':
specifier: ^1.1.12 specifier: ^1.1.12
version: 1.1.12(@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) version: 1.1.12(@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)
@ -1645,6 +1651,19 @@ packages:
'@radix-ui/primitive@1.1.3': '@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-accordion@1.2.12':
resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==}
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-arrow@1.1.7': '@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies: peerDependencies:
@ -1684,6 +1703,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-collapsible@1.1.12':
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
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-collection@1.1.7': '@radix-ui/react-collection@1.1.7':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies: peerDependencies:
@ -1972,6 +2004,19 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-switch@1.2.6':
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
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-tabs@1.1.12': '@radix-ui/react-tabs@1.1.12':
resolution: {integrity: sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw==} resolution: {integrity: sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw==}
peerDependencies: peerDependencies:
@ -8166,6 +8211,23 @@ snapshots:
'@radix-ui/primitive@1.1.3': {} '@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-accordion@1.2.12(@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/primitive': 1.1.3
'@radix-ui/react-collapsible': 1.1.12(@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-collection': 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-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.1)
'@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)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(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-arrow@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-arrow@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: 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) '@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)
@ -8204,6 +8266,22 @@ snapshots:
'@types/react': 19.1.8 '@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8) '@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-collapsible@1.1.12(@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/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-presence': 1.1.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-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)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(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-collection@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-collection@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: dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.1)
@ -8520,6 +8598,21 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.8 '@types/react': 19.1.8
'@radix-ui/react-switch@1.2.6(@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/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.1)
'@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)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.8)(react@19.1.1)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(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-tabs@1.1.12(@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-tabs@1.1.12(@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: dependencies:
'@radix-ui/primitive': 1.1.2 '@radix-ui/primitive': 1.1.2