|
import { |
|
Dialog, |
|
DialogContent, |
|
DialogHeader, |
|
DialogTitle, |
|
DialogDescription, |
|
} from "@/components/ui/dialog"; |
|
import { CalendarDays, Globe, Tag, Clock, AlarmClock, CalendarPlus } from "lucide-react"; |
|
import { Conference } from "@/types/conference"; |
|
import { formatDistanceToNow, parseISO, isValid, format, parse, addDays } from "date-fns"; |
|
import { Button } from "@/components/ui/button"; |
|
import { |
|
DropdownMenu, |
|
DropdownMenuContent, |
|
DropdownMenuItem, |
|
DropdownMenuTrigger, |
|
} from "@/components/ui/dropdown-menu"; |
|
import { useState, useEffect } from "react"; |
|
|
|
interface ConferenceDialogProps { |
|
conference: Conference; |
|
open: boolean; |
|
onOpenChange: (open: boolean) => void; |
|
} |
|
|
|
const ConferenceDialog = ({ conference, open, onOpenChange }: ConferenceDialogProps) => { |
|
const deadlineDate = conference.deadline && conference.deadline !== 'TBD' ? parseISO(conference.deadline) : null; |
|
const [countdown, setCountdown] = useState<string>(''); |
|
|
|
useEffect(() => { |
|
const calculateTimeLeft = () => { |
|
if (!deadlineDate || !isValid(deadlineDate)) { |
|
setCountdown('TBD'); |
|
return; |
|
} |
|
|
|
const now = new Date().getTime(); |
|
const difference = deadlineDate.getTime() - now; |
|
|
|
if (difference <= 0) { |
|
setCountdown('Deadline passed'); |
|
return; |
|
} |
|
|
|
const days = Math.floor(difference / (1000 * 60 * 60 * 24)); |
|
const hours = Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); |
|
const minutes = Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60)); |
|
const seconds = Math.floor((difference % (1000 * 60)) / 1000); |
|
|
|
setCountdown(`${days}d ${hours}h ${minutes}m ${seconds}s`); |
|
}; |
|
|
|
|
|
calculateTimeLeft(); |
|
|
|
|
|
const timer = setInterval(calculateTimeLeft, 1000); |
|
|
|
|
|
return () => clearInterval(timer); |
|
}, [deadlineDate]); |
|
|
|
const getCountdownColor = () => { |
|
if (!deadlineDate || !isValid(deadlineDate)) return "text-neutral-600"; |
|
const daysRemaining = Math.ceil((deadlineDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)); |
|
if (daysRemaining <= 7) return "text-red-600"; |
|
if (daysRemaining <= 30) return "text-orange-600"; |
|
return "text-green-600"; |
|
}; |
|
|
|
const parseDateFromString = (dateStr: string) => { |
|
try { |
|
|
|
const [monthDay, year] = dateStr.split(", "); |
|
const [month, dayRange] = monthDay.split(" "); |
|
const [startDay] = dayRange.split("-"); |
|
|
|
|
|
const dateString = `${month} ${startDay} ${year}`; |
|
const date = parse(dateString, 'MMMM d yyyy', new Date()); |
|
|
|
if (!isValid(date)) { |
|
|
|
return parse(dateString, 'MMM d yyyy', new Date()); |
|
} |
|
|
|
return date; |
|
} catch (error) { |
|
console.error("Error parsing date:", error); |
|
return new Date(); |
|
} |
|
}; |
|
|
|
const createCalendarEvent = (type: 'google' | 'apple') => { |
|
try { |
|
if (!conference.deadline || conference.deadline === 'TBD') { |
|
throw new Error('No valid deadline found'); |
|
} |
|
|
|
|
|
const deadlineDate = parseISO(conference.deadline); |
|
if (!isValid(deadlineDate)) { |
|
throw new Error('Invalid deadline date'); |
|
} |
|
|
|
|
|
const endDate = new Date(deadlineDate.getTime() + (60 * 60 * 1000)); |
|
|
|
const formatDateForGoogle = (date: Date) => format(date, "yyyyMMdd'T'HHmmss'Z'"); |
|
const formatDateForApple = (date: Date) => format(date, "yyyyMMdd'T'HHmmss'Z'"); |
|
|
|
const title = encodeURIComponent(`${conference.title} deadline`); |
|
const location = encodeURIComponent(conference.place); |
|
const description = encodeURIComponent( |
|
`Paper Submission Deadline for ${conference.full_name || conference.title}\n` + |
|
(conference.abstract_deadline ? `Abstract Deadline: ${conference.abstract_deadline}\n` : '') + |
|
`Dates: ${conference.date}\n` + |
|
`Location: ${conference.place}\n` + |
|
(conference.link ? `Website: ${conference.link}` : '') |
|
); |
|
|
|
if (type === 'google') { |
|
const url = `https://calendar.google.com/calendar/render?action=TEMPLATE` + |
|
`&text=${title}` + |
|
`&dates=${formatDateForGoogle(deadlineDate)}/${formatDateForGoogle(endDate)}` + |
|
`&details=${description}` + |
|
`&location=${location}` + |
|
`&sprop=website:${encodeURIComponent(conference.link || '')}`; |
|
window.open(url, '_blank'); |
|
} else { |
|
const url = `data:text/calendar;charset=utf8,BEGIN:VCALENDAR |
|
VERSION:2.0 |
|
BEGIN:VEVENT |
|
URL:${conference.link || ''} |
|
DTSTART:${formatDateForApple(deadlineDate)} |
|
DTEND:${formatDateForApple(endDate)} |
|
SUMMARY:${title} |
|
DESCRIPTION:${description} |
|
LOCATION:${location} |
|
END:VEVENT |
|
END:VCALENDAR`; |
|
|
|
const link = document.createElement('a'); |
|
link.href = url; |
|
link.download = `${conference.title.toLowerCase().replace(/\s+/g, '-')}-deadline.ics`; |
|
document.body.appendChild(link); |
|
link.click(); |
|
document.body.removeChild(link); |
|
} |
|
} catch (error) { |
|
console.error("Error creating calendar event:", error); |
|
alert("Sorry, there was an error creating the calendar event. Please try again."); |
|
} |
|
}; |
|
|
|
const generateGoogleMapsUrl = (venue: string | undefined, place: string): string => { |
|
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(venue || place)}`; |
|
}; |
|
|
|
return ( |
|
<Dialog open={open} onOpenChange={onOpenChange}> |
|
<DialogContent className="max-w-xl"> |
|
<DialogHeader> |
|
<DialogTitle>{conference.title} {conference.year}</DialogTitle> |
|
<DialogDescription> |
|
{conference.full_name} |
|
</DialogDescription> |
|
</DialogHeader> |
|
|
|
<div className="space-y-4"> |
|
<div className="flex items-start gap-2"> |
|
<CalendarDays className="h-5 w-5 mt-0.5 text-gray-500" /> |
|
<div> |
|
<p className="font-medium">Dates</p> |
|
<p className="text-sm text-gray-500">{conference.date}</p> |
|
</div> |
|
</div> |
|
|
|
<div className="flex items-start gap-2"> |
|
<Clock className="h-5 w-5 mt-0.5 text-gray-500" /> |
|
<div className="space-y-2 flex-1"> |
|
<p className="font-medium">Important Deadlines</p> |
|
<div className="text-sm text-gray-500 space-y-2"> |
|
{conference.abstract_deadline && ( |
|
<div className="bg-gray-100 rounded-md p-2"> |
|
<p>Abstract: {parseISO(conference.abstract_deadline) && isValid(parseISO(conference.abstract_deadline)) |
|
? format(parseISO(conference.abstract_deadline), "MMMM d, yyyy") |
|
: conference.abstract_deadline} |
|
</p> |
|
</div> |
|
)} |
|
<div className="bg-gray-100 rounded-md p-2"> |
|
<p>Submission: {conference.deadline && conference.deadline !== 'TBD' && isValid(parseISO(conference.deadline)) |
|
? format(parseISO(conference.deadline), "MMMM d, yyyy") |
|
: conference.deadline} |
|
</p> |
|
</div> |
|
{conference.commitment_deadline && ( |
|
<div className="bg-gray-100 rounded-md p-2"> |
|
<p>Commitment: {isValid(parseISO(conference.commitment_deadline)) |
|
? format(parseISO(conference.commitment_deadline), "MMMM d, yyyy") |
|
: conference.commitment_deadline} |
|
</p> |
|
</div> |
|
)} |
|
{conference.review_release_date && ( |
|
<div className="bg-gray-100 rounded-md p-2"> |
|
<p>Reviews Released: {isValid(parseISO(conference.review_release_date)) |
|
? format(parseISO(conference.review_release_date), "MMMM d, yyyy") |
|
: conference.review_release_date} |
|
</p> |
|
</div> |
|
)} |
|
{(conference.rebuttal_period_start || conference.rebuttal_period_end) && ( |
|
<div className="bg-gray-100 rounded-md p-2"> |
|
<p>Rebuttal Period: {conference.rebuttal_period_start && isValid(parseISO(conference.rebuttal_period_start)) |
|
? format(parseISO(conference.rebuttal_period_start), "MMMM d, yyyy") |
|
: conference.rebuttal_period_start} - {conference.rebuttal_period_end && isValid(parseISO(conference.rebuttal_period_end)) |
|
? format(parseISO(conference.rebuttal_period_end), "MMMM d, yyyy") |
|
: conference.rebuttal_period_end} |
|
</p> |
|
</div> |
|
)} |
|
{conference.final_decision_date && ( |
|
<div className="bg-gray-100 rounded-md p-2"> |
|
<p>Final Decision: {isValid(parseISO(conference.final_decision_date)) |
|
? format(parseISO(conference.final_decision_date), "MMMM d, yyyy") |
|
: conference.final_decision_date} |
|
</p> |
|
</div> |
|
)} |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<div className="flex items-start gap-2"> |
|
<Globe className="h-5 w-5 mt-0.5 text-gray-500" /> |
|
<div> |
|
<p className="font-medium">Location</p> |
|
<p className="text-sm text-gray-500">{conference.place}</p> |
|
{conference.venue && ( |
|
<p className="text-sm text-gray-500">{conference.venue}</p> |
|
)} |
|
</div> |
|
</div> |
|
|
|
<div className="flex items-center"> |
|
<AlarmClock className={`h-5 w-5 mr-3 flex-shrink-0 ${getCountdownColor()}`} /> |
|
<div> |
|
<span className={`font-medium ${getCountdownColor()}`}> |
|
{countdown} |
|
</span> |
|
{deadlineDate && isValid(deadlineDate) && ( |
|
<div className="text-sm text-neutral-500"> |
|
{format(deadlineDate, "MMMM d, yyyy 'at' HH:mm:ss")} {conference.timezone} |
|
</div> |
|
)} |
|
</div> |
|
</div> |
|
|
|
{Array.isArray(conference.tags) && conference.tags.length > 0 && ( |
|
<div className="flex flex-wrap gap-2"> |
|
{conference.tags.map((tag) => ( |
|
<span key={tag} className="tag"> |
|
<Tag className="h-3 w-3 mr-1" /> |
|
{tag} |
|
</span> |
|
))} |
|
</div> |
|
)} |
|
|
|
{conference.note && ( |
|
<div |
|
className="text-sm text-neutral-600 mt-2 p-3 bg-neutral-50 rounded-lg" |
|
dangerouslySetInnerHTML={{ __html: conference.note }} |
|
/> |
|
)} |
|
|
|
<div className="flex items-center justify-between pt-2"> |
|
{conference.link && ( |
|
<Button |
|
variant="ghost" |
|
size="sm" |
|
className="text-base text-primary hover:underline p-0" |
|
asChild |
|
> |
|
<a |
|
href={conference.link} |
|
target="_blank" |
|
rel="noopener noreferrer" |
|
> |
|
Visit website |
|
</a> |
|
</Button> |
|
)} |
|
|
|
<DropdownMenu> |
|
<DropdownMenuTrigger asChild> |
|
<Button |
|
variant="ghost" |
|
size="sm" |
|
className="text-sm focus-visible:ring-0 focus:outline-none" |
|
> |
|
<CalendarPlus className="h-4 w-4 mr-2" /> |
|
Add to Calendar |
|
</Button> |
|
</DropdownMenuTrigger> |
|
<DropdownMenuContent className="bg-white" align="end"> |
|
<DropdownMenuItem |
|
className="text-neutral-800 hover:bg-neutral-100" |
|
onClick={() => createCalendarEvent('google')} |
|
> |
|
Add to Google Calendar |
|
</DropdownMenuItem> |
|
<DropdownMenuItem |
|
className="text-neutral-800 hover:bg-neutral-100" |
|
onClick={() => createCalendarEvent('apple')} |
|
> |
|
Add to Apple Calendar |
|
</DropdownMenuItem> |
|
</DropdownMenuContent> |
|
</DropdownMenu> |
|
</div> |
|
</div> |
|
</DialogContent> |
|
</Dialog> |
|
); |
|
}; |
|
|
|
export default ConferenceDialog; |
|
|