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:
Connor Johnstone
2025-08-28 22:07:09 -04:00
parent b444ae710d
commit 7e62e3b7e3
10 changed files with 313 additions and 8 deletions

View File

@@ -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

View File

@@ -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<Arc<AppState>>,
headers: HeaderMap,
Json(request): Json<DeleteEventRequest>,
) -> Result<Json<DeleteEventResponse>, 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(),
}))
}

View File

@@ -41,6 +41,7 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
.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()

View File

@@ -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 {

View File

@@ -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>
}

View File

@@ -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 {

View 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>
}
}

View File

@@ -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;

View File

@@ -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()}
/>
}
}
}

View File

@@ -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,