Add Webcal synchronization feature with modal and backend integration
This commit is contained in:
parent
58d5c3ef9e
commit
150284ad26
5 changed files with 359 additions and 3 deletions
14
sql/16_create_calendar_sync_table.sql
Normal file
14
sql/16_create_calendar_sync_table.sql
Normal 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;
|
||||
254
ui/src/components/WebcalModal.tsx
Normal file
254
ui/src/components/WebcalModal.tsx
Normal 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 “
|
||||
{getTabloName(selectedTabloId || "")}
|
||||
”
|
||||
</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'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
65
ui/src/hooks/webcal.ts
Normal 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 };
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue