From ded3a9d2186ecf670b2f62af3efe611b23300380 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 27 Jul 2025 15:46:38 +0200 Subject: [PATCH] Implement ICS import functionality with event parsing and creation in the UI --- api/src/tablo.ts | 26 +- api/src/types.ts | 44 ++- ui/src/components/CreateEventModal.tsx | 6 +- ui/src/components/ImportICSModal.tsx | 357 +++++++++++++++++++++++++ ui/src/hooks/events.ts | 46 ++-- ui/src/hooks/tablos.ts | 8 +- ui/src/pages/planning.tsx | 26 ++ ui/src/types/events.types.ts | 1 + ui/src/types/tablos.types.ts | 8 + ui/src/utils/helpers.ts | 107 ++++++++ 10 files changed, 594 insertions(+), 35 deletions(-) create mode 100644 ui/src/components/ImportICSModal.tsx diff --git a/api/src/tablo.ts b/api/src/tablo.ts index ecc6e82..ac36616 100644 --- a/api/src/tablo.ts +++ b/api/src/tablo.ts @@ -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 & { + 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; + 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" }); }); diff --git a/api/src/types.ts b/api/src/types.ts index 770fe17..9710f6e 100644 --- a/api/src/types.ts +++ b/api/src/types.ts @@ -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, "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 extends null ? never : T; + +/** + * Utility type to remove null from all properties of an object type + */ +export type RemoveNullFromObject = { + [L in keyof T]: L extends K ? RemoveNull : T[L]; }; diff --git a/ui/src/components/CreateEventModal.tsx b/ui/src/components/CreateEventModal.tsx index f2e241c..0346d11 100644 --- a/ui/src/components/CreateEventModal.tsx +++ b/ui/src/components/CreateEventModal.tsx @@ -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() } ); diff --git a/ui/src/components/ImportICSModal.tsx b/ui/src/components/ImportICSModal.tsx new file mode 100644 index 0000000..b47a6b4 --- /dev/null +++ b/ui/src/components/ImportICSModal.tsx @@ -0,0 +1,357 @@ +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(null); + const [selectedFile, setSelectedFile] = useState(null); + const [parsedEvents, setParsedEvents] = useState([]); + const [selectedTabloId, setSelectedTabloId] = useState(""); + 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) => { + 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 } + ); + + onClose(); + } else { + await createEvents(eventsToInsert); + } + } 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 ( +
+
+ {/* Header */} +
+
+

Importer un fichier ICS

+ +
+
+ Importez vos événements depuis un fichier calendrier +
+
+ + {/* Form Content */} +
+ {/* File Selection */} +
+ +
+ + + {selectedFile && ( + + {selectedFile.name} + + )} +
+ {parsedEvents.length > 0 && ( +
+ {parsedEvents.length} événement(s) trouvé(s) +
+ )} +
+ + {/* Tablo Selection */} +
+ + + {/* Create new tablo option */} +
+ { + setCreateNewTablo(e.target.checked); + if (e.target.checked) { + setSelectedTabloId(""); + } + }} + className="rounded border-gray-300 dark:border-gray-600" + /> + +
+ + {createNewTablo ? ( + 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" + /> + ) : ( + + )} +
+ + {/* Preview */} + {parsedEvents.length > 0 && ( +
+ +
+ {parsedEvents.slice(0, 5).map((event, index) => ( +
+ {event.title} + + {event.start_date} {event.start_time.substring(0, 5)} + +
+ ))} + {parsedEvents.length > 5 && ( +
+ ... et {parsedEvents.length - 5} autre(s) +
+ )} +
+
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; diff --git a/ui/src/hooks/events.ts b/ui/src/hooks/events.ts index 11e2d60..0c5f06b 100644 --- a/ui/src/hooks/events.ts +++ b/ui/src/hooks/events.ts @@ -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", }, { diff --git a/ui/src/hooks/tablos.ts b/ui/src/hooks/tablos.ts index bd93ca1..2511ee3 100644 --- a/ui/src/hooks/tablos.ts +++ b/ui/src/hooks/tablos.ts @@ -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 - ) => { + 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); diff --git a/ui/src/pages/planning.tsx b/ui/src/pages/planning.tsx index 8323bc4..b574955 100644 --- a/ui/src/pages/planning.tsx +++ b/ui/src/pages/planning.tsx @@ -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( 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 + + {/* Mini Calendar */} @@ -884,6 +906,10 @@ export const PlanningPage = () => { )} */} + + {isImportModalOpen && ( + setIsImportModalOpen(false)} /> + )} ); }; diff --git a/ui/src/types/events.types.ts b/ui/src/types/events.types.ts index f665deb..90d3a2e 100644 --- a/ui/src/types/events.types.ts +++ b/ui/src/types/events.types.ts @@ -8,6 +8,7 @@ export type Event = RemoveNullFromObject< export type EventInsert = TablesInsert<"events">; export type EventUpdate = TablesUpdate<"events">; +export type EventInsertInTablo = Omit; export type EventAndTablo = RemoveNullFromObject< Tables<"events_and_tablos">, | "event_id" diff --git a/ui/src/types/tablos.types.ts b/ui/src/types/tablos.types.ts index f6e0445..975c10d 100644 --- a/ui/src/types/tablos.types.ts +++ b/ui/src/types/tablos.types.ts @@ -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[]; +}; diff --git a/ui/src/utils/helpers.ts b/ui/src/utils/helpers.ts index 18e4f52..5fd3437 100644 --- a/ui/src/utils/helpers.ts +++ b/ui/src/utils/helpers.ts @@ -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 | 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; +};