Compare commits
	
		
			5 Commits
		
	
	
		
			5b0e84121b
			...
			d945c46e5a
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | d945c46e5a | ||
|   | 1c4857ccad | ||
|   | 01a21cb869 | ||
|   | b1b8e1e580 | ||
|   | a3d1612dac | 
| @@ -6,10 +6,14 @@ dist = "dist" | |||||||
| BACKEND_API_URL = "http://localhost:3000/api" | BACKEND_API_URL = "http://localhost:3000/api" | ||||||
|  |  | ||||||
| [watch] | [watch] | ||||||
| watch = ["src", "Cargo.toml"] | watch = ["src", "Cargo.toml", "styles.css", "index.html"] | ||||||
| ignore = ["backend/"] | ignore = ["backend/"] | ||||||
|  |  | ||||||
| [serve] | [serve] | ||||||
| address = "127.0.0.1" | address = "127.0.0.1" | ||||||
| port = 8080 | port = 8080 | ||||||
| open = false | open = false | ||||||
|  |  | ||||||
|  | [[copy]] | ||||||
|  | from = "styles.css" | ||||||
|  | to = "dist/" | ||||||
							
								
								
									
										
											BIN
										
									
								
								backend/calendar.db
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								backend/calendar.db
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| @@ -53,6 +53,9 @@ pub struct CalendarEvent { | |||||||
|     /// All-day event flag |     /// All-day event flag | ||||||
|     pub all_day: bool, |     pub all_day: bool, | ||||||
|      |      | ||||||
|  |     /// Reminders/alarms for this event | ||||||
|  |     pub reminders: Vec<EventReminder>, | ||||||
|  |      | ||||||
|     /// ETag from CalDAV server for conflict detection |     /// ETag from CalDAV server for conflict detection | ||||||
|     pub etag: Option<String>, |     pub etag: Option<String>, | ||||||
|      |      | ||||||
| @@ -88,6 +91,27 @@ impl Default for EventClass { | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /// Event reminder/alarm information | ||||||
|  | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||||
|  | pub struct EventReminder { | ||||||
|  |     /// How long before the event to trigger the reminder (in minutes) | ||||||
|  |     pub minutes_before: i32, | ||||||
|  |      | ||||||
|  |     /// Type of reminder action | ||||||
|  |     pub action: ReminderAction, | ||||||
|  |      | ||||||
|  |     /// Optional description for the reminder | ||||||
|  |     pub description: Option<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Reminder action types | ||||||
|  | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||||
|  | pub enum ReminderAction { | ||||||
|  |     Display, | ||||||
|  |     Email, | ||||||
|  |     Audio, | ||||||
|  | } | ||||||
|  |  | ||||||
| /// CalDAV client for fetching and parsing calendar events | /// CalDAV client for fetching and parsing calendar events | ||||||
| pub struct CalDAVClient { | pub struct CalDAVClient { | ||||||
|     config: crate::config::CalDAVConfig, |     config: crate::config::CalDAVConfig, | ||||||
| @@ -168,6 +192,17 @@ impl CalDAVClient { | |||||||
|         Ok(events) |         Ok(events) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /// Fetch a single calendar event by UID from the CalDAV server | ||||||
|  |     pub async fn fetch_event_by_uid(&self, calendar_path: &str, uid: &str) -> Result<Option<CalendarEvent>, CalDAVError> { | ||||||
|  |         // First fetch all events and find the one with matching UID | ||||||
|  |         let events = self.fetch_events(calendar_path).await?; | ||||||
|  |          | ||||||
|  |         // Find event with matching UID | ||||||
|  |         let event = events.into_iter().find(|e| e.uid == uid); | ||||||
|  |          | ||||||
|  |         Ok(event) | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /// Extract calendar data sections from CalDAV XML response |     /// Extract calendar data sections from CalDAV XML response | ||||||
|     fn extract_calendar_data(&self, xml_response: &str) -> Vec<CalendarDataSection> { |     fn extract_calendar_data(&self, xml_response: &str) -> Vec<CalendarDataSection> { | ||||||
|         let mut sections = Vec::new(); |         let mut sections = Vec::new(); | ||||||
| @@ -244,8 +279,8 @@ impl CalDAVClient { | |||||||
|         let mut properties: HashMap<String, String> = HashMap::new(); |         let mut properties: HashMap<String, String> = HashMap::new(); | ||||||
|          |          | ||||||
|         // Extract all properties from the event |         // Extract all properties from the event | ||||||
|         for property in event.properties { |         for property in &event.properties { | ||||||
|             properties.insert(property.name.to_uppercase(), property.value.unwrap_or_default()); |             properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default()); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Required UID field |         // Required UID field | ||||||
| @@ -325,11 +360,82 @@ impl CalDAVClient { | |||||||
|             last_modified, |             last_modified, | ||||||
|             recurrence_rule: properties.get("RRULE").cloned(), |             recurrence_rule: properties.get("RRULE").cloned(), | ||||||
|             all_day, |             all_day, | ||||||
|  |             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 | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /// Parse VALARM components from an iCal event | ||||||
|  |     fn parse_alarms(&self, event: &ical::parser::ical::component::IcalEvent) -> Result<Vec<EventReminder>, CalDAVError> { | ||||||
|  |         let mut reminders = Vec::new(); | ||||||
|  |          | ||||||
|  |         for alarm in &event.alarms { | ||||||
|  |             if let Ok(reminder) = self.parse_single_alarm(alarm) { | ||||||
|  |                 reminders.push(reminder); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         Ok(reminders) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Parse a single VALARM component into an EventReminder | ||||||
|  |     fn parse_single_alarm(&self, alarm: &ical::parser::ical::component::IcalAlarm) -> Result<EventReminder, CalDAVError> { | ||||||
|  |         let mut properties: HashMap<String, String> = HashMap::new(); | ||||||
|  |          | ||||||
|  |         // Extract all properties from the alarm | ||||||
|  |         for property in &alarm.properties { | ||||||
|  |             properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default()); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // Parse ACTION (required) | ||||||
|  |         let action = match properties.get("ACTION").map(|s| s.to_uppercase()) { | ||||||
|  |             Some(ref action_str) if action_str == "DISPLAY" => ReminderAction::Display, | ||||||
|  |             Some(ref action_str) if action_str == "EMAIL" => ReminderAction::Email, | ||||||
|  |             Some(ref action_str) if action_str == "AUDIO" => ReminderAction::Audio, | ||||||
|  |             _ => ReminderAction::Display, // Default | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         // Parse TRIGGER (required) | ||||||
|  |         let minutes_before = if let Some(trigger) = properties.get("TRIGGER") { | ||||||
|  |             self.parse_trigger_duration(trigger).unwrap_or(15) // Default 15 minutes | ||||||
|  |         } else { | ||||||
|  |             15 // Default 15 minutes | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         // Get description | ||||||
|  |         let description = properties.get("DESCRIPTION").cloned(); | ||||||
|  |          | ||||||
|  |         Ok(EventReminder { | ||||||
|  |             minutes_before, | ||||||
|  |             action, | ||||||
|  |             description, | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Parse a TRIGGER duration string into minutes before event | ||||||
|  |     fn parse_trigger_duration(&self, trigger: &str) -> Option<i32> { | ||||||
|  |         // Basic parsing of ISO 8601 duration or relative time | ||||||
|  |         // Examples: "-PT15M" (15 minutes before), "-P1D" (1 day before) | ||||||
|  |          | ||||||
|  |         if trigger.starts_with("-PT") && trigger.ends_with("M") { | ||||||
|  |             // Parse "-PT15M" format (minutes) | ||||||
|  |             let minutes_str = &trigger[3..trigger.len()-1]; | ||||||
|  |             minutes_str.parse::<i32>().ok() | ||||||
|  |         } else if trigger.starts_with("-PT") && trigger.ends_with("H") { | ||||||
|  |             // Parse "-PT1H" format (hours) | ||||||
|  |             let hours_str = &trigger[3..trigger.len()-1]; | ||||||
|  |             hours_str.parse::<i32>().ok().map(|h| h * 60) | ||||||
|  |         } else if trigger.starts_with("-P") && trigger.ends_with("D") { | ||||||
|  |             // Parse "-P1D" format (days) | ||||||
|  |             let days_str = &trigger[2..trigger.len()-1]; | ||||||
|  |             days_str.parse::<i32>().ok().map(|d| d * 24 * 60) | ||||||
|  |         } else { | ||||||
|  |             // Try to parse as raw minutes | ||||||
|  |             trigger.parse::<i32>().ok().map(|m| m.abs()) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /// Discover available calendar collections on the server |     /// Discover available calendar collections on the server | ||||||
|     pub async fn discover_calendars(&self) -> Result<Vec<String>, CalDAVError> { |     pub async fn discover_calendars(&self) -> Result<Vec<String>, CalDAVError> { | ||||||
|         // First, try to discover user calendars if we have a calendar path in config |         // First, try to discover user calendars if we have a calendar path in config | ||||||
|   | |||||||
							
								
								
									
										61
									
								
								backend/src/debug_caldav.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								backend/src/debug_caldav.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | |||||||
|  | use crate::calendar::CalDAVClient; | ||||||
|  | use crate::config::CalDAVConfig; | ||||||
|  |  | ||||||
|  | pub async fn debug_caldav_fetch() -> Result<(), Box<dyn std::error::Error>> { | ||||||
|  |     let config = CalDAVConfig::from_env()?; | ||||||
|  |     let client = CalDAVClient::new(config); | ||||||
|  |      | ||||||
|  |     println!("=== DEBUG: CalDAV Fetch ==="); | ||||||
|  |      | ||||||
|  |     // Discover calendars | ||||||
|  |     let calendars = client.discover_calendars().await?; | ||||||
|  |     println!("Found {} calendars: {:?}", calendars.len(), calendars); | ||||||
|  |      | ||||||
|  |     if let Some(calendar_path) = calendars.first() { | ||||||
|  |         println!("Fetching events from: {}", calendar_path); | ||||||
|  |          | ||||||
|  |         // Make the raw REPORT request | ||||||
|  |         let report_body = r#"<?xml version="1.0" encoding="utf-8" ?> | ||||||
|  | <c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"> | ||||||
|  |     <d:prop> | ||||||
|  |         <d:getetag/> | ||||||
|  |         <c:calendar-data/> | ||||||
|  |     </d:prop> | ||||||
|  |     <c:filter> | ||||||
|  |         <c:comp-filter name="VCALENDAR"> | ||||||
|  |             <c:comp-filter name="VEVENT"/> | ||||||
|  |         </c:comp-filter> | ||||||
|  |     </c:filter> | ||||||
|  | </c:calendar-query>"#; | ||||||
|  |  | ||||||
|  |         let url = format!("{}{}", client.config.server_url.trim_end_matches('/'), calendar_path); | ||||||
|  |         println!("Request URL: {}", url); | ||||||
|  |          | ||||||
|  |         let response = client.http_client | ||||||
|  |             .request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url) | ||||||
|  |             .header("Authorization", format!("Basic {}", client.config.get_basic_auth())) | ||||||
|  |             .header("Content-Type", "application/xml") | ||||||
|  |             .header("Depth", "1") | ||||||
|  |             .header("User-Agent", "calendar-app/0.1.0") | ||||||
|  |             .body(report_body) | ||||||
|  |             .send() | ||||||
|  |             .await?; | ||||||
|  |          | ||||||
|  |         println!("Response status: {}", response.status()); | ||||||
|  |         let body = response.text().await?; | ||||||
|  |         println!("Response body length: {}", body.len()); | ||||||
|  |         println!("First 500 chars of response: {}", &body[..std::cmp::min(500, body.len())]); | ||||||
|  |          | ||||||
|  |         // Try to parse it | ||||||
|  |         let events = client.parse_calendar_response(&body)?; | ||||||
|  |         println!("Parsed {} events", events.len()); | ||||||
|  |          | ||||||
|  |         for (i, event) in events.iter().enumerate() { | ||||||
|  |             println!("Event {}: {}", i+1, event.summary.as_deref().unwrap_or("No title")); | ||||||
|  |             println!("  Start: {}", event.start); | ||||||
|  |             println!("  UID: {}", event.uid); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     Ok(()) | ||||||
|  | } | ||||||
| @@ -1,5 +1,5 @@ | |||||||
| use axum::{ | use axum::{ | ||||||
|     extract::{State, Query}, |     extract::{State, Query, Path}, | ||||||
|     http::HeaderMap, |     http::HeaderMap, | ||||||
|     response::Json, |     response::Json, | ||||||
| }; | }; | ||||||
| @@ -73,6 +73,52 @@ pub async fn get_calendar_events( | |||||||
|     Ok(Json(filtered_events)) |     Ok(Json(filtered_events)) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | pub async fn refresh_event( | ||||||
|  |     State(_state): State<Arc<AppState>>, | ||||||
|  |     Path(uid): Path<String>, | ||||||
|  |     headers: HeaderMap, | ||||||
|  | ) -> Result<Json<Option<CalendarEvent>>, ApiError> { | ||||||
|  |     // Verify authentication (extract token from Authorization header) | ||||||
|  |     let _token = if let Some(auth_header) = headers.get("authorization") { | ||||||
|  |         let auth_str = auth_header | ||||||
|  |             .to_str() | ||||||
|  |             .map_err(|_| ApiError::Unauthorized("Invalid authorization header".to_string()))?; | ||||||
|  |          | ||||||
|  |         if auth_str.starts_with("Bearer ") { | ||||||
|  |             auth_str.strip_prefix("Bearer ").unwrap().to_string() | ||||||
|  |         } else { | ||||||
|  |             return Err(ApiError::Unauthorized("Invalid authorization format".to_string())); | ||||||
|  |         } | ||||||
|  |     } else { | ||||||
|  |         return Err(ApiError::Unauthorized("Missing authorization header".to_string())); | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     // TODO: Validate JWT token here | ||||||
|  |      | ||||||
|  |     // Load CalDAV configuration | ||||||
|  |     let config = CalDAVConfig::from_env() | ||||||
|  |         .map_err(|e| ApiError::Internal(format!("Failed to load CalDAV config: {}", e)))?; | ||||||
|  |      | ||||||
|  |     let client = CalDAVClient::new(config); | ||||||
|  |      | ||||||
|  |     // Discover calendars if needed | ||||||
|  |     let calendar_paths = client.discover_calendars() | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; | ||||||
|  |      | ||||||
|  |     if calendar_paths.is_empty() { | ||||||
|  |         return Ok(Json(None)); // No calendars found | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // Fetch the specific event by UID from the first calendar | ||||||
|  |     let calendar_path = &calendar_paths[0]; | ||||||
|  |     let event = client.fetch_event_by_uid(calendar_path, &uid) | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?; | ||||||
|  |      | ||||||
|  |     Ok(Json(event)) | ||||||
|  | } | ||||||
|  |  | ||||||
| pub async fn register( | pub async fn register( | ||||||
|     State(state): State<Arc<AppState>>, |     State(state): State<Arc<AppState>>, | ||||||
|     Json(request): Json<RegisterRequest>, |     Json(request): Json<RegisterRequest>, | ||||||
|   | |||||||
| @@ -53,6 +53,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/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)) | ||||||
|         .layer( |         .layer( | ||||||
|             CorsLayer::new() |             CorsLayer::new() | ||||||
|                 .allow_origin(Any) |                 .allow_origin(Any) | ||||||
|   | |||||||
							
								
								
									
										431
									
								
								index.html
									
									
									
									
									
								
							
							
						
						
									
										431
									
								
								index.html
									
									
									
									
									
								
							| @@ -4,436 +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"> | ||||||
|     <style> |     <link rel="stylesheet" href="styles.css"> | ||||||
|         * { |  | ||||||
|             margin: 0; |  | ||||||
|             padding: 0; |  | ||||||
|             box-sizing: border-box; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         body { |  | ||||||
|             font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |  | ||||||
|             background-color: #f8f9fa; |  | ||||||
|             color: #333; |  | ||||||
|             line-height: 1.6; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .app { |  | ||||||
|             min-height: 100vh; |  | ||||||
|             display: flex; |  | ||||||
|             flex-direction: column; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .app-header { |  | ||||||
|             background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |  | ||||||
|             color: white; |  | ||||||
|             padding: 1rem 2rem; |  | ||||||
|             display: flex; |  | ||||||
|             justify-content: space-between; |  | ||||||
|             align-items: center; |  | ||||||
|             box-shadow: 0 2px 4px rgba(0,0,0,0.1); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .app-header h1 { |  | ||||||
|             margin: 0; |  | ||||||
|             font-size: 1.8rem; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .app-header nav { |  | ||||||
|             display: flex; |  | ||||||
|             gap: 1rem; |  | ||||||
|             align-items: center; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .app-header nav a { |  | ||||||
|             color: white; |  | ||||||
|             text-decoration: none; |  | ||||||
|             padding: 0.5rem 1rem; |  | ||||||
|             border-radius: 4px; |  | ||||||
|             transition: background-color 0.2s; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .app-header nav a:hover { |  | ||||||
|             background-color: rgba(255,255,255,0.2); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .app-main { |  | ||||||
|             flex: 1; |  | ||||||
|             padding: 2rem; |  | ||||||
|             max-width: 1200px; |  | ||||||
|             margin: 0 auto; |  | ||||||
|             width: 100%; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         /* Authentication Forms */ |  | ||||||
|         .login-container, .register-container { |  | ||||||
|             display: flex; |  | ||||||
|             justify-content: center; |  | ||||||
|             align-items: center; |  | ||||||
|             min-height: 60vh; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .login-form, .register-form { |  | ||||||
|             background: white; |  | ||||||
|             padding: 2rem; |  | ||||||
|             border-radius: 8px; |  | ||||||
|             box-shadow: 0 4px 6px rgba(0,0,0,0.1); |  | ||||||
|             width: 100%; |  | ||||||
|             max-width: 400px; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .login-form h2, .register-form h2 { |  | ||||||
|             text-align: center; |  | ||||||
|             margin-bottom: 2rem; |  | ||||||
|             color: #333; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .form-group { |  | ||||||
|             margin-bottom: 1.5rem; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .form-group label { |  | ||||||
|             display: block; |  | ||||||
|             margin-bottom: 0.5rem; |  | ||||||
|             font-weight: 500; |  | ||||||
|             color: #555; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .form-group input { |  | ||||||
|             width: 100%; |  | ||||||
|             padding: 0.75rem; |  | ||||||
|             border: 1px solid #ddd; |  | ||||||
|             border-radius: 4px; |  | ||||||
|             font-size: 1rem; |  | ||||||
|             transition: border-color 0.2s; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .form-group input:focus { |  | ||||||
|             outline: none; |  | ||||||
|             border-color: #667eea; |  | ||||||
|             box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .form-group input:disabled { |  | ||||||
|             background-color: #f5f5f5; |  | ||||||
|             cursor: not-allowed; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .login-button, .register-button { |  | ||||||
|             width: 100%; |  | ||||||
|             padding: 0.75rem; |  | ||||||
|             background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |  | ||||||
|             color: white; |  | ||||||
|             border: none; |  | ||||||
|             border-radius: 4px; |  | ||||||
|             font-size: 1rem; |  | ||||||
|             font-weight: 500; |  | ||||||
|             cursor: pointer; |  | ||||||
|             transition: transform 0.2s, box-shadow 0.2s; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .login-button:hover, .register-button:hover { |  | ||||||
|             transform: translateY(-1px); |  | ||||||
|             box-shadow: 0 4px 8px rgba(0,0,0,0.2); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .login-button:disabled, .register-button:disabled { |  | ||||||
|             background: #ccc; |  | ||||||
|             transform: none; |  | ||||||
|             cursor: not-allowed; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .error-message { |  | ||||||
|             background-color: #f8d7da; |  | ||||||
|             color: #721c24; |  | ||||||
|             padding: 0.75rem; |  | ||||||
|             border-radius: 4px; |  | ||||||
|             margin-bottom: 1rem; |  | ||||||
|             border: 1px solid #f5c6cb; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .auth-links { |  | ||||||
|             text-align: center; |  | ||||||
|             margin-top: 2rem; |  | ||||||
|             color: #666; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .auth-links a { |  | ||||||
|             color: #667eea; |  | ||||||
|             text-decoration: none; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .auth-links a:hover { |  | ||||||
|             text-decoration: underline; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .logout-button { |  | ||||||
|             background: rgba(255,255,255,0.2); |  | ||||||
|             border: 1px solid rgba(255,255,255,0.3); |  | ||||||
|             color: white; |  | ||||||
|             padding: 0.5rem 1rem; |  | ||||||
|             border-radius: 4px; |  | ||||||
|             cursor: pointer; |  | ||||||
|             transition: background-color 0.2s; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .logout-button:hover { |  | ||||||
|             background: rgba(255,255,255,0.3); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         /* Calendar View */ |  | ||||||
|         .calendar-view { |  | ||||||
|             height: calc(100vh - 140px); /* Full height minus header and padding */ |  | ||||||
|             display: flex; |  | ||||||
|             flex-direction: column; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .calendar-loading, .calendar-error { |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             justify-content: center; |  | ||||||
|             height: 100%; |  | ||||||
|             background: white; |  | ||||||
|             border-radius: 12px; |  | ||||||
|             box-shadow: 0 4px 16px rgba(0,0,0,0.1); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .calendar-loading p { |  | ||||||
|             font-size: 1.2rem; |  | ||||||
|             color: #666; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .calendar-error p { |  | ||||||
|             font-size: 1.2rem; |  | ||||||
|             color: #d32f2f; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         /* Calendar Component */ |  | ||||||
|         .calendar { |  | ||||||
|             background: white; |  | ||||||
|             border-radius: 12px; |  | ||||||
|             box-shadow: 0 4px 16px rgba(0,0,0,0.1); |  | ||||||
|             overflow: hidden; |  | ||||||
|             flex: 1; |  | ||||||
|             display: flex; |  | ||||||
|             flex-direction: column; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .calendar-header { |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             justify-content: space-between; |  | ||||||
|             padding: 1.5rem 2rem; |  | ||||||
|             background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |  | ||||||
|             color: white; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .month-year { |  | ||||||
|             font-size: 1.8rem; |  | ||||||
|             font-weight: 600; |  | ||||||
|             margin: 0; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .nav-button { |  | ||||||
|             background: rgba(255,255,255,0.2); |  | ||||||
|             border: none; |  | ||||||
|             color: white; |  | ||||||
|             font-size: 1.5rem; |  | ||||||
|             font-weight: bold; |  | ||||||
|             width: 40px; |  | ||||||
|             height: 40px; |  | ||||||
|             border-radius: 50%; |  | ||||||
|             cursor: pointer; |  | ||||||
|             display: flex; |  | ||||||
|             align-items: center; |  | ||||||
|             justify-content: center; |  | ||||||
|             transition: background-color 0.2s; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .nav-button:hover { |  | ||||||
|             background: rgba(255,255,255,0.3); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .calendar-grid { |  | ||||||
|             display: grid; |  | ||||||
|             grid-template-columns: repeat(7, 1fr); |  | ||||||
|             flex: 1; |  | ||||||
|             background: white; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .weekday-header { |  | ||||||
|             background: #f8f9fa; |  | ||||||
|             padding: 1rem; |  | ||||||
|             text-align: center; |  | ||||||
|             font-weight: 600; |  | ||||||
|             color: #666; |  | ||||||
|             border-bottom: 1px solid #e9ecef; |  | ||||||
|             font-size: 0.9rem; |  | ||||||
|             text-transform: uppercase; |  | ||||||
|             letter-spacing: 0.5px; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .calendar-day { |  | ||||||
|             border: 1px solid #f0f0f0; |  | ||||||
|             padding: 0.75rem; |  | ||||||
|             min-height: 100px; |  | ||||||
|             display: flex; |  | ||||||
|             flex-direction: column; |  | ||||||
|             cursor: pointer; |  | ||||||
|             transition: background-color 0.2s; |  | ||||||
|             position: relative; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .calendar-day:hover { |  | ||||||
|             background-color: #f8f9ff; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .calendar-day.current-month { |  | ||||||
|             background: white; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .calendar-day.prev-month, |  | ||||||
|         .calendar-day.next-month { |  | ||||||
|             background: #fafafa; |  | ||||||
|             color: #ccc; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .calendar-day.today { |  | ||||||
|             background: #e3f2fd; |  | ||||||
|             border: 2px solid #2196f3; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .calendar-day.has-events { |  | ||||||
|             background: #fff3e0; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .calendar-day.today.has-events { |  | ||||||
|             background: #e1f5fe; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .day-number { |  | ||||||
|             font-weight: 600; |  | ||||||
|             font-size: 1.1rem; |  | ||||||
|             margin-bottom: 0.5rem; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .calendar-day.today .day-number { |  | ||||||
|             color: #1976d2; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .event-indicators { |  | ||||||
|             flex: 1; |  | ||||||
|             display: flex; |  | ||||||
|             flex-direction: column; |  | ||||||
|             gap: 2px; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .event-box { |  | ||||||
|             background: #2196f3; |  | ||||||
|             color: white; |  | ||||||
|             padding: 2px 4px; |  | ||||||
|             border-radius: 3px; |  | ||||||
|             font-size: 0.7rem; |  | ||||||
|             line-height: 1.2; |  | ||||||
|             overflow: hidden; |  | ||||||
|             text-overflow: ellipsis; |  | ||||||
|             white-space: nowrap; |  | ||||||
|             cursor: pointer; |  | ||||||
|             transition: background-color 0.2s; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .event-box:hover { |  | ||||||
|             background: #1976d2; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .event-dot { |  | ||||||
|             background: #ff9800; |  | ||||||
|             height: 6px; |  | ||||||
|             border-radius: 3px; |  | ||||||
|             margin-bottom: 1px; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         .more-events { |  | ||||||
|             font-size: 0.7rem; |  | ||||||
|             color: #666; |  | ||||||
|             margin-top: 2px; |  | ||||||
|             font-weight: 500; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         /* Responsive Design */ |  | ||||||
|         @media (max-width: 768px) { |  | ||||||
|             .calendar-header { |  | ||||||
|                 padding: 1rem; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             .month-year { |  | ||||||
|                 font-size: 1.4rem; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             .nav-button { |  | ||||||
|                 width: 35px; |  | ||||||
|                 height: 35px; |  | ||||||
|                 font-size: 1.2rem; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             .weekday-header { |  | ||||||
|                 padding: 0.5rem; |  | ||||||
|                 font-size: 0.8rem; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             .calendar-day { |  | ||||||
|                 min-height: 70px; |  | ||||||
|                 padding: 0.5rem; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             .day-number { |  | ||||||
|                 font-size: 1rem; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             .app-main { |  | ||||||
|                 padding: 1rem; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             .calendar-view { |  | ||||||
|                 height: calc(100vh - 120px); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         @media (max-width: 480px) { |  | ||||||
|             .calendar-day { |  | ||||||
|                 min-height: 60px; |  | ||||||
|                 padding: 0.25rem; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             .weekday-header { |  | ||||||
|                 padding: 0.5rem 0.25rem; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             .day-number { |  | ||||||
|                 font-size: 0.9rem; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         @media (max-width: 768px) { |  | ||||||
|             .app-header { |  | ||||||
|                 flex-direction: column; |  | ||||||
|                 text-align: center; |  | ||||||
|                 padding: 1rem; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             .app-header nav { |  | ||||||
|                 margin-top: 1rem; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             .app-main { |  | ||||||
|                 padding: 1rem; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             .login-form, .register-form { |  | ||||||
|                 padding: 1.5rem; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     </style> |  | ||||||
| </head> | </head> | ||||||
| <body></body> | <body></body> | ||||||
| </html> | </html> | ||||||
							
								
								
									
										61
									
								
								src/app.rs
									
									
									
									
									
								
							
							
						
						
									
										61
									
								
								src/app.rs
									
									
									
									
									
								
							| @@ -2,7 +2,7 @@ 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, Register, Calendar}; | use crate::components::{Login, Register, Calendar}; | ||||||
| use crate::services::CalendarService; | use crate::services::{CalendarService, CalendarEvent}; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| use chrono::{Local, NaiveDate, Datelike}; | use chrono::{Local, NaiveDate, Datelike}; | ||||||
|  |  | ||||||
| @@ -107,9 +107,10 @@ pub fn App() -> Html { | |||||||
|  |  | ||||||
| #[function_component] | #[function_component] | ||||||
| fn CalendarView() -> Html { | fn CalendarView() -> Html { | ||||||
|     let events = use_state(|| HashMap::<NaiveDate, Vec<String>>::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>); | ||||||
|  |     let refreshing_event = use_state(|| None::<String>); | ||||||
|      |      | ||||||
|     // Get current auth token |     // Get current auth token | ||||||
|     let auth_token: Option<String> = LocalStorage::get("auth_token").ok(); |     let auth_token: Option<String> = LocalStorage::get("auth_token").ok(); | ||||||
| @@ -118,6 +119,57 @@ fn CalendarView() -> Html { | |||||||
|     let current_year = today.year(); |     let current_year = today.year(); | ||||||
|     let current_month = today.month(); |     let current_month = today.month(); | ||||||
|      |      | ||||||
|  |     // Event refresh callback | ||||||
|  |     let on_event_click = { | ||||||
|  |         let events = events.clone(); | ||||||
|  |         let refreshing_event = refreshing_event.clone(); | ||||||
|  |         let auth_token = auth_token.clone(); | ||||||
|  |          | ||||||
|  |         Callback::from(move |event: CalendarEvent| { | ||||||
|  |             if let Some(token) = auth_token.clone() { | ||||||
|  |                 let events = events.clone(); | ||||||
|  |                 let refreshing_event = refreshing_event.clone(); | ||||||
|  |                 let uid = event.uid.clone(); | ||||||
|  |                  | ||||||
|  |                 refreshing_event.set(Some(uid.clone())); | ||||||
|  |                  | ||||||
|  |                 wasm_bindgen_futures::spawn_local(async move { | ||||||
|  |                     let calendar_service = CalendarService::new(); | ||||||
|  |                      | ||||||
|  |                     match calendar_service.refresh_event(&token, &uid).await { | ||||||
|  |                         Ok(Some(refreshed_event)) => { | ||||||
|  |                             // Update the event in the existing events map | ||||||
|  |                             let mut updated_events = (*events).clone(); | ||||||
|  |                             for (_, day_events) in updated_events.iter_mut() { | ||||||
|  |                                 for existing_event in day_events.iter_mut() { | ||||||
|  |                                     if existing_event.uid == uid { | ||||||
|  |                                         *existing_event = refreshed_event.clone(); | ||||||
|  |                                         break; | ||||||
|  |                                     } | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                             events.set(updated_events); | ||||||
|  |                         } | ||||||
|  |                         Ok(None) => { | ||||||
|  |                             // Event was deleted, remove it from the map | ||||||
|  |                             let mut updated_events = (*events).clone(); | ||||||
|  |                             for (_, day_events) in updated_events.iter_mut() { | ||||||
|  |                                 day_events.retain(|e| e.uid != uid); | ||||||
|  |                             } | ||||||
|  |                             events.set(updated_events); | ||||||
|  |                         } | ||||||
|  |                         Err(_err) => { | ||||||
|  |                             // Log error but don't show it to user - keep using cached event | ||||||
|  |                             // Silently fall back to cached event data | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     refreshing_event.set(None); | ||||||
|  |                 }); | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |      | ||||||
|     // Fetch events when component mounts |     // Fetch events when component mounts | ||||||
|     { |     { | ||||||
|         let events = events.clone(); |         let events = events.clone(); | ||||||
| @@ -165,15 +217,16 @@ fn CalendarView() -> Html { | |||||||
|                         </div> |                         </div> | ||||||
|                     } |                     } | ||||||
|                 } else if let Some(err) = (*error).clone() { |                 } else if let Some(err) = (*error).clone() { | ||||||
|  |                     let dummy_callback = Callback::from(|_: CalendarEvent| {}); | ||||||
|                     html! { |                     html! { | ||||||
|                         <div class="calendar-error"> |                         <div class="calendar-error"> | ||||||
|                             <p>{format!("Error: {}", err)}</p> |                             <p>{format!("Error: {}", err)}</p> | ||||||
|                             <Calendar events={HashMap::new()} /> |                             <Calendar events={HashMap::new()} on_event_click={dummy_callback} refreshing_event_uid={(*refreshing_event).clone()} /> | ||||||
|                         </div> |                         </div> | ||||||
|                     } |                     } | ||||||
|                 } else { |                 } else { | ||||||
|                     html! { |                     html! { | ||||||
|                         <Calendar events={(*events).clone()} /> |                         <Calendar events={(*events).clone()} on_event_click={on_event_click} refreshing_event_uid={(*refreshing_event).clone()} /> | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|   | |||||||
| @@ -1,17 +1,24 @@ | |||||||
| 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::components::EventModal; | ||||||
|  |  | ||||||
| #[derive(Properties, PartialEq)] | #[derive(Properties, PartialEq)] | ||||||
| pub struct CalendarProps { | pub struct CalendarProps { | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub events: HashMap<NaiveDate, Vec<String>>, |     pub events: HashMap<NaiveDate, Vec<CalendarEvent>>, | ||||||
|  |     pub on_event_click: Callback<CalendarEvent>, | ||||||
|  |     #[prop_or_default] | ||||||
|  |     pub refreshing_event_uid: Option<String>, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[function_component] | #[function_component] | ||||||
| pub fn Calendar(props: &CalendarProps) -> Html { | pub fn Calendar(props: &CalendarProps) -> Html { | ||||||
|     let today = Local::now().date_naive(); |     let today = Local::now().date_naive(); | ||||||
|     let current_month = use_state(|| today); |     let current_month = use_state(|| today); | ||||||
|  |     let selected_day = use_state(|| today); | ||||||
|  |     let selected_event = use_state(|| None::<CalendarEvent>); | ||||||
|      |      | ||||||
|     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); | ||||||
| @@ -71,18 +78,27 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                     (1..=days_in_month).map(|day| { |                     (1..=days_in_month).map(|day| { | ||||||
|                         let date = current_month.with_day(day).unwrap(); |                         let date = current_month.with_day(day).unwrap(); | ||||||
|                         let is_today = date == today; |                         let is_today = date == today; | ||||||
|  |                         let is_selected = date == *selected_day; | ||||||
|                         let events = props.events.get(&date).cloned().unwrap_or_default(); |                         let events = props.events.get(&date).cloned().unwrap_or_default(); | ||||||
|                          |                          | ||||||
|                         let mut classes = vec!["calendar-day", "current-month"]; |                         let mut classes = vec!["calendar-day", "current-month"]; | ||||||
|                         if is_today { |                         if is_today { | ||||||
|                             classes.push("today"); |                             classes.push("today"); | ||||||
|                         } |                         } | ||||||
|  |                         if is_selected { | ||||||
|  |                             classes.push("selected"); | ||||||
|  |                         } | ||||||
|                         if !events.is_empty() { |                         if !events.is_empty() { | ||||||
|                             classes.push("has-events"); |                             classes.push("has-events"); | ||||||
|                         } |                         } | ||||||
|  |  | ||||||
|  |                         let selected_day_clone = selected_day.clone(); | ||||||
|  |                         let on_click = Callback::from(move |_| { | ||||||
|  |                             selected_day_clone.set(date); | ||||||
|  |                         }); | ||||||
|  |                          | ||||||
|                         html! { |                         html! { | ||||||
|                             <div class={classes!(classes)}> |                             <div class={classes!(classes)} onclick={on_click}> | ||||||
|                                 <div class="day-number">{day}</div> |                                 <div class="day-number">{day}</div> | ||||||
|                                 { |                                 { | ||||||
|                                     if !events.is_empty() { |                                     if !events.is_empty() { | ||||||
| @@ -90,13 +106,29 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                                             <div class="event-indicators"> |                                             <div class="event-indicators"> | ||||||
|                                                 { |                                                 { | ||||||
|                                                     events.iter().take(2).map(|event| { |                                                     events.iter().take(2).map(|event| { | ||||||
|  |                                                         let event_clone = event.clone(); | ||||||
|  |                                                         let selected_event_clone = selected_event.clone(); | ||||||
|  |                                                         let on_event_click = props.on_event_click.clone(); | ||||||
|  |                                                         let event_click = Callback::from(move |e: MouseEvent| { | ||||||
|  |                                                             e.stop_propagation(); // Prevent day selection | ||||||
|  |                                                             on_event_click.emit(event_clone.clone()); | ||||||
|  |                                                             selected_event_clone.set(Some(event_clone.clone())); | ||||||
|  |                                                         }); | ||||||
|  |                                                          | ||||||
|  |                                                         let title = event.get_title(); | ||||||
|  |                                                         let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid); | ||||||
|  |                                                         let class_name = if is_refreshing { "event-box refreshing" } else { "event-box" }; | ||||||
|                                                         html! {  |                                                         html! {  | ||||||
|                                                             <div class="event-box" title={event.clone()}> |                                                             <div class={class_name}  | ||||||
|  |                                                                  title={title.clone()}  | ||||||
|  |                                                                  onclick={event_click}> | ||||||
|                                                                 { |                                                                 { | ||||||
|                                                                     if event.len() > 15 { |                                                                     if is_refreshing { | ||||||
|                                                                         format!("{}...", &event[..12]) |                                                                         "🔄 Refreshing...".to_string() | ||||||
|  |                                                                     } else if title.len() > 15 { | ||||||
|  |                                                                         format!("{}...", &title[..12]) | ||||||
|                                                                     } else { |                                                                     } else { | ||||||
|                                                                         event.clone() |                                                                         title | ||||||
|                                                                     } |                                                                     } | ||||||
|                                                                 } |                                                                 } | ||||||
|                                                             </div>  |                                                             </div>  | ||||||
| @@ -123,6 +155,17 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                  |                  | ||||||
|                 { render_next_month_days(days_from_prev_month.len(), days_in_month) } |                 { render_next_month_days(days_from_prev_month.len(), days_in_month) } | ||||||
|             </div> |             </div> | ||||||
|  |              | ||||||
|  |             // Event details modal | ||||||
|  |             <EventModal  | ||||||
|  |                 event={(*selected_event).clone()} | ||||||
|  |                 on_close={{ | ||||||
|  |                     let selected_event_clone = selected_event.clone(); | ||||||
|  |                     Callback::from(move |_| { | ||||||
|  |                         selected_event_clone.set(None); | ||||||
|  |                     }) | ||||||
|  |                 }} | ||||||
|  |             /> | ||||||
|         </div> |         </div> | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -194,3 +237,4 @@ fn get_month_name(month: u32) -> &'static str { | |||||||
|         _ => "Invalid" |         _ => "Invalid" | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										289
									
								
								src/components/event_modal.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										289
									
								
								src/components/event_modal.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,289 @@ | |||||||
|  | use yew::prelude::*; | ||||||
|  | use chrono::{DateTime, Utc}; | ||||||
|  | use crate::services::{CalendarEvent, EventReminder, ReminderAction}; | ||||||
|  |  | ||||||
|  | #[derive(Properties, PartialEq)] | ||||||
|  | pub struct EventModalProps { | ||||||
|  |     pub event: Option<CalendarEvent>, | ||||||
|  |     pub on_close: Callback<()>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[function_component] | ||||||
|  | pub fn EventModal(props: &EventModalProps) -> Html { | ||||||
|  |     let close_modal = { | ||||||
|  |         let on_close = props.on_close.clone(); | ||||||
|  |         Callback::from(move |_| { | ||||||
|  |             on_close.emit(()); | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |      | ||||||
|  |     let backdrop_click = { | ||||||
|  |         let on_close = props.on_close.clone(); | ||||||
|  |         Callback::from(move |e: MouseEvent| { | ||||||
|  |             if e.target() == e.current_target() { | ||||||
|  |                 on_close.emit(()); | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     if let Some(ref event) = props.event { | ||||||
|  |         html! { | ||||||
|  |             <div class="modal-backdrop" onclick={backdrop_click}> | ||||||
|  |                 <div class="modal-content"> | ||||||
|  |                     <div class="modal-header"> | ||||||
|  |                         <h3>{"Event Details"}</h3> | ||||||
|  |                         <button class="modal-close" onclick={close_modal}>{"×"}</button> | ||||||
|  |                     </div> | ||||||
|  |                     <div class="modal-body"> | ||||||
|  |                         <div class="event-detail"> | ||||||
|  |                             <strong>{"Title:"}</strong> | ||||||
|  |                             <span>{event.get_title()}</span> | ||||||
|  |                         </div> | ||||||
|  |                          | ||||||
|  |                         { | ||||||
|  |                             if let Some(ref description) = event.description { | ||||||
|  |                                 html! { | ||||||
|  |                                     <div class="event-detail"> | ||||||
|  |                                         <strong>{"Description:"}</strong> | ||||||
|  |                                         <span>{description}</span> | ||||||
|  |                                     </div> | ||||||
|  |                                 } | ||||||
|  |                             } else { | ||||||
|  |                                 html! {} | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                          | ||||||
|  |                         <div class="event-detail"> | ||||||
|  |                             <strong>{"Start:"}</strong> | ||||||
|  |                             <span>{format_datetime(&event.start, event.all_day)}</span> | ||||||
|  |                         </div> | ||||||
|  |                          | ||||||
|  |                         { | ||||||
|  |                             if let Some(ref end) = event.end { | ||||||
|  |                                 html! { | ||||||
|  |                                     <div class="event-detail"> | ||||||
|  |                                         <strong>{"End:"}</strong> | ||||||
|  |                                         <span>{format_datetime(end, event.all_day)}</span> | ||||||
|  |                                     </div> | ||||||
|  |                                 } | ||||||
|  |                             } else { | ||||||
|  |                                 html! {} | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                          | ||||||
|  |                         <div class="event-detail"> | ||||||
|  |                             <strong>{"All Day:"}</strong> | ||||||
|  |                             <span>{if event.all_day { "Yes" } else { "No" }}</span> | ||||||
|  |                         </div> | ||||||
|  |                          | ||||||
|  |                         { | ||||||
|  |                             if let Some(ref location) = event.location { | ||||||
|  |                                 html! { | ||||||
|  |                                     <div class="event-detail"> | ||||||
|  |                                         <strong>{"Location:"}</strong> | ||||||
|  |                                         <span>{location}</span> | ||||||
|  |                                     </div> | ||||||
|  |                                 } | ||||||
|  |                             } else { | ||||||
|  |                                 html! {} | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                          | ||||||
|  |                         <div class="event-detail"> | ||||||
|  |                             <strong>{"Status:"}</strong> | ||||||
|  |                             <span>{event.get_status_display()}</span> | ||||||
|  |                         </div> | ||||||
|  |                          | ||||||
|  |                         <div class="event-detail"> | ||||||
|  |                             <strong>{"Privacy:"}</strong> | ||||||
|  |                             <span>{event.get_class_display()}</span> | ||||||
|  |                         </div> | ||||||
|  |                          | ||||||
|  |                         <div class="event-detail"> | ||||||
|  |                             <strong>{"Priority:"}</strong> | ||||||
|  |                             <span>{event.get_priority_display()}</span> | ||||||
|  |                         </div> | ||||||
|  |                          | ||||||
|  |                         { | ||||||
|  |                             if let Some(ref organizer) = event.organizer { | ||||||
|  |                                 html! { | ||||||
|  |                                     <div class="event-detail"> | ||||||
|  |                                         <strong>{"Organizer:"}</strong> | ||||||
|  |                                         <span>{organizer}</span> | ||||||
|  |                                     </div> | ||||||
|  |                                 } | ||||||
|  |                             } else { | ||||||
|  |                                 html! {} | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                          | ||||||
|  |                         { | ||||||
|  |                             if !event.attendees.is_empty() { | ||||||
|  |                                 html! { | ||||||
|  |                                     <div class="event-detail"> | ||||||
|  |                                         <strong>{"Attendees:"}</strong> | ||||||
|  |                                         <span>{event.attendees.join(", ")}</span> | ||||||
|  |                                     </div> | ||||||
|  |                                 } | ||||||
|  |                             } else { | ||||||
|  |                                 html! {} | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                          | ||||||
|  |                         { | ||||||
|  |                             if !event.categories.is_empty() { | ||||||
|  |                                 html! { | ||||||
|  |                                     <div class="event-detail"> | ||||||
|  |                                         <strong>{"Categories:"}</strong> | ||||||
|  |                                         <span>{event.categories.join(", ")}</span> | ||||||
|  |                                     </div> | ||||||
|  |                                 } | ||||||
|  |                             } else { | ||||||
|  |                                 html! {} | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                          | ||||||
|  |                         { | ||||||
|  |                             if let Some(ref recurrence) = event.recurrence_rule { | ||||||
|  |                                 html! { | ||||||
|  |                                     <div class="event-detail"> | ||||||
|  |                                         <strong>{"Repeats:"}</strong> | ||||||
|  |                                         <span>{format_recurrence_rule(recurrence)}</span> | ||||||
|  |                                     </div> | ||||||
|  |                                 } | ||||||
|  |                             } else { | ||||||
|  |                                 html! { | ||||||
|  |                                     <div class="event-detail"> | ||||||
|  |                                         <strong>{"Repeats:"}</strong> | ||||||
|  |                                         <span>{"No"}</span> | ||||||
|  |                                     </div> | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                          | ||||||
|  |                         { | ||||||
|  |                             if !event.reminders.is_empty() { | ||||||
|  |                                 html! { | ||||||
|  |                                     <div class="event-detail"> | ||||||
|  |                                         <strong>{"Reminders:"}</strong> | ||||||
|  |                                         <span>{format_reminders(&event.reminders)}</span> | ||||||
|  |                                     </div> | ||||||
|  |                                 } | ||||||
|  |                             } else { | ||||||
|  |                                 html! { | ||||||
|  |                                     <div class="event-detail"> | ||||||
|  |                                         <strong>{"Reminders:"}</strong> | ||||||
|  |                                         <span>{"None"}</span> | ||||||
|  |                                     </div> | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                          | ||||||
|  |                         { | ||||||
|  |                             if let Some(ref created) = event.created { | ||||||
|  |                                 html! { | ||||||
|  |                                     <div class="event-detail"> | ||||||
|  |                                         <strong>{"Created:"}</strong> | ||||||
|  |                                         <span>{format_datetime(created, false)}</span> | ||||||
|  |                                     </div> | ||||||
|  |                                 } | ||||||
|  |                             } else { | ||||||
|  |                                 html! {} | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                          | ||||||
|  |                         { | ||||||
|  |                             if let Some(ref modified) = event.last_modified { | ||||||
|  |                                 html! { | ||||||
|  |                                     <div class="event-detail"> | ||||||
|  |                                         <strong>{"Last Modified:"}</strong> | ||||||
|  |                                         <span>{format_datetime(modified, false)}</span> | ||||||
|  |                                     </div> | ||||||
|  |                                 } | ||||||
|  |                             } else { | ||||||
|  |                                 html! {} | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         } | ||||||
|  |     } else { | ||||||
|  |         html! {} | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String { | ||||||
|  |     if all_day { | ||||||
|  |         dt.format("%B %d, %Y").to_string() | ||||||
|  |     } else { | ||||||
|  |         dt.format("%B %d, %Y at %I:%M %p").to_string() | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fn format_recurrence_rule(rrule: &str) -> String { | ||||||
|  |     // Basic parsing of RRULE to display user-friendly text | ||||||
|  |     if rrule.contains("FREQ=DAILY") { | ||||||
|  |         "Daily".to_string() | ||||||
|  |     } else if rrule.contains("FREQ=WEEKLY") { | ||||||
|  |         "Weekly".to_string() | ||||||
|  |     } else if rrule.contains("FREQ=MONTHLY") { | ||||||
|  |         "Monthly".to_string() | ||||||
|  |     } else if rrule.contains("FREQ=YEARLY") { | ||||||
|  |         "Yearly".to_string() | ||||||
|  |     } else { | ||||||
|  |         // Show the raw rule if we can't parse it | ||||||
|  |         format!("Custom ({})", rrule) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fn format_reminders(reminders: &[EventReminder]) -> String { | ||||||
|  |     if reminders.is_empty() { | ||||||
|  |         return "None".to_string(); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     let formatted_reminders: Vec<String> = reminders | ||||||
|  |         .iter() | ||||||
|  |         .map(|reminder| { | ||||||
|  |             let time_text = if reminder.minutes_before == 0 { | ||||||
|  |                 "At event time".to_string() | ||||||
|  |             } else if reminder.minutes_before < 60 { | ||||||
|  |                 format!("{} minutes before", reminder.minutes_before) | ||||||
|  |             } else if reminder.minutes_before == 60 { | ||||||
|  |                 "1 hour before".to_string() | ||||||
|  |             } else if reminder.minutes_before % 60 == 0 { | ||||||
|  |                 format!("{} hours before", reminder.minutes_before / 60) | ||||||
|  |             } else if reminder.minutes_before < 1440 { | ||||||
|  |                 let hours = reminder.minutes_before / 60; | ||||||
|  |                 let minutes = reminder.minutes_before % 60; | ||||||
|  |                 format!("{}h {}m before", hours, minutes) | ||||||
|  |             } else if reminder.minutes_before == 1440 { | ||||||
|  |                 "1 day before".to_string() | ||||||
|  |             } else if reminder.minutes_before % 1440 == 0 { | ||||||
|  |                 format!("{} days before", reminder.minutes_before / 1440) | ||||||
|  |             } else { | ||||||
|  |                 let days = reminder.minutes_before / 1440; | ||||||
|  |                 let remaining_minutes = reminder.minutes_before % 1440; | ||||||
|  |                 let hours = remaining_minutes / 60; | ||||||
|  |                 let minutes = remaining_minutes % 60; | ||||||
|  |                 if hours > 0 { | ||||||
|  |                     format!("{}d {}h before", days, hours) | ||||||
|  |                 } else if minutes > 0 { | ||||||
|  |                     format!("{}d {}m before", days, minutes) | ||||||
|  |                 } else { | ||||||
|  |                     format!("{} days before", days) | ||||||
|  |                 } | ||||||
|  |             }; | ||||||
|  |              | ||||||
|  |             let action_text = match reminder.action { | ||||||
|  |                 ReminderAction::Display => "notification", | ||||||
|  |                 ReminderAction::Email => "email", | ||||||
|  |                 ReminderAction::Audio => "sound", | ||||||
|  |             }; | ||||||
|  |              | ||||||
|  |             format!("{} ({})", time_text, action_text) | ||||||
|  |         }) | ||||||
|  |         .collect(); | ||||||
|  |      | ||||||
|  |     formatted_reminders.join(", ") | ||||||
|  | } | ||||||
| @@ -1,7 +1,9 @@ | |||||||
| pub mod login; | pub mod login; | ||||||
| pub mod register; | pub mod register; | ||||||
| pub mod calendar; | pub mod calendar; | ||||||
|  | pub mod event_modal; | ||||||
|  |  | ||||||
| pub use login::Login; | pub use login::Login; | ||||||
| pub use register::Register; | pub use register::Register; | ||||||
| pub use calendar::Calendar; | pub use calendar::Calendar; | ||||||
|  | pub use event_modal::EventModal; | ||||||
| @@ -5,7 +5,27 @@ use wasm_bindgen_futures::JsFuture; | |||||||
| use web_sys::{Request, RequestInit, RequestMode, Response}; | use web_sys::{Request, RequestInit, RequestMode, Response}; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
|  |  | ||||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||||
|  | pub struct EventReminder { | ||||||
|  |     pub minutes_before: i32, | ||||||
|  |     pub action: ReminderAction, | ||||||
|  |     pub description: Option<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||||
|  | pub enum ReminderAction { | ||||||
|  |     Display, | ||||||
|  |     Email, | ||||||
|  |     Audio, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Default for ReminderAction { | ||||||
|  |     fn default() -> Self { | ||||||
|  |         ReminderAction::Display | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||||
| pub struct CalendarEvent { | pub struct CalendarEvent { | ||||||
|     pub uid: String, |     pub uid: String, | ||||||
|     pub summary: Option<String>, |     pub summary: Option<String>, | ||||||
| @@ -13,8 +33,45 @@ pub struct CalendarEvent { | |||||||
|     pub start: DateTime<Utc>, |     pub start: DateTime<Utc>, | ||||||
|     pub end: Option<DateTime<Utc>>, |     pub end: Option<DateTime<Utc>>, | ||||||
|     pub location: Option<String>, |     pub location: Option<String>, | ||||||
|     pub status: String, |     pub status: EventStatus, | ||||||
|  |     pub class: EventClass, | ||||||
|  |     pub priority: Option<u8>, | ||||||
|  |     pub organizer: Option<String>, | ||||||
|  |     pub attendees: Vec<String>, | ||||||
|  |     pub categories: Vec<String>, | ||||||
|  |     pub created: Option<DateTime<Utc>>, | ||||||
|  |     pub last_modified: Option<DateTime<Utc>>, | ||||||
|  |     pub recurrence_rule: Option<String>, | ||||||
|     pub all_day: bool, |     pub all_day: bool, | ||||||
|  |     pub reminders: Vec<EventReminder>, | ||||||
|  |     pub etag: Option<String>, | ||||||
|  |     pub href: Option<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||||
|  | pub enum EventStatus { | ||||||
|  |     Tentative, | ||||||
|  |     Confirmed, | ||||||
|  |     Cancelled, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Default for EventStatus { | ||||||
|  |     fn default() -> Self { | ||||||
|  |         EventStatus::Confirmed | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||||
|  | pub enum EventClass { | ||||||
|  |     Public, | ||||||
|  |     Private, | ||||||
|  |     Confidential, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Default for EventClass { | ||||||
|  |     fn default() -> Self { | ||||||
|  |         EventClass::Public | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| impl CalendarEvent { | impl CalendarEvent { | ||||||
| @@ -31,6 +88,38 @@ impl CalendarEvent { | |||||||
|     pub fn get_title(&self) -> String { |     pub fn get_title(&self) -> String { | ||||||
|         self.summary.clone().unwrap_or_else(|| "Untitled Event".to_string()) |         self.summary.clone().unwrap_or_else(|| "Untitled Event".to_string()) | ||||||
|     } |     } | ||||||
|  |      | ||||||
|  |     /// Get display string for status | ||||||
|  |     pub fn get_status_display(&self) -> &'static str { | ||||||
|  |         match self.status { | ||||||
|  |             EventStatus::Tentative => "Tentative", | ||||||
|  |             EventStatus::Confirmed => "Confirmed", | ||||||
|  |             EventStatus::Cancelled => "Cancelled", | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /// Get display string for class | ||||||
|  |     pub fn get_class_display(&self) -> &'static str { | ||||||
|  |         match self.class { | ||||||
|  |             EventClass::Public => "Public", | ||||||
|  |             EventClass::Private => "Private", | ||||||
|  |             EventClass::Confidential => "Confidential", | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /// Get display string for priority | ||||||
|  |     pub fn get_priority_display(&self) -> String { | ||||||
|  |         match self.priority { | ||||||
|  |             None => "Not set".to_string(), | ||||||
|  |             Some(0) => "Undefined".to_string(), | ||||||
|  |             Some(1) => "High".to_string(), | ||||||
|  |             Some(p) if p <= 4 => "High".to_string(), | ||||||
|  |             Some(5) => "Medium".to_string(), | ||||||
|  |             Some(p) if p <= 8 => "Low".to_string(), | ||||||
|  |             Some(9) => "Low".to_string(), | ||||||
|  |             Some(p) => format!("Priority {}", p), | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| pub struct CalendarService { | pub struct CalendarService { | ||||||
| @@ -91,18 +180,56 @@ impl CalendarService { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Convert events to a HashMap grouped by date for calendar display |     /// Convert events to a HashMap grouped by date for calendar display | ||||||
|     pub fn group_events_by_date(events: Vec<CalendarEvent>) -> HashMap<NaiveDate, Vec<String>> { |     pub fn group_events_by_date(events: Vec<CalendarEvent>) -> HashMap<NaiveDate, Vec<CalendarEvent>> { | ||||||
|         let mut grouped = HashMap::new(); |         let mut grouped = HashMap::new(); | ||||||
|          |          | ||||||
|         for event in events { |         for event in events { | ||||||
|             let date = event.get_date(); |             let date = event.get_date(); | ||||||
|             let title = event.get_title(); |  | ||||||
|              |              | ||||||
|             grouped.entry(date) |             grouped.entry(date) | ||||||
|                 .or_insert_with(Vec::new) |                 .or_insert_with(Vec::new) | ||||||
|                 .push(title); |                 .push(event); | ||||||
|         } |         } | ||||||
|          |          | ||||||
|         grouped |         grouped | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /// Refresh a single event by UID from the CalDAV server | ||||||
|  |     pub async fn refresh_event(&self, token: &str, uid: &str) -> Result<Option<CalendarEvent>, String> { | ||||||
|  |         let window = web_sys::window().ok_or("No global window exists")?; | ||||||
|  |          | ||||||
|  |         let opts = RequestInit::new(); | ||||||
|  |         opts.set_method("GET"); | ||||||
|  |         opts.set_mode(RequestMode::Cors); | ||||||
|  |  | ||||||
|  |         let url = format!("{}/calendar/events/{}", self.base_url, uid); | ||||||
|  |         let request = Request::new_with_str_and_init(&url, &opts) | ||||||
|  |             .map_err(|e| format!("Request creation failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         request.headers().set("Authorization", &format!("Bearer {}", token)) | ||||||
|  |             .map_err(|e| format!("Authorization header setting failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let resp_value = JsFuture::from(window.fetch_with_request(&request)) | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| format!("Network request failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let resp: Response = resp_value.dyn_into() | ||||||
|  |             .map_err(|e| format!("Response cast failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let text = JsFuture::from(resp.text() | ||||||
|  |             .map_err(|e| format!("Text extraction failed: {:?}", e))?) | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| format!("Text promise failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let text_string = text.as_string() | ||||||
|  |             .ok_or("Response text is not a string")?; | ||||||
|  |  | ||||||
|  |         if resp.ok() { | ||||||
|  |             let event: Option<CalendarEvent> = serde_json::from_str(&text_string) | ||||||
|  |                 .map_err(|e| format!("JSON parsing failed: {}", e))?; | ||||||
|  |             Ok(event) | ||||||
|  |         } else { | ||||||
|  |             Err(format!("Request failed with status {}: {}", resp.status(), text_string)) | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -1,3 +1,3 @@ | |||||||
| pub mod calendar_service; | pub mod calendar_service; | ||||||
|  |  | ||||||
| pub use calendar_service::CalendarService; | pub use calendar_service::{CalendarService, CalendarEvent, EventStatus, EventClass, EventReminder, ReminderAction}; | ||||||
							
								
								
									
										589
									
								
								styles.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										589
									
								
								styles.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,589 @@ | |||||||
|  | * { | ||||||
|  |     margin: 0; | ||||||
|  |     padding: 0; | ||||||
|  |     box-sizing: border-box; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body { | ||||||
|  |     font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | ||||||
|  |     background-color: #f8f9fa; | ||||||
|  |     color: #333; | ||||||
|  |     line-height: 1.6; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .app { | ||||||
|  |     min-height: 100vh; | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .app-header { | ||||||
|  |     background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||||
|  |     color: white; | ||||||
|  |     padding: 1rem 2rem; | ||||||
|  |     display: flex; | ||||||
|  |     justify-content: space-between; | ||||||
|  |     align-items: center; | ||||||
|  |     box-shadow: 0 2px 4px rgba(0,0,0,0.1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .app-header h1 { | ||||||
|  |     margin: 0; | ||||||
|  |     font-size: 1.8rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .app-header nav { | ||||||
|  |     display: flex; | ||||||
|  |     gap: 1rem; | ||||||
|  |     align-items: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .app-header nav a { | ||||||
|  |     color: white; | ||||||
|  |     text-decoration: none; | ||||||
|  |     padding: 0.5rem 1rem; | ||||||
|  |     border-radius: 4px; | ||||||
|  |     transition: background-color 0.2s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .app-header nav a:hover { | ||||||
|  |     background-color: rgba(255,255,255,0.2); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .app-main { | ||||||
|  |     flex: 1; | ||||||
|  |     padding: 2rem; | ||||||
|  |     max-width: 1200px; | ||||||
|  |     margin: 0 auto; | ||||||
|  |     width: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Authentication Forms */ | ||||||
|  | .login-container, .register-container { | ||||||
|  |     display: flex; | ||||||
|  |     justify-content: center; | ||||||
|  |     align-items: center; | ||||||
|  |     min-height: 60vh; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .login-form, .register-form { | ||||||
|  |     background: white; | ||||||
|  |     padding: 2rem; | ||||||
|  |     border-radius: 8px; | ||||||
|  |     box-shadow: 0 4px 6px rgba(0,0,0,0.1); | ||||||
|  |     width: 100%; | ||||||
|  |     max-width: 400px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .login-form h2, .register-form h2 { | ||||||
|  |     text-align: center; | ||||||
|  |     margin-bottom: 2rem; | ||||||
|  |     color: #333; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .form-group { | ||||||
|  |     margin-bottom: 1.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .form-group label { | ||||||
|  |     display: block; | ||||||
|  |     margin-bottom: 0.5rem; | ||||||
|  |     font-weight: 500; | ||||||
|  |     color: #555; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .form-group input { | ||||||
|  |     width: 100%; | ||||||
|  |     padding: 0.75rem; | ||||||
|  |     border: 1px solid #ddd; | ||||||
|  |     border-radius: 4px; | ||||||
|  |     font-size: 1rem; | ||||||
|  |     transition: border-color 0.2s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .form-group input:focus { | ||||||
|  |     outline: none; | ||||||
|  |     border-color: #667eea; | ||||||
|  |     box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .form-group input:disabled { | ||||||
|  |     background-color: #f5f5f5; | ||||||
|  |     cursor: not-allowed; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .login-button, .register-button { | ||||||
|  |     width: 100%; | ||||||
|  |     padding: 0.75rem; | ||||||
|  |     background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||||
|  |     color: white; | ||||||
|  |     border: none; | ||||||
|  |     border-radius: 4px; | ||||||
|  |     font-size: 1rem; | ||||||
|  |     font-weight: 500; | ||||||
|  |     cursor: pointer; | ||||||
|  |     transition: transform 0.2s, box-shadow 0.2s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .login-button:hover, .register-button:hover { | ||||||
|  |     transform: translateY(-1px); | ||||||
|  |     box-shadow: 0 4px 8px rgba(0,0,0,0.2); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .login-button:disabled, .register-button:disabled { | ||||||
|  |     background: #ccc; | ||||||
|  |     transform: none; | ||||||
|  |     cursor: not-allowed; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .error-message { | ||||||
|  |     background-color: #f8d7da; | ||||||
|  |     color: #721c24; | ||||||
|  |     padding: 0.75rem; | ||||||
|  |     border-radius: 4px; | ||||||
|  |     margin-bottom: 1rem; | ||||||
|  |     border: 1px solid #f5c6cb; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .auth-links { | ||||||
|  |     text-align: center; | ||||||
|  |     margin-top: 2rem; | ||||||
|  |     color: #666; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .auth-links a { | ||||||
|  |     color: #667eea; | ||||||
|  |     text-decoration: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .auth-links a:hover { | ||||||
|  |     text-decoration: underline; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .logout-button { | ||||||
|  |     background: rgba(255,255,255,0.2); | ||||||
|  |     border: 1px solid rgba(255,255,255,0.3); | ||||||
|  |     color: white; | ||||||
|  |     padding: 0.5rem 1rem; | ||||||
|  |     border-radius: 4px; | ||||||
|  |     cursor: pointer; | ||||||
|  |     transition: background-color 0.2s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .logout-button:hover { | ||||||
|  |     background: rgba(255,255,255,0.3); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Calendar View */ | ||||||
|  | .calendar-view { | ||||||
|  |     height: calc(100vh - 140px); /* Full height minus header and padding */ | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-loading, .calendar-error { | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     justify-content: center; | ||||||
|  |     height: 100%; | ||||||
|  |     background: white; | ||||||
|  |     border-radius: 12px; | ||||||
|  |     box-shadow: 0 4px 16px rgba(0,0,0,0.1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-loading p { | ||||||
|  |     font-size: 1.2rem; | ||||||
|  |     color: #666; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-error p { | ||||||
|  |     font-size: 1.2rem; | ||||||
|  |     color: #d32f2f; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Calendar Component */ | ||||||
|  | .calendar { | ||||||
|  |     background: white; | ||||||
|  |     border-radius: 12px; | ||||||
|  |     box-shadow: 0 4px 16px rgba(0,0,0,0.1); | ||||||
|  |     overflow: hidden; | ||||||
|  |     flex: 1; | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-header { | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     justify-content: space-between; | ||||||
|  |     padding: 1.5rem 2rem; | ||||||
|  |     background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||||
|  |     color: white; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .month-year { | ||||||
|  |     font-size: 1.8rem; | ||||||
|  |     font-weight: 600; | ||||||
|  |     margin: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .nav-button { | ||||||
|  |     background: rgba(255,255,255,0.2); | ||||||
|  |     border: none; | ||||||
|  |     color: white; | ||||||
|  |     font-size: 1.5rem; | ||||||
|  |     font-weight: bold; | ||||||
|  |     width: 40px; | ||||||
|  |     height: 40px; | ||||||
|  |     border-radius: 50%; | ||||||
|  |     cursor: pointer; | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     justify-content: center; | ||||||
|  |     transition: background-color 0.2s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .nav-button:hover { | ||||||
|  |     background: rgba(255,255,255,0.3); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-grid { | ||||||
|  |     display: grid; | ||||||
|  |     grid-template-columns: repeat(7, 1fr); | ||||||
|  |     flex: 1; | ||||||
|  |     background: white; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .weekday-header { | ||||||
|  |     background: #f8f9fa; | ||||||
|  |     padding: 1rem; | ||||||
|  |     text-align: center; | ||||||
|  |     font-weight: 600; | ||||||
|  |     color: #666; | ||||||
|  |     border-bottom: 1px solid #e9ecef; | ||||||
|  |     font-size: 0.9rem; | ||||||
|  |     text-transform: uppercase; | ||||||
|  |     letter-spacing: 0.5px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-day { | ||||||
|  |     border: 1px solid #f0f0f0; | ||||||
|  |     padding: 0.75rem; | ||||||
|  |     min-height: 100px; | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  |     cursor: pointer; | ||||||
|  |     transition: background-color 0.2s; | ||||||
|  |     position: relative; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-day:hover { | ||||||
|  |     background-color: #f8f9ff; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-day.current-month { | ||||||
|  |     background: white; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-day.prev-month, | ||||||
|  | .calendar-day.next-month { | ||||||
|  |     background: #fafafa; | ||||||
|  |     color: #ccc; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-day.today { | ||||||
|  |     background: #e3f2fd; | ||||||
|  |     border: 2px solid #2196f3; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-day.has-events { | ||||||
|  |     background: #fff3e0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-day.today.has-events { | ||||||
|  |     background: #e1f5fe; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-day.selected { | ||||||
|  |     background: #e8f5e8; | ||||||
|  |     border: 2px solid #4caf50; | ||||||
|  |     box-shadow: 0 0 8px rgba(76, 175, 80, 0.3); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-day.selected.has-events { | ||||||
|  |     background: #f1f8e9; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-day.selected.today { | ||||||
|  |     background: #e0f2f1; | ||||||
|  |     border: 2px solid #4caf50; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-day.selected .day-number { | ||||||
|  |     color: #2e7d32; | ||||||
|  |     font-weight: 700; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .day-number { | ||||||
|  |     font-weight: 600; | ||||||
|  |     font-size: 1.1rem; | ||||||
|  |     margin-bottom: 0.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-day.today .day-number { | ||||||
|  |     color: #1976d2; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .event-indicators { | ||||||
|  |     flex: 1; | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  |     gap: 2px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .event-box { | ||||||
|  |     background: #2196f3; | ||||||
|  |     color: white; | ||||||
|  |     padding: 2px 4px; | ||||||
|  |     border-radius: 3px; | ||||||
|  |     font-size: 0.7rem; | ||||||
|  |     line-height: 1.2; | ||||||
|  |     overflow: hidden; | ||||||
|  |     text-overflow: ellipsis; | ||||||
|  |     white-space: nowrap; | ||||||
|  |     cursor: pointer; | ||||||
|  |     transition: background-color 0.2s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .event-box:hover { | ||||||
|  |     background: #1976d2; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .event-box.refreshing { | ||||||
|  |     background: #ff9800; | ||||||
|  |     animation: pulse 1.5s ease-in-out infinite alternate; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes pulse { | ||||||
|  |     0% { | ||||||
|  |         opacity: 0.7; | ||||||
|  |     } | ||||||
|  |     100% { | ||||||
|  |         opacity: 1; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .event-dot { | ||||||
|  |     background: #ff9800; | ||||||
|  |     height: 6px; | ||||||
|  |     border-radius: 3px; | ||||||
|  |     margin-bottom: 1px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .more-events { | ||||||
|  |     font-size: 0.7rem; | ||||||
|  |     color: #666; | ||||||
|  |     margin-top: 2px; | ||||||
|  |     font-weight: 500; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Event Modal Styles */ | ||||||
|  | .modal-backdrop { | ||||||
|  |     position: fixed; | ||||||
|  |     top: 0; | ||||||
|  |     left: 0; | ||||||
|  |     right: 0; | ||||||
|  |     bottom: 0; | ||||||
|  |     background: rgba(0, 0, 0, 0.5); | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     justify-content: center; | ||||||
|  |     z-index: 1000; | ||||||
|  |     backdrop-filter: blur(2px); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .modal-content { | ||||||
|  |     background: white; | ||||||
|  |     border-radius: 12px; | ||||||
|  |     box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15); | ||||||
|  |     max-width: 500px; | ||||||
|  |     width: 90%; | ||||||
|  |     max-height: 80vh; | ||||||
|  |     overflow-y: auto; | ||||||
|  |     position: relative; | ||||||
|  |     animation: modalAppear 0.2s ease-out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes modalAppear { | ||||||
|  |     from { | ||||||
|  |         opacity: 0; | ||||||
|  |         transform: scale(0.9) translateY(-20px); | ||||||
|  |     } | ||||||
|  |     to { | ||||||
|  |         opacity: 1; | ||||||
|  |         transform: scale(1) translateY(0); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .modal-header { | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     justify-content: space-between; | ||||||
|  |     padding: 1.5rem 2rem 1rem; | ||||||
|  |     border-bottom: 1px solid #e9ecef; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .modal-header h3 { | ||||||
|  |     margin: 0; | ||||||
|  |     color: #333; | ||||||
|  |     font-size: 1.4rem; | ||||||
|  |     font-weight: 600; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .modal-close { | ||||||
|  |     background: none; | ||||||
|  |     border: none; | ||||||
|  |     font-size: 1.8rem; | ||||||
|  |     color: #999; | ||||||
|  |     cursor: pointer; | ||||||
|  |     padding: 0; | ||||||
|  |     width: 30px; | ||||||
|  |     height: 30px; | ||||||
|  |     border-radius: 50%; | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     justify-content: center; | ||||||
|  |     transition: all 0.2s; | ||||||
|  |     line-height: 1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .modal-close:hover { | ||||||
|  |     background: #f8f9fa; | ||||||
|  |     color: #666; | ||||||
|  |     transform: scale(1.1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .modal-body { | ||||||
|  |     padding: 1.5rem 2rem 2rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .event-detail { | ||||||
|  |     display: grid; | ||||||
|  |     grid-template-columns: 100px 1fr; | ||||||
|  |     gap: 1rem; | ||||||
|  |     margin-bottom: 1rem; | ||||||
|  |     align-items: start; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .event-detail strong { | ||||||
|  |     color: #555; | ||||||
|  |     font-weight: 600; | ||||||
|  |     font-size: 0.9rem; | ||||||
|  |     text-transform: uppercase; | ||||||
|  |     letter-spacing: 0.5px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .event-detail span { | ||||||
|  |     color: #333; | ||||||
|  |     font-size: 1rem; | ||||||
|  |     line-height: 1.5; | ||||||
|  |     word-break: break-word; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Responsive Design */ | ||||||
|  | @media (max-width: 768px) { | ||||||
|  |     .calendar-header { | ||||||
|  |         padding: 1rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .month-year { | ||||||
|  |         font-size: 1.4rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .nav-button { | ||||||
|  |         width: 35px; | ||||||
|  |         height: 35px; | ||||||
|  |         font-size: 1.2rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .weekday-header { | ||||||
|  |         padding: 0.5rem; | ||||||
|  |         font-size: 0.8rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .calendar-day { | ||||||
|  |         min-height: 70px; | ||||||
|  |         padding: 0.5rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .day-number { | ||||||
|  |         font-size: 1rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .app-main { | ||||||
|  |         padding: 1rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .calendar-view { | ||||||
|  |         height: calc(100vh - 120px); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @media (max-width: 480px) { | ||||||
|  |     .calendar-day { | ||||||
|  |         min-height: 60px; | ||||||
|  |         padding: 0.25rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .weekday-header { | ||||||
|  |         padding: 0.5rem 0.25rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .day-number { | ||||||
|  |         font-size: 0.9rem; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Mobile adjustments for modal */ | ||||||
|  | @media (max-width: 768px) { | ||||||
|  |     .modal-content { | ||||||
|  |         margin: 1rem; | ||||||
|  |         width: calc(100% - 2rem); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .modal-header, .modal-body { | ||||||
|  |         padding: 1rem 1.5rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .event-detail { | ||||||
|  |         grid-template-columns: 80px 1fr; | ||||||
|  |         gap: 0.75rem; | ||||||
|  |         margin-bottom: 0.75rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .event-detail strong { | ||||||
|  |         font-size: 0.8rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .event-detail span { | ||||||
|  |         font-size: 0.9rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .app-header { | ||||||
|  |         flex-direction: column; | ||||||
|  |         text-align: center; | ||||||
|  |         padding: 1rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .app-header nav { | ||||||
|  |         margin-top: 1rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .app-main { | ||||||
|  |         padding: 1rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .login-form, .register-form { | ||||||
|  |         padding: 1.5rem; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										1
									
								
								test_backend_url.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								test_backend_url.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | console.log("Backend URL test"); | ||||||
		Reference in New Issue
	
	Block a user