458 lines
12 KiB
TypeScript
458 lines
12 KiB
TypeScript
// Calendar Event Shape - Individual event cards spawned on the canvas
|
|
// Similar to FathomNoteShape but for calendar events
|
|
|
|
import {
|
|
BaseBoxShapeUtil,
|
|
HTMLContainer,
|
|
TLBaseShape,
|
|
createShapeId,
|
|
} from "tldraw"
|
|
import React from "react"
|
|
import type { DecryptedCalendarEvent } from "@/hooks/useCalendarEvents"
|
|
|
|
type ICalendarEventShape = TLBaseShape<
|
|
"CalendarEvent",
|
|
{
|
|
w: number
|
|
h: number
|
|
// Event data
|
|
eventId: string
|
|
calendarId: string
|
|
summary: string
|
|
description: string | null
|
|
location: string | null
|
|
startTime: number // timestamp
|
|
endTime: number // timestamp
|
|
isAllDay: boolean
|
|
timezone: string
|
|
meetingLink: string | null
|
|
// Visual
|
|
primaryColor: string
|
|
tags: string[]
|
|
}
|
|
>
|
|
|
|
export class CalendarEventShape extends BaseBoxShapeUtil<ICalendarEventShape> {
|
|
static override type = "CalendarEvent" as const
|
|
|
|
// Calendar theme color: Green
|
|
static readonly PRIMARY_COLOR = "#22c55e"
|
|
|
|
getDefaultProps(): ICalendarEventShape["props"] {
|
|
return {
|
|
w: 350,
|
|
h: 250,
|
|
eventId: "",
|
|
calendarId: "",
|
|
summary: "Untitled Event",
|
|
description: null,
|
|
location: null,
|
|
startTime: Date.now(),
|
|
endTime: Date.now() + 3600000, // 1 hour later
|
|
isAllDay: false,
|
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
meetingLink: null,
|
|
primaryColor: CalendarEventShape.PRIMARY_COLOR,
|
|
tags: ["calendar", "event"],
|
|
}
|
|
}
|
|
|
|
override canResize() {
|
|
return true
|
|
}
|
|
|
|
// Factory method to create from DecryptedCalendarEvent
|
|
static createFromEvent(
|
|
event: DecryptedCalendarEvent,
|
|
x: number,
|
|
y: number,
|
|
primaryColor: string = CalendarEventShape.PRIMARY_COLOR
|
|
) {
|
|
return {
|
|
id: createShapeId(`calendar-event-${event.id}`),
|
|
type: "CalendarEvent" as const,
|
|
x,
|
|
y,
|
|
props: {
|
|
w: 350,
|
|
h: 250,
|
|
eventId: event.id,
|
|
calendarId: event.calendarId,
|
|
summary: event.summary,
|
|
description: event.description,
|
|
location: event.location,
|
|
startTime: event.startTime.getTime(),
|
|
endTime: event.endTime.getTime(),
|
|
isAllDay: event.isAllDay,
|
|
timezone: event.timezone,
|
|
meetingLink: event.meetingLink,
|
|
primaryColor,
|
|
tags: ["calendar", "event"],
|
|
},
|
|
}
|
|
}
|
|
|
|
component(shape: ICalendarEventShape) {
|
|
const { props } = shape
|
|
const { w, h } = props
|
|
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
|
|
|
// Detect dark mode
|
|
const isDarkMode =
|
|
typeof document !== "undefined" &&
|
|
document.documentElement.classList.contains("dark")
|
|
|
|
// Format date and time
|
|
const formatDateTime = (timestamp: number, isAllDay: boolean) => {
|
|
const date = new Date(timestamp)
|
|
const dateStr = date.toLocaleDateString("en-US", {
|
|
weekday: "short",
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
})
|
|
|
|
if (isAllDay) {
|
|
return dateStr
|
|
}
|
|
|
|
const timeStr = date.toLocaleTimeString("en-US", {
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
hour12: true,
|
|
})
|
|
|
|
return `${dateStr} at ${timeStr}`
|
|
}
|
|
|
|
// Format time range
|
|
const formatTimeRange = () => {
|
|
if (props.isAllDay) {
|
|
const startDate = new Date(props.startTime)
|
|
const endDate = new Date(props.endTime)
|
|
|
|
// Check if same day
|
|
if (startDate.toDateString() === endDate.toDateString()) {
|
|
return `All day - ${startDate.toLocaleDateString("en-US", {
|
|
weekday: "long",
|
|
month: "long",
|
|
day: "numeric",
|
|
})}`
|
|
}
|
|
|
|
return `${startDate.toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
})} - ${endDate.toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
})}`
|
|
}
|
|
|
|
const startDate = new Date(props.startTime)
|
|
const endDate = new Date(props.endTime)
|
|
|
|
const startTimeStr = startDate.toLocaleTimeString("en-US", {
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
hour12: true,
|
|
})
|
|
|
|
const endTimeStr = endDate.toLocaleTimeString("en-US", {
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
hour12: true,
|
|
})
|
|
|
|
const dateStr = startDate.toLocaleDateString("en-US", {
|
|
weekday: "long",
|
|
month: "long",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
})
|
|
|
|
return `${dateStr}\n${startTimeStr} - ${endTimeStr}`
|
|
}
|
|
|
|
// Calculate duration
|
|
const getDuration = () => {
|
|
const durationMs = props.endTime - props.startTime
|
|
const hours = Math.floor(durationMs / (1000 * 60 * 60))
|
|
const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60))
|
|
|
|
if (hours === 0) {
|
|
return `${minutes}m`
|
|
}
|
|
if (minutes === 0) {
|
|
return `${hours}h`
|
|
}
|
|
return `${hours}h ${minutes}m`
|
|
}
|
|
|
|
const colors = isDarkMode
|
|
? {
|
|
bg: "#1a1a1a",
|
|
headerBg: props.primaryColor,
|
|
text: "#e4e4e7",
|
|
textMuted: "#a1a1aa",
|
|
border: "#404040",
|
|
linkBg: "#3b82f6",
|
|
}
|
|
: {
|
|
bg: "#ffffff",
|
|
headerBg: props.primaryColor,
|
|
text: "#1f2937",
|
|
textMuted: "#6b7280",
|
|
border: "#e5e7eb",
|
|
linkBg: "#3b82f6",
|
|
}
|
|
|
|
const handleMeetingLinkClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
if (props.meetingLink) {
|
|
window.open(props.meetingLink, "_blank", "noopener,noreferrer")
|
|
}
|
|
}
|
|
|
|
return (
|
|
<HTMLContainer
|
|
style={{
|
|
width: w,
|
|
height: h,
|
|
pointerEvents: "all",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
backgroundColor: colors.bg,
|
|
borderRadius: "12px",
|
|
border: `2px solid ${colors.border}`,
|
|
boxShadow: isSelected
|
|
? `0 0 0 3px ${props.primaryColor}40`
|
|
: "0 4px 12px rgba(0, 0, 0, 0.1)",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
overflow: "hidden",
|
|
transition: "box-shadow 0.15s ease",
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
style={{
|
|
backgroundColor: colors.headerBg,
|
|
padding: "12px 16px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "8px",
|
|
}}
|
|
>
|
|
<span style={{ fontSize: "18px" }}>
|
|
{props.isAllDay ? "📅" : "🕐"}
|
|
</span>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div
|
|
style={{
|
|
fontSize: "14px",
|
|
fontWeight: "600",
|
|
color: "#ffffff",
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
}}
|
|
>
|
|
{props.summary}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: "11px",
|
|
color: "rgba(255, 255, 255, 0.8)",
|
|
marginTop: "2px",
|
|
}}
|
|
>
|
|
{props.isAllDay ? "All Day" : getDuration()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
padding: "16px",
|
|
overflow: "auto",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "12px",
|
|
}}
|
|
>
|
|
{/* Time */}
|
|
<div>
|
|
<div
|
|
style={{
|
|
fontSize: "10px",
|
|
fontWeight: "600",
|
|
color: colors.textMuted,
|
|
textTransform: "uppercase",
|
|
letterSpacing: "0.5px",
|
|
marginBottom: "4px",
|
|
}}
|
|
>
|
|
When
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: "13px",
|
|
color: colors.text,
|
|
whiteSpace: "pre-line",
|
|
}}
|
|
>
|
|
{formatTimeRange()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Location */}
|
|
{props.location && (
|
|
<div>
|
|
<div
|
|
style={{
|
|
fontSize: "10px",
|
|
fontWeight: "600",
|
|
color: colors.textMuted,
|
|
textTransform: "uppercase",
|
|
letterSpacing: "0.5px",
|
|
marginBottom: "4px",
|
|
}}
|
|
>
|
|
Location
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: "13px",
|
|
color: colors.text,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "6px",
|
|
}}
|
|
>
|
|
<span>📍</span>
|
|
<span
|
|
style={{
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{props.location}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Description */}
|
|
{props.description && (
|
|
<div style={{ flex: 1, minHeight: 0 }}>
|
|
<div
|
|
style={{
|
|
fontSize: "10px",
|
|
fontWeight: "600",
|
|
color: colors.textMuted,
|
|
textTransform: "uppercase",
|
|
letterSpacing: "0.5px",
|
|
marginBottom: "4px",
|
|
}}
|
|
>
|
|
Description
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: "12px",
|
|
color: colors.text,
|
|
lineHeight: "1.5",
|
|
overflow: "auto",
|
|
maxHeight: "80px",
|
|
}}
|
|
>
|
|
{props.description}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Meeting Link */}
|
|
{props.meetingLink && (
|
|
<button
|
|
onClick={handleMeetingLinkClick}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
style={{
|
|
marginTop: "auto",
|
|
padding: "10px 16px",
|
|
backgroundColor: colors.linkBg,
|
|
color: "#ffffff",
|
|
border: "none",
|
|
borderRadius: "8px",
|
|
fontSize: "13px",
|
|
fontWeight: "600",
|
|
cursor: "pointer",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: "8px",
|
|
transition: "background-color 0.15s ease",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.backgroundColor = "#2563eb"
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.backgroundColor = colors.linkBg
|
|
}}
|
|
>
|
|
<span>🔗</span>
|
|
Join Meeting
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer with tags */}
|
|
<div
|
|
style={{
|
|
padding: "8px 16px",
|
|
borderTop: `1px solid ${colors.border}`,
|
|
display: "flex",
|
|
gap: "6px",
|
|
flexWrap: "wrap",
|
|
}}
|
|
>
|
|
{props.tags.map((tag, i) => (
|
|
<span
|
|
key={i}
|
|
style={{
|
|
fontSize: "10px",
|
|
padding: "3px 8px",
|
|
backgroundColor: isDarkMode ? "#374151" : "#f3f4f6",
|
|
color: colors.textMuted,
|
|
borderRadius: "4px",
|
|
}}
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</HTMLContainer>
|
|
)
|
|
}
|
|
|
|
indicator(shape: ICalendarEventShape) {
|
|
return (
|
|
<rect
|
|
width={shape.props.w}
|
|
height={shape.props.h}
|
|
rx={12}
|
|
ry={12}
|
|
/>
|
|
)
|
|
}
|
|
}
|