Compare commits
	
		
			3 Commits
		
	
	
		
			7c83a4522c
			...
			f9c87369e5
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | f9c87369e5 | ||
|   | f94d057f81 | ||
|   | 5d519fd875 | 
| @@ -14,6 +14,3 @@ address = "127.0.0.1" | |||||||
| port = 8080 | port = 8080 | ||||||
| open = false | open = false | ||||||
|  |  | ||||||
| [[copy]] |  | ||||||
| from = "styles.css" |  | ||||||
| to = "dist/" |  | ||||||
| @@ -61,6 +61,9 @@ pub struct CalendarEvent { | |||||||
|      |      | ||||||
|     /// URL/href of this event on the CalDAV server |     /// URL/href of this event on the CalDAV server | ||||||
|     pub href: Option<String>, |     pub href: Option<String>, | ||||||
|  |      | ||||||
|  |     /// Calendar path this event belongs to | ||||||
|  |     pub calendar_path: Option<String>, | ||||||
| } | } | ||||||
|  |  | ||||||
| /// Event status enumeration | /// Event status enumeration | ||||||
| @@ -182,11 +185,11 @@ impl CalDAVClient { | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         let body = response.text().await.map_err(CalDAVError::RequestError)?; |         let body = response.text().await.map_err(CalDAVError::RequestError)?; | ||||||
|         self.parse_calendar_response(&body) |         self.parse_calendar_response(&body, calendar_path) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Parse CalDAV XML response containing calendar data |     /// Parse CalDAV XML response containing calendar data | ||||||
|     fn parse_calendar_response(&self, xml_response: &str) -> Result<Vec<CalendarEvent>, CalDAVError> { |     fn parse_calendar_response(&self, xml_response: &str, calendar_path: &str) -> Result<Vec<CalendarEvent>, CalDAVError> { | ||||||
|         let mut events = Vec::new(); |         let mut events = Vec::new(); | ||||||
|          |          | ||||||
|         // Extract calendar data from XML response |         // Extract calendar data from XML response | ||||||
| @@ -198,6 +201,7 @@ impl CalDAVClient { | |||||||
|                 for mut event in parsed_events { |                 for mut event in parsed_events { | ||||||
|                     event.etag = calendar_data.etag.clone(); |                     event.etag = calendar_data.etag.clone(); | ||||||
|                     event.href = calendar_data.href.clone(); |                     event.href = calendar_data.href.clone(); | ||||||
|  |                     event.calendar_path = Some(calendar_path.to_string()); | ||||||
|                     events.push(event); |                     events.push(event); | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
| @@ -377,6 +381,7 @@ impl CalDAVClient { | |||||||
|             reminders: self.parse_alarms(&event)?, |             reminders: self.parse_alarms(&event)?, | ||||||
|             etag: None, // Set by caller |             etag: None, // Set by caller | ||||||
|             href: None, // Set by caller |             href: None, // Set by caller | ||||||
|  |             calendar_path: None, // Set by caller | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -585,6 +590,74 @@ impl CalDAVClient { | |||||||
|          |          | ||||||
|         Err(CalDAVError::ParseError(format!("Unable to parse datetime: {}", datetime_str))) |         Err(CalDAVError::ParseError(format!("Unable to parse datetime: {}", datetime_str))) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /// Create a new calendar on the CalDAV server using MKCALENDAR | ||||||
|  |     pub async fn create_calendar(&self, name: &str, description: Option<&str>, color: Option<&str>) -> Result<(), CalDAVError> { | ||||||
|  |         // Sanitize calendar name for URL path | ||||||
|  |         let calendar_id = name | ||||||
|  |             .chars() | ||||||
|  |             .map(|c| if c.is_alphanumeric() { c } else { '-' }) | ||||||
|  |             .collect::<String>() | ||||||
|  |             .to_lowercase(); | ||||||
|  |          | ||||||
|  |         let calendar_path = format!("/calendars/{}/{}/", self.config.username, calendar_id); | ||||||
|  |         let full_url = format!("{}{}", self.config.server_url.trim_end_matches('/'), calendar_path); | ||||||
|  |          | ||||||
|  |         // Build color property if provided | ||||||
|  |         let color_property = if let Some(color) = color { | ||||||
|  |             format!(r#"<ic:calendar-color xmlns:ic="http://apple.com/ns/ical/">{}</ic:calendar-color>"#, color) | ||||||
|  |         } else { | ||||||
|  |             String::new() | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         let description_property = if let Some(desc) = description { | ||||||
|  |             format!(r#"<c:calendar-description xmlns:c="urn:ietf:params:xml:ns:caldav">{}</c:calendar-description>"#, desc) | ||||||
|  |         } else { | ||||||
|  |             String::new() | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         // Create the MKCALENDAR request body | ||||||
|  |         let mkcalendar_body = format!( | ||||||
|  |             r#"<?xml version="1.0" encoding="utf-8" ?> | ||||||
|  | <c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:ic="http://apple.com/ns/ical/"> | ||||||
|  |     <d:set> | ||||||
|  |         <d:prop> | ||||||
|  |             <d:displayname>{}</d:displayname> | ||||||
|  |             <c:supported-calendar-component-set> | ||||||
|  |                 <c:comp name="VEVENT"/> | ||||||
|  |             </c:supported-calendar-component-set> | ||||||
|  |             {} | ||||||
|  |             {} | ||||||
|  |         </d:prop> | ||||||
|  |     </d:set> | ||||||
|  | </c:mkcalendar>"#, | ||||||
|  |             name, color_property, description_property | ||||||
|  |         ); | ||||||
|  |          | ||||||
|  |         println!("Creating calendar at: {}", full_url); | ||||||
|  |         println!("MKCALENDAR body: {}", mkcalendar_body); | ||||||
|  |          | ||||||
|  |         let response = self.http_client | ||||||
|  |             .request(reqwest::Method::from_bytes(b"MKCALENDAR").unwrap(), &full_url) | ||||||
|  |             .header("Content-Type", "application/xml; charset=utf-8") | ||||||
|  |             .header("Authorization", format!("Basic {}", self.config.get_basic_auth())) | ||||||
|  |             .body(mkcalendar_body) | ||||||
|  |             .send() | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| CalDAVError::ParseError(e.to_string()))?; | ||||||
|  |  | ||||||
|  |         println!("Calendar creation response status: {}", response.status()); | ||||||
|  |          | ||||||
|  |         if response.status().is_success() { | ||||||
|  |             println!("✅ Calendar created successfully at {}", calendar_path); | ||||||
|  |             Ok(()) | ||||||
|  |         } else { | ||||||
|  |             let status = response.status(); | ||||||
|  |             let error_body = response.text().await.unwrap_or_default(); | ||||||
|  |             println!("❌ Calendar creation 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 | ||||||
| @@ -683,7 +756,9 @@ mod tests { | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Test parsing a sample iCal event | } | ||||||
|  |  | ||||||
|  | /// Test parsing a sample iCal event | ||||||
|     #[test] |     #[test] | ||||||
|     fn test_parse_ical_event() { |     fn test_parse_ical_event() { | ||||||
|         let sample_ical = r#"BEGIN:VCALENDAR |         let sample_ical = r#"BEGIN:VCALENDAR | ||||||
| @@ -775,4 +850,3 @@ END:VCALENDAR"#; | |||||||
|          |          | ||||||
|         println!("✓ Event enum tests passed!"); |         println!("✓ Event enum tests passed!"); | ||||||
|     } |     } | ||||||
| } |  | ||||||
| @@ -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}}; | use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo, CreateCalendarRequest, CreateCalendarResponse}}; | ||||||
| use crate::calendar::{CalDAVClient, CalendarEvent}; | use crate::calendar::{CalDAVClient, CalendarEvent}; | ||||||
|  |  | ||||||
| #[derive(Deserialize)] | #[derive(Deserialize)] | ||||||
| @@ -145,8 +145,9 @@ pub async fn get_user_info( | |||||||
|                 None |                 None | ||||||
|             } else { |             } else { | ||||||
|                 Some(CalendarInfo { |                 Some(CalendarInfo { | ||||||
|                     path, |                     path: path.clone(), | ||||||
|                     display_name, |                     display_name, | ||||||
|  |                     color: generate_calendar_color(&path), | ||||||
|                 }) |                 }) | ||||||
|             } |             } | ||||||
|         }).collect(); |         }).collect(); | ||||||
| @@ -158,6 +159,39 @@ pub async fn get_user_info( | |||||||
|     })) |     })) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // Helper function to generate a consistent color for a calendar based on its path | ||||||
|  | fn generate_calendar_color(path: &str) -> String { | ||||||
|  |     // Predefined set of attractive, accessible colors for calendars | ||||||
|  |     let colors = [ | ||||||
|  |         "#3B82F6", // Blue | ||||||
|  |         "#10B981", // Emerald | ||||||
|  |         "#F59E0B", // Amber | ||||||
|  |         "#EF4444", // Red | ||||||
|  |         "#8B5CF6", // Violet | ||||||
|  |         "#06B6D4", // Cyan | ||||||
|  |         "#84CC16", // Lime | ||||||
|  |         "#F97316", // Orange | ||||||
|  |         "#EC4899", // Pink | ||||||
|  |         "#6366F1", // Indigo | ||||||
|  |         "#14B8A6", // Teal | ||||||
|  |         "#F3B806", // Yellow | ||||||
|  |         "#8B5A2B", // Brown | ||||||
|  |         "#6B7280", // Gray | ||||||
|  |         "#DC2626", // Red-600 | ||||||
|  |         "#7C3AED", // Violet-600 | ||||||
|  |     ]; | ||||||
|  |      | ||||||
|  |     // Create a simple hash from the path to ensure consistent color assignment | ||||||
|  |     let mut hash: u32 = 0; | ||||||
|  |     for byte in path.bytes() { | ||||||
|  |         hash = hash.wrapping_mul(31).wrapping_add(byte as u32); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // Use the hash to select a color from our palette | ||||||
|  |     let color_index = (hash as usize) % colors.len(); | ||||||
|  |     colors[color_index].to_string() | ||||||
|  | } | ||||||
|  |  | ||||||
| // Helper function to extract a readable calendar name from path | // Helper function to extract a readable calendar name from path | ||||||
| fn extract_calendar_name(path: &str) -> String { | fn extract_calendar_name(path: &str) -> String { | ||||||
|     // Extract the last meaningful part of the path |     // Extract the last meaningful part of the path | ||||||
| @@ -219,3 +253,43 @@ fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> { | |||||||
|         Err(ApiError::BadRequest("X-CalDAV-Password header required".to_string())) |         Err(ApiError::BadRequest("X-CalDAV-Password header required".to_string())) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | pub async fn create_calendar( | ||||||
|  |     State(state): State<Arc<AppState>>, | ||||||
|  |     headers: HeaderMap, | ||||||
|  |     Json(request): Json<CreateCalendarRequest>, | ||||||
|  | ) -> Result<Json<CreateCalendarResponse>, ApiError> { | ||||||
|  |     println!("📝 Create calendar request received: name='{}', description={:?}, color={:?}",  | ||||||
|  |              request.name, request.description, request.color); | ||||||
|  |      | ||||||
|  |     // Extract and verify token | ||||||
|  |     let token = extract_bearer_token(&headers)?; | ||||||
|  |     let password = extract_password_header(&headers)?; | ||||||
|  |  | ||||||
|  |     // Validate request | ||||||
|  |     if request.name.trim().is_empty() { | ||||||
|  |         return Err(ApiError::BadRequest("Calendar name is required".to_string())); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     if request.name.len() > 100 { | ||||||
|  |         return Err(ApiError::BadRequest("Calendar name too long (max 100 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); | ||||||
|  |  | ||||||
|  |     // Create the calendar | ||||||
|  |     client.create_calendar( | ||||||
|  |         &request.name, | ||||||
|  |         request.description.as_deref(), | ||||||
|  |         request.color.as_deref() | ||||||
|  |     ) | ||||||
|  |     .await | ||||||
|  |     .map_err(|e| ApiError::Internal(format!("Failed to create calendar: {}", e)))?; | ||||||
|  |  | ||||||
|  |     Ok(Json(CreateCalendarResponse { | ||||||
|  |         success: true, | ||||||
|  |         message: "Calendar created successfully".to_string(), | ||||||
|  |     })) | ||||||
|  | } | ||||||
| @@ -38,6 +38,7 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> { | |||||||
|         .route("/api/auth/login", post(handlers::login)) |         .route("/api/auth/login", post(handlers::login)) | ||||||
|         .route("/api/auth/verify", get(handlers::verify_token)) |         .route("/api/auth/verify", get(handlers::verify_token)) | ||||||
|         .route("/api/user/info", get(handlers::get_user_info)) |         .route("/api/user/info", get(handlers::get_user_info)) | ||||||
|  |         .route("/api/calendar/create", post(handlers::create_calendar)) | ||||||
|         .route("/api/calendar/events", get(handlers::get_calendar_events)) |         .route("/api/calendar/events", get(handlers::get_calendar_events)) | ||||||
|         .route("/api/calendar/events/:uid", get(handlers::refresh_event)) |         .route("/api/calendar/events/:uid", get(handlers::refresh_event)) | ||||||
|         .layer( |         .layer( | ||||||
|   | |||||||
| @@ -31,6 +31,20 @@ pub struct UserInfo { | |||||||
| pub struct CalendarInfo { | pub struct CalendarInfo { | ||||||
|     pub path: String, |     pub path: String, | ||||||
|     pub display_name: String, |     pub display_name: String, | ||||||
|  |     pub color: String, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | pub struct CreateCalendarRequest { | ||||||
|  |     pub name: String, | ||||||
|  |     pub description: Option<String>, | ||||||
|  |     pub color: Option<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize)] | ||||||
|  | pub struct CreateCalendarResponse { | ||||||
|  |     pub success: bool, | ||||||
|  |     pub message: String, | ||||||
| } | } | ||||||
|  |  | ||||||
| // Error handling | // Error handling | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ | |||||||
|     <meta charset="utf-8" /> |     <meta charset="utf-8" /> | ||||||
|     <title>Calendar App</title> |     <title>Calendar App</title> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> |     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||||
|     <link rel="stylesheet" href="styles.css"> |     <link data-trunk rel="css" href="styles.css"> | ||||||
| </head> | </head> | ||||||
| <body></body> | <body></body> | ||||||
| </html> | </html> | ||||||
							
								
								
									
										195
									
								
								src/app.rs
									
									
									
									
									
								
							
							
						
						
									
										195
									
								
								src/app.rs
									
									
									
									
									
								
							| @@ -1,8 +1,8 @@ | |||||||
| use yew::prelude::*; | use yew::prelude::*; | ||||||
| use yew_router::prelude::*; | use yew_router::prelude::*; | ||||||
| use gloo_storage::{LocalStorage, Storage}; | use gloo_storage::{LocalStorage, Storage}; | ||||||
| use crate::components::{Login, Calendar}; | use crate::components::{Login, Calendar, CreateCalendarModal}; | ||||||
| use crate::services::{CalendarService, CalendarEvent, UserInfo, CalendarInfo}; | use crate::services::{CalendarService, CalendarEvent, UserInfo}; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| use chrono::{Local, NaiveDate, Datelike}; | use chrono::{Local, NaiveDate, Datelike}; | ||||||
|  |  | ||||||
| @@ -23,6 +23,16 @@ pub fn App() -> Html { | |||||||
|     }); |     }); | ||||||
|      |      | ||||||
|     let user_info = use_state(|| -> Option<UserInfo> { None }); |     let user_info = use_state(|| -> Option<UserInfo> { None }); | ||||||
|  |     let color_picker_open = use_state(|| -> Option<String> { None }); // Store calendar path of open picker | ||||||
|  |     let create_modal_open = use_state(|| false); | ||||||
|  |      | ||||||
|  |     // Available colors for calendar customization | ||||||
|  |     let available_colors = [ | ||||||
|  |         "#3B82F6", "#10B981", "#F59E0B", "#EF4444",  | ||||||
|  |         "#8B5CF6", "#06B6D4", "#84CC16", "#F97316", | ||||||
|  |         "#EC4899", "#6366F1", "#14B8A6", "#F3B806", | ||||||
|  |         "#8B5A2B", "#6B7280", "#DC2626", "#7C3AED" | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|     let on_login = { |     let on_login = { | ||||||
|         let auth_token = auth_token.clone(); |         let auth_token = auth_token.clone(); | ||||||
| @@ -67,7 +77,20 @@ pub fn App() -> Html { | |||||||
|                      |                      | ||||||
|                     if !password.is_empty() { |                     if !password.is_empty() { | ||||||
|                         match calendar_service.fetch_user_info(&token, &password).await { |                         match calendar_service.fetch_user_info(&token, &password).await { | ||||||
|                             Ok(info) => { |                             Ok(mut info) => { | ||||||
|  |                                 // Load saved colors from local storage | ||||||
|  |                                 if let Ok(saved_colors_json) = LocalStorage::get::<String>("calendar_colors") { | ||||||
|  |                                     if let Ok(saved_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) { | ||||||
|  |                                         // Update colors with saved preferences | ||||||
|  |                                         for saved_cal in &saved_info.calendars { | ||||||
|  |                                             for cal in &mut info.calendars { | ||||||
|  |                                                 if cal.path == saved_cal.path { | ||||||
|  |                                                     cal.color = saved_cal.color.clone(); | ||||||
|  |                                                 } | ||||||
|  |                                             } | ||||||
|  |                                         } | ||||||
|  |                                     } | ||||||
|  |                                 } | ||||||
|                                 user_info.set(Some(info)); |                                 user_info.set(Some(info)); | ||||||
|                             } |                             } | ||||||
|                             Err(err) => { |                             Err(err) => { | ||||||
| @@ -84,9 +107,21 @@ pub fn App() -> Html { | |||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     let on_outside_click = { | ||||||
|  |         let color_picker_open = color_picker_open.clone(); | ||||||
|  |         Callback::from(move |_: MouseEvent| { | ||||||
|  |             color_picker_open.set(None); | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     // Clone variables needed for the modal outside of the conditional blocks | ||||||
|  |     let auth_token_for_modal = auth_token.clone(); | ||||||
|  |     let user_info_for_modal = user_info.clone(); | ||||||
|  |     let create_modal_open_for_modal = create_modal_open.clone(); | ||||||
|  |  | ||||||
|     html! { |     html! { | ||||||
|         <BrowserRouter> |         <BrowserRouter> | ||||||
|             <div class="app"> |             <div class="app" onclick={on_outside_click}> | ||||||
|                 { |                 { | ||||||
|                     if auth_token.is_some() { |                     if auth_token.is_some() { | ||||||
|                         html! { |                         html! { | ||||||
| @@ -119,8 +154,71 @@ pub fn App() -> Html { | |||||||
|                                                         <ul> |                                                         <ul> | ||||||
|                                                             { |                                                             { | ||||||
|                                                                 info.calendars.iter().map(|cal| { |                                                                 info.calendars.iter().map(|cal| { | ||||||
|  |                                                                     let _cal_clone = cal.clone(); | ||||||
|  |                                                                     let color_picker_open_clone = color_picker_open.clone(); | ||||||
|  |                                                                      | ||||||
|  |                                                                     let on_color_click = { | ||||||
|  |                                                                         let cal_path = cal.path.clone(); | ||||||
|  |                                                                         let color_picker_open = color_picker_open.clone(); | ||||||
|  |                                                                         Callback::from(move |e: MouseEvent| { | ||||||
|  |                                                                             e.stop_propagation(); | ||||||
|  |                                                                             color_picker_open.set(Some(cal_path.clone())); | ||||||
|  |                                                                         }) | ||||||
|  |                                                                     }; | ||||||
|  |                                                                      | ||||||
|                                                                     html! { |                                                                     html! { | ||||||
|                                                                         <li key={cal.path.clone()}> |                                                                         <li key={cal.path.clone()}> | ||||||
|  |                                                                             <span class="calendar-color"  | ||||||
|  |                                                                                   style={format!("background-color: {}", cal.color)} | ||||||
|  |                                                                                   onclick={on_color_click}> | ||||||
|  |                                                                                 { | ||||||
|  |                                                                                     if color_picker_open_clone.as_ref() == Some(&cal.path) { | ||||||
|  |                                                                                         html! { | ||||||
|  |                                                                                             <div class="color-picker"> | ||||||
|  |                                                                                                 { | ||||||
|  |                                                                                                     available_colors.iter().map(|&color| { | ||||||
|  |                                                                                                         let color_str = color.to_string(); | ||||||
|  |                                                                                                         let cal_path = cal.path.clone(); | ||||||
|  |                                                                                                         let user_info_clone = user_info.clone(); | ||||||
|  |                                                                                                         let color_picker_open = color_picker_open.clone(); | ||||||
|  |                                                                                                          | ||||||
|  |                                                                                                         let on_color_select = Callback::from(move |_: MouseEvent| { | ||||||
|  |                                                                                                             // Update the calendar color locally | ||||||
|  |                                                                                                             if let Some(mut info) = (*user_info_clone).clone() { | ||||||
|  |                                                                                                                 for calendar in &mut info.calendars { | ||||||
|  |                                                                                                                     if calendar.path == cal_path { | ||||||
|  |                                                                                                                         calendar.color = color_str.clone(); | ||||||
|  |                                                                                                                         break; | ||||||
|  |                                                                                                                     } | ||||||
|  |                                                                                                                 } | ||||||
|  |                                                                                                                 user_info_clone.set(Some(info.clone())); | ||||||
|  |                                                                                                                  | ||||||
|  |                                                                                                                 // Save to local storage | ||||||
|  |                                                                                                                 if let Ok(json) = serde_json::to_string(&info) { | ||||||
|  |                                                                                                                     let _ = LocalStorage::set("calendar_colors", json); | ||||||
|  |                                                                                                                 } | ||||||
|  |                                                                                                             } | ||||||
|  |                                                                                                             color_picker_open.set(None); | ||||||
|  |                                                                                                         }); | ||||||
|  |                                                                                                          | ||||||
|  |                                                                                                         let is_selected = cal.color == color; | ||||||
|  |                                                                                                         let class_name = if is_selected { "color-option selected" } else { "color-option" }; | ||||||
|  |                                                                                                          | ||||||
|  |                                                                                                         html! { | ||||||
|  |                                                                                                             <div class={class_name} | ||||||
|  |                                                                                                                  style={format!("background-color: {}", color)} | ||||||
|  |                                                                                                                  onclick={on_color_select}> | ||||||
|  |                                                                                                             </div> | ||||||
|  |                                                                                                         } | ||||||
|  |                                                                                                     }).collect::<Html>() | ||||||
|  |                                                                                                 } | ||||||
|  |                                                                                             </div> | ||||||
|  |                                                                                         } | ||||||
|  |                                                                                     } else { | ||||||
|  |                                                                                         html! {} | ||||||
|  |                                                                                     } | ||||||
|  |                                                                                 } | ||||||
|  |                                                                             </span> | ||||||
|                                                                             <span class="calendar-name">{&cal.display_name}</span> |                                                                             <span class="calendar-name">{&cal.display_name}</span> | ||||||
|                                                                         </li> |                                                                         </li> | ||||||
|                                                                     } |                                                                     } | ||||||
| @@ -137,6 +235,12 @@ pub fn App() -> Html { | |||||||
|                                         } |                                         } | ||||||
|                                     } |                                     } | ||||||
|                                     <div class="sidebar-footer"> |                                     <div class="sidebar-footer"> | ||||||
|  |                                         <button onclick={Callback::from({ | ||||||
|  |                                             let create_modal_open = create_modal_open.clone(); | ||||||
|  |                                             move |_| create_modal_open.set(true) | ||||||
|  |                                         })} class="create-calendar-button"> | ||||||
|  |                                             {"+ Create Calendar"} | ||||||
|  |                                         </button> | ||||||
|                                         <button onclick={on_logout} class="logout-button">{"Logout"}</button> |                                         <button onclick={on_logout} class="logout-button">{"Logout"}</button> | ||||||
|                                     </div> |                                     </div> | ||||||
|                                 </aside> |                                 </aside> | ||||||
| @@ -162,7 +266,7 @@ pub fn App() -> Html { | |||||||
|                                             } |                                             } | ||||||
|                                             Route::Calendar => { |                                             Route::Calendar => { | ||||||
|                                                 if auth_token.is_some() { |                                                 if auth_token.is_some() { | ||||||
|                                                     html! { <CalendarView /> } |                                                     html! { <CalendarView user_info={(*user_info).clone()} /> } | ||||||
|                                                 } else { |                                                 } else { | ||||||
|                                                     html! { <Redirect<Route> to={Route::Login}/> } |                                                     html! { <Redirect<Route> to={Route::Login}/> } | ||||||
|                                                 } |                                                 } | ||||||
| @@ -196,7 +300,7 @@ pub fn App() -> Html { | |||||||
|                                         } |                                         } | ||||||
|                                         Route::Calendar => { |                                         Route::Calendar => { | ||||||
|                                             if auth_token.is_some() { |                                             if auth_token.is_some() { | ||||||
|                                                 html! { <CalendarView /> } |                                                 html! { <CalendarView user_info={(*user_info).clone()} /> } | ||||||
|                                             } else { |                                             } else { | ||||||
|                                                 html! { <Redirect<Route> to={Route::Login}/> } |                                                 html! { <Redirect<Route> to={Route::Login}/> } | ||||||
|                                             } |                                             } | ||||||
| @@ -207,13 +311,86 @@ pub fn App() -> Html { | |||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|  |                  | ||||||
|  |                 <CreateCalendarModal  | ||||||
|  |                     is_open={*create_modal_open} | ||||||
|  |                     on_close={Callback::from({ | ||||||
|  |                         let create_modal_open = create_modal_open_for_modal.clone(); | ||||||
|  |                         move |_| create_modal_open.set(false) | ||||||
|  |                     })} | ||||||
|  |                     on_create={Callback::from({ | ||||||
|  |                         let auth_token = auth_token_for_modal.clone(); | ||||||
|  |                         let user_info = user_info_for_modal.clone(); | ||||||
|  |                         let create_modal_open = create_modal_open_for_modal.clone(); | ||||||
|  |                         move |(name, description, color): (String, Option<String>, Option<String>)| { | ||||||
|  |                             if let Some(token) = (*auth_token).clone() { | ||||||
|  |                                 let user_info = user_info.clone(); | ||||||
|  |                                 let create_modal_open = create_modal_open.clone(); | ||||||
|  |                                  | ||||||
|  |                                 wasm_bindgen_futures::spawn_local(async move { | ||||||
|  |                                     let calendar_service = CalendarService::new(); | ||||||
|  |                                      | ||||||
|  |                                     // Get password from stored credentials | ||||||
|  |                                     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() | ||||||
|  |                                     }; | ||||||
|  |                                      | ||||||
|  |                                     match calendar_service.create_calendar(&token, &password, name, description, color).await { | ||||||
|  |                                         Ok(_) => { | ||||||
|  |                                             web_sys::console::log_1(&"Calendar created successfully!".into()); | ||||||
|  |                                             // Refresh user info to show the new calendar | ||||||
|  |                                             match calendar_service.fetch_user_info(&token, &password).await { | ||||||
|  |                                                 Ok(mut info) => { | ||||||
|  |                                                     // Load saved colors from local storage | ||||||
|  |                                                     if let Ok(saved_colors_json) = LocalStorage::get::<String>("calendar_colors") { | ||||||
|  |                                                         if let Ok(saved_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) { | ||||||
|  |                                                             for saved_cal in &saved_info.calendars { | ||||||
|  |                                                                 for cal in &mut info.calendars { | ||||||
|  |                                                                     if cal.path == saved_cal.path { | ||||||
|  |                                                                         cal.color = saved_cal.color.clone(); | ||||||
|  |                                                                     } | ||||||
|  |                                                                 } | ||||||
|  |                                                             } | ||||||
|  |                                                         } | ||||||
|  |                                                     } | ||||||
|  |                                                     user_info.set(Some(info)); | ||||||
|  |                                                 } | ||||||
|  |                                                 Err(err) => { | ||||||
|  |                                                     web_sys::console::log_1(&format!("Failed to refresh calendars: {}", err).into()); | ||||||
|  |                                                 } | ||||||
|  |                                             } | ||||||
|  |                                             create_modal_open.set(false); | ||||||
|  |                                         } | ||||||
|  |                                         Err(err) => { | ||||||
|  |                                             web_sys::console::log_1(&format!("Failed to create calendar: {}", err).into()); | ||||||
|  |                                             // TODO: Show error to user | ||||||
|  |                                             create_modal_open.set(false); | ||||||
|  |                                         } | ||||||
|  |                                     } | ||||||
|  |                                 }); | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     })} | ||||||
|  |                     available_colors={available_colors.iter().map(|c| c.to_string()).collect::<Vec<_>>()} | ||||||
|  |                 /> | ||||||
|             </div> |             </div> | ||||||
|         </BrowserRouter> |         </BrowserRouter> | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #[derive(Properties, PartialEq)] | ||||||
|  | pub struct CalendarViewProps { | ||||||
|  |     pub user_info: Option<UserInfo>, | ||||||
|  | } | ||||||
|  |  | ||||||
| #[function_component] | #[function_component] | ||||||
| fn CalendarView() -> Html { | fn CalendarView(props: &CalendarViewProps) -> Html { | ||||||
|     let events = use_state(|| HashMap::<NaiveDate, Vec<CalendarEvent>>::new()); |     let events = use_state(|| HashMap::<NaiveDate, Vec<CalendarEvent>>::new()); | ||||||
|     let loading = use_state(|| true); |     let loading = use_state(|| true); | ||||||
|     let error = use_state(|| None::<String>); |     let error = use_state(|| None::<String>); | ||||||
| @@ -367,12 +544,12 @@ fn CalendarView() -> 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()} /> |                             <Calendar events={HashMap::new()} on_event_click={dummy_callback} refreshing_event_uid={(*refreshing_event).clone()} user_info={props.user_info.clone()} /> | ||||||
|                         </div> |                         </div> | ||||||
|                     } |                     } | ||||||
|                 } else { |                 } else { | ||||||
|                     html! { |                     html! { | ||||||
|                         <Calendar events={(*events).clone()} on_event_click={on_event_click} refreshing_event_uid={(*refreshing_event).clone()} /> |                         <Calendar events={(*events).clone()} on_event_click={on_event_click} refreshing_event_uid={(*refreshing_event).clone()} user_info={props.user_info.clone()} /> | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| use yew::prelude::*; | use yew::prelude::*; | ||||||
| use chrono::{Datelike, Local, NaiveDate, Duration, Weekday}; | use chrono::{Datelike, Local, NaiveDate, Duration, Weekday}; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| use crate::services::calendar_service::CalendarEvent; | use crate::services::calendar_service::{CalendarEvent, UserInfo}; | ||||||
| use crate::components::EventModal; | use crate::components::EventModal; | ||||||
|  |  | ||||||
| #[derive(Properties, PartialEq)] | #[derive(Properties, PartialEq)] | ||||||
| @@ -11,6 +11,8 @@ pub struct CalendarProps { | |||||||
|     pub on_event_click: Callback<CalendarEvent>, |     pub on_event_click: Callback<CalendarEvent>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub refreshing_event_uid: Option<String>, |     pub refreshing_event_uid: Option<String>, | ||||||
|  |     #[prop_or_default] | ||||||
|  |     pub user_info: Option<UserInfo>, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[function_component] | #[function_component] | ||||||
| @@ -20,6 +22,21 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|     let selected_day = use_state(|| today); |     let selected_day = use_state(|| today); | ||||||
|     let selected_event = use_state(|| None::<CalendarEvent>); |     let selected_event = use_state(|| None::<CalendarEvent>); | ||||||
|      |      | ||||||
|  |     // Helper function to get calendar color for an event | ||||||
|  |     let get_event_color = |event: &CalendarEvent| -> String { | ||||||
|  |         if let Some(user_info) = &props.user_info { | ||||||
|  |             if let Some(calendar_path) = &event.calendar_path { | ||||||
|  |                 // Find the calendar that matches this event's path | ||||||
|  |                 if let Some(calendar) = user_info.calendars.iter() | ||||||
|  |                     .find(|cal| &cal.path == calendar_path) { | ||||||
|  |                     return calendar.color.clone(); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         // Default color if no match found | ||||||
|  |         "#3B82F6".to_string() | ||||||
|  |     }; | ||||||
|  |      | ||||||
|     let first_day_of_month = current_month.with_day(1).unwrap(); |     let first_day_of_month = current_month.with_day(1).unwrap(); | ||||||
|     let days_in_month = get_days_in_month(*current_month); |     let days_in_month = get_days_in_month(*current_month); | ||||||
|     let first_weekday = first_day_of_month.weekday(); |     let first_weekday = first_day_of_month.weekday(); | ||||||
| @@ -118,10 +135,12 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                                                         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" }; | ||||||
|  |                                                         let event_color = get_event_color(&event); | ||||||
|                                                         html! {  |                                                         html! {  | ||||||
|                                                             <div class={class_name}  |                                                             <div class={class_name}  | ||||||
|                                                                  title={title.clone()}  |                                                                  title={title.clone()}  | ||||||
|                                                                  onclick={event_click}> |                                                                  onclick={event_click} | ||||||
|  |                                                                  style={format!("background-color: {}", event_color)}> | ||||||
|                                                                 { |                                                                 { | ||||||
|                                                                     if is_refreshing { |                                                                     if is_refreshing { | ||||||
|                                                                         "🔄 Refreshing...".to_string() |                                                                         "🔄 Refreshing...".to_string() | ||||||
|   | |||||||
							
								
								
									
										196
									
								
								src/components/create_calendar_modal.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										196
									
								
								src/components/create_calendar_modal.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,196 @@ | |||||||
|  | use yew::prelude::*; | ||||||
|  |  | ||||||
|  | #[derive(Properties, PartialEq)] | ||||||
|  | pub struct CreateCalendarModalProps { | ||||||
|  |     pub is_open: bool, | ||||||
|  |     pub on_close: Callback<()>, | ||||||
|  |     pub on_create: Callback<(String, Option<String>, Option<String>)>, // name, description, color | ||||||
|  |     pub available_colors: Vec<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[function_component] | ||||||
|  | pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html { | ||||||
|  |     let calendar_name = use_state(|| String::new()); | ||||||
|  |     let description = use_state(|| String::new()); | ||||||
|  |     let selected_color = use_state(|| None::<String>); | ||||||
|  |     let error_message = use_state(|| None::<String>); | ||||||
|  |     let is_creating = use_state(|| false); | ||||||
|  |  | ||||||
|  |     let on_name_change = { | ||||||
|  |         let calendar_name = calendar_name.clone(); | ||||||
|  |         Callback::from(move |e: InputEvent| { | ||||||
|  |             let input: web_sys::HtmlInputElement = e.target_unchecked_into(); | ||||||
|  |             calendar_name.set(input.value()); | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_description_change = { | ||||||
|  |         let description = description.clone(); | ||||||
|  |         Callback::from(move |e: InputEvent| { | ||||||
|  |             let input: web_sys::HtmlTextAreaElement = e.target_unchecked_into(); | ||||||
|  |             description.set(input.value()); | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_submit = { | ||||||
|  |         let calendar_name = calendar_name.clone(); | ||||||
|  |         let description = description.clone(); | ||||||
|  |         let selected_color = selected_color.clone(); | ||||||
|  |         let error_message = error_message.clone(); | ||||||
|  |         let is_creating = is_creating.clone(); | ||||||
|  |         let on_create = props.on_create.clone(); | ||||||
|  |          | ||||||
|  |         Callback::from(move |e: SubmitEvent| { | ||||||
|  |             e.prevent_default(); | ||||||
|  |              | ||||||
|  |             let name = (*calendar_name).trim(); | ||||||
|  |             if name.is_empty() { | ||||||
|  |                 error_message.set(Some("Calendar name is required".to_string())); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             if name.len() > 100 { | ||||||
|  |                 error_message.set(Some("Calendar name too long (max 100 characters)".to_string())); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             error_message.set(None); | ||||||
|  |             is_creating.set(true); | ||||||
|  |              | ||||||
|  |             let desc = if (*description).trim().is_empty() { | ||||||
|  |                 None | ||||||
|  |             } else { | ||||||
|  |                 Some((*description).clone()) | ||||||
|  |             }; | ||||||
|  |              | ||||||
|  |             on_create.emit((name.to_string(), desc, (*selected_color).clone())); | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_backdrop_click = { | ||||||
|  |         let on_close = props.on_close.clone(); | ||||||
|  |         Callback::from(move |e: MouseEvent| { | ||||||
|  |             // Only close if clicking the backdrop, not the modal content | ||||||
|  |             if e.target() == e.current_target() { | ||||||
|  |                 on_close.emit(()); | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     if !props.is_open { | ||||||
|  |         return html! {}; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     html! { | ||||||
|  |         <div class="modal-backdrop" onclick={on_backdrop_click}> | ||||||
|  |             <div class="create-calendar-modal"> | ||||||
|  |                 <div class="modal-header"> | ||||||
|  |                     <h2>{"Create New Calendar"}</h2> | ||||||
|  |                     <button class="close-button" onclick={props.on_close.reform(|_| ())}> | ||||||
|  |                         {"×"} | ||||||
|  |                     </button> | ||||||
|  |                 </div> | ||||||
|  |                  | ||||||
|  |                 <form class="modal-body" onsubmit={on_submit}> | ||||||
|  |                     { | ||||||
|  |                         if let Some(ref error) = *error_message { | ||||||
|  |                             html! { | ||||||
|  |                                 <div class="error-message"> | ||||||
|  |                                     {error} | ||||||
|  |                                 </div> | ||||||
|  |                             } | ||||||
|  |                         } else { | ||||||
|  |                             html! {} | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     <div class="form-group"> | ||||||
|  |                         <label for="calendar-name">{"Calendar Name *"}</label> | ||||||
|  |                         <input  | ||||||
|  |                             id="calendar-name" | ||||||
|  |                             type="text" | ||||||
|  |                             value={(*calendar_name).clone()} | ||||||
|  |                             oninput={on_name_change} | ||||||
|  |                             placeholder="Enter calendar name" | ||||||
|  |                             maxlength="100" | ||||||
|  |                             disabled={*is_creating} | ||||||
|  |                         /> | ||||||
|  |                     </div> | ||||||
|  |                      | ||||||
|  |                     <div class="form-group"> | ||||||
|  |                         <label for="calendar-description">{"Description"}</label> | ||||||
|  |                         <textarea | ||||||
|  |                             id="calendar-description" | ||||||
|  |                             value={(*description).clone()} | ||||||
|  |                             oninput={on_description_change} | ||||||
|  |                             placeholder="Optional calendar description" | ||||||
|  |                             rows="3" | ||||||
|  |                             disabled={*is_creating} | ||||||
|  |                         /> | ||||||
|  |                     </div> | ||||||
|  |                      | ||||||
|  |                     <div class="form-group"> | ||||||
|  |                         <label>{"Calendar Color"}</label> | ||||||
|  |                         <div class="color-grid"> | ||||||
|  |                             { | ||||||
|  |                                 props.available_colors.iter().enumerate().map(|(index, color)| { | ||||||
|  |                                     let color = color.clone(); | ||||||
|  |                                     let selected_color = selected_color.clone(); | ||||||
|  |                                     let is_selected = selected_color.as_ref() == Some(&color); | ||||||
|  |                                     let on_color_select = { | ||||||
|  |                                         let color = color.clone(); | ||||||
|  |                                         Callback::from(move |_: MouseEvent| { | ||||||
|  |                                             selected_color.set(Some(color.clone())); | ||||||
|  |                                         }) | ||||||
|  |                                     }; | ||||||
|  |                                      | ||||||
|  |                                     let class_name = if is_selected {  | ||||||
|  |                                         "color-option selected"  | ||||||
|  |                                     } else {  | ||||||
|  |                                         "color-option"  | ||||||
|  |                                     }; | ||||||
|  |                                      | ||||||
|  |                                     html! { | ||||||
|  |                                         <button | ||||||
|  |                                             key={index} | ||||||
|  |                                             type="button" | ||||||
|  |                                             class={class_name} | ||||||
|  |                                             style={format!("background-color: {}", color)} | ||||||
|  |                                             onclick={on_color_select} | ||||||
|  |                                             disabled={*is_creating} | ||||||
|  |                                         /> | ||||||
|  |                                     } | ||||||
|  |                                 }).collect::<Html>() | ||||||
|  |                             } | ||||||
|  |                         </div> | ||||||
|  |                         <p class="color-help-text">{"Optional: Choose a color for your calendar"}</p> | ||||||
|  |                     </div> | ||||||
|  |                      | ||||||
|  |                     <div class="modal-actions"> | ||||||
|  |                         <button  | ||||||
|  |                             type="button"  | ||||||
|  |                             class="cancel-button" | ||||||
|  |                             onclick={props.on_close.reform(|_| ())} | ||||||
|  |                             disabled={*is_creating} | ||||||
|  |                         > | ||||||
|  |                             {"Cancel"} | ||||||
|  |                         </button> | ||||||
|  |                         <button  | ||||||
|  |                             type="submit"  | ||||||
|  |                             class="create-button" | ||||||
|  |                             disabled={*is_creating} | ||||||
|  |                         > | ||||||
|  |                             { | ||||||
|  |                                 if *is_creating { | ||||||
|  |                                     "Creating..." | ||||||
|  |                                 } else { | ||||||
|  |                                     "Create Calendar" | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                         </button> | ||||||
|  |                     </div> | ||||||
|  |                 </form> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,7 +1,9 @@ | |||||||
| pub mod login; | pub mod login; | ||||||
| pub mod calendar; | pub mod calendar; | ||||||
| pub mod event_modal; | pub mod event_modal; | ||||||
|  | pub mod create_calendar_modal; | ||||||
|  |  | ||||||
| pub use login::Login; | pub use login::Login; | ||||||
| pub use calendar::Calendar; | pub use calendar::Calendar; | ||||||
| pub use event_modal::EventModal; | pub use event_modal::EventModal; | ||||||
|  | pub use create_calendar_modal::CreateCalendarModal; | ||||||
| @@ -36,6 +36,7 @@ pub struct UserInfo { | |||||||
| pub struct CalendarInfo { | pub struct CalendarInfo { | ||||||
|     pub path: String, |     pub path: String, | ||||||
|     pub display_name: String, |     pub display_name: String, | ||||||
|  |     pub color: String, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||||
| @@ -59,6 +60,7 @@ pub struct CalendarEvent { | |||||||
|     pub reminders: Vec<EventReminder>, |     pub reminders: Vec<EventReminder>, | ||||||
|     pub etag: Option<String>, |     pub etag: Option<String>, | ||||||
|     pub href: Option<String>, |     pub href: Option<String>, | ||||||
|  |     pub calendar_path: Option<String>, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||||
| @@ -465,6 +467,67 @@ impl CalendarService { | |||||||
|         (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) |         (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /// Create a new calendar on the CalDAV server | ||||||
|  |     pub async fn create_calendar( | ||||||
|  |         &self,  | ||||||
|  |         token: &str,  | ||||||
|  |         password: &str, | ||||||
|  |         name: String, | ||||||
|  |         description: Option<String>, | ||||||
|  |         color: 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!({ | ||||||
|  |             "name": name, | ||||||
|  |             "description": description, | ||||||
|  |             "color": color | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         let body_string = serde_json::to_string(&body) | ||||||
|  |             .map_err(|e| format!("JSON serialization failed: {}", e))?; | ||||||
|  |  | ||||||
|  |         opts.set_body(&body_string.into()); | ||||||
|  |  | ||||||
|  |         let url = format!("{}/calendar/create", self.base_url); | ||||||
|  |         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)) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /// Refresh a single event by UID from the CalDAV server |     /// Refresh a single event by UID from the CalDAV server | ||||||
|     pub async fn refresh_event(&self, token: &str, password: &str, uid: &str) -> Result<Option<CalendarEvent>, String> { |     pub async fn refresh_event(&self, token: &str, password: &str, uid: &str) -> Result<Option<CalendarEvent>, String> { | ||||||
|         let window = web_sys::window().ok_or("No global window exists")?; |         let window = web_sys::window().ok_or("No global window exists")?; | ||||||
|   | |||||||
| @@ -1,3 +1,3 @@ | |||||||
| pub mod calendar_service; | pub mod calendar_service; | ||||||
|  |  | ||||||
| pub use calendar_service::{CalendarService, CalendarEvent, EventReminder, ReminderAction, UserInfo, CalendarInfo}; | pub use calendar_service::{CalendarService, CalendarEvent, EventReminder, ReminderAction, UserInfo}; | ||||||
							
								
								
									
										368
									
								
								styles.css
									
									
									
									
									
								
							
							
						
						
									
										368
									
								
								styles.css
									
									
									
									
									
								
							| @@ -136,6 +136,7 @@ body { | |||||||
|     border-radius: 6px; |     border-radius: 6px; | ||||||
|     transition: all 0.2s; |     transition: all 0.2s; | ||||||
|     cursor: pointer; |     cursor: pointer; | ||||||
|  |     gap: 0.75rem; | ||||||
| } | } | ||||||
|  |  | ||||||
| .calendar-list li:hover { | .calendar-list li:hover { | ||||||
| @@ -143,10 +144,72 @@ body { | |||||||
|     transform: translateX(2px); |     transform: translateX(2px); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .calendar-color { | ||||||
|  |     width: 16px; | ||||||
|  |     height: 16px; | ||||||
|  |     border-radius: 50%; | ||||||
|  |     flex-shrink: 0; | ||||||
|  |     border: 2px solid rgba(255,255,255,0.3); | ||||||
|  |     transition: all 0.2s; | ||||||
|  |     cursor: pointer; | ||||||
|  |     position: relative; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-list li:hover .calendar-color { | ||||||
|  |     border-color: rgba(255,255,255,0.6); | ||||||
|  |     transform: scale(1.1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .color-picker { | ||||||
|  |     position: absolute; | ||||||
|  |     top: 100%; | ||||||
|  |     left: 0; | ||||||
|  |     background: white; | ||||||
|  |     border-radius: 8px; | ||||||
|  |     padding: 8px; | ||||||
|  |     box-shadow: 0 4px 12px rgba(0,0,0,0.15); | ||||||
|  |     z-index: 1000; | ||||||
|  |     display: grid; | ||||||
|  |     grid-template-columns: repeat(4, 1fr); | ||||||
|  |     gap: 6px; | ||||||
|  |     min-width: 120px; | ||||||
|  |     border: 1px solid rgba(0,0,0,0.1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .color-option { | ||||||
|  |     width: 20px; | ||||||
|  |     height: 20px; | ||||||
|  |     border-radius: 50%; | ||||||
|  |     border: 2px solid rgba(0,0,0,0.1); | ||||||
|  |     cursor: pointer; | ||||||
|  |     transition: all 0.2s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .color-option:hover { | ||||||
|  |     transform: scale(1.2); | ||||||
|  |     border-color: rgba(0,0,0,0.3); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .color-option.selected { | ||||||
|  |     border-color: #333; | ||||||
|  |     border-width: 3px; | ||||||
|  |     transform: scale(1.1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .color-picker-overlay { | ||||||
|  |     position: fixed; | ||||||
|  |     top: 0; | ||||||
|  |     left: 0; | ||||||
|  |     right: 0; | ||||||
|  |     bottom: 0; | ||||||
|  |     z-index: 999; | ||||||
|  | } | ||||||
|  |  | ||||||
| .calendar-name { | .calendar-name { | ||||||
|     color: white; |     color: white; | ||||||
|     font-size: 0.9rem; |     font-size: 0.9rem; | ||||||
|     font-weight: 500; |     font-weight: 500; | ||||||
|  |     flex: 1; | ||||||
| } | } | ||||||
|  |  | ||||||
| .no-calendars { | .no-calendars { | ||||||
| @@ -459,7 +522,7 @@ body { | |||||||
| } | } | ||||||
|  |  | ||||||
| .event-box { | .event-box { | ||||||
|     background: #2196f3; |     /* Background color will be set inline via style attribute */ | ||||||
|     color: white; |     color: white; | ||||||
|     padding: 2px 4px; |     padding: 2px 4px; | ||||||
|     border-radius: 3px; |     border-radius: 3px; | ||||||
| @@ -469,16 +532,34 @@ body { | |||||||
|     text-overflow: ellipsis; |     text-overflow: ellipsis; | ||||||
|     white-space: nowrap; |     white-space: nowrap; | ||||||
|     cursor: pointer; |     cursor: pointer; | ||||||
|     transition: background-color 0.2s; |     transition: all 0.2s ease; | ||||||
|  |     border: 1px solid rgba(255,255,255,0.2); | ||||||
|  |     text-shadow: 0 1px 1px rgba(0,0,0,0.3); | ||||||
|  |     font-weight: 500; | ||||||
|  |     box-shadow: 0 1px 2px rgba(0,0,0,0.1); | ||||||
|  |     position: relative; | ||||||
| } | } | ||||||
|  |  | ||||||
| .event-box:hover { | .event-box:hover { | ||||||
|     background: #1976d2; |     filter: brightness(1.15); | ||||||
|  |     transform: translateY(-1px); | ||||||
|  |     box-shadow: 0 2px 4px rgba(0,0,0,0.15); | ||||||
| } | } | ||||||
|  |  | ||||||
| .event-box.refreshing { | .event-box.refreshing { | ||||||
|     background: #ff9800; |  | ||||||
|     animation: pulse 1.5s ease-in-out infinite alternate; |     animation: pulse 1.5s ease-in-out infinite alternate; | ||||||
|  |     border-color: #ff9800; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .event-box.refreshing::after { | ||||||
|  |     content: ''; | ||||||
|  |     position: absolute; | ||||||
|  |     top: 0; | ||||||
|  |     left: 0; | ||||||
|  |     right: 0; | ||||||
|  |     bottom: 0; | ||||||
|  |     background: rgba(255, 152, 0, 0.3); | ||||||
|  |     pointer-events: none; | ||||||
| } | } | ||||||
|  |  | ||||||
| @keyframes pulse { | @keyframes pulse { | ||||||
| @@ -748,3 +829,282 @@ body { | |||||||
|         padding: 1.5rem; |         padding: 1.5rem; | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /* Create Calendar Button */ | ||||||
|  | .create-calendar-button { | ||||||
|  |     background: rgba(255, 255, 255, 0.2); | ||||||
|  |     border: 1px solid rgba(255, 255, 255, 0.3); | ||||||
|  |     color: white; | ||||||
|  |     padding: 0.75rem 1rem; | ||||||
|  |     border-radius: 8px; | ||||||
|  |     cursor: pointer; | ||||||
|  |     transition: all 0.2s ease; | ||||||
|  |     margin-bottom: 1rem; | ||||||
|  |     font-size: 0.9rem; | ||||||
|  |     font-weight: 500; | ||||||
|  |     backdrop-filter: blur(10px); | ||||||
|  |     width: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .create-calendar-button:hover { | ||||||
|  |     background: rgba(255, 255, 255, 0.3); | ||||||
|  |     border-color: rgba(255, 255, 255, 0.5); | ||||||
|  |     transform: translateY(-1px); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .create-calendar-button:active { | ||||||
|  |     transform: translateY(0); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Create Calendar Modal */ | ||||||
|  | .modal-backdrop { | ||||||
|  |     position: fixed; | ||||||
|  |     top: 0; | ||||||
|  |     left: 0; | ||||||
|  |     right: 0; | ||||||
|  |     bottom: 0; | ||||||
|  |     background: rgba(0, 0, 0, 0.5); | ||||||
|  |     backdrop-filter: blur(4px); | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     justify-content: center; | ||||||
|  |     z-index: 1000; | ||||||
|  |     padding: 2rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .create-calendar-modal { | ||||||
|  |     background: white; | ||||||
|  |     border-radius: 12px; | ||||||
|  |     box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); | ||||||
|  |     max-width: 500px; | ||||||
|  |     width: 100%; | ||||||
|  |     max-height: 90vh; | ||||||
|  |     overflow-y: auto; | ||||||
|  |     animation: modalSlideIn 0.3s ease; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes modalSlideIn { | ||||||
|  |     from { | ||||||
|  |         opacity: 0; | ||||||
|  |         transform: scale(0.9) translateY(-20px); | ||||||
|  |     } | ||||||
|  |     to { | ||||||
|  |         opacity: 1; | ||||||
|  |         transform: scale(1) translateY(0); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .create-calendar-modal .modal-header { | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     justify-content: space-between; | ||||||
|  |     padding: 2rem 2rem 1rem; | ||||||
|  |     border-bottom: 1px solid #e9ecef; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .create-calendar-modal .modal-header h2 { | ||||||
|  |     margin: 0; | ||||||
|  |     color: #495057; | ||||||
|  |     font-size: 1.5rem; | ||||||
|  |     font-weight: 600; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .close-button { | ||||||
|  |     background: none; | ||||||
|  |     border: none; | ||||||
|  |     font-size: 1.5rem; | ||||||
|  |     color: #6c757d; | ||||||
|  |     cursor: pointer; | ||||||
|  |     padding: 0.25rem; | ||||||
|  |     line-height: 1; | ||||||
|  |     border-radius: 4px; | ||||||
|  |     transition: all 0.2s ease; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .close-button:hover { | ||||||
|  |     color: #495057; | ||||||
|  |     background: #f8f9fa; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .create-calendar-modal .modal-body { | ||||||
|  |     padding: 1.5rem 2rem 2rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .form-group { | ||||||
|  |     margin-bottom: 1.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .form-group label { | ||||||
|  |     display: block; | ||||||
|  |     margin-bottom: 0.5rem; | ||||||
|  |     color: #495057; | ||||||
|  |     font-weight: 500; | ||||||
|  |     font-size: 0.9rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .form-group input, | ||||||
|  | .form-group textarea { | ||||||
|  |     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; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .form-group input:focus, | ||||||
|  | .form-group textarea:focus { | ||||||
|  |     outline: none; | ||||||
|  |     border-color: #667eea; | ||||||
|  |     box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .form-group input:disabled, | ||||||
|  | .form-group textarea:disabled { | ||||||
|  |     background-color: #f8f9fa; | ||||||
|  |     color: #6c757d; | ||||||
|  |     cursor: not-allowed; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .color-grid { | ||||||
|  |     display: grid; | ||||||
|  |     grid-template-columns: repeat(4, 1fr); | ||||||
|  |     gap: 0.75rem; | ||||||
|  |     margin: 0.75rem 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .color-option { | ||||||
|  |     width: 40px; | ||||||
|  |     height: 40px; | ||||||
|  |     border: 3px solid transparent; | ||||||
|  |     border-radius: 8px; | ||||||
|  |     cursor: pointer; | ||||||
|  |     transition: all 0.2s ease; | ||||||
|  |     position: relative; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .color-option:hover { | ||||||
|  |     transform: scale(1.1); | ||||||
|  |     box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .color-option.selected { | ||||||
|  |     border-color: #495057; | ||||||
|  |     transform: scale(1.1); | ||||||
|  |     box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .color-option.selected::after { | ||||||
|  |     content: '✓'; | ||||||
|  |     position: absolute; | ||||||
|  |     top: 50%; | ||||||
|  |     left: 50%; | ||||||
|  |     transform: translate(-50%, -50%); | ||||||
|  |     color: white; | ||||||
|  |     font-weight: bold; | ||||||
|  |     text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .color-help-text { | ||||||
|  |     font-size: 0.8rem; | ||||||
|  |     color: #6c757d; | ||||||
|  |     margin-top: 0.5rem; | ||||||
|  |     margin-bottom: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .modal-actions { | ||||||
|  |     display: flex; | ||||||
|  |     justify-content: flex-end; | ||||||
|  |     gap: 1rem; | ||||||
|  |     margin-top: 2rem; | ||||||
|  |     padding-top: 1.5rem; | ||||||
|  |     border-top: 1px solid #e9ecef; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .cancel-button, | ||||||
|  | .create-button { | ||||||
|  |     padding: 0.75rem 1.5rem; | ||||||
|  |     border-radius: 8px; | ||||||
|  |     font-size: 1rem; | ||||||
|  |     font-weight: 500; | ||||||
|  |     cursor: pointer; | ||||||
|  |     transition: all 0.2s ease; | ||||||
|  |     border: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .cancel-button { | ||||||
|  |     background: #f8f9fa; | ||||||
|  |     color: #6c757d; | ||||||
|  |     border: 1px solid #ced4da; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .cancel-button:hover:not(:disabled) { | ||||||
|  |     background: #e9ecef; | ||||||
|  |     color: #495057; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .create-button { | ||||||
|  |     background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||||
|  |     color: white; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .create-button:hover:not(:disabled) { | ||||||
|  |     transform: translateY(-1px); | ||||||
|  |     box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .cancel-button:disabled, | ||||||
|  | .create-button:disabled { | ||||||
|  |     opacity: 0.6; | ||||||
|  |     cursor: not-allowed; | ||||||
|  |     transform: none !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .error-message { | ||||||
|  |     background: #f8d7da; | ||||||
|  |     color: #721c24; | ||||||
|  |     padding: 0.75rem 1rem; | ||||||
|  |     border: 1px solid #f5c6cb; | ||||||
|  |     border-radius: 6px; | ||||||
|  |     margin-bottom: 1rem; | ||||||
|  |     font-size: 0.9rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Mobile adjustments for create calendar modal */ | ||||||
|  | @media (max-width: 768px) { | ||||||
|  |     .modal-backdrop { | ||||||
|  |         padding: 1rem; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     .create-calendar-modal { | ||||||
|  |         max-height: 95vh; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     .create-calendar-modal .modal-header, | ||||||
|  |     .create-calendar-modal .modal-body { | ||||||
|  |         padding-left: 1.5rem; | ||||||
|  |         padding-right: 1.5rem; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     .color-grid { | ||||||
|  |         grid-template-columns: repeat(4, 1fr); | ||||||
|  |         gap: 0.5rem; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     .color-option { | ||||||
|  |         width: 35px; | ||||||
|  |         height: 35px; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     .modal-actions { | ||||||
|  |         flex-direction: column; | ||||||
|  |         gap: 0.75rem; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     .cancel-button, | ||||||
|  |     .create-button { | ||||||
|  |         width: 100%; | ||||||
|  |         text-align: center; | ||||||
|  |     } | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user