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
|
// Open Mapping - OSM map shape for geographic visualization
|
||||||
import { MapShape } from "@/shapes/MapShapeUtil"
|
import { MapShape } from "@/shapes/MapShapeUtil"
|
||||||
import { MapTool } from "@/tools/MapTool"
|
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 {
|
import {
|
||||||
lockElement,
|
lockElement,
|
||||||
unlockElement,
|
unlockElement,
|
||||||
|
|
@ -84,6 +93,7 @@ import "@/css/anonymous-banner.css"
|
||||||
import "react-cmdk/dist/cmdk.css"
|
import "react-cmdk/dist/cmdk.css"
|
||||||
import "@/css/style.css"
|
import "@/css/style.css"
|
||||||
import "@/css/obsidian-browser.css"
|
import "@/css/obsidian-browser.css"
|
||||||
|
import "@/css/workflow.css"
|
||||||
|
|
||||||
// Helper to validate and fix tldraw IndexKey format
|
// Helper to validate and fix tldraw IndexKey format
|
||||||
// tldraw uses fractional indexing where the first letter encodes integer part length:
|
// 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
|
PrivateWorkspaceShape, // Private zone for Google Export data sovereignty
|
||||||
GoogleItemShape, // Individual items from Google Export with privacy badges
|
GoogleItemShape, // Individual items from Google Export with privacy badges
|
||||||
MapShape, // Open Mapping - OSM map shape
|
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 = [
|
const customTools = [
|
||||||
ChatBoxTool,
|
ChatBoxTool,
|
||||||
|
|
@ -186,6 +199,8 @@ const customTools = [
|
||||||
PrivateWorkspaceTool,
|
PrivateWorkspaceTool,
|
||||||
GoogleItemTool,
|
GoogleItemTool,
|
||||||
MapTool, // Open Mapping - OSM map tool
|
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
|
// Debug: Log tool and shape registration info
|
||||||
|
|
@ -1358,6 +1373,10 @@ export function Board() {
|
||||||
ClickPropagator,
|
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
|
// Clean up corrupted shapes that cause "No nearest point found" errors
|
||||||
// This typically happens with draw/line shapes that have no points
|
// This typically happens with draw/line shapes that have no points
|
||||||
try {
|
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 type { ObsidianObsNote } from "../lib/obsidianImporter"
|
||||||
import { HolonData } from "../lib/HoloSphereService"
|
import { HolonData } from "../lib/HoloSphereService"
|
||||||
import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
|
import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
|
||||||
|
import { WorkflowPalette } from "../components/workflow/WorkflowPalette"
|
||||||
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
|
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
|
||||||
import { getMyConnections, updateEdgeMetadata, createConnection, removeConnection, updateTrustLevel } from "../lib/networking/connectionService"
|
import { getMyConnections, updateEdgeMetadata, createConnection, removeConnection, updateTrustLevel } from "../lib/networking/connectionService"
|
||||||
import { TRUST_LEVEL_COLORS, type TrustLevel, type UserConnectionWithProfile, type EdgeMetadata } from "../lib/networking/types"
|
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 [showHolonBrowser, setShowHolonBrowser] = useState(false)
|
||||||
const [vaultBrowserMode, setVaultBrowserMode] = useState<'keyboard' | 'button'>('keyboard')
|
const [vaultBrowserMode, setVaultBrowserMode] = useState<'keyboard' | 'button'>('keyboard')
|
||||||
const [showFathomPanel, setShowFathomPanel] = useState(false)
|
const [showFathomPanel, setShowFathomPanel] = useState(false)
|
||||||
|
const [showWorkflowPalette, setShowWorkflowPalette] = useState(false)
|
||||||
const profilePopupRef = useRef<HTMLDivElement>(null)
|
const profilePopupRef = useRef<HTMLDivElement>(null)
|
||||||
const [isDarkMode, setIsDarkMode] = useState(getDarkMode())
|
const [isDarkMode, setIsDarkMode] = useState(getDarkMode())
|
||||||
|
|
||||||
|
|
@ -782,6 +784,21 @@ export function CustomToolbar() {
|
||||||
isSelected={tools["Map"].id === editor.getCurrentToolId()}
|
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 */}
|
{/* Refresh All ObsNotes Button */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const allShapes = editor.getCurrentPageShapes()
|
const allShapes = editor.getCurrentPageShapes()
|
||||||
|
|
@ -808,6 +825,12 @@ export function CustomToolbar() {
|
||||||
onClose={() => setShowFathomPanel(false)}
|
onClose={() => setShowFathomPanel(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Workflow Builder Palette */}
|
||||||
|
<WorkflowPalette
|
||||||
|
isOpen={showWorkflowPalette}
|
||||||
|
onClose={() => setShowWorkflowPalette(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,14 @@ export const overrides: TLUiOverrides = {
|
||||||
readonlyOk: true,
|
readonlyOk: true,
|
||||||
onSelect: () => editor.setCurrentTool("map"),
|
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)
|
// MycelialIntelligence removed - now a permanent UI bar (MycelialIntelligenceBar.tsx)
|
||||||
hand: {
|
hand: {
|
||||||
...tools.hand,
|
...tools.hand,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue