Implement event deletion with right-click context menu
- Add EventContextMenu component with delete option - Create DELETE /api/calendar/events/delete endpoint - Implement CalDAV event deletion in backend - Add proper URL construction for CalDAV event hrefs - Integrate context menu with calendar event right-clicks - Auto-refresh UI after successful event deletion 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
		
							
								
								
									
										75
									
								
								src/app.rs
									
									
									
									
									
								
							
							
						
						
									
										75
									
								
								src/app.rs
									
									
									
									
									
								
							| @@ -2,8 +2,8 @@ use yew::prelude::*; | ||||
| use yew_router::prelude::*; | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
| use web_sys::MouseEvent; | ||||
| use crate::components::{Sidebar, CreateCalendarModal, ContextMenu, RouteHandler}; | ||||
| use crate::services::{CalendarService, calendar_service::UserInfo}; | ||||
| use crate::components::{Sidebar, CreateCalendarModal, ContextMenu, EventContextMenu, RouteHandler}; | ||||
| use crate::services::{CalendarService, calendar_service::{UserInfo, CalendarEvent}}; | ||||
|  | ||||
|  | ||||
| #[function_component] | ||||
| @@ -18,6 +18,9 @@ pub fn App() -> Html { | ||||
|     let context_menu_open = use_state(|| false); | ||||
|     let context_menu_pos = use_state(|| (0i32, 0i32)); | ||||
|     let context_menu_calendar_path = use_state(|| -> Option<String> { None }); | ||||
|     let event_context_menu_open = use_state(|| false); | ||||
|     let event_context_menu_pos = use_state(|| (0i32, 0i32)); | ||||
|     let event_context_menu_event = use_state(|| -> Option<CalendarEvent> { None }); | ||||
|      | ||||
|     let available_colors = [ | ||||
|         "#3B82F6", "#10B981", "#F59E0B", "#EF4444",  | ||||
| @@ -99,9 +102,11 @@ pub fn App() -> Html { | ||||
|     let on_outside_click = { | ||||
|         let color_picker_open = color_picker_open.clone(); | ||||
|         let context_menu_open = context_menu_open.clone(); | ||||
|         let event_context_menu_open = event_context_menu_open.clone(); | ||||
|         Callback::from(move |_: MouseEvent| { | ||||
|             color_picker_open.set(None); | ||||
|             context_menu_open.set(false); | ||||
|             event_context_menu_open.set(false); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
| @@ -148,6 +153,17 @@ pub fn App() -> Html { | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_event_context_menu = { | ||||
|         let event_context_menu_open = event_context_menu_open.clone(); | ||||
|         let event_context_menu_pos = event_context_menu_pos.clone(); | ||||
|         let event_context_menu_event = event_context_menu_event.clone(); | ||||
|         Callback::from(move |(event, calendar_event): (MouseEvent, CalendarEvent)| { | ||||
|             event_context_menu_open.set(true); | ||||
|             event_context_menu_pos.set((event.client_x(), event.client_y())); | ||||
|             event_context_menu_event.set(Some(calendar_event)); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let refresh_calendars = { | ||||
|         let auth_token = auth_token.clone(); | ||||
|         let user_info = user_info.clone(); | ||||
| @@ -217,6 +233,7 @@ pub fn App() -> Html { | ||||
|                                         auth_token={(*auth_token).clone()} | ||||
|                                         user_info={(*user_info).clone()} | ||||
|                                         on_login={on_login.clone()} | ||||
|                                         on_event_context_menu={Some(on_event_context_menu.clone())} | ||||
|                                     /> | ||||
|                                 </main> | ||||
|                             </> | ||||
| @@ -228,6 +245,7 @@ pub fn App() -> Html { | ||||
|                                     auth_token={(*auth_token).clone()} | ||||
|                                     user_info={(*user_info).clone()} | ||||
|                                     on_login={on_login.clone()} | ||||
|                                     on_event_context_menu={Some(on_event_context_menu.clone())} | ||||
|                                 /> | ||||
|                             </div> | ||||
|                         } | ||||
| @@ -323,6 +341,59 @@ pub fn App() -> Html { | ||||
|                         } | ||||
|                     })} | ||||
|                 /> | ||||
|                  | ||||
|                 <EventContextMenu  | ||||
|                     is_open={*event_context_menu_open} | ||||
|                     x={event_context_menu_pos.0} | ||||
|                     y={event_context_menu_pos.1} | ||||
|                     on_close={Callback::from({ | ||||
|                         let event_context_menu_open = event_context_menu_open.clone(); | ||||
|                         move |_| event_context_menu_open.set(false) | ||||
|                     })} | ||||
|                     on_delete={Callback::from({ | ||||
|                         let auth_token = auth_token.clone(); | ||||
|                         let event_context_menu_event = event_context_menu_event.clone(); | ||||
|                         let event_context_menu_open = event_context_menu_open.clone(); | ||||
|                         let refresh_calendars = refresh_calendars.clone(); | ||||
|                         move |_: MouseEvent| { | ||||
|                             if let (Some(token), Some(event)) = ((*auth_token).clone(), (*event_context_menu_event).clone()) { | ||||
|                                 let refresh_calendars = refresh_calendars.clone(); | ||||
|                                 let event_context_menu_open = event_context_menu_open.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() | ||||
|                                     }; | ||||
|                                      | ||||
|                                     if let (Some(calendar_path), Some(event_href)) = (&event.calendar_path, &event.href) { | ||||
|                                         match calendar_service.delete_event(&token, &password, calendar_path.clone(), event_href.clone()).await { | ||||
|                                             Ok(_) => { | ||||
|                                                 web_sys::console::log_1(&"Event deleted successfully!".into()); | ||||
|                                                 // Close the context menu | ||||
|                                                 event_context_menu_open.set(false); | ||||
|                                                 // Force a page reload to refresh the calendar events | ||||
|                                                 web_sys::window().unwrap().location().reload().unwrap(); | ||||
|                                             } | ||||
|                                             Err(err) => { | ||||
|                                                 web_sys::console::log_1(&format!("Failed to delete event: {}", err).into()); | ||||
|                                             } | ||||
|                                         } | ||||
|                                     } else { | ||||
|                                         web_sys::console::log_1(&"Missing calendar_path or href - cannot delete event".into()); | ||||
|                                     } | ||||
|                                 }); | ||||
|                             } | ||||
|                         } | ||||
|                     })} | ||||
|                 /> | ||||
|             </div> | ||||
|         </BrowserRouter> | ||||
|     } | ||||
|   | ||||
| @@ -13,6 +13,8 @@ pub struct CalendarProps { | ||||
|     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, CalendarEvent)>>, | ||||
| } | ||||
|  | ||||
| #[function_component] | ||||
| @@ -131,6 +133,18 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                                                             on_event_click.emit(event_clone.clone()); | ||||
|                                                             selected_event_clone.set(Some(event_clone.clone())); | ||||
|                                                         }); | ||||
|  | ||||
|                                                         let event_context_menu = { | ||||
|                                                             let event_clone = event.clone(); | ||||
|                                                             let on_event_context_menu = props.on_event_context_menu.clone(); | ||||
|                                                             Callback::from(move |e: MouseEvent| { | ||||
|                                                                 e.prevent_default(); | ||||
|                                                                 e.stop_propagation(); | ||||
|                                                                 if let Some(callback) = &on_event_context_menu { | ||||
|                                                                     callback.emit((e, event_clone.clone())); | ||||
|                                                                 } | ||||
|                                                             }) | ||||
|                                                         }; | ||||
|                                                          | ||||
|                                                         let title = event.get_title(); | ||||
|                                                         let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid); | ||||
| @@ -140,6 +154,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                                                             <div class={class_name}  | ||||
|                                                                  title={title.clone()}  | ||||
|                                                                  onclick={event_click} | ||||
|                                                                  oncontextmenu={event_context_menu} | ||||
|                                                                  style={format!("background-color: {}", event_color)}> | ||||
|                                                                 { | ||||
|                                                                     if is_refreshing { | ||||
|   | ||||
							
								
								
									
										47
									
								
								src/components/event_context_menu.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/components/event_context_menu.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| use yew::prelude::*; | ||||
| use web_sys::MouseEvent; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct EventContextMenuProps { | ||||
|     pub is_open: bool, | ||||
|     pub x: i32, | ||||
|     pub y: i32, | ||||
|     pub on_delete: Callback<MouseEvent>, | ||||
|     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 | ||||
|     ); | ||||
|  | ||||
|     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 Event"} | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| @@ -3,6 +3,7 @@ pub mod calendar; | ||||
| pub mod event_modal; | ||||
| pub mod create_calendar_modal; | ||||
| pub mod context_menu; | ||||
| pub mod event_context_menu; | ||||
| pub mod sidebar; | ||||
| pub mod calendar_list_item; | ||||
| pub mod route_handler; | ||||
| @@ -12,6 +13,7 @@ pub use calendar::Calendar; | ||||
| pub use event_modal::EventModal; | ||||
| pub use create_calendar_modal::CreateCalendarModal; | ||||
| pub use context_menu::ContextMenu; | ||||
| pub use event_context_menu::EventContextMenu; | ||||
| pub use sidebar::Sidebar; | ||||
| pub use calendar_list_item::CalendarListItem; | ||||
| pub use route_handler::RouteHandler; | ||||
| @@ -1,7 +1,7 @@ | ||||
| use yew::prelude::*; | ||||
| use yew_router::prelude::*; | ||||
| use crate::components::Login; | ||||
| use crate::services::calendar_service::UserInfo; | ||||
| use crate::services::calendar_service::{UserInfo, CalendarEvent}; | ||||
|  | ||||
| #[derive(Clone, Routable, PartialEq)] | ||||
| pub enum Route { | ||||
| @@ -18,6 +18,8 @@ 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, CalendarEvent)>>, | ||||
| } | ||||
|  | ||||
| #[function_component(RouteHandler)] | ||||
| @@ -25,12 +27,14 @@ 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(); | ||||
|      | ||||
|     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(); | ||||
|              | ||||
|             match route { | ||||
|                 Route::Home => { | ||||
| @@ -49,7 +53,12 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | ||||
|                 } | ||||
|                 Route::Calendar => { | ||||
|                     if auth_token.is_some() { | ||||
|                         html! { <CalendarView user_info={user_info} /> } | ||||
|                         html! {  | ||||
|                             <CalendarView  | ||||
|                                 user_info={user_info}  | ||||
|                                 on_event_context_menu={on_event_context_menu} | ||||
|                             />  | ||||
|                         } | ||||
|                     } else { | ||||
|                         html! { <Redirect<Route> to={Route::Login}/> } | ||||
|                     } | ||||
| @@ -62,10 +71,12 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct CalendarViewProps { | ||||
|     pub user_info: Option<UserInfo>, | ||||
|     #[prop_or_default] | ||||
|     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>, | ||||
| } | ||||
|  | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
| use crate::services::{CalendarService, CalendarEvent}; | ||||
| use crate::services::CalendarService; | ||||
| use crate::components::Calendar; | ||||
| use std::collections::HashMap; | ||||
| use chrono::{Local, NaiveDate, Datelike}; | ||||
| @@ -79,6 +90,7 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html { | ||||
|      | ||||
|     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(); | ||||
| @@ -212,12 +224,24 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html { | ||||
|                     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()} /> | ||||
|                             <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()} | ||||
|                             /> | ||||
|                         </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()} /> | ||||
|                         <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()} | ||||
|                         /> | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|   | ||||
| @@ -528,6 +528,64 @@ impl CalendarService { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Delete an event from the CalDAV server | ||||
|     pub async fn delete_event( | ||||
|         &self,  | ||||
|         token: &str,  | ||||
|         password: &str, | ||||
|         calendar_path: String, | ||||
|         event_href: String | ||||
|     ) -> Result<(), String> { | ||||
|         let window = web_sys::window().ok_or("No global window exists")?; | ||||
|          | ||||
|         let opts = RequestInit::new(); | ||||
|         opts.set_method("POST"); | ||||
|         opts.set_mode(RequestMode::Cors); | ||||
|  | ||||
|         let body = serde_json::json!({ | ||||
|             "calendar_path": calendar_path, | ||||
|             "event_href": event_href | ||||
|         }); | ||||
|  | ||||
|         let body_string = serde_json::to_string(&body) | ||||
|             .map_err(|e| format!("JSON serialization failed: {}", e))?; | ||||
|  | ||||
|         let url = format!("{}/calendar/events/delete", self.base_url); | ||||
|         opts.set_body(&body_string.into()); | ||||
|         let request = Request::new_with_str_and_init(&url, &opts) | ||||
|             .map_err(|e| format!("Request creation failed: {:?}", e))?; | ||||
|  | ||||
|         request.headers().set("Authorization", &format!("Bearer {}", token)) | ||||
|             .map_err(|e| format!("Authorization header setting failed: {:?}", e))?; | ||||
|          | ||||
|         request.headers().set("X-CalDAV-Password", password) | ||||
|             .map_err(|e| format!("Password header setting failed: {:?}", e))?; | ||||
|  | ||||
|         request.headers().set("Content-Type", "application/json") | ||||
|             .map_err(|e| format!("Content-Type header setting failed: {:?}", e))?; | ||||
|  | ||||
|         let resp_value = JsFuture::from(window.fetch_with_request(&request)) | ||||
|             .await | ||||
|             .map_err(|e| format!("Network request failed: {:?}", e))?; | ||||
|  | ||||
|         let resp: Response = resp_value.dyn_into() | ||||
|             .map_err(|e| format!("Response cast failed: {:?}", e))?; | ||||
|  | ||||
|         let text = JsFuture::from(resp.text() | ||||
|             .map_err(|e| format!("Text extraction failed: {:?}", e))?) | ||||
|             .await | ||||
|             .map_err(|e| format!("Text promise failed: {:?}", e))?; | ||||
|  | ||||
|         let text_string = text.as_string() | ||||
|             .ok_or("Response text is not a string")?; | ||||
|  | ||||
|         if resp.ok() { | ||||
|             Ok(()) | ||||
|         } else { | ||||
|             Err(format!("Request failed with status {}: {}", resp.status(), text_string)) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Delete a calendar from the CalDAV server | ||||
|     pub async fn delete_calendar( | ||||
|         &self,  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Connor Johnstone
					Connor Johnstone