From 974d2dbab62386ff42c4bf881206faa6c7b30ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=B5=20Ho=C3=A0ng=20Ph=C3=BAc?= Date: Tue, 25 Feb 2025 19:33:53 +0700 Subject: [PATCH] feat(calendar): implement event filtering and CRUD API for calendar events --- .../[wsId]/calendar/events/[eventId]/route.ts | 90 +++ .../[wsId]/calendar/events/route.ts | 77 +++ apps/web/src/components/calendar/Calendar.tsx | 47 +- .../src/components/calendar/CalendarCell.tsx | 48 +- .../calendar/CalendarEventMatrix.tsx | 34 +- .../web/src/components/calendar/EventCard.tsx | 572 ++++++----------- .../src/components/calendar/EventModal.tsx | 259 ++++++++ .../src/components/calendar/MonthCalendar.tsx | 2 +- apps/web/src/hooks/useCalendar.tsx | 578 +++++++++++------- .../ui/src/components/ui/date-time-picker.tsx | 140 +++++ 10 files changed, 1211 insertions(+), 636 deletions(-) create mode 100644 apps/web/src/app/api/v1/workspaces/[wsId]/calendar/events/[eventId]/route.ts create mode 100644 apps/web/src/app/api/v1/workspaces/[wsId]/calendar/events/route.ts create mode 100644 apps/web/src/components/calendar/EventModal.tsx create mode 100644 packages/ui/src/components/ui/date-time-picker.tsx diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/calendar/events/[eventId]/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/calendar/events/[eventId]/route.ts new file mode 100644 index 0000000000..18637e2d87 --- /dev/null +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/calendar/events/[eventId]/route.ts @@ -0,0 +1,90 @@ +import { createClient } from '@tuturuuu/supabase/next/server'; +import { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event'; +import { NextResponse } from 'next/server'; + +interface Params { + params: Promise<{ + wsId: string; + eventId: string; + }>; +} + +export async function GET(_: Request, { params }: Params) { + const supabase = await createClient(); + const { wsId, eventId } = await params; + + try { + const { data: event, error } = await supabase + .from('workspace_calendar_events') + .select('*') + .eq('id', eventId) + .eq('ws_id', wsId) + .single(); + + if (error) throw error; + + return NextResponse.json(event); + } catch (error) { + console.error('Calendar event API error:', error); + return NextResponse.json( + { error: 'An error occurred while processing your request' }, + { status: 500 } + ); + } +} + +export async function PUT(request: Request, { params }: Params) { + const supabase = await createClient(); + const { wsId, eventId } = await params; + + try { + const updates: Partial = await request.json(); + + const { data, error } = await supabase + .from('workspace_calendar_events') + .update({ + title: updates.title, + description: updates.description, + start_at: updates.start_at, + end_at: updates.end_at, + color: updates.color, + }) + .eq('id', eventId) + .eq('ws_id', wsId) + .select() + .single(); + + if (error) throw error; + + return NextResponse.json(data); + } catch (error) { + console.error('Calendar event API error:', error); + return NextResponse.json( + { error: 'An error occurred while processing your request' }, + { status: 500 } + ); + } +} + +export async function DELETE(_: Request, { params }: Params) { + const supabase = await createClient(); + const { wsId, eventId } = await params; + + try { + const { error } = await supabase + .from('workspace_calendar_events') + .delete() + .eq('id', eventId) + .eq('ws_id', wsId); + + if (error) throw error; + + return NextResponse.json({ message: 'Event deleted successfully' }); + } catch (error) { + console.error('Calendar event API error:', error); + return NextResponse.json( + { error: 'An error occurred while processing your request' }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/calendar/events/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/calendar/events/route.ts new file mode 100644 index 0000000000..0d7c06e803 --- /dev/null +++ b/apps/web/src/app/api/v1/workspaces/[wsId]/calendar/events/route.ts @@ -0,0 +1,77 @@ +import { createClient } from '@tuturuuu/supabase/next/server'; +import { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event'; +import { NextResponse } from 'next/server'; + +interface Params { + params: Promise<{ + wsId: string; + }>; +} + +export async function GET(request: Request, { params }: Params) { + const supabase = await createClient(); + const { wsId } = await params; + + // Get the start_at and end_at from the URL + const url = new URL(request.url); + const start_at = url.searchParams.get('start_at'); + const end_at = url.searchParams.get('end_at'); + + if (!start_at || !end_at) { + return NextResponse.json( + { error: 'Start and end dates are required' }, + { status: 400 } + ); + } + + try { + const { data: events, error } = await supabase + .from('workspace_calendar_events') + .select('*') + .eq('ws_id', wsId) + .gte('start_at', new Date(start_at).toISOString()) + .lte('end_at', new Date(end_at).toISOString()); + + if (error) throw error; + + return NextResponse.json({ data: events, count: events.length }); + } catch (error) { + console.error('Calendar events API error:', error); + return NextResponse.json( + { error: 'An error occurred while processing your request' }, + { status: 500 } + ); + } +} + +export async function POST(request: Request, { params }: Params) { + const supabase = await createClient(); + const { wsId } = await params; + + try { + const event: Omit = await request.json(); + + const { data, error } = await supabase + .from('workspace_calendar_events') + .insert({ + title: event.title || '', + description: event.description || '', + start_at: event.start_at, + end_at: event.end_at, + color: event.color || 'blue', + ws_id: wsId, + }) + .select() + .single(); + + if (error) throw error; + + return NextResponse.json(data, { status: 201 }); + } catch (error) { + console.error('Calendar events API error:', error); + return NextResponse.json( + { error: 'An error occurred while processing your request' }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/components/calendar/Calendar.tsx b/apps/web/src/components/calendar/Calendar.tsx index 42a8050566..4af34e458b 100644 --- a/apps/web/src/components/calendar/Calendar.tsx +++ b/apps/web/src/components/calendar/Calendar.tsx @@ -2,15 +2,42 @@ import CalendarHeader from './CalendarHeader'; import CalendarViewWithTrail from './CalendarViewWithTrail'; -import DynamicIsland from './DynamicIsland'; +import { EventModal } from './EventModal'; import MonthCalendar from './MonthCalendar'; import WeekdayBar from './WeekdayBar'; -import { CalendarProvider } from '@/hooks/useCalendar'; +import { CalendarProvider, useCalendar } from '@/hooks/useCalendar'; import { CalendarView, useViewTransition } from '@/hooks/useViewTransition'; import { Workspace } from '@tuturuuu/types/primitives/Workspace'; +import { Button } from '@tuturuuu/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip'; +import { cn } from '@tuturuuu/utils/format'; +import { PlusIcon } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useCallback, useEffect, useState } from 'react'; +// Floating action button for quick event creation +const CreateEventButton = () => { + const { openModal } = useCalendar(); + + return ( +
+ + + + + Create new event + +
+ ); +}; + const Calendar = ({ workspace }: { workspace: Workspace }) => { const t = useTranslations('calendar'); const { transition } = useViewTransition(); @@ -208,7 +235,14 @@ const Calendar = ({ workspace }: { workspace: Workspace }) => { return ( -
+
{ else if (newView === 'month') enableMonthView(); }} /> + {view !== 'month' && }
@@ -234,7 +269,11 @@ const Calendar = ({ workspace }: { workspace: Workspace }) => { )}
- + {/* Event Creation/Editing Modal */} + + + {/* Floating action button */} +
); diff --git a/apps/web/src/components/calendar/CalendarCell.tsx b/apps/web/src/components/calendar/CalendarCell.tsx index f0851e4ab5..8f7d1a8bb3 100644 --- a/apps/web/src/components/calendar/CalendarCell.tsx +++ b/apps/web/src/components/calendar/CalendarCell.tsx @@ -1,4 +1,5 @@ -// import { useCalendar } from '@/hooks/useCalendar'; +import { useCalendar } from '@/hooks/useCalendar'; +import { cn } from '@tuturuuu/utils/format'; interface CalendarCellProps { date: string; @@ -6,37 +7,48 @@ interface CalendarCellProps { } const CalendarCell = ({ date, hour }: CalendarCellProps) => { - // const { addEmptyEvent } = useCalendar(); + const { addEmptyEvent } = useCalendar(); const id = `cell-${date}-${hour}`; - // const handleCreateEvent = (midHour?: boolean) => { - // const newDate = new Date(date); - - // newDate.setDate(newDate.getDate() + 1); - // newDate.setHours(hour, midHour ? 30 : 0, 0, 0); - - // addEmptyEvent(newDate); - // }; + const handleCreateEvent = (midHour?: boolean) => { + const newDate = new Date(date); + newDate.setHours(hour, midHour ? 30 : 0, 0, 0); + addEmptyEvent(newDate); + }; return (
{ e.preventDefault(); }} + data-hour={hour} + data-date={date} > +
); }; diff --git a/apps/web/src/components/calendar/CalendarEventMatrix.tsx b/apps/web/src/components/calendar/CalendarEventMatrix.tsx index 636a9bc580..893bdd4368 100644 --- a/apps/web/src/components/calendar/CalendarEventMatrix.tsx +++ b/apps/web/src/components/calendar/CalendarEventMatrix.tsx @@ -5,10 +5,35 @@ import { useParams } from 'next/navigation'; const CalendarEventMatrix = ({ dates }: { dates: Date[] }) => { const params = useParams(); const wsId = params?.wsId as string; - const { getEvents } = useCalendar(); - const events = getEvents(); + // Get all events and filter them based on the visible dates + const allEvents = getEvents(); + const visibleEvents = allEvents.filter(event => { + const eventStart = new Date(event.start_at); + const eventEnd = new Date(event.end_at); + + // Check if the event falls within any of the visible dates + return dates.some(date => { + const isAllDay = eventStart.getHours() === 0 && eventEnd.getHours() === 23; + + if (isAllDay) { + // For all-day events, check if the date falls within the event's range + return ( + eventStart <= date && + eventEnd >= date + ); + } + + // For regular events, check if the event starts on this date + return ( + date.getFullYear() === eventStart.getFullYear() && + date.getMonth() === eventStart.getMonth() && + date.getDate() === eventStart.getDate() + ); + }); + }); + const columns = dates.length; return ( @@ -16,9 +41,10 @@ const CalendarEventMatrix = ({ dates }: { dates: Date[] }) => { className={`pointer-events-none absolute inset-0 grid ${ columns === 1 && 'max-w-lg' }`} + style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }} > -
- {events.map((event) => ( +
+ {visibleEvents.map((event) => ( ))}
diff --git a/apps/web/src/components/calendar/EventCard.tsx b/apps/web/src/components/calendar/EventCard.tsx index 443303fb59..b155954a17 100644 --- a/apps/web/src/components/calendar/EventCard.tsx +++ b/apps/web/src/components/calendar/EventCard.tsx @@ -1,6 +1,7 @@ import { useCalendar } from '@/hooks/useCalendar'; import { useDebouncedState } from '@mantine/hooks'; import { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event'; +import { cn } from '@tuturuuu/utils/format'; import moment from 'moment'; import { useEffect, useRef, useState } from 'react'; @@ -10,270 +11,133 @@ interface EventCardProps { event: CalendarEvent; } -export default function EventCard({ dates, wsId, event }: EventCardProps) { - const { id, start_at, end_at } = event; +export default function EventCard({ dates, event }: EventCardProps) { + const { id, title, description, start_at, end_at, color = 'blue' } = event; const { getEventLevel: getLevel, updateEvent, - // getActiveEvent, - // openModal, - // closeModal, hideModal, showModal, } = useCalendar(); - useEffect(() => { - const syncEvent = async () => { - try { - const res = await fetch( - `/api/workspaces/${wsId}/calendar/events/${id}`, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(event), - } - ); - - if (!res.ok) throw new Error('Failed to sync event'); - } catch (err) { - console.error(err); - } - }; - - if (event.local) { - // Wait 500ms before syncing the event - const timeout = setTimeout(() => { - syncEvent(); - }, 500); - - return () => clearTimeout(timeout); - } - }, [event, wsId, id]); - - // const convertTime = (time: number) => { - // // 9.5 => 9:30 - // const hours = Math.floor(time); - // const minutes = Math.round((time - hours) * 60); - - // // pad with 0 - // const pad = (n: number) => (n < 10 ? '0' + n : n); - // return `${pad(hours)}:${pad(minutes)}`; - // }; - const startDate = moment(start_at).toDate(); const endDate = moment(end_at).toDate(); const startHours = startDate.getHours() + startDate.getMinutes() / 60; const endHours = endDate.getHours() + endDate.getMinutes() / 60; - - // const startTime = convertTime(startHours); - // const endTime = convertTime(endHours); - const duration = startHours > endHours ? 24 - startHours : endHours - startHours; - const level = getLevel ? getLevel(id) : 0; - // const cardStyle = { - // minHeight: 16, - // opacity: 0, - // transition: - // 'width 150ms ease-in-out,' + - // 'left 150ms ease-in-out,' + - // 'opacity 0.3s ease-in-out,' + - // 'background-color 0.5s ease-in-out,' + - // 'border-color 0.5s ease-in-out,' + - // 'color 0.5s ease-in-out', - // }; + const handleRef = useRef(null); + const contentRef = useRef(null); + const [isDragging, setIsDragging] = useDebouncedState(false, 200); + const [isResizing, setIsResizing] = useState(false); + + // Event positioning and sizing useEffect(() => { - // Every time the event is updated, update the card style const cardEl = document.getElementById(`event-${id}`); - - const cellEl = document.querySelector( - `.calendar-cell` - ) as HTMLDivElement | null; + const cellEl = document.querySelector('.calendar-cell') as HTMLDivElement; if (!cardEl || !cellEl) return; - const startHours = startDate.getHours() + startDate.getMinutes() / 60; - const endHours = endDate.getHours() + endDate.getMinutes() / 60; - - const duration = - startHours > endHours ? 24 - startHours : endHours - startHours; - // Calculate event height const height = Math.max(20 - 4, duration * 80 - 4); // Calculate the index of the day the event is in const dateIdx = dates.findIndex((date) => { + const eventDate = startDate; return ( - date.getFullYear() === startDate.getFullYear() && - date.getMonth() === startDate.getMonth() && - date.getDate() === startDate.getDate() + date.getFullYear() === eventDate.getFullYear() && + date.getMonth() === eventDate.getMonth() && + date.getDate() === eventDate.getDate() ); }); if (dateIdx === -1) { - cardEl.style.transitionDelay = '0ms, 0ms, 0ms, 0ms, 0ms, 0ms'; cardEl.style.opacity = '0'; cardEl.style.pointerEvents = 'none'; return; - } else { - cardEl.style.transitionDelay = '0ms, 0ms, 300ms, 0ms, 0ms, 0ms'; - cardEl.style.opacity = '1'; - cardEl.style.pointerEvents = 'all'; } - // Update event dimensions + // Update event dimensions and position cardEl.style.height = `${height}px`; - - // Update event position cardEl.style.top = `${startHours * 80}px`; const observer = new ResizeObserver(() => { - const left = dateIdx * (cellEl.offsetWidth + 0.5) + level * 12; + const columnWidth = cellEl.offsetWidth; + const left = dateIdx * columnWidth + level * 12; + const width = columnWidth - level * 12 - 4; + + cardEl.style.width = `${width}px`; cardEl.style.left = `${left}px`; }); observer.observe(cellEl); - // Update event time visibility - const timeEl = cardEl.querySelector('#time'); - if (duration <= 0.5) timeEl?.classList.add('hidden'); - else timeEl?.classList.remove('hidden'); + cardEl.style.opacity = '1'; + cardEl.style.pointerEvents = 'all'; return () => observer.disconnect(); - }, [id, startDate, endDate, level, dates]); - - // const isPast = () => { - // const endAt = new Date(startDate); - - // const extraHours = Math.floor(duration); - // const extraMinutes = Math.round((duration - extraHours) * 60); - - // endAt.setHours(endAt.getHours() + extraHours); - // endAt.setMinutes(endAt.getMinutes() + extraMinutes); - - // return endAt < new Date(); - // }; - - const handleRef = useRef(null); - const contentRef = useRef(null); - - const [isDragging, setIsDragging] = useDebouncedState(false, 200); - const [isResizing, setIsResizing] = useState(false); - - useEffect(() => { - // If the event is being dragged or resized, update the card width - const cardEl = document.getElementById(`event-${id}`); - if (!cardEl) return; - - const cellEl = document.querySelector( - `.calendar-cell` - ) as HTMLDivElement | null; - if (!cellEl) return; - - const observer = new ResizeObserver(() => { - const paddedWidth = cellEl.offsetWidth - (level + 1) * 12; - const normalWidth = cellEl.offsetWidth - level * 12 - 4; - - const isEditing = isDragging || isResizing; - const padding = isEditing ? paddedWidth : normalWidth; - - cardEl.style.width = `${padding}px`; - if (isEditing) hideModal(); - }); - - observer.observe(cellEl); - - return () => { - observer.disconnect(); - }; - }, [id, level, isDragging, isResizing, hideModal]); - - // const activeEvent = getActiveEvent(); - // const isOpened = activeEvent?.id === id; + }, [id, startDate, duration, level, dates]); // Event resizing useEffect(() => { const rootEl = handleRef.current; - if (!rootEl) return; - - const cardEl = rootEl.parentElement; - if (!cardEl) return; + const cardEl = rootEl?.parentElement; + if (!rootEl || !cardEl) return; const handleMouseDown = (e: MouseEvent) => { - e.preventDefault(); - + e.stopPropagation(); if (isDragging || isResizing) return; setIsResizing(true); + hideModal(); const startY = e.clientY; const startHeight = cardEl.offsetHeight; const handleMouseMove = (e: MouseEvent) => { e.preventDefault(); - if (isDragging) return; - setIsResizing(true); - const height = - Math.round((startHeight + e.clientY - startY) / 20) * 20 - 4; + const height = Math.round((startHeight + e.clientY - startY) / 20) * 20; + if (height <= 20) return; // Minimum height - // If the height doesn't change, don't update - if (height === cardEl.offsetHeight) return; - cardEl.style.height = height + 'px'; + cardEl.style.height = `${height - 4}px`; - // calculate new end time - const newDuration = Math.round(cardEl.offsetHeight / 20) / 4; + // Calculate new end time + const newDuration = Math.round(height / 20) / 4; const newEndAt = new Date(startDate); - const extraHours = Math.floor(newDuration); const extraMinutes = Math.round((newDuration - extraHours) * 60); - newEndAt.setHours(newEndAt.getHours() + extraHours); - newEndAt.setMinutes(newEndAt.getMinutes() + extraMinutes); + newEndAt.setHours(startDate.getHours() + extraHours); + newEndAt.setMinutes(startDate.getMinutes() + extraMinutes); - // update event updateEvent(id, { end_at: newEndAt.toISOString() }); }; - const handleMouseUp = (e: MouseEvent) => { - e.preventDefault(); - - if (isDragging) return; + const handleMouseUp = () => { setIsResizing(false); showModal(); - window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); - - return () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - }; }; - if (isDragging) return; rootEl.addEventListener('mousedown', handleMouseDown); - - return () => { - rootEl.removeEventListener('mousedown', handleMouseDown); - }; + return () => rootEl.removeEventListener('mousedown', handleMouseDown); }, [ id, - updateEvent, - isResizing, startDate, + updateEvent, isDragging, + isResizing, hideModal, showModal, ]); @@ -281,256 +145,196 @@ export default function EventCard({ dates, wsId, event }: EventCardProps) { // Event dragging useEffect(() => { const rootEl = contentRef.current; - if (!rootEl) return; + const cardEl = rootEl?.parentElement; + const cellEl = document.querySelector('.calendar-cell') as HTMLDivElement; - const cardEl = rootEl.parentElement; - if (!cardEl) return; - - const cellEl = document.querySelector( - `.calendar-cell` - ) as HTMLDivElement | null; - if (!cellEl) return; + if (!rootEl || !cardEl || !cellEl) return; const handleMouseDown = (e: MouseEvent) => { - e.preventDefault(); - + e.stopPropagation(); if (isResizing) return; setIsDragging(true); + hideModal(); const startX = e.clientX; const startY = e.clientY; - const startLeft = cardEl.offsetLeft; const startTop = cardEl.offsetTop; + const columnWidth = cellEl.offsetWidth; const handleMouseMove = (e: MouseEvent) => { e.preventDefault(); - if (isResizing) return; - const top = Math.round((startTop + e.clientY - startY) / 20) * 20; - - const cellWidth = cellEl.offsetWidth; - const halfCellWidth = cellWidth / 2; + // Calculate new position + const deltaX = e.clientX - startX; + const deltaY = e.clientY - startY; + // Snap to grid + const top = Math.round((startTop + deltaY) / 20) * 20; const left = - Math.round((startLeft + e.clientX - startX) / halfCellWidth) * - halfCellWidth; - - // If the top or left doesn't change, don't update - if (top === cardEl.offsetTop && left === cardEl.offsetLeft) return; + Math.round((startLeft + deltaX) / columnWidth) * columnWidth; + + // Update position if changed + if (top !== cardEl.offsetTop || left !== cardEl.offsetLeft) { + cardEl.style.top = `${top}px`; + cardEl.style.left = `${left}px`; + + // Calculate new date index + const newDateIdx = Math.floor(left / columnWidth); + + // Calculate new times + const newStartAt = new Date(startDate); + const newStartHour = Math.floor(top / 80); + const newStartMinute = Math.round(((top % 80) / 80) * 60); + + newStartAt.setHours(newStartHour); + newStartAt.setMinutes(newStartMinute); + + // Update date if moved to different day + if (newDateIdx >= 0 && newDateIdx < dates.length) { + const newDate = dates[newDateIdx]; + if (!newDate) return; + newStartAt.setFullYear(newDate.getFullYear()); + newStartAt.setMonth(newDate.getMonth()); + newStartAt.setDate(newDate.getDate()); + } - const dateIdx = dates.findIndex((date) => { - return ( - date.getFullYear() === startDate.getFullYear() && - date.getMonth() === startDate.getMonth() && - date.getDate() === startDate.getDate() + // Calculate new end time maintaining duration + const newEndAt = new Date(newStartAt); + newEndAt.setTime( + newStartAt.getTime() + (endDate.getTime() - startDate.getTime()) ); - }); - - if (dateIdx === -1) return; - const newDateIdx = Math.round(left / halfCellWidth / 2); - - // calculate new start time - const newStartAt = new Date(startDate); - - const newStartHour = Math.round(top / 20) / 4; - const leftoverHour = newStartHour - Math.floor(newStartHour); - - const newStartMinute = Math.round(leftoverHour * 60); - - newStartAt.setHours(Math.floor(newStartHour)); - newStartAt.setMinutes(newStartMinute); - - // calculate new end time (duration) - const newEndAt = new Date(endDate); - const extraHours = Math.floor(duration); - const extraMinutes = Math.round((duration - extraHours) * 60); - - newEndAt.setHours(newStartAt.getHours() + extraHours); - newEndAt.setMinutes(newStartAt.getMinutes() + extraMinutes); - - // Update startDate and endDate if the date changes - if (dateIdx !== newDateIdx) { - const newDate = dates[newDateIdx]; - if (!newDate) return; - - newStartAt.setFullYear(newDate.getFullYear()); - newStartAt.setMonth(newDate.getMonth()); - newStartAt.setDate(newDate.getDate()); - - newEndAt.setFullYear(newDate.getFullYear()); - newEndAt.setMonth(newDate.getMonth()); - newEndAt.setDate(newDate.getDate()); + updateEvent(id, { + start_at: newStartAt.toISOString(), + end_at: newEndAt.toISOString(), + }); } - - // update event - updateEvent(id, { - start_at: newStartAt.toISOString(), - end_at: newEndAt.toISOString(), - }); }; - const handleMouseUp = (e: MouseEvent) => { - e.preventDefault(); - - if (isResizing) return; + const handleMouseUp = () => { setIsDragging(false); showModal(); - window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); - - return () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - }; }; - if (isResizing) return; - cardEl.addEventListener('mousedown', handleMouseDown); - - return () => { - cardEl.removeEventListener('mousedown', handleMouseDown); - }; + rootEl.addEventListener('mousedown', handleMouseDown); + return () => rootEl.removeEventListener('mousedown', handleMouseDown); }, [ id, + updateEvent, + isResizing, startDate, endDate, duration, - level, + isDragging, dates, - startHours, - isResizing, - updateEvent, + hideModal, showModal, - setIsDragging, ]); - // const isNotFocused = activeEvent != null && !isOpened; - - // const generateColor = () => { - // const eventColor = color?.toLowerCase() || 'blue'; - - // const colors: { - // [key: string]: string; - // } = { - // red: isNotFocused - // ? 'bg-[#fdecec] dark:bg-[#241f22] border-red-500/40 text-red-600/50 dark:border-red-300/30 dark:text-red-200/50' - // : 'bg-[#fcdada] dark:bg-[#302729] border-red-500/80 text-red-600 dark:border-red-300/80 dark:text-red-200', - // blue: isNotFocused - // ? 'bg-[#ebf2fe] dark:bg-[#1e2127] border-blue-500/40 text-blue-600/50 dark:border-blue-300/30 dark:text-blue-200/50' - // : 'bg-[#d8e6fd] dark:bg-[#252a32] border-blue-500/80 text-blue-600 dark:border-blue-300/80 dark:text-blue-200', - // green: isNotFocused - // ? 'bg-[#e8f9ef] dark:bg-[#1e2323] border-green-500/40 text-green-600/50 dark:border-green-300/30 dark:text-green-200/50' - // : 'bg-[#d3f3df] dark:bg-[#242e2a] border-green-500/80 text-green-600 dark:border-green-300/80 dark:text-green-200', - // yellow: isNotFocused - // ? 'bg-[#fdf7e6] dark:bg-[#24221e] border-yellow-500/40 text-yellow-600/50 dark:border-yellow-300/30 dark:text-yellow-200/50' - // : 'bg-[#fbf0ce] dark:bg-[#302d1f] border-yellow-500/80 text-yellow-600 dark:border-yellow-300/80 dark:text-yellow-200', - // orange: isNotFocused - // ? 'bg-[#fef1e7] dark:bg-[#242020] border-orange-500/40 text-orange-600/50 dark:border-orange-300/30 dark:text-orange-200/50' - // : 'bg-[#fee3d0] dark:bg-[#302924] border-orange-500/80 text-orange-600 dark:border-orange-300/80 dark:text-orange-200', - // purple: isNotFocused - // ? 'bg-[#f6eefe] dark:bg-[#222027] border-purple-500/40 text-purple-600/50 dark:border-purple-300/30 dark:text-purple-200/50' - // : 'bg-[#eeddfd] dark:bg-[#2c2832] border-purple-500/80 text-purple-600 dark:border-purple-300/80 dark:text-purple-200', - // pink: isNotFocused - // ? 'bg-[#fdecf5] dark:bg-[#242025] border-pink-500/40 text-pink-600/50 dark:border-pink-300/30 dark:text-pink-200/50' - // : 'bg-[#fbdaeb] dark:bg-[#2f272e] border-pink-500/80 text-pink-600 dark:border-pink-300/80 dark:text-pink-200', - // indigo: isNotFocused - // ? 'bg-[#efeffe] dark:bg-[#1f2027] border-indigo-500/40 text-indigo-600/50 dark:border-indigo-300/30 dark:text-indigo-200/50' - // : 'bg-[#e0e0fc] dark:bg-[#272832] border-indigo-500/80 text-indigo-600 dark:border-indigo-300/80 dark:text-indigo-200', - // cyan: isNotFocused - // ? 'bg-[#e6f8fb] dark:bg-[#1c2327] border-cyan-500/40 text-cyan-600/50 dark:border-cyan-300/30 dark:text-cyan-200/50' - // : 'bg-[#cdf0f6] dark:bg-[#212e31] border-cyan-500/80 text-cyan-600 dark:border-cyan-300/80 dark:text-cyan-200', - // gray: isNotFocused - // ? 'bg-[#f0f1f2] dark:bg-[#222225] border-gray-500/40 text-gray-600/50 dark:border-gray-300/30 dark:text-gray-200/50' - // : 'bg-[#e1e3e6] dark:bg-[#2b2c2e] border-gray-500/80 text-gray-600 dark:border-gray-300/80 dark:text-gray-200', - // }; - - // return colors[eventColor]; - // }; - - return
; - - // return ( - // - // - // {/* */} - // - - //
{ - // e.preventDefault(); - // openModal(id); - // }} - // onDoubleClick={(e) => { - // e.preventDefault(); - // openModal(id); - // }} - // > - // - //
- //
- // {isPast() ? '✅'.concat(title || '') : title} - //
- // {duration > 0.5 && ( - //
- // {startTime} - {endTime} - //
- // )} - //
- //
- - //
- //
- // - // ); + // Color styles based on event color + const getEventStyles = () => { + const colorStyles: Record< + string, + { bg: string; border: string; text: string } + > = { + blue: { + bg: 'bg-blue-100 dark:bg-blue-900/30', + border: 'border-blue-500/80 dark:border-blue-300/80', + text: 'text-blue-700 dark:text-blue-300', + }, + red: { + bg: 'bg-red-100 dark:bg-red-900/30', + border: 'border-red-500/80 dark:border-red-300/80', + text: 'text-red-700 dark:text-red-300', + }, + green: { + bg: 'bg-green-100 dark:bg-green-900/30', + border: 'border-green-500/80 dark:border-green-300/80', + text: 'text-green-700 dark:text-green-300', + }, + yellow: { + bg: 'bg-yellow-100 dark:bg-yellow-900/30', + border: 'border-yellow-500/80 dark:border-yellow-300/80', + text: 'text-yellow-700 dark:text-yellow-300', + }, + purple: { + bg: 'bg-purple-100 dark:bg-purple-900/30', + border: 'border-purple-500/80 dark:border-purple-300/80', + text: 'text-purple-700 dark:text-purple-300', + }, + pink: { + bg: 'bg-pink-100 dark:bg-pink-900/30', + border: 'border-pink-500/80 dark:border-pink-300/80', + text: 'text-pink-700 dark:text-pink-300', + }, + orange: { + bg: 'bg-orange-100 dark:bg-orange-900/30', + border: 'border-orange-500/80 dark:border-orange-300/80', + text: 'text-orange-700 dark:text-orange-300', + }, + gray: { + bg: 'bg-gray-100 dark:bg-gray-900/30', + border: 'border-gray-500/80 dark:border-gray-300/80', + text: 'text-gray-700 dark:text-gray-300', + }, + }; + + return colorStyles[color.toLowerCase()] || colorStyles.blue; + }; + + const { bg, border, text } = getEventStyles()!; + + return ( +
{ + e.stopPropagation(); + showModal(); + }} + > +
+
+ {title || 'Untitled event'} +
+ {duration > 0.5 && description && ( +
{description}
+ )} +
+ +
+
+ ); } diff --git a/apps/web/src/components/calendar/EventModal.tsx b/apps/web/src/components/calendar/EventModal.tsx new file mode 100644 index 0000000000..6b0f3c6fd5 --- /dev/null +++ b/apps/web/src/components/calendar/EventModal.tsx @@ -0,0 +1,259 @@ +'use client'; + +import { useCalendar } from '@/hooks/useCalendar'; +import type { SupportedColor } from '@tuturuuu/types/primitives/SupportedColors'; +import { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event'; +import { Button } from '@tuturuuu/ui/button'; +import { ColorPicker } from '@tuturuuu/ui/color-picker'; +import { DateTimePicker } from '@tuturuuu/ui/date-time-picker'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@tuturuuu/ui/dialog'; +import { Input } from '@tuturuuu/ui/input'; +import { Label } from '@tuturuuu/ui/label'; +import { Switch } from '@tuturuuu/ui/switch'; +import { Textarea } from '@tuturuuu/ui/textarea'; +import { useEffect, useState } from 'react'; + +export function EventModal() { + const { + activeEvent, + isModalOpen, + closeModal, + addEvent, + updateEvent, + deleteEvent, + } = useCalendar(); + + const [event, setEvent] = useState>({ + title: '', + description: '', + start_at: new Date().toISOString(), + end_at: new Date(new Date().getTime() + 60 * 60 * 1000).toISOString(), // Default to 1 hour + color: 'blue', + }); + + const [isAllDay, setIsAllDay] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + // Reset form when modal opens/closes or active event changes + useEffect(() => { + if (activeEvent) { + setEvent({ + ...activeEvent, + }); + + // Check if this is an all-day event (no time component) + const start = new Date(activeEvent.start_at); + const end = new Date(activeEvent.end_at); + setIsAllDay( + start.getHours() === 0 && + start.getMinutes() === 0 && + end.getHours() === 23 && + end.getMinutes() === 59 + ); + } else { + // Set default values for new event + const now = new Date(); + const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000); + + setEvent({ + title: '', + description: '', + start_at: now.toISOString(), + end_at: oneHourLater.toISOString(), + color: 'blue', + }); + setIsAllDay(false); + } + }, [activeEvent, isModalOpen]); + + const handleSave = async () => { + if (!event.title || !event.start_at || !event.end_at) return; + + setIsSaving(true); + try { + // Check if this is a new event or an existing one + if (activeEvent?.id === 'new') { + // Create a new event + await addEvent(event as Omit); + } else if (activeEvent?.id) { + // Update existing event + await updateEvent(activeEvent.id, event); + } + closeModal(); + } catch (error) { + console.error('Error saving event:', error); + } finally { + setIsSaving(false); + } + }; + + const handleDelete = async () => { + if (!activeEvent?.id) return; + + setIsDeleting(true); + try { + await deleteEvent(activeEvent.id); + closeModal(); + } catch (error) { + console.error('Error deleting event:', error); + } finally { + setIsDeleting(false); + } + }; + + const handleStartDateChange = (date: Date | undefined) => { + if (!date) return; + + setEvent((prev) => { + const newEvent = { ...prev, start_at: date.toISOString() }; + + // If start time is after end time, push end time forward + const endDate = new Date(prev.end_at || ''); + if (date > endDate) { + const duration = + endDate.getTime() - new Date(prev.start_at || '').getTime(); + const newEndDate = new Date(date.getTime() + duration); + newEvent.end_at = newEndDate.toISOString(); + } + + return newEvent; + }); + }; + + const handleEndDateChange = (date: Date | undefined) => { + if (!date) return; + setEvent((prev) => ({ ...prev, end_at: date.toISOString() })); + }; + + const handleAllDayChange = (checked: boolean) => { + setIsAllDay(checked); + + if (checked) { + // Set times to start of day and end of day + const startDate = new Date(event.start_at || ''); + startDate.setHours(0, 0, 0, 0); + + const endDate = new Date(event.end_at || ''); + endDate.setHours(23, 59, 59, 999); + + setEvent((prev) => ({ + ...prev, + start_at: startDate.toISOString(), + end_at: endDate.toISOString(), + })); + } + }; + + return ( + !open && closeModal()}> + + + + {activeEvent?.id && activeEvent.id !== 'new' + ? 'Edit Event' + : 'New Event'} + + + {activeEvent?.id && activeEvent.id !== 'new' + ? 'Make changes to your event here.' + : 'Add a new event to your calendar.'} + + + +
+
+ + setEvent({ ...event, title: e.target.value })} + placeholder="Event title" + autoFocus + /> +
+ +
+ +