Spaces:
Running
Running
| import { CalendarDays, Globe, Tag, Clock } from "lucide-react"; | |
| import { Conference, Deadline } from "@/types/conference"; | |
| import { formatDistanceToNow, format, isValid, isPast } from "date-fns"; | |
| import ConferenceDialog from "./ConferenceDialog"; | |
| import { useEffect, useState } from "react"; | |
| import { getDeadlineInLocalTime } from '@/utils/dateUtils'; | |
| import { getAllDeadlines, getUpcomingDeadlines } from '@/utils/deadlineUtils'; | |
| import { Checkbox } from "@/components/ui/checkbox"; | |
| import { ConferenceProgress, getProgress, setProgress } from "@/utils/progressUtils"; | |
| type ConferenceCardProps = Conference & { | |
| compact?: boolean; | |
| }; | |
| const ConferenceCard = ({ | |
| title, | |
| full_name, | |
| year, | |
| id, | |
| date, | |
| deadline, | |
| timezone, | |
| tags = [], | |
| link, | |
| note, | |
| abstract_deadline, | |
| city, | |
| country, | |
| venue, | |
| compact = false, | |
| ...conferenceProps | |
| }: ConferenceCardProps) => { | |
| const [dialogOpen, setDialogOpen] = useState(false); | |
| const confKey = `${id}:${year}`; | |
| const [progress, setProgressState] = useState<ConferenceProgress>(() => getProgress(confKey)); | |
| // Get the next upcoming deadline or primary deadline for display | |
| const conference = { | |
| title, full_name, year, id, date, deadline, timezone, tags, link, note, | |
| abstract_deadline, city, country, venue, ...conferenceProps | |
| }; | |
| useEffect(() => { | |
| setProgressState(getProgress(confKey)); | |
| }, [confKey]); | |
| const upcomingDeadlines = getUpcomingDeadlines(conference); | |
| const hasUpcomingDeadlines = upcomingDeadlines.length > 0; | |
| const allDeadlines = getAllDeadlines(conference); | |
| const isDeadlinePassed = (types: string[]) => { | |
| const matching = allDeadlines.filter((deadlineItem) => types.includes(deadlineItem.type)); | |
| if (matching.length === 0) return false; | |
| const latest = matching.reduce<Date | null>((acc, deadlineItem) => { | |
| const deadlineDate = getDeadlineInLocalTime( | |
| deadlineItem.date, | |
| deadlineItem.timezone || timezone | |
| ); | |
| if (!deadlineDate || !isValid(deadlineDate)) return acc; | |
| if (!acc || deadlineDate.getTime() > acc.getTime()) return deadlineDate; | |
| return acc; | |
| }, null); | |
| return latest ? isPast(latest) : false; | |
| }; | |
| const paperDeadlinePassed = isDeadlinePassed(["paper", "submission", "paper_submission"]); | |
| const posterDeadlinePassed = isDeadlinePassed(["poster", "poster_submission"]); | |
| const notificationDeadlinePassed = isDeadlinePassed([ | |
| "notification", | |
| "decision", | |
| "acceptance_notification", | |
| ]); | |
| const deadlineTypeToProgressKey = (deadlineItem: Deadline) => { | |
| const type = deadlineItem.type; | |
| if (["paper", "submission", "paper_submission"].includes(type)) return "paper"; | |
| if (["poster", "poster_submission"].includes(type)) return "poster"; | |
| if (["notification", "decision", "acceptance_notification"].includes(type)) return "notification"; | |
| return null; | |
| }; | |
| const nextStepDeadline = upcomingDeadlines.find((deadlineItem) => { | |
| const progressKey = deadlineTypeToProgressKey(deadlineItem); | |
| return progressKey ? !progress[progressKey] : false; | |
| }) || upcomingDeadlines[0]; | |
| const deadlineDate = nextStepDeadline | |
| ? getDeadlineInLocalTime(nextStepDeadline.date, nextStepDeadline.timezone || timezone) | |
| : null; | |
| // Add validation before using formatDistanceToNow | |
| const getTimeRemaining = () => { | |
| if (!deadlineDate || !isValid(deadlineDate)) { | |
| return 'TBD'; | |
| } | |
| if (isPast(deadlineDate)) { | |
| return 'Deadline passed'; | |
| } | |
| try { | |
| return formatDistanceToNow(deadlineDate, { addSuffix: true }); | |
| } catch (error) { | |
| console.error('Error formatting time remaining:', error); | |
| return 'Invalid date'; | |
| } | |
| }; | |
| const timeRemaining = getTimeRemaining(); | |
| // Create location string by concatenating city and country | |
| const location = [city, country].filter(Boolean).join(", "); | |
| const conferenceEnd = conference.end || conference.start; | |
| const conferenceEndDate = conferenceEnd ? new Date(conferenceEnd) : null; | |
| const conferenceOver = !hasUpcomingDeadlines | |
| ? true | |
| : conferenceEndDate && isValid(conferenceEndDate) | |
| ? new Date().getTime() > conferenceEndDate.getTime() | |
| : false; | |
| const formatLocalDeadline = (deadlineItem: Deadline) => { | |
| const localDate = getDeadlineInLocalTime(deadlineItem.date, deadlineItem.timezone || timezone); | |
| if (!localDate || !isValid(localDate)) return deadlineItem.date; | |
| return format(localDate, "MMM d, yyyy HH:mm"); | |
| }; | |
| const updateProgress = (key: keyof ConferenceProgress) => { | |
| const nextProgress = { ...progress, [key]: !progress[key] }; | |
| setProgressState(nextProgress); | |
| setProgress(confKey, nextProgress); | |
| }; | |
| // Determine countdown color based on days remaining | |
| const getCountdownColor = () => { | |
| if (!deadlineDate || !isValid(deadlineDate)) return "text-neutral-600"; | |
| try { | |
| 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"; | |
| } catch (error) { | |
| console.error('Error calculating countdown color:', error); | |
| return "text-neutral-600"; | |
| } | |
| }; | |
| const handleCardClick = (e: React.MouseEvent) => { | |
| if (!(e.target as HTMLElement).closest('a') && | |
| !(e.target as HTMLElement).closest('.tag-button') && | |
| !(e.target as HTMLElement).closest('.status-toggle')) { | |
| setDialogOpen(true); | |
| } | |
| }; | |
| const handleTagClick = (e: React.MouseEvent, tag: string) => { | |
| e.stopPropagation(); | |
| const searchParams = new URLSearchParams(window.location.search); | |
| const currentTags = searchParams.get('tags')?.split(',') || []; | |
| let newTags; | |
| if (currentTags.includes(tag)) { | |
| newTags = currentTags.filter(t => t !== tag); | |
| } else { | |
| newTags = [...currentTags, tag]; | |
| } | |
| if (newTags.length > 0) { | |
| searchParams.set('tags', newTags.join(',')); | |
| } else { | |
| searchParams.delete('tags'); | |
| } | |
| const newUrl = `${window.location.pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; | |
| window.history.pushState({}, '', newUrl); | |
| window.dispatchEvent(new CustomEvent('urlchange', { detail: { tag } })); | |
| }; | |
| return ( | |
| <> | |
| <div | |
| className={`bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow flex flex-col cursor-pointer ${ | |
| compact ? "p-3" : "p-4" | |
| }`} | |
| onClick={handleCardClick} | |
| > | |
| <div className="flex justify-between items-start mb-2"> | |
| <div className="flex flex-col"> | |
| <h3 className={`${compact ? "text-base" : "text-lg"} font-semibold text-foreground`}> | |
| {title} {year} | |
| </h3> | |
| {full_name && ( | |
| <p className={`${compact ? "text-xs" : "text-sm"} italic text-neutral-500`}> | |
| {full_name} | |
| </p> | |
| )} | |
| </div> | |
| {link && ( | |
| <a | |
| href={link} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="hover:underline" | |
| onClick={(e) => e.stopPropagation()} | |
| > | |
| <Globe className="h-4 w-4 mr-2 flex-shrink-0" /> | |
| </a> | |
| )} | |
| </div> | |
| <div className="flex flex-col gap-2 mb-3"> | |
| <div className="flex items-center text-neutral"> | |
| <CalendarDays className="h-4 w-4 mr-2 flex-shrink-0" /> | |
| <span className={`${compact ? "text-xs" : "text-sm"} truncate`}>{date}</span> | |
| </div> | |
| <div | |
| className={`status-toggle flex flex-wrap items-center gap-3 rounded-md bg-neutral-50 ${ | |
| compact ? "p-1.5 text-[10px]" : "p-2 text-xs" | |
| } text-neutral-700`} | |
| > | |
| <label | |
| className="flex items-center gap-2" | |
| onClick={(event) => event.stopPropagation()} | |
| > | |
| <Checkbox | |
| checked={!hasUpcomingDeadlines || progress.paper || paperDeadlinePassed} | |
| onCheckedChange={() => updateProgress("paper")} | |
| disabled={!hasUpcomingDeadlines || paperDeadlinePassed} | |
| /> | |
| Papers submitted | |
| </label> | |
| <label | |
| className="flex items-center gap-2" | |
| onClick={(event) => event.stopPropagation()} | |
| > | |
| <Checkbox | |
| checked={!hasUpcomingDeadlines || progress.poster || posterDeadlinePassed} | |
| onCheckedChange={() => updateProgress("poster")} | |
| disabled={!hasUpcomingDeadlines || posterDeadlinePassed} | |
| /> | |
| Posters sent | |
| </label> | |
| <label | |
| className="flex items-center gap-2" | |
| onClick={(event) => event.stopPropagation()} | |
| > | |
| <Checkbox | |
| checked={!hasUpcomingDeadlines || progress.notification || notificationDeadlinePassed} | |
| onCheckedChange={() => updateProgress("notification")} | |
| disabled={!hasUpcomingDeadlines || notificationDeadlinePassed} | |
| /> | |
| Notification | |
| </label> | |
| <label className="flex items-center gap-2 text-neutral-600"> | |
| <Checkbox checked={conferenceOver} disabled /> | |
| Conference over | |
| </label> | |
| </div> | |
| <div className="flex items-start text-neutral"> | |
| <Clock className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" /> | |
| <div className={compact ? "text-xs" : "text-sm"}> | |
| <span className="font-medium">Next step:</span>{" "} | |
| {nextStepDeadline | |
| ? `${formatLocalDeadline(nextStepDeadline)} — ${nextStepDeadline.label}` | |
| : "No upcoming deadlines"} | |
| {nextStepDeadline && ( | |
| <div className={`${compact ? "text-[10px]" : "text-xs"} ${getCountdownColor()}`}> | |
| {timeRemaining} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {hasUpcomingDeadlines && ( | |
| <div className={`space-y-1 ${compact ? "text-xs" : "text-sm"} text-neutral`}> | |
| <p className="font-medium text-neutral-700">Upcoming deadlines</p> | |
| <ul | |
| className={`list-disc pl-4 ${compact ? "text-[10px]" : "text-xs"} text-neutral-600 space-y-1`} | |
| > | |
| {upcomingDeadlines.slice(0, 4).map((deadlineItem, index) => ( | |
| <li key={`${deadlineItem.type}-${index}`}> | |
| {formatLocalDeadline(deadlineItem)} — {deadlineItem.label} | |
| </li> | |
| ))} | |
| {upcomingDeadlines.length > 4 && ( | |
| <li className={`list-none ${compact ? "text-[10px]" : "text-xs"} text-neutral-500`}> | |
| +{upcomingDeadlines.length - 4} more | |
| </li> | |
| )} | |
| </ul> | |
| </div> | |
| )} | |
| {Array.isArray(tags) && tags.length > 0 && ( | |
| <div className="flex flex-wrap gap-2"> | |
| {tags.map((tag) => ( | |
| <button | |
| key={tag} | |
| className="tag tag-button" | |
| onClick={(e) => handleTagClick(e, tag)} | |
| > | |
| <Tag className="h-3 w-3 mr-1" /> | |
| {tag} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| {location && ( | |
| <div className="flex items-center text-neutral"> | |
| <Globe className="h-4 w-4 mr-2 flex-shrink-0" /> | |
| <span className={`${compact ? "text-xs" : "text-sm"} truncate`}>{location}</span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| <ConferenceDialog | |
| conference={{ | |
| title, | |
| full_name, | |
| year, | |
| date, | |
| deadline, | |
| timezone, | |
| tags, | |
| link, | |
| note, | |
| abstract_deadline, | |
| city, | |
| country, | |
| venue, | |
| ...conferenceProps | |
| }} | |
| open={dialogOpen} | |
| onOpenChange={setDialogOpen} | |
| /> | |
| </> | |
| ); | |
| }; | |
| export default ConferenceCard; | |