Improve UI

This commit is contained in:
Arthur Belleville 2025-10-16 18:56:49 +02:00
parent 1197a8cb06
commit 23840319c2
No known key found for this signature in database
11 changed files with 338 additions and 396 deletions

View file

@ -1,16 +1,8 @@
import { Button } from "@ui/components/ui/button";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@ui/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/components/ui/select";
import { Switch } from "@ui/components/ui/switch";
import { useTimePicker } from "@ui/ui-library/time-picker";
import { TimeInput } from "@ui/components/ui/time-input";
import { Copy as CopyIcon, Minus as MinusIcon, Plus as PlusIcon } from "lucide-react";
import { useState } from "react";
interface TimeRange {
start: string;
@ -59,8 +51,6 @@ export function AvailabilityCard({
}: AvailabilityCardProps) {
const dayDisplay = DAYS_OF_WEEK_DISPLAY[day];
const [selectedRangeIndex, setSelectedRangeIndex] = useState(0);
const handleAddRange = () => {
// Find a free slot for the new range
const sortedRanges = [...timeRanges].sort(
@ -100,17 +90,13 @@ export function AvailabilityCard({
const newRanges = [...timeRanges, { start: newStart, end: newEnd }];
onTimeRangesChange(newRanges);
setSelectedRangeIndex(newRanges.length - 1);
};
const handleDeleteRange = (index: number) => {
const newRanges = timeRanges.filter((_, i) => i !== index);
onTimeRangesChange(newRanges);
setSelectedRangeIndex(Math.min(selectedRangeIndex, newRanges.length - 1));
};
const timeOptions = useTimePicker({ intervalInMinute: 30 });
const validateTimeRange = (ranges: TimeRange[], index: number): boolean => {
const currentRange = ranges[index];
const currentStart = timeToMinutes(currentRange.start);
@ -171,93 +157,54 @@ export function AvailabilityCard({
</div>
{/* Time Ranges */}
<div className="flex gap-1 flex-wrap items-center">
<div className="space-y-2">
{timeRanges.map((range, index) => (
<div
key={index}
onClick={() => setSelectedRangeIndex(index)}
className={`flex items-center gap-1 rounded-md px-1.5 py-1 cursor-pointer transition-all duration-200 ${
selectedRangeIndex === index ? "bg-primary/10" : "bg-muted/80 hover:bg-muted"
}`}
>
<div className="flex items-center text-xs w-fit">
{selectedRangeIndex === index ? (
<>
<Select
value={range.start}
disabled={!enabled}
onValueChange={(value) => {
const newRanges = [...timeRanges];
newRanges[index] = {
...range,
start: value,
};
if (validateTimeRange(newRanges, index)) {
onTimeRangesChange(newRanges);
}
}}
>
<SelectTrigger
aria-label="Heure de début"
className="min-h-0 h-6 px-1 py-0 text-xs bg-transparent hover:bg-accent focus:bg-accent shadow-none outline-none ring-0 focus:ring-0 focus:outline-none min-w-[3rem] w-auto rounded-sm border-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{timeOptions.map((item) => (
<SelectItem key={item.id} value={item.value} className="text-xs py-0.5">
{item.value}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-[10px] mx-2">-</span>
<Select
value={range.end}
disabled={!enabled}
onValueChange={(value) => {
const newRanges = [...timeRanges];
newRanges[index] = {
...range,
end: value,
};
if (validateTimeRange(newRanges, index)) {
onTimeRangesChange(newRanges);
}
}}
>
<SelectTrigger
aria-label="Heure de fin"
className="min-h-0 h-6 px-1 py-0 text-xs bg-transparent hover:bg-accent focus:bg-accent shadow-none outline-none ring-0 focus:ring-0 focus:outline-none min-w-[3rem] w-auto rounded-sm border-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{timeOptions.map((item) => (
<SelectItem key={item.id} value={item.value} className="text-xs py-0.5">
{item.value}
</SelectItem>
))}
</SelectContent>
</Select>
</>
) : (
<>
<span className="font-medium text-xs px-1">{range.start}</span>
<span className="text-muted-foreground text-[10px]"></span>
<span className="font-medium text-xs px-1">{range.end}</span>
</>
)}
</div>
<div key={index} className="flex items-center gap-2">
<TimeInput
value={range.start}
onChange={(value) => {
const newRanges = [...timeRanges];
newRanges[index] = {
...range,
start: value,
};
if (validateTimeRange(newRanges, index)) {
onTimeRangesChange(newRanges);
}
}}
isDisabled={!enabled}
className="h-8 text-sm max-w-32"
id={`start-${day}-${index}`}
/>
<span className="text-muted-foreground text-sm">-</span>
<TimeInput
value={range.end}
onChange={(value) => {
const newRanges = [...timeRanges];
newRanges[index] = {
...range,
end: value,
};
if (validateTimeRange(newRanges, index)) {
onTimeRangesChange(newRanges);
}
}}
isDisabled={!enabled}
className="h-8 text-sm max-w-32"
id={`end-${day}-${index}`}
/>
{timeRanges.length > 1 && (
<Button
onClick={() => handleDeleteRange(index)}
onClick={(e) => {
e.stopPropagation();
handleDeleteRange(index);
}}
disabled={!enabled}
variant="destructive"
size="sm"
className="h-4 w-4 p-0 border-0"
variant="outline"
size="icon"
className="hover:text-red-500 hover:bg-red-50/10 dark:hover:bg-red-950/10"
>
<MinusIcon className="size-2" />
<MinusIcon />
</Button>
)}
</div>
@ -268,9 +215,10 @@ export function AvailabilityCard({
disabled={!enabled}
variant="outline"
size="sm"
className="h-5 px-1.5 flex items-center text-xs border-0 bg-muted/50 hover:bg-muted"
className="h-8 px-3 flex items-center text-sm"
>
<PlusIcon className="size-2.5" />
<PlusIcon className="size-4 mr-1" />
Ajouter une plage horaire
</Button>
)}
</div>

View file

@ -1,5 +1,16 @@
import { getLocalTimeZone, parseDate, today } from "@internationalized/date";
import { Button } from "@ui/components/ui/button";
import { DatePicker } from "@ui/components/ui/date-picker";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@ui/components/ui/dialog";
import { Input } from "@ui/components/ui/input";
import { Label } from "@ui/components/ui/label";
import {
Select,
SelectContent,
@ -7,13 +18,13 @@ import {
SelectTrigger,
SelectValue,
} from "@ui/components/ui/select";
import { Textarea } from "@ui/components/ui/textarea";
import { TimeInput } from "@ui/components/ui/time-input";
import { useCreateEvents, useEvent, useUpdateEvent } from "@ui/hooks/events";
import { useTablosList } from "@ui/hooks/tablos";
import { useUser } from "@ui/providers/UserStoreProvider";
import { Event, EventInsert } from "@ui/types/events.types";
import { useTimePicker } from "@ui/ui-library/time-picker";
import { useEffect, useState } from "react";
import { Group } from "react-aria-components";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
@ -29,7 +40,6 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
const { data: tablos, isLoading: tablosLoading } = useTablosList();
const createEvents = useCreateEvents();
const updateEvent = useUpdateEvent();
const timeOptions = useTimePicker({ intervalInMinute: 15 });
const navigate = useNavigate();
const onClose = () => {
@ -44,31 +54,20 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
return `${year}-${month}-${day}`;
};
// Find the nearest time option to the selected date
const getNearestTimeOption = (date: Date, type: "start" | "end") => {
const dateMinutes = date.getHours() * 60 + date.getMinutes();
let nearestOption = timeOptions[0];
let smallestDiff = Infinity;
for (const option of timeOptions) {
const optionMinutes = option.hour * 60 + option.minute;
const diff =
type === "start" ? Math.abs(dateMinutes - optionMinutes) : dateMinutes + 30 - optionMinutes;
if (0 <= diff && diff < smallestDiff) {
smallestDiff = diff;
nearestOption = option;
}
}
return nearestOption?.id || "";
// Format time from Date to HH:MM string
const formatTimeFromDate = (date: Date, addMinutes: number = 0): string => {
const hours = date.getHours();
const minutes = date.getMinutes() + addMinutes;
const totalMinutes = hours * 60 + minutes;
const finalHours = Math.floor(totalMinutes / 60) % 24;
const finalMinutes = totalMinutes % 60;
return `${finalHours.toString().padStart(2, "0")}:${finalMinutes.toString().padStart(2, "0")}`;
};
const [formEvent, setFormEvent] = useState<EventInsert>({
start_date: date ? getLocalDateString(date) : "",
start_time: date ? getNearestTimeOption(date, "start") : "",
end_time: date ? getNearestTimeOption(date, "end") : "",
start_time: date ? formatTimeFromDate(date) : "",
end_time: date ? formatTimeFromDate(date, 30) : "",
tablo_id: tablo_id || "",
title: "",
created_by: user.id,
@ -90,30 +89,11 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
}, [mode, event]);
return (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-lg mx-4 overflow-hidden">
{/* Header with colored accent */}
<div className="bg-gradient-to-r from-blue-500 to-blue-600 p-6 text-white">
<div className="flex items-center justify-between">
<h2 className="text-xl font-medium">
{mode === "edit" ? "Modifier l'événement" : "Nouvel événement"}
</h2>
<button
onClick={onClose}
className="text-white hover:text-gray-200 transition-colors"
aria-label="Fermer le modal"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="mt-2 text-blue-100 text-sm">
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{mode === "edit" ? "Modifier l'événement" : "Nouvel événement"}</DialogTitle>
<DialogDescription>
{mode === "edit" && event
? new Date(event.start_date).toLocaleDateString("fr-FR", {
weekday: "long",
@ -127,14 +107,15 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
month: "long",
day: "numeric",
})}
</div>
</div>
</DialogDescription>
</DialogHeader>
{/* Form Content */}
<div className="p-6 space-y-6">
<div className="space-y-4">
{/* Title Input */}
<div className="space-y-2">
<input
<Label htmlFor="event-title">Titre *</Label>
<Input
id="event-title"
type="text"
value={formEvent?.title}
onChange={(e) =>
@ -143,19 +124,14 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
title: e.target.value,
} as Event)
}
className="w-full text-lg font-medium border-none outline-none bg-transparent text-gray-900 dark:text-white placeholder-gray-400 focus:ring-0 px-0"
placeholder="Ajouter un titre"
aria-label="Titre de l'événement"
autoFocus
/>
<div className="border-b border-gray-200 dark:border-gray-700"></div>
</div>
{/* Tablo Selection */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400">
Tablo *
</label>
<Label htmlFor="event-tablo">Tablo *</Label>
<Select
value={formEvent?.tablo_id}
onValueChange={(value) =>
@ -166,7 +142,7 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
}
disabled={tablosLoading}
>
<SelectTrigger className="w-full">
<SelectTrigger id="event-tablo" className="w-full">
<SelectValue placeholder="Sélectionner un tablo" />
</SelectTrigger>
<SelectContent>
@ -179,9 +155,9 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
</Select>
</div>
<Group className="flex flex-row gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">Date</label>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="event-date">Date *</Label>
<DatePicker
aria-label="Date de l'événement"
value={formEvent?.start_date ? parseDate(formEvent?.start_date) : undefined}
@ -198,65 +174,46 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
});
}
}}
buttonClassName="h-[36px]"
buttonClassName="h-10 w-full"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">Début</label>
<Select
<div className="space-y-2">
<Label htmlFor="event-start-time">Début *</Label>
<TimeInput
value={formEvent?.start_time || undefined}
onValueChange={(value) => {
onChange={(value) => {
setFormEvent({
...formEvent,
start_time: value,
});
}}
>
<SelectTrigger className="min-w-[110px]" aria-label="Heure de début">
<SelectValue placeholder="--:--" />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{timeOptions.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.value}
</SelectItem>
))}
</SelectContent>
</Select>
className="w-full"
id="event-start-time"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-600 dark:text-gray-400">Fin</label>
<Select
<div className="space-y-2">
<Label htmlFor="event-end-time">Fin</Label>
<TimeInput
value={formEvent?.end_time || undefined}
onValueChange={(value) => {
onChange={(value) => {
setFormEvent({
...formEvent,
end_time: value,
});
}}
>
<SelectTrigger className="min-w-[110px]" aria-label="Heure de fin">
<SelectValue placeholder="--:--" />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{timeOptions.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.value}
</SelectItem>
))}
</SelectContent>
</Select>
className="w-full"
id="event-end-time"
/>
</div>
</Group>
</div>
{/* Description */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400">
Description
</label>
<textarea
<Label htmlFor="event-description">Description</Label>
<Textarea
id="event-description"
value={formEvent?.description ?? ""}
onChange={(e) =>
setFormEvent({
@ -265,28 +222,16 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
} as Event)
}
rows={3}
className="w-full px-3 py-2.5 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800 dark:text-white resize-none transition-all"
placeholder="Ajouter une description (optionnel)"
aria-label="Description de l'événement"
/>
</div>
</div>
{/* Footer */}
<div className="bg-gray-50 dark:bg-gray-800 px-6 py-4 flex justify-end space-x-3">
<button
type="button"
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
onClick={onClose}
aria-label={
mode === "edit" ? "Annuler la modification" : "Annuler la création d'événement"
}
>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Annuler
</button>
<button
type="button"
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm hover:shadow-md"
</Button>
<Button
onClick={() => {
const eventName = formEvent?.title.trim() || "(Sans titre)";
if (mode === "edit" && event) {
@ -299,12 +244,11 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => {
}
}}
disabled={!formEvent?.tablo_id}
aria-label={mode === "edit" ? "Modifier l'événement" : "Enregistrer l'événement"}
>
{mode === "edit" ? "Modifier" : "Enregistrer"}
</button>
</div>
</div>
</div>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -8,7 +8,6 @@ import {
CardTitle,
} from "@ui/components/ui/card";
import { CopyButton } from "@ui/components/ui/clipboard";
import { Text } from "@ui/components/ui/typography";
import { EventType, EventTypeConfig, useEventTypes } from "@ui/hooks/event-types";
import { CheckIcon, EditIcon, ExternalLinkIcon, TrashIcon, XIcon } from "lucide-react";
import { useUser } from "src/providers/UserStoreProvider";
@ -38,7 +37,7 @@ export function EventTypeCard({
};
return (
<Card key={eventType.id} className={eventType.isActive ? "opacity-100" : "opacity-60"}>
<CardHeader>
<CardHeader className="min-h-[80px]">
<CardTitle className="text-lg">{eventType.name}</CardTitle>
<CardAction>
<div className="flex gap-2">
@ -66,6 +65,7 @@ export function EventTypeCard({
variant="ghost"
size="icon"
onClick={() => deleteEventType({ id: eventType.id })}
className="hover:text-red-500 hover:bg-red-50/10 dark:hover:bg-red-950/10"
>
<TrashIcon className="w-4 h-4" />
</Button>
@ -74,7 +74,7 @@ export function EventTypeCard({
</CardHeader>
<CardContent className="min-h-[200px]">
<Text className="text-muted-foreground">{eventType.description}</Text>
{/* <Text className="text-muted-foreground">{eventType.description}</Text> */}
<div className="space-y-2 text-sm">
<div className="flex justify-between">

View file

@ -71,9 +71,7 @@ export function EventTypeModal({
{/* Timing Configuration Section */}
<div className="space-y-2">
<h4 className="text-lg font-medium text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
Configuration des horaires
</h4>
<Label>Configuration des horaires</Label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">

View file

@ -1,6 +1,5 @@
import { Button } from "@ui/components/ui/button";
import { ButtonGroup } from "@ui/components/ui/button-group";
import { Text } from "@ui/components/ui/typography";
import { useTheme } from "@ui/contexts/ThemeContext";
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
@ -10,38 +9,70 @@ const translation = {
system: "Système",
};
export function ThemeSwitcher() {
export function ThemeSwitcher({ isCollapsed = false }: { isCollapsed?: boolean }) {
const { theme, setTheme } = useTheme();
const getThemeIcon = () => {
switch (theme) {
case "light":
return <SunIcon className="w-5 h-5" />;
case "dark":
return <MoonIcon className="w-5 h-5" />;
case "system":
return <MonitorIcon className="w-5 h-5" />;
}
};
const cycleTheme = () => {
const themes: Array<"light" | "dark" | "system"> = ["light", "system", "dark"];
const currentIndex = themes.indexOf(theme);
const nextIndex = (currentIndex + 1) % themes.length;
setTheme(themes[nextIndex]);
};
if (isCollapsed) {
return (
<Button
variant="ghost"
size="icon"
onClick={cycleTheme}
aria-label={`Thème actuel: ${translation[theme]}`}
className="hover:bg-navbar-darker w-full h-auto px-2 py-1.5 text-gray-300/90"
>
{getThemeIcon()}
</Button>
);
}
return (
<div className="flex flex-col gap-2">
<Text className="text-gray-300/90">Thème: {translation[theme]}</Text>
<ButtonGroup orientation="horizontal">
<Button
variant={theme === "light" ? "default" : "outline"}
size="icon"
onClick={() => setTheme("light")}
aria-label="Mode clair"
>
<SunIcon className="w-4 h-4" />
</Button>
<Button
variant={theme === "system" ? "default" : "outline"}
size="icon"
onClick={() => setTheme("system")}
aria-label="Mode système"
>
<MonitorIcon className="w-4 h-4" />
</Button>
<Button
variant={theme === "dark" ? "default" : "outline"}
size="icon"
onClick={() => setTheme("dark")}
aria-label="Mode sombre"
>
<MoonIcon className="w-4 h-4" />
</Button>
</ButtonGroup>
</div>
<ButtonGroup orientation="horizontal" className="w-fit">
<Button
variant={theme === "light" ? "default" : "outline"}
size="icon-sm"
onClick={() => setTheme("light")}
aria-label="Mode clair"
className="flex-1"
>
<SunIcon className="w-4 h-4 color-foreground" />
</Button>
<Button
variant={theme === "system" ? "default" : "outline"}
size="icon-sm"
onClick={() => setTheme("system")}
aria-label="Mode système"
className="flex-1"
>
<MonitorIcon className="w-4 h-4 color-foreground" />
</Button>
<Button
variant={theme === "dark" ? "default" : "outline"}
size="icon-sm"
onClick={() => setTheme("dark")}
aria-label="Mode sombre"
className="flex-1"
>
<MoonIcon className="w-4 h-4 color-foreground" />
</Button>
</ButtonGroup>
);
}

View file

@ -10,7 +10,8 @@ const buttonVariants = cva(
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
outline:
"border bg-background text-foreground shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",

View file

@ -68,7 +68,7 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
className={cn("text-lg font-semibold leading-none tracking-tight text-foreground", className)}
{...props}
/>
));

View file

@ -7,6 +7,7 @@ interface TimeInputProps {
defaultValue?: string;
onChange?: (value: string) => void;
className?: string;
isDisabled?: boolean;
id?: string;
}
@ -20,6 +21,7 @@ export const TimeInputWithLabel = ({
defaultValue,
onChange,
className,
isDisabled,
id = "time-picker",
}: TimeInputWithLabelProps) => {
return (
@ -32,6 +34,7 @@ export const TimeInputWithLabel = ({
defaultValue={defaultValue || "08:30:00"}
onChange={onChange}
className={className}
isDisabled={isDisabled}
id={id}
/>
</div>
@ -43,6 +46,7 @@ export const TimeInput = ({
defaultValue,
onChange,
className,
isDisabled,
id = "time-picker",
}: TimeInputProps) => {
return (
@ -52,6 +56,7 @@ export const TimeInput = ({
step="60"
value={value}
defaultValue={defaultValue}
disabled={isDisabled}
onChange={(e) => onChange?.(e.target.value)}
className={cn(
"bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none",

View file

@ -1,22 +1,34 @@
import { createContext, ReactNode, useContext, useEffect, useState } from "react";
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system";
interface ThemeContextType {
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
}
};
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
};
const THEME_STORAGE_KEY = "xtablo-theme";
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
// Load theme from localStorage on initial render
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY) as Theme;
return savedTheme || "system";
});
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
useEffect(() => {
const root = window.document.documentElement;
@ -26,22 +38,32 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
root.classList.add(systemTheme);
} else {
root.classList.add(theme);
return;
}
// Save theme to localStorage whenever it changes
localStorage.setItem(THEME_STORAGE_KEY, theme);
root.classList.add(theme);
}, [theme]);
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider");
return context;
}
};

View file

@ -1,17 +1,80 @@
@import "tailwindcss";
@import "tw-animate-css";
@plugin 'tailwindcss-animate';
@plugin '@tailwindcss/container-queries';
@custom-variant dark (&:is(.dark *));
/*
---break---
*/
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@ -27,6 +90,7 @@
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
@ -35,6 +99,10 @@
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
@ -43,103 +111,16 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-navbar-background: #292e39;
--color-navbar-darker: #171920;
/* --color-logo-staging-background: #4a3522; */
}
/*
---break---
*/
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
/*
---break---
*/
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
/*
---break---
*/
@layer base {
*,
*:before,
*:after {
@apply border-[hsl(var(--border))];
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-[hsl(var(--background))] text-[hsl(var(--foreground))];
@apply bg-background text-foreground;
}
}
@ -320,7 +301,8 @@
transform: translate(-50%, -50%) rotate(0deg) translateX(150px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg) translateX(150px) rotate(-360deg);
transform: translate(-50%, -50%) rotate(360deg) translateX(150px)
rotate(-360deg);
}
}
@ -329,7 +311,8 @@
transform: translate(-50%, -50%) rotate(0deg) translateX(200px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(-360deg) translateX(200px) rotate(360deg);
transform: translate(-50%, -50%) rotate(-360deg) translateX(200px)
rotate(360deg);
}
}
@ -338,7 +321,8 @@
transform: translate(-50%, -50%) rotate(0deg) translateX(100px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg) translateX(100px) rotate(-360deg);
transform: translate(-50%, -50%) rotate(360deg) translateX(100px)
rotate(-360deg);
}
}
@ -516,7 +500,8 @@
transform: translate(-50%, -50%) rotate(0deg) translateX(250px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg) translateX(250px) rotate(-360deg);
transform: translate(-50%, -50%) rotate(360deg) translateX(250px)
rotate(-360deg);
}
}
@ -525,7 +510,8 @@
transform: translate(-50%, -50%) rotate(0deg) translateX(120px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(-360deg) translateX(120px) rotate(360deg);
transform: translate(-50%, -50%) rotate(-360deg) translateX(120px)
rotate(360deg);
}
}

View file

@ -25,6 +25,7 @@ import { TabloInsert, TabloUpdate, UserTablo } from "@ui/types/tablos.types";
import {
CheckCircle2,
Clock,
Eye,
HelpCircle,
LayoutGrid,
List,
@ -549,8 +550,8 @@ export const TabloPage = () => {
<div className="flex items-center gap-2 flex-shrink-0">
{/* Quick action buttons */}
<Button
variant="ghost"
size="sm"
variant="outline"
size="icon"
className="p-2"
onClick={(e) => {
e.stopPropagation();
@ -558,11 +559,11 @@ export const TabloPage = () => {
}}
title="Conversations"
>
<Users className="w-5 h-5" />
<Users className="w-5 h-5 color-foreground" />
</Button>
<Button
variant="ghost"
size="sm"
variant="outline"
size="icon"
className="p-2"
onClick={(e) => {
e.stopPropagation();
@ -574,8 +575,8 @@ export const TabloPage = () => {
</Button>
{isAdmin && (
<Button
variant="ghost"
size="sm"
variant="outline"
size="icon"
className="p-2 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
@ -587,9 +588,15 @@ export const TabloPage = () => {
</Button>
)}
{!isAdmin && (
<div className="p-2 text-muted-foreground" title="Lecture seule">
<Shield className="w-5 h-5" />
</div>
<Button
variant="outline"
size="icon"
disabled
className="p-2 text-muted-foreground hover:text-muted-foreground"
title="Lecture seule"
>
<Eye className="w-5 h-5" />
</Button>
)}
</div>
</div>