Implement shared RFC 5545 VEvent library with workspace restructuring
- Created calendar-models/ shared library with RFC 5545-compliant VEvent structures - Migrated backend to use shared VEvent with proper field mappings (dtstart/dtend, rrule, exdate, etc.) - Converted CalDAV client to parse into VEvent structures with structured types - Updated all CRUD handlers to use VEvent with CalendarUser, Attendee, VAlarm types - Restructured project as Cargo workspace with frontend/, backend/, calendar-models/ - Updated Trunk configuration for new directory structure - Fixed all compilation errors and field references throughout codebase - Updated documentation and build instructions for workspace structure 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
		
							
								
								
									
										316
									
								
								frontend/src/components/calendar.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										316
									
								
								frontend/src/components/calendar.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,316 @@ | ||||
| use yew::prelude::*; | ||||
| use chrono::{Datelike, Local, NaiveDate, Duration}; | ||||
| use std::collections::HashMap; | ||||
| use web_sys::MouseEvent; | ||||
| use crate::services::calendar_service::UserInfo; | ||||
| use crate::models::ical::VEvent; | ||||
| use crate::components::{EventModal, ViewMode, CalendarHeader, MonthView, WeekView, CreateEventModal, EventCreationData}; | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct CalendarProps { | ||||
|     #[prop_or_default] | ||||
|     pub events: HashMap<NaiveDate, Vec<VEvent>>, | ||||
|     pub on_event_click: Callback<VEvent>, | ||||
|     #[prop_or_default] | ||||
|     pub refreshing_event_uid: Option<String>, | ||||
|     #[prop_or_default] | ||||
|     pub user_info: Option<UserInfo>, | ||||
|     #[prop_or_default] | ||||
|     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, | ||||
|     #[prop_or_default] | ||||
|     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>, | ||||
|     #[prop_or_default] | ||||
|     pub view: ViewMode, | ||||
|     #[prop_or_default] | ||||
|     pub on_create_event_request: Option<Callback<EventCreationData>>, | ||||
|     #[prop_or_default] | ||||
|     pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)>>, | ||||
|     #[prop_or_default] | ||||
|     pub context_menus_open: bool, | ||||
| } | ||||
|  | ||||
| #[function_component] | ||||
| pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|     let today = Local::now().date_naive(); | ||||
|     // Track the currently selected date (the actual day the user has selected) | ||||
|     let selected_date = use_state(|| { | ||||
|         // Try to load saved selected date from localStorage | ||||
|         if let Ok(saved_date_str) = LocalStorage::get::<String>("calendar_selected_date") { | ||||
|             if let Ok(saved_date) = NaiveDate::parse_from_str(&saved_date_str, "%Y-%m-%d") { | ||||
|                 saved_date | ||||
|             } else { | ||||
|                 today | ||||
|             } | ||||
|         } else { | ||||
|             // Check for old key for backward compatibility | ||||
|             if let Ok(saved_date_str) = LocalStorage::get::<String>("calendar_current_month") { | ||||
|                 if let Ok(saved_date) = NaiveDate::parse_from_str(&saved_date_str, "%Y-%m-%d") { | ||||
|                     saved_date | ||||
|                 } else { | ||||
|                     today | ||||
|                 } | ||||
|             } else { | ||||
|                 today | ||||
|             } | ||||
|         } | ||||
|     }); | ||||
|      | ||||
|     // Track the display date (what to show in the view) | ||||
|     let current_date = use_state(|| { | ||||
|         match props.view { | ||||
|             ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date), | ||||
|             ViewMode::Week => *selected_date, | ||||
|         } | ||||
|     }); | ||||
|     let selected_event = use_state(|| None::<VEvent>); | ||||
|      | ||||
|     // State for create event modal | ||||
|     let show_create_modal = use_state(|| false); | ||||
|     let create_event_data = use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>); | ||||
|      | ||||
|     // State for time increment snapping (15 or 30 minutes) | ||||
|     let time_increment = use_state(|| { | ||||
|         // Try to load saved time increment from localStorage | ||||
|         if let Ok(saved_increment) = LocalStorage::get::<u32>("calendar_time_increment") { | ||||
|             if saved_increment == 15 || saved_increment == 30 { | ||||
|                 saved_increment | ||||
|             } else { | ||||
|                 15 | ||||
|             } | ||||
|         } else { | ||||
|             15 | ||||
|         } | ||||
|     }); | ||||
|      | ||||
|     // Handle view mode changes - adjust current_date format when switching between month/week | ||||
|     { | ||||
|         let current_date = current_date.clone(); | ||||
|         let selected_date = selected_date.clone(); | ||||
|         let view = props.view.clone(); | ||||
|         use_effect_with(view, move |view_mode| { | ||||
|             let selected = *selected_date; | ||||
|             let new_display_date = match view_mode { | ||||
|                 ViewMode::Month => selected.with_day(1).unwrap_or(selected), | ||||
|                 ViewMode::Week => selected, // Show the week containing the selected date | ||||
|             }; | ||||
|             current_date.set(new_display_date); | ||||
|             || {} | ||||
|         }); | ||||
|     } | ||||
|      | ||||
|     let on_prev = { | ||||
|         let current_date = current_date.clone(); | ||||
|         let selected_date = selected_date.clone(); | ||||
|         let view = props.view.clone(); | ||||
|         Callback::from(move |_: MouseEvent| { | ||||
|             let (new_selected, new_display) = match view { | ||||
|                 ViewMode::Month => { | ||||
|                     // Go to previous month, select the 1st day | ||||
|                     let prev_month = *current_date - Duration::days(1); | ||||
|                     let first_of_prev = prev_month.with_day(1).unwrap(); | ||||
|                     (first_of_prev, first_of_prev) | ||||
|                 }, | ||||
|                 ViewMode::Week => { | ||||
|                     // Go to previous week | ||||
|                     let prev_week = *selected_date - Duration::weeks(1); | ||||
|                     (prev_week, prev_week) | ||||
|                 }, | ||||
|             }; | ||||
|             selected_date.set(new_selected); | ||||
|             current_date.set(new_display); | ||||
|             let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string()); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     let on_next = { | ||||
|         let current_date = current_date.clone(); | ||||
|         let selected_date = selected_date.clone(); | ||||
|         let view = props.view.clone(); | ||||
|         Callback::from(move |_: MouseEvent| { | ||||
|             let (new_selected, new_display) = match view { | ||||
|                 ViewMode::Month => { | ||||
|                     // Go to next month, select the 1st day | ||||
|                     let next_month = if current_date.month() == 12 { | ||||
|                         NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap() | ||||
|                     } else { | ||||
|                         NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1).unwrap() | ||||
|                     }; | ||||
|                     (next_month, next_month) | ||||
|                 }, | ||||
|                 ViewMode::Week => { | ||||
|                     // Go to next week | ||||
|                     let next_week = *selected_date + Duration::weeks(1); | ||||
|                     (next_week, next_week) | ||||
|                 }, | ||||
|             }; | ||||
|             selected_date.set(new_selected); | ||||
|             current_date.set(new_display); | ||||
|             let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_today = { | ||||
|         let current_date = current_date.clone(); | ||||
|         let selected_date = selected_date.clone(); | ||||
|         let view = props.view.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             let today = Local::now().date_naive(); | ||||
|             let (new_selected, new_display) = match view { | ||||
|                 ViewMode::Month => { | ||||
|                     let first_of_today = today.with_day(1).unwrap(); | ||||
|                     (today, first_of_today) // Select today, but display the month | ||||
|                 }, | ||||
|                 ViewMode::Week => (today, today), // Select and display today | ||||
|             }; | ||||
|             selected_date.set(new_selected); | ||||
|             current_date.set(new_display); | ||||
|             let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string()); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     // Handle time increment toggle | ||||
|     let on_time_increment_toggle = { | ||||
|         let time_increment = time_increment.clone(); | ||||
|         Callback::from(move |_: MouseEvent| { | ||||
|             let current = *time_increment; | ||||
|             let next = if current == 15 { 30 } else { 15 }; | ||||
|             time_increment.set(next); | ||||
|             let _ = LocalStorage::set("calendar_time_increment", next); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     // Handle drag-to-create event | ||||
|     let on_create_event = { | ||||
|         let show_create_modal = show_create_modal.clone(); | ||||
|         let create_event_data = create_event_data.clone(); | ||||
|         Callback::from(move |(_date, start_datetime, end_datetime): (NaiveDate, chrono::NaiveDateTime, chrono::NaiveDateTime)| { | ||||
|             // For drag-to-create, we don't need the temporary event approach | ||||
|             // Instead, just pass the local times directly via initial_time props | ||||
|             create_event_data.set(Some((start_datetime.date(), start_datetime.time(), end_datetime.time()))); | ||||
|             show_create_modal.set(true); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     // Handle drag-to-move event | ||||
|     let on_event_update = { | ||||
|         let on_event_update_request = props.on_event_update_request.clone(); | ||||
|         Callback::from(move |(event, new_start, new_end, preserve_rrule, until_date): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)| { | ||||
|             if let Some(callback) = &on_event_update_request { | ||||
|                 callback.emit((event, new_start, new_end, preserve_rrule, until_date)); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <div class={classes!("calendar", match props.view { ViewMode::Week => Some("week-view"), _ => None })}> | ||||
|             <CalendarHeader  | ||||
|                 current_date={*current_date} | ||||
|                 view_mode={props.view.clone()} | ||||
|                 on_prev={on_prev} | ||||
|                 on_next={on_next} | ||||
|                 on_today={on_today} | ||||
|                 time_increment={Some(*time_increment)} | ||||
|                 on_time_increment_toggle={Some(on_time_increment_toggle)} | ||||
|             /> | ||||
|              | ||||
|             { | ||||
|                 match props.view { | ||||
|                     ViewMode::Month => { | ||||
|                         let on_day_select = { | ||||
|                             let selected_date = selected_date.clone(); | ||||
|                             Callback::from(move |date: NaiveDate| { | ||||
|                                 selected_date.set(date); | ||||
|                                 let _ = LocalStorage::set("calendar_selected_date", date.format("%Y-%m-%d").to_string()); | ||||
|                             }) | ||||
|                         }; | ||||
|                          | ||||
|                         html! { | ||||
|                             <MonthView | ||||
|                                 current_month={*current_date} | ||||
|                                 today={today} | ||||
|                                 events={props.events.clone()} | ||||
|                                 on_event_click={props.on_event_click.clone()} | ||||
|                                 refreshing_event_uid={props.refreshing_event_uid.clone()} | ||||
|                                 user_info={props.user_info.clone()} | ||||
|                                 on_event_context_menu={props.on_event_context_menu.clone()} | ||||
|                                 on_calendar_context_menu={props.on_calendar_context_menu.clone()} | ||||
|                                 selected_date={Some(*selected_date)} | ||||
|                                 on_day_select={Some(on_day_select)} | ||||
|                             /> | ||||
|                         } | ||||
|                     }, | ||||
|                     ViewMode::Week => html! { | ||||
|                         <WeekView | ||||
|                             current_date={*current_date} | ||||
|                             today={today} | ||||
|                             events={props.events.clone()} | ||||
|                             on_event_click={props.on_event_click.clone()} | ||||
|                             refreshing_event_uid={props.refreshing_event_uid.clone()} | ||||
|                             user_info={props.user_info.clone()} | ||||
|                             on_event_context_menu={props.on_event_context_menu.clone()} | ||||
|                             on_calendar_context_menu={props.on_calendar_context_menu.clone()} | ||||
|                             on_create_event={Some(on_create_event)} | ||||
|                             on_create_event_request={props.on_create_event_request.clone()} | ||||
|                             on_event_update={Some(on_event_update)} | ||||
|                             context_menus_open={props.context_menus_open} | ||||
|                             time_increment={*time_increment} | ||||
|                         /> | ||||
|                     }, | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             // Event details modal | ||||
|             <EventModal  | ||||
|                 event={(*selected_event).clone()} | ||||
|                 on_close={{ | ||||
|                     let selected_event_clone = selected_event.clone(); | ||||
|                     Callback::from(move |_| { | ||||
|                         selected_event_clone.set(None); | ||||
|                     }) | ||||
|                 }} | ||||
|             /> | ||||
|              | ||||
|             // Create event modal | ||||
|             <CreateEventModal | ||||
|                 is_open={*show_create_modal} | ||||
|                 selected_date={create_event_data.as_ref().map(|(date, _, _)| *date)} | ||||
|                 event_to_edit={None} | ||||
|                 available_calendars={props.user_info.as_ref().map(|info| info.calendars.clone()).unwrap_or_default()} | ||||
|                 initial_start_time={create_event_data.as_ref().map(|(_, start_time, _)| *start_time)} | ||||
|                 initial_end_time={create_event_data.as_ref().map(|(_, _, end_time)| *end_time)} | ||||
|                 on_close={{ | ||||
|                     let show_create_modal = show_create_modal.clone(); | ||||
|                     let create_event_data = create_event_data.clone(); | ||||
|                     Callback::from(move |_| { | ||||
|                         show_create_modal.set(false); | ||||
|                         create_event_data.set(None); | ||||
|                     }) | ||||
|                 }} | ||||
|                 on_create={{ | ||||
|                     let show_create_modal = show_create_modal.clone(); | ||||
|                     let create_event_data = create_event_data.clone(); | ||||
|                     let on_create_event_request = props.on_create_event_request.clone(); | ||||
|                     Callback::from(move |event_data: EventCreationData| { | ||||
|                         show_create_modal.set(false); | ||||
|                         create_event_data.set(None); | ||||
|                          | ||||
|                         // Emit the create event request to parent | ||||
|                         if let Some(callback) = &on_create_event_request { | ||||
|                             callback.emit(event_data); | ||||
|                         } | ||||
|                     }) | ||||
|                 }} | ||||
|                 on_update={{ | ||||
|                     let show_create_modal = show_create_modal.clone(); | ||||
|                     let create_event_data = create_event_data.clone(); | ||||
|                     Callback::from(move |(_original_event, _updated_data): (VEvent, EventCreationData)| { | ||||
|                         show_create_modal.set(false); | ||||
|                         create_event_data.set(None); | ||||
|                         // TODO: Handle actual event update | ||||
|                     }) | ||||
|                 }} | ||||
|             /> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										47
									
								
								frontend/src/components/calendar_context_menu.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								frontend/src/components/calendar_context_menu.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| use yew::prelude::*; | ||||
| use web_sys::MouseEvent; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct CalendarContextMenuProps { | ||||
|     pub is_open: bool, | ||||
|     pub x: i32, | ||||
|     pub y: i32, | ||||
|     pub on_close: Callback<()>, | ||||
|     pub on_create_event: Callback<MouseEvent>, | ||||
| } | ||||
|  | ||||
| #[function_component(CalendarContextMenu)] | ||||
| pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html { | ||||
|     let menu_ref = use_node_ref(); | ||||
|      | ||||
|     if !props.is_open { | ||||
|         return html! {}; | ||||
|     } | ||||
|  | ||||
|     let style = format!( | ||||
|         "position: fixed; left: {}px; top: {}px; z-index: 1001;", | ||||
|         props.x, props.y | ||||
|     ); | ||||
|  | ||||
|     let on_create_event_click = { | ||||
|         let on_create_event = props.on_create_event.clone(); | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |e: MouseEvent| { | ||||
|             on_create_event.emit(e); | ||||
|             on_close.emit(()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <div  | ||||
|             ref={menu_ref} | ||||
|             class="context-menu"  | ||||
|             style={style} | ||||
|         > | ||||
|             <div class="context-menu-item context-menu-create" onclick={on_create_event_click}> | ||||
|                 <span class="context-menu-icon">{"+"}</span> | ||||
|                 {"Create Event"} | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										64
									
								
								frontend/src/components/calendar_header.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								frontend/src/components/calendar_header.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| use yew::prelude::*; | ||||
| use chrono::{NaiveDate, Datelike}; | ||||
| use crate::components::ViewMode; | ||||
| use web_sys::MouseEvent; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct CalendarHeaderProps { | ||||
|     pub current_date: NaiveDate, | ||||
|     pub view_mode: ViewMode, | ||||
|     pub on_prev: Callback<MouseEvent>, | ||||
|     pub on_next: Callback<MouseEvent>, | ||||
|     pub on_today: Callback<MouseEvent>, | ||||
|     #[prop_or_default] | ||||
|     pub time_increment: Option<u32>, | ||||
|     #[prop_or_default] | ||||
|     pub on_time_increment_toggle: Option<Callback<MouseEvent>>, | ||||
| } | ||||
|  | ||||
| #[function_component(CalendarHeader)] | ||||
| pub fn calendar_header(props: &CalendarHeaderProps) -> Html { | ||||
|     let title = format!("{} {}", get_month_name(props.current_date.month()), props.current_date.year()); | ||||
|  | ||||
|     html! { | ||||
|         <div class="calendar-header"> | ||||
|             <div class="header-left"> | ||||
|                 <button class="nav-button" onclick={props.on_prev.clone()}>{"‹"}</button> | ||||
|                 { | ||||
|                     if let (Some(increment), Some(callback)) = (props.time_increment, &props.on_time_increment_toggle) { | ||||
|                         html! { | ||||
|                             <button class="time-increment-button" onclick={callback.clone()}> | ||||
|                                 {format!("{}", increment)} | ||||
|                             </button> | ||||
|                         } | ||||
|                     } else { | ||||
|                         html! {} | ||||
|                     } | ||||
|                 } | ||||
|             </div> | ||||
|             <h2 class="month-year">{title}</h2> | ||||
|             <div class="header-right"> | ||||
|                 <button class="today-button" onclick={props.on_today.clone()}>{"Today"}</button> | ||||
|                 <button class="nav-button" onclick={props.on_next.clone()}>{"›"}</button> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn get_month_name(month: u32) -> &'static str { | ||||
|     match month { | ||||
|         1 => "January", | ||||
|         2 => "February",  | ||||
|         3 => "March", | ||||
|         4 => "April", | ||||
|         5 => "May", | ||||
|         6 => "June", | ||||
|         7 => "July", | ||||
|         8 => "August", | ||||
|         9 => "September", | ||||
|         10 => "October", | ||||
|         11 => "November", | ||||
|         12 => "December", | ||||
|         _ => "Invalid" | ||||
|     } | ||||
| } | ||||
							
								
								
									
										75
									
								
								frontend/src/components/calendar_list_item.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								frontend/src/components/calendar_list_item.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | ||||
| use yew::prelude::*; | ||||
| use web_sys::MouseEvent; | ||||
| use crate::services::calendar_service::CalendarInfo; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct CalendarListItemProps { | ||||
|     pub calendar: CalendarInfo, | ||||
|     pub color_picker_open: bool, | ||||
|     pub on_color_change: Callback<(String, String)>, // (calendar_path, color) | ||||
|     pub on_color_picker_toggle: Callback<String>, // calendar_path | ||||
|     pub available_colors: Vec<String>, | ||||
|     pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path) | ||||
| } | ||||
|  | ||||
| #[function_component(CalendarListItem)] | ||||
| pub fn calendar_list_item(props: &CalendarListItemProps) -> Html { | ||||
|     let on_color_click = { | ||||
|         let cal_path = props.calendar.path.clone(); | ||||
|         let on_color_picker_toggle = props.on_color_picker_toggle.clone(); | ||||
|         Callback::from(move |e: MouseEvent| { | ||||
|             e.stop_propagation(); | ||||
|             on_color_picker_toggle.emit(cal_path.clone()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_context_menu = { | ||||
|         let cal_path = props.calendar.path.clone(); | ||||
|         let on_context_menu = props.on_context_menu.clone(); | ||||
|         Callback::from(move |e: MouseEvent| { | ||||
|             e.prevent_default(); | ||||
|             on_context_menu.emit((e, cal_path.clone())); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}> | ||||
|             <span class="calendar-color"  | ||||
|                   style={format!("background-color: {}", props.calendar.color)} | ||||
|                   onclick={on_color_click}> | ||||
|                 { | ||||
|                     if props.color_picker_open { | ||||
|                         html! { | ||||
|                             <div class="color-picker"> | ||||
|                                 { | ||||
|                                     props.available_colors.iter().map(|color| { | ||||
|                                         let color_str = color.clone(); | ||||
|                                         let cal_path = props.calendar.path.clone(); | ||||
|                                         let on_color_change = props.on_color_change.clone(); | ||||
|                                          | ||||
|                                         let on_color_select = Callback::from(move |_: MouseEvent| { | ||||
|                                             on_color_change.emit((cal_path.clone(), color_str.clone())); | ||||
|                                         }); | ||||
|                                          | ||||
|                                         let is_selected = props.calendar.color == *color; | ||||
|                                         let class_name = if is_selected { "color-option selected" } else { "color-option" }; | ||||
|                                          | ||||
|                                         html! { | ||||
|                                             <div class={class_name} | ||||
|                                                  style={format!("background-color: {}", color)} | ||||
|                                                  onclick={on_color_select}> | ||||
|                                             </div> | ||||
|                                         } | ||||
|                                     }).collect::<Html>() | ||||
|                                 } | ||||
|                             </div> | ||||
|                         } | ||||
|                     } else { | ||||
|                         html! {} | ||||
|                     } | ||||
|                 } | ||||
|             </span> | ||||
|             <span class="calendar-name">{&props.calendar.display_name}</span> | ||||
|         </li> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										49
									
								
								frontend/src/components/context_menu.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								frontend/src/components/context_menu.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| use yew::prelude::*; | ||||
| use web_sys::MouseEvent; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct ContextMenuProps { | ||||
|     pub is_open: bool, | ||||
|     pub x: i32, | ||||
|     pub y: i32, | ||||
|     pub on_delete: Callback<MouseEvent>, | ||||
|     pub on_close: Callback<()>, | ||||
| } | ||||
|  | ||||
| #[function_component(ContextMenu)] | ||||
| pub fn context_menu(props: &ContextMenuProps) -> Html { | ||||
|     let menu_ref = use_node_ref(); | ||||
|      | ||||
|     // Close menu when clicking outside (handled by parent component) | ||||
|  | ||||
|     if !props.is_open { | ||||
|         return html! {}; | ||||
|     } | ||||
|  | ||||
|     let style = format!( | ||||
|         "position: fixed; left: {}px; top: {}px; z-index: 1001;", | ||||
|         props.x, props.y | ||||
|     ); | ||||
|  | ||||
|     let on_delete_click = { | ||||
|         let on_delete = props.on_delete.clone(); | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |e: MouseEvent| { | ||||
|             on_delete.emit(e); | ||||
|             on_close.emit(()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <div  | ||||
|             ref={menu_ref} | ||||
|             class="context-menu"  | ||||
|             style={style} | ||||
|         > | ||||
|             <div class="context-menu-item context-menu-delete" onclick={on_delete_click}> | ||||
|                 <span class="context-menu-icon">{"🗑️"}</span> | ||||
|                 {"Delete Calendar"} | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										196
									
								
								frontend/src/components/create_calendar_modal.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										196
									
								
								frontend/src/components/create_calendar_modal.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,196 @@ | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct CreateCalendarModalProps { | ||||
|     pub is_open: bool, | ||||
|     pub on_close: Callback<()>, | ||||
|     pub on_create: Callback<(String, Option<String>, Option<String>)>, // name, description, color | ||||
|     pub available_colors: Vec<String>, | ||||
| } | ||||
|  | ||||
| #[function_component] | ||||
| pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html { | ||||
|     let calendar_name = use_state(|| String::new()); | ||||
|     let description = use_state(|| String::new()); | ||||
|     let selected_color = use_state(|| None::<String>); | ||||
|     let error_message = use_state(|| None::<String>); | ||||
|     let is_creating = use_state(|| false); | ||||
|  | ||||
|     let on_name_change = { | ||||
|         let calendar_name = calendar_name.clone(); | ||||
|         Callback::from(move |e: InputEvent| { | ||||
|             let input: web_sys::HtmlInputElement = e.target_unchecked_into(); | ||||
|             calendar_name.set(input.value()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_description_change = { | ||||
|         let description = description.clone(); | ||||
|         Callback::from(move |e: InputEvent| { | ||||
|             let input: web_sys::HtmlTextAreaElement = e.target_unchecked_into(); | ||||
|             description.set(input.value()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_submit = { | ||||
|         let calendar_name = calendar_name.clone(); | ||||
|         let description = description.clone(); | ||||
|         let selected_color = selected_color.clone(); | ||||
|         let error_message = error_message.clone(); | ||||
|         let is_creating = is_creating.clone(); | ||||
|         let on_create = props.on_create.clone(); | ||||
|          | ||||
|         Callback::from(move |e: SubmitEvent| { | ||||
|             e.prevent_default(); | ||||
|              | ||||
|             let name = (*calendar_name).trim(); | ||||
|             if name.is_empty() { | ||||
|                 error_message.set(Some("Calendar name is required".to_string())); | ||||
|                 return; | ||||
|             } | ||||
|              | ||||
|             if name.len() > 100 { | ||||
|                 error_message.set(Some("Calendar name too long (max 100 characters)".to_string())); | ||||
|                 return; | ||||
|             } | ||||
|              | ||||
|             error_message.set(None); | ||||
|             is_creating.set(true); | ||||
|              | ||||
|             let desc = if (*description).trim().is_empty() { | ||||
|                 None | ||||
|             } else { | ||||
|                 Some((*description).clone()) | ||||
|             }; | ||||
|              | ||||
|             on_create.emit((name.to_string(), desc, (*selected_color).clone())); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_backdrop_click = { | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |e: MouseEvent| { | ||||
|             // Only close if clicking the backdrop, not the modal content | ||||
|             if e.target() == e.current_target() { | ||||
|                 on_close.emit(()); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     if !props.is_open { | ||||
|         return html! {}; | ||||
|     } | ||||
|  | ||||
|     html! { | ||||
|         <div class="modal-backdrop" onclick={on_backdrop_click}> | ||||
|             <div class="create-calendar-modal"> | ||||
|                 <div class="modal-header"> | ||||
|                     <h2>{"Create New Calendar"}</h2> | ||||
|                     <button class="close-button" onclick={props.on_close.reform(|_| ())}> | ||||
|                         {"×"} | ||||
|                     </button> | ||||
|                 </div> | ||||
|                  | ||||
|                 <form class="modal-body" onsubmit={on_submit}> | ||||
|                     { | ||||
|                         if let Some(ref error) = *error_message { | ||||
|                             html! { | ||||
|                                 <div class="error-message"> | ||||
|                                     {error} | ||||
|                                 </div> | ||||
|                             } | ||||
|                         } else { | ||||
|                             html! {} | ||||
|                         } | ||||
|                     } | ||||
|                      | ||||
|                     <div class="form-group"> | ||||
|                         <label for="calendar-name">{"Calendar Name *"}</label> | ||||
|                         <input  | ||||
|                             id="calendar-name" | ||||
|                             type="text" | ||||
|                             value={(*calendar_name).clone()} | ||||
|                             oninput={on_name_change} | ||||
|                             placeholder="Enter calendar name" | ||||
|                             maxlength="100" | ||||
|                             disabled={*is_creating} | ||||
|                         /> | ||||
|                     </div> | ||||
|                      | ||||
|                     <div class="form-group"> | ||||
|                         <label for="calendar-description">{"Description"}</label> | ||||
|                         <textarea | ||||
|                             id="calendar-description" | ||||
|                             value={(*description).clone()} | ||||
|                             oninput={on_description_change} | ||||
|                             placeholder="Optional calendar description" | ||||
|                             rows="3" | ||||
|                             disabled={*is_creating} | ||||
|                         /> | ||||
|                     </div> | ||||
|                      | ||||
|                     <div class="form-group"> | ||||
|                         <label>{"Calendar Color"}</label> | ||||
|                         <div class="color-grid"> | ||||
|                             { | ||||
|                                 props.available_colors.iter().enumerate().map(|(index, color)| { | ||||
|                                     let color = color.clone(); | ||||
|                                     let selected_color = selected_color.clone(); | ||||
|                                     let is_selected = selected_color.as_ref() == Some(&color); | ||||
|                                     let on_color_select = { | ||||
|                                         let color = color.clone(); | ||||
|                                         Callback::from(move |_: MouseEvent| { | ||||
|                                             selected_color.set(Some(color.clone())); | ||||
|                                         }) | ||||
|                                     }; | ||||
|                                      | ||||
|                                     let class_name = if is_selected {  | ||||
|                                         "color-option selected"  | ||||
|                                     } else {  | ||||
|                                         "color-option"  | ||||
|                                     }; | ||||
|                                      | ||||
|                                     html! { | ||||
|                                         <button | ||||
|                                             key={index} | ||||
|                                             type="button" | ||||
|                                             class={class_name} | ||||
|                                             style={format!("background-color: {}", color)} | ||||
|                                             onclick={on_color_select} | ||||
|                                             disabled={*is_creating} | ||||
|                                         /> | ||||
|                                     } | ||||
|                                 }).collect::<Html>() | ||||
|                             } | ||||
|                         </div> | ||||
|                         <p class="color-help-text">{"Optional: Choose a color for your calendar"}</p> | ||||
|                     </div> | ||||
|                      | ||||
|                     <div class="modal-actions"> | ||||
|                         <button  | ||||
|                             type="button"  | ||||
|                             class="cancel-button" | ||||
|                             onclick={props.on_close.reform(|_| ())} | ||||
|                             disabled={*is_creating} | ||||
|                         > | ||||
|                             {"Cancel"} | ||||
|                         </button> | ||||
|                         <button  | ||||
|                             type="submit"  | ||||
|                             class="create-button" | ||||
|                             disabled={*is_creating} | ||||
|                         > | ||||
|                             { | ||||
|                                 if *is_creating { | ||||
|                                     "Creating..." | ||||
|                                 } else { | ||||
|                                     "Create Calendar" | ||||
|                                 } | ||||
|                             } | ||||
|                         </button> | ||||
|                     </div> | ||||
|                 </form> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										857
									
								
								frontend/src/components/create_event_modal.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										857
									
								
								frontend/src/components/create_event_modal.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,857 @@ | ||||
| use yew::prelude::*; | ||||
| use web_sys::{HtmlInputElement, HtmlTextAreaElement, HtmlSelectElement}; | ||||
| use chrono::{NaiveDate, NaiveTime, Local, TimeZone, Utc}; | ||||
| use crate::services::calendar_service::CalendarInfo; | ||||
| use crate::models::ical::VEvent; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct CreateEventModalProps { | ||||
|     pub is_open: bool, | ||||
|     pub selected_date: Option<NaiveDate>, | ||||
|     pub event_to_edit: Option<VEvent>, | ||||
|     pub on_close: Callback<()>, | ||||
|     pub on_create: Callback<EventCreationData>, | ||||
|     pub on_update: Callback<(VEvent, EventCreationData)>, // (original_event, updated_data) | ||||
|     pub available_calendars: Vec<CalendarInfo>, | ||||
|     #[prop_or_default] | ||||
|     pub initial_start_time: Option<NaiveTime>, | ||||
|     #[prop_or_default] | ||||
|     pub initial_end_time: Option<NaiveTime>, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub enum EventStatus { | ||||
|     Tentative, | ||||
|     Confirmed, | ||||
|     Cancelled, | ||||
| } | ||||
|  | ||||
| impl Default for EventStatus { | ||||
|     fn default() -> Self { | ||||
|         EventStatus::Confirmed | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub enum EventClass { | ||||
|     Public, | ||||
|     Private, | ||||
|     Confidential, | ||||
| } | ||||
|  | ||||
| impl Default for EventClass { | ||||
|     fn default() -> Self { | ||||
|         EventClass::Public | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub enum ReminderType { | ||||
|     None, | ||||
|     Minutes15, | ||||
|     Minutes30, | ||||
|     Hour1, | ||||
|     Hours2, | ||||
|     Day1, | ||||
|     Days2, | ||||
|     Week1, | ||||
| } | ||||
|  | ||||
| impl Default for ReminderType { | ||||
|     fn default() -> Self { | ||||
|         ReminderType::None | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub enum RecurrenceType { | ||||
|     None, | ||||
|     Daily, | ||||
|     Weekly, | ||||
|     Monthly, | ||||
|     Yearly, | ||||
| } | ||||
|  | ||||
| impl Default for RecurrenceType { | ||||
|     fn default() -> Self { | ||||
|         RecurrenceType::None | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl RecurrenceType { | ||||
|     pub fn from_rrule(rrule: Option<&str>) -> Self { | ||||
|         match rrule { | ||||
|             Some(rule) if rule.contains("FREQ=DAILY") => RecurrenceType::Daily, | ||||
|             Some(rule) if rule.contains("FREQ=WEEKLY") => RecurrenceType::Weekly, | ||||
|             Some(rule) if rule.contains("FREQ=MONTHLY") => RecurrenceType::Monthly, | ||||
|             Some(rule) if rule.contains("FREQ=YEARLY") => RecurrenceType::Yearly, | ||||
|             _ => RecurrenceType::None, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub struct EventCreationData { | ||||
|     pub title: String, | ||||
|     pub description: String, | ||||
|     pub start_date: NaiveDate, | ||||
|     pub start_time: NaiveTime, | ||||
|     pub end_date: NaiveDate, | ||||
|     pub end_time: NaiveTime, | ||||
|     pub location: String, | ||||
|     pub all_day: bool, | ||||
|     pub status: EventStatus, | ||||
|     pub class: EventClass, | ||||
|     pub priority: Option<u8>, | ||||
|     pub organizer: String, | ||||
|     pub attendees: String, // Comma-separated list | ||||
|     pub categories: String, // Comma-separated list | ||||
|     pub reminder: ReminderType, | ||||
|     pub recurrence: RecurrenceType, | ||||
|     pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence | ||||
|     pub selected_calendar: Option<String>, // Calendar path | ||||
| } | ||||
|  | ||||
| impl Default for EventCreationData { | ||||
|     fn default() -> Self { | ||||
|         let now = chrono::Local::now().naive_local(); | ||||
|         let start_time = NaiveTime::from_hms_opt(9, 0, 0).unwrap_or_default(); | ||||
|         let end_time = NaiveTime::from_hms_opt(10, 0, 0).unwrap_or_default(); | ||||
|          | ||||
|         Self { | ||||
|             title: String::new(), | ||||
|             description: String::new(), | ||||
|             start_date: now.date(), | ||||
|             start_time, | ||||
|             end_date: now.date(), | ||||
|             end_time, | ||||
|             location: String::new(), | ||||
|             all_day: false, | ||||
|             status: EventStatus::default(), | ||||
|             class: EventClass::default(), | ||||
|             priority: None, | ||||
|             organizer: String::new(), | ||||
|             attendees: String::new(), | ||||
|             categories: String::new(), | ||||
|             reminder: ReminderType::default(), | ||||
|             recurrence: RecurrenceType::default(), | ||||
|             recurrence_days: vec![false; 7], // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] - all false by default | ||||
|             selected_calendar: None, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl EventCreationData { | ||||
|     pub fn to_create_event_params(&self) -> (String, String, String, String, String, String, String, bool, String, String, Option<u8>, String, String, String, String, String, Vec<bool>, Option<String>) { | ||||
|         // Convert local date/time to UTC | ||||
|         let start_local = Local.from_local_datetime(&self.start_date.and_time(self.start_time)).single() | ||||
|             .unwrap_or_else(|| Local::now()); | ||||
|         let end_local = Local.from_local_datetime(&self.end_date.and_time(self.end_time)).single() | ||||
|             .unwrap_or_else(|| Local::now()); | ||||
|          | ||||
|         let start_utc = start_local.with_timezone(&Utc); | ||||
|         let end_utc = end_local.with_timezone(&Utc); | ||||
|          | ||||
|         ( | ||||
|             self.title.clone(), | ||||
|             self.description.clone(), | ||||
|             start_utc.format("%Y-%m-%d").to_string(), | ||||
|             start_utc.format("%H:%M").to_string(), | ||||
|             end_utc.format("%Y-%m-%d").to_string(), | ||||
|             end_utc.format("%H:%M").to_string(), | ||||
|             self.location.clone(), | ||||
|             self.all_day, | ||||
|             match self.status { | ||||
|                 EventStatus::Tentative => "TENTATIVE".to_string(), | ||||
|                 EventStatus::Confirmed => "CONFIRMED".to_string(), | ||||
|                 EventStatus::Cancelled => "CANCELLED".to_string(), | ||||
|             }, | ||||
|             match self.class { | ||||
|                 EventClass::Public => "PUBLIC".to_string(), | ||||
|                 EventClass::Private => "PRIVATE".to_string(), | ||||
|                 EventClass::Confidential => "CONFIDENTIAL".to_string(), | ||||
|             }, | ||||
|             self.priority, | ||||
|             self.organizer.clone(), | ||||
|             self.attendees.clone(), | ||||
|             self.categories.clone(), | ||||
|             match self.reminder { | ||||
|                 ReminderType::None => "".to_string(), | ||||
|                 ReminderType::Minutes15 => "15".to_string(), | ||||
|                 ReminderType::Minutes30 => "30".to_string(), | ||||
|                 ReminderType::Hour1 => "60".to_string(), | ||||
|                 ReminderType::Hours2 => "120".to_string(), | ||||
|                 ReminderType::Day1 => "1440".to_string(), | ||||
|                 ReminderType::Days2 => "2880".to_string(), | ||||
|                 ReminderType::Week1 => "10080".to_string(), | ||||
|             }, | ||||
|             match self.recurrence { | ||||
|                 RecurrenceType::None => "".to_string(), | ||||
|                 RecurrenceType::Daily => "DAILY".to_string(), | ||||
|                 RecurrenceType::Weekly => "WEEKLY".to_string(), | ||||
|                 RecurrenceType::Monthly => "MONTHLY".to_string(), | ||||
|                 RecurrenceType::Yearly => "YEARLY".to_string(), | ||||
|             }, | ||||
|             self.recurrence_days.clone(), | ||||
|             self.selected_calendar.clone() | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl EventCreationData { | ||||
|     pub fn from_calendar_event(event: &VEvent) -> Self { | ||||
|         // Convert VEvent to EventCreationData for editing | ||||
|         // All events (including temporary drag events) now have proper UTC times | ||||
|         // Convert to local time for display in the modal | ||||
|          | ||||
|         Self { | ||||
|             title: event.summary.clone().unwrap_or_default(), | ||||
|             description: event.description.clone().unwrap_or_default(), | ||||
|             start_date: event.dtstart.with_timezone(&chrono::Local).date_naive(), | ||||
|             start_time: event.dtstart.with_timezone(&chrono::Local).time(), | ||||
|             end_date: event.dtend.as_ref().map(|e| e.with_timezone(&chrono::Local).date_naive()).unwrap_or(event.dtstart.with_timezone(&chrono::Local).date_naive()), | ||||
|             end_time: event.dtend.as_ref().map(|e| e.with_timezone(&chrono::Local).time()).unwrap_or(event.dtstart.with_timezone(&chrono::Local).time()), | ||||
|             location: event.location.clone().unwrap_or_default(), | ||||
|             all_day: event.all_day, | ||||
|             status: event.status.as_ref().map(|s| match s { | ||||
|                 crate::models::ical::EventStatus::Tentative => EventStatus::Tentative, | ||||
|                 crate::models::ical::EventStatus::Confirmed => EventStatus::Confirmed, | ||||
|                 crate::models::ical::EventStatus::Cancelled => EventStatus::Cancelled, | ||||
|             }).unwrap_or(EventStatus::Confirmed), | ||||
|             class: event.class.as_ref().map(|c| match c { | ||||
|                 crate::models::ical::EventClass::Public => EventClass::Public, | ||||
|                 crate::models::ical::EventClass::Private => EventClass::Private, | ||||
|                 crate::models::ical::EventClass::Confidential => EventClass::Confidential, | ||||
|             }).unwrap_or(EventClass::Public), | ||||
|             priority: event.priority, | ||||
|             organizer: event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(), | ||||
|             attendees: event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(", "), | ||||
|             categories: event.categories.join(", "), | ||||
|             reminder: ReminderType::default(), // TODO: Convert from event reminders | ||||
|             recurrence: RecurrenceType::from_rrule(event.rrule.as_deref()), | ||||
|             recurrence_days: vec![false; 7], // TODO: Parse from RRULE | ||||
|             selected_calendar: event.calendar_path.clone(), | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| #[function_component(CreateEventModal)] | ||||
| pub fn create_event_modal(props: &CreateEventModalProps) -> Html { | ||||
|     let event_data = use_state(|| EventCreationData::default()); | ||||
|      | ||||
|     // Initialize with selected date or event data if provided | ||||
|     use_effect_with((props.selected_date, props.event_to_edit.clone(), props.is_open, props.available_calendars.clone(), props.initial_start_time, props.initial_end_time), { | ||||
|         let event_data = event_data.clone(); | ||||
|         move |(selected_date, event_to_edit, is_open, available_calendars, initial_start_time, initial_end_time)| { | ||||
|             if *is_open { | ||||
|                 let mut data = if let Some(event) = event_to_edit { | ||||
|                     // Pre-populate with event data for editing | ||||
|                     EventCreationData::from_calendar_event(event) | ||||
|                 } else if let Some(date) = selected_date { | ||||
|                     // Initialize with selected date for new event | ||||
|                     let mut data = EventCreationData::default(); | ||||
|                     data.start_date = *date; | ||||
|                     data.end_date = *date; | ||||
|                      | ||||
|                     // Use initial times if provided (from drag-to-create) | ||||
|                     if let Some(start_time) = initial_start_time { | ||||
|                         data.start_time = *start_time; | ||||
|                     } | ||||
|                     if let Some(end_time) = initial_end_time { | ||||
|                         data.end_time = *end_time; | ||||
|                     } | ||||
|                      | ||||
|                     data | ||||
|                 } else { | ||||
|                     // Default initialization | ||||
|                     EventCreationData::default() | ||||
|                 }; | ||||
|                  | ||||
|                 // Set default calendar to the first available one if none selected | ||||
|                 if data.selected_calendar.is_none() && !available_calendars.is_empty() { | ||||
|                     data.selected_calendar = Some(available_calendars[0].path.clone()); | ||||
|                 } | ||||
|                  | ||||
|                 event_data.set(data); | ||||
|             } | ||||
|             || () | ||||
|         } | ||||
|     }); | ||||
|      | ||||
|     if !props.is_open { | ||||
|         return html! {}; | ||||
|     } | ||||
|  | ||||
|     let on_backdrop_click = { | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |e: MouseEvent| { | ||||
|             if e.target() == e.current_target() { | ||||
|                 on_close.emit(()); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_title_input = { | ||||
|         let event_data = event_data.clone(); | ||||
|         Callback::from(move |e: InputEvent| { | ||||
|             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||
|                 let mut data = (*event_data).clone(); | ||||
|                 data.title = input.value(); | ||||
|                 event_data.set(data); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_calendar_change = { | ||||
|         let event_data = event_data.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() { | ||||
|                 let mut data = (*event_data).clone(); | ||||
|                 let value = select.value(); | ||||
|                 data.selected_calendar = if value.is_empty() { None } else { Some(value) }; | ||||
|                 event_data.set(data); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_description_input = { | ||||
|         let event_data = event_data.clone(); | ||||
|         Callback::from(move |e: InputEvent| { | ||||
|             if let Some(textarea) = e.target_dyn_into::<HtmlTextAreaElement>() { | ||||
|                 let mut data = (*event_data).clone(); | ||||
|                 data.description = textarea.value(); | ||||
|                 event_data.set(data); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_location_input = { | ||||
|         let event_data = event_data.clone(); | ||||
|         Callback::from(move |e: InputEvent| { | ||||
|             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||
|                 let mut data = (*event_data).clone(); | ||||
|                 data.location = input.value(); | ||||
|                 event_data.set(data); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_organizer_input = { | ||||
|         let event_data = event_data.clone(); | ||||
|         Callback::from(move |e: InputEvent| { | ||||
|             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||
|                 let mut data = (*event_data).clone(); | ||||
|                 data.organizer = input.value(); | ||||
|                 event_data.set(data); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_attendees_input = { | ||||
|         let event_data = event_data.clone(); | ||||
|         Callback::from(move |e: InputEvent| { | ||||
|             if let Some(textarea) = e.target_dyn_into::<HtmlTextAreaElement>() { | ||||
|                 let mut data = (*event_data).clone(); | ||||
|                 data.attendees = textarea.value(); | ||||
|                 event_data.set(data); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_categories_input = { | ||||
|         let event_data = event_data.clone(); | ||||
|         Callback::from(move |e: InputEvent| { | ||||
|             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||
|                 let mut data = (*event_data).clone(); | ||||
|                 data.categories = input.value(); | ||||
|                 event_data.set(data); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_status_change = { | ||||
|         let event_data = event_data.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() { | ||||
|                 let mut data = (*event_data).clone(); | ||||
|                 data.status = match select.value().as_str() { | ||||
|                     "tentative" => EventStatus::Tentative, | ||||
|                     "cancelled" => EventStatus::Cancelled, | ||||
|                     _ => EventStatus::Confirmed, | ||||
|                 }; | ||||
|                 event_data.set(data); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_class_change = { | ||||
|         let event_data = event_data.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() { | ||||
|                 let mut data = (*event_data).clone(); | ||||
|                 data.class = match select.value().as_str() { | ||||
|                     "private" => EventClass::Private, | ||||
|                     "confidential" => EventClass::Confidential, | ||||
|                     _ => EventClass::Public, | ||||
|                 }; | ||||
|                 event_data.set(data); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_priority_input = { | ||||
|         let event_data = event_data.clone(); | ||||
|         Callback::from(move |e: InputEvent| { | ||||
|             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||
|                 let mut data = (*event_data).clone(); | ||||
|                 data.priority = input.value().parse::<u8>().ok().filter(|&p| p <= 9); | ||||
|                 event_data.set(data); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_reminder_change = { | ||||
|         let event_data = event_data.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() { | ||||
|                 let mut data = (*event_data).clone(); | ||||
|                 data.reminder = match select.value().as_str() { | ||||
|                     "15min" => ReminderType::Minutes15, | ||||
|                     "30min" => ReminderType::Minutes30, | ||||
|                     "1hour" => ReminderType::Hour1, | ||||
|                     "2hours" => ReminderType::Hours2, | ||||
|                     "1day" => ReminderType::Day1, | ||||
|                     "2days" => ReminderType::Days2, | ||||
|                     "1week" => ReminderType::Week1, | ||||
|                     _ => ReminderType::None, | ||||
|                 }; | ||||
|                 event_data.set(data); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_recurrence_change = { | ||||
|         let event_data = event_data.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() { | ||||
|                 let mut data = (*event_data).clone(); | ||||
|                 data.recurrence = match select.value().as_str() { | ||||
|                     "daily" => RecurrenceType::Daily, | ||||
|                     "weekly" => RecurrenceType::Weekly, | ||||
|                     "monthly" => RecurrenceType::Monthly, | ||||
|                     "yearly" => RecurrenceType::Yearly, | ||||
|                     _ => RecurrenceType::None, | ||||
|                 }; | ||||
|                 // Reset recurrence days when changing recurrence type | ||||
|                 data.recurrence_days = vec![false; 7]; | ||||
|                 event_data.set(data); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_weekday_change = { | ||||
|         let event_data = event_data.clone(); | ||||
|         move |day_index: usize| { | ||||
|             let event_data = event_data.clone(); | ||||
|             Callback::from(move |e: Event| { | ||||
|                 if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||
|                     let mut data = (*event_data).clone(); | ||||
|                     if day_index < data.recurrence_days.len() { | ||||
|                         data.recurrence_days[day_index] = input.checked(); | ||||
|                         event_data.set(data); | ||||
|                     } | ||||
|                 } | ||||
|             }) | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     let on_start_date_change = { | ||||
|         let event_data = event_data.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||
|                 if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") { | ||||
|                     let mut data = (*event_data).clone(); | ||||
|                     data.start_date = date; | ||||
|                     event_data.set(data); | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_start_time_change = { | ||||
|         let event_data = event_data.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||
|                 if let Ok(time) = NaiveTime::parse_from_str(&input.value(), "%H:%M") { | ||||
|                     let mut data = (*event_data).clone(); | ||||
|                     data.start_time = time; | ||||
|                     event_data.set(data); | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_end_date_change = { | ||||
|         let event_data = event_data.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||
|                 if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") { | ||||
|                     let mut data = (*event_data).clone(); | ||||
|                     data.end_date = date; | ||||
|                     event_data.set(data); | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_end_time_change = { | ||||
|         let event_data = event_data.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||
|                 if let Ok(time) = NaiveTime::parse_from_str(&input.value(), "%H:%M") { | ||||
|                     let mut data = (*event_data).clone(); | ||||
|                     data.end_time = time; | ||||
|                     event_data.set(data); | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_all_day_change = { | ||||
|         let event_data = event_data.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||
|                 let mut data = (*event_data).clone(); | ||||
|                 data.all_day = input.checked(); | ||||
|                 event_data.set(data); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_submit_click = { | ||||
|         let event_data = event_data.clone(); | ||||
|         let on_create = props.on_create.clone(); | ||||
|         let on_update = props.on_update.clone(); | ||||
|         let event_to_edit = props.event_to_edit.clone(); | ||||
|         Callback::from(move |_: MouseEvent| { | ||||
|             if let Some(original_event) = &event_to_edit { | ||||
|                 // We're editing - call on_update with original event and new data | ||||
|                 on_update.emit((original_event.clone(), (*event_data).clone())); | ||||
|             } else { | ||||
|                 // We're creating - call on_create with new data | ||||
|                 on_create.emit((*event_data).clone()); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_cancel_click = { | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |_: MouseEvent| { | ||||
|             on_close.emit(()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let data = &*event_data; | ||||
|  | ||||
|     html! { | ||||
|         <div class="modal-backdrop" onclick={on_backdrop_click}> | ||||
|             <div class="modal-content create-event-modal" onclick={Callback::from(|e: MouseEvent| e.stop_propagation())}> | ||||
|                 <div class="modal-header"> | ||||
|                     <h3>{if props.event_to_edit.is_some() { "Edit Event" } else { "Create New Event" }}</h3> | ||||
|                     <button type="button" class="modal-close" onclick={Callback::from({ | ||||
|                         let on_close = props.on_close.clone(); | ||||
|                         move |_: MouseEvent| on_close.emit(()) | ||||
|                     })}>{"×"}</button> | ||||
|                 </div> | ||||
|                  | ||||
|                 <div class="modal-body"> | ||||
|                     <div class="form-group"> | ||||
|                         <label for="event-title">{"Title *"}</label> | ||||
|                         <input  | ||||
|                             type="text"  | ||||
|                             id="event-title" | ||||
|                             class="form-input"  | ||||
|                             value={data.title.clone()} | ||||
|                             oninput={on_title_input} | ||||
|                             placeholder="Enter event title" | ||||
|                             required=true | ||||
|                         /> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="form-group"> | ||||
|                         <label for="event-calendar">{"Calendar"}</label> | ||||
|                         <select  | ||||
|                             id="event-calendar" | ||||
|                             class="form-input"  | ||||
|                             onchange={on_calendar_change} | ||||
|                         > | ||||
|                             <option value="" selected={data.selected_calendar.is_none()}>{"Select calendar..."}</option> | ||||
|                             { | ||||
|                                 props.available_calendars.iter().map(|calendar| { | ||||
|                                     let is_selected = data.selected_calendar.as_ref() == Some(&calendar.path); | ||||
|                                     html! { | ||||
|                                         <option  | ||||
|                                             key={calendar.path.clone()}  | ||||
|                                             value={calendar.path.clone()} | ||||
|                                             selected={is_selected} | ||||
|                                         > | ||||
|                                             {&calendar.display_name} | ||||
|                                         </option> | ||||
|                                     } | ||||
|                                 }).collect::<Html>() | ||||
|                             } | ||||
|                         </select> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="form-group"> | ||||
|                         <label for="event-description">{"Description"}</label> | ||||
|                         <textarea  | ||||
|                             id="event-description" | ||||
|                             class="form-input"  | ||||
|                             value={data.description.clone()} | ||||
|                             oninput={on_description_input} | ||||
|                             placeholder="Enter event description" | ||||
|                             rows="3" | ||||
|                         ></textarea> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="form-group"> | ||||
|                         <label> | ||||
|                             <input  | ||||
|                                 type="checkbox"  | ||||
|                                 checked={data.all_day} | ||||
|                                 onchange={on_all_day_change} | ||||
|                             /> | ||||
|                             {" All Day"} | ||||
|                         </label> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="form-row"> | ||||
|                         <div class="form-group"> | ||||
|                             <label for="start-date">{"Start Date *"}</label> | ||||
|                             <input  | ||||
|                                 type="date"  | ||||
|                                 id="start-date" | ||||
|                                 class="form-input"  | ||||
|                                 value={data.start_date.format("%Y-%m-%d").to_string()} | ||||
|                                 onchange={on_start_date_change} | ||||
|                                 required=true | ||||
|                             /> | ||||
|                         </div> | ||||
|                          | ||||
|                         if !data.all_day { | ||||
|                             <div class="form-group"> | ||||
|                                 <label for="start-time">{"Start Time"}</label> | ||||
|                                 <input  | ||||
|                                     type="time"  | ||||
|                                     id="start-time" | ||||
|                                     class="form-input"  | ||||
|                                     value={data.start_time.format("%H:%M").to_string()} | ||||
|                                     onchange={on_start_time_change} | ||||
|                                 /> | ||||
|                             </div> | ||||
|                         } | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="form-row"> | ||||
|                         <div class="form-group"> | ||||
|                             <label for="end-date">{"End Date *"}</label> | ||||
|                             <input  | ||||
|                                 type="date"  | ||||
|                                 id="end-date" | ||||
|                                 class="form-input"  | ||||
|                                 value={data.end_date.format("%Y-%m-%d").to_string()} | ||||
|                                 onchange={on_end_date_change} | ||||
|                                 required=true | ||||
|                             /> | ||||
|                         </div> | ||||
|                          | ||||
|                         if !data.all_day { | ||||
|                             <div class="form-group"> | ||||
|                                 <label for="end-time">{"End Time"}</label> | ||||
|                                 <input  | ||||
|                                     type="time"  | ||||
|                                     id="end-time" | ||||
|                                     class="form-input"  | ||||
|                                     value={data.end_time.format("%H:%M").to_string()} | ||||
|                                     onchange={on_end_time_change} | ||||
|                                 /> | ||||
|                             </div> | ||||
|                         } | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="form-group"> | ||||
|                         <label for="event-location">{"Location"}</label> | ||||
|                         <input  | ||||
|                             type="text"  | ||||
|                             id="event-location" | ||||
|                             class="form-input"  | ||||
|                             value={data.location.clone()} | ||||
|                             oninput={on_location_input} | ||||
|                             placeholder="Enter event location" | ||||
|                         /> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="form-row"> | ||||
|                         <div class="form-group"> | ||||
|                             <label for="event-status">{"Status"}</label> | ||||
|                             <select  | ||||
|                                 id="event-status" | ||||
|                                 class="form-input"  | ||||
|                                 onchange={on_status_change} | ||||
|                             > | ||||
|                                 <option value="confirmed" selected={matches!(data.status, EventStatus::Confirmed)}>{"Confirmed"}</option> | ||||
|                                 <option value="tentative" selected={matches!(data.status, EventStatus::Tentative)}>{"Tentative"}</option> | ||||
|                                 <option value="cancelled" selected={matches!(data.status, EventStatus::Cancelled)}>{"Cancelled"}</option> | ||||
|                             </select> | ||||
|                         </div> | ||||
|                          | ||||
|                         <div class="form-group"> | ||||
|                             <label for="event-class">{"Privacy"}</label> | ||||
|                             <select  | ||||
|                                 id="event-class" | ||||
|                                 class="form-input"  | ||||
|                                 onchange={on_class_change} | ||||
|                             > | ||||
|                                 <option value="public" selected={matches!(data.class, EventClass::Public)}>{"Public"}</option> | ||||
|                                 <option value="private" selected={matches!(data.class, EventClass::Private)}>{"Private"}</option> | ||||
|                                 <option value="confidential" selected={matches!(data.class, EventClass::Confidential)}>{"Confidential"}</option> | ||||
|                             </select> | ||||
|                         </div> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="form-group"> | ||||
|                         <label for="event-priority">{"Priority (0-9, optional)"}</label> | ||||
|                         <input  | ||||
|                             type="number"  | ||||
|                             id="event-priority" | ||||
|                             class="form-input"  | ||||
|                             value={data.priority.map(|p| p.to_string()).unwrap_or_default()} | ||||
|                             oninput={on_priority_input} | ||||
|                             placeholder="0-9 priority level" | ||||
|                             min="0" | ||||
|                             max="9" | ||||
|                         /> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="form-group"> | ||||
|                         <label for="event-organizer">{"Organizer Email"}</label> | ||||
|                         <input  | ||||
|                             type="email"  | ||||
|                             id="event-organizer" | ||||
|                             class="form-input"  | ||||
|                             value={data.organizer.clone()} | ||||
|                             oninput={on_organizer_input} | ||||
|                             placeholder="organizer@example.com" | ||||
|                         /> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="form-group"> | ||||
|                         <label for="event-attendees">{"Attendees (comma-separated emails)"}</label> | ||||
|                         <textarea  | ||||
|                             id="event-attendees" | ||||
|                             class="form-input"  | ||||
|                             value={data.attendees.clone()} | ||||
|                             oninput={on_attendees_input} | ||||
|                             placeholder="attendee1@example.com, attendee2@example.com" | ||||
|                             rows="2" | ||||
|                         ></textarea> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="form-group"> | ||||
|                         <label for="event-categories">{"Categories (comma-separated)"}</label> | ||||
|                         <input  | ||||
|                             type="text"  | ||||
|                             id="event-categories" | ||||
|                             class="form-input"  | ||||
|                             value={data.categories.clone()} | ||||
|                             oninput={on_categories_input} | ||||
|                             placeholder="work, meeting, personal" | ||||
|                         /> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="form-row"> | ||||
|                         <div class="form-group"> | ||||
|                             <label for="event-reminder">{"Reminder"}</label> | ||||
|                             <select  | ||||
|                                 id="event-reminder" | ||||
|                                 class="form-input"  | ||||
|                                 onchange={on_reminder_change} | ||||
|                             > | ||||
|                                 <option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"None"}</option> | ||||
|                                 <option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes"}</option> | ||||
|                                 <option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes"}</option> | ||||
|                                 <option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour"}</option> | ||||
|                                 <option value="2hours" selected={matches!(data.reminder, ReminderType::Hours2)}>{"2 hours"}</option> | ||||
|                                 <option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day"}</option> | ||||
|                                 <option value="2days" selected={matches!(data.reminder, ReminderType::Days2)}>{"2 days"}</option> | ||||
|                                 <option value="1week" selected={matches!(data.reminder, ReminderType::Week1)}>{"1 week"}</option> | ||||
|                             </select> | ||||
|                         </div> | ||||
|                          | ||||
|                         <div class="form-group"> | ||||
|                             <label for="event-recurrence">{"Recurrence"}</label> | ||||
|                             <select  | ||||
|                                 id="event-recurrence" | ||||
|                                 class="form-input"  | ||||
|                                 onchange={on_recurrence_change} | ||||
|                             > | ||||
|                                 <option value="none" selected={matches!(data.recurrence, RecurrenceType::None)}>{"None"}</option> | ||||
|                                 <option value="daily" selected={matches!(data.recurrence, RecurrenceType::Daily)}>{"Daily"}</option> | ||||
|                                 <option value="weekly" selected={matches!(data.recurrence, RecurrenceType::Weekly)}>{"Weekly"}</option> | ||||
|                                 <option value="monthly" selected={matches!(data.recurrence, RecurrenceType::Monthly)}>{"Monthly"}</option> | ||||
|                                 <option value="yearly" selected={matches!(data.recurrence, RecurrenceType::Yearly)}>{"Yearly"}</option> | ||||
|                             </select> | ||||
|                         </div> | ||||
|                     </div> | ||||
|  | ||||
|                     // Show weekday selection only when weekly recurrence is selected | ||||
|                     if matches!(data.recurrence, RecurrenceType::Weekly) { | ||||
|                         <div class="form-group"> | ||||
|                             <label>{"Repeat on"}</label> | ||||
|                             <div class="weekday-selection"> | ||||
|                                 { | ||||
|                                     ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] | ||||
|                                         .iter() | ||||
|                                         .enumerate() | ||||
|                                         .map(|(i, day)| { | ||||
|                                             let day_checked = data.recurrence_days.get(i).cloned().unwrap_or(false); | ||||
|                                             let on_change = on_weekday_change(i); | ||||
|                                             html! { | ||||
|                                                 <label key={i} class="weekday-checkbox"> | ||||
|                                                     <input  | ||||
|                                                         type="checkbox"  | ||||
|                                                         checked={day_checked} | ||||
|                                                         onchange={on_change} | ||||
|                                                     /> | ||||
|                                                     <span class="weekday-label">{day}</span> | ||||
|                                                 </label> | ||||
|                                             } | ||||
|                                         }) | ||||
|                                         .collect::<Html>() | ||||
|                                 } | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     } | ||||
|                 </div> | ||||
|                  | ||||
|                 <div class="modal-footer"> | ||||
|                     <button type="button" class="btn btn-secondary" onclick={on_cancel_click}> | ||||
|                         {"Cancel"} | ||||
|                     </button> | ||||
|                     <button  | ||||
|                         type="button"  | ||||
|                         class="btn btn-primary"  | ||||
|                         onclick={on_submit_click} | ||||
|                         disabled={data.title.trim().is_empty()} | ||||
|                     > | ||||
|                         {if props.event_to_edit.is_some() { "Update Event" } else { "Create Event" }} | ||||
|                     </button> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										98
									
								
								frontend/src/components/event_context_menu.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								frontend/src/components/event_context_menu.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | ||||
| use yew::prelude::*; | ||||
| use web_sys::MouseEvent; | ||||
| use crate::models::ical::VEvent; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub enum DeleteAction { | ||||
|     DeleteThis, | ||||
|     DeleteFollowing, | ||||
|     DeleteSeries, | ||||
| } | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct EventContextMenuProps { | ||||
|     pub is_open: bool, | ||||
|     pub x: i32, | ||||
|     pub y: i32, | ||||
|     pub event: Option<VEvent>, | ||||
|     pub on_edit: Callback<()>, | ||||
|     pub on_delete: Callback<DeleteAction>, | ||||
|     pub on_close: Callback<()>, | ||||
| } | ||||
|  | ||||
| #[function_component(EventContextMenu)] | ||||
| pub fn event_context_menu(props: &EventContextMenuProps) -> Html { | ||||
|     let menu_ref = use_node_ref(); | ||||
|      | ||||
|     if !props.is_open { | ||||
|         return html! {}; | ||||
|     } | ||||
|  | ||||
|     let style = format!( | ||||
|         "position: fixed; left: {}px; top: {}px; z-index: 1001;", | ||||
|         props.x, props.y | ||||
|     ); | ||||
|  | ||||
|     // Check if the event is recurring | ||||
|     let is_recurring = props.event.as_ref() | ||||
|         .map(|event| event.rrule.is_some()) | ||||
|         .unwrap_or(false); | ||||
|  | ||||
|     let on_edit_click = { | ||||
|         let on_edit = props.on_edit.clone(); | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |_: MouseEvent| { | ||||
|             on_edit.emit(()); | ||||
|             on_close.emit(()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let create_delete_callback = |action: DeleteAction| { | ||||
|         let on_delete = props.on_delete.clone(); | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |_: MouseEvent| { | ||||
|             on_delete.emit(action.clone()); | ||||
|             on_close.emit(()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <div  | ||||
|             ref={menu_ref} | ||||
|             class="context-menu"  | ||||
|             style={style} | ||||
|         > | ||||
|             <div class="context-menu-item" onclick={on_edit_click}> | ||||
|                 <span class="context-menu-icon">{"✏️"}</span> | ||||
|                 {"Edit Event"} | ||||
|             </div> | ||||
|             { | ||||
|                 if is_recurring { | ||||
|                     html! { | ||||
|                         <> | ||||
|                             <div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}> | ||||
|                                 <span class="context-menu-icon">{"🗑️"}</span> | ||||
|                                 {"Delete This Event"} | ||||
|                             </div> | ||||
|                             <div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteFollowing)}> | ||||
|                                 <span class="context-menu-icon">{"🗑️"}</span> | ||||
|                                 {"Delete Following Events"} | ||||
|                             </div> | ||||
|                             <div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteSeries)}> | ||||
|                                 <span class="context-menu-icon">{"🗑️"}</span> | ||||
|                                 {"Delete Entire Series"} | ||||
|                             </div> | ||||
|                         </> | ||||
|                     } | ||||
|                 } else { | ||||
|                     html! { | ||||
|                         <div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}> | ||||
|                             <span class="context-menu-icon">{"🗑️"}</span> | ||||
|                             {"Delete Event"} | ||||
|                         </div> | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										239
									
								
								frontend/src/components/event_modal.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										239
									
								
								frontend/src/components/event_modal.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,239 @@ | ||||
| use yew::prelude::*; | ||||
| use chrono::{DateTime, Utc}; | ||||
| use crate::models::ical::VEvent; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct EventModalProps { | ||||
|     pub event: Option<VEvent>, | ||||
|     pub on_close: Callback<()>, | ||||
| } | ||||
|  | ||||
| #[function_component] | ||||
| pub fn EventModal(props: &EventModalProps) -> Html { | ||||
|     let close_modal = { | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             on_close.emit(()); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     let backdrop_click = { | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |e: MouseEvent| { | ||||
|             if e.target() == e.current_target() { | ||||
|                 on_close.emit(()); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     if let Some(ref event) = props.event { | ||||
|         html! { | ||||
|             <div class="modal-backdrop" onclick={backdrop_click}> | ||||
|                 <div class="modal-content"> | ||||
|                     <div class="modal-header"> | ||||
|                         <h3>{"Event Details"}</h3> | ||||
|                         <button class="modal-close" onclick={close_modal}>{"×"}</button> | ||||
|                     </div> | ||||
|                     <div class="modal-body"> | ||||
|                         <div class="event-detail"> | ||||
|                             <strong>{"Title:"}</strong> | ||||
|                             <span>{event.get_title()}</span> | ||||
|                         </div> | ||||
|                          | ||||
|                         { | ||||
|                             if let Some(ref description) = event.description { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Description:"}</strong> | ||||
|                                         <span>{description}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         <div class="event-detail"> | ||||
|                             <strong>{"Start:"}</strong> | ||||
|                             <span>{format_datetime(&event.dtstart, event.all_day)}</span> | ||||
|                         </div> | ||||
|                          | ||||
|                         { | ||||
|                             if let Some(ref end) = event.dtend { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"End:"}</strong> | ||||
|                                         <span>{format_datetime(end, event.all_day)}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         <div class="event-detail"> | ||||
|                             <strong>{"All Day:"}</strong> | ||||
|                             <span>{if event.all_day { "Yes" } else { "No" }}</span> | ||||
|                         </div> | ||||
|                          | ||||
|                         { | ||||
|                             if let Some(ref location) = event.location { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Location:"}</strong> | ||||
|                                         <span>{location}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         <div class="event-detail"> | ||||
|                             <strong>{"Status:"}</strong> | ||||
|                             <span>{event.get_status_display()}</span> | ||||
|                         </div> | ||||
|                          | ||||
|                         <div class="event-detail"> | ||||
|                             <strong>{"Privacy:"}</strong> | ||||
|                             <span>{event.get_class_display()}</span> | ||||
|                         </div> | ||||
|                          | ||||
|                         <div class="event-detail"> | ||||
|                             <strong>{"Priority:"}</strong> | ||||
|                             <span>{event.get_priority_display()}</span> | ||||
|                         </div> | ||||
|                          | ||||
|                         { | ||||
|                             if let Some(ref organizer) = event.organizer { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Organizer:"}</strong> | ||||
|                                         <span>{organizer.cal_address.clone()}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         { | ||||
|                             if !event.attendees.is_empty() { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Attendees:"}</strong> | ||||
|                                         <span>{event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(", ")}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         { | ||||
|                             if !event.categories.is_empty() { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Categories:"}</strong> | ||||
|                                         <span>{event.categories.join(", ")}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         { | ||||
|                             if let Some(ref recurrence) = event.rrule { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Repeats:"}</strong> | ||||
|                                         <span>{format_recurrence_rule(recurrence)}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Repeats:"}</strong> | ||||
|                                         <span>{"No"}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         { | ||||
|                             if !event.alarms.is_empty() { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Reminders:"}</strong> | ||||
|                                         <span>{"Alarms configured"}</span> /* TODO: Convert VAlarm to displayable format */ | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Reminders:"}</strong> | ||||
|                                         <span>{"None"}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         { | ||||
|                             if let Some(ref created) = event.created { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Created:"}</strong> | ||||
|                                         <span>{format_datetime(created, false)}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         { | ||||
|                             if let Some(ref modified) = event.last_modified { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Last Modified:"}</strong> | ||||
|                                         <span>{format_datetime(modified, false)}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } else { | ||||
|         html! {} | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String { | ||||
|     if all_day { | ||||
|         dt.format("%B %d, %Y").to_string() | ||||
|     } else { | ||||
|         dt.format("%B %d, %Y at %I:%M %p").to_string() | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn format_recurrence_rule(rrule: &str) -> String { | ||||
|     // Basic parsing of RRULE to display user-friendly text | ||||
|     if rrule.contains("FREQ=DAILY") { | ||||
|         "Daily".to_string() | ||||
|     } else if rrule.contains("FREQ=WEEKLY") { | ||||
|         "Weekly".to_string() | ||||
|     } else if rrule.contains("FREQ=MONTHLY") { | ||||
|         "Monthly".to_string() | ||||
|     } else if rrule.contains("FREQ=YEARLY") { | ||||
|         "Yearly".to_string() | ||||
|     } else { | ||||
|         // Show the raw rule if we can't parse it | ||||
|         format!("Custom ({})", rrule) | ||||
|     } | ||||
| } | ||||
|  | ||||
							
								
								
									
										206
									
								
								frontend/src/components/login.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										206
									
								
								frontend/src/components/login.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,206 @@ | ||||
| use yew::prelude::*; | ||||
| use web_sys::HtmlInputElement; | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct LoginProps { | ||||
|     pub on_login: Callback<String>, // Callback with JWT token | ||||
| } | ||||
|  | ||||
| #[function_component] | ||||
| pub fn Login(props: &LoginProps) -> Html { | ||||
|     let server_url = use_state(String::new); | ||||
|     let username = use_state(String::new); | ||||
|     let password = use_state(String::new); | ||||
|     let error_message = use_state(|| Option::<String>::None); | ||||
|     let is_loading = use_state(|| false); | ||||
|  | ||||
|     let server_url_ref = use_node_ref(); | ||||
|     let username_ref = use_node_ref(); | ||||
|     let password_ref = use_node_ref(); | ||||
|  | ||||
|     let on_server_url_change = { | ||||
|         let server_url = server_url.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             let target = e.target_unchecked_into::<HtmlInputElement>(); | ||||
|             server_url.set(target.value()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_username_change = { | ||||
|         let username = username.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             let target = e.target_unchecked_into::<HtmlInputElement>(); | ||||
|             username.set(target.value()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_password_change = { | ||||
|         let password = password.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             let target = e.target_unchecked_into::<HtmlInputElement>(); | ||||
|             password.set(target.value()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_submit = { | ||||
|         let server_url = server_url.clone(); | ||||
|         let username = username.clone(); | ||||
|         let password = password.clone(); | ||||
|         let error_message = error_message.clone(); | ||||
|         let is_loading = is_loading.clone(); | ||||
|         let on_login = props.on_login.clone(); | ||||
|  | ||||
|         Callback::from(move |e: SubmitEvent| { | ||||
|             e.prevent_default(); | ||||
|              | ||||
|             let server_url = (*server_url).clone(); | ||||
|             let username = (*username).clone(); | ||||
|             let password = (*password).clone(); | ||||
|             let error_message = error_message.clone(); | ||||
|             let is_loading = is_loading.clone(); | ||||
|             let on_login = on_login.clone(); | ||||
|  | ||||
|             // Basic client-side validation | ||||
|             if server_url.trim().is_empty() || username.trim().is_empty() || password.is_empty() { | ||||
|                 error_message.set(Some("Please fill in all fields".to_string())); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             is_loading.set(true); | ||||
|             error_message.set(None); | ||||
|  | ||||
|             wasm_bindgen_futures::spawn_local(async move { | ||||
|                 web_sys::console::log_1(&"🚀 Starting login process...".into()); | ||||
|                 match perform_login(server_url.clone(), username.clone(), password.clone()).await { | ||||
|                     Ok((token, credentials)) => { | ||||
|                         web_sys::console::log_1(&"✅ Login successful!".into()); | ||||
|                         // Store token and credentials in local storage | ||||
|                         if let Err(_) = LocalStorage::set("auth_token", &token) { | ||||
|                             error_message.set(Some("Failed to store authentication token".to_string())); | ||||
|                             is_loading.set(false); | ||||
|                             return; | ||||
|                         } | ||||
|                         if let Err(_) = LocalStorage::set("caldav_credentials", &credentials) { | ||||
|                             error_message.set(Some("Failed to store credentials".to_string())); | ||||
|                             is_loading.set(false); | ||||
|                             return; | ||||
|                         } | ||||
|                          | ||||
|                         is_loading.set(false); | ||||
|                         on_login.emit(token); | ||||
|                     } | ||||
|                     Err(err) => { | ||||
|                         web_sys::console::log_1(&format!("❌ Login failed: {}", err).into()); | ||||
|                         error_message.set(Some(err)); | ||||
|                         is_loading.set(false); | ||||
|                     } | ||||
|                 } | ||||
|             }); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <div class="login-container"> | ||||
|             <div class="login-form"> | ||||
|                 <h2>{"Sign In to CalDAV"}</h2> | ||||
|                 <form onsubmit={on_submit}> | ||||
|                     <div class="form-group"> | ||||
|                         <label for="server_url">{"CalDAV Server URL"}</label> | ||||
|                         <input | ||||
|                             ref={server_url_ref} | ||||
|                             type="text" | ||||
|                             id="server_url" | ||||
|                             placeholder="https://your-caldav-server.com/dav/" | ||||
|                             value={(*server_url).clone()} | ||||
|                             onchange={on_server_url_change} | ||||
|                             disabled={*is_loading} | ||||
|                         /> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="form-group"> | ||||
|                         <label for="username">{"Username"}</label> | ||||
|                         <input | ||||
|                             ref={username_ref} | ||||
|                             type="text" | ||||
|                             id="username" | ||||
|                             placeholder="Enter your username" | ||||
|                             value={(*username).clone()} | ||||
|                             onchange={on_username_change} | ||||
|                             disabled={*is_loading} | ||||
|                         /> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="form-group"> | ||||
|                         <label for="password">{"Password"}</label> | ||||
|                         <input | ||||
|                             ref={password_ref} | ||||
|                             type="password" | ||||
|                             id="password" | ||||
|                             placeholder="Enter your password" | ||||
|                             value={(*password).clone()} | ||||
|                             onchange={on_password_change} | ||||
|                             disabled={*is_loading} | ||||
|                         /> | ||||
|                     </div> | ||||
|  | ||||
|                     { | ||||
|                         if let Some(error) = (*error_message).clone() { | ||||
|                             html! { <div class="error-message">{error}</div> } | ||||
|                         } else { | ||||
|                             html! {} | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     <button type="submit" disabled={*is_loading} class="login-button"> | ||||
|                         { | ||||
|                             if *is_loading { | ||||
|                                 "Signing in..." | ||||
|                             } else { | ||||
|                                 "Sign In" | ||||
|                             } | ||||
|                         } | ||||
|                     </button> | ||||
|                 </form> | ||||
|  | ||||
|                 <div class="auth-links"> | ||||
|                     <p>{"Enter your CalDAV server credentials to connect to your calendar"}</p> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Perform login using the CalDAV auth service | ||||
| async fn perform_login(server_url: String, username: String, password: String) -> Result<(String, String), String> { | ||||
|     use crate::auth::{AuthService, CalDAVLoginRequest}; | ||||
|     use serde_json; | ||||
|      | ||||
|     web_sys::console::log_1(&format!("📡 Creating auth service and request...").into()); | ||||
|      | ||||
|     let auth_service = AuthService::new(); | ||||
|     let request = CalDAVLoginRequest {  | ||||
|         server_url: server_url.clone(),  | ||||
|         username: username.clone(),  | ||||
|         password: password.clone()  | ||||
|     }; | ||||
|      | ||||
|     web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into()); | ||||
|      | ||||
|     match auth_service.login(request).await { | ||||
|         Ok(response) => { | ||||
|             web_sys::console::log_1(&format!("✅ Backend responded successfully").into()); | ||||
|             // Create credentials object to store | ||||
|             let credentials = serde_json::json!({ | ||||
|                 "server_url": server_url, | ||||
|                 "username": username, | ||||
|                 "password": password | ||||
|             }); | ||||
|             Ok((response.token, credentials.to_string())) | ||||
|         }, | ||||
|         Err(err) => { | ||||
|             web_sys::console::log_1(&format!("❌ Backend error: {}", err).into()); | ||||
|             Err(err) | ||||
|         }, | ||||
|     } | ||||
| } | ||||
							
								
								
									
										31
									
								
								frontend/src/components/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								frontend/src/components/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| pub mod login; | ||||
| pub mod calendar; | ||||
| pub mod calendar_header; | ||||
| pub mod month_view; | ||||
| pub mod week_view; | ||||
| pub mod event_modal; | ||||
| pub mod create_calendar_modal; | ||||
| pub mod context_menu; | ||||
| pub mod event_context_menu; | ||||
| pub mod calendar_context_menu; | ||||
| pub mod create_event_modal; | ||||
| pub mod sidebar; | ||||
| pub mod calendar_list_item; | ||||
| pub mod route_handler; | ||||
| pub mod recurring_edit_modal; | ||||
|  | ||||
| pub use login::Login; | ||||
| pub use calendar::Calendar; | ||||
| pub use calendar_header::CalendarHeader; | ||||
| pub use month_view::MonthView; | ||||
| pub use week_view::WeekView; | ||||
| pub use event_modal::EventModal; | ||||
| pub use create_calendar_modal::CreateCalendarModal; | ||||
| pub use context_menu::ContextMenu; | ||||
| pub use event_context_menu::{EventContextMenu, DeleteAction}; | ||||
| pub use calendar_context_menu::CalendarContextMenu; | ||||
| pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType}; | ||||
| pub use sidebar::{Sidebar, ViewMode, Theme}; | ||||
| pub use calendar_list_item::CalendarListItem; | ||||
| pub use route_handler::RouteHandler; | ||||
| pub use recurring_edit_modal::{RecurringEditModal, RecurringEditAction}; | ||||
							
								
								
									
										268
									
								
								frontend/src/components/month_view.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										268
									
								
								frontend/src/components/month_view.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,268 @@ | ||||
| use yew::prelude::*; | ||||
| use chrono::{Datelike, NaiveDate, Weekday}; | ||||
| use std::collections::HashMap; | ||||
| use web_sys::window; | ||||
| use wasm_bindgen::{prelude::*, JsCast}; | ||||
| use crate::services::calendar_service::UserInfo; | ||||
| use crate::models::ical::VEvent; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct MonthViewProps { | ||||
|     pub current_month: NaiveDate, | ||||
|     pub today: NaiveDate, | ||||
|     pub events: HashMap<NaiveDate, Vec<VEvent>>, | ||||
|     pub on_event_click: Callback<VEvent>, | ||||
|     #[prop_or_default] | ||||
|     pub refreshing_event_uid: Option<String>, | ||||
|     #[prop_or_default] | ||||
|     pub user_info: Option<UserInfo>, | ||||
|     #[prop_or_default] | ||||
|     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, | ||||
|     #[prop_or_default] | ||||
|     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>, | ||||
|     #[prop_or_default] | ||||
|     pub selected_date: Option<NaiveDate>, | ||||
|     #[prop_or_default] | ||||
|     pub on_day_select: Option<Callback<NaiveDate>>, | ||||
| } | ||||
|  | ||||
| #[function_component(MonthView)] | ||||
| pub fn month_view(props: &MonthViewProps) -> Html { | ||||
|     let max_events_per_day = use_state(|| 4); // Default to 4 events max | ||||
|     let first_day_of_month = props.current_month.with_day(1).unwrap(); | ||||
|     let days_in_month = get_days_in_month(props.current_month); | ||||
|     let first_weekday = first_day_of_month.weekday(); | ||||
|     let days_from_prev_month = get_days_from_previous_month(props.current_month, first_weekday); | ||||
|  | ||||
|     // Calculate maximum events that can fit based on available height | ||||
|     let calculate_max_events = { | ||||
|         let max_events_per_day = max_events_per_day.clone(); | ||||
|         move || { | ||||
|             // Since we're using CSS Grid with equal row heights, | ||||
|             // we can estimate based on typical calendar dimensions | ||||
|             // Typical calendar height is around 600-800px for 6 rows | ||||
|             // Each row gets ~100-133px, minus day number and padding leaves ~70-100px | ||||
|             // Each event is ~18px, so we can fit ~3-4 events + "+n more" indicator | ||||
|             max_events_per_day.set(3); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     // Setup resize handler and initial calculation | ||||
|     { | ||||
|         let calculate_max_events = calculate_max_events.clone(); | ||||
|         use_effect_with((), move |_| { | ||||
|             let calculate_max_events_clone = calculate_max_events.clone(); | ||||
|              | ||||
|             // Initial calculation with a slight delay to ensure DOM is ready | ||||
|             if let Some(window) = window() { | ||||
|                 let timeout_closure = Closure::wrap(Box::new(move || { | ||||
|                     calculate_max_events_clone(); | ||||
|                 }) as Box<dyn FnMut()>); | ||||
|                  | ||||
|                 let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( | ||||
|                     timeout_closure.as_ref().unchecked_ref(), | ||||
|                     100, | ||||
|                 ); | ||||
|                 timeout_closure.forget(); | ||||
|             } | ||||
|              | ||||
|             // Setup resize listener | ||||
|             let resize_closure = Closure::wrap(Box::new(move || { | ||||
|                 calculate_max_events(); | ||||
|             }) as Box<dyn Fn()>); | ||||
|              | ||||
|             if let Some(window) = window() { | ||||
|                 let _ = window.add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref()); | ||||
|                 resize_closure.forget(); // Keep the closure alive | ||||
|             } | ||||
|              | ||||
|             || {} | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     // Helper function to get calendar color for an event | ||||
|     let get_event_color = |event: &VEvent| -> String { | ||||
|         if let Some(user_info) = &props.user_info { | ||||
|             if let Some(calendar_path) = &event.calendar_path { | ||||
|                 if let Some(calendar) = user_info.calendars.iter() | ||||
|                     .find(|cal| &cal.path == calendar_path) { | ||||
|                     return calendar.color.clone(); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         "#3B82F6".to_string() | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <div class="calendar-grid"> | ||||
|             // Weekday headers | ||||
|             <div class="weekday-header">{"Sun"}</div> | ||||
|             <div class="weekday-header">{"Mon"}</div> | ||||
|             <div class="weekday-header">{"Tue"}</div> | ||||
|             <div class="weekday-header">{"Wed"}</div> | ||||
|             <div class="weekday-header">{"Thu"}</div> | ||||
|             <div class="weekday-header">{"Fri"}</div> | ||||
|             <div class="weekday-header">{"Sat"}</div> | ||||
|              | ||||
|             // Days from previous month (grayed out) | ||||
|             { | ||||
|                 days_from_prev_month.iter().map(|day| { | ||||
|                     html! { | ||||
|                         <div class="calendar-day prev-month">{*day}</div> | ||||
|                     } | ||||
|                 }).collect::<Html>() | ||||
|             } | ||||
|              | ||||
|             // Days of the current month | ||||
|             { | ||||
|                 (1..=days_in_month).map(|day| { | ||||
|                     let date = props.current_month.with_day(day).unwrap(); | ||||
|                     let is_today = date == props.today; | ||||
|                     let is_selected = props.selected_date == Some(date); | ||||
|                     let day_events = props.events.get(&date).cloned().unwrap_or_default(); | ||||
|                      | ||||
|                     // Calculate visible events and overflow | ||||
|                     let max_events = *max_events_per_day as usize; | ||||
|                     let visible_events: Vec<_> = day_events.iter().take(max_events).collect(); | ||||
|                     let hidden_count = day_events.len().saturating_sub(max_events); | ||||
|                      | ||||
|                     html! { | ||||
|                         <div  | ||||
|                             class={classes!( | ||||
|                                 "calendar-day",  | ||||
|                                 if is_today { Some("today") } else { None }, | ||||
|                                 if is_selected { Some("selected") } else { None } | ||||
|                             )} | ||||
|                             onclick={ | ||||
|                                 if let Some(callback) = &props.on_day_select { | ||||
|                                     let callback = callback.clone(); | ||||
|                                     Some(Callback::from(move |e: web_sys::MouseEvent| { | ||||
|                                         e.stop_propagation(); // Prevent other handlers | ||||
|                                         callback.emit(date); | ||||
|                                     })) | ||||
|                                 } else { | ||||
|                                     None | ||||
|                                 } | ||||
|                             } | ||||
|                             oncontextmenu={ | ||||
|                                 if let Some(callback) = &props.on_calendar_context_menu { | ||||
|                                     let callback = callback.clone(); | ||||
|                                     Some(Callback::from(move |e: web_sys::MouseEvent| { | ||||
|                                         e.prevent_default(); | ||||
|                                         callback.emit((e, date)); | ||||
|                                     })) | ||||
|                                 } else { | ||||
|                                     None | ||||
|                                 } | ||||
|                             } | ||||
|                         > | ||||
|                             <div class="day-number">{day}</div> | ||||
|                             <div class="day-events"> | ||||
|                                 { | ||||
|                                     visible_events.iter().map(|event| { | ||||
|                                         let event_color = get_event_color(event); | ||||
|                                         let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid); | ||||
|                                          | ||||
|                                         let onclick = { | ||||
|                                             let on_event_click = props.on_event_click.clone(); | ||||
|                                             let event = (*event).clone(); | ||||
|                                             Callback::from(move |_: web_sys::MouseEvent| { | ||||
|                                                 on_event_click.emit(event.clone()); | ||||
|                                             }) | ||||
|                                         }; | ||||
|                                          | ||||
|                                         let oncontextmenu = { | ||||
|                                             if let Some(callback) = &props.on_event_context_menu { | ||||
|                                                 let callback = callback.clone(); | ||||
|                                                 let event = (*event).clone(); | ||||
|                                                 Some(Callback::from(move |e: web_sys::MouseEvent| { | ||||
|                                                     e.prevent_default(); | ||||
|                                                     callback.emit((e, event.clone())); | ||||
|                                                 })) | ||||
|                                             } else { | ||||
|                                                 None | ||||
|                                             } | ||||
|                                         }; | ||||
|                                          | ||||
|                                         html! { | ||||
|                                             <div  | ||||
|                                                 class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })} | ||||
|                                                 style={format!("background-color: {}", event_color)} | ||||
|                                                 {onclick} | ||||
|                                                 {oncontextmenu} | ||||
|                                             > | ||||
|                                                 {event.summary.as_ref().unwrap_or(&"Untitled".to_string())} | ||||
|                                             </div> | ||||
|                                         } | ||||
|                                     }).collect::<Html>() | ||||
|                                 } | ||||
|                                 { | ||||
|                                     if hidden_count > 0 { | ||||
|                                         html! { | ||||
|                                             <div class="more-events-indicator"> | ||||
|                                                 {format!("+{} more", hidden_count)} | ||||
|                                             </div> | ||||
|                                         } | ||||
|                                     } else { | ||||
|                                         html! {} | ||||
|                                     } | ||||
|                                 } | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     } | ||||
|                 }).collect::<Html>() | ||||
|             } | ||||
|              | ||||
|             { render_next_month_days(days_from_prev_month.len(), days_in_month) } | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html { | ||||
|     let total_slots = 42; // 6 rows x 7 days | ||||
|     let used_slots = prev_days_count + current_days_count as usize; | ||||
|     let remaining_slots = if used_slots < total_slots { total_slots - used_slots } else { 0 }; | ||||
|      | ||||
|     (1..=remaining_slots).map(|day| { | ||||
|         html! { | ||||
|             <div class="calendar-day next-month">{day}</div> | ||||
|         } | ||||
|     }).collect::<Html>() | ||||
| } | ||||
|  | ||||
| fn get_days_in_month(date: NaiveDate) -> u32 { | ||||
|     NaiveDate::from_ymd_opt( | ||||
|         if date.month() == 12 { date.year() + 1 } else { date.year() }, | ||||
|         if date.month() == 12 { 1 } else { date.month() + 1 }, | ||||
|         1 | ||||
|     ) | ||||
|     .unwrap() | ||||
|     .pred_opt() | ||||
|     .unwrap() | ||||
|     .day() | ||||
| } | ||||
|  | ||||
| fn get_days_from_previous_month(current_month: NaiveDate, first_weekday: Weekday) -> Vec<u32> { | ||||
|     let days_before = match first_weekday { | ||||
|         Weekday::Sun => 0, | ||||
|         Weekday::Mon => 1, | ||||
|         Weekday::Tue => 2, | ||||
|         Weekday::Wed => 3, | ||||
|         Weekday::Thu => 4, | ||||
|         Weekday::Fri => 5, | ||||
|         Weekday::Sat => 6, | ||||
|     }; | ||||
|      | ||||
|     if days_before == 0 { | ||||
|         vec![] | ||||
|     } else { | ||||
|         let prev_month = if current_month.month() == 1 { | ||||
|             NaiveDate::from_ymd_opt(current_month.year() - 1, 12, 1).unwrap() | ||||
|         } else { | ||||
|             NaiveDate::from_ymd_opt(current_month.year(), current_month.month() - 1, 1).unwrap() | ||||
|         }; | ||||
|          | ||||
|         let prev_month_days = get_days_in_month(prev_month); | ||||
|         ((prev_month_days - days_before as u32 + 1)..=prev_month_days).collect() | ||||
|     } | ||||
| } | ||||
							
								
								
									
										93
									
								
								frontend/src/components/recurring_edit_modal.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								frontend/src/components/recurring_edit_modal.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,93 @@ | ||||
| use yew::prelude::*; | ||||
| use chrono::NaiveDateTime; | ||||
| use crate::models::ical::VEvent; | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum RecurringEditAction { | ||||
|     ThisEvent, | ||||
|     FutureEvents, | ||||
|     AllEvents, | ||||
| } | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct RecurringEditModalProps { | ||||
|     pub show: bool, | ||||
|     pub event: VEvent, | ||||
|     pub new_start: NaiveDateTime, | ||||
|     pub new_end: NaiveDateTime, | ||||
|     pub on_choice: Callback<RecurringEditAction>, | ||||
|     pub on_cancel: Callback<()>, | ||||
| } | ||||
|  | ||||
| #[function_component(RecurringEditModal)] | ||||
| pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html { | ||||
|     if !props.show { | ||||
|         return html! {}; | ||||
|     } | ||||
|  | ||||
|     let event_title = props.event.summary.as_ref().map(|s| s.as_str()).unwrap_or("Untitled Event"); | ||||
|      | ||||
|     let on_this_event = { | ||||
|         let on_choice = props.on_choice.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             on_choice.emit(RecurringEditAction::ThisEvent); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     let on_future_events = { | ||||
|         let on_choice = props.on_choice.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             on_choice.emit(RecurringEditAction::FutureEvents); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     let on_all_events = { | ||||
|         let on_choice = props.on_choice.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             on_choice.emit(RecurringEditAction::AllEvents); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     let on_cancel = { | ||||
|         let on_cancel = props.on_cancel.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             on_cancel.emit(()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <div class="modal-backdrop"> | ||||
|             <div class="modal-content recurring-edit-modal"> | ||||
|                 <div class="modal-header"> | ||||
|                     <h3>{"Edit Recurring Event"}</h3> | ||||
|                 </div> | ||||
|                 <div class="modal-body"> | ||||
|                     <p>{format!("You're modifying \"{}\" which is part of a recurring series.", event_title)}</p> | ||||
|                     <p>{"How would you like to apply this change?"}</p> | ||||
|                      | ||||
|                     <div class="recurring-edit-options"> | ||||
|                         <button class="btn btn-primary recurring-option" onclick={on_this_event}> | ||||
|                             <div class="option-title">{"This event only"}</div> | ||||
|                             <div class="option-description">{"Change only this occurrence"}</div> | ||||
|                         </button> | ||||
|                          | ||||
|                         <button class="btn btn-primary recurring-option" onclick={on_future_events}> | ||||
|                             <div class="option-title">{"This and future events"}</div> | ||||
|                             <div class="option-description">{"Change this occurrence and all future occurrences"}</div> | ||||
|                         </button> | ||||
|                          | ||||
|                         <button class="btn btn-primary recurring-option" onclick={on_all_events}> | ||||
|                             <div class="option-title">{"All events in series"}</div> | ||||
|                             <div class="option-description">{"Change all occurrences in the series"}</div> | ||||
|                         </button> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 <div class="modal-footer"> | ||||
|                     <button class="btn btn-secondary" onclick={on_cancel}> | ||||
|                         {"Cancel"} | ||||
|                     </button> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										297
									
								
								frontend/src/components/route_handler.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										297
									
								
								frontend/src/components/route_handler.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,297 @@ | ||||
| use yew::prelude::*; | ||||
| use yew_router::prelude::*; | ||||
| use crate::components::{Login, ViewMode}; | ||||
| use crate::services::calendar_service::UserInfo; | ||||
| use crate::models::ical::VEvent; | ||||
|  | ||||
| #[derive(Clone, Routable, PartialEq)] | ||||
| pub enum Route { | ||||
|     #[at("/")] | ||||
|     Home, | ||||
|     #[at("/login")] | ||||
|     Login, | ||||
|     #[at("/calendar")] | ||||
|     Calendar, | ||||
| } | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct RouteHandlerProps { | ||||
|     pub auth_token: Option<String>, | ||||
|     pub user_info: Option<UserInfo>, | ||||
|     pub on_login: Callback<String>, | ||||
|     #[prop_or_default] | ||||
|     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, | ||||
|     #[prop_or_default] | ||||
|     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>, | ||||
|     #[prop_or_default] | ||||
|     pub view: ViewMode, | ||||
|     #[prop_or_default] | ||||
|     pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>, | ||||
|     #[prop_or_default] | ||||
|     pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)>>, | ||||
|     #[prop_or_default] | ||||
|     pub context_menus_open: bool, | ||||
| } | ||||
|  | ||||
| #[function_component(RouteHandler)] | ||||
| pub fn route_handler(props: &RouteHandlerProps) -> Html { | ||||
|     let auth_token = props.auth_token.clone(); | ||||
|     let user_info = props.user_info.clone(); | ||||
|     let on_login = props.on_login.clone(); | ||||
|     let on_event_context_menu = props.on_event_context_menu.clone(); | ||||
|     let on_calendar_context_menu = props.on_calendar_context_menu.clone(); | ||||
|     let view = props.view.clone(); | ||||
|     let on_create_event_request = props.on_create_event_request.clone(); | ||||
|     let on_event_update_request = props.on_event_update_request.clone(); | ||||
|     let context_menus_open = props.context_menus_open; | ||||
|      | ||||
|     html! { | ||||
|         <Switch<Route> render={move |route| { | ||||
|             let auth_token = auth_token.clone(); | ||||
|             let user_info = user_info.clone(); | ||||
|             let on_login = on_login.clone(); | ||||
|             let on_event_context_menu = on_event_context_menu.clone(); | ||||
|             let on_calendar_context_menu = on_calendar_context_menu.clone(); | ||||
|             let view = view.clone(); | ||||
|             let on_create_event_request = on_create_event_request.clone(); | ||||
|             let on_event_update_request = on_event_update_request.clone(); | ||||
|             let context_menus_open = context_menus_open; | ||||
|              | ||||
|             match route { | ||||
|                 Route::Home => { | ||||
|                     if auth_token.is_some() { | ||||
|                         html! { <Redirect<Route> to={Route::Calendar}/> } | ||||
|                     } else { | ||||
|                         html! { <Redirect<Route> to={Route::Login}/> } | ||||
|                     } | ||||
|                 } | ||||
|                 Route::Login => { | ||||
|                     if auth_token.is_some() { | ||||
|                         html! { <Redirect<Route> to={Route::Calendar}/> } | ||||
|                     } else { | ||||
|                         html! { <Login {on_login} /> } | ||||
|                     } | ||||
|                 } | ||||
|                 Route::Calendar => { | ||||
|                     if auth_token.is_some() { | ||||
|                         html! {  | ||||
|                             <CalendarView  | ||||
|                                 user_info={user_info}  | ||||
|                                 on_event_context_menu={on_event_context_menu} | ||||
|                                 on_calendar_context_menu={on_calendar_context_menu} | ||||
|                                 view={view} | ||||
|                                 on_create_event_request={on_create_event_request} | ||||
|                                 on_event_update_request={on_event_update_request} | ||||
|                                 context_menus_open={context_menus_open} | ||||
|                             />  | ||||
|                         } | ||||
|                     } else { | ||||
|                         html! { <Redirect<Route> to={Route::Login}/> } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         }} /> | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct CalendarViewProps { | ||||
|     pub user_info: Option<UserInfo>, | ||||
|     #[prop_or_default] | ||||
|     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, | ||||
|     #[prop_or_default] | ||||
|     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>, | ||||
|     #[prop_or_default] | ||||
|     pub view: ViewMode, | ||||
|     #[prop_or_default] | ||||
|     pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>, | ||||
|     #[prop_or_default] | ||||
|     pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)>>, | ||||
|     #[prop_or_default] | ||||
|     pub context_menus_open: bool, | ||||
| } | ||||
|  | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
| use crate::services::CalendarService; | ||||
| use crate::components::Calendar; | ||||
| use std::collections::HashMap; | ||||
| use chrono::{Local, NaiveDate, Datelike}; | ||||
|  | ||||
| #[function_component(CalendarView)] | ||||
| pub fn calendar_view(props: &CalendarViewProps) -> Html { | ||||
|     let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new()); | ||||
|     let loading = use_state(|| true); | ||||
|     let error = use_state(|| None::<String>); | ||||
|     let refreshing_event = use_state(|| None::<String>); | ||||
|      | ||||
|     let auth_token: Option<String> = LocalStorage::get("auth_token").ok(); | ||||
|      | ||||
|      | ||||
|     let today = Local::now().date_naive(); | ||||
|     let current_year = today.year(); | ||||
|     let current_month = today.month(); | ||||
|      | ||||
|     let on_event_click = { | ||||
|         let events = events.clone(); | ||||
|         let refreshing_event = refreshing_event.clone(); | ||||
|         let auth_token = auth_token.clone(); | ||||
|          | ||||
|         Callback::from(move |event: VEvent| { | ||||
|             if let Some(token) = auth_token.clone() { | ||||
|                 let events = events.clone(); | ||||
|                 let refreshing_event = refreshing_event.clone(); | ||||
|                 let uid = event.uid.clone(); | ||||
|                  | ||||
|                 refreshing_event.set(Some(uid.clone())); | ||||
|                  | ||||
|                 wasm_bindgen_futures::spawn_local(async move { | ||||
|                     let calendar_service = CalendarService::new(); | ||||
|                      | ||||
|                     let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") { | ||||
|                         if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) { | ||||
|                             credentials["password"].as_str().unwrap_or("").to_string() | ||||
|                         } else { | ||||
|                             String::new() | ||||
|                         } | ||||
|                     } else { | ||||
|                         String::new() | ||||
|                     }; | ||||
|                      | ||||
|                     match calendar_service.refresh_event(&token, &password, &uid).await { | ||||
|                         Ok(Some(refreshed_event)) => { | ||||
|                             let refreshed_vevent = VEvent::from_calendar_event(&refreshed_event); | ||||
|                             let mut updated_events = (*events).clone(); | ||||
|                              | ||||
|                             for (_, day_events) in updated_events.iter_mut() { | ||||
|                                 day_events.retain(|e| e.uid != uid); | ||||
|                             } | ||||
|                              | ||||
|                             if refreshed_vevent.rrule.is_some() { | ||||
|                                 let new_occurrences = CalendarService::expand_recurring_events(vec![refreshed_vevent.clone()]); | ||||
|                                  | ||||
|                                 for occurrence in new_occurrences { | ||||
|                                     let date = occurrence.get_date(); | ||||
|                                     updated_events.entry(date) | ||||
|                                         .or_insert_with(Vec::new) | ||||
|                                         .push(occurrence); | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 let date = refreshed_vevent.get_date(); | ||||
|                                 updated_events.entry(date) | ||||
|                                     .or_insert_with(Vec::new) | ||||
|                                     .push(refreshed_vevent); | ||||
|                             } | ||||
|                              | ||||
|                             events.set(updated_events); | ||||
|                         } | ||||
|                         Ok(None) => { | ||||
|                             let mut updated_events = (*events).clone(); | ||||
|                             for (_, day_events) in updated_events.iter_mut() { | ||||
|                                 day_events.retain(|e| e.uid != uid); | ||||
|                             } | ||||
|                             events.set(updated_events); | ||||
|                         } | ||||
|                         Err(_err) => { | ||||
|                         } | ||||
|                     } | ||||
|                      | ||||
|                     refreshing_event.set(None); | ||||
|                 }); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     { | ||||
|         let events = events.clone(); | ||||
|         let loading = loading.clone(); | ||||
|         let error = error.clone(); | ||||
|         let auth_token = auth_token.clone(); | ||||
|          | ||||
|         use_effect_with((), move |_| { | ||||
|             if let Some(token) = auth_token { | ||||
|                 let events = events.clone(); | ||||
|                 let loading = loading.clone(); | ||||
|                 let error = error.clone(); | ||||
|                  | ||||
|                 wasm_bindgen_futures::spawn_local(async move { | ||||
|                     let calendar_service = CalendarService::new(); | ||||
|                      | ||||
|                     let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") { | ||||
|                         if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) { | ||||
|                             credentials["password"].as_str().unwrap_or("").to_string() | ||||
|                         } else { | ||||
|                             String::new() | ||||
|                         } | ||||
|                     } else { | ||||
|                         String::new() | ||||
|                     }; | ||||
|                      | ||||
|                     match calendar_service.fetch_events_for_month_vevent(&token, &password, current_year, current_month).await { | ||||
|                         Ok(vevents) => { | ||||
|                             let grouped_events = CalendarService::group_events_by_date(vevents); | ||||
|                             events.set(grouped_events); | ||||
|                             loading.set(false); | ||||
|                         } | ||||
|                         Err(err) => { | ||||
|                             error.set(Some(format!("Failed to load events: {}", err))); | ||||
|                             loading.set(false); | ||||
|                         } | ||||
|                     } | ||||
|                 }); | ||||
|             } else { | ||||
|                 loading.set(false); | ||||
|                 error.set(Some("No authentication token found".to_string())); | ||||
|             } | ||||
|              | ||||
|             || () | ||||
|         }); | ||||
|     } | ||||
|      | ||||
|     html! { | ||||
|         <div class="calendar-view"> | ||||
|             { | ||||
|                 if *loading { | ||||
|                     html! { | ||||
|                         <div class="calendar-loading"> | ||||
|                             <p>{"Loading calendar events..."}</p> | ||||
|                         </div> | ||||
|                     } | ||||
|                 } else if let Some(err) = (*error).clone() { | ||||
|                     let dummy_callback = Callback::from(|_: VEvent| {}); | ||||
|                     html! { | ||||
|                         <div class="calendar-error"> | ||||
|                             <p>{format!("Error: {}", err)}</p> | ||||
|                             <Calendar  | ||||
|                                 events={HashMap::new()}  | ||||
|                                 on_event_click={dummy_callback}  | ||||
|                                 refreshing_event_uid={(*refreshing_event).clone()}  | ||||
|                                 user_info={props.user_info.clone()} | ||||
|                                 on_event_context_menu={props.on_event_context_menu.clone()} | ||||
|                                 on_calendar_context_menu={props.on_calendar_context_menu.clone()} | ||||
|                                 view={props.view.clone()} | ||||
|                                 on_create_event_request={props.on_create_event_request.clone()} | ||||
|                                 on_event_update_request={props.on_event_update_request.clone()} | ||||
|                                 context_menus_open={props.context_menus_open} | ||||
|                             /> | ||||
|                         </div> | ||||
|                     } | ||||
|                 } else { | ||||
|                     html! { | ||||
|                         <Calendar  | ||||
|                             events={(*events).clone()}  | ||||
|                             on_event_click={on_event_click}  | ||||
|                             refreshing_event_uid={(*refreshing_event).clone()}  | ||||
|                             user_info={props.user_info.clone()} | ||||
|                             on_event_context_menu={props.on_event_context_menu.clone()} | ||||
|                             on_calendar_context_menu={props.on_calendar_context_menu.clone()} | ||||
|                             view={props.view.clone()} | ||||
|                             on_create_event_request={props.on_create_event_request.clone()} | ||||
|                             on_event_update_request={props.on_event_update_request.clone()} | ||||
|                             context_menus_open={props.context_menus_open} | ||||
|                         /> | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										195
									
								
								frontend/src/components/sidebar.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										195
									
								
								frontend/src/components/sidebar.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,195 @@ | ||||
| use yew::prelude::*; | ||||
| use yew_router::prelude::*; | ||||
| use web_sys::HtmlSelectElement; | ||||
| use crate::services::calendar_service::UserInfo; | ||||
| use crate::components::CalendarListItem; | ||||
|  | ||||
| #[derive(Clone, Routable, PartialEq)] | ||||
| pub enum Route { | ||||
|     #[at("/")] | ||||
|     Home, | ||||
|     #[at("/login")] | ||||
|     Login, | ||||
|     #[at("/calendar")] | ||||
|     Calendar, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum ViewMode { | ||||
|     Month, | ||||
|     Week, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum Theme { | ||||
|     Default, | ||||
|     Ocean, | ||||
|     Forest, | ||||
|     Sunset, | ||||
|     Purple, | ||||
|     Dark, | ||||
|     Rose, | ||||
|     Mint, | ||||
| } | ||||
|  | ||||
| impl Theme { | ||||
|      | ||||
|     pub fn value(&self) -> &'static str { | ||||
|         match self { | ||||
|             Theme::Default => "default", | ||||
|             Theme::Ocean => "ocean", | ||||
|             Theme::Forest => "forest",  | ||||
|             Theme::Sunset => "sunset", | ||||
|             Theme::Purple => "purple", | ||||
|             Theme::Dark => "dark", | ||||
|             Theme::Rose => "rose", | ||||
|             Theme::Mint => "mint", | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     pub fn from_value(value: &str) -> Self { | ||||
|         match value { | ||||
|             "ocean" => Theme::Ocean, | ||||
|             "forest" => Theme::Forest, | ||||
|             "sunset" => Theme::Sunset, | ||||
|             "purple" => Theme::Purple, | ||||
|             "dark" => Theme::Dark, | ||||
|             "rose" => Theme::Rose, | ||||
|             "mint" => Theme::Mint, | ||||
|             _ => Theme::Default, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Default for ViewMode { | ||||
|     fn default() -> Self { | ||||
|         ViewMode::Month | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct SidebarProps { | ||||
|     pub user_info: Option<UserInfo>, | ||||
|     pub on_logout: Callback<()>, | ||||
|     pub on_create_calendar: Callback<()>, | ||||
|     pub color_picker_open: Option<String>, | ||||
|     pub on_color_change: Callback<(String, String)>, | ||||
|     pub on_color_picker_toggle: Callback<String>, | ||||
|     pub available_colors: Vec<String>, | ||||
|     pub on_calendar_context_menu: Callback<(MouseEvent, String)>, | ||||
|     pub current_view: ViewMode, | ||||
|     pub on_view_change: Callback<ViewMode>, | ||||
|     pub current_theme: Theme, | ||||
|     pub on_theme_change: Callback<Theme>, | ||||
| } | ||||
|  | ||||
| #[function_component(Sidebar)] | ||||
| pub fn sidebar(props: &SidebarProps) -> Html { | ||||
|     let on_view_change = { | ||||
|         let on_view_change = props.on_view_change.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             let target = e.target_dyn_into::<HtmlSelectElement>(); | ||||
|             if let Some(select) = target { | ||||
|                 let value = select.value(); | ||||
|                 let new_view = match value.as_str() { | ||||
|                     "week" => ViewMode::Week, | ||||
|                     _ => ViewMode::Month, | ||||
|                 }; | ||||
|                 on_view_change.emit(new_view); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_theme_change = { | ||||
|         let on_theme_change = props.on_theme_change.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             let target = e.target_dyn_into::<HtmlSelectElement>(); | ||||
|             if let Some(select) = target { | ||||
|                 let value = select.value(); | ||||
|                 let new_theme = Theme::from_value(&value); | ||||
|                 on_theme_change.emit(new_theme); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <aside class="app-sidebar"> | ||||
|             <div class="sidebar-header"> | ||||
|                 <h1>{"Calendar App"}</h1> | ||||
|                 { | ||||
|                     if let Some(ref info) = props.user_info { | ||||
|                         html! { | ||||
|                             <div class="user-info"> | ||||
|                                 <div class="username">{&info.username}</div> | ||||
|                                 <div class="server-url">{&info.server_url}</div> | ||||
|                             </div> | ||||
|                         } | ||||
|                     } else { | ||||
|                         html! { <div class="user-info loading">{"Loading..."}</div> } | ||||
|                     } | ||||
|                 } | ||||
|             </div> | ||||
|             <nav class="sidebar-nav"> | ||||
|                 <Link<Route> to={Route::Calendar} classes="nav-link">{"Calendar"}</Link<Route>> | ||||
|             </nav> | ||||
|             { | ||||
|                 if let Some(ref info) = props.user_info { | ||||
|                     if !info.calendars.is_empty() { | ||||
|                         html! { | ||||
|                             <div class="calendar-list"> | ||||
|                                 <h3>{"My Calendars"}</h3> | ||||
|                                 <ul> | ||||
|                                     { | ||||
|                                         info.calendars.iter().map(|cal| { | ||||
|                                             html! { | ||||
|                                                 <CalendarListItem | ||||
|                                                     calendar={cal.clone()} | ||||
|                                                     color_picker_open={props.color_picker_open.as_ref() == Some(&cal.path)} | ||||
|                                                     on_color_change={props.on_color_change.clone()} | ||||
|                                                     on_color_picker_toggle={props.on_color_picker_toggle.clone()} | ||||
|                                                     available_colors={props.available_colors.clone()} | ||||
|                                                     on_context_menu={props.on_calendar_context_menu.clone()} | ||||
|                                                 /> | ||||
|                                             } | ||||
|                                         }).collect::<Html>() | ||||
|                                     } | ||||
|                                 </ul> | ||||
|                             </div> | ||||
|                         } | ||||
|                     } else { | ||||
|                         html! { <div class="no-calendars">{"No calendars found"}</div> } | ||||
|                     } | ||||
|                 } else { | ||||
|                     html! {} | ||||
|                 } | ||||
|             } | ||||
|             <div class="sidebar-footer"> | ||||
|                 <button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button"> | ||||
|                     {"+ Create Calendar"} | ||||
|                 </button> | ||||
|                  | ||||
|                 <div class="view-selector"> | ||||
|                     <select class="view-selector-dropdown" onchange={on_view_change}> | ||||
|                         <option value="month" selected={matches!(props.current_view, ViewMode::Month)}>{"Month"}</option> | ||||
|                         <option value="week" selected={matches!(props.current_view, ViewMode::Week)}>{"Week"}</option> | ||||
|                     </select> | ||||
|                 </div> | ||||
|                  | ||||
|                 <div class="theme-selector"> | ||||
|                     <select class="theme-selector-dropdown" onchange={on_theme_change}> | ||||
|                         <option value="default" selected={matches!(props.current_theme, Theme::Default)}>{"🎨 Default"}</option> | ||||
|                         <option value="ocean" selected={matches!(props.current_theme, Theme::Ocean)}>{"🌊 Ocean"}</option> | ||||
|                         <option value="forest" selected={matches!(props.current_theme, Theme::Forest)}>{"🌲 Forest"}</option> | ||||
|                         <option value="sunset" selected={matches!(props.current_theme, Theme::Sunset)}>{"🌅 Sunset"}</option> | ||||
|                         <option value="purple" selected={matches!(props.current_theme, Theme::Purple)}>{"💜 Purple"}</option> | ||||
|                         <option value="dark" selected={matches!(props.current_theme, Theme::Dark)}>{"🌙 Dark"}</option> | ||||
|                         <option value="rose" selected={matches!(props.current_theme, Theme::Rose)}>{"🌹 Rose"}</option> | ||||
|                         <option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"🍃 Mint"}</option> | ||||
|                     </select> | ||||
|                 </div> | ||||
|                  | ||||
|                 <button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button> | ||||
|             </div> | ||||
|         </aside> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										1047
									
								
								frontend/src/components/week_view.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1047
									
								
								frontend/src/components/week_view.rs
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
		Reference in New Issue
	
	Block a user
	 Connor Johnstone
					Connor Johnstone