Add Webcal synchronization feature with modal and backend integration

This commit is contained in:
Arthur Belleville 2025-07-28 22:07:14 +02:00
parent 58d5c3ef9e
commit 150284ad26
No known key found for this signature in database
5 changed files with 359 additions and 3 deletions

View file

@ -0,0 +1,14 @@
CREATE TABLE calendar_subscriptions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
tablo_id TEXT NOT NULL UNIQUE REFERENCES tablos(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create index on token for fast lookups
CREATE INDEX idx_calendar_subscriptions_token ON calendar_subscriptions(token);
-- Note: Index on tablo_id is automatically created due to UNIQUE constraint
-- Enable Row Level Security
ALTER TABLE calendar_subscriptions ENABLE ROW LEVEL SECURITY;

View file

@ -0,0 +1,254 @@
import { useState } from "react";
import { useTablosList } from "@ui/hooks/tablos";
import { useGenerateWebcalToken } from "@ui/hooks/webcal";
import {
Select,
SelectButton,
SelectPopover,
SelectListBox,
SelectListItem,
} from "@ui/ui-library/select";
import { toast } from "@ui/ui-library/toast/toast-queue";
interface WebcalModalProps {
onClose: () => void;
}
export const WebcalModal = ({ onClose }: WebcalModalProps) => {
const [selectedTabloId, setSelectedTabloId] = useState<string | null>(null);
const { data: tablos, isLoading: tablosLoading } = useTablosList();
const {
generateWebcalUrl,
isPending,
data: webcalUrl,
} = useGenerateWebcalToken();
const copyToClipboard = async (text: string, type: string) => {
try {
await navigator.clipboard.writeText(text);
toast.add(
{
title: "Copié !",
description: `${type} copié dans le presse-papiers`,
type: "success",
},
{ timeout: 2000 }
);
} catch (error) {
console.error("Failed to copy:", error);
toast.add(
{
title: "Erreur",
description: "Impossible de copier dans le presse-papiers",
type: "error",
},
{ timeout: 3000 }
);
}
};
const getTabloName = (tabloId: string) => {
if (tabloId === null) return "Tous les tablos";
return tablos?.find((t) => t.id === tabloId)?.name || "Tablo inconnu";
};
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-2xl mx-4 overflow-hidden max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="bg-gradient-to-r from-purple-500 to-purple-600 p-6 text-white">
<div className="flex items-center justify-between">
<h2 className="text-xl font-medium">
Synchronisation de calendrier
</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-purple-100 text-sm">
Synchronisez vos événements avec votre application de calendrier
préférée
</div>
</div>
{/* Content */}
<div className="p-6">
{/* Generate new subscription */}
<div className="mb-8">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
Créer un nouvel abonnement
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
Calendrier à synchroniser
</label>
<Select
placeholder={
tablosLoading
? "Chargement..."
: "Sélectionner un calendrier"
}
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-purple-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>
<button
onClick={() => generateWebcalUrl(selectedTabloId)}
disabled={isPending || selectedTabloId === "all"}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{isPending
? "Génération..."
: "Générer l'URL de synchronisation"}
</button>
</div>
{/* Generated webcal URLs */}
{webcalUrl && (
<div className="mt-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<h4 className="font-medium text-green-800 dark:text-green-200 mb-3">
URLs générées pour &ldquo;
{getTabloName(selectedTabloId || "")}
&rdquo;
</h4>
<div className="space-y-3">
{/* TODO: Add webcal URL */}
{/* <div>
<label className="block text-sm font-medium text-green-700 dark:text-green-300 mb-1">
URL de souscription (webcal://)
</label>
<div className="flex">
<input
type="text"
value={webcalUrl.http_url}
readOnly
className="flex-1 px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-green-300 dark:border-green-600 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-green-500"
/>
<button
onClick={() =>
copyToClipboard(
webcalUrl.http_url,
"URL de souscription"
)
}
className="px-3 py-2 bg-green-600 text-white rounded-r-lg hover:bg-green-700 transition-colors"
title="Copier l'URL"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
</div>
</div> */}
<div>
<label className="block text-sm font-medium text-green-700 dark:text-green-300 mb-1">
URL HTTP
</label>
<div className="flex">
<input
type="text"
value={webcalUrl.http_url}
readOnly
className="flex-1 px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-green-300 dark:border-green-600 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-green-500"
/>
<button
onClick={() =>
copyToClipboard(webcalUrl.http_url, "URL HTTP")
}
className="px-3 py-2 bg-green-600 text-white rounded-r-lg hover:bg-green-700 transition-colors"
title="Copier l'URL"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
</div>
</div>
</div>
<div className="mt-3 text-sm text-green-600 dark:text-green-400">
<p className="font-medium">Instructions :</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>
Copiez l&apos;URL de souscription et ajoutez-la à votre
application de calendrier
</li>
<li>
Les événements se synchroniseront automatiquement toutes
les heures
</li>
</ul>
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="bg-gray-50 dark:bg-gray-800 px-6 py-4 flex justify-end">
<button
onClick={onClose}
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"
>
Fermer
</button>
</div>
</div>
</div>
);
};

65
ui/src/hooks/webcal.ts Normal file
View file

@ -0,0 +1,65 @@
import { useMutation } from "@tanstack/react-query";
import { useSession } from "@ui/contexts/SessionContext";
import { toast } from "@ui/ui-library/toast/toast-queue";
import { api } from "@ui/lib/api";
export interface WebcalToken {
token: string;
metadata: {
tablo_id: string;
};
created_at: string;
}
export interface WebcalResponse {
webcal_url: string;
http_url: string;
}
// Generate a new webcal token
export const useGenerateWebcalToken = () => {
const { session } = useSession();
const { mutate, isPending, data } = useMutation({
mutationFn: async (tablo_id: string | null): Promise<WebcalResponse> => {
const { data } = await api.post(
"/api/v1/tablos/webcal/generate-url",
{ tablo_id },
{
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
}
);
return data;
},
onSuccess: () => {
toast.add(
{
title: "URL de synchronisation générée",
description:
"Vous pouvez maintenant ajouter ce calendrier à votre application",
type: "success",
},
{
timeout: 4000,
}
);
},
onError: (error) => {
console.error("Error generating webcal token:", error);
toast.add(
{
title: "Erreur",
description: "Impossible de générer l'URL de synchronisation",
type: "error",
},
{
timeout: 4000,
}
);
},
});
return { generateWebcalUrl: mutate, isPending, data };
};

View file

@ -11,6 +11,7 @@ import {
import { Outlet, useNavigate, useParams } from "react-router-dom";
import { generateICSFromEvents, downloadICSFile } from "@ui/utils/helpers";
import { ImportICSModal } from "@ui/components/ImportICSModal";
import { WebcalModal } from "@ui/components/WebcalModal";
type ViewType = "month" | "week" | "day";
@ -24,6 +25,7 @@ export const PlanningPage = () => {
tablo_id || "all"
);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isWebcalModalOpen, setIsWebcalModalOpen] = useState(false);
// Fetch tablos
const { data: tablos, isLoading: tablosLoading } = useTablosList();
@ -835,6 +837,26 @@ export const PlanningPage = () => {
</svg>
<span>Exporter</span>
</button>
<button
onClick={() => setIsWebcalModalOpen(true)}
className="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 flex items-center space-x-1"
title="Synchroniser avec votre calendrier"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>Synchroniser</span>
</button>
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
{(["month", "week", "day"] as ViewType[]).map((view) => (
<button
@ -910,6 +932,10 @@ export const PlanningPage = () => {
{isImportModalOpen && (
<ImportICSModal onClose={() => setIsImportModalOpen(false)} />
)}
{isWebcalModalOpen && (
<WebcalModal onClose={() => setIsWebcalModalOpen(false)} />
)}
</div>
);
};

View file

@ -118,8 +118,6 @@ export const generateICSFromEvents = (
events.forEach((event) => {
if (!event.start_date || !event.start_time || !event.title) return;
console.log("event", event);
const startDateTime = formatDate(event.start_date, event.start_time);
const endDateTime = event.end_time
? formatDate(event.start_date, event.end_time)
@ -156,7 +154,6 @@ export const downloadICSFile = (
icsContent: string,
filename: string = "planning.ics"
) => {
console.log("icsContent", icsContent);
const blob = new Blob([icsContent], { type: "text/calendar;charset=utf-8" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);