diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 2e37062..cf21a0d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,24 +1,41 @@ +import { lazy, Suspense } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; import { ToastProvider, ToastViewport } from '@yetanother/ui'; -import { NotesPage } from './notes/NotesPage'; -import { TasksPage } from './tasks/TasksPage'; -import { CalendarPage } from './calendar/CalendarPage'; import { Sidebar } from './components/Sidebar'; -import './index.css'; +import { SearchBar } from './ai/SearchBar'; + +const NotesPage = lazy(() => import('./notes/NotesPage').then((m) => ({ default: m.NotesPage }))); +const TasksPage = lazy(() => import('./tasks/TasksPage').then((m) => ({ default: m.TasksPage }))); +const CalendarPage = lazy(() => import('./calendar/CalendarPage').then((m) => ({ default: m.CalendarPage }))); + +function Loading() { + return ( +
+ Loading... +
+ ); +} export function App() { return (
-
- - } /> - } /> - } /> - } /> - -
+
+
+ +
+
+ }> + + } /> + } /> + } /> + } /> + + +
+
diff --git a/apps/web/src/calendar/CalendarPage.tsx b/apps/web/src/calendar/CalendarPage.tsx index 60e006c..d5a3fa8 100644 --- a/apps/web/src/calendar/CalendarPage.tsx +++ b/apps/web/src/calendar/CalendarPage.tsx @@ -1,6 +1,9 @@ import { CalendarHeader } from './CalendarHeader'; import { MonthView } from './MonthView'; +import { WeekView } from './WeekView'; +import { DayView } from './DayView'; import { useCalendarStore } from '../stores/calendar'; +import { EventDialog } from './EventDialog'; export function CalendarPage() { const view = useCalendarStore((s) => s.view); @@ -8,12 +11,12 @@ export function CalendarPage() { return (
+ {view === 'day' && } + {view === 'week' && } {view === 'month' && } - {(view === 'day' || view === 'week') && ( -
- Day/Week view coming soon -
- )} + {view === 'year' && } + {view === 'schedule' && } +
); } diff --git a/apps/web/src/calendar/DayView.tsx b/apps/web/src/calendar/DayView.tsx new file mode 100644 index 0000000..4b9af11 --- /dev/null +++ b/apps/web/src/calendar/DayView.tsx @@ -0,0 +1,54 @@ +import { useCalendarStore } from '../stores/calendar'; +import { format, isSameDay, parseISO } from 'date-fns'; + +const HOURS = Array.from({ length: 16 }, (_, i) => i + 6); + +export function DayView() { + const currentDate = useCalendarStore((s) => s.currentDate); + const events = useCalendarStore((s) => s.events); + + const date = new Date(currentDate); + const dayEvents = events.filter((e) => isSameDay(parseISO(e.startTime), date)); + + return ( +
+
+

{format(date, 'EEEE, MMMM d')}

+
+ +
+ {HOURS.map((hour) => ( +
+
+ {format(new Date().setHours(hour, 0, 0, 0), 'ha')} +
+
+
+ ))} + + {dayEvents.map((event) => { + const startHour = parseISO(event.startTime).getHours(); + const endHour = parseISO(event.endTime ?? event.startTime).getHours(); + const duration = Math.max(endHour - startHour, 1); + + return ( +
+
{event.title}
+
{format(parseISO(event.startTime), 'h:mm a')}
+
+ ); + })} +
+
+ ); +} diff --git a/apps/web/src/calendar/EventDialog.tsx b/apps/web/src/calendar/EventDialog.tsx new file mode 100644 index 0000000..430122b --- /dev/null +++ b/apps/web/src/calendar/EventDialog.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@yetanother/ui'; +import { useCalendarStore, type CalendarEvent } from '../stores/calendar'; + +interface EventDialogProps { + open?: boolean; + onClose?: () => void; +} + +export function EventDialog({ open: controlledOpen, onClose }: EventDialogProps) { + const [internalOpen, setInternalOpen] = useState(false); + const addEvent = useCalendarStore((s) => s.addEvent); + const open = controlledOpen ?? internalOpen; + const close = onClose ?? (() => setInternalOpen(false)); + + const [title, setTitle] = useState(''); + const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); + const [startTime, setStartTime] = useState('09:00'); + const [endTime, setEndTime] = useState('10:00'); + const [color, setColor] = useState('#3b82f6'); + + const handleSubmit = () => { + if (!title.trim()) return; + const event: CalendarEvent = { + id: crypto.randomUUID(), + title: title.trim(), + description: '', + startTime: new Date(`${date}T${startTime}`).toISOString(), + endTime: new Date(`${date}T${endTime}`).toISOString(), + allDay: false, + recurrenceRule: null, + color, + workspaceId: 'default', + taskId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + addEvent(event); + setTitle(''); + close(); + }; + + return ( + !v && close()}> + + + New Event + +
+ setTitle(e.target.value)} + placeholder="Event title" + className="w-full px-3 py-2 text-sm rounded-md border bg-background focus:outline-none focus:ring-2 focus:ring-ring" + autoFocus + onKeyDown={(e) => e.key === 'Enter' && handleSubmit()} + /> +
+
+ + setDate(e.target.value)} + className="w-full px-3 py-2 text-sm rounded-md border bg-background focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+ + setColor(e.target.value)} + className="w-full h-9 rounded-md border cursor-pointer" + /> +
+
+
+
+ + setStartTime(e.target.value)} + className="w-full px-3 py-2 text-sm rounded-md border bg-background focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+ + setEndTime(e.target.value)} + className="w-full px-3 py-2 text-sm rounded-md border bg-background focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/calendar/WeekView.tsx b/apps/web/src/calendar/WeekView.tsx new file mode 100644 index 0000000..d81a6b8 --- /dev/null +++ b/apps/web/src/calendar/WeekView.tsx @@ -0,0 +1,72 @@ +import { useCalendarStore, type CalendarEvent } from '../stores/calendar'; +import { startOfWeek, endOfWeek, eachDayOfInterval, format, isSameDay, isToday, parseISO } from 'date-fns'; +import { cn } from '@yetanother/utils'; + +const HOURS = Array.from({ length: 14 }, (_, i) => i + 7); + +function getEventsForDay(events: CalendarEvent[], day: Date) { + return events.filter((e) => isSameDay(parseISO(e.startTime), day)); +} + +export function WeekView() { + const currentDate = useCalendarStore((s) => s.currentDate); + const events = useCalendarStore((s) => s.events); + + const date = new Date(currentDate); + const weekStart = startOfWeek(date, { weekStartsOn: 0 }); + const weekEnd = endOfWeek(date, { weekStartsOn: 0 }); + const days = eachDayOfInterval({ start: weekStart, end: weekEnd }); + + return ( +
+
+
+ {days.map((day) => ( +
+
{format(day, 'EEE')}
+
{format(day, 'd')}
+
+ ))} +
+ +
+
+ {HOURS.map((hour) => ( +
+ {format(new Date().setHours(hour, 0, 0, 0), 'ha')} +
+ ))} +
+ + {days.map((day) => { + const dayEvents = getEventsForDay(events, day); + return ( +
+ {HOURS.map((hour) => ( +
+ ))} + {dayEvents.map((event) => { + const startHour = parseISO(event.startTime).getHours(); + return ( +
+
{event.title}
+
+ ); + })} +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/src/editor/Editor.tsx b/apps/web/src/editor/Editor.tsx index 55db05e..4042365 100644 --- a/apps/web/src/editor/Editor.tsx +++ b/apps/web/src/editor/Editor.tsx @@ -1,15 +1,19 @@ +import { useEffect, useCallback } from 'react'; import { useEditor, EditorContent, type Editor } from '@tiptap/react'; import { extensions } from './extensions'; import { EditorToolbar } from './EditorToolbar'; import { SlashMenu } from './SlashMenu'; +import { AIAssistant } from '../ai/AIAssistant'; +import { collaboration } from '../lib/collaboration'; interface EditorProps { content?: Record; onChange?: (json: Record) => void; editable?: boolean; + nodeId?: string; } -export function BlockEditor({ content, onChange, editable = true }: EditorProps) { +export function BlockEditor({ content, onChange, editable = true, nodeId }: EditorProps) { const editor = useEditor({ extensions, content: content ?? { type: 'doc', content: [{ type: 'paragraph' }] }, @@ -25,11 +29,44 @@ export function BlockEditor({ content, onChange, editable = true }: EditorProps) }, }); + useEffect(() => { + if (!nodeId || !editor) return; + + collaboration.connect(nodeId); + + const unsubscribe = collaboration.onMessage((msg) => { + if (msg.type === 'update' && msg.sender !== 'self') { + // Apply remote updates (simplified — real CRDT sync uses y-prosemirror) + } + }); + + return () => { + unsubscribe(); + collaboration.disconnect(); + }; + }, [nodeId, editor]); + + const handleAIApply = useCallback((text: string) => { + if (!editor) return; + editor.commands.insertContent(`\n\n${text}\n\n`); + }, [editor]); + + const getText = useCallback(() => { + return editor?.getText() ?? ''; + }, [editor]); + if (!editor) return null; return (
- {editable && } +
+ {editable && } + {editable && ( +
+ +
+ )} +