This repository has no description
Something went wrong. Try again.
21 kB · 519 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520import React, { useEffect, useMemo, useRef, useState } from "react";import { startOfWeek, endOfWeek, eachDayOfInterval, isToday, format,} from "date-fns";import { Video, MapPin, Clock } from "lucide-react";import { CalendarEventItem, CalendarEventRecord, CalendarSpace,} from "../types/calendar";import { atprotoService } from "../services/atproto";import { eventsOnDay, resolveEventDrop } from "../utils/calendarInteraction";import { getEventDaySegment, getEventTimeLabel, isAllDayEvent,} from "../utils/dateUtils";import { layoutWeekSpans } from "../utils/weekSpans";
interface CalendarWeekViewProps { currentDate: Date; events: CalendarEventItem[]; space: CalendarSpace; onSelectEvent: (event: CalendarEventItem) => void; onCreateEventOnDate: (date: Date) => void; onRescheduleEvent?: ( event: CalendarEventItem, updates: Partial<CalendarEventRecord>, ) => Promise<void> | void;}
const HOURS = Array.from({ length: 24 }, (_, i) => i);const HOUR_HEIGHT = 64; // px per hour
export const CalendarWeekView: React.FC<CalendarWeekViewProps> = ({ currentDate, events, space, onSelectEvent, onCreateEventOnDate, onRescheduleEvent,}) => { const scrollContainerRef = useRef<HTMLDivElement>(null); const [now, setNow] = useState(new Date()); const [dragOverSlot, setDragOverSlot] = useState<{ dayKey: string; hour: number; } | null>(null); const [draggingEventUri, setDraggingEventUri] = useState<string | null>(null); const currentDid = atprotoService.getCurrentDid();
const weekStart = startOfWeek(currentDate); const weekEnd = endOfWeek(currentDate); const days = eachDayOfInterval({ start: weekStart, end: weekEnd }); const weekSpans = useMemo( () => layoutWeekSpans(events, days), [events, currentDate], );
useEffect(() => { const timer = setInterval(() => setNow(new Date()), 60000); return () => clearInterval(timer); }, []);
useEffect(() => { if (scrollContainerRef.current) { const scrollHour = 8; scrollContainerRef.current.scrollTop = scrollHour * HOUR_HEIGHT; } }, []);
const getEventsForDay = (day: Date) => eventsOnDay(events, day);
const handleDropOnSlot = ( e: React.DragEvent, targetDay: Date, targetHour: number, ) => { e.preventDefault(); e.stopPropagation(); setDragOverSlot(null); setDraggingEventUri(null);
const raw = e.dataTransfer.getData("application/json") || e.dataTransfer.getData("text/plain"); const move = resolveEventDrop( raw, events, currentDid, targetDay, targetHour, ); if (move) onRescheduleEvent?.(move.event, move.updates); };
const handleDropOnColumn = ( e: React.DragEvent<HTMLDivElement>, targetDay: Date, ) => { e.preventDefault(); const rect = e.currentTarget.getBoundingClientRect(); const offsetY = e.clientY - rect.top; const calculatedHour = Math.min( 23, Math.max(0, Math.floor(offsetY / HOUR_HEIGHT)), ); handleDropOnSlot(e, targetDay, calculatedHour); };
const currentMinutes = now.getHours() * 60 + now.getMinutes(); const currentIndicatorTop = (currentMinutes / 60) * HOUR_HEIGHT;
return ( <div className="flex-1 flex flex-col bg-white dark:bg-slate-900 overflow-hidden select-none"> <div className="flex border-b border-slate-200 dark:border-slate-800 bg-slate-50/70 dark:bg-slate-900/60 sticky top-0 z-20 flex-shrink-0"> <div className="w-12 sm:w-14 flex-shrink-0 border-r border-slate-200 dark:border-slate-800" /> <div className="flex-1 grid grid-cols-7 divide-x divide-slate-200 dark:divide-slate-800"> {days.map((day) => { const dayIsToday = isToday(day); return ( <div key={day.toISOString()} className="py-2 text-center"> <div className="text-[10px] sm:text-[11px] font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider"> {format(day, "EEE")} </div> <div className={`inline-flex items-center justify-center w-6 h-6 sm:w-7 sm:h-7 rounded-full text-xs font-bold mt-0.5 ${ dayIsToday ? "bg-primary-600 text-white shadow-sm" : "text-slate-800 dark:text-slate-200" }`} > {format(day, "d")} </div> </div> ); })} </div> </div>
<div data-testid="week-all-day-row" className="flex border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900 flex-shrink-0" > <div className="w-12 sm:w-14 flex-shrink-0 border-r border-slate-200 px-1 py-2 text-right text-[10px] font-semibold uppercase text-slate-400 dark:border-slate-800"> All-day </div> <div className="relative flex-1 grid grid-cols-7 divide-x divide-slate-100 dark:divide-slate-800/60"> {days.map((day) => { const dayKey = format(day, "yyyy-MM-dd"); const allDayEvents = getEventsForDay(day).filter( (event) => isAllDayEvent(event.value) && !weekSpans.some((span) => span.event.uri === event.uri), ); return ( <div key={dayKey} data-testid={`week-all-day-${dayKey}`} className="min-h-10 space-y-0.5 p-0.5" onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; }} onDrop={(e) => handleDropOnSlot(e, day, 0)} > {allDayEvents.map((event) => { const isOwner = event.repo === currentDid; const eventColor = event.calendarColor || space.color; const eventLabel = `${event.value.name}${event.calendarName ? `, ${event.calendarName}` : ""}${event.hasConflict ? ", Overlapping event" : ""}`; return ( <button key={`all-day-${event.uri}`} type="button" draggable={isOwner} onDragStart={(e) => { if (!isOwner) return; e.stopPropagation(); e.dataTransfer.setData( "text/plain", JSON.stringify({ uri: event.uri, sourceDate: dayKey, }), ); e.dataTransfer.effectAllowed = "move"; setDraggingEventUri(event.uri); }} onDragEnd={() => { setDraggingEventUri(null); setDragOverSlot(null); }} onClick={() => onSelectEvent(event)} onKeyDown={(e) => { if ( !e.metaKey && !e.ctrlKey && !e.altKey && (e.key === "Enter" || e.key === " ") ) { e.preventDefault(); onSelectEvent(event); } }} aria-label={eventLabel} title={ event.calendarName ? `${event.value.name} · ${event.calendarName}` : event.value.name } className={`w-full truncate rounded px-1 py-0.5 text-left text-[10px] font-semibold ${isOwner ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"} ${draggingEventUri === event.uri ? "opacity-40" : ""}`} style={{ backgroundColor: `${eventColor}22`, color: eventColor, }} > {event.value.name} {event.hasConflict && ( <span className="text-amber-700 dark:text-amber-300 flex-shrink-0" title="Overlapping event" aria-label="Overlapping event" > ! </span> )} </button> ); })} </div> ); })} <div className="pointer-events-none absolute inset-x-0 top-0 grid grid-cols-7"> {weekSpans.map((span) => { const color = span.event.calendarColor || space.color; const isOwner = span.event.repo === currentDid; return ( <button key={span.event.uri} data-testid={`week-span-${span.event.rkey}`} draggable={isOwner} onDragStart={(event) => { if (!isOwner) return; event.dataTransfer.setData( "text/plain", JSON.stringify({ uri: span.event.uri, sourceDate: format(days[span.start], "yyyy-MM-dd"), }), ); event.dataTransfer.effectAllowed = "move"; setDraggingEventUri(span.event.uri); }} onDragEnd={() => { setDraggingEventUri(null); setDragOverSlot(null); }} onClick={() => onSelectEvent(span.event)} onKeyDown={(event) => { if ( !event.metaKey && !event.ctrlKey && !event.altKey && (event.key === "Enter" || event.key === " ") ) { event.preventDefault(); onSelectEvent(span.event); } }} aria-label={span.event.value.name} className="pointer-events-auto mx-0.5 truncate rounded px-1 py-0.5 text-left text-[10px] font-semibold" style={{ gridColumn: `${span.start + 1} / ${span.end + 2}`, gridRow: span.lane + 1, backgroundColor: `${color}22`, color, borderLeft: `3px solid ${color}`, }} > {span.event.value.name} </button> ); })} </div> </div> </div>
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto flex relative" > <div className="w-12 sm:w-14 flex-shrink-0 border-r border-slate-200 dark:border-slate-800 select-none py-1 bg-white dark:bg-slate-900"> {HOURS.map((hour) => ( <div key={hour} className="h-16 text-right pr-2 text-[10px] text-slate-400 dark:text-slate-500 font-mono -translate-y-2" > {hour === 0 ? "" : `${String(hour).padStart(2, "0")}:00`} </div> ))} </div>
<div className="flex-1 grid grid-cols-7 divide-x divide-slate-100 dark:divide-slate-800/60 relative"> {days.map((day) => { const dayIsToday = isToday(day); const dayEvents = getEventsForDay(day).filter( (event) => !weekSpans.some((span) => span.event.uri === event.uri), ); const dayKey = format(day, "yyyy-MM-dd");
return ( <div key={day.toISOString()} data-testid={`week-timeline-${dayKey}`} className="relative h-[1536px] bg-white dark:bg-slate-900 transition-colors" onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; }} onDrop={(e) => handleDropOnColumn(e, day)} > {HOURS.map((hour) => { const isSlotDragOver = dragOverSlot?.dayKey === dayKey && dragOverSlot?.hour === hour;
return ( <div key={hour} data-testid={`week-slot-${dayKey}-${hour}`} className={`h-16 border-b border-slate-100 dark:border-slate-800/60 cursor-pointer transition-colors relative ${ isSlotDragOver ? "bg-primary-100/70 dark:bg-primary-900/40 ring-2 ring-inset ring-primary-500 z-10" : "hover:bg-slate-50/60 dark:hover:bg-slate-800/30" }`} onClick={() => { const newDate = new Date(day); newDate.setHours(hour, 0, 0, 0); onCreateEventOnDate(newDate); }} onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; if ( dragOverSlot?.dayKey !== dayKey || dragOverSlot?.hour !== hour ) { setDragOverSlot({ dayKey, hour }); } }} onDragEnter={(e) => { e.preventDefault(); setDragOverSlot({ dayKey, hour }); }} onDragLeave={(e) => { if ( e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node) ) { setDragOverSlot((prev) => prev && prev.dayKey === dayKey && prev.hour === hour ? null : prev, ); } }} onDrop={(e) => handleDropOnSlot(e, day, hour)} > {isSlotDragOver && ( <div className="absolute top-1 left-2 pointer-events-none text-[10px] font-mono font-bold text-primary-700 dark:text-primary-300 select-none"> {String(hour).padStart(2, "0")}:00 </div> )} </div> ); })}
{dayIsToday && ( <div className="absolute left-0 right-0 z-20 pointer-events-none flex items-center" style={{ top: `${currentIndicatorTop}px` }} > <div className="w-2 h-2 rounded-full bg-red-500 -ml-1 shadow-sm" /> <div className="flex-1 h-0.5 bg-red-500/80 shadow-xs" /> </div> )}
{dayEvents.map((event) => { try { if (isAllDayEvent(event.value)) return null; const segment = getEventDaySegment(event.value, day); if (!segment) return null; const startMinutes = segment.startMinute; const segmentMinutes = segment.endMinute - segment.startMinute; const durationMinutes = Math.min( 1440 - startMinutes, Math.max(25, segmentMinutes), );
const topPx = (startMinutes / 60) * HOUR_HEIGHT; const heightPx = (durationMinutes / 60) * HOUR_HEIGHT;
const isVirtual = event.value.mode === "community.lexicon.calendar.event#virtual"; const isOwner = event.repo === currentDid; const eventColor = event.calendarColor || space.color; const eventLabel = `${event.value.name}${event.calendarName ? `, ${event.calendarName}` : ""}${event.hasConflict ? ", Overlapping event" : ""}`; const isBeingDragged = draggingEventUri === event.uri;
return ( <div key={event.uri} data-testid={`week-event-${event.rkey}-${dayKey}`} draggable={isOwner} onDragStart={(e) => { if (!isOwner) return; e.stopPropagation(); e.dataTransfer.setData( "text/plain", JSON.stringify({ uri: event.uri, sourceDate: format(day, "yyyy-MM-dd"), }), ); e.dataTransfer.effectAllowed = "move"; setDraggingEventUri(event.uri); }} onDragEnd={() => { setDraggingEventUri(null); setDragOverSlot(null); }} onClick={(e) => { e.stopPropagation(); onSelectEvent(event); }} onKeyDown={(e) => { if ( !e.metaKey && !e.ctrlKey && !e.altKey && (e.key === "Enter" || e.key === " ") ) { e.preventDefault(); onSelectEvent(event); } }} role="button" tabIndex={0} aria-label={eventLabel} title={ event.calendarName ? `${event.value.name} · ${event.calendarName}` : event.value.name } style={{ top: `${topPx}px`, height: `${Math.max(1, heightPx - 2)}px`, backgroundColor: `${eventColor}22`, borderLeftColor: eventColor, }} className={`absolute inset-x-0.5 sm:inset-x-1 rounded-lg border-l-4 p-1 overflow-hidden shadow-sm hover:shadow-md transition group z-10 hover:brightness-95 ${ isBeingDragged ? "opacity-40 cursor-grabbing" : draggingEventUri ? "pointer-events-none opacity-60" : isOwner ? "cursor-grab active:cursor-grabbing" : "cursor-pointer" }`} > <div className="flex items-center gap-1 text-[11px] font-semibold text-slate-900 dark:text-white truncate"> {isVirtual ? ( <Video className="w-2.5 h-2.5 flex-shrink-0 opacity-70" /> ) : ( <MapPin className="w-2.5 h-2.5 flex-shrink-0 opacity-70" /> )} <span className="truncate">{event.value.name}</span> {event.hasConflict && ( <span className="text-amber-700 dark:text-amber-300 flex-shrink-0" title="Overlapping event" aria-label="Overlapping event" > ! </span> )} </div>
<div className="text-[10px] text-slate-500 dark:text-slate-400 font-mono flex items-center gap-1 mt-0.5"> <Clock className="w-2.5 h-2.5 opacity-70 flex-shrink-0" /> <span>{getEventTimeLabel(event.value, day)}</span> </div>
{event.value.description && heightPx > 60 && ( <div className="text-[10px] text-slate-600 dark:text-slate-400 truncate mt-1 line-clamp-2"> {event.value.description} </div> )} </div> ); } catch { return null; } })} </div> ); })} </div> </div> </div> );};