Merge branch 'develop'

This commit is contained in:
Arthur Belleville 2025-07-27 15:52:15 +02:00
commit 9c330810b8
No known key found for this signature in database
11 changed files with 601 additions and 35 deletions

View file

@ -8,11 +8,9 @@ import type { SupabaseClient, User } from "@supabase/supabase-js";
import type { Transporter } from "nodemailer";
import { generateToken } from "./token.js";
import { config } from "./config.js";
import type { Database, Tables } from "./database.types.js";
import type { Tables } from "./database.types.js";
import type { StreamChat } from "stream-chat";
type Tablo = Database["public"]["Tables"]["tablos"];
type TabloInsert = Tablo["Insert"];
import type { TabloInsert, EventInsertInTablo } from "./types.js";
export const tabloRouter = new Hono<{
Variables: {
@ -27,18 +25,23 @@ tabloRouter.use(authMiddleware);
tabloRouter.use(emailMiddleware);
tabloRouter.use(streamChatMiddleware);
type PostTablo = Omit<TabloInsert, "owner_id"> & {
events?: EventInsertInTablo[];
};
tabloRouter.post("/create", async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const data = await c.req.json();
const tablo = data as Omit<TabloInsert, "owner_id">;
const typedPayload = data as PostTablo;
const { data: insertedTablo, error } = await supabase
.from("tablos")
.insert({
...tablo,
...typedPayload,
owner_id: user.id,
events: undefined,
})
.select()
.single();
@ -52,12 +55,21 @@ tabloRouter.post("/create", async (c) => {
const streamServerClient = c.get("streamServerClient");
const channel = streamServerClient.channel("messaging", tabloData.id, {
// @ts-ignore
name: tablo.name,
name: tabloData.name,
created_by_id: user.id,
members: [user.id],
});
await channel.create();
if (typedPayload.events) {
const eventsToInsert = typedPayload.events.map((event) => ({
...event,
tablo_id: tabloData.id,
created_by: user.id,
}));
await supabase.from("events").insert(eventsToInsert);
}
return c.json({ message: "Tablo created successfully" });
});

View file

@ -1,5 +1,41 @@
type User = {
user_id: string;
email: string;
username: string;
import type {
Database,
Tables,
TablesInsert,
TablesUpdate,
} from "./database.types.js";
export type Tablo = Database["public"]["Tables"]["tablos"];
export type TabloInsert = Tablo["Insert"];
export type Event = RemoveNullFromObject<
Tables<"events">,
"created_at" | "end_time"
>;
export type EventInsertInTablo = Omit<TablesInsert<"events">, "tablo_id">;
export type EventUpdate = TablesUpdate<"events">;
export type EventAndTablo = RemoveNullFromObject<
Tables<"events_and_tablos">,
| "event_id"
| "tablo_id"
| "tablo_name"
| "tablo_color"
| "tablo_status"
| "start_time"
| "end_time"
| "title"
| "start_date"
>;
/**
* Utility type to remove null from a type
*/
export type RemoveNull<T> = T extends null ? never : T;
/**
* Utility type to remove null from all properties of an object type
*/
export type RemoveNullFromObject<T, K extends keyof T = keyof T> = {
[L in keyof T]: L extends K ? RemoveNull<T[L]> : T[L];
};

View file

@ -1,7 +1,7 @@
import { Event, EventInsert } from "@ui/types/events.types";
import { useState } from "react";
import { useTablosList } from "@ui/hooks/tablos";
import { useCreateEvent } from "@ui/hooks/events";
import { useCreateEvents } from "@ui/hooks/events";
import { useUser } from "@ui/providers/UserStoreProvider";
import {
Select,
@ -23,7 +23,7 @@ export const CreateEventModal = () => {
const date = new Date(searchParams.get("date") || "");
const { data: tablos, isLoading: tablosLoading } = useTablosList();
const createEvent = useCreateEvent();
const createEvents = useCreateEvents();
const timeOptions = useTimePicker({ intervalInMinute: 15 });
const navigate = useNavigate();
@ -286,7 +286,7 @@ export const CreateEventModal = () => {
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"
onClick={() => {
const eventName = createdEvent?.title.trim() || "(Sans titre)";
createEvent(
createEvents(
{ ...createdEvent, title: eventName },
{ onSuccess: () => onClose() }
);

View file

@ -0,0 +1,356 @@
import { useState, useRef } from "react";
import { useTablosList, useCreateTablo } from "@ui/hooks/tablos";
import { useCreateEvents } from "@ui/hooks/events";
import { useUser } from "@ui/providers/UserStoreProvider";
import {
Select,
SelectButton,
SelectPopover,
SelectListBox,
SelectListItem,
} from "@ui/ui-library/select";
import { parseICSFile, ParsedICSEvent } from "@ui/utils/helpers";
import { EventInsert } from "@ui/types/events.types";
import { CreateTablo } from "@ui/types/tablos.types";
import { toast } from "@ui/ui-library/toast/toast-queue";
interface ImportICSModalProps {
onClose: () => void;
}
export const ImportICSModal = ({ onClose }: ImportICSModalProps) => {
const user = useUser();
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [parsedEvents, setParsedEvents] = useState<ParsedICSEvent[]>([]);
const [selectedTabloId, setSelectedTabloId] = useState<string>("");
const [newTabloName, setNewTabloName] = useState("");
const [createNewTablo, setCreateNewTablo] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const { data: tablos, isLoading: tablosLoading } = useTablosList();
const createTabloMutation = useCreateTablo();
const createEvents = useCreateEvents();
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file && file.name.endsWith(".ics")) {
setSelectedFile(file);
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
try {
const events = parseICSFile(content);
setParsedEvents(events);
if (events.length === 0) {
toast.add(
{
title: "Aucun événement trouvé",
description:
"Le fichier ICS ne contient aucun événement valide",
type: "warning",
},
{ timeout: 4000 }
);
}
} catch (error) {
console.error("Error parsing ICS file:", error);
toast.add(
{
title: "Erreur de lecture",
description: "Impossible de lire le fichier ICS",
type: "error",
},
{ timeout: 4000 }
);
}
};
reader.readAsText(file);
} else {
toast.add(
{
title: "Format invalide",
description: "Veuillez sélectionner un fichier .ics",
type: "error",
},
{ timeout: 4000 }
);
}
};
const handleImport = async () => {
if (parsedEvents.length === 0) {
toast.add(
{
title: "Aucun événement",
description: "Aucun événement à importer",
type: "warning",
},
{ timeout: 3000 }
);
return;
}
if (!createNewTablo && !selectedTabloId) {
toast.add(
{
title: "Tablo requis",
description: "Veuillez sélectionner un tablo ou créer un nouveau",
type: "warning",
},
{ timeout: 3000 }
);
return;
}
if (createNewTablo && !newTabloName.trim()) {
toast.add(
{
title: "Nom du tablo requis",
description: "Veuillez saisir un nom pour le nouveau tablo",
type: "warning",
},
{ timeout: 3000 }
);
return;
}
setIsImporting(true);
try {
const targetTabloId = selectedTabloId;
const eventsToInsert = parsedEvents.map((event) => {
const eventData: EventInsert = {
title: event.title,
description: event.description || "",
start_date: event.start_date,
start_time: event.start_time,
end_time: event.end_time || event.start_time,
tablo_id: targetTabloId,
created_by: user.id,
};
return eventData;
});
if (createNewTablo) {
const newTabloData: CreateTablo = {
name: newTabloName.trim(),
color: "bg-blue-500",
image: null,
status: "todo",
events: eventsToInsert,
};
await createTabloMutation.mutateAsync(newTabloData);
toast.add(
{
title: "Import réussi",
description: `${parsedEvents.length} événement(s) importé(s) avec succès`,
type: "success",
},
{ timeout: 4000 }
);
} else {
await createEvents(eventsToInsert);
}
onClose();
} catch (error) {
console.error("Error importing events:", error);
toast.add(
{
title: "Erreur d'import",
description: "Une erreur est survenue lors de l'import",
type: "error",
},
{ timeout: 4000 }
);
} finally {
setIsImporting(false);
}
};
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 */}
<div className="bg-gradient-to-r from-green-500 to-green-600 p-6 text-white">
<div className="flex items-center justify-between">
<h2 className="text-xl font-medium">Importer un fichier ICS</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-green-100 text-sm">
Importez vos événements depuis un fichier calendrier
</div>
</div>
{/* Form Content */}
<div className="p-6 space-y-6">
{/* File Selection */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400">
Fichier ICS *
</label>
<div className="flex items-center space-x-3">
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept=".ics"
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 transition-colors"
>
Choisir un fichier
</button>
{selectedFile && (
<span className="text-sm text-gray-600 dark:text-gray-400">
{selectedFile.name}
</span>
)}
</div>
{parsedEvents.length > 0 && (
<div className="text-sm text-green-600 dark:text-green-400">
{parsedEvents.length} événement(s) trouvé(s)
</div>
)}
</div>
{/* Tablo Selection */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400">
Destination
</label>
{/* Create new tablo option */}
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="createNewTablo"
checked={createNewTablo}
onChange={(e) => {
setCreateNewTablo(e.target.checked);
if (e.target.checked) {
setSelectedTabloId("");
}
}}
className="rounded border-gray-300 dark:border-gray-600"
/>
<label
htmlFor="createNewTablo"
className="text-sm text-gray-700 dark:text-gray-300"
>
Créer un nouveau tablo
</label>
</div>
{createNewTablo ? (
<input
type="text"
value={newTabloName}
onChange={(e) => setNewTabloName(e.target.value)}
placeholder="Nom du nouveau tablo"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500 dark:bg-gray-800 dark:text-white"
/>
) : (
<Select
placeholder="Sélectionner un tablo existant"
selectedKey={selectedTabloId}
onSelectionChange={(key) => setSelectedTabloId(key as string)}
className="w-full"
isDisabled={tablosLoading}
>
<SelectButton className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500 dark:bg-gray-800 dark:text-white text-left" />
<SelectPopover>
<SelectListBox>
{tablos?.map((tablo) => (
<SelectListItem key={tablo.id} id={tablo.id}>
{tablo.name}
</SelectListItem>
))}
</SelectListBox>
</SelectPopover>
</Select>
)}
</div>
{/* Preview */}
{parsedEvents.length > 0 && (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400">
Aperçu des événements
</label>
<div className="max-h-32 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg p-3 bg-gray-50 dark:bg-gray-800">
{parsedEvents.slice(0, 5).map((event, index) => (
<div
key={index}
className="text-xs text-gray-600 dark:text-gray-400 py-1"
>
<span className="font-medium">{event.title}</span>
<span className="ml-2 opacity-75">
{event.start_date} {event.start_time.substring(0, 5)}
</span>
</div>
))}
{parsedEvents.length > 5 && (
<div className="text-xs text-gray-500 dark:text-gray-500 pt-1">
... et {parsedEvents.length - 5} autre(s)
</div>
)}
</div>
</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}
disabled={isImporting}
>
Annuler
</button>
<button
type="button"
className="px-6 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm hover:shadow-md"
onClick={handleImport}
disabled={
isImporting ||
parsedEvents.length === 0 ||
(!createNewTablo && !selectedTabloId) ||
(createNewTablo && !newTabloName.trim())
}
>
{isImporting
? "Import en cours..."
: `Importer ${parsedEvents.length} événement(s)`}
</button>
</div>
</div>
</div>
);
};

View file

@ -11,15 +11,16 @@ import {
// Fetch events for a specific tablo
export const useEventsByTablo = (tabloId: string | null) => {
const key = tabloId ? ["events", tabloId] : ["events", "all"];
return useQuery({
queryKey: ["events", tabloId],
queryKey: key,
queryFn: async () => {
if (!tabloId) {
const { data, error } = await supabase
.from("events_and_tablos")
.select("*")
.order("start_date", { ascending: true })
.order("start_time", { ascending: true });
.order("start_date", { ascending: false })
.order("start_time", { ascending: false });
if (error) throw error;
return data as EventAndTablo[];
@ -28,8 +29,8 @@ export const useEventsByTablo = (tabloId: string | null) => {
.from("events_and_tablos")
.select("*")
.eq("tablo_id", tabloId)
.order("start_date", { ascending: true })
.order("start_time", { ascending: true });
.order("start_date", { ascending: false })
.order("start_time", { ascending: false });
if (error) throw error;
return data as EventAndTablo[];
@ -83,30 +84,41 @@ export const useEvent = (eventId: string) => {
};
// Create new event
export const useCreateEvent = () => {
export const useCreateEvents = () => {
const user = useUser();
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: async (event: EventInsert) => {
mutationFn: async (events: EventInsert | EventInsert[]) => {
const { data, error } = await supabase
.from("events")
.insert({
...event,
created_by: user.id,
})
.select()
.single();
.insert(
Array.isArray(events)
? events.map((e) => ({
...e,
created_by: user.id,
}))
: {
...events,
created_by: user.id,
}
)
.select();
if (error) throw error;
return data as Event;
return data as Event[];
},
onSuccess: () => {
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["events"] });
const eventsCount = Array.isArray(data) ? data.length : 1;
const pluralizeText =
eventsCount === 1
? "L'événement a été créé"
: "Les événements ont été créés";
toast.add(
{
title: "Événement créé",
description: "L'événement a été créé avec succès",
title: `${eventsCount} événement(s) créé(s)`,
description: `${pluralizeText} avec succès`,
type: "success",
},
{

View file

@ -6,10 +6,10 @@ import { api } from "@ui/lib/api";
import { toast } from "@ui/ui-library/toast/toast-queue";
import { RemoveNullFromObject } from "@ui/types/removeNull";
import { useUser } from "@ui/providers/UserStoreProvider";
import { CreateTablo } from "@ui/types/tablos.types";
type Tablo = Database["public"]["Tables"]["tablos"];
type TabloInsert = Tablo["Insert"];
type TabloUpdate = Tablo["Update"];
type UserTablo = RemoveNullFromObject<
@ -74,9 +74,7 @@ export const useCreateTablo = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
tablo: Pick<TabloInsert, "name" | "color" | "image" | "status">
) => {
mutationFn: async (tablo: CreateTablo) => {
const { data } = await api.post("/api/v1/tablos/create", tablo, {
headers: {
Authorization: `Bearer ${session?.access_token}`,
@ -127,6 +125,8 @@ export const useUpdateTablo = () => {
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ["tablos"] });
queryClient.invalidateQueries({ queryKey: ["tablos", id] });
queryClient.invalidateQueries({ queryKey: ["events", "all"] });
queryClient.invalidateQueries({ queryKey: ["events", id] });
},
onError: (error) => {
console.error(error);

View file

@ -10,6 +10,7 @@ import {
} from "@ui/ui-library/select";
import { Outlet, useNavigate, useParams } from "react-router-dom";
import { generateICSFromEvents, downloadICSFile } from "@ui/utils/helpers";
import { ImportICSModal } from "@ui/components/ImportICSModal";
type ViewType = "month" | "week" | "day";
@ -22,6 +23,7 @@ export const PlanningPage = () => {
const [selectedTabloId, setSelectedTabloId] = useState<string>(
tablo_id || "all"
);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
// Fetch tablos
const { data: tablos, isLoading: tablosLoading } = useTablosList();
@ -692,6 +694,26 @@ export const PlanningPage = () => {
>
+ Créer un événement
</button>
<button
onClick={() => setIsImportModalOpen(true)}
className="w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium shadow-sm mt-2 flex items-center justify-center space-x-2"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"
/>
</svg>
<span>Importer un planning</span>
</button>
</div>
{/* Mini Calendar */}
@ -884,6 +906,10 @@ export const PlanningPage = () => {
)} */}
<Outlet />
{isImportModalOpen && (
<ImportICSModal onClose={() => setIsImportModalOpen(false)} />
)}
</div>
);
};

View file

@ -8,6 +8,7 @@ export type Event = RemoveNullFromObject<
export type EventInsert = TablesInsert<"events">;
export type EventUpdate = TablesUpdate<"events">;
export type EventInsertInTablo = Omit<EventInsert, "tablo_id">;
export type EventAndTablo = RemoveNullFromObject<
Tables<"events_and_tablos">,
| "event_id"

View file

@ -1,5 +1,6 @@
import { Database } from "@ui/types/database.types";
import { RemoveNullFromObject } from "@ui/types/removeNull";
import { EventInsertInTablo } from "@ui/types/events.types";
export type UserTablo = RemoveNullFromObject<
Database["public"]["Views"]["user_tablos"]["Row"],
@ -18,3 +19,10 @@ export type Tablo = Database["public"]["Tables"]["tablos"];
export type TabloInsert = Tablo["Insert"];
export type TabloUpdate = Tablo["Update"];
export type CreateTablo = Pick<
TabloInsert,
"name" | "color" | "image" | "status"
> & {
events?: EventInsertInTablo[];
};

View file

@ -135,4 +135,12 @@
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
button:hover {
cursor: pointer;
}
button:disabled:hover {
cursor: not-allowed;
}
}

View file

@ -166,3 +166,110 @@ export const downloadICSFile = (
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
};
// ICS Import functionality
export interface ParsedICSEvent {
title: string;
description?: string;
start_date: string;
start_time: string;
end_time?: string;
}
export const parseICSFile = (icsContent: string): ParsedICSEvent[] => {
const events: ParsedICSEvent[] = [];
const lines = icsContent.split(/\r?\n/);
let currentEvent: Partial<ParsedICSEvent> | null = null;
const unescapeICSText = (text: string) => {
return text
.replace(/\\n/g, "\n")
.replace(/\\,/g, ",")
.replace(/\\;/g, ";")
.replace(/\\\\/g, "\\");
};
const parseDateTime = (dateTimeStr: string) => {
// Handle both YYYYMMDDTHHMMSSZ and YYYYMMDDTHHMMSS formats
const cleanDate = dateTimeStr.replace(/[TZ]/g, "");
if (cleanDate.length >= 8) {
const year = cleanDate.substring(0, 4);
const month = cleanDate.substring(4, 6);
const day = cleanDate.substring(6, 8);
const date = `${year}-${month}-${day}`;
if (cleanDate.length >= 14) {
const hour = cleanDate.substring(8, 10);
const minute = cleanDate.substring(10, 12);
const second = cleanDate.substring(12, 14) || "00";
const time = `${hour}:${minute}:${second}`;
return { date, time };
} else {
return { date, time: "00:00:00" };
}
}
return null;
};
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
// Handle line folding (lines starting with space or tab)
while (i + 1 < lines.length && /^[ \t]/.test(lines[i + 1])) {
line += lines[i + 1].substring(1);
i++;
}
if (line === "BEGIN:VEVENT") {
currentEvent = {};
} else if (line === "END:VEVENT" && currentEvent) {
if (
currentEvent.title &&
currentEvent.start_date &&
currentEvent.start_time
) {
events.push(currentEvent as ParsedICSEvent);
}
currentEvent = null;
} else if (currentEvent && line.includes(":")) {
const colonIndex = line.indexOf(":");
const property = line.substring(0, colonIndex);
const value = line.substring(colonIndex + 1);
// Handle properties that might have parameters (e.g., DTSTART;TZID=...)
const [propName] = property.split(";");
switch (propName) {
case "SUMMARY":
currentEvent.title = unescapeICSText(value);
break;
case "DESCRIPTION":
currentEvent.description = unescapeICSText(value);
break;
case "DTSTART": {
const startDateTime = parseDateTime(value);
if (startDateTime) {
currentEvent.start_date = startDateTime.date;
currentEvent.start_time = startDateTime.time;
}
break;
}
case "DTEND": {
const endDateTime = parseDateTime(value);
if (endDateTime) {
// Only use end time if it's on the same date
const startDate = currentEvent.start_date;
if (startDate === endDateTime.date) {
currentEvent.end_time = endDateTime.time;
}
}
break;
}
}
}
}
return events;
};