feat: add preferences
This commit is contained in:
parent
a63dd0ab89
commit
098d528413
15 changed files with 610 additions and 43 deletions
|
|
@ -29,7 +29,7 @@ export default function BottomNavigation({ activeId }: { activeId: string }) {
|
|||
navigationMenuTriggerStyle(),
|
||||
"flex flex-col h-auto items-center px-5 py-2.5"
|
||||
)}
|
||||
active={item.id === activeId}
|
||||
data-active={item.id === activeId}
|
||||
asChild
|
||||
>
|
||||
<Link to={item.href}>
|
||||
|
|
@ -1,32 +1,31 @@
|
|||
import type { Colle } from "~/lib/api";
|
||||
import type { Colle, UserPreferences } from "~/lib/api";
|
||||
|
||||
import { Link } from "react-router";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import { User, Star, CalendarDays, MapPin } from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { cn, formatDate, formatGrade, formatTime } 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
|
||||
};
|
||||
import {
|
||||
cn,
|
||||
formatDate,
|
||||
formatGrade,
|
||||
formatTime,
|
||||
getColorClass,
|
||||
getSubjectColor,
|
||||
getSubjectEmoji,
|
||||
} from "~/lib/utils";
|
||||
|
||||
type ColleCardProps = {
|
||||
colle: Colle;
|
||||
onToggleFavorite: (id: number, favorite: boolean) => void;
|
||||
isFavorite: boolean;
|
||||
preferences: UserPreferences;
|
||||
};
|
||||
|
||||
export default function ColleCard({
|
||||
colle,
|
||||
onToggleFavorite,
|
||||
isFavorite,
|
||||
preferences,
|
||||
}: ColleCardProps) {
|
||||
// TODO: Favorites
|
||||
// const handleToggleFavorite = (e: React.MouseEvent) => {
|
||||
|
|
@ -36,8 +35,8 @@ export default function ColleCard({
|
|||
// onToggleFavorite(colle.id, newValue);
|
||||
// };
|
||||
|
||||
const subjectColor = getSubjectColor(colle.subject.name);
|
||||
const subjectEmoji = getSubjectEmoji(colle.subject.name);
|
||||
const subjectColor = getColorClass(getSubjectColor(colle.subject.name, preferences));
|
||||
const subjectEmoji = getSubjectEmoji(colle.subject.name, preferences);
|
||||
|
||||
return (
|
||||
<Link to={`/colles/${colle.id}`}>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
ChevronRight,
|
||||
Star,
|
||||
Users,
|
||||
User,
|
||||
UserIcon,
|
||||
SortAsc,
|
||||
SortDesc,
|
||||
} from "lucide-react";
|
||||
|
|
@ -17,18 +17,15 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { Tabs, TabsList, tabsStyle, TabsTrigger } from "~/components/ui/tabs";
|
||||
import DatePickerWithRange from "~/components/home/date-picker";
|
||||
import BottomNavigation from "~/components/home/bottom-nav";
|
||||
import BottomNavigation from "~/components/bottom-nav";
|
||||
import Error from "~/components/error";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useColles } from "~/lib/api";
|
||||
import { useColles, type User } from "~/lib/api";
|
||||
import TabContent from "~/components/home/tab-content";
|
||||
|
||||
const tabsStyle =
|
||||
"rounded-none bg-background data-[state=active]:bg-background! h-full data-[state=active]:shadow-none border-b-2 border-transparent border-t-0 border-r-0 border-l-0 data-[state=active]:border-primary!";
|
||||
|
||||
export default function Home() {
|
||||
export default function Home({ user }: { user: User }) {
|
||||
// Handle query parameters
|
||||
const [query, setQuery] = useSearchParams();
|
||||
const updateQuery = (key: string, value: string) => {
|
||||
|
|
@ -147,7 +144,7 @@ export default function Home() {
|
|||
>
|
||||
<TabsList className="w-full p-0 bg-background justify-start border-b rounded-none">
|
||||
<TabsTrigger value="you" className={tabsStyle}>
|
||||
<User className="h-4 w-4" />
|
||||
<UserIcon className="h-4 w-4" />
|
||||
Vous
|
||||
</TabsTrigger>
|
||||
{/* <TabsTrigger value="favorites" className={tabsStyle}>
|
||||
|
|
@ -233,6 +230,7 @@ export default function Home() {
|
|||
isLoading={isLoading}
|
||||
isSorted={sorted === "desc"}
|
||||
colles={applyFilters(studentColles)}
|
||||
preferences={user.preferences}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -254,6 +252,7 @@ export default function Home() {
|
|||
isLoading={isLoading}
|
||||
isSorted={sorted === "desc"}
|
||||
colles={applyFilters(classColles)}
|
||||
preferences={user.preferences}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { DateTime } from "luxon";
|
||||
import type { Colle } from "~/lib/api";
|
||||
import type { Colle, UserPreferences } from "~/lib/api";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import ColleCard from "~/components/home/colle-card";
|
||||
import ColleCardSkeleton from "~/components/home/skeleton-card";
|
||||
|
|
@ -10,7 +10,8 @@ type TabContentProps = {
|
|||
isLoading: boolean;
|
||||
colles: Colle[];
|
||||
isSorted?: boolean;
|
||||
}
|
||||
preferences: UserPreferences;
|
||||
};
|
||||
|
||||
const WEEK_DAYS = [
|
||||
"Lundi",
|
||||
|
|
@ -27,7 +28,8 @@ export default function TabContent({
|
|||
emptyCollesText,
|
||||
isLoading,
|
||||
colles,
|
||||
isSorted
|
||||
isSorted,
|
||||
preferences,
|
||||
}: TabContentProps) {
|
||||
const collesByDay: Record<string, Colle[]> = {};
|
||||
colles.forEach((colle) => {
|
||||
|
|
@ -92,6 +94,7 @@ export default function TabContent({
|
|||
colle={colle}
|
||||
onToggleFavorite={() => {}}
|
||||
isFavorite={false}
|
||||
preferences={preferences}
|
||||
// TODO: Implement favorite toggle
|
||||
// onToggleFavorite={handleToggleFavorite}
|
||||
// isFavorite={isFavorite(colle)}
|
||||
|
|
|
|||
141
app/components/settings/emoji-input.tsx
Normal file
141
app/components/settings/emoji-input.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { useState } from "react";
|
||||
|
||||
export default function EmojiInput({
|
||||
value,
|
||||
onChange,
|
||||
defaultValue = "",
|
||||
...props
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
[key: string]: any; // Allow other props to be passed
|
||||
}) {
|
||||
const [inputValue, setInputValue] = useState(value || defaultValue);
|
||||
|
||||
// Function to get emoji segments using Intl.Segmenter
|
||||
function getEmojiSegments(text: string) {
|
||||
if (Intl.Segmenter) {
|
||||
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
|
||||
return Array.from(segmenter.segment(text)).map(
|
||||
(segment) => segment.segment
|
||||
);
|
||||
} else {
|
||||
// Fallback for browsers without Intl.Segmenter
|
||||
return [...text];
|
||||
}
|
||||
}
|
||||
|
||||
// Function to check if a string contains only emojis
|
||||
function isOnlyEmojis(text: string) {
|
||||
if (!text.trim()) return false;
|
||||
|
||||
const segments = getEmojiSegments(text);
|
||||
|
||||
for (const segment of segments) {
|
||||
// Skip whitespace
|
||||
if (/^\s+$/.test(segment)) continue;
|
||||
|
||||
// Check if it's likely an emoji (contains emoji-range characters or common emoji symbols)
|
||||
const hasEmojiChars =
|
||||
/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{3000}-\u{303F}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}\u{E0020}-\u{E007F}]|[\u{00A9}\u{00AE}\u{2122}\u{2194}-\u{21AA}\u{231A}-\u{231B}\u{2328}\u{23CF}\u{23E9}-\u{23F3}\u{23F8}-\u{23FA}\u{24C2}\u{25AA}-\u{25AB}\u{25B6}\u{25C0}\u{25FB}-\u{25FE}\u{2600}-\u{2604}\u{260E}\u{2611}\u{2614}-\u{2615}\u{2618}\u{261D}\u{2620}\u{2622}-\u{2623}\u{2626}\u{262A}\u{262E}-\u{262F}\u{2638}-\u{263A}\u{2640}\u{2642}\u{2648}-\u{2653}\u{265F}-\u{2660}\u{2663}\u{2665}-\u{2666}\u{2668}\u{267B}\u{267E}-\u{267F}\u{2692}-\u{2697}\u{2699}\u{269B}-\u{269C}\u{26A0}-\u{26A1}\u{26AA}-\u{26AB}\u{26B0}-\u{26B1}\u{26BD}-\u{26BE}\u{26C4}-\u{26C5}\u{26C8}\u{26CE}\u{26CF}\u{26D1}\u{26D3}-\u{26D4}\u{26E9}-\u{26EA}\u{26F0}-\u{26F5}\u{26F7}-\u{26FA}\u{26FD}\u{2702}\u{2705}\u{2708}-\u{270D}\u{270F}\u{2712}\u{2714}\u{2716}\u{271D}\u{2721}\u{2728}\u{2733}-\u{2734}\u{2744}\u{2747}\u{274C}\u{274E}\u{2753}-\u{2755}\u{2757}\u{2763}-\u{2764}\u{2795}-\u{2797}\u{27A1}\u{27B0}\u{27BF}\u{2934}-\u{2935}]/u.test(
|
||||
segment
|
||||
);
|
||||
|
||||
// Check if it's regular text (letters, numbers, basic punctuation)
|
||||
const isRegularText =
|
||||
/^[a-zA-Z0-9\s\.,!?;:'"()\-_+=<>@#$%^&*`~{}[\]|\\\/]*$/.test(segment);
|
||||
|
||||
if (isRegularText && !hasEmojiChars) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleInput(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const text = event.target.value;
|
||||
|
||||
if (!text) {
|
||||
setInputValue("");
|
||||
onChange?.("");
|
||||
return;
|
||||
}
|
||||
|
||||
let processedValue = text;
|
||||
|
||||
// Check if input contains only emojis
|
||||
if (!isOnlyEmojis(text)) {
|
||||
// Filter out non-emoji characters
|
||||
processedValue = text.replace(
|
||||
/[a-zA-Z0-9\s\.,!?;:'"()\-_+=<>@#$%^&*`~{}[\]|\\\/]/g,
|
||||
""
|
||||
);
|
||||
if (!processedValue) {
|
||||
setInputValue("");
|
||||
onChange?.("");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get emoji segments and keep only the last one
|
||||
const segments = getEmojiSegments(processedValue);
|
||||
if (segments.length > 1) {
|
||||
processedValue = segments[segments.length - 1];
|
||||
}
|
||||
|
||||
setInputValue(processedValue);
|
||||
onChange?.(processedValue);
|
||||
}
|
||||
|
||||
function handlePaste(event: React.ClipboardEvent<HTMLInputElement>) {
|
||||
event.preventDefault();
|
||||
const paste = event.clipboardData.getData("text");
|
||||
|
||||
if (isOnlyEmojis(paste)) {
|
||||
const segments = getEmojiSegments(paste);
|
||||
if (segments.length > 0) {
|
||||
const lastEmoji = segments[segments.length - 1];
|
||||
setInputValue(lastEmoji);
|
||||
onChange?.(lastEmoji);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
// Allow control keys
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
|
||||
// Allow navigation and deletion keys
|
||||
const allowedKeys = [
|
||||
"Backspace",
|
||||
"Delete",
|
||||
"Tab",
|
||||
"Escape",
|
||||
"Enter",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"Home",
|
||||
"End",
|
||||
];
|
||||
|
||||
if (allowedKeys.includes(event.key)) return;
|
||||
|
||||
// For regular character input, let the input event handle emoji filtering
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
74
app/components/settings/index.tsx
Normal file
74
app/components/settings/index.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { Bell, Sliders, UserIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Tabs, TabsList, tabsStyle, TabsTrigger } from "~/components/ui/tabs";
|
||||
import Preferences from "./preferences";
|
||||
import BottomNavigation from "~/components/bottom-nav";
|
||||
import Profile from "./profile";
|
||||
import type { User } from "~/lib/api";
|
||||
|
||||
export default function SettingsPage({ user }: { user: User }) {
|
||||
const tabs = [
|
||||
{
|
||||
value: "user",
|
||||
label: "Profil",
|
||||
icon: <UserIcon className="h-4 w-4" />,
|
||||
content: <Profile />,
|
||||
},
|
||||
{
|
||||
value: "preferences",
|
||||
label: "Préférences",
|
||||
icon: <Sliders className="h-4 w-4" />,
|
||||
content: <Preferences user={user} />,
|
||||
},
|
||||
{
|
||||
value: "notifications",
|
||||
label: "Notifications",
|
||||
icon: <Bell className="h-4 w-4" />,
|
||||
content: <div>WIP</div>,
|
||||
},
|
||||
];
|
||||
|
||||
// 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="settings" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
app/components/settings/preferences.tsx
Normal file
187
app/components/settings/preferences.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { Palette, Save, Undo } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "~/components/ui/card";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
cn,
|
||||
colors,
|
||||
DEFAULT_COLOR,
|
||||
DEFAULT_EMOJI,
|
||||
getColorClass,
|
||||
getSubjectColor,
|
||||
getSubjectEmoji,
|
||||
} from "~/lib/utils";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
import { Separator } from "../ui/separator";
|
||||
import { updateUserPreferences, useSubjects, type User } from "~/lib/api";
|
||||
import EmojiInput from "./emoji-input";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export default function Preferences({ user }: { user: User }) {
|
||||
const [preferences, setPreferences] = useState(user.preferences || []);
|
||||
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
||||
// TODO: Add a loading state and error handling
|
||||
const { subjects, isLoading, isError } = useSubjects();
|
||||
|
||||
const resetPreferences = () => {
|
||||
setPreferences(user.preferences || []);
|
||||
setUnsavedChanges(false);
|
||||
};
|
||||
|
||||
const setPref = (subjectName: string, key: string, value: string) => {
|
||||
const existingPreference = preferences.find((p) => p.name === subjectName);
|
||||
const updatedPreferences = existingPreference
|
||||
? preferences.map((p) =>
|
||||
p.name === subjectName ? { ...p, [key]: value } : p
|
||||
)
|
||||
: [
|
||||
...preferences,
|
||||
Object.assign(
|
||||
{ name: subjectName, emoji: DEFAULT_EMOJI, color: DEFAULT_COLOR },
|
||||
{
|
||||
name: subjectName,
|
||||
[key]: value,
|
||||
}
|
||||
),
|
||||
];
|
||||
if (!unsavedChanges) {
|
||||
toast("Vos changements n'ont pas été sauvegardés", {
|
||||
description:
|
||||
"N'oubliez pas de sauvegarder vos préférences en bas de la page.",
|
||||
duration: 3000,
|
||||
});
|
||||
setUnsavedChanges(true);
|
||||
}
|
||||
setPreferences(updatedPreferences);
|
||||
};
|
||||
|
||||
function savePreferences() {
|
||||
updateUserPreferences(
|
||||
// Filter out any incomplete preferences
|
||||
preferences.filter((p) => p.name && p.emoji && p.color)
|
||||
)
|
||||
.then(() => {
|
||||
// Invalidate the user query to refresh the data
|
||||
const queryClient = useQueryClient();
|
||||
queryClient.removeQueries({ queryKey: ["user"] });
|
||||
|
||||
toast.success("Vos préférences ont été sauvegardé avec succès !");
|
||||
setUnsavedChanges(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"Erreur lors de la sauvegarde de vos préférences :",
|
||||
error
|
||||
);
|
||||
toast.error("Échec de la sauvegarde. Veuillez réessayer.");
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Subject Customization Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Palette className="h-5 w-5" />
|
||||
Personnalisation
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Choisissez les couleurs et emojis des matières.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{subjects
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((subjectName) => ({
|
||||
subjectName,
|
||||
subjectEmoji: getSubjectEmoji(subjectName, preferences),
|
||||
subjectColor: getSubjectColor(subjectName, preferences),
|
||||
}))
|
||||
.map(({ subjectName, subjectEmoji, subjectColor }) => (
|
||||
<div key={subjectName} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base font-medium truncate">
|
||||
{subjectName}
|
||||
</Label>
|
||||
<Badge
|
||||
className={cn(
|
||||
"px-3 py-1 truncate",
|
||||
getColorClass(subjectColor)
|
||||
)}
|
||||
>
|
||||
{subjectEmoji} {subjectName}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Color selector */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Couleur</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{colors.map((colorName) => (
|
||||
<button
|
||||
key={colorName}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-4 h-4 rounded-full border",
|
||||
subjectColor === colorName
|
||||
? "ring-2 ring-offset-2 ring-primary"
|
||||
: "ring-0",
|
||||
getColorClass(colorName)
|
||||
)}
|
||||
onClick={() =>
|
||||
setPref(subjectName, "color", colorName)
|
||||
}
|
||||
title={colorName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Emoji selector */}
|
||||
<div className="space-y-2">
|
||||
<Label>Emoji</Label>
|
||||
<div className="relative">
|
||||
<EmojiInput
|
||||
type="text"
|
||||
defaultValue={subjectEmoji}
|
||||
onChange={(emoji) =>
|
||||
setPref(subjectName, "emoji", emoji)
|
||||
}
|
||||
className="w-full bg-transparent border border-gray-300 rounded-md px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => savePreferences()}
|
||||
disabled={!unsavedChanges}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Enregistrer vos préférences
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full text-white"
|
||||
onClick={resetPreferences}
|
||||
disabled={!unsavedChanges}
|
||||
>
|
||||
<Undo className="h-4 w-4 mr-2" />
|
||||
Annuler les modifications en cours
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/components/settings/profile.tsx
Normal file
14
app/components/settings/profile.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Trash } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { clearCache } from "~/lib/utils";
|
||||
|
||||
export default function Profile() {
|
||||
return (
|
||||
<div>
|
||||
<Button variant="destructive" className="w-full text-white" onClick={clearCache}>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
Vider le cache
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
|
|
@ -13,7 +13,7 @@ function Tabs({
|
|||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
|
|
@ -29,7 +29,7 @@ function TabsList({
|
|||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
|
|
@ -45,7 +45,7 @@ function TabsTrigger({
|
|||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
|
|
@ -58,7 +58,9 @@ function TabsContent({
|
|||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
export const tabsStyle =
|
||||
"rounded-none bg-background data-[state=active]:bg-background! h-full data-[state=active]:shadow-none border-b-2 border-transparent border-t-0 border-r-0 border-l-0 data-[state=active]:border-primary!";
|
||||
|
|
|
|||
|
|
@ -105,11 +105,12 @@ const defaultUser = {
|
|||
fullName: "",
|
||||
email: "",
|
||||
className: "",
|
||||
preferences: [] as UserPreferences,
|
||||
};
|
||||
|
||||
export type User = typeof defaultUser;
|
||||
|
||||
export const AUTH_ERROR = "Unauthorized access"
|
||||
export const AUTH_ERROR = "Unauthorized access";
|
||||
|
||||
export const useUser = () => {
|
||||
const { data, ...props } = useQuery({
|
||||
|
|
@ -132,6 +133,24 @@ export const logout = async () => {
|
|||
return makePostRequest("/auth/logout", {}, "Échec de la déconnexion", "POST");
|
||||
};
|
||||
|
||||
export const updateUserPreferences = async (
|
||||
preferences: { name: string; emoji: string; color: string }[]
|
||||
) => {
|
||||
return makePostRequest(
|
||||
"/users/@me",
|
||||
{ preferences },
|
||||
"Échec de la mise à jour des préférences utilisateur",
|
||||
"POST"
|
||||
);
|
||||
};
|
||||
|
||||
interface UserPreference {
|
||||
name: string;
|
||||
emoji: string;
|
||||
color: string;
|
||||
}
|
||||
export type UserPreferences = UserPreference[];
|
||||
|
||||
/**
|
||||
* === COLLES API ===
|
||||
*/
|
||||
|
|
@ -252,3 +271,26 @@ export const useColle = (id: number) => {
|
|||
...props,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* === SUBJECTS API ===
|
||||
*/
|
||||
export const getSubjects = async () => {
|
||||
return makeRequest("/subjects", "Échec de la récupération des matières");
|
||||
};
|
||||
export const useSubjects = () => {
|
||||
const { data, ...props } = useQuery({
|
||||
queryKey: ["subjects"],
|
||||
queryFn: getSubjects,
|
||||
staleTime: Duration.fromObject({
|
||||
hours: 1, // 1 day
|
||||
}).toMillis(),
|
||||
gcTime: Duration.fromObject({
|
||||
days: 3, // 3 days
|
||||
}).toMillis(),
|
||||
});
|
||||
return {
|
||||
subjects: (data as string[]) || [],
|
||||
...props,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { QueryClient } from "@tanstack/react-query";
|
|||
import { persistQueryClient } from "@tanstack/react-query-persist-client";
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
|
||||
const CACHE_KEY = "khollise-cache"; // Key for IndexedDB storage
|
||||
export const CACHE_KEY = "khollise-cache"; // Key for IndexedDB storage
|
||||
|
||||
// Check if we're in a browser environment with IndexedDB support
|
||||
const isIndexedDBAvailable = () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { type ClassValue, clsx } from "clsx";
|
||||
import { del } from "idb-keyval";
|
||||
import { DateTime } from "luxon";
|
||||
import type { NavigateFunction } from "react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { CACHE_KEY } from "./client";
|
||||
import type { User, UserPreferences } from "./api";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
|
|
@ -60,10 +63,14 @@ export const formatDate = (date: string) => {
|
|||
|
||||
export const formatTime = (date: string) => {
|
||||
const dt = DateTime.fromISO(date).setLocale("fr");
|
||||
return dt.toLocaleString({
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})?.replace(":", "h") || "N/A";
|
||||
return (
|
||||
dt
|
||||
.toLocaleString({
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
?.replace(":", "h") || "N/A"
|
||||
);
|
||||
};
|
||||
|
||||
export const formatGrade = (grade?: number) => {
|
||||
|
|
@ -78,3 +85,70 @@ export const formatGrade = (grade?: number) => {
|
|||
|
||||
return str.replace(".", ",").padStart(2, "0"); // pad with zero if needed
|
||||
};
|
||||
|
||||
// === COLORS UTILS ===
|
||||
export const colors = [
|
||||
"red",
|
||||
"orange",
|
||||
"amber",
|
||||
"yellow",
|
||||
"lime",
|
||||
"green",
|
||||
"emerald",
|
||||
"teal",
|
||||
"cyan",
|
||||
"sky",
|
||||
"blue",
|
||||
"indigo",
|
||||
"violet",
|
||||
"purple",
|
||||
"fuchsia",
|
||||
"pink",
|
||||
"rose",
|
||||
"slate",
|
||||
"zinc",
|
||||
];
|
||||
|
||||
export const getColorClass = (colorCode: string) =>
|
||||
`bg-${colorCode}-100 text-${colorCode}-800 dark:bg-${colorCode}-300 dark:text-${colorCode}-900 hover:bg-${colorCode}-100 dark:hover:bg-${colorCode}-300`;
|
||||
|
||||
// Used for Tailwind CSS to generate the classes
|
||||
export const classNames = [
|
||||
[
|
||||
"bg-red-100 text-red-800 dark:bg-red-300 dark:text-red-900 hover:bg-red-100 dark:hover:bg-red-300",
|
||||
"bg-orange-100 text-orange-800 dark:bg-orange-300 dark:text-orange-900 hover:bg-orange-100 dark:hover:bg-orange-300",
|
||||
"bg-amber-100 text-amber-800 dark:bg-amber-300 dark:text-amber-900 hover:bg-amber-100 dark:hover:bg-amber-300",
|
||||
"bg-yellow-100 text-yellow-800 dark:bg-yellow-300 dark:text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-300",
|
||||
"bg-lime-100 text-lime-800 dark:bg-lime-300 dark:text-lime-900 hover:bg-lime-100 dark:hover:bg-lime-300",
|
||||
"bg-green-100 text-green-800 dark:bg-green-300 dark:text-green-900 hover:bg-green-100 dark:hover:bg-green-300",
|
||||
"bg-emerald-100 text-emerald-800 dark:bg-emerald-300 dark:text-emerald-900 hover:bg-emerald-100 dark:hover:bg-emerald-300",
|
||||
"bg-teal-100 text-teal-800 dark:bg-teal-300 dark:text-teal-900 hover:bg-teal-100 dark:hover:bg-teal-300",
|
||||
"bg-cyan-100 text-cyan-800 dark:bg-cyan-300 dark:text-cyan-900 hover:bg-cyan-100 dark:hover:bg-cyan-300",
|
||||
"bg-sky-100 text-sky-800 dark:bg-sky-300 dark:text-sky-900 hover:bg-sky-100 dark:hover:bg-sky-300",
|
||||
"bg-blue-100 text-blue-800 dark:bg-blue-300 dark:text-blue-900 hover:bg-blue-100 dark:hover:bg-blue-300",
|
||||
"bg-indigo-100 text-indigo-800 dark:bg-indigo-300 dark:text-indigo-900 hover:bg-indigo-100 dark:hover:bg-indigo-300",
|
||||
"bg-violet-100 text-violet-800 dark:bg-violet-300 dark:text-violet-900 hover:bg-violet-100 dark:hover:bg-violet-300",
|
||||
"bg-purple-100 text-purple-800 dark:bg-purple-300 dark:text-purple-900 hover:bg-purple-100 dark:hover:bg-purple-300",
|
||||
"bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-300 dark:text-fuchsia-900 hover:bg-fuchsia-100 dark:hover:bg-fuchsia-300",
|
||||
"bg-pink-100 text-pink-800 dark:bg-pink-300 dark:text-pink-900 hover:bg-pink-100 dark:hover:bg-pink-300",
|
||||
"bg-rose-100 text-rose-800 dark:bg-rose-300 dark:text-rose-900 hover:bg-rose-100 dark:hover:bg-rose-300",
|
||||
"bg-slate-100 text-slate-800 dark:bg-slate-300 dark:text-slate-900 hover:bg-slate-100 dark:hover:bg-slate-300",
|
||||
"bg-zinc-100 text-zinc-800 dark:bg-zinc-300 dark:text-zinc-900 hover:bg-zinc-100 dark:hover:bg-zinc-300",
|
||||
"bg-gray-100 text-gray-800 dark:bg-gray-300 dark:text-gray-900 hover:bg-gray-100 dark:hover:bg-gray-300",
|
||||
],
|
||||
];
|
||||
|
||||
export const DEFAULT_COLOR = "blue"; // Default color for subjects
|
||||
export const DEFAULT_EMOJI = "📚"; // Default emoji for subjects
|
||||
|
||||
export const getSubjectEmoji = (subject: string, pref: UserPreferences) => {
|
||||
const preference = pref.find((p) => p.name === subject);
|
||||
return preference ? preference.emoji : DEFAULT_EMOJI;
|
||||
};
|
||||
export const getSubjectColor = (subject: string, pref: UserPreferences) => {
|
||||
const preference = pref.find((p) => p.name === subject);
|
||||
return preference ? preference.color : DEFAULT_COLOR;
|
||||
};
|
||||
|
||||
// === DEBUG UTILS ===
|
||||
export const clearCache = () => del(CACHE_KEY);
|
||||
|
|
|
|||
|
|
@ -6,4 +6,5 @@ export default [
|
|||
route("/verify", "routes/verify.tsx"),
|
||||
route("/register", "routes/register.tsx"),
|
||||
route("/colles/:colleId", "routes/colles.tsx"),
|
||||
route("/settings", "routes/settings.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default function Home() {
|
|||
}
|
||||
|
||||
return (
|
||||
<MainLayout page={<HomePage />}>
|
||||
<MainLayout page={<HomePage user={user} />}>
|
||||
<h1 className="text-2xl font-bold" onClick={forceReload}>
|
||||
Khollisé - {user.className} ⚔️
|
||||
</h1>
|
||||
|
|
|
|||
31
app/routes/settings.tsx
Normal file
31
app/routes/settings.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Navigate } from "react-router";
|
||||
import Error from "~/components/error";
|
||||
import SettingsPage from "~/components/settings";
|
||||
import Loader from "~/components/loader";
|
||||
import UserDropdown from "~/components/user-dropdown";
|
||||
import { MainLayout } from "~/layout";
|
||||
import { AUTH_ERROR, useUser } from "~/lib/api";
|
||||
import { forceReload } from "~/lib/utils";
|
||||
|
||||
export default function Home() {
|
||||
const { user, isLoading, error } = useUser();
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
if (error?.message === AUTH_ERROR) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
if (error) {
|
||||
return <Error message={error.message} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MainLayout page={<SettingsPage user={user} />}>
|
||||
<h1 className="text-2xl font-bold" onClick={forceReload}>
|
||||
Khollisé - {user.className} ⚔️
|
||||
</h1>
|
||||
<UserDropdown user={user} />
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue