diff --git a/src/components/CalendarPanel.tsx b/src/components/CalendarPanel.tsx new file mode 100644 index 0000000..160f909 --- /dev/null +++ b/src/components/CalendarPanel.tsx @@ -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(initialView) + const [currentDate, setCurrentDate] = useState(initialDate || new Date()) + const [selectedDate, setSelectedDate] = useState(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 ( +
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" + } + }} + > +
+ {date.getDate()} +
+
+ {dayEvents.slice(0, 3).map((event, i) => ( +
+ ))} + {dayEvents.length > 3 && ( +
+ +{dayEvents.length - 3} +
+ )} +
+
+ ) + } + + // Event list item + const EventItem = ({ event }: { event: DecryptedCalendarEvent }) => ( +
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 + }} + > +
+ {event.summary} +
+
+ {formatEventTime(event)} + {event.location && ( + <> + | + + {event.location} + + + )} +
+
+ ) + + // Loading state + if (loading) { + return ( +
+
+
Loading...
+
Fetching calendar events
+
+
+ ) + } + + // Error state + if (error) { + return ( +
+
+
Error
+
{error}
+ +
+
+ ) + } + + // No events state + if (events.length === 0) { + return ( +
+
+
📅
+
+ No Calendar Events +
+
+ Import your Google Calendar to see events here. +
+
+
+ ) + } + + return ( +
+ {/* Main Calendar Area */} +
+ {/* Navigation Header */} +
+ + +
+ {formatMonthYear(currentDate)} +
+ + + + + + {/* View toggle */} +
+ + +
+
+ + {/* Calendar Grid */} +
+ {/* Day headers */} +
+ {["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((day) => ( +
+ {day} +
+ ))} +
+ + {/* Day cells */} +
+ {monthGrid.map(({ date, isCurrentMonth }, i) => ( + + ))} +
+
+
+ + {/* Sidebar - Events */} +
+ {/* Selected Date Events or Upcoming */} +
+
+ {selectedDate + ? selectedDate.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + }) + : "Upcoming Events"} +
+ +
+ {(selectedDate ? selectedDateEvents : upcomingEvents).map( + (event) => ( + + ) + )} + + {(selectedDate ? selectedDateEvents : upcomingEvents).length === + 0 && ( +
+ {selectedDate + ? "No events on this day" + : "No upcoming events"} +
+ )} +
+
+ + {/* Click hint */} + {onEventSelect && ( +
+ Click an event to add it to the canvas +
+ )} +
+
+ ) +} + +export default CalendarPanel diff --git a/src/components/YearViewPanel.tsx b/src/components/YearViewPanel.tsx new file mode 100644 index 0000000..2a3c85e --- /dev/null +++ b/src/components/YearViewPanel.tsx @@ -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 = ({ + 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 ( +
onMonthSelect?.(currentYear, month)} + onPointerDown={(e) => e.stopPropagation()} + > + {/* Month name */} +
+ {SHORT_MONTH_NAMES[month]} +
+ + {/* Day headers */} +
+ {["M", "T", "W", "T", "F", "S", "S"].map((day, i) => ( +
+ {day} +
+ ))} +
+ + {/* Days grid */} +
+ {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 ( +
+ {day} +
+ ) + })} +
+
+ ) + } + + return ( +
+ {/* Header with year navigation */} +
+ + +
+ + {currentYear} + + {currentYear !== new Date().getFullYear() && ( + + )} +
+ + +
+ + {/* 12-month grid (4x3 layout) */} +
+ {loading ? ( +
+ Loading calendar data... +
+ ) : ( +
+ {Array.from({ length: 12 }, (_, month) => renderMiniMonth(month))} +
+ )} +
+ + {/* Legend */} +
+
+
+ Today +
+
+
+ 1 event +
+
+
+ 3+ events +
+
+
+ 5+ events +
+
+
+ ) +} diff --git a/src/hooks/useCalendarEvents.ts b/src/hooks/useCalendarEvents.ts new file mode 100644 index 0000000..6ae96ac --- /dev/null +++ b/src/hooks/useCalendarEvents.ts @@ -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 + 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 { + 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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 }) +} diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index 6e1e6d1..d5877a6 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -64,6 +64,15 @@ import { GoogleItemTool } from "@/tools/GoogleItemTool" // Open Mapping - OSM map shape for geographic visualization import { MapShape } from "@/shapes/MapShapeUtil" import { MapTool } from "@/tools/MapTool" +// Workflow Builder - Flowy-like workflow blocks +import { WorkflowBlockShape } from "@/shapes/WorkflowBlockShapeUtil" +import { WorkflowBlockTool } from "@/tools/WorkflowBlockTool" +// Calendar - Unified calendar with view switching (browser, widget, year) +import { CalendarShape } from "@/shapes/CalendarShapeUtil" +import { CalendarTool } from "@/tools/CalendarTool" +import { CalendarEventShape } from "@/shapes/CalendarEventShapeUtil" +import { registerWorkflowPropagator } from "@/propagators/WorkflowPropagator" +import { setupBlockExecutionListener } from "@/lib/workflow/executor" import { lockElement, unlockElement, @@ -84,6 +93,7 @@ import "@/css/anonymous-banner.css" import "react-cmdk/dist/cmdk.css" import "@/css/style.css" import "@/css/obsidian-browser.css" +import "@/css/workflow.css" // Helper to validate and fix tldraw IndexKey format // tldraw uses fractional indexing where the first letter encodes integer part length: @@ -164,6 +174,9 @@ const customShapeUtils = [ PrivateWorkspaceShape, // Private zone for Google Export data sovereignty GoogleItemShape, // Individual items from Google Export with privacy badges MapShape, // Open Mapping - OSM map shape + WorkflowBlockShape, // Workflow Builder - Flowy-like blocks + CalendarShape, // Calendar - Unified with view switching (browser/widget/year) + CalendarEventShape, // Calendar - Individual event cards ] const customTools = [ ChatBoxTool, @@ -186,6 +199,8 @@ const customTools = [ PrivateWorkspaceTool, GoogleItemTool, MapTool, // Open Mapping - OSM map tool + WorkflowBlockTool, // Workflow Builder - click-to-place + CalendarTool, // Calendar - Unified with view switching ] // Debug: Log tool and shape registration info @@ -1358,6 +1373,10 @@ export function Board() { ClickPropagator, ]) + // Register workflow propagator for real-time data flow + const cleanupWorkflowPropagator = registerWorkflowPropagator(editor) + const cleanupBlockExecution = setupBlockExecutionListener(editor) + // Clean up corrupted shapes that cause "No nearest point found" errors // This typically happens with draw/line shapes that have no points try { diff --git a/src/shapes/CalendarEventShapeUtil.tsx b/src/shapes/CalendarEventShapeUtil.tsx new file mode 100644 index 0000000..f4a0c14 --- /dev/null +++ b/src/shapes/CalendarEventShapeUtil.tsx @@ -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 { + 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 ( + +
+ {/* Header */} +
+ + {props.isAllDay ? "📅" : "🕐"} + +
+
+ {props.summary} +
+
+ {props.isAllDay ? "All Day" : getDuration()} +
+
+
+ + {/* Content */} +
+ {/* Time */} +
+
+ When +
+
+ {formatTimeRange()} +
+
+ + {/* Location */} + {props.location && ( +
+
+ Location +
+
+ 📍 + + {props.location} + +
+
+ )} + + {/* Description */} + {props.description && ( +
+
+ Description +
+
+ {props.description} +
+
+ )} + + {/* Meeting Link */} + {props.meetingLink && ( + + )} +
+ + {/* Footer with tags */} +
+ {props.tags.map((tag, i) => ( + + {tag} + + ))} +
+
+
+ ) + } + + indicator(shape: ICalendarEventShape) { + return ( + + ) + } +} diff --git a/src/shapes/CalendarShapeUtil.tsx b/src/shapes/CalendarShapeUtil.tsx new file mode 100644 index 0000000..1d24562 --- /dev/null +++ b/src/shapes/CalendarShapeUtil.tsx @@ -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 = { + browser: { w: 900, h: 650 }, + widget: { w: 320, h: 420 }, + year: { w: 900, h: 650 }, +} + +export class CalendarShape extends BaseBoxShapeUtil { + 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({ + 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({ + 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({ + 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 ( + + ) + case "year": + return ( + + ) + case "browser": + default: + return ( + + ) + } + } + + // 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 ( +
+ {tabs.map((tab) => ( + + ))} +
+ ) + } + + return ( + + { + this.editor.updateShape({ + id: shape.id, + type: "Calendar", + props: { + ...shape.props, + tags: newTags, + }, + }) + }} + tagsEditable={true} + > +
+ +
{renderContent()}
+
+
+
+ ) + } + + indicator(shape: ICalendar) { + return + } +} + +// 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 ( +
+ {/* Mini Calendar Header */} +
+ + + {currentDate.toLocaleDateString("en-US", { month: "long", year: "numeric" })} + + +
+ + {/* Mini Calendar Grid */} +
+
+ {["M", "T", "W", "T", "F", "S", "S"].map((day, i) => ( +
+ {day} +
+ ))} +
+
+ {days.map(({ day, date }, i) => { + const isToday = date && isSameDay(date, today) + const hasEvents = date && getEventsForDate(date).length > 0 + + return ( +
+ {day} + {hasEvents && ( +
+ )} +
+ ) + })} +
+
+ + {/* Upcoming Events */} +
+
+ Upcoming +
+ {loading ? ( +
+ Loading... +
+ ) : upcomingEvents.length === 0 ? ( +
+ No upcoming events +
+ ) : ( +
+ {upcomingEvents.map((event) => ( +
onEventSelect(event)} + onPointerDown={(e) => e.stopPropagation()} + style={{ + padding: "6px 8px", + backgroundColor: colors.cardBg, + borderRadius: "6px", + borderLeft: `3px solid ${colors.eventDot}`, + cursor: "pointer", + }} + > +
+ {event.summary} +
+
+ {formatEventDate(event)} + | + {formatEventTime(event)} +
+
+ ))} +
+ )} +
+
+ ) +} diff --git a/src/tools/CalendarTool.ts b/src/tools/CalendarTool.ts new file mode 100644 index 0000000..06aad05 --- /dev/null +++ b/src/tools/CalendarTool.ts @@ -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") + } +} diff --git a/src/ui/CustomToolbar.tsx b/src/ui/CustomToolbar.tsx index 77bdb77..f8ee311 100644 --- a/src/ui/CustomToolbar.tsx +++ b/src/ui/CustomToolbar.tsx @@ -14,6 +14,7 @@ import { createShapeId } from "tldraw" import type { ObsidianObsNote } from "../lib/obsidianImporter" import { HolonData } from "../lib/HoloSphereService" import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel" +import { WorkflowPalette } from "../components/workflow/WorkflowPalette" import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey" import { getMyConnections, updateEdgeMetadata, createConnection, removeConnection, updateTrustLevel } from "../lib/networking/connectionService" import { TRUST_LEVEL_COLORS, type TrustLevel, type UserConnectionWithProfile, type EdgeMetadata } from "../lib/networking/types" @@ -57,6 +58,7 @@ export function CustomToolbar() { const [showHolonBrowser, setShowHolonBrowser] = useState(false) const [vaultBrowserMode, setVaultBrowserMode] = useState<'keyboard' | 'button'>('keyboard') const [showFathomPanel, setShowFathomPanel] = useState(false) + const [showWorkflowPalette, setShowWorkflowPalette] = useState(false) const profilePopupRef = useRef(null) const [isDarkMode, setIsDarkMode] = useState(getDarkMode()) @@ -782,6 +784,21 @@ export function CustomToolbar() { isSelected={tools["Map"].id === editor.getCurrentToolId()} /> )} + {tools["calendar"] && ( + + )} + {/* Workflow Builder - Toggle Palette */} + setShowWorkflowPalette(!showWorkflowPalette)} + /> {/* Refresh All ObsNotes Button */} {(() => { const allShapes = editor.getCurrentPageShapes() @@ -808,6 +825,12 @@ export function CustomToolbar() { onClose={() => setShowFathomPanel(false)} /> )} + + {/* Workflow Builder Palette */} + setShowWorkflowPalette(false)} + /> ) } diff --git a/src/ui/overrides.tsx b/src/ui/overrides.tsx index 14ebc23..8594d9c 100644 --- a/src/ui/overrides.tsx +++ b/src/ui/overrides.tsx @@ -246,6 +246,14 @@ export const overrides: TLUiOverrides = { readonlyOk: true, onSelect: () => editor.setCurrentTool("map"), }, + calendar: { + id: "calendar", + icon: "calendar", + label: "Calendar", + kbd: "ctrl+alt+k", + readonlyOk: true, + onSelect: () => editor.setCurrentTool("calendar"), + }, // MycelialIntelligence removed - now a permanent UI bar (MycelialIntelligenceBar.tsx) hand: { ...tools.hand,