clean without courrier and calendar
This commit is contained in:
parent
144b047877
commit
75ffabd1d2
@ -1,121 +0,0 @@
|
|||||||
import { getServerSession } from "next-auth/next";
|
|
||||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { CalendarClient } from "@/components/calendar/calendar-client";
|
|
||||||
import { Metadata } from "next";
|
|
||||||
import { CalendarDays, Users, Bookmark, Clock } from "lucide-react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { add } from 'date-fns';
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Enkun - Calendrier | Gestion d'événements professionnelle",
|
|
||||||
description: "Plateforme avancée pour la gestion de vos rendez-vous, réunions et événements professionnels",
|
|
||||||
keywords: "calendrier, rendez-vous, événements, gestion du temps, enkun",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Event {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description?: string | null;
|
|
||||||
start: Date;
|
|
||||||
end: Date;
|
|
||||||
location?: string | null;
|
|
||||||
isAllDay: boolean;
|
|
||||||
type?: string;
|
|
||||||
attendees?: { id: string; name: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Calendar {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
description?: string | null;
|
|
||||||
events: Event[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function CalendarPage() {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
redirect("/api/auth/signin");
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = session.user.username || session.user.email || '';
|
|
||||||
|
|
||||||
// Get all calendars for the user
|
|
||||||
let calendars = await prisma.calendar.findMany({
|
|
||||||
where: {
|
|
||||||
userId: session?.user?.id || '',
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
events: {
|
|
||||||
orderBy: {
|
|
||||||
start: 'asc'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// If no calendars exist, create default ones
|
|
||||||
if (calendars.length === 0) {
|
|
||||||
const defaultCalendars = [
|
|
||||||
{
|
|
||||||
name: "Default",
|
|
||||||
color: "#4F46E5",
|
|
||||||
description: "Your default calendar"
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
calendars = await Promise.all(
|
|
||||||
defaultCalendars.map(async (cal) => {
|
|
||||||
return prisma.calendar.create({
|
|
||||||
data: {
|
|
||||||
...cal,
|
|
||||||
userId: session?.user?.id || '',
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
events: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const nextWeek = add(now, { days: 7 });
|
|
||||||
|
|
||||||
const upcomingEvents = calendars.flatMap(cal =>
|
|
||||||
cal.events.filter(event =>
|
|
||||||
new Date(event.start) >= now &&
|
|
||||||
new Date(event.start) <= nextWeek
|
|
||||||
)
|
|
||||||
).sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
|
||||||
|
|
||||||
// Calculate statistics
|
|
||||||
const totalEvents = calendars.flatMap(cal => cal.events).length;
|
|
||||||
|
|
||||||
const totalMeetingHours = calendars
|
|
||||||
.flatMap(cal => cal.events)
|
|
||||||
.reduce((total, event) => {
|
|
||||||
const start = new Date(event.start);
|
|
||||||
const end = new Date(event.end);
|
|
||||||
const hours = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
|
|
||||||
return total + (isNaN(hours) ? 0 : hours);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto py-10">
|
|
||||||
<CalendarClient
|
|
||||||
initialCalendars={calendars}
|
|
||||||
userId={session.user.id}
|
|
||||||
userProfile={{
|
|
||||||
name: session.user.name || '',
|
|
||||||
email: session.user.email || '',
|
|
||||||
avatar: session.user.image || undefined
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,348 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRef, useEffect, useState } from 'react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Paperclip, X } from 'lucide-react';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { decodeComposeContent, encodeComposeContent } from '@/lib/compose-mime-decoder';
|
|
||||||
import { Email } from '@/app/courrier/page';
|
|
||||||
|
|
||||||
interface ComposeEmailProps {
|
|
||||||
showCompose: boolean;
|
|
||||||
setShowCompose: (show: boolean) => void;
|
|
||||||
composeTo: string;
|
|
||||||
setComposeTo: (to: string) => void;
|
|
||||||
composeCc: string;
|
|
||||||
setComposeCc: (cc: string) => void;
|
|
||||||
composeBcc: string;
|
|
||||||
setComposeBcc: (bcc: string) => void;
|
|
||||||
composeSubject: string;
|
|
||||||
setComposeSubject: (subject: string) => void;
|
|
||||||
composeBody: string;
|
|
||||||
setComposeBody: (body: string) => void;
|
|
||||||
showCc: boolean;
|
|
||||||
setShowCc: (show: boolean) => void;
|
|
||||||
showBcc: boolean;
|
|
||||||
setShowBcc: (show: boolean) => void;
|
|
||||||
attachments: any[];
|
|
||||||
setAttachments: (attachments: any[]) => void;
|
|
||||||
handleSend: () => Promise<void>;
|
|
||||||
originalEmail?: {
|
|
||||||
content: string;
|
|
||||||
type: 'reply' | 'reply-all' | 'forward';
|
|
||||||
};
|
|
||||||
onSend: (email: Email) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
onBodyChange?: (body: string) => void;
|
|
||||||
initialTo?: string;
|
|
||||||
initialSubject?: string;
|
|
||||||
initialBody?: string;
|
|
||||||
initialCc?: string;
|
|
||||||
initialBcc?: string;
|
|
||||||
replyTo?: Email | null;
|
|
||||||
forwardFrom?: Email | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ComposeEmail({
|
|
||||||
showCompose,
|
|
||||||
setShowCompose,
|
|
||||||
composeTo,
|
|
||||||
setComposeTo,
|
|
||||||
composeCc,
|
|
||||||
setComposeCc,
|
|
||||||
composeBcc,
|
|
||||||
setComposeBcc,
|
|
||||||
composeSubject,
|
|
||||||
setComposeSubject,
|
|
||||||
composeBody,
|
|
||||||
setComposeBody,
|
|
||||||
showCc,
|
|
||||||
setShowCc,
|
|
||||||
showBcc,
|
|
||||||
setShowBcc,
|
|
||||||
attachments,
|
|
||||||
setAttachments,
|
|
||||||
handleSend,
|
|
||||||
originalEmail,
|
|
||||||
onSend,
|
|
||||||
onCancel,
|
|
||||||
onBodyChange,
|
|
||||||
initialTo,
|
|
||||||
initialSubject,
|
|
||||||
initialBody,
|
|
||||||
initialCc,
|
|
||||||
initialBcc,
|
|
||||||
replyTo,
|
|
||||||
forwardFrom
|
|
||||||
}: ComposeEmailProps) {
|
|
||||||
const composeBodyRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [localContent, setLocalContent] = useState('');
|
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (composeBodyRef.current && !isInitialized) {
|
|
||||||
// Initialize the content structure with both new reply area and original content in a single contentEditable div
|
|
||||||
const content = replyTo || forwardFrom ? `
|
|
||||||
<div class="compose-area" contenteditable="true"></div>
|
|
||||||
<div class="quoted-content" contenteditable="false">
|
|
||||||
${forwardFrom ? `
|
|
||||||
---------- Forwarded message ---------<br/>
|
|
||||||
From: ${forwardFrom.from}<br/>
|
|
||||||
Date: ${new Date(forwardFrom.date).toLocaleString()}<br/>
|
|
||||||
Subject: ${forwardFrom.subject}<br/>
|
|
||||||
To: ${forwardFrom.to}<br/>
|
|
||||||
${forwardFrom.cc ? `Cc: ${forwardFrom.cc}<br/>` : ''}
|
|
||||||
<br/>
|
|
||||||
` : `
|
|
||||||
On ${new Date(replyTo?.date || '').toLocaleString()}, ${replyTo?.from} wrote:<br/>
|
|
||||||
`}
|
|
||||||
<blockquote style="margin: 0 0 0 0.8ex; border-left: 1px solid rgb(204,204,204); padding-left: 1ex;">
|
|
||||||
${composeBody}
|
|
||||||
</blockquote>
|
|
||||||
</div>
|
|
||||||
` : '';
|
|
||||||
|
|
||||||
composeBodyRef.current.innerHTML = content;
|
|
||||||
setIsInitialized(true);
|
|
||||||
|
|
||||||
// Place cursor at the beginning of the compose area
|
|
||||||
const composeArea = composeBodyRef.current.querySelector('.compose-area');
|
|
||||||
if (composeArea) {
|
|
||||||
const range = document.createRange();
|
|
||||||
const sel = window.getSelection();
|
|
||||||
range.setStart(composeArea, 0);
|
|
||||||
range.collapse(true);
|
|
||||||
sel?.removeAllRanges();
|
|
||||||
sel?.addRange(range);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [composeBody, replyTo, forwardFrom, isInitialized]);
|
|
||||||
|
|
||||||
// Modified input handler to work with the single contentEditable area
|
|
||||||
const handleInput = (e: React.FormEvent<HTMLDivElement>) => {
|
|
||||||
if (!composeBodyRef.current) return;
|
|
||||||
const composeArea = composeBodyRef.current.querySelector('.compose-area');
|
|
||||||
if (composeArea) {
|
|
||||||
setComposeBody(composeArea.innerHTML);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileAttachment = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (!e.target.files) return;
|
|
||||||
|
|
||||||
const newAttachments: any[] = [];
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB in bytes
|
|
||||||
const oversizedFiles: string[] = [];
|
|
||||||
|
|
||||||
for (const file of e.target.files) {
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
|
||||||
oversizedFiles.push(file.name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Read file as base64
|
|
||||||
const base64Content = await new Promise<string>((resolve) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onloadend = () => {
|
|
||||||
const base64 = reader.result as string;
|
|
||||||
resolve(base64.split(',')[1]); // Remove data URL prefix
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
|
|
||||||
newAttachments.push({
|
|
||||||
name: file.name,
|
|
||||||
type: file.type,
|
|
||||||
content: base64Content,
|
|
||||||
encoding: 'base64'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error processing attachment:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oversizedFiles.length > 0) {
|
|
||||||
alert(`The following files exceed the 10MB size limit and were not attached:\n${oversizedFiles.join('\n')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newAttachments.length > 0) {
|
|
||||||
setAttachments([...attachments, ...newAttachments]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!showCompose) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-gray-600/30 backdrop-blur-sm z-50 flex items-center justify-center">
|
|
||||||
<div className="w-full max-w-2xl h-[90vh] bg-white rounded-xl shadow-xl flex flex-col mx-4">
|
|
||||||
{/* Modal Header */}
|
|
||||||
<div className="flex-none flex items-center justify-between px-6 py-3 border-b border-gray-200">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
|
||||||
{replyTo ? 'Reply' : forwardFrom ? 'Forward' : 'New Message'}
|
|
||||||
</h3>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="hover:bg-gray-100 rounded-full"
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5 text-gray-500" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Modal Body */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<div className="h-full flex flex-col p-6 space-y-4 overflow-y-auto">
|
|
||||||
{/* To Field */}
|
|
||||||
<div className="flex-none">
|
|
||||||
<Label htmlFor="to" className="block text-sm font-medium text-gray-700">To</Label>
|
|
||||||
<Input
|
|
||||||
id="to"
|
|
||||||
value={composeTo}
|
|
||||||
onChange={(e) => setComposeTo(e.target.value)}
|
|
||||||
placeholder="recipient@example.com"
|
|
||||||
className="w-full mt-1 bg-white border-gray-300 text-gray-900"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CC/BCC Toggle Buttons */}
|
|
||||||
<div className="flex-none flex items-center gap-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
|
|
||||||
onClick={() => setShowCc(!showCc)}
|
|
||||||
>
|
|
||||||
{showCc ? 'Hide Cc' : 'Add Cc'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
|
|
||||||
onClick={() => setShowBcc(!showBcc)}
|
|
||||||
>
|
|
||||||
{showBcc ? 'Hide Bcc' : 'Add Bcc'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CC Field */}
|
|
||||||
{showCc && (
|
|
||||||
<div className="flex-none">
|
|
||||||
<Label htmlFor="cc" className="block text-sm font-medium text-gray-700">Cc</Label>
|
|
||||||
<Input
|
|
||||||
id="cc"
|
|
||||||
value={composeCc}
|
|
||||||
onChange={(e) => setComposeCc(e.target.value)}
|
|
||||||
placeholder="cc@example.com"
|
|
||||||
className="w-full mt-1 bg-white border-gray-300 text-gray-900"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* BCC Field */}
|
|
||||||
{showBcc && (
|
|
||||||
<div className="flex-none">
|
|
||||||
<Label htmlFor="bcc" className="block text-sm font-medium text-gray-700">Bcc</Label>
|
|
||||||
<Input
|
|
||||||
id="bcc"
|
|
||||||
value={composeBcc}
|
|
||||||
onChange={(e) => setComposeBcc(e.target.value)}
|
|
||||||
placeholder="bcc@example.com"
|
|
||||||
className="w-full mt-1 bg-white border-gray-300 text-gray-900"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Subject Field */}
|
|
||||||
<div className="flex-none">
|
|
||||||
<Label htmlFor="subject" className="block text-sm font-medium text-gray-700">Subject</Label>
|
|
||||||
<Input
|
|
||||||
id="subject"
|
|
||||||
value={composeSubject}
|
|
||||||
onChange={(e) => setComposeSubject(e.target.value)}
|
|
||||||
placeholder="Enter subject"
|
|
||||||
className="w-full mt-1 bg-white border-gray-300 text-gray-900"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Message Body - Single contentEditable area with separated regions */}
|
|
||||||
<div className="flex-1 min-h-[200px] flex flex-col">
|
|
||||||
<Label htmlFor="message" className="flex-none block text-sm font-medium text-gray-700 mb-2">Message</Label>
|
|
||||||
<div
|
|
||||||
ref={composeBodyRef}
|
|
||||||
className="flex-1 w-full bg-white border border-gray-300 rounded-md p-4 text-gray-900 overflow-y-auto focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
style={{ direction: 'ltr' }}
|
|
||||||
dir="ltr"
|
|
||||||
spellCheck="true"
|
|
||||||
role="textbox"
|
|
||||||
aria-multiline="true"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<style>{`
|
|
||||||
.compose-area {
|
|
||||||
min-height: 100px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.quoted-content {
|
|
||||||
color: #666;
|
|
||||||
user-select: text;
|
|
||||||
-webkit-user-select: text;
|
|
||||||
}
|
|
||||||
.quoted-content blockquote {
|
|
||||||
margin: 0.8ex;
|
|
||||||
padding-left: 1ex;
|
|
||||||
border-left: 1px solid #ccc;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Modal Footer */}
|
|
||||||
<div className="flex-none flex items-center justify-between px-6 py-3 border-t border-gray-200 bg-white">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* File Input for Attachments */}
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
id="file-attachment"
|
|
||||||
className="hidden"
|
|
||||||
multiple
|
|
||||||
onChange={handleFileAttachment}
|
|
||||||
/>
|
|
||||||
<label htmlFor="file-attachment">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="rounded-full bg-white hover:bg-gray-100 border-gray-300"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
document.getElementById('file-attachment')?.click();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Paperclip className="h-4 w-4 text-gray-600" />
|
|
||||||
</Button>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="text-gray-600 hover:text-gray-700 hover:bg-gray-100"
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
|
||||||
onClick={handleSend}
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,172 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { RefreshCw, Calendar as CalendarIcon } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
interface Event {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
allDay: boolean;
|
|
||||||
calendar: string;
|
|
||||||
calendarColor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Calendar() {
|
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const fetchEvents = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/calendars');
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch events');
|
|
||||||
}
|
|
||||||
|
|
||||||
const calendarsData = await response.json();
|
|
||||||
console.log('Calendar Widget - Fetched calendars:', calendarsData);
|
|
||||||
|
|
||||||
// Get current date at the start of the day
|
|
||||||
const now = new Date();
|
|
||||||
now.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
// Extract and process events from all calendars
|
|
||||||
const allEvents = calendarsData.flatMap((calendar: any) =>
|
|
||||||
(calendar.events || []).map((event: any) => ({
|
|
||||||
id: event.id,
|
|
||||||
title: event.title,
|
|
||||||
start: event.start,
|
|
||||||
end: event.end,
|
|
||||||
allDay: event.isAllDay,
|
|
||||||
calendar: calendar.name,
|
|
||||||
calendarColor: calendar.color
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter for upcoming events
|
|
||||||
const upcomingEvents = allEvents
|
|
||||||
.filter((event: any) => new Date(event.start) >= now)
|
|
||||||
.sort((a: any, b: any) => new Date(a.start).getTime() - new Date(b.start).getTime())
|
|
||||||
.slice(0, 7);
|
|
||||||
|
|
||||||
console.log('Calendar Widget - Processed events:', upcomingEvents);
|
|
||||||
setEvents(upcomingEvents);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching events:', err);
|
|
||||||
setError('Failed to load events');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchEvents();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return new Intl.DateTimeFormat('fr-FR', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: 'short'
|
|
||||||
}).format(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateString: string) => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return new Intl.DateTimeFormat('fr-FR', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
}).format(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
|
|
||||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
|
||||||
<CalendarIcon className="h-5 w-5 text-gray-600" />
|
|
||||||
Agenda
|
|
||||||
</CardTitle>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => fetchEvents()}
|
|
||||||
className="h-7 w-7 p-0 hover:bg-gray-100/50 rounded-full"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-3.5 w-3.5 text-gray-600" />
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-3">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-6">
|
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="text-xs text-red-500 text-center py-3">{error}</div>
|
|
||||||
) : events.length === 0 ? (
|
|
||||||
<div className="text-xs text-gray-500 text-center py-6">No upcoming events</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1 scrollbar-thin scrollbar-thumb-gray-200 scrollbar-track-transparent">
|
|
||||||
{events.map((event) => (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
className="p-2 rounded-lg bg-white shadow-sm hover:shadow-md transition-all duration-200 border border-gray-100"
|
|
||||||
>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 w-14 h-14 rounded-lg flex flex-col items-center justify-center border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: `${event.calendarColor}10`,
|
|
||||||
borderColor: event.calendarColor
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="text-[10px] font-medium"
|
|
||||||
style={{ color: event.calendarColor }}
|
|
||||||
>
|
|
||||||
{formatDate(event.start)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="text-[10px] font-bold mt-0.5"
|
|
||||||
style={{ color: event.calendarColor }}
|
|
||||||
>
|
|
||||||
{formatTime(event.start)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0 space-y-1">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<p className="text-sm font-medium text-gray-800 line-clamp-2 flex-1">
|
|
||||||
{event.title}
|
|
||||||
</p>
|
|
||||||
{!event.allDay && (
|
|
||||||
<span className="text-[10px] text-gray-500 whitespace-nowrap">
|
|
||||||
{formatTime(event.start)} - {formatTime(event.end)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="flex items-center text-[10px] px-1.5 py-0.5 rounded-md"
|
|
||||||
style={{
|
|
||||||
backgroundColor: `${event.calendarColor}10`,
|
|
||||||
color: event.calendarColor
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="truncate">{event.calendar}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,114 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Calendar } from "@prisma/client";
|
|
||||||
|
|
||||||
interface CalendarDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (calendarData: Partial<Calendar>) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CalendarDialog({ open, onClose, onSave }: CalendarDialogProps) {
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [color, setColor] = useState("#0082c9");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onSave({ name, color, description });
|
|
||||||
resetForm();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Erreur lors de la création du calendrier:", error);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setName("");
|
|
||||||
setColor("#0082c9");
|
|
||||||
setDescription("");
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
|
||||||
<DialogContent className='sm:max-w-md'>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Créer un nouveau calendrier</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className='space-y-4 py-2'>
|
|
||||||
<div className='space-y-2'>
|
|
||||||
<Label htmlFor='calendar-name'>Nom</Label>
|
|
||||||
<Input
|
|
||||||
id='calendar-name'
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder='Nom du calendrier'
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='space-y-2'>
|
|
||||||
<Label htmlFor='calendar-color'>Couleur</Label>
|
|
||||||
<div className='flex items-center gap-4'>
|
|
||||||
<Input
|
|
||||||
id='calendar-color'
|
|
||||||
type='color'
|
|
||||||
value={color}
|
|
||||||
onChange={(e) => setColor(e.target.value)}
|
|
||||||
className='w-12 h-12 p-1 cursor-pointer'
|
|
||||||
/>
|
|
||||||
<span className='text-sm font-medium'>{color}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='space-y-2'>
|
|
||||||
<Label htmlFor='calendar-description'>
|
|
||||||
Description (optionnelle)
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id='calendar-description'
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder='Description du calendrier'
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className='mt-4'>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='outline'
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</Button>
|
|
||||||
<Button type='submit' disabled={!name || isSubmitting}>
|
|
||||||
{isSubmitting ? "Création..." : "Créer"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,261 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { format, parseISO } from "date-fns";
|
|
||||||
import { fr } from "date-fns/locale";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Calendar as CalendarType } from "@prisma/client";
|
|
||||||
|
|
||||||
interface EventDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
event?: any;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (eventData: any) => Promise<void>;
|
|
||||||
onDelete?: (eventId: string) => Promise<void>;
|
|
||||||
calendars: CalendarType[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EventDialog({
|
|
||||||
open,
|
|
||||||
event,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
onDelete,
|
|
||||||
calendars,
|
|
||||||
}: EventDialogProps) {
|
|
||||||
const [title, setTitle] = useState(event?.title || "");
|
|
||||||
const [description, setDescription] = useState(event?.description || "");
|
|
||||||
const [location, setLocation] = useState(event?.location || "");
|
|
||||||
const [start, setStart] = useState(event?.start || "");
|
|
||||||
const [end, setEnd] = useState(event?.end || "");
|
|
||||||
const [allDay, setAllDay] = useState(event?.allDay || false);
|
|
||||||
const [calendarId, setCalendarId] = useState(event?.calendarId || "");
|
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
||||||
|
|
||||||
// Formater les dates pour l'affichage
|
|
||||||
const formatDate = (dateStr: string) => {
|
|
||||||
if (!dateStr) return "";
|
|
||||||
try {
|
|
||||||
// @ts-ignore
|
|
||||||
const date = parseISO(dateStr);
|
|
||||||
// @ts-ignore
|
|
||||||
return format(date, allDay ? "yyyy-MM-dd" : "yyyy-MM-dd'T'HH:mm", {
|
|
||||||
// @ts-ignore
|
|
||||||
locale: fr,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
return dateStr;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Gérer le changement de l'option "Toute la journée"
|
|
||||||
const handleAllDayChange = (checked: boolean) => {
|
|
||||||
setAllDay(checked);
|
|
||||||
|
|
||||||
// Ajuster les dates si nécessaire
|
|
||||||
if (checked && start) {
|
|
||||||
// @ts-ignore
|
|
||||||
const startDate = parseISO(start);
|
|
||||||
// @ts-ignore
|
|
||||||
setStart(format(startDate, "yyyy-MM-dd"));
|
|
||||||
|
|
||||||
if (end) {
|
|
||||||
// @ts-ignore
|
|
||||||
const endDate = parseISO(end);
|
|
||||||
// @ts-ignore
|
|
||||||
setEnd(format(endDate, "yyyy-MM-dd"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Enregistrer l'événement
|
|
||||||
const handleSave = () => {
|
|
||||||
onSave({
|
|
||||||
id: event?.id,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
location,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
calendarId,
|
|
||||||
isAllDay: allDay,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Supprimer l'événement
|
|
||||||
const handleDelete = () => {
|
|
||||||
if (onDelete && event?.id) {
|
|
||||||
onDelete(event.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
|
||||||
<DialogContent className='sm:max-w-[550px]'>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{event?.id ? "Modifier l'événement" : "Nouvel événement"}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className='grid gap-4 py-4'>
|
|
||||||
<div className='grid gap-2'>
|
|
||||||
<Label htmlFor='title'>Titre *</Label>
|
|
||||||
<Input
|
|
||||||
id='title'
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder='Ajouter un titre'
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sélection du calendrier */}
|
|
||||||
<div className='grid gap-2'>
|
|
||||||
<Label htmlFor='calendar'>Calendrier *</Label>
|
|
||||||
<Select value={calendarId} onValueChange={setCalendarId} required>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder='Sélectionner un calendrier' />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{calendars.map((calendar) => (
|
|
||||||
<SelectItem key={calendar.id} value={calendar.id}>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<div
|
|
||||||
className='w-3 h-3 rounded-full'
|
|
||||||
style={{ backgroundColor: calendar.color }}
|
|
||||||
/>
|
|
||||||
<span>{calendar.name}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='grid grid-cols-2 gap-4'>
|
|
||||||
<div className='grid gap-2'>
|
|
||||||
<Label htmlFor='start-date'>Début *</Label>
|
|
||||||
<Input
|
|
||||||
type={allDay ? "date" : "datetime-local"}
|
|
||||||
id='start-date'
|
|
||||||
value={formatDate(start)}
|
|
||||||
onChange={(e) => setStart(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='grid gap-2'>
|
|
||||||
<Label htmlFor='end-date'>Fin *</Label>
|
|
||||||
<Input
|
|
||||||
type={allDay ? "date" : "datetime-local"}
|
|
||||||
id='end-date'
|
|
||||||
value={formatDate(end)}
|
|
||||||
onChange={(e) => setEnd(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Checkbox
|
|
||||||
id='all-day'
|
|
||||||
checked={allDay}
|
|
||||||
onCheckedChange={handleAllDayChange}
|
|
||||||
/>
|
|
||||||
<Label htmlFor='all-day'>Toute la journée</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='grid gap-2'>
|
|
||||||
<Label htmlFor='location'>Lieu (optionnel)</Label>
|
|
||||||
<Input
|
|
||||||
id='location'
|
|
||||||
value={location}
|
|
||||||
onChange={(e) => setLocation(e.target.value)}
|
|
||||||
placeholder='Ajouter un lieu'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='grid gap-2'>
|
|
||||||
<Label htmlFor='description'>Description (optionnel)</Label>
|
|
||||||
<Textarea
|
|
||||||
id='description'
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder='Ajouter une description'
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
{event?.id && onDelete && (
|
|
||||||
<Button
|
|
||||||
variant='destructive'
|
|
||||||
onClick={() => setConfirmDelete(true)}
|
|
||||||
type='button'
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant='outline' onClick={onClose} type='button'>
|
|
||||||
Annuler
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!title || !start || !end || !calendarId}
|
|
||||||
type='button'
|
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Supprimer l'événement</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Êtes-vous sûr de vouloir supprimer cet événement ? Cette action
|
|
||||||
est irréversible.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={handleDelete}>
|
|
||||||
Supprimer
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,192 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { RefreshCw, Mail } from "lucide-react";
|
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { formatDistance } from 'date-fns/formatDistance';
|
|
||||||
import { fr } from 'date-fns/locale/fr';
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
interface Email {
|
|
||||||
id: string;
|
|
||||||
subject: string;
|
|
||||||
from: string;
|
|
||||||
fromName?: string;
|
|
||||||
date: string;
|
|
||||||
read: boolean;
|
|
||||||
starred: boolean;
|
|
||||||
folder: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EmailResponse {
|
|
||||||
emails: Email[];
|
|
||||||
mailUrl: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Email() {
|
|
||||||
const [emails, setEmails] = useState<Email[]>([]);
|
|
||||||
const [mailUrl, setMailUrl] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const { data: session, status } = useSession();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const fetchEmails = async (isRefresh = false) => {
|
|
||||||
if (status !== 'authenticated') {
|
|
||||||
setError('Please sign in to view emails');
|
|
||||||
setLoading(false);
|
|
||||||
setRefreshing(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRefresh) setRefreshing(true);
|
|
||||||
if (!isRefresh) setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/mail');
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to fetch emails');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.error) {
|
|
||||||
throw new Error(data.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const validatedEmails = data.emails.map((email: any) => ({
|
|
||||||
id: email.id || Date.now().toString(),
|
|
||||||
subject: email.subject || '(No subject)',
|
|
||||||
from: email.from || '',
|
|
||||||
fromName: email.fromName || email.from?.split('@')[0] || 'Unknown',
|
|
||||||
date: email.date || new Date().toISOString(),
|
|
||||||
read: !!email.read,
|
|
||||||
starred: !!email.starred,
|
|
||||||
folder: email.folder || 'INBOX'
|
|
||||||
}));
|
|
||||||
|
|
||||||
setEmails(validatedEmails);
|
|
||||||
setMailUrl(data.mailUrl || 'https://espace.slm-lab.net/apps/courrier/');
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Error fetching emails');
|
|
||||||
setEmails([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initial fetch
|
|
||||||
useEffect(() => {
|
|
||||||
if (status === 'authenticated') {
|
|
||||||
fetchEmails();
|
|
||||||
} else if (status === 'unauthenticated') {
|
|
||||||
setError('Please sign in to view emails');
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
// Auto-refresh every 5 minutes
|
|
||||||
useEffect(() => {
|
|
||||||
if (status !== 'authenticated') return;
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
fetchEmails(true);
|
|
||||||
}, 5 * 60 * 1000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
try {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return formatDistance(date, new Date(), {
|
|
||||||
addSuffix: true,
|
|
||||||
locale: fr
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
return dateString;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (status === 'loading' || loading) {
|
|
||||||
return (
|
|
||||||
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg h-full">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
|
|
||||||
<CardTitle className="text-lg font-semibold text-gray-800">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Mail className="h-5 w-5 text-gray-600" />
|
|
||||||
<span>Courrier</span>
|
|
||||||
</div>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<RefreshCw className="h-5 w-5 animate-spin text-gray-400" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="transition-transform duration-500 ease-in-out transform hover:scale-105 bg-white/95 backdrop-blur-sm border-0 shadow-lg h-full">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-x-4 border-b border-gray-100">
|
|
||||||
<CardTitle className="text-lg font-semibold text-gray-800">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Mail className="h-5 w-5 text-gray-600" />
|
|
||||||
<span>Courrier</span>
|
|
||||||
</div>
|
|
||||||
</CardTitle>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => fetchEmails(true)}
|
|
||||||
disabled={refreshing}
|
|
||||||
className={`${refreshing ? 'animate-spin' : ''} text-gray-600 hover:text-gray-900`}
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-3">
|
|
||||||
{error ? (
|
|
||||||
<p className="text-center text-red-500">{error}</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2 max-h-[220px] overflow-y-auto">
|
|
||||||
{emails.length === 0 ? (
|
|
||||||
<p className="text-center text-gray-500">
|
|
||||||
{loading ? 'Loading emails...' : 'No unread emails'}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
emails.map((email) => (
|
|
||||||
<div
|
|
||||||
key={email.id}
|
|
||||||
className="p-2 hover:bg-gray-50/50 rounded-lg transition-colors cursor-pointer"
|
|
||||||
onClick={() => router.push('/mail')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<span className="text-sm text-gray-600 truncate max-w-[60%]" title={email.fromName || email.from}>
|
|
||||||
{email.fromName || email.from}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{!email.read && <span className="w-1.5 h-1.5 bg-blue-600 rounded-full"></span>}
|
|
||||||
<span className="text-xs text-gray-500">{formatDate(email.date)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-800 truncate">{email.subject}</p>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { RefreshCw, MessageSquare } from "lucide-react";
|
|
||||||
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2 border-b border-gray-100">
|
|
||||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
|
||||||
<MessageSquare className="h-5 w-5 text-gray-600" />
|
|
||||||
Emails non lus
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
import { Mail } from "@/types/mail";
|
|
||||||
import { Star, StarOff, Paperclip } from "lucide-react";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
|
|
||||||
interface MailListProps {
|
|
||||||
mails: Mail[];
|
|
||||||
onMailClick: (mail: Mail) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MailList({ mails, onMailClick }: MailListProps) {
|
|
||||||
if (!mails || mails.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<p className="text-gray-500">No emails found</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
{mails.map((mail) => (
|
|
||||||
<div
|
|
||||||
key={mail.id}
|
|
||||||
className={`p-4 border-b cursor-pointer hover:bg-muted ${
|
|
||||||
!mail.read ? "bg-muted/50" : ""
|
|
||||||
}`}
|
|
||||||
onClick={() => onMailClick(mail)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{mail.starred ? (
|
|
||||||
<Star className="h-4 w-4 text-yellow-400" />
|
|
||||||
) : (
|
|
||||||
<StarOff className="h-4 w-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<span className="font-medium">{mail.from}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{format(new Date(mail.date), "MMM d, yyyy")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2">
|
|
||||||
<h3 className="font-medium">{mail.subject}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
|
||||||
{mail.body}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{mail.attachments && mail.attachments.length > 0 && (
|
|
||||||
<div className="mt-2 flex items-center text-sm text-muted-foreground">
|
|
||||||
<Paperclip className="h-3 w-3 mr-1" />
|
|
||||||
{mail.attachments.length} attachment
|
|
||||||
{mail.attachments.length > 1 ? "s" : ""}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { RefreshCw, Plus, Search } from "lucide-react";
|
|
||||||
|
|
||||||
interface MailToolbarProps {
|
|
||||||
onRefresh: () => void;
|
|
||||||
onCompose: () => void;
|
|
||||||
onSearch: (query: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MailToolbar({ onRefresh, onCompose, onSearch }: MailToolbarProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between p-4 border-b">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button variant="ghost" size="icon" onClick={onRefresh}>
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onCompose}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Compose
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 max-w-md mx-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search emails..."
|
|
||||||
className="pl-8"
|
|
||||||
onChange={(e) => onSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* Simple MIME decoder for compose message box
|
|
||||||
* Handles basic email content without creating nested structures
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function decodeComposeContent(content: string): string {
|
|
||||||
if (!content) return '';
|
|
||||||
|
|
||||||
// Basic HTML cleaning without creating nested structures
|
|
||||||
let cleaned = content
|
|
||||||
// Remove script and style tags
|
|
||||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
||||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
||||||
// Remove meta tags
|
|
||||||
.replace(/<meta[^>]*>/gi, '')
|
|
||||||
// Remove head and title
|
|
||||||
.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '')
|
|
||||||
.replace(/<title[^>]*>[\s\S]*?<\/title>/gi, '')
|
|
||||||
// Remove body tags
|
|
||||||
.replace(/<body[^>]*>/gi, '')
|
|
||||||
.replace(/<\/body>/gi, '')
|
|
||||||
// Remove html tags
|
|
||||||
.replace(/<html[^>]*>/gi, '')
|
|
||||||
.replace(/<\/html>/gi, '')
|
|
||||||
// Handle basic formatting
|
|
||||||
.replace(/<br\s*\/?>/gi, '\n')
|
|
||||||
.replace(/<p[^>]*>/gi, '\n')
|
|
||||||
.replace(/<\/p>/gi, '\n')
|
|
||||||
// Handle lists
|
|
||||||
.replace(/<ul[^>]*>/gi, '\n')
|
|
||||||
.replace(/<\/ul>/gi, '\n')
|
|
||||||
.replace(/<ol[^>]*>/gi, '\n')
|
|
||||||
.replace(/<\/ol>/gi, '\n')
|
|
||||||
.replace(/<li[^>]*>/gi, '• ')
|
|
||||||
.replace(/<\/li>/gi, '\n')
|
|
||||||
// Handle basic text formatting
|
|
||||||
.replace(/<strong[^>]*>/gi, '**')
|
|
||||||
.replace(/<\/strong>/gi, '**')
|
|
||||||
.replace(/<b[^>]*>/gi, '**')
|
|
||||||
.replace(/<\/b>/gi, '**')
|
|
||||||
.replace(/<em[^>]*>/gi, '*')
|
|
||||||
.replace(/<\/em>/gi, '*')
|
|
||||||
.replace(/<i[^>]*>/gi, '*')
|
|
||||||
.replace(/<\/i>/gi, '*')
|
|
||||||
// Handle links
|
|
||||||
.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '$2 ($1)')
|
|
||||||
// Handle basic entities
|
|
||||||
.replace(/ /g, ' ')
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, "'")
|
|
||||||
// Clean up whitespace
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
// Do NOT wrap in additional divs
|
|
||||||
return cleaned;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encodeComposeContent(content: string): string {
|
|
||||||
if (!content) return '';
|
|
||||||
|
|
||||||
// Basic HTML encoding without adding structure
|
|
||||||
const encoded = content
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
.replace(/\n/g, '<br>');
|
|
||||||
|
|
||||||
return encoded;
|
|
||||||
}
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
interface EmailHeaders {
|
|
||||||
from: string;
|
|
||||||
subject: string;
|
|
||||||
date: string;
|
|
||||||
to?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseEmailHeaders(headerContent: string): EmailHeaders {
|
|
||||||
const headers: { [key: string]: string } = {};
|
|
||||||
let currentHeader = '';
|
|
||||||
let currentValue = '';
|
|
||||||
|
|
||||||
// Split the header content into lines
|
|
||||||
const lines = headerContent.split(/\r?\n/);
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
|
|
||||||
// If line starts with whitespace, it's a continuation of the previous header
|
|
||||||
if (/^\s+/.test(line)) {
|
|
||||||
currentValue += ' ' + line.trim();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have a current header being processed, save it
|
|
||||||
if (currentHeader && currentValue) {
|
|
||||||
headers[currentHeader.toLowerCase()] = currentValue.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start processing new header
|
|
||||||
const match = line.match(/^([^:]+):\s*(.*)$/);
|
|
||||||
if (match) {
|
|
||||||
currentHeader = match[1];
|
|
||||||
currentValue = match[2];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the last header
|
|
||||||
if (currentHeader && currentValue) {
|
|
||||||
headers[currentHeader.toLowerCase()] = currentValue.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
from: headers['from'] || '',
|
|
||||||
subject: headers['subject'] || '',
|
|
||||||
date: headers['date'] || new Date().toISOString(),
|
|
||||||
to: headers['to']
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeEmailBody(content: string, contentType: string): string {
|
|
||||||
try {
|
|
||||||
// Remove email client-specific markers
|
|
||||||
content = content.replace(/\r\n/g, '\n')
|
|
||||||
.replace(/=\n/g, '')
|
|
||||||
.replace(/=3D/g, '=')
|
|
||||||
.replace(/=09/g, '\t');
|
|
||||||
|
|
||||||
// If it's HTML content
|
|
||||||
if (contentType.includes('text/html')) {
|
|
||||||
return extractTextFromHtml(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
return content;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error decoding email body:', error);
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTextFromHtml(html: string): string {
|
|
||||||
// Remove scripts and style tags
|
|
||||||
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
||||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
|
|
||||||
|
|
||||||
// Convert <br> and <p> to newlines
|
|
||||||
html = html.replace(/<br[^>]*>/gi, '\n')
|
|
||||||
.replace(/<p[^>]*>/gi, '\n')
|
|
||||||
.replace(/<\/p>/gi, '\n');
|
|
||||||
|
|
||||||
// Remove all other HTML tags
|
|
||||||
html = html.replace(/<[^>]+>/g, '');
|
|
||||||
|
|
||||||
// Decode HTML entities
|
|
||||||
html = html.replace(/ /g, ' ')
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
|
|
||||||
// Clean up whitespace
|
|
||||||
return html.replace(/\n\s*\n/g, '\n\n').trim();
|
|
||||||
}
|
|
||||||
68
lib/imap.ts
68
lib/imap.ts
@ -1,68 +0,0 @@
|
|||||||
import { ImapFlow } from 'imapflow';
|
|
||||||
import { getServerSession } from 'next-auth';
|
|
||||||
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
|
||||||
import { prisma } from '@/lib/prisma';
|
|
||||||
|
|
||||||
export async function getImapClient() {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
throw new Error('No authenticated user');
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentials = await prisma.mailCredentials.findUnique({
|
|
||||||
where: {
|
|
||||||
userId: session.user.id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!credentials) {
|
|
||||||
throw new Error('No mail credentials found. Please configure your email account.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new ImapFlow({
|
|
||||||
host: credentials.host,
|
|
||||||
port: credentials.port,
|
|
||||||
secure: true,
|
|
||||||
auth: {
|
|
||||||
user: credentials.email,
|
|
||||||
pass: credentials.password,
|
|
||||||
},
|
|
||||||
logger: false,
|
|
||||||
tls: {
|
|
||||||
rejectUnauthorized: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.connect();
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function moveEmails(emailIds: number[], targetFolder: string) {
|
|
||||||
const imap = await getImapClient();
|
|
||||||
const lock = await imap.getMailboxLock('INBOX');
|
|
||||||
try {
|
|
||||||
for (const id of emailIds) {
|
|
||||||
const message = await imap.fetchOne(id.toString(), { uid: true });
|
|
||||||
if (message) {
|
|
||||||
await imap.messageMove(message.uid, targetFolder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
lock.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markAsRead(emailIds: number[], isRead: boolean) {
|
|
||||||
const imap = await getImapClient();
|
|
||||||
const lock = await imap.getMailboxLock('INBOX');
|
|
||||||
try {
|
|
||||||
for (const id of emailIds) {
|
|
||||||
const message = await imap.fetchOne(id.toString(), { uid: true });
|
|
||||||
if (message) {
|
|
||||||
await imap.messageFlagsAdd(message.uid, isRead ? ['\\Seen'] : [], { uid: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
lock.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,221 +0,0 @@
|
|||||||
// Infomaniak-specific MIME decoder functions
|
|
||||||
|
|
||||||
export function decodeQuotedPrintable(text: string, charset: string): string {
|
|
||||||
if (!text) return '';
|
|
||||||
|
|
||||||
// Replace soft line breaks (=\r\n or =\n or =\r)
|
|
||||||
let decoded = text.replace(/=(?:\r\n|\n|\r)/g, '');
|
|
||||||
|
|
||||||
// Replace quoted-printable encoded characters
|
|
||||||
decoded = decoded
|
|
||||||
// Handle common encoded characters
|
|
||||||
.replace(/=3D/g, '=')
|
|
||||||
.replace(/=20/g, ' ')
|
|
||||||
.replace(/=09/g, '\t')
|
|
||||||
.replace(/=0A/g, '\n')
|
|
||||||
.replace(/=0D/g, '\r')
|
|
||||||
// Handle other quoted-printable encoded characters
|
|
||||||
.replace(/=([0-9A-F]{2})/gi, (match, p1) => {
|
|
||||||
return String.fromCharCode(parseInt(p1, 16));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle character encoding
|
|
||||||
try {
|
|
||||||
if (typeof TextDecoder !== 'undefined') {
|
|
||||||
const bytes = new Uint8Array(Array.from(decoded).map(c => c.charCodeAt(0)));
|
|
||||||
return new TextDecoder(charset).decode(bytes);
|
|
||||||
}
|
|
||||||
return decoded;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Charset conversion error:', e);
|
|
||||||
return decoded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeBase64(text: string, charset: string): string {
|
|
||||||
if (!text) return '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Remove any whitespace and line breaks
|
|
||||||
const cleanText = text.replace(/\s+/g, '');
|
|
||||||
|
|
||||||
// Decode base64
|
|
||||||
const binary = atob(cleanText);
|
|
||||||
|
|
||||||
// Convert to bytes
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let i = 0; i < binary.length; i++) {
|
|
||||||
bytes[i] = binary.charCodeAt(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode using specified charset
|
|
||||||
if (typeof TextDecoder !== 'undefined') {
|
|
||||||
return new TextDecoder(charset).decode(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback
|
|
||||||
return binary;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Base64 decoding error:', e);
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function convertCharset(text: string, charset: string): string {
|
|
||||||
if (!text) return '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (typeof TextDecoder !== 'undefined') {
|
|
||||||
// Handle common charset aliases
|
|
||||||
const normalizedCharset = charset.toLowerCase()
|
|
||||||
.replace(/^iso-8859-1$/, 'windows-1252')
|
|
||||||
.replace(/^iso-8859-15$/, 'windows-1252')
|
|
||||||
.replace(/^utf-8$/, 'utf-8')
|
|
||||||
.replace(/^us-ascii$/, 'utf-8');
|
|
||||||
|
|
||||||
const bytes = new Uint8Array(Array.from(text).map(c => c.charCodeAt(0)));
|
|
||||||
return new TextDecoder(normalizedCharset).decode(bytes);
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Charset conversion error:', e);
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cleanHtml(html: string): string {
|
|
||||||
if (!html) return '';
|
|
||||||
|
|
||||||
// Detect text direction from the content
|
|
||||||
const hasRtlChars = /[\u0591-\u07FF\u200F\u202B\u202E\uFB1D-\uFDFD\uFE70-\uFEFC]/.test(html);
|
|
||||||
const defaultDir = hasRtlChars ? 'rtl' : 'ltr';
|
|
||||||
|
|
||||||
// Remove or fix malformed URLs
|
|
||||||
html = html.replace(/=3D"(http[^"]+)"/g, (match, url) => {
|
|
||||||
try {
|
|
||||||
return `"${decodeURIComponent(url)}"`;
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove any remaining quoted-printable artifacts
|
|
||||||
html = html.replace(/=([0-9A-F]{2})/gi, (match, p1) => {
|
|
||||||
return String.fromCharCode(parseInt(p1, 16));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up any remaining HTML issues while preserving direction
|
|
||||||
html = html
|
|
||||||
// Remove style and script tags
|
|
||||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
||||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
||||||
.replace(/<meta[^>]*>/gi, '')
|
|
||||||
.replace(/<link[^>]*>/gi, '')
|
|
||||||
.replace(/<base[^>]*>/gi, '')
|
|
||||||
.replace(/<title[^>]*>[\s\S]*?<\/title>/gi, '')
|
|
||||||
.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '')
|
|
||||||
// Preserve body attributes
|
|
||||||
.replace(/<body[^>]*>/gi, (match) => {
|
|
||||||
const dir = match.match(/dir=["'](rtl|ltr)["']/i)?.[1] || defaultDir;
|
|
||||||
return `<body dir="${dir}">`;
|
|
||||||
})
|
|
||||||
.replace(/<\/body>/gi, '')
|
|
||||||
.replace(/<html[^>]*>/gi, '')
|
|
||||||
.replace(/<\/html>/gi, '')
|
|
||||||
// Handle tables
|
|
||||||
.replace(/<table[^>]*>/gi, '\n')
|
|
||||||
.replace(/<\/table>/gi, '\n')
|
|
||||||
.replace(/<tr[^>]*>/gi, '\n')
|
|
||||||
.replace(/<\/tr>/gi, '\n')
|
|
||||||
.replace(/<td[^>]*>/gi, ' ')
|
|
||||||
.replace(/<\/td>/gi, ' ')
|
|
||||||
.replace(/<th[^>]*>/gi, ' ')
|
|
||||||
.replace(/<\/th>/gi, ' ')
|
|
||||||
// Handle lists
|
|
||||||
.replace(/<ul[^>]*>/gi, '\n')
|
|
||||||
.replace(/<\/ul>/gi, '\n')
|
|
||||||
.replace(/<ol[^>]*>/gi, '\n')
|
|
||||||
.replace(/<\/ol>/gi, '\n')
|
|
||||||
.replace(/<li[^>]*>/gi, '• ')
|
|
||||||
.replace(/<\/li>/gi, '\n')
|
|
||||||
// Handle other block elements
|
|
||||||
.replace(/<div[^>]*>/gi, '\n')
|
|
||||||
.replace(/<\/div>/gi, '\n')
|
|
||||||
.replace(/<p[^>]*>/gi, '\n')
|
|
||||||
.replace(/<\/p>/gi, '\n')
|
|
||||||
.replace(/<br[^>]*>/gi, '\n')
|
|
||||||
.replace(/<hr[^>]*>/gi, '\n')
|
|
||||||
// Handle inline elements
|
|
||||||
.replace(/<span[^>]*>/gi, '')
|
|
||||||
.replace(/<\/span>/gi, '')
|
|
||||||
.replace(/<a[^>]*>/gi, '')
|
|
||||||
.replace(/<\/a>/gi, '')
|
|
||||||
.replace(/<strong[^>]*>/gi, '**')
|
|
||||||
.replace(/<\/strong>/gi, '**')
|
|
||||||
.replace(/<b[^>]*>/gi, '**')
|
|
||||||
.replace(/<\/b>/gi, '**')
|
|
||||||
.replace(/<em[^>]*>/gi, '*')
|
|
||||||
.replace(/<\/em>/gi, '*')
|
|
||||||
.replace(/<i[^>]*>/gi, '*')
|
|
||||||
.replace(/<\/i>/gi, '*')
|
|
||||||
// Handle special characters
|
|
||||||
.replace(/ /g, ' ')
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, "'")
|
|
||||||
// Clean up whitespace
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
// Wrap in a div with the detected direction
|
|
||||||
return `<div dir="${defaultDir}">${html}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseEmailHeaders(headers: string): { contentType: string; encoding: string; charset: string } {
|
|
||||||
const result = {
|
|
||||||
contentType: 'text/plain',
|
|
||||||
encoding: '7bit',
|
|
||||||
charset: 'utf-8'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract content type and charset
|
|
||||||
const contentTypeMatch = headers.match(/Content-Type:\s*([^;]+)(?:;\s*charset=([^;"\r\n]+)|(?:;\s*charset="([^"]+)"))?/i);
|
|
||||||
if (contentTypeMatch) {
|
|
||||||
result.contentType = contentTypeMatch[1].trim().toLowerCase();
|
|
||||||
if (contentTypeMatch[2]) {
|
|
||||||
result.charset = contentTypeMatch[2].trim().toLowerCase();
|
|
||||||
} else if (contentTypeMatch[3]) {
|
|
||||||
result.charset = contentTypeMatch[3].trim().toLowerCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract content transfer encoding
|
|
||||||
const encodingMatch = headers.match(/Content-Transfer-Encoding:\s*([^\s;\r\n]+)/i);
|
|
||||||
if (encodingMatch) {
|
|
||||||
result.encoding = encodingMatch[1].trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractBoundary(headers: string): string | null {
|
|
||||||
const boundaryMatch = headers.match(/boundary="?([^"\r\n;]+)"?/i) ||
|
|
||||||
headers.match(/boundary=([^\r\n;]+)/i);
|
|
||||||
|
|
||||||
return boundaryMatch ? boundaryMatch[1].trim() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractFilename(headers: string): string {
|
|
||||||
const filenameMatch = headers.match(/filename="?([^"\r\n;]+)"?/i) ||
|
|
||||||
headers.match(/name="?([^"\r\n;]+)"?/i);
|
|
||||||
|
|
||||||
return filenameMatch ? filenameMatch[1] : 'attachment';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractHeader(headers: string, headerName: string): string {
|
|
||||||
const regex = new RegExp(`^${headerName}:\\s*(.*)$`, 'im');
|
|
||||||
const match = headers.match(regex);
|
|
||||||
return match ? match[1].trim() : '';
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user