From 7e62e3b7e3b33c810e855db395b8cfec16682ce8 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Thu, 28 Aug 2025 22:07:09 -0400 Subject: [PATCH] Implement event deletion with right-click context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/src/calendar.rs | 41 +++++++++++++++ backend/src/handlers.rs | 36 ++++++++++++- backend/src/lib.rs | 1 + backend/src/models.rs | 12 +++++ src/app.rs | 75 +++++++++++++++++++++++++++- src/components/calendar.rs | 15 ++++++ src/components/event_context_menu.rs | 47 +++++++++++++++++ src/components/mod.rs | 2 + src/components/route_handler.rs | 34 +++++++++++-- src/services/calendar_service.rs | 58 +++++++++++++++++++++ 10 files changed, 313 insertions(+), 8 deletions(-) create mode 100644 src/components/event_context_menu.rs diff --git a/backend/src/calendar.rs b/backend/src/calendar.rs index cd65ac7..497e0a2 100644 --- a/backend/src/calendar.rs +++ b/backend/src/calendar.rs @@ -694,6 +694,47 @@ impl CalDAVClient { Err(CalDAVError::ServerError(status.as_u16())) } } + + /// Delete an event from a CalDAV calendar + pub async fn delete_event(&self, calendar_path: &str, event_href: &str) -> Result<(), CalDAVError> { + // Construct the full URL for the event + let full_url = if event_href.starts_with("http") { + event_href.to_string() + } else if event_href.starts_with("/dav.php") { + // Event href is already a full path, combine with base server URL (without /dav.php) + let base_url = self.config.server_url.trim_end_matches('/').trim_end_matches("/dav.php"); + format!("{}{}", base_url, event_href) + } else { + // Event href is just a filename, combine with calendar path + let clean_path = if calendar_path.starts_with("/dav.php") { + calendar_path.trim_start_matches("/dav.php") + } else { + calendar_path + }; + format!("{}/dav.php{}{}", self.config.server_url.trim_end_matches('/'), clean_path, event_href) + }; + + println!("Deleting event at: {}", full_url); + + let response = self.http_client + .delete(&full_url) + .header("Authorization", format!("Basic {}", self.config.get_basic_auth())) + .send() + .await + .map_err(|e| CalDAVError::ParseError(e.to_string()))?; + + println!("Event deletion response status: {}", response.status()); + + if response.status().is_success() || response.status().as_u16() == 204 { + println!("✅ Event deleted successfully at {}", event_href); + Ok(()) + } else { + let status = response.status(); + let error_body = response.text().await.unwrap_or_default(); + println!("❌ Event deletion failed: {} - {}", status, error_body); + Err(CalDAVError::ServerError(status.as_u16())) + } + } } /// Helper struct for extracting calendar data from XML responses diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index fcbcf4f..7d34aac 100644 --- a/backend/src/handlers.rs +++ b/backend/src/handlers.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use std::sync::Arc; use chrono::Datelike; -use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse}}; +use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse, DeleteEventRequest, DeleteEventResponse}}; use crate::calendar::{CalDAVClient, CalendarEvent}; #[derive(Deserialize)] @@ -323,4 +323,38 @@ pub async fn delete_calendar( success: true, message: "Calendar deleted successfully".to_string(), })) +} + +pub async fn delete_event( + State(state): State>, + headers: HeaderMap, + Json(request): Json, +) -> Result, ApiError> { + println!("🗑️ Delete event request received: calendar_path='{}', event_href='{}'", request.calendar_path, request.event_href); + + // Extract and verify token + let token = extract_bearer_token(&headers)?; + let password = extract_password_header(&headers)?; + + // Validate request + if request.calendar_path.trim().is_empty() { + return Err(ApiError::BadRequest("Calendar path is required".to_string())); + } + if request.event_href.trim().is_empty() { + return Err(ApiError::BadRequest("Event href is required".to_string())); + } + + // Create CalDAV config from token and password + let config = state.auth_service.caldav_config_from_token(&token, &password)?; + let client = CalDAVClient::new(config); + + // Delete the event + client.delete_event(&request.calendar_path, &request.event_href) + .await + .map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?; + + Ok(Json(DeleteEventResponse { + success: true, + message: "Event deleted successfully".to_string(), + })) } \ No newline at end of file diff --git a/backend/src/lib.rs b/backend/src/lib.rs index f465087..5b31c2a 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -41,6 +41,7 @@ pub async fn run_server() -> Result<(), Box> { .route("/api/calendar/create", post(handlers::create_calendar)) .route("/api/calendar/delete", post(handlers::delete_calendar)) .route("/api/calendar/events", get(handlers::get_calendar_events)) + .route("/api/calendar/events/delete", post(handlers::delete_event)) .route("/api/calendar/events/:uid", get(handlers::refresh_event)) .layer( CorsLayer::new() diff --git a/backend/src/models.rs b/backend/src/models.rs index 0e0d2d2..d62c039 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -58,6 +58,18 @@ pub struct DeleteCalendarResponse { pub message: String, } +#[derive(Debug, Deserialize)] +pub struct DeleteEventRequest { + pub calendar_path: String, + pub event_href: String, +} + +#[derive(Debug, Serialize)] +pub struct DeleteEventResponse { + pub success: bool, + pub message: String, +} + // Error handling #[derive(Debug)] pub enum ApiError { diff --git a/src/app.rs b/src/app.rs index 873eed4..2a69cf3 100644 --- a/src/app.rs +++ b/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 { 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 { 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())} /> @@ -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())} /> } @@ -323,6 +341,59 @@ pub fn App() -> Html { } })} /> + + ("caldav_credentials") { + if let Ok(credentials) = serde_json::from_str::(&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()); + } + }); + } + } + })} + /> } diff --git a/src/components/calendar.rs b/src/components/calendar.rs index 3551684..7a1dd08 100644 --- a/src/components/calendar.rs +++ b/src/components/calendar.rs @@ -13,6 +13,8 @@ pub struct CalendarProps { pub refreshing_event_uid: Option, #[prop_or_default] pub user_info: Option, + #[prop_or_default] + pub on_event_context_menu: Option>, } #[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 {
{ if is_refreshing { diff --git a/src/components/event_context_menu.rs b/src/components/event_context_menu.rs new file mode 100644 index 0000000..d843cc8 --- /dev/null +++ b/src/components/event_context_menu.rs @@ -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, + 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! { +
+
+ {"🗑️"} + {"Delete Event"} +
+
+ } +} \ No newline at end of file diff --git a/src/components/mod.rs b/src/components/mod.rs index 7f97e2c..e4cd503 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -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; \ No newline at end of file diff --git a/src/components/route_handler.rs b/src/components/route_handler.rs index aed0edb..0cfb67e 100644 --- a/src/components/route_handler.rs +++ b/src/components/route_handler.rs @@ -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, pub user_info: Option, pub on_login: Callback, + #[prop_or_default] + pub on_event_context_menu: Option>, } #[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! { 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! { } + html! { + + } } else { html! { to={Route::Login}/> } } @@ -62,10 +71,12 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { #[derive(Properties, PartialEq)] pub struct CalendarViewProps { pub user_info: Option, + #[prop_or_default] + pub on_event_context_menu: Option>, } 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 = 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! {

{format!("Error: {}", err)}

- +
} } else { html! { - + } } } diff --git a/src/services/calendar_service.rs b/src/services/calendar_service.rs index 6a516a3..b868e12 100644 --- a/src/services/calendar_service.rs +++ b/src/services/calendar_service.rs @@ -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,