xtablo-source/apps/main/src/components/ImportICSModal.tsx
2025-10-25 10:42:33 +02:00

346 lines
12 KiB
TypeScript

import { ParsedICSEvent, parseICSFile, toast } from "@xtablo/shared";
import { EventInsert } from "@xtablo/shared/types/events.types";
import { CreateTablo } from "@xtablo/shared/types/tablos.types";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@xtablo/ui/components/select";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useCreateEvents } from "../hooks/events";
import { useCreateTablo, useTablosList } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
interface ImportICSModalProps {
onClose: () => void;
}
export const ImportICSModal = ({ onClose }: ImportICSModalProps) => {
const { t } = useTranslation("components");
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: t("importICSModal.toasts.noEvents.title"),
description: t("importICSModal.toasts.noEvents.description"),
type: "warning",
},
{ timeout: 4000 }
);
}
} catch (error) {
console.error("Error parsing ICS file:", error);
toast.add(
{
title: t("importICSModal.toasts.readError.title"),
description: t("importICSModal.toasts.readError.description"),
type: "error",
},
{ timeout: 4000 }
);
}
};
reader.readAsText(file);
} else {
toast.add(
{
title: t("importICSModal.toasts.invalidFormat.title"),
description: t("importICSModal.toasts.invalidFormat.description"),
type: "error",
},
{ timeout: 4000 }
);
}
};
const handleImport = async () => {
if (parsedEvents.length === 0) {
toast.add(
{
title: t("importICSModal.toasts.noEventsToImport.title"),
description: t("importICSModal.toasts.noEventsToImport.description"),
type: "warning",
},
{ timeout: 3000 }
);
return;
}
if (!createNewTablo && !selectedTabloId) {
toast.add(
{
title: t("importICSModal.toasts.tabloRequired.title"),
description: t("importICSModal.toasts.tabloRequired.description"),
type: "warning",
},
{ timeout: 3000 }
);
return;
}
if (createNewTablo && !newTabloName.trim()) {
toast.add(
{
title: t("importICSModal.toasts.tabloNameRequired.title"),
description: t("importICSModal.toasts.tabloNameRequired.description"),
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: t("importICSModal.toasts.importSuccess.title"),
description: t("importICSModal.toasts.importSuccess.description", {
count: parsedEvents.length,
}),
type: "success",
},
{ timeout: 4000 }
);
} else {
await createEvents(eventsToInsert);
}
onClose();
} catch (error) {
console.error("Error importing events:", error);
toast.add(
{
title: t("importICSModal.toasts.importError.title"),
description: t("importICSModal.toasts.importError.description"),
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-linear-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">{t("importICSModal.title")}</h2>
<button
onClick={onClose}
className="text-white hover:text-gray-200 transition-colors"
aria-label="Close"
>
<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">{t("importICSModal.subtitle")}</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">
{t("importICSModal.labels.file")}
</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"
>
{t("importICSModal.buttons.chooseFile")}
</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">
{t("importICSModal.messages.eventsFound", { count: parsedEvents.length })}
</div>
)}
</div>
{/* Tablo Selection */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-600 dark:text-gray-400">
{t("importICSModal.labels.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">
{t("importICSModal.checkbox.createNewTablo")}
</label>
</div>
{createNewTablo ? (
<input
type="text"
value={newTabloName}
onChange={(e) => setNewTabloName(e.target.value)}
placeholder={t("importICSModal.placeholders.newTabloName")}
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
value={selectedTabloId}
onValueChange={(value) => setSelectedTabloId(value)}
disabled={tablosLoading}
>
<SelectTrigger
className="w-full"
aria-label={t("importICSModal.placeholders.selectTablo")}
>
<SelectValue placeholder={t("importICSModal.placeholders.selectTablo")} />
</SelectTrigger>
<SelectContent>
{tablos?.map((tablo) => (
<SelectItem key={tablo.id} value={tablo.id}>
{tablo.name}
</SelectItem>
))}
</SelectContent>
</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">
{t("importICSModal.labels.preview")}
</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">
{t("importICSModal.messages.andMore", { count: parsedEvents.length - 5 })}
</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}
>
{t("importICSModal.buttons.cancel")}
</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
? t("importICSModal.buttons.importing")
: t("importICSModal.buttons.import", { count: parsedEvents.length })}
</button>
</div>
</div>
</div>
);
};