feat: add unified calendar tool with switchable views
- Add CalendarShapeUtil with view tabs (Browser/Widget/Year) - Add CalendarTool for placing calendar on canvas - Add CalendarEventShapeUtil for spawning event cards - Add CalendarPanel component with month/week views - Add YearViewPanel component with 12-month grid - Add useCalendarEvents hook for fetching encrypted calendar data - Single keyboard shortcut (Ctrl+Alt+K) with in-shape view switching - Auto-resize when switching between views 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9f2cc9267e
commit
4bf46a34e6
|
|
@ -0,0 +1,670 @@
|
|||
// Calendar Panel - Month/Week view with event list
|
||||
// Used inside CalendarBrowserShape
|
||||
|
||||
import React, { useState, useMemo, useCallback } from "react"
|
||||
import {
|
||||
useCalendarEvents,
|
||||
type DecryptedCalendarEvent,
|
||||
} from "@/hooks/useCalendarEvents"
|
||||
|
||||
interface CalendarPanelProps {
|
||||
onClose?: () => void
|
||||
onEventSelect?: (event: DecryptedCalendarEvent) => void
|
||||
shapeMode?: boolean
|
||||
initialView?: "month" | "week"
|
||||
initialDate?: Date
|
||||
}
|
||||
|
||||
type ViewMode = "month" | "week"
|
||||
|
||||
// Helper functions
|
||||
const getDaysInMonth = (year: number, month: number) => {
|
||||
return new Date(year, month + 1, 0).getDate()
|
||||
}
|
||||
|
||||
const getFirstDayOfMonth = (year: number, month: number) => {
|
||||
const day = new Date(year, month, 1).getDay()
|
||||
// Convert Sunday (0) to 7 for Monday-first week
|
||||
return day === 0 ? 6 : day - 1
|
||||
}
|
||||
|
||||
const formatMonthYear = (date: Date) => {
|
||||
return date.toLocaleDateString("en-US", { month: "long", year: "numeric" })
|
||||
}
|
||||
|
||||
const isSameDay = (date1: Date, date2: Date) => {
|
||||
return (
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate()
|
||||
)
|
||||
}
|
||||
|
||||
const isToday = (date: Date) => {
|
||||
return isSameDay(date, new Date())
|
||||
}
|
||||
|
||||
export function CalendarPanel({
|
||||
onClose,
|
||||
onEventSelect,
|
||||
shapeMode = false,
|
||||
initialView = "month",
|
||||
initialDate,
|
||||
}: CalendarPanelProps) {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(initialView)
|
||||
const [currentDate, setCurrentDate] = useState(initialDate || new Date())
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
|
||||
|
||||
// Detect dark mode
|
||||
const isDarkMode =
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.classList.contains("dark")
|
||||
|
||||
// Get calendar events for the visible range
|
||||
const startOfVisibleRange = useMemo(() => {
|
||||
const year = currentDate.getFullYear()
|
||||
const month = currentDate.getMonth()
|
||||
// Start from previous month to show leading days
|
||||
return new Date(year, month - 1, 1)
|
||||
}, [currentDate])
|
||||
|
||||
const endOfVisibleRange = useMemo(() => {
|
||||
const year = currentDate.getFullYear()
|
||||
const month = currentDate.getMonth()
|
||||
// End at next month to show trailing days
|
||||
return new Date(year, month + 2, 0)
|
||||
}, [currentDate])
|
||||
|
||||
const { events, loading, error, refresh, getEventsForDate, getUpcoming } =
|
||||
useCalendarEvents({
|
||||
startDate: startOfVisibleRange,
|
||||
endDate: endOfVisibleRange,
|
||||
})
|
||||
|
||||
// Colors
|
||||
const colors = isDarkMode
|
||||
? {
|
||||
bg: "#1a1a1a",
|
||||
cardBg: "#252525",
|
||||
headerBg: "#22c55e",
|
||||
text: "#e4e4e7",
|
||||
textMuted: "#a1a1aa",
|
||||
border: "#404040",
|
||||
todayBg: "#22c55e20",
|
||||
selectedBg: "#3b82f620",
|
||||
eventDot: "#3b82f6",
|
||||
buttonBg: "#374151",
|
||||
buttonHover: "#4b5563",
|
||||
}
|
||||
: {
|
||||
bg: "#ffffff",
|
||||
cardBg: "#f9fafb",
|
||||
headerBg: "#22c55e",
|
||||
text: "#1f2937",
|
||||
textMuted: "#6b7280",
|
||||
border: "#e5e7eb",
|
||||
todayBg: "#22c55e15",
|
||||
selectedBg: "#3b82f615",
|
||||
eventDot: "#3b82f6",
|
||||
buttonBg: "#f3f4f6",
|
||||
buttonHover: "#e5e7eb",
|
||||
}
|
||||
|
||||
// Navigation handlers
|
||||
const goToPrevious = () => {
|
||||
if (viewMode === "month") {
|
||||
setCurrentDate(
|
||||
new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1)
|
||||
)
|
||||
} else {
|
||||
const newDate = new Date(currentDate)
|
||||
newDate.setDate(newDate.getDate() - 7)
|
||||
setCurrentDate(newDate)
|
||||
}
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
if (viewMode === "month") {
|
||||
setCurrentDate(
|
||||
new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1)
|
||||
)
|
||||
} else {
|
||||
const newDate = new Date(currentDate)
|
||||
newDate.setDate(newDate.getDate() + 7)
|
||||
setCurrentDate(newDate)
|
||||
}
|
||||
}
|
||||
|
||||
const goToToday = () => {
|
||||
setCurrentDate(new Date())
|
||||
setSelectedDate(new Date())
|
||||
}
|
||||
|
||||
// Generate month grid
|
||||
const monthGrid = useMemo(() => {
|
||||
const year = currentDate.getFullYear()
|
||||
const month = currentDate.getMonth()
|
||||
const daysInMonth = getDaysInMonth(year, month)
|
||||
const firstDay = getFirstDayOfMonth(year, month)
|
||||
|
||||
const days: { date: Date; isCurrentMonth: boolean }[] = []
|
||||
|
||||
// Previous month days
|
||||
const prevMonth = month === 0 ? 11 : month - 1
|
||||
const prevYear = month === 0 ? year - 1 : year
|
||||
const daysInPrevMonth = getDaysInMonth(prevYear, prevMonth)
|
||||
for (let i = firstDay - 1; i >= 0; i--) {
|
||||
days.push({
|
||||
date: new Date(prevYear, prevMonth, daysInPrevMonth - i),
|
||||
isCurrentMonth: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Current month days
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push({
|
||||
date: new Date(year, month, i),
|
||||
isCurrentMonth: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Next month days to complete grid
|
||||
const nextMonth = month === 11 ? 0 : month + 1
|
||||
const nextYear = month === 11 ? year + 1 : year
|
||||
const remainingDays = 42 - days.length // 6 rows * 7 days
|
||||
for (let i = 1; i <= remainingDays; i++) {
|
||||
days.push({
|
||||
date: new Date(nextYear, nextMonth, i),
|
||||
isCurrentMonth: false,
|
||||
})
|
||||
}
|
||||
|
||||
return days
|
||||
}, [currentDate])
|
||||
|
||||
// Format event time
|
||||
const formatEventTime = (event: DecryptedCalendarEvent) => {
|
||||
if (event.isAllDay) return "All day"
|
||||
return event.startTime.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Upcoming events for sidebar
|
||||
const upcomingEvents = useMemo(() => {
|
||||
return getUpcoming(10)
|
||||
}, [getUpcoming])
|
||||
|
||||
// Events for selected date
|
||||
const selectedDateEvents = useMemo(() => {
|
||||
if (!selectedDate) return []
|
||||
return getEventsForDate(selectedDate)
|
||||
}, [selectedDate, getEventsForDate])
|
||||
|
||||
// Day cell component
|
||||
const DayCell = ({
|
||||
date,
|
||||
isCurrentMonth,
|
||||
}: {
|
||||
date: Date
|
||||
isCurrentMonth: boolean
|
||||
}) => {
|
||||
const dayEvents = getEventsForDate(date)
|
||||
const isSelectedDate = selectedDate && isSameDay(date, selectedDate)
|
||||
const isTodayDate = isToday(date)
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => setSelectedDate(date)}
|
||||
style={{
|
||||
padding: "4px",
|
||||
minHeight: "60px",
|
||||
cursor: "pointer",
|
||||
backgroundColor: isSelectedDate
|
||||
? colors.selectedBg
|
||||
: isTodayDate
|
||||
? colors.todayBg
|
||||
: "transparent",
|
||||
borderRadius: "4px",
|
||||
border: isTodayDate
|
||||
? `2px solid ${colors.headerBg}`
|
||||
: "1px solid transparent",
|
||||
transition: "background-color 0.15s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelectedDate && !isTodayDate) {
|
||||
e.currentTarget.style.backgroundColor = colors.buttonBg
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelectedDate && !isTodayDate) {
|
||||
e.currentTarget.style.backgroundColor = "transparent"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: isTodayDate ? "700" : "500",
|
||||
color: isCurrentMonth ? colors.text : colors.textMuted,
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
{date.getDate()}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "2px" }}>
|
||||
{dayEvents.slice(0, 3).map((event, i) => (
|
||||
<div
|
||||
key={event.id}
|
||||
style={{
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: colors.eventDot,
|
||||
}}
|
||||
title={event.summary}
|
||||
/>
|
||||
))}
|
||||
{dayEvents.length > 3 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "9px",
|
||||
color: colors.textMuted,
|
||||
}}
|
||||
>
|
||||
+{dayEvents.length - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Event list item
|
||||
const EventItem = ({ event }: { event: DecryptedCalendarEvent }) => (
|
||||
<div
|
||||
onClick={() => onEventSelect?.(event)}
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
backgroundColor: colors.cardBg,
|
||||
borderRadius: "8px",
|
||||
cursor: "pointer",
|
||||
borderLeft: `3px solid ${colors.eventDot}`,
|
||||
transition: "background-color 0.15s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = colors.buttonBg
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = colors.cardBg
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
fontWeight: "600",
|
||||
color: colors.text,
|
||||
marginBottom: "4px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{event.summary}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: colors.textMuted,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<span>{formatEventTime(event)}</span>
|
||||
{event.location && (
|
||||
<>
|
||||
<span>|</span>
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{event.location}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
color: colors.textMuted,
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<div style={{ fontSize: "24px", marginBottom: "8px" }}>Loading...</div>
|
||||
<div style={{ fontSize: "12px" }}>Fetching calendar events</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
color: colors.textMuted,
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center", padding: "20px" }}>
|
||||
<div style={{ fontSize: "24px", marginBottom: "8px" }}>Error</div>
|
||||
<div style={{ fontSize: "12px", marginBottom: "16px" }}>{error}</div>
|
||||
<button
|
||||
onClick={refresh}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
backgroundColor: colors.headerBg,
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// No events state
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
color: colors.textMuted,
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center", padding: "20px" }}>
|
||||
<div style={{ fontSize: "48px", marginBottom: "16px" }}>📅</div>
|
||||
<div style={{ fontSize: "16px", fontWeight: "600", marginBottom: "8px" }}>
|
||||
No Calendar Events
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", marginBottom: "16px" }}>
|
||||
Import your Google Calendar to see events here.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
backgroundColor: colors.bg,
|
||||
color: colors.text,
|
||||
}}
|
||||
>
|
||||
{/* Main Calendar Area */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRight: `1px solid ${colors.border}`,
|
||||
}}
|
||||
>
|
||||
{/* Navigation Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
backgroundColor: colors.buttonBg,
|
||||
color: colors.text,
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
<
|
||||
</button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: "center",
|
||||
fontSize: "16px",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
{formatMonthYear(currentDate)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={goToNext}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
backgroundColor: colors.buttonBg,
|
||||
color: colors.text,
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goToToday}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
backgroundColor: colors.headerBg,
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
|
||||
{/* View toggle */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
backgroundColor: colors.buttonBg,
|
||||
borderRadius: "6px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setViewMode("month")}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
backgroundColor:
|
||||
viewMode === "month" ? colors.headerBg : "transparent",
|
||||
color: viewMode === "month" ? "#fff" : colors.text,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
Month
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("week")}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
backgroundColor:
|
||||
viewMode === "week" ? colors.headerBg : "transparent",
|
||||
color: viewMode === "week" ? "#fff" : colors.text,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
Week
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div style={{ flex: 1, padding: "12px", overflow: "auto" }}>
|
||||
{/* Day headers */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: "4px",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: "11px",
|
||||
fontWeight: "600",
|
||||
color: colors.textMuted,
|
||||
padding: "4px",
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day cells */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
{monthGrid.map(({ date, isCurrentMonth }, i) => (
|
||||
<DayCell key={i} date={date} isCurrentMonth={isCurrentMonth} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - Events */}
|
||||
<div
|
||||
style={{
|
||||
width: "280px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Selected Date Events or Upcoming */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "12px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "600",
|
||||
color: colors.textMuted,
|
||||
marginBottom: "12px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.5px",
|
||||
}}
|
||||
>
|
||||
{selectedDate
|
||||
? selectedDate.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
: "Upcoming Events"}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{(selectedDate ? selectedDateEvents : upcomingEvents).map(
|
||||
(event) => (
|
||||
<EventItem key={event.id} event={event} />
|
||||
)
|
||||
)}
|
||||
|
||||
{(selectedDate ? selectedDateEvents : upcomingEvents).length ===
|
||||
0 && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "20px",
|
||||
color: colors.textMuted,
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
{selectedDate
|
||||
? "No events on this day"
|
||||
: "No upcoming events"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Click hint */}
|
||||
{onEventSelect && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px",
|
||||
borderTop: `1px solid ${colors.border}`,
|
||||
fontSize: "11px",
|
||||
color: colors.textMuted,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Click an event to add it to the canvas
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CalendarPanel
|
||||
|
|
@ -0,0 +1,420 @@
|
|||
// Year View Panel - KalNext-style 12-month yearly overview
|
||||
// Shows all months in a 4x3 grid with event density indicators
|
||||
|
||||
import React, { useState, useMemo } from "react"
|
||||
import { useCalendarEvents, type DecryptedCalendarEvent } from "@/hooks/useCalendarEvents"
|
||||
|
||||
interface YearViewPanelProps {
|
||||
onClose?: () => void
|
||||
onMonthSelect?: (year: number, month: number) => void
|
||||
shapeMode?: boolean
|
||||
initialYear?: number
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
const getDaysInMonth = (year: number, month: number) => {
|
||||
return new Date(year, month + 1, 0).getDate()
|
||||
}
|
||||
|
||||
const getFirstDayOfMonth = (year: number, month: number) => {
|
||||
const day = new Date(year, month, 1).getDay()
|
||||
return day === 0 ? 6 : day - 1 // Monday-first
|
||||
}
|
||||
|
||||
const isSameDay = (date1: Date, date2: Date) => {
|
||||
return (
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate()
|
||||
)
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"January", "February", "March", "April",
|
||||
"May", "June", "July", "August",
|
||||
"September", "October", "November", "December"
|
||||
]
|
||||
|
||||
const SHORT_MONTH_NAMES = [
|
||||
"Jan", "Feb", "Mar", "Apr",
|
||||
"May", "Jun", "Jul", "Aug",
|
||||
"Sep", "Oct", "Nov", "Dec"
|
||||
]
|
||||
|
||||
export const YearViewPanel: React.FC<YearViewPanelProps> = ({
|
||||
onClose,
|
||||
onMonthSelect,
|
||||
shapeMode = false,
|
||||
initialYear,
|
||||
}) => {
|
||||
const [currentYear, setCurrentYear] = useState(initialYear || new Date().getFullYear())
|
||||
|
||||
// Detect dark mode
|
||||
const isDarkMode =
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.classList.contains("dark")
|
||||
|
||||
// Fetch all events for the current year
|
||||
const yearStart = new Date(currentYear, 0, 1)
|
||||
const yearEnd = new Date(currentYear, 11, 31, 23, 59, 59)
|
||||
|
||||
const { events, loading, getEventsForDate } = useCalendarEvents({
|
||||
startDate: yearStart,
|
||||
endDate: yearEnd,
|
||||
})
|
||||
|
||||
// Colors
|
||||
const colors = isDarkMode
|
||||
? {
|
||||
bg: "#1f2937",
|
||||
text: "#e4e4e7",
|
||||
textMuted: "#a1a1aa",
|
||||
border: "#374151",
|
||||
monthBg: "#252525",
|
||||
todayBg: "#22c55e30",
|
||||
todayBorder: "#22c55e",
|
||||
eventDot1: "#3b82f620", // 1 event
|
||||
eventDot2: "#3b82f640", // 2 events
|
||||
eventDot3: "#3b82f680", // 3+ events
|
||||
eventDotMax: "#3b82f6", // 5+ events
|
||||
headerBg: "#22c55e",
|
||||
}
|
||||
: {
|
||||
bg: "#f9fafb",
|
||||
text: "#1f2937",
|
||||
textMuted: "#6b7280",
|
||||
border: "#e5e7eb",
|
||||
monthBg: "#ffffff",
|
||||
todayBg: "#22c55e20",
|
||||
todayBorder: "#22c55e",
|
||||
eventDot1: "#3b82f620",
|
||||
eventDot2: "#3b82f640",
|
||||
eventDot3: "#3b82f680",
|
||||
eventDotMax: "#3b82f6",
|
||||
headerBg: "#22c55e",
|
||||
}
|
||||
|
||||
// Get event count for a specific date
|
||||
const getEventCount = (date: Date) => {
|
||||
return getEventsForDate(date).length
|
||||
}
|
||||
|
||||
// Get background color based on event density
|
||||
const getEventDensityColor = (count: number) => {
|
||||
if (count === 0) return "transparent"
|
||||
if (count === 1) return colors.eventDot1
|
||||
if (count === 2) return colors.eventDot2
|
||||
if (count <= 4) return colors.eventDot3
|
||||
return colors.eventDotMax
|
||||
}
|
||||
|
||||
// Navigation
|
||||
const goToPrevYear = () => setCurrentYear((y) => y - 1)
|
||||
const goToNextYear = () => setCurrentYear((y) => y + 1)
|
||||
const goToCurrentYear = () => setCurrentYear(new Date().getFullYear())
|
||||
|
||||
const today = new Date()
|
||||
|
||||
// Generate mini calendar for a month
|
||||
const renderMiniMonth = (month: number) => {
|
||||
const daysInMonth = getDaysInMonth(currentYear, month)
|
||||
const firstDay = getFirstDayOfMonth(currentYear, month)
|
||||
|
||||
const days: { day: number | null; date: Date | null }[] = []
|
||||
|
||||
// Leading empty cells
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push({ day: null, date: null })
|
||||
}
|
||||
|
||||
// Days of month
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push({ day: i, date: new Date(currentYear, month, i) })
|
||||
}
|
||||
|
||||
// Trailing empty cells to complete grid (6 rows max)
|
||||
while (days.length < 42) {
|
||||
days.push({ day: null, date: null })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={month}
|
||||
style={{
|
||||
backgroundColor: colors.monthBg,
|
||||
borderRadius: "8px",
|
||||
padding: "8px",
|
||||
border: `1px solid ${colors.border}`,
|
||||
cursor: onMonthSelect ? "pointer" : "default",
|
||||
}}
|
||||
onClick={() => onMonthSelect?.(currentYear, month)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Month name */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
fontWeight: "600",
|
||||
color: colors.text,
|
||||
marginBottom: "6px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{SHORT_MONTH_NAMES[month]}
|
||||
</div>
|
||||
|
||||
{/* Day headers */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: "1px",
|
||||
marginBottom: "2px",
|
||||
}}
|
||||
>
|
||||
{["M", "T", "W", "T", "F", "S", "S"].map((day, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: "7px",
|
||||
fontWeight: "500",
|
||||
color: colors.textMuted,
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Days grid */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: "1px",
|
||||
}}
|
||||
>
|
||||
{days.slice(0, 42).map(({ day, date }, i) => {
|
||||
const isToday = date && isSameDay(date, today)
|
||||
const eventCount = date ? getEventCount(date) : 0
|
||||
const densityColor = getEventDensityColor(eventCount)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: "8px",
|
||||
padding: "2px 0",
|
||||
borderRadius: "2px",
|
||||
backgroundColor: isToday ? colors.todayBg : densityColor,
|
||||
border: isToday ? `1px solid ${colors.todayBorder}` : "1px solid transparent",
|
||||
color: day ? colors.text : "transparent",
|
||||
fontWeight: isToday ? "700" : "400",
|
||||
minHeight: "14px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: colors.bg,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Header with year navigation */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
backgroundColor: colors.headerBg,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={goToPrevYear}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.2)",
|
||||
border: "none",
|
||||
color: "#fff",
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
fontWeight: "600",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<
|
||||
</button>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
fontWeight: "700",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{currentYear}
|
||||
</span>
|
||||
{currentYear !== new Date().getFullYear() && (
|
||||
<button
|
||||
onClick={goToCurrentYear}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.2)",
|
||||
border: "none",
|
||||
color: "#fff",
|
||||
padding: "4px 10px",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "11px",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={goToNextYear}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.2)",
|
||||
border: "none",
|
||||
color: "#fff",
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
fontWeight: "600",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 12-month grid (4x3 layout) */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "12px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
color: colors.textMuted,
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
Loading calendar data...
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gridTemplateRows: "repeat(3, 1fr)",
|
||||
gap: "10px",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 12 }, (_, month) => renderMiniMonth(month))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
borderTop: `1px solid ${colors.border}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<div
|
||||
style={{
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "2px",
|
||||
backgroundColor: colors.todayBg,
|
||||
border: `1px solid ${colors.todayBorder}`,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "10px", color: colors.textMuted }}>Today</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<div
|
||||
style={{
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "2px",
|
||||
backgroundColor: colors.eventDot1,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "10px", color: colors.textMuted }}>1 event</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<div
|
||||
style={{
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "2px",
|
||||
backgroundColor: colors.eventDot3,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "10px", color: colors.textMuted }}>3+ events</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<div
|
||||
style={{
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "2px",
|
||||
backgroundColor: colors.eventDotMax,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "10px", color: colors.textMuted }}>5+ events</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
// Hook for accessing decrypted calendar events from local encrypted storage
|
||||
// Uses the existing Google Data Sovereignty infrastructure
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { calendarStore } from '@/lib/google/database'
|
||||
import { deriveServiceKey, decryptDataToString, importMasterKey } from '@/lib/google/encryption'
|
||||
import { getGoogleDataService } from '@/lib/google'
|
||||
import type { EncryptedCalendarEvent } from '@/lib/google/types'
|
||||
|
||||
// Decrypted event type for display
|
||||
export interface DecryptedCalendarEvent {
|
||||
id: string
|
||||
calendarId: string
|
||||
summary: string
|
||||
description: string | null
|
||||
location: string | null
|
||||
startTime: Date
|
||||
endTime: Date
|
||||
isAllDay: boolean
|
||||
timezone: string
|
||||
isRecurring: boolean
|
||||
meetingLink: string | null
|
||||
reminders: { method: string; minutes: number }[]
|
||||
syncedAt: number
|
||||
}
|
||||
|
||||
// Hook options
|
||||
export interface UseCalendarEventsOptions {
|
||||
startDate?: Date
|
||||
endDate?: Date
|
||||
limit?: number
|
||||
calendarId?: string
|
||||
autoRefresh?: boolean
|
||||
refreshInterval?: number // in milliseconds
|
||||
}
|
||||
|
||||
// Hook return type
|
||||
export interface UseCalendarEventsResult {
|
||||
events: DecryptedCalendarEvent[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
initialized: boolean
|
||||
refresh: () => Promise<void>
|
||||
getEventsForDate: (date: Date) => DecryptedCalendarEvent[]
|
||||
getEventsForMonth: (year: number, month: number) => DecryptedCalendarEvent[]
|
||||
getEventsForWeek: (date: Date) => DecryptedCalendarEvent[]
|
||||
getUpcoming: (limit?: number) => DecryptedCalendarEvent[]
|
||||
eventCount: number
|
||||
}
|
||||
|
||||
// Helper to get start of day
|
||||
function startOfDay(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
}
|
||||
|
||||
// Helper to get end of day
|
||||
function endOfDay(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
d.setHours(23, 59, 59, 999)
|
||||
return d
|
||||
}
|
||||
|
||||
// Helper to get start of week (Monday)
|
||||
function startOfWeek(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
const day = d.getDay()
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1) // Monday as first day
|
||||
d.setDate(diff)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
}
|
||||
|
||||
// Helper to get end of week (Sunday)
|
||||
function endOfWeek(date: Date): Date {
|
||||
const start = startOfWeek(date)
|
||||
const end = new Date(start)
|
||||
end.setDate(end.getDate() + 6)
|
||||
end.setHours(23, 59, 59, 999)
|
||||
return end
|
||||
}
|
||||
|
||||
// Helper to get start of month
|
||||
function startOfMonth(year: number, month: number): Date {
|
||||
return new Date(year, month, 1, 0, 0, 0, 0)
|
||||
}
|
||||
|
||||
// Helper to get end of month
|
||||
function endOfMonth(year: number, month: number): Date {
|
||||
return new Date(year, month + 1, 0, 23, 59, 59, 999)
|
||||
}
|
||||
|
||||
// Decrypt a single event
|
||||
async function decryptEvent(
|
||||
event: EncryptedCalendarEvent,
|
||||
calendarKey: CryptoKey
|
||||
): Promise<DecryptedCalendarEvent> {
|
||||
const [summary, description, location, meetingLink] = await Promise.all([
|
||||
decryptDataToString(event.encryptedSummary, calendarKey),
|
||||
event.encryptedDescription
|
||||
? decryptDataToString(event.encryptedDescription, calendarKey)
|
||||
: Promise.resolve(null),
|
||||
event.encryptedLocation
|
||||
? decryptDataToString(event.encryptedLocation, calendarKey)
|
||||
: Promise.resolve(null),
|
||||
event.encryptedMeetingLink
|
||||
? decryptDataToString(event.encryptedMeetingLink, calendarKey)
|
||||
: Promise.resolve(null),
|
||||
])
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
calendarId: event.calendarId,
|
||||
summary,
|
||||
description,
|
||||
location,
|
||||
startTime: new Date(event.startTime),
|
||||
endTime: new Date(event.endTime),
|
||||
isAllDay: event.isAllDay,
|
||||
timezone: event.timezone,
|
||||
isRecurring: event.isRecurring,
|
||||
meetingLink,
|
||||
reminders: event.reminders || [],
|
||||
syncedAt: event.syncedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function useCalendarEvents(
|
||||
options: UseCalendarEventsOptions = {}
|
||||
): UseCalendarEventsResult {
|
||||
const {
|
||||
startDate,
|
||||
endDate,
|
||||
limit,
|
||||
calendarId,
|
||||
autoRefresh = false,
|
||||
refreshInterval = 60000, // 1 minute default
|
||||
} = options
|
||||
|
||||
const [events, setEvents] = useState<DecryptedCalendarEvent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
|
||||
// Fetch and decrypt events
|
||||
const fetchEvents = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const service = getGoogleDataService()
|
||||
|
||||
// Check if service is initialized
|
||||
if (!service.isInitialized()) {
|
||||
// Try to initialize
|
||||
const success = await service.initialize()
|
||||
if (!success) {
|
||||
setEvents([])
|
||||
setInitialized(false)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get the master key
|
||||
const masterKeyData = await service.exportKey()
|
||||
if (!masterKeyData) {
|
||||
setError('No encryption key available')
|
||||
setEvents([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Derive calendar-specific key
|
||||
const masterKey = await importMasterKey(masterKeyData)
|
||||
const calendarKey = await deriveServiceKey(masterKey, 'calendar')
|
||||
|
||||
// Determine query range
|
||||
let encryptedEvents: EncryptedCalendarEvent[]
|
||||
|
||||
if (startDate && endDate) {
|
||||
// Query by date range
|
||||
encryptedEvents = await calendarStore.getByDateRange(
|
||||
startDate.getTime(),
|
||||
endDate.getTime()
|
||||
)
|
||||
} else if (calendarId) {
|
||||
// Query by calendar ID
|
||||
encryptedEvents = await calendarStore.getByCalendar(calendarId)
|
||||
} else {
|
||||
// Get upcoming events (default: next 90 days)
|
||||
const now = Date.now()
|
||||
const ninetyDaysLater = now + 90 * 24 * 60 * 60 * 1000
|
||||
encryptedEvents = await calendarStore.getByDateRange(now, ninetyDaysLater)
|
||||
}
|
||||
|
||||
// Apply limit if specified
|
||||
if (limit && encryptedEvents.length > limit) {
|
||||
encryptedEvents = encryptedEvents.slice(0, limit)
|
||||
}
|
||||
|
||||
// Decrypt all events in parallel
|
||||
const decryptedEvents = await Promise.all(
|
||||
encryptedEvents.map(event => decryptEvent(event, calendarKey))
|
||||
)
|
||||
|
||||
// Sort by start time
|
||||
decryptedEvents.sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
|
||||
|
||||
setEvents(decryptedEvents)
|
||||
setInitialized(true)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch calendar events:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to load calendar events')
|
||||
setEvents([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [startDate, endDate, limit, calendarId])
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchEvents()
|
||||
}, [fetchEvents])
|
||||
|
||||
// Auto-refresh if enabled
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || !initialized) return
|
||||
|
||||
const intervalId = setInterval(fetchEvents, refreshInterval)
|
||||
return () => clearInterval(intervalId)
|
||||
}, [autoRefresh, refreshInterval, fetchEvents, initialized])
|
||||
|
||||
// Get events for a specific date
|
||||
const getEventsForDate = useCallback(
|
||||
(date: Date): DecryptedCalendarEvent[] => {
|
||||
const dayStart = startOfDay(date).getTime()
|
||||
const dayEnd = endOfDay(date).getTime()
|
||||
|
||||
return events.filter(event => {
|
||||
const eventStart = event.startTime.getTime()
|
||||
const eventEnd = event.endTime.getTime()
|
||||
|
||||
// Event overlaps with this day
|
||||
return eventStart <= dayEnd && eventEnd >= dayStart
|
||||
})
|
||||
},
|
||||
[events]
|
||||
)
|
||||
|
||||
// Get events for a specific month
|
||||
const getEventsForMonth = useCallback(
|
||||
(year: number, month: number): DecryptedCalendarEvent[] => {
|
||||
const monthStart = startOfMonth(year, month).getTime()
|
||||
const monthEnd = endOfMonth(year, month).getTime()
|
||||
|
||||
return events.filter(event => {
|
||||
const eventStart = event.startTime.getTime()
|
||||
const eventEnd = event.endTime.getTime()
|
||||
|
||||
// Event overlaps with this month
|
||||
return eventStart <= monthEnd && eventEnd >= monthStart
|
||||
})
|
||||
},
|
||||
[events]
|
||||
)
|
||||
|
||||
// Get events for a specific week
|
||||
const getEventsForWeek = useCallback(
|
||||
(date: Date): DecryptedCalendarEvent[] => {
|
||||
const weekStart = startOfWeek(date).getTime()
|
||||
const weekEnd = endOfWeek(date).getTime()
|
||||
|
||||
return events.filter(event => {
|
||||
const eventStart = event.startTime.getTime()
|
||||
const eventEnd = event.endTime.getTime()
|
||||
|
||||
// Event overlaps with this week
|
||||
return eventStart <= weekEnd && eventEnd >= weekStart
|
||||
})
|
||||
},
|
||||
[events]
|
||||
)
|
||||
|
||||
// Get upcoming events from now
|
||||
const getUpcoming = useCallback(
|
||||
(upcomingLimit: number = 10): DecryptedCalendarEvent[] => {
|
||||
const now = Date.now()
|
||||
return events
|
||||
.filter(event => event.startTime.getTime() >= now)
|
||||
.slice(0, upcomingLimit)
|
||||
},
|
||||
[events]
|
||||
)
|
||||
|
||||
// Memoized event count
|
||||
const eventCount = useMemo(() => events.length, [events])
|
||||
|
||||
return {
|
||||
events,
|
||||
loading,
|
||||
error,
|
||||
initialized,
|
||||
refresh: fetchEvents,
|
||||
getEventsForDate,
|
||||
getEventsForMonth,
|
||||
getEventsForWeek,
|
||||
getUpcoming,
|
||||
eventCount,
|
||||
}
|
||||
}
|
||||
|
||||
// Hook for getting events for a specific year (useful for YearView)
|
||||
export function useCalendarEventsForYear(year: number) {
|
||||
const startDate = useMemo(() => new Date(year, 0, 1), [year])
|
||||
const endDate = useMemo(() => new Date(year, 11, 31, 23, 59, 59, 999), [year])
|
||||
|
||||
return useCalendarEvents({ startDate, endDate })
|
||||
}
|
||||
|
||||
// Hook for getting events for current month
|
||||
export function useCurrentMonthEvents() {
|
||||
const now = new Date()
|
||||
const startDate = useMemo(() => startOfMonth(now.getFullYear(), now.getMonth()), [])
|
||||
const endDate = useMemo(() => endOfMonth(now.getFullYear(), now.getMonth()), [])
|
||||
|
||||
return useCalendarEvents({ startDate, endDate })
|
||||
}
|
||||
|
||||
// Hook for getting upcoming events only
|
||||
export function useUpcomingEvents(limit: number = 10) {
|
||||
const startDate = useMemo(() => new Date(), [])
|
||||
const endDate = useMemo(() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + 90) // Next 90 days
|
||||
return d
|
||||
}, [])
|
||||
|
||||
return useCalendarEvents({ startDate, endDate, limit })
|
||||
}
|
||||
|
|
@ -64,6 +64,15 @@ import { GoogleItemTool } from "@/tools/GoogleItemTool"
|
|||
// Open Mapping - OSM map shape for geographic visualization
|
||||
import { MapShape } from "@/shapes/MapShapeUtil"
|
||||
import { MapTool } from "@/tools/MapTool"
|
||||
// Workflow Builder - Flowy-like workflow blocks
|
||||
import { WorkflowBlockShape } from "@/shapes/WorkflowBlockShapeUtil"
|
||||
import { WorkflowBlockTool } from "@/tools/WorkflowBlockTool"
|
||||
// Calendar - Unified calendar with view switching (browser, widget, year)
|
||||
import { CalendarShape } from "@/shapes/CalendarShapeUtil"
|
||||
import { CalendarTool } from "@/tools/CalendarTool"
|
||||
import { CalendarEventShape } from "@/shapes/CalendarEventShapeUtil"
|
||||
import { registerWorkflowPropagator } from "@/propagators/WorkflowPropagator"
|
||||
import { setupBlockExecutionListener } from "@/lib/workflow/executor"
|
||||
import {
|
||||
lockElement,
|
||||
unlockElement,
|
||||
|
|
@ -84,6 +93,7 @@ import "@/css/anonymous-banner.css"
|
|||
import "react-cmdk/dist/cmdk.css"
|
||||
import "@/css/style.css"
|
||||
import "@/css/obsidian-browser.css"
|
||||
import "@/css/workflow.css"
|
||||
|
||||
// Helper to validate and fix tldraw IndexKey format
|
||||
// tldraw uses fractional indexing where the first letter encodes integer part length:
|
||||
|
|
@ -164,6 +174,9 @@ const customShapeUtils = [
|
|||
PrivateWorkspaceShape, // Private zone for Google Export data sovereignty
|
||||
GoogleItemShape, // Individual items from Google Export with privacy badges
|
||||
MapShape, // Open Mapping - OSM map shape
|
||||
WorkflowBlockShape, // Workflow Builder - Flowy-like blocks
|
||||
CalendarShape, // Calendar - Unified with view switching (browser/widget/year)
|
||||
CalendarEventShape, // Calendar - Individual event cards
|
||||
]
|
||||
const customTools = [
|
||||
ChatBoxTool,
|
||||
|
|
@ -186,6 +199,8 @@ const customTools = [
|
|||
PrivateWorkspaceTool,
|
||||
GoogleItemTool,
|
||||
MapTool, // Open Mapping - OSM map tool
|
||||
WorkflowBlockTool, // Workflow Builder - click-to-place
|
||||
CalendarTool, // Calendar - Unified with view switching
|
||||
]
|
||||
|
||||
// Debug: Log tool and shape registration info
|
||||
|
|
@ -1358,6 +1373,10 @@ export function Board() {
|
|||
ClickPropagator,
|
||||
])
|
||||
|
||||
// Register workflow propagator for real-time data flow
|
||||
const cleanupWorkflowPropagator = registerWorkflowPropagator(editor)
|
||||
const cleanupBlockExecution = setupBlockExecutionListener(editor)
|
||||
|
||||
// Clean up corrupted shapes that cause "No nearest point found" errors
|
||||
// This typically happens with draw/line shapes that have no points
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,456 @@
|
|||
// 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 { w, h, props } = shape
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,644 @@
|
|||
// Unified Calendar Shape - Combines Browser, Widget, and Year views
|
||||
// User can switch between views using tabs in the header
|
||||
|
||||
import {
|
||||
BaseBoxShapeUtil,
|
||||
HTMLContainer,
|
||||
TLBaseShape,
|
||||
Box,
|
||||
} from "tldraw"
|
||||
import React, { useState } from "react"
|
||||
import { CalendarPanel } from "@/components/CalendarPanel"
|
||||
import { YearViewPanel } from "@/components/YearViewPanel"
|
||||
import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper"
|
||||
import { usePinnedToView } from "@/hooks/usePinnedToView"
|
||||
import { useMaximize } from "@/hooks/useMaximize"
|
||||
import { CalendarEventShape } from "./CalendarEventShapeUtil"
|
||||
import { useUpcomingEvents, type DecryptedCalendarEvent } from "@/hooks/useCalendarEvents"
|
||||
|
||||
type CalendarView = "browser" | "widget" | "year"
|
||||
|
||||
type ICalendar = TLBaseShape<
|
||||
"Calendar",
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
pinnedToView: boolean
|
||||
tags: string[]
|
||||
currentView: CalendarView
|
||||
calendarView: "month" | "week" // For browser view
|
||||
currentDate: number // timestamp
|
||||
}
|
||||
>
|
||||
|
||||
// View size presets
|
||||
const VIEW_SIZES: Record<CalendarView, { w: number; h: number }> = {
|
||||
browser: { w: 900, h: 650 },
|
||||
widget: { w: 320, h: 420 },
|
||||
year: { w: 900, h: 650 },
|
||||
}
|
||||
|
||||
export class CalendarShape extends BaseBoxShapeUtil<ICalendar> {
|
||||
static override type = "Calendar" as const
|
||||
|
||||
// Calendar theme color: Green
|
||||
static readonly PRIMARY_COLOR = "#22c55e"
|
||||
|
||||
getDefaultProps(): ICalendar["props"] {
|
||||
return {
|
||||
w: 900,
|
||||
h: 650,
|
||||
pinnedToView: false,
|
||||
tags: ["calendar"],
|
||||
currentView: "browser",
|
||||
calendarView: "month",
|
||||
currentDate: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
override canResize() {
|
||||
return true
|
||||
}
|
||||
|
||||
component(shape: ICalendar) {
|
||||
const { w, h } = shape.props
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
|
||||
// Use the pinning hook
|
||||
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
|
||||
|
||||
// Use the maximize hook
|
||||
const { isMaximized, toggleMaximize } = useMaximize({
|
||||
editor: this.editor,
|
||||
shapeId: shape.id,
|
||||
currentW: w,
|
||||
currentH: h,
|
||||
shapeType: "Calendar",
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false)
|
||||
this.editor.deleteShape(shape.id)
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
const handlePinToggle = () => {
|
||||
this.editor.updateShape<ICalendar>({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
...shape.props,
|
||||
pinnedToView: !shape.props.pinnedToView,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Handle view change with size adjustment
|
||||
const handleViewChange = (newView: CalendarView) => {
|
||||
const newSize = VIEW_SIZES[newView]
|
||||
this.editor.updateShape<ICalendar>({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
...shape.props,
|
||||
currentView: newView,
|
||||
w: newSize.w,
|
||||
h: newSize.h,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Handle event selection - spawn event card on canvas
|
||||
const handleEventSelect = (event: DecryptedCalendarEvent) => {
|
||||
try {
|
||||
const shapeBounds = this.editor.getShapePageBounds(shape.id)
|
||||
let startX: number
|
||||
let startY: number
|
||||
|
||||
if (!shapeBounds) {
|
||||
const viewport = this.editor.getViewportPageBounds()
|
||||
startX = viewport.x + viewport.w / 2
|
||||
startY = viewport.y + viewport.h / 2
|
||||
} else {
|
||||
const spacing = 30
|
||||
startX = shapeBounds.x + shapeBounds.w + spacing
|
||||
startY = shapeBounds.y
|
||||
}
|
||||
|
||||
// Check for existing event shape
|
||||
const allShapes = this.editor.getCurrentPageShapes()
|
||||
const existingEventShape = allShapes.find(
|
||||
(s) =>
|
||||
s.type === "CalendarEvent" &&
|
||||
(s as any).props?.eventId === event.id
|
||||
)
|
||||
|
||||
if (existingEventShape) {
|
||||
this.editor.setSelectedShapes([existingEventShape.id])
|
||||
const bounds = this.editor.getShapePageBounds(existingEventShape.id)
|
||||
if (bounds) {
|
||||
this.editor.zoomToBounds(bounds, {
|
||||
inset: 50,
|
||||
animation: { duration: 300, easing: (t) => t * (2 - t) },
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Stack new events vertically
|
||||
const existingCalendarEvents = allShapes.filter(
|
||||
(s) => s.type === "CalendarEvent"
|
||||
)
|
||||
if (existingCalendarEvents.length > 0 && shapeBounds) {
|
||||
let maxY = startY
|
||||
existingCalendarEvents.forEach((s) => {
|
||||
const bounds = this.editor.getShapePageBounds(s.id)
|
||||
if (bounds && bounds.x >= shapeBounds.x + shapeBounds.w) {
|
||||
const shapeBottom = bounds.y + bounds.h
|
||||
if (shapeBottom > maxY) {
|
||||
maxY = shapeBottom + 20
|
||||
}
|
||||
}
|
||||
})
|
||||
startY = maxY === startY ? startY : maxY
|
||||
}
|
||||
|
||||
const eventShape = CalendarEventShape.createFromEvent(
|
||||
event,
|
||||
startX,
|
||||
startY,
|
||||
CalendarShape.PRIMARY_COLOR
|
||||
)
|
||||
|
||||
this.editor.createShapes([eventShape])
|
||||
|
||||
setTimeout(() => {
|
||||
const newShapeBounds = this.editor.getShapePageBounds(eventShape.id as any)
|
||||
if (newShapeBounds && shapeBounds) {
|
||||
const combinedBounds = Box.Common([shapeBounds, newShapeBounds])
|
||||
this.editor.zoomToBounds(combinedBounds, {
|
||||
inset: 50,
|
||||
animation: { duration: 400, easing: (t) => t * (2 - t) },
|
||||
})
|
||||
}
|
||||
this.editor.setSelectedShapes([eventShape.id as any])
|
||||
this.editor.setCurrentTool("select")
|
||||
}, 50)
|
||||
} catch (error) {
|
||||
console.error("Error creating calendar event shape:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle month selection from year view
|
||||
const handleMonthSelect = (year: number, month: number) => {
|
||||
this.editor.updateShape<ICalendar>({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
...shape.props,
|
||||
currentView: "browser",
|
||||
calendarView: "month",
|
||||
currentDate: new Date(year, month, 1).getTime(),
|
||||
w: VIEW_SIZES.browser.w,
|
||||
h: VIEW_SIZES.browser.h,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Render based on current view
|
||||
const renderContent = () => {
|
||||
switch (shape.props.currentView) {
|
||||
case "widget":
|
||||
return (
|
||||
<CalendarWidgetContent
|
||||
onEventSelect={handleEventSelect}
|
||||
primaryColor={CalendarShape.PRIMARY_COLOR}
|
||||
/>
|
||||
)
|
||||
case "year":
|
||||
return (
|
||||
<YearViewPanel
|
||||
onClose={handleClose}
|
||||
onMonthSelect={handleMonthSelect}
|
||||
shapeMode={true}
|
||||
initialYear={new Date(shape.props.currentDate).getFullYear()}
|
||||
/>
|
||||
)
|
||||
case "browser":
|
||||
default:
|
||||
return (
|
||||
<CalendarPanel
|
||||
onClose={handleClose}
|
||||
onEventSelect={handleEventSelect}
|
||||
shapeMode={true}
|
||||
initialView={shape.props.calendarView}
|
||||
initialDate={new Date(shape.props.currentDate)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// View tabs component
|
||||
const ViewTabs = () => {
|
||||
const isDarkMode =
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.classList.contains("dark")
|
||||
|
||||
const tabs: { id: CalendarView; label: string; icon: string }[] = [
|
||||
{ id: "browser", label: "Calendar", icon: "📅" },
|
||||
{ id: "widget", label: "Widget", icon: "📋" },
|
||||
{ id: "year", label: "Year", icon: "📆" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "4px",
|
||||
padding: "8px 12px",
|
||||
borderBottom: `1px solid ${isDarkMode ? "#404040" : "#e5e7eb"}`,
|
||||
backgroundColor: isDarkMode ? "#1a1a1a" : "#f9fafb",
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleViewChange(tab.id)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "6px 12px",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
fontSize: "12px",
|
||||
fontWeight: shape.props.currentView === tab.id ? "600" : "400",
|
||||
cursor: "pointer",
|
||||
backgroundColor:
|
||||
shape.props.currentView === tab.id
|
||||
? CalendarShape.PRIMARY_COLOR
|
||||
: isDarkMode
|
||||
? "#374151"
|
||||
: "#e5e7eb",
|
||||
color:
|
||||
shape.props.currentView === tab.id
|
||||
? "#ffffff"
|
||||
: isDarkMode
|
||||
? "#e4e4e7"
|
||||
: "#374151",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<span>{tab.icon}</span>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<HTMLContainer style={{ width: w, height: h }}>
|
||||
<StandardizedToolWrapper
|
||||
title="Calendar"
|
||||
primaryColor={CalendarShape.PRIMARY_COLOR}
|
||||
isSelected={isSelected}
|
||||
width={w}
|
||||
height={h}
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
isMinimized={isMinimized}
|
||||
onMaximize={toggleMaximize}
|
||||
isMaximized={isMaximized}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
isPinnedToView={shape.props.pinnedToView}
|
||||
onPinToggle={handlePinToggle}
|
||||
tags={shape.props.tags}
|
||||
onTagsChange={(newTags) => {
|
||||
this.editor.updateShape<ICalendar>({
|
||||
id: shape.id,
|
||||
type: "Calendar",
|
||||
props: {
|
||||
...shape.props,
|
||||
tags: newTags,
|
||||
},
|
||||
})
|
||||
}}
|
||||
tagsEditable={true}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<ViewTabs />
|
||||
<div style={{ flex: 1, overflow: "hidden" }}>{renderContent()}</div>
|
||||
</div>
|
||||
</StandardizedToolWrapper>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
indicator(shape: ICalendar) {
|
||||
return <rect width={shape.props.w} height={shape.props.h} />
|
||||
}
|
||||
}
|
||||
|
||||
// Compact widget content component (extracted from CalendarWidgetShapeUtil)
|
||||
const CalendarWidgetContent: React.FC<{
|
||||
onEventSelect: (event: DecryptedCalendarEvent) => void
|
||||
primaryColor: string
|
||||
}> = ({ onEventSelect, primaryColor }) => {
|
||||
const [currentDate, setCurrentDate] = useState(new Date())
|
||||
const { events: upcomingEvents, loading, getEventsForDate } = useUpcomingEvents(5)
|
||||
|
||||
const isDarkMode =
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.classList.contains("dark")
|
||||
|
||||
const colors = isDarkMode
|
||||
? {
|
||||
bg: "#1f2937",
|
||||
text: "#e4e4e7",
|
||||
textMuted: "#a1a1aa",
|
||||
border: "#404040",
|
||||
todayBg: "#22c55e30",
|
||||
eventDot: "#3b82f6",
|
||||
cardBg: "#252525",
|
||||
}
|
||||
: {
|
||||
bg: "#f9fafb",
|
||||
text: "#1f2937",
|
||||
textMuted: "#6b7280",
|
||||
border: "#e5e7eb",
|
||||
todayBg: "#22c55e20",
|
||||
eventDot: "#3b82f6",
|
||||
cardBg: "#f9fafb",
|
||||
}
|
||||
|
||||
// Generate mini calendar
|
||||
const getDaysInMonth = (year: number, month: number) =>
|
||||
new Date(year, month + 1, 0).getDate()
|
||||
|
||||
const getFirstDayOfMonth = (year: number, month: number) => {
|
||||
const day = new Date(year, month, 1).getDay()
|
||||
return day === 0 ? 6 : day - 1
|
||||
}
|
||||
|
||||
const isSameDay = (date1: Date, date2: Date) =>
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate()
|
||||
|
||||
const year = currentDate.getFullYear()
|
||||
const month = currentDate.getMonth()
|
||||
const daysInMonth = getDaysInMonth(year, month)
|
||||
const firstDay = getFirstDayOfMonth(year, month)
|
||||
const today = new Date()
|
||||
|
||||
const days: { day: number | null; date: Date | null }[] = []
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push({ day: null, date: null })
|
||||
}
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push({ day: i, date: new Date(year, month, i) })
|
||||
}
|
||||
|
||||
const goToPrevMonth = () =>
|
||||
setCurrentDate(new Date(year, month - 1, 1))
|
||||
const goToNextMonth = () =>
|
||||
setCurrentDate(new Date(year, month + 1, 1))
|
||||
|
||||
const formatEventTime = (event: DecryptedCalendarEvent) => {
|
||||
if (event.isAllDay) return "All day"
|
||||
return event.startTime.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
|
||||
const formatEventDate = (event: DecryptedCalendarEvent) => {
|
||||
if (isSameDay(today, event.startTime)) return "Today"
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
if (isSameDay(tomorrow, event.startTime)) return "Tomorrow"
|
||||
return event.startTime.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
backgroundColor: colors.bg,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Mini Calendar Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={goToPrevMonth}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: isDarkMode ? "#374151" : "#e5e7eb",
|
||||
border: "none",
|
||||
color: colors.text,
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
<
|
||||
</button>
|
||||
<span style={{ fontSize: "13px", fontWeight: "600", color: colors.text }}>
|
||||
{currentDate.toLocaleDateString("en-US", { month: "long", year: "numeric" })}
|
||||
</span>
|
||||
<button
|
||||
onClick={goToNextMonth}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: isDarkMode ? "#374151" : "#e5e7eb",
|
||||
border: "none",
|
||||
color: colors.text,
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mini Calendar Grid */}
|
||||
<div style={{ padding: "8px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: "2px",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
{["M", "T", "W", "T", "F", "S", "S"].map((day, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: "10px",
|
||||
fontWeight: "600",
|
||||
color: colors.textMuted,
|
||||
padding: "2px 0",
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: "2px",
|
||||
}}
|
||||
>
|
||||
{days.map(({ day, date }, i) => {
|
||||
const isToday = date && isSameDay(date, today)
|
||||
const hasEvents = date && getEventsForDate(date).length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: "11px",
|
||||
padding: "4px 2px",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: isToday ? colors.todayBg : "transparent",
|
||||
border: isToday ? `1px solid ${primaryColor}` : "1px solid transparent",
|
||||
color: day ? colors.text : "transparent",
|
||||
fontWeight: isToday ? "700" : "400",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
{hasEvents && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "1px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: "4px",
|
||||
height: "4px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: colors.eventDot,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Events */}
|
||||
<div style={{ flex: 1, padding: "8px", overflow: "auto" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "10px",
|
||||
fontWeight: "600",
|
||||
color: colors.textMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.5px",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
Upcoming
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: "center", color: colors.textMuted, fontSize: "11px", padding: "12px 0" }}>
|
||||
Loading...
|
||||
</div>
|
||||
) : upcomingEvents.length === 0 ? (
|
||||
<div style={{ textAlign: "center", color: colors.textMuted, fontSize: "11px", padding: "12px 0" }}>
|
||||
No upcoming events
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
{upcomingEvents.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
onClick={() => onEventSelect(event)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
backgroundColor: colors.cardBg,
|
||||
borderRadius: "6px",
|
||||
borderLeft: `3px solid ${colors.eventDot}`,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
fontWeight: "600",
|
||||
color: colors.text,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{event.summary}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "10px",
|
||||
color: colors.textMuted,
|
||||
display: "flex",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
<span>{formatEventDate(event)}</span>
|
||||
<span>|</span>
|
||||
<span>{formatEventTime(event)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
// Unified Calendar Tool - Places Calendar shape on canvas
|
||||
// Supports switching between Browser, Widget, and Year views
|
||||
|
||||
import { StateNode } from "tldraw"
|
||||
|
||||
export class CalendarTool extends StateNode {
|
||||
static override id = "calendar"
|
||||
static override initial = "idle"
|
||||
static override children = () => [CalendarIdle]
|
||||
}
|
||||
|
||||
export class CalendarIdle extends StateNode {
|
||||
static override id = "idle"
|
||||
tooltipElement?: HTMLDivElement
|
||||
mouseMoveHandler?: (e: MouseEvent) => void
|
||||
isCreatingShape = false
|
||||
|
||||
override onEnter = () => {
|
||||
// Set cursor to cross
|
||||
this.editor.setCursor({ type: "cross", rotation: 0 })
|
||||
|
||||
// Create tooltip element
|
||||
this.tooltipElement = document.createElement("div")
|
||||
this.tooltipElement.textContent = "Click anywhere to place Calendar"
|
||||
this.tooltipElement.style.cssText = `
|
||||
position: fixed;
|
||||
background: rgba(34, 197, 94, 0.95);
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
pointer-events: none;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
white-space: nowrap;
|
||||
`
|
||||
document.body.appendChild(this.tooltipElement)
|
||||
|
||||
// Update tooltip position on mouse move
|
||||
this.mouseMoveHandler = (e: MouseEvent) => {
|
||||
if (this.tooltipElement) {
|
||||
this.tooltipElement.style.left = `${e.clientX + 15}px`
|
||||
this.tooltipElement.style.top = `${e.clientY - 35}px`
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousemove", this.mouseMoveHandler)
|
||||
}
|
||||
|
||||
override onPointerDown = (info?: any) => {
|
||||
// Validate click event
|
||||
if (!info?.point || info.button === undefined) return
|
||||
if (info.button !== 0) return // Only left mouse button
|
||||
|
||||
// Prevent UI element clicks from creating shapes
|
||||
if (info.target?.closest?.("[data-tldraw-ui]")) return
|
||||
|
||||
// Prevent double-creation
|
||||
if (this.isCreatingShape) return
|
||||
|
||||
// Convert screen coords to page coords
|
||||
const pagePoint = this.editor.screenToPage(info.point)
|
||||
this.createCalendarShape(pagePoint.x, pagePoint.y)
|
||||
}
|
||||
|
||||
private createCalendarShape(clickX: number, clickY: number) {
|
||||
this.isCreatingShape = true
|
||||
|
||||
try {
|
||||
// Check if a Calendar already exists - focus it instead of creating new one
|
||||
const existingCalendar = this.editor
|
||||
.getCurrentPageShapes()
|
||||
.find((s) => s.type === "Calendar")
|
||||
|
||||
if (existingCalendar) {
|
||||
// Focus existing calendar
|
||||
this.editor.setSelectedShapes([existingCalendar.id])
|
||||
const bounds = this.editor.getShapePageBounds(existingCalendar.id)
|
||||
if (bounds) {
|
||||
this.editor.zoomToBounds(bounds, {
|
||||
inset: 50,
|
||||
animation: { duration: 300, easing: (t) => t * (2 - t) },
|
||||
})
|
||||
}
|
||||
this.editor.setCurrentTool("select")
|
||||
setTimeout(() => {
|
||||
this.isCreatingShape = false
|
||||
}, 200)
|
||||
return
|
||||
}
|
||||
|
||||
// Default to browser view size
|
||||
const shapeWidth = 900
|
||||
const shapeHeight = 650
|
||||
|
||||
// Center shape on click
|
||||
const baseX = clickX - shapeWidth / 2
|
||||
const baseY = clickY - shapeHeight / 2
|
||||
|
||||
const calendarShape = this.editor.createShape({
|
||||
type: "Calendar",
|
||||
x: baseX,
|
||||
y: baseY,
|
||||
props: {
|
||||
w: shapeWidth,
|
||||
h: shapeHeight,
|
||||
currentView: "browser",
|
||||
},
|
||||
})
|
||||
|
||||
// Switch back to select tool
|
||||
this.editor.setCurrentTool("select")
|
||||
|
||||
// Reset flag after tool switch
|
||||
setTimeout(() => {
|
||||
this.isCreatingShape = false
|
||||
}, 200)
|
||||
} catch (error) {
|
||||
console.error("Error creating Calendar shape:", error)
|
||||
this.isCreatingShape = false
|
||||
}
|
||||
}
|
||||
|
||||
override onExit = () => {
|
||||
// Clean up tooltip and listeners
|
||||
if (this.mouseMoveHandler) {
|
||||
document.removeEventListener("mousemove", this.mouseMoveHandler)
|
||||
this.mouseMoveHandler = undefined
|
||||
}
|
||||
if (this.tooltipElement?.parentNode) {
|
||||
document.body.removeChild(this.tooltipElement)
|
||||
this.tooltipElement = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel on escape
|
||||
override onCancel = () => {
|
||||
this.editor.setCurrentTool("select")
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { createShapeId } from "tldraw"
|
|||
import type { ObsidianObsNote } from "../lib/obsidianImporter"
|
||||
import { HolonData } from "../lib/HoloSphereService"
|
||||
import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
|
||||
import { WorkflowPalette } from "../components/workflow/WorkflowPalette"
|
||||
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
|
||||
import { getMyConnections, updateEdgeMetadata, createConnection, removeConnection, updateTrustLevel } from "../lib/networking/connectionService"
|
||||
import { TRUST_LEVEL_COLORS, type TrustLevel, type UserConnectionWithProfile, type EdgeMetadata } from "../lib/networking/types"
|
||||
|
|
@ -57,6 +58,7 @@ export function CustomToolbar() {
|
|||
const [showHolonBrowser, setShowHolonBrowser] = useState(false)
|
||||
const [vaultBrowserMode, setVaultBrowserMode] = useState<'keyboard' | 'button'>('keyboard')
|
||||
const [showFathomPanel, setShowFathomPanel] = useState(false)
|
||||
const [showWorkflowPalette, setShowWorkflowPalette] = useState(false)
|
||||
const profilePopupRef = useRef<HTMLDivElement>(null)
|
||||
const [isDarkMode, setIsDarkMode] = useState(getDarkMode())
|
||||
|
||||
|
|
@ -782,6 +784,21 @@ export function CustomToolbar() {
|
|||
isSelected={tools["Map"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["calendar"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["calendar"]}
|
||||
icon="calendar"
|
||||
label="Calendar"
|
||||
isSelected={tools["calendar"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{/* Workflow Builder - Toggle Palette */}
|
||||
<TldrawUiMenuItem
|
||||
id="workflow-palette"
|
||||
icon="sticker"
|
||||
label="Workflow Blocks"
|
||||
onSelect={() => setShowWorkflowPalette(!showWorkflowPalette)}
|
||||
/>
|
||||
{/* Refresh All ObsNotes Button */}
|
||||
{(() => {
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
|
|
@ -808,6 +825,12 @@ export function CustomToolbar() {
|
|||
onClose={() => setShowFathomPanel(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Workflow Builder Palette */}
|
||||
<WorkflowPalette
|
||||
isOpen={showWorkflowPalette}
|
||||
onClose={() => setShowWorkflowPalette(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -246,6 +246,14 @@ export const overrides: TLUiOverrides = {
|
|||
readonlyOk: true,
|
||||
onSelect: () => editor.setCurrentTool("map"),
|
||||
},
|
||||
calendar: {
|
||||
id: "calendar",
|
||||
icon: "calendar",
|
||||
label: "Calendar",
|
||||
kbd: "ctrl+alt+k",
|
||||
readonlyOk: true,
|
||||
onSelect: () => editor.setCurrentTool("calendar"),
|
||||
},
|
||||
// MycelialIntelligence removed - now a permanent UI bar (MycelialIntelligenceBar.tsx)
|
||||
hand: {
|
||||
...tools.hand,
|
||||
|
|
|
|||
Loading…
Reference in New Issue