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