346 lines
12 KiB
TypeScript
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>
|
|
);
|
|
};
|