Compare commits
	
		
			4 Commits
		
	
	
		
			b444ae710d
			...
			749ffaff58
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 749ffaff58 | ||
|   | 3440403bed | ||
|   | 5c966b2571 | ||
|   | 7e62e3b7e3 | 
| @@ -694,6 +694,233 @@ impl CalDAVClient { | |||||||
|             Err(CalDAVError::ServerError(status.as_u16())) |             Err(CalDAVError::ServerError(status.as_u16())) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /// Create a new event in a CalDAV calendar | ||||||
|  |     pub async fn create_event(&self, calendar_path: &str, event: &CalendarEvent) -> Result<String, CalDAVError> { | ||||||
|  |         // Generate a unique filename for the event (using UID + .ics extension) | ||||||
|  |         let event_filename = format!("{}.ics", event.uid); | ||||||
|  |          | ||||||
|  |         // Construct the full URL for the event | ||||||
|  |         let full_url = if calendar_path.starts_with("http") { | ||||||
|  |             format!("{}/{}", calendar_path.trim_end_matches('/'), event_filename) | ||||||
|  |         } else { | ||||||
|  |             // Handle URL construction more carefully | ||||||
|  |             let server_url = self.config.server_url.trim_end_matches('/'); | ||||||
|  |              | ||||||
|  |             // Remove /dav.php from the end of server URL if present | ||||||
|  |             let base_url = if server_url.ends_with("/dav.php") { | ||||||
|  |                 server_url.trim_end_matches("/dav.php") | ||||||
|  |             } else { | ||||||
|  |                 server_url | ||||||
|  |             }; | ||||||
|  |              | ||||||
|  |             // Calendar path should start with /dav.php, if not add it | ||||||
|  |             let clean_calendar_path = if calendar_path.starts_with("/dav.php") { | ||||||
|  |                 calendar_path.trim_end_matches('/') | ||||||
|  |             } else { | ||||||
|  |                 // This shouldn't happen in our case, but handle it | ||||||
|  |                 &format!("/dav.php{}", calendar_path.trim_end_matches('/')) | ||||||
|  |             }; | ||||||
|  |              | ||||||
|  |             format!("{}{}/{}", base_url, clean_calendar_path, event_filename) | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         println!("📝 Creating event with calendar_path: {}", calendar_path); | ||||||
|  |         println!("📝 Server URL: {}", self.config.server_url); | ||||||
|  |         println!("📝 Constructed URL: {}", full_url); | ||||||
|  |          | ||||||
|  |         // Generate iCalendar data for the event | ||||||
|  |         let ical_data = self.generate_ical_event(event)?; | ||||||
|  |          | ||||||
|  |         println!("Creating event at: {}", full_url); | ||||||
|  |         println!("iCal data: {}", ical_data); | ||||||
|  |          | ||||||
|  |         let response = self.http_client | ||||||
|  |             .put(&full_url) | ||||||
|  |             .header("Authorization", format!("Basic {}", self.config.get_basic_auth())) | ||||||
|  |             .header("Content-Type", "text/calendar; charset=utf-8") | ||||||
|  |             .header("User-Agent", "calendar-app/0.1.0") | ||||||
|  |             .body(ical_data) | ||||||
|  |             .send() | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| CalDAVError::ParseError(e.to_string()))?; | ||||||
|  |  | ||||||
|  |         println!("Event creation response status: {}", response.status()); | ||||||
|  |          | ||||||
|  |         if response.status().is_success() || response.status().as_u16() == 201 { | ||||||
|  |             println!("✅ Event created successfully at {}", event_filename); | ||||||
|  |             Ok(event_filename) | ||||||
|  |         } else { | ||||||
|  |             let status = response.status(); | ||||||
|  |             let error_body = response.text().await.unwrap_or_default(); | ||||||
|  |             println!("❌ Event creation failed: {} - {}", status, error_body); | ||||||
|  |             Err(CalDAVError::ServerError(status.as_u16())) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Generate iCalendar data for a CalendarEvent | ||||||
|  |     fn generate_ical_event(&self, event: &CalendarEvent) -> Result<String, CalDAVError> { | ||||||
|  |         let now = chrono::Utc::now(); | ||||||
|  |          | ||||||
|  |         // Format datetime for iCal (YYYYMMDDTHHMMSSZ format) | ||||||
|  |         let format_datetime = |dt: &DateTime<Utc>| -> String { | ||||||
|  |             dt.format("%Y%m%dT%H%M%SZ").to_string() | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         let format_date = |dt: &DateTime<Utc>| -> String { | ||||||
|  |             dt.format("%Y%m%d").to_string() | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         // Start building the iCal event | ||||||
|  |         let mut ical = String::new(); | ||||||
|  |         ical.push_str("BEGIN:VCALENDAR\r\n"); | ||||||
|  |         ical.push_str("VERSION:2.0\r\n"); | ||||||
|  |         ical.push_str("PRODID:-//calendar-app//calendar-app//EN\r\n"); | ||||||
|  |         ical.push_str("BEGIN:VEVENT\r\n"); | ||||||
|  |          | ||||||
|  |         // Required fields | ||||||
|  |         ical.push_str(&format!("UID:{}\r\n", event.uid)); | ||||||
|  |         ical.push_str(&format!("DTSTAMP:{}\r\n", format_datetime(&now))); | ||||||
|  |          | ||||||
|  |         // Start and end times | ||||||
|  |         if event.all_day { | ||||||
|  |             ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", format_date(&event.start))); | ||||||
|  |             if let Some(end) = &event.end { | ||||||
|  |                 ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_date(end))); | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             ical.push_str(&format!("DTSTART:{}\r\n", format_datetime(&event.start))); | ||||||
|  |             if let Some(end) = &event.end { | ||||||
|  |                 ical.push_str(&format!("DTEND:{}\r\n", format_datetime(end))); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // Optional fields | ||||||
|  |         if let Some(summary) = &event.summary { | ||||||
|  |             ical.push_str(&format!("SUMMARY:{}\r\n", self.escape_ical_text(summary))); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         if let Some(description) = &event.description { | ||||||
|  |             ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(description))); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         if let Some(location) = &event.location { | ||||||
|  |             ical.push_str(&format!("LOCATION:{}\r\n", self.escape_ical_text(location))); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // Status | ||||||
|  |         let status_str = match event.status { | ||||||
|  |             EventStatus::Tentative => "TENTATIVE", | ||||||
|  |             EventStatus::Confirmed => "CONFIRMED",  | ||||||
|  |             EventStatus::Cancelled => "CANCELLED", | ||||||
|  |         }; | ||||||
|  |         ical.push_str(&format!("STATUS:{}\r\n", status_str)); | ||||||
|  |          | ||||||
|  |         // Classification | ||||||
|  |         let class_str = match event.class { | ||||||
|  |             EventClass::Public => "PUBLIC", | ||||||
|  |             EventClass::Private => "PRIVATE", | ||||||
|  |             EventClass::Confidential => "CONFIDENTIAL", | ||||||
|  |         }; | ||||||
|  |         ical.push_str(&format!("CLASS:{}\r\n", class_str)); | ||||||
|  |          | ||||||
|  |         // Priority | ||||||
|  |         if let Some(priority) = event.priority { | ||||||
|  |             ical.push_str(&format!("PRIORITY:{}\r\n", priority)); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // Categories | ||||||
|  |         if !event.categories.is_empty() { | ||||||
|  |             let categories = event.categories.join(","); | ||||||
|  |             ical.push_str(&format!("CATEGORIES:{}\r\n", self.escape_ical_text(&categories))); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // Creation and modification times | ||||||
|  |         if let Some(created) = &event.created { | ||||||
|  |             ical.push_str(&format!("CREATED:{}\r\n", format_datetime(created))); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         ical.push_str(&format!("LAST-MODIFIED:{}\r\n", format_datetime(&now))); | ||||||
|  |          | ||||||
|  |         // Add alarms/reminders | ||||||
|  |         for reminder in &event.reminders { | ||||||
|  |             ical.push_str("BEGIN:VALARM\r\n"); | ||||||
|  |              | ||||||
|  |             let action = match reminder.action { | ||||||
|  |                 ReminderAction::Display => "DISPLAY", | ||||||
|  |                 ReminderAction::Email => "EMAIL",  | ||||||
|  |                 ReminderAction::Audio => "AUDIO", | ||||||
|  |             }; | ||||||
|  |             ical.push_str(&format!("ACTION:{}\r\n", action)); | ||||||
|  |              | ||||||
|  |             // Convert minutes to ISO 8601 duration format | ||||||
|  |             let trigger = format!("-PT{}M", reminder.minutes_before); | ||||||
|  |             ical.push_str(&format!("TRIGGER:{}\r\n", trigger)); | ||||||
|  |              | ||||||
|  |             if let Some(description) = &reminder.description { | ||||||
|  |                 ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(description))); | ||||||
|  |             } else if let Some(summary) = &event.summary { | ||||||
|  |                 ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(summary))); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             ical.push_str("END:VALARM\r\n"); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         ical.push_str("END:VEVENT\r\n"); | ||||||
|  |         ical.push_str("END:VCALENDAR\r\n"); | ||||||
|  |          | ||||||
|  |         Ok(ical) | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /// Escape text for iCalendar format (RFC 5545) | ||||||
|  |     fn escape_ical_text(&self, text: &str) -> String { | ||||||
|  |         text.replace('\\', "\\\\") | ||||||
|  |             .replace('\n', "\\n") | ||||||
|  |             .replace('\r', "") | ||||||
|  |             .replace(',', "\\,") | ||||||
|  |             .replace(';', "\\;") | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// 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 | /// Helper struct for extracting calendar data from XML responses | ||||||
|   | |||||||
| @@ -7,7 +7,7 @@ use serde::Deserialize; | |||||||
| use std::sync::Arc; | use std::sync::Arc; | ||||||
| use chrono::Datelike; | 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, CreateEventRequest, CreateEventResponse}}; | ||||||
| use crate::calendar::{CalDAVClient, CalendarEvent}; | use crate::calendar::{CalDAVClient, CalendarEvent}; | ||||||
|  |  | ||||||
| #[derive(Deserialize)] | #[derive(Deserialize)] | ||||||
| @@ -324,3 +324,163 @@ pub async fn delete_calendar( | |||||||
|         message: "Calendar deleted successfully".to_string(), |         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(), | ||||||
|  |     })) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn create_event( | ||||||
|  |     State(state): State<Arc<AppState>>, | ||||||
|  |     headers: HeaderMap, | ||||||
|  |     Json(request): Json<CreateEventRequest>, | ||||||
|  | ) -> Result<Json<CreateEventResponse>, ApiError> { | ||||||
|  |     println!("📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",  | ||||||
|  |              request.title, request.all_day, request.calendar_path); | ||||||
|  |      | ||||||
|  |     // Extract and verify token | ||||||
|  |     let token = extract_bearer_token(&headers)?; | ||||||
|  |     let password = extract_password_header(&headers)?; | ||||||
|  |  | ||||||
|  |     // Validate request | ||||||
|  |     if request.title.trim().is_empty() { | ||||||
|  |         return Err(ApiError::BadRequest("Event title is required".to_string())); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     if request.title.len() > 200 { | ||||||
|  |         return Err(ApiError::BadRequest("Event title too long (max 200 characters)".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); | ||||||
|  |  | ||||||
|  |     // Determine which calendar to use | ||||||
|  |     let calendar_path = if let Some(path) = request.calendar_path { | ||||||
|  |         path | ||||||
|  |     } else { | ||||||
|  |         // Use the first available calendar | ||||||
|  |         let calendar_paths = client.discover_calendars() | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; | ||||||
|  |          | ||||||
|  |         if calendar_paths.is_empty() { | ||||||
|  |             return Err(ApiError::BadRequest("No calendars available for event creation".to_string())); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         calendar_paths[0].clone() | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     // Parse dates and times | ||||||
|  |     let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day) | ||||||
|  |         .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; | ||||||
|  |      | ||||||
|  |     let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day) | ||||||
|  |         .map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?; | ||||||
|  |  | ||||||
|  |     // Validate that end is after start | ||||||
|  |     if end_datetime <= start_datetime { | ||||||
|  |         return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string())); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Generate a unique UID for the event | ||||||
|  |     let uid = format!("{}-{}", uuid::Uuid::new_v4(), chrono::Utc::now().timestamp()); | ||||||
|  |  | ||||||
|  |     // Create the CalendarEvent struct | ||||||
|  |     let event = crate::calendar::CalendarEvent { | ||||||
|  |         uid, | ||||||
|  |         summary: Some(request.title.clone()), | ||||||
|  |         description: if request.description.trim().is_empty() {  | ||||||
|  |             None  | ||||||
|  |         } else {  | ||||||
|  |             Some(request.description.clone())  | ||||||
|  |         }, | ||||||
|  |         start: start_datetime, | ||||||
|  |         end: Some(end_datetime), | ||||||
|  |         location: if request.location.trim().is_empty() {  | ||||||
|  |             None  | ||||||
|  |         } else {  | ||||||
|  |             Some(request.location.clone())  | ||||||
|  |         }, | ||||||
|  |         status: crate::calendar::EventStatus::Confirmed, | ||||||
|  |         class: crate::calendar::EventClass::Public, | ||||||
|  |         priority: None, | ||||||
|  |         organizer: None, | ||||||
|  |         attendees: Vec::new(), | ||||||
|  |         categories: Vec::new(), | ||||||
|  |         created: Some(chrono::Utc::now()), | ||||||
|  |         last_modified: Some(chrono::Utc::now()), | ||||||
|  |         recurrence_rule: None, | ||||||
|  |         all_day: request.all_day, | ||||||
|  |         reminders: Vec::new(), | ||||||
|  |         etag: None, | ||||||
|  |         href: None, | ||||||
|  |         calendar_path: Some(calendar_path.clone()), | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     // Create the event on the CalDAV server | ||||||
|  |     let event_href = client.create_event(&calendar_path, &event) | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?; | ||||||
|  |  | ||||||
|  |     Ok(Json(CreateEventResponse { | ||||||
|  |         success: true, | ||||||
|  |         message: "Event created successfully".to_string(), | ||||||
|  |         event_href: Some(event_href), | ||||||
|  |     })) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Parse date and time strings into a UTC DateTime | ||||||
|  | fn parse_event_datetime(date_str: &str, time_str: &str, all_day: bool) -> Result<chrono::DateTime<chrono::Utc>, String> { | ||||||
|  |     use chrono::{NaiveDate, NaiveTime, NaiveDateTime, Utc, TimeZone}; | ||||||
|  |      | ||||||
|  |     // Parse the date | ||||||
|  |     let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") | ||||||
|  |         .map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?; | ||||||
|  |      | ||||||
|  |     if all_day { | ||||||
|  |         // For all-day events, use midnight UTC | ||||||
|  |         let datetime = date.and_hms_opt(0, 0, 0) | ||||||
|  |             .ok_or_else(|| "Failed to create midnight datetime".to_string())?; | ||||||
|  |         Ok(Utc.from_utc_datetime(&datetime)) | ||||||
|  |     } else { | ||||||
|  |         // Parse the time | ||||||
|  |         let time = NaiveTime::parse_from_str(time_str, "%H:%M") | ||||||
|  |             .map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?; | ||||||
|  |          | ||||||
|  |         // Combine date and time | ||||||
|  |         let datetime = NaiveDateTime::new(date, time); | ||||||
|  |          | ||||||
|  |         // Assume local time and convert to UTC (in a real app, you'd want timezone support) | ||||||
|  |         Ok(Utc.from_utc_datetime(&datetime)) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -41,6 +41,8 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> { | |||||||
|         .route("/api/calendar/create", post(handlers::create_calendar)) |         .route("/api/calendar/create", post(handlers::create_calendar)) | ||||||
|         .route("/api/calendar/delete", post(handlers::delete_calendar)) |         .route("/api/calendar/delete", post(handlers::delete_calendar)) | ||||||
|         .route("/api/calendar/events", get(handlers::get_calendar_events)) |         .route("/api/calendar/events", get(handlers::get_calendar_events)) | ||||||
|  |         .route("/api/calendar/events/create", post(handlers::create_event)) | ||||||
|  |         .route("/api/calendar/events/delete", post(handlers::delete_event)) | ||||||
|         .route("/api/calendar/events/:uid", get(handlers::refresh_event)) |         .route("/api/calendar/events/:uid", get(handlers::refresh_event)) | ||||||
|         .layer( |         .layer( | ||||||
|             CorsLayer::new() |             CorsLayer::new() | ||||||
|   | |||||||
| @@ -58,6 +58,38 @@ pub struct DeleteCalendarResponse { | |||||||
|     pub message: String, |     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, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | pub struct CreateEventRequest { | ||||||
|  |     pub title: String, | ||||||
|  |     pub description: String, | ||||||
|  |     pub start_date: String,        // YYYY-MM-DD format | ||||||
|  |     pub start_time: String,        // HH:MM format   | ||||||
|  |     pub end_date: String,          // YYYY-MM-DD format | ||||||
|  |     pub end_time: String,          // HH:MM format | ||||||
|  |     pub location: String, | ||||||
|  |     pub all_day: bool, | ||||||
|  |     pub calendar_path: Option<String>, // Optional - use first calendar if not specified | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize)] | ||||||
|  | pub struct CreateEventResponse { | ||||||
|  |     pub success: bool, | ||||||
|  |     pub message: String, | ||||||
|  |     pub event_href: Option<String>, // The created event's href/filename | ||||||
|  | } | ||||||
|  |  | ||||||
| // Error handling | // Error handling | ||||||
| #[derive(Debug)] | #[derive(Debug)] | ||||||
| pub enum ApiError { | pub enum ApiError { | ||||||
|   | |||||||
							
								
								
									
										183
									
								
								src/app.rs
									
									
									
									
									
								
							
							
						
						
									
										183
									
								
								src/app.rs
									
									
									
									
									
								
							| @@ -2,8 +2,9 @@ use yew::prelude::*; | |||||||
| use yew_router::prelude::*; | use yew_router::prelude::*; | ||||||
| use gloo_storage::{LocalStorage, Storage}; | use gloo_storage::{LocalStorage, Storage}; | ||||||
| use web_sys::MouseEvent; | use web_sys::MouseEvent; | ||||||
| use crate::components::{Sidebar, CreateCalendarModal, ContextMenu, RouteHandler}; | use crate::components::{Sidebar, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler}; | ||||||
| use crate::services::{CalendarService, calendar_service::UserInfo}; | use crate::services::{CalendarService, calendar_service::{UserInfo, CalendarEvent}}; | ||||||
|  | use chrono::NaiveDate; | ||||||
|  |  | ||||||
|  |  | ||||||
| #[function_component] | #[function_component] | ||||||
| @@ -18,6 +19,14 @@ pub fn App() -> Html { | |||||||
|     let context_menu_open = use_state(|| false); |     let context_menu_open = use_state(|| false); | ||||||
|     let context_menu_pos = use_state(|| (0i32, 0i32)); |     let context_menu_pos = use_state(|| (0i32, 0i32)); | ||||||
|     let context_menu_calendar_path = use_state(|| -> Option<String> { None }); |     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 calendar_context_menu_open = use_state(|| false); | ||||||
|  |     let calendar_context_menu_pos = use_state(|| (0i32, 0i32)); | ||||||
|  |     let calendar_context_menu_date = use_state(|| -> Option<NaiveDate> { None }); | ||||||
|  |     let create_event_modal_open = use_state(|| false); | ||||||
|  |     let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None }); | ||||||
|      |      | ||||||
|     let available_colors = [ |     let available_colors = [ | ||||||
|         "#3B82F6", "#10B981", "#F59E0B", "#EF4444",  |         "#3B82F6", "#10B981", "#F59E0B", "#EF4444",  | ||||||
| @@ -99,9 +108,13 @@ pub fn App() -> Html { | |||||||
|     let on_outside_click = { |     let on_outside_click = { | ||||||
|         let color_picker_open = color_picker_open.clone(); |         let color_picker_open = color_picker_open.clone(); | ||||||
|         let context_menu_open = context_menu_open.clone(); |         let context_menu_open = context_menu_open.clone(); | ||||||
|  |         let event_context_menu_open = event_context_menu_open.clone(); | ||||||
|  |         let calendar_context_menu_open = calendar_context_menu_open.clone(); | ||||||
|         Callback::from(move |_: MouseEvent| { |         Callback::from(move |_: MouseEvent| { | ||||||
|             color_picker_open.set(None); |             color_picker_open.set(None); | ||||||
|             context_menu_open.set(false); |             context_menu_open.set(false); | ||||||
|  |             event_context_menu_open.set(false); | ||||||
|  |             calendar_context_menu_open.set(false); | ||||||
|         }) |         }) | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
| @@ -148,6 +161,94 @@ 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 on_calendar_date_context_menu = { | ||||||
|  |         let calendar_context_menu_open = calendar_context_menu_open.clone(); | ||||||
|  |         let calendar_context_menu_pos = calendar_context_menu_pos.clone(); | ||||||
|  |         let calendar_context_menu_date = calendar_context_menu_date.clone(); | ||||||
|  |         Callback::from(move |(event, date): (MouseEvent, NaiveDate)| { | ||||||
|  |             calendar_context_menu_open.set(true); | ||||||
|  |             calendar_context_menu_pos.set((event.client_x(), event.client_y())); | ||||||
|  |             calendar_context_menu_date.set(Some(date)); | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_create_event_click = { | ||||||
|  |         let create_event_modal_open = create_event_modal_open.clone(); | ||||||
|  |         let selected_date_for_event = selected_date_for_event.clone(); | ||||||
|  |         let calendar_context_menu_date = calendar_context_menu_date.clone(); | ||||||
|  |         Callback::from(move |_: MouseEvent| { | ||||||
|  |             create_event_modal_open.set(true); | ||||||
|  |             selected_date_for_event.set((*calendar_context_menu_date).clone()); | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_event_create = { | ||||||
|  |         let create_event_modal_open = create_event_modal_open.clone(); | ||||||
|  |         let auth_token = auth_token.clone(); | ||||||
|  |         Callback::from(move |event_data: EventCreationData| { | ||||||
|  |             web_sys::console::log_1(&format!("Creating event: {:?}", event_data).into()); | ||||||
|  |             create_event_modal_open.set(false); | ||||||
|  |              | ||||||
|  |             if let Some(token) = (*auth_token).clone() { | ||||||
|  |                 wasm_bindgen_futures::spawn_local(async move { | ||||||
|  |                     let calendar_service = CalendarService::new(); | ||||||
|  |                      | ||||||
|  |                     // Get CalDAV password from storage | ||||||
|  |                     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() | ||||||
|  |                     }; | ||||||
|  |                      | ||||||
|  |                     // Format date and time strings | ||||||
|  |                     let start_date = event_data.start_date.format("%Y-%m-%d").to_string(); | ||||||
|  |                     let start_time = event_data.start_time.format("%H:%M").to_string(); | ||||||
|  |                     let end_date = event_data.end_date.format("%Y-%m-%d").to_string(); | ||||||
|  |                     let end_time = event_data.end_time.format("%H:%M").to_string(); | ||||||
|  |                      | ||||||
|  |                     match calendar_service.create_event( | ||||||
|  |                         &token, | ||||||
|  |                         &password, | ||||||
|  |                         event_data.title, | ||||||
|  |                         event_data.description, | ||||||
|  |                         start_date, | ||||||
|  |                         start_time, | ||||||
|  |                         end_date, | ||||||
|  |                         end_time, | ||||||
|  |                         event_data.location, | ||||||
|  |                         event_data.all_day, | ||||||
|  |                         None // Let backend use first available calendar | ||||||
|  |                     ).await { | ||||||
|  |                         Ok(_) => { | ||||||
|  |                             web_sys::console::log_1(&"Event created successfully".into()); | ||||||
|  |                             // Refresh the page to show the new event | ||||||
|  |                             web_sys::window().unwrap().location().reload().unwrap(); | ||||||
|  |                         } | ||||||
|  |                         Err(err) => { | ||||||
|  |                             web_sys::console::error_1(&format!("Failed to create event: {}", err).into()); | ||||||
|  |                             web_sys::window().unwrap().alert_with_message(&format!("Failed to create event: {}", err)).unwrap(); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 }); | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|     let refresh_calendars = { |     let refresh_calendars = { | ||||||
|         let auth_token = auth_token.clone(); |         let auth_token = auth_token.clone(); | ||||||
|         let user_info = user_info.clone(); |         let user_info = user_info.clone(); | ||||||
| @@ -217,6 +318,8 @@ pub fn App() -> Html { | |||||||
|                                         auth_token={(*auth_token).clone()} |                                         auth_token={(*auth_token).clone()} | ||||||
|                                         user_info={(*user_info).clone()} |                                         user_info={(*user_info).clone()} | ||||||
|                                         on_login={on_login.clone()} |                                         on_login={on_login.clone()} | ||||||
|  |                                         on_event_context_menu={Some(on_event_context_menu.clone())} | ||||||
|  |                                         on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())} | ||||||
|                                     /> |                                     /> | ||||||
|                                 </main> |                                 </main> | ||||||
|                             </> |                             </> | ||||||
| @@ -228,6 +331,8 @@ pub fn App() -> Html { | |||||||
|                                     auth_token={(*auth_token).clone()} |                                     auth_token={(*auth_token).clone()} | ||||||
|                                     user_info={(*user_info).clone()} |                                     user_info={(*user_info).clone()} | ||||||
|                                     on_login={on_login.clone()} |                                     on_login={on_login.clone()} | ||||||
|  |                                     on_event_context_menu={Some(on_event_context_menu.clone())} | ||||||
|  |                                     on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())} | ||||||
|                                 /> |                                 /> | ||||||
|                             </div> |                             </div> | ||||||
|                         } |                         } | ||||||
| @@ -323,6 +428,80 @@ 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()); | ||||||
|  |                                     } | ||||||
|  |                                 }); | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     })} | ||||||
|  |                 /> | ||||||
|  |                  | ||||||
|  |                 <CalendarContextMenu  | ||||||
|  |                     is_open={*calendar_context_menu_open} | ||||||
|  |                     x={calendar_context_menu_pos.0} | ||||||
|  |                     y={calendar_context_menu_pos.1} | ||||||
|  |                     on_close={Callback::from({ | ||||||
|  |                         let calendar_context_menu_open = calendar_context_menu_open.clone(); | ||||||
|  |                         move |_| calendar_context_menu_open.set(false) | ||||||
|  |                     })} | ||||||
|  |                     on_create_event={on_create_event_click} | ||||||
|  |                 /> | ||||||
|  |                  | ||||||
|  |                 <CreateEventModal  | ||||||
|  |                     is_open={*create_event_modal_open} | ||||||
|  |                     selected_date={(*selected_date_for_event).clone()} | ||||||
|  |                     on_close={Callback::from({ | ||||||
|  |                         let create_event_modal_open = create_event_modal_open.clone(); | ||||||
|  |                         move |_| create_event_modal_open.set(false) | ||||||
|  |                     })} | ||||||
|  |                     on_create={on_event_create} | ||||||
|  |                 /> | ||||||
|             </div> |             </div> | ||||||
|         </BrowserRouter> |         </BrowserRouter> | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ use chrono::{Datelike, Local, NaiveDate, Duration, Weekday}; | |||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| use crate::services::calendar_service::{CalendarEvent, UserInfo}; | use crate::services::calendar_service::{CalendarEvent, UserInfo}; | ||||||
| use crate::components::EventModal; | use crate::components::EventModal; | ||||||
|  | use wasm_bindgen::JsCast; | ||||||
|  |  | ||||||
| #[derive(Properties, PartialEq)] | #[derive(Properties, PartialEq)] | ||||||
| pub struct CalendarProps { | pub struct CalendarProps { | ||||||
| @@ -13,6 +14,10 @@ pub struct CalendarProps { | |||||||
|     pub refreshing_event_uid: Option<String>, |     pub refreshing_event_uid: Option<String>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub user_info: Option<UserInfo>, |     pub user_info: Option<UserInfo>, | ||||||
|  |     #[prop_or_default] | ||||||
|  |     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>, | ||||||
|  |     #[prop_or_default] | ||||||
|  |     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[function_component] | #[function_component] | ||||||
| @@ -114,8 +119,33 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                             selected_day_clone.set(date); |                             selected_day_clone.set(date); | ||||||
|                         }); |                         }); | ||||||
|  |  | ||||||
|  |                         let on_context_menu = { | ||||||
|  |                             let on_calendar_context_menu = props.on_calendar_context_menu.clone(); | ||||||
|  |                             Callback::from(move |e: MouseEvent| { | ||||||
|  |                                 // Only show context menu if we're not right-clicking on an event | ||||||
|  |                                 if let Some(target) = e.target() { | ||||||
|  |                                     if let Ok(element) = target.dyn_into::<web_sys::Element>() { | ||||||
|  |                                         // Check if the click is on an event box or inside one | ||||||
|  |                                         let mut current = Some(element); | ||||||
|  |                                         while let Some(el) = current { | ||||||
|  |                                             if el.class_name().contains("event-box") { | ||||||
|  |                                                 return; // Don't show calendar context menu on events | ||||||
|  |                                             } | ||||||
|  |                                             current = el.parent_element(); | ||||||
|  |                                         } | ||||||
|  |                                     } | ||||||
|  |                                 } | ||||||
|  |                                  | ||||||
|  |                                 e.prevent_default(); | ||||||
|  |                                 e.stop_propagation(); | ||||||
|  |                                 if let Some(callback) = &on_calendar_context_menu { | ||||||
|  |                                     callback.emit((e, date)); | ||||||
|  |                                 } | ||||||
|  |                             }) | ||||||
|  |                         }; | ||||||
|  |                          | ||||||
|                         html! { |                         html! { | ||||||
|                             <div class={classes!(classes)} onclick={on_click}> |                             <div class={classes!(classes)} onclick={on_click} oncontextmenu={on_context_menu}> | ||||||
|                                 <div class="day-number">{day}</div> |                                 <div class="day-number">{day}</div> | ||||||
|                                 { |                                 { | ||||||
|                                     if !events.is_empty() { |                                     if !events.is_empty() { | ||||||
| @@ -132,6 +162,18 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                                                             selected_event_clone.set(Some(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 title = event.get_title(); | ||||||
|                                                         let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid); |                                                         let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid); | ||||||
|                                                         let class_name = if is_refreshing { "event-box refreshing" } else { "event-box" }; |                                                         let class_name = if is_refreshing { "event-box refreshing" } else { "event-box" }; | ||||||
| @@ -140,6 +182,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                                                             <div class={class_name}  |                                                             <div class={class_name}  | ||||||
|                                                                  title={title.clone()}  |                                                                  title={title.clone()}  | ||||||
|                                                                  onclick={event_click} |                                                                  onclick={event_click} | ||||||
|  |                                                                  oncontextmenu={event_context_menu} | ||||||
|                                                                  style={format!("background-color: {}", event_color)}> |                                                                  style={format!("background-color: {}", event_color)}> | ||||||
|                                                                 { |                                                                 { | ||||||
|                                                                     if is_refreshing { |                                                                     if is_refreshing { | ||||||
|   | |||||||
							
								
								
									
										47
									
								
								src/components/calendar_context_menu.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								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> | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										323
									
								
								src/components/create_event_modal.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										323
									
								
								src/components/create_event_modal.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,323 @@ | |||||||
|  | use yew::prelude::*; | ||||||
|  | use web_sys::{HtmlInputElement, HtmlTextAreaElement}; | ||||||
|  | use chrono::{NaiveDate, NaiveTime}; | ||||||
|  |  | ||||||
|  | #[derive(Properties, PartialEq)] | ||||||
|  | pub struct CreateEventModalProps { | ||||||
|  |     pub is_open: bool, | ||||||
|  |     pub selected_date: Option<NaiveDate>, | ||||||
|  |     pub on_close: Callback<()>, | ||||||
|  |     pub on_create: Callback<EventCreationData>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[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, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[function_component(CreateEventModal)] | ||||||
|  | pub fn create_event_modal(props: &CreateEventModalProps) -> Html { | ||||||
|  |     let event_data = use_state(|| EventCreationData::default()); | ||||||
|  |      | ||||||
|  |     // Initialize with selected date if provided | ||||||
|  |     use_effect_with((props.selected_date, props.is_open), { | ||||||
|  |         let event_data = event_data.clone(); | ||||||
|  |         move |(selected_date, is_open)| { | ||||||
|  |             if *is_open { | ||||||
|  |                 if let Some(date) = selected_date { | ||||||
|  |                     let mut data = (*event_data).clone(); | ||||||
|  |                     data.start_date = *date; | ||||||
|  |                     data.end_date = *date; | ||||||
|  |                     event_data.set(data); | ||||||
|  |                 } else { | ||||||
|  |                     event_data.set(EventCreationData::default()); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             || () | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     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_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_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_create_click = { | ||||||
|  |         let event_data = event_data.clone(); | ||||||
|  |         let on_create = props.on_create.clone(); | ||||||
|  |         Callback::from(move |_: MouseEvent| { | ||||||
|  |             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>{"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-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> | ||||||
|  |                  | ||||||
|  |                 <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_create_click} | ||||||
|  |                         disabled={data.title.trim().is_empty()} | ||||||
|  |                     > | ||||||
|  |                         {"Create Event"} | ||||||
|  |                     </button> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										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,9 @@ pub mod calendar; | |||||||
| pub mod event_modal; | pub mod event_modal; | ||||||
| pub mod create_calendar_modal; | pub mod create_calendar_modal; | ||||||
| pub mod context_menu; | pub mod context_menu; | ||||||
|  | pub mod event_context_menu; | ||||||
|  | pub mod calendar_context_menu; | ||||||
|  | pub mod create_event_modal; | ||||||
| pub mod sidebar; | pub mod sidebar; | ||||||
| pub mod calendar_list_item; | pub mod calendar_list_item; | ||||||
| pub mod route_handler; | pub mod route_handler; | ||||||
| @@ -12,6 +15,9 @@ pub use calendar::Calendar; | |||||||
| pub use event_modal::EventModal; | pub use event_modal::EventModal; | ||||||
| pub use create_calendar_modal::CreateCalendarModal; | pub use create_calendar_modal::CreateCalendarModal; | ||||||
| pub use context_menu::ContextMenu; | pub use context_menu::ContextMenu; | ||||||
|  | pub use event_context_menu::EventContextMenu; | ||||||
|  | pub use calendar_context_menu::CalendarContextMenu; | ||||||
|  | pub use create_event_modal::{CreateEventModal, EventCreationData}; | ||||||
| pub use sidebar::Sidebar; | pub use sidebar::Sidebar; | ||||||
| pub use calendar_list_item::CalendarListItem; | pub use calendar_list_item::CalendarListItem; | ||||||
| pub use route_handler::RouteHandler; | pub use route_handler::RouteHandler; | ||||||
| @@ -1,7 +1,7 @@ | |||||||
| use yew::prelude::*; | use yew::prelude::*; | ||||||
| use yew_router::prelude::*; | use yew_router::prelude::*; | ||||||
| use crate::components::Login; | use crate::components::Login; | ||||||
| use crate::services::calendar_service::UserInfo; | use crate::services::calendar_service::{UserInfo, CalendarEvent}; | ||||||
|  |  | ||||||
| #[derive(Clone, Routable, PartialEq)] | #[derive(Clone, Routable, PartialEq)] | ||||||
| pub enum Route { | pub enum Route { | ||||||
| @@ -18,6 +18,10 @@ pub struct RouteHandlerProps { | |||||||
|     pub auth_token: Option<String>, |     pub auth_token: Option<String>, | ||||||
|     pub user_info: Option<UserInfo>, |     pub user_info: Option<UserInfo>, | ||||||
|     pub on_login: Callback<String>, |     pub on_login: Callback<String>, | ||||||
|  |     #[prop_or_default] | ||||||
|  |     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>, | ||||||
|  |     #[prop_or_default] | ||||||
|  |     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[function_component(RouteHandler)] | #[function_component(RouteHandler)] | ||||||
| @@ -25,12 +29,16 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | |||||||
|     let auth_token = props.auth_token.clone(); |     let auth_token = props.auth_token.clone(); | ||||||
|     let user_info = props.user_info.clone(); |     let user_info = props.user_info.clone(); | ||||||
|     let on_login = props.on_login.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(); | ||||||
|      |      | ||||||
|     html! { |     html! { | ||||||
|         <Switch<Route> render={move |route| { |         <Switch<Route> render={move |route| { | ||||||
|             let auth_token = auth_token.clone(); |             let auth_token = auth_token.clone(); | ||||||
|             let user_info = user_info.clone(); |             let user_info = user_info.clone(); | ||||||
|             let on_login = on_login.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(); | ||||||
|              |              | ||||||
|             match route { |             match route { | ||||||
|                 Route::Home => { |                 Route::Home => { | ||||||
| @@ -49,7 +57,13 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | |||||||
|                 } |                 } | ||||||
|                 Route::Calendar => { |                 Route::Calendar => { | ||||||
|                     if auth_token.is_some() { |                     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} | ||||||
|  |                                 on_calendar_context_menu={on_calendar_context_menu} | ||||||
|  |                             />  | ||||||
|  |                         } | ||||||
|                     } else { |                     } else { | ||||||
|                         html! { <Redirect<Route> to={Route::Login}/> } |                         html! { <Redirect<Route> to={Route::Login}/> } | ||||||
|                     } |                     } | ||||||
| @@ -62,10 +76,14 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | |||||||
| #[derive(Properties, PartialEq)] | #[derive(Properties, PartialEq)] | ||||||
| pub struct CalendarViewProps { | pub struct CalendarViewProps { | ||||||
|     pub user_info: Option<UserInfo>, |     pub user_info: Option<UserInfo>, | ||||||
|  |     #[prop_or_default] | ||||||
|  |     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>, | ||||||
|  |     #[prop_or_default] | ||||||
|  |     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>, | ||||||
| } | } | ||||||
|  |  | ||||||
| use gloo_storage::{LocalStorage, Storage}; | use gloo_storage::{LocalStorage, Storage}; | ||||||
| use crate::services::{CalendarService, CalendarEvent}; | use crate::services::CalendarService; | ||||||
| use crate::components::Calendar; | use crate::components::Calendar; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| use chrono::{Local, NaiveDate, Datelike}; | use chrono::{Local, NaiveDate, Datelike}; | ||||||
| @@ -79,6 +97,7 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html { | |||||||
|      |      | ||||||
|     let auth_token: Option<String> = LocalStorage::get("auth_token").ok(); |     let auth_token: Option<String> = LocalStorage::get("auth_token").ok(); | ||||||
|      |      | ||||||
|  |      | ||||||
|     let today = Local::now().date_naive(); |     let today = Local::now().date_naive(); | ||||||
|     let current_year = today.year(); |     let current_year = today.year(); | ||||||
|     let current_month = today.month(); |     let current_month = today.month(); | ||||||
| @@ -212,12 +231,26 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html { | |||||||
|                     html! { |                     html! { | ||||||
|                         <div class="calendar-error"> |                         <div class="calendar-error"> | ||||||
|                             <p>{format!("Error: {}", err)}</p> |                             <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()} | ||||||
|  |                                 on_calendar_context_menu={props.on_calendar_context_menu.clone()} | ||||||
|  |                             /> | ||||||
|                         </div> |                         </div> | ||||||
|                     } |                     } | ||||||
|                 } else { |                 } else { | ||||||
|                     html! { |                     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()} | ||||||
|  |                             on_calendar_context_menu={props.on_calendar_context_menu.clone()} | ||||||
|  |                         /> | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|   | |||||||
| @@ -528,6 +528,136 @@ 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)) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Create a new event on the CalDAV server | ||||||
|  |     pub async fn create_event( | ||||||
|  |         &self, | ||||||
|  |         token: &str, | ||||||
|  |         password: &str,  | ||||||
|  |         title: String, | ||||||
|  |         description: String, | ||||||
|  |         start_date: String, | ||||||
|  |         start_time: String, | ||||||
|  |         end_date: String, | ||||||
|  |         end_time: String, | ||||||
|  |         location: String, | ||||||
|  |         all_day: bool, | ||||||
|  |         calendar_path: Option<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!({ | ||||||
|  |             "title": title, | ||||||
|  |             "description": description, | ||||||
|  |             "start_date": start_date, | ||||||
|  |             "start_time": start_time, | ||||||
|  |             "end_date": end_date, | ||||||
|  |             "end_time": end_time, | ||||||
|  |             "location": location, | ||||||
|  |             "all_day": all_day, | ||||||
|  |             "calendar_path": calendar_path | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         let body_string = serde_json::to_string(&body) | ||||||
|  |             .map_err(|e| format!("JSON serialization failed: {}", e))?; | ||||||
|  |  | ||||||
|  |         let url = format!("{}/calendar/events/create", 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 |     /// Delete a calendar from the CalDAV server | ||||||
|     pub async fn delete_calendar( |     pub async fn delete_calendar( | ||||||
|         &self,  |         &self,  | ||||||
|   | |||||||
							
								
								
									
										118
									
								
								styles.css
									
									
									
									
									
								
							
							
						
						
									
										118
									
								
								styles.css
									
									
									
									
									
								
							| @@ -1170,3 +1170,121 @@ body { | |||||||
|     -moz-user-select: none; |     -moz-user-select: none; | ||||||
|     -ms-user-select: none; |     -ms-user-select: none; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /* Event Creation Modal Styles */ | ||||||
|  | .create-event-modal { | ||||||
|  |     max-width: 600px; | ||||||
|  |     width: 95%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .create-event-modal .modal-body { | ||||||
|  |     max-height: 60vh; | ||||||
|  |     overflow-y: auto; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .create-event-modal .form-group { | ||||||
|  |     margin-bottom: 1.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .create-event-modal .form-group label { | ||||||
|  |     display: block; | ||||||
|  |     margin-bottom: 0.5rem; | ||||||
|  |     color: #495057; | ||||||
|  |     font-weight: 500; | ||||||
|  |     font-size: 0.9rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .create-event-modal .form-input { | ||||||
|  |     width: 100%; | ||||||
|  |     padding: 0.75rem; | ||||||
|  |     border: 1px solid #ced4da; | ||||||
|  |     border-radius: 8px; | ||||||
|  |     font-size: 1rem; | ||||||
|  |     transition: border-color 0.2s ease, box-shadow 0.2s ease; | ||||||
|  |     font-family: inherit; | ||||||
|  |     box-sizing: border-box; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .create-event-modal .form-input:focus { | ||||||
|  |     outline: none; | ||||||
|  |     border-color: #667eea; | ||||||
|  |     box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .create-event-modal .form-row { | ||||||
|  |     display: grid; | ||||||
|  |     grid-template-columns: 1fr 1fr; | ||||||
|  |     gap: 1rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .create-event-modal .modal-footer { | ||||||
|  |     display: flex; | ||||||
|  |     justify-content: flex-end; | ||||||
|  |     gap: 1rem; | ||||||
|  |     padding: 1.5rem 2rem; | ||||||
|  |     border-top: 1px solid #e9ecef; | ||||||
|  |     background: #f8f9fa; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Button Styles */ | ||||||
|  | .btn { | ||||||
|  |     padding: 0.75rem 1.5rem; | ||||||
|  |     border-radius: 8px; | ||||||
|  |     font-size: 1rem; | ||||||
|  |     font-weight: 500; | ||||||
|  |     cursor: pointer; | ||||||
|  |     transition: all 0.2s ease; | ||||||
|  |     border: none; | ||||||
|  |     text-align: center; | ||||||
|  |     text-decoration: none; | ||||||
|  |     display: inline-block; | ||||||
|  |     line-height: 1.5; | ||||||
|  |     user-select: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn:disabled { | ||||||
|  |     opacity: 0.6; | ||||||
|  |     cursor: not-allowed; | ||||||
|  |     transform: none !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-secondary { | ||||||
|  |     background: #f8f9fa; | ||||||
|  |     color: #6c757d; | ||||||
|  |     border: 1px solid #ced4da; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-secondary:hover:not(:disabled) { | ||||||
|  |     background: #e9ecef; | ||||||
|  |     color: #495057; | ||||||
|  |     border-color: #adb5bd; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-primary { | ||||||
|  |     background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||||
|  |     color: white; | ||||||
|  |     border: 1px solid transparent; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-primary:hover:not(:disabled) { | ||||||
|  |     transform: translateY(-1px); | ||||||
|  |     box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Mobile adjustments for event creation modal */ | ||||||
|  | @media (max-width: 768px) { | ||||||
|  |     .create-event-modal .form-row { | ||||||
|  |         grid-template-columns: 1fr; | ||||||
|  |         gap: 1rem; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     .create-event-modal .modal-footer { | ||||||
|  |         flex-direction: column; | ||||||
|  |         gap: 0.75rem; | ||||||
|  |         padding: 1rem 1.5rem; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     .create-event-modal .btn { | ||||||
|  |         width: 100%; | ||||||
|  |     } | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user