diff --git a/backend/src/calendar.rs b/backend/src/calendar.rs index 608d356..e369f62 100644 --- a/backend/src/calendar.rs +++ b/backend/src/calendar.rs @@ -192,6 +192,17 @@ impl CalDAVClient { Ok(events) } + /// Fetch a single calendar event by UID from the CalDAV server + pub async fn fetch_event_by_uid(&self, calendar_path: &str, uid: &str) -> Result, CalDAVError> { + // First fetch all events and find the one with matching UID + let events = self.fetch_events(calendar_path).await?; + + // Find event with matching UID + let event = events.into_iter().find(|e| e.uid == uid); + + Ok(event) + } + /// Extract calendar data sections from CalDAV XML response fn extract_calendar_data(&self, xml_response: &str) -> Vec { let mut sections = Vec::new(); diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index 0d9f62e..e3e438d 100644 --- a/backend/src/handlers.rs +++ b/backend/src/handlers.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{State, Query}, + extract::{State, Query, Path}, http::HeaderMap, response::Json, }; @@ -73,6 +73,52 @@ pub async fn get_calendar_events( Ok(Json(filtered_events)) } +pub async fn refresh_event( + State(_state): State>, + Path(uid): Path, + headers: HeaderMap, +) -> Result>, ApiError> { + // Verify authentication (extract token from Authorization header) + let _token = if let Some(auth_header) = headers.get("authorization") { + let auth_str = auth_header + .to_str() + .map_err(|_| ApiError::Unauthorized("Invalid authorization header".to_string()))?; + + if auth_str.starts_with("Bearer ") { + auth_str.strip_prefix("Bearer ").unwrap().to_string() + } else { + return Err(ApiError::Unauthorized("Invalid authorization format".to_string())); + } + } else { + return Err(ApiError::Unauthorized("Missing authorization header".to_string())); + }; + + // TODO: Validate JWT token here + + // Load CalDAV configuration + let config = CalDAVConfig::from_env() + .map_err(|e| ApiError::Internal(format!("Failed to load CalDAV config: {}", e)))?; + + let client = CalDAVClient::new(config); + + // Discover calendars if needed + let calendar_paths = client.discover_calendars() + .await + .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; + + if calendar_paths.is_empty() { + return Ok(Json(None)); // No calendars found + } + + // Fetch the specific event by UID from the first calendar + let calendar_path = &calendar_paths[0]; + let event = client.fetch_event_by_uid(calendar_path, &uid) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?; + + Ok(Json(event)) +} + pub async fn register( State(state): State>, Json(request): Json, diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 6facf2a..8da55cc 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -53,6 +53,7 @@ pub async fn run_server() -> Result<(), Box> { .route("/api/auth/login", post(handlers::login)) .route("/api/auth/verify", get(handlers::verify_token)) .route("/api/calendar/events", get(handlers::get_calendar_events)) + .route("/api/calendar/events/:uid", get(handlers::refresh_event)) .layer( CorsLayer::new() .allow_origin(Any) diff --git a/src/app.rs b/src/app.rs index 87902f1..2844549 100644 --- a/src/app.rs +++ b/src/app.rs @@ -110,6 +110,7 @@ fn CalendarView() -> Html { let events = use_state(|| HashMap::>::new()); let loading = use_state(|| true); let error = use_state(|| None::); + let refreshing_event = use_state(|| None::); // Get current auth token let auth_token: Option = LocalStorage::get("auth_token").ok(); @@ -118,6 +119,57 @@ fn CalendarView() -> Html { let current_year = today.year(); let current_month = today.month(); + // Event refresh callback + let on_event_click = { + let events = events.clone(); + let refreshing_event = refreshing_event.clone(); + let auth_token = auth_token.clone(); + + Callback::from(move |event: CalendarEvent| { + 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(); + + match calendar_service.refresh_event(&token, &uid).await { + Ok(Some(refreshed_event)) => { + // Update the event in the existing events map + let mut updated_events = (*events).clone(); + for (_, day_events) in updated_events.iter_mut() { + for existing_event in day_events.iter_mut() { + if existing_event.uid == uid { + *existing_event = refreshed_event.clone(); + break; + } + } + } + events.set(updated_events); + } + Ok(None) => { + // Event was deleted, remove it from the map + 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) => { + // Log error but don't show it to user - keep using cached event + // Silently fall back to cached event data + } + } + + refreshing_event.set(None); + }); + } + }) + }; + // Fetch events when component mounts { let events = events.clone(); @@ -165,15 +217,16 @@ fn CalendarView() -> Html { } } else if let Some(err) = (*error).clone() { + let dummy_callback = Callback::from(|_: CalendarEvent| {}); html! {

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

- +
} } else { html! { - + } } } diff --git a/src/components/calendar.rs b/src/components/calendar.rs index 3084996..2628d70 100644 --- a/src/components/calendar.rs +++ b/src/components/calendar.rs @@ -8,6 +8,9 @@ use crate::components::EventModal; pub struct CalendarProps { #[prop_or_default] pub events: HashMap>, + pub on_event_click: Callback, + #[prop_or_default] + pub refreshing_event_uid: Option, } #[function_component] @@ -105,18 +108,24 @@ pub fn Calendar(props: &CalendarProps) -> Html { events.iter().take(2).map(|event| { let event_clone = event.clone(); let selected_event_clone = selected_event.clone(); + let on_event_click = props.on_event_click.clone(); let event_click = Callback::from(move |e: MouseEvent| { e.stop_propagation(); // Prevent day selection + on_event_click.emit(event_clone.clone()); selected_event_clone.set(Some(event_clone.clone())); }); let title = event.get_title(); + let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid); + let class_name = if is_refreshing { "event-box refreshing" } else { "event-box" }; html! { -
{ - if title.len() > 15 { + if is_refreshing { + "🔄 Refreshing...".to_string() + } else if title.len() > 15 { format!("{}...", &title[..12]) } else { title diff --git a/src/services/calendar_service.rs b/src/services/calendar_service.rs index 804c627..fafb27e 100644 --- a/src/services/calendar_service.rs +++ b/src/services/calendar_service.rs @@ -193,4 +193,43 @@ impl CalendarService { grouped } + + /// Refresh a single event by UID from the CalDAV server + pub async fn refresh_event(&self, token: &str, uid: &str) -> Result, String> { + let window = web_sys::window().ok_or("No global window exists")?; + + let opts = RequestInit::new(); + opts.set_method("GET"); + opts.set_mode(RequestMode::Cors); + + let url = format!("{}/calendar/events/{}", self.base_url, uid); + 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))?; + + 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() { + let event: Option = serde_json::from_str(&text_string) + .map_err(|e| format!("JSON parsing failed: {}", e))?; + Ok(event) + } else { + Err(format!("Request failed with status {}: {}", resp.status(), text_string)) + } + } } \ No newline at end of file diff --git a/styles.css b/styles.css index 886264c..9a6677a 100644 --- a/styles.css +++ b/styles.css @@ -359,6 +359,20 @@ body { background: #1976d2; } +.event-box.refreshing { + background: #ff9800; + animation: pulse 1.5s ease-in-out infinite alternate; +} + +@keyframes pulse { + 0% { + opacity: 0.7; + } + 100% { + opacity: 1; + } +} + .event-dot { background: #ff9800; height: 6px;