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" | ||||
|  | ||||
| [watch] | ||||
| watch = ["src", "Cargo.toml"] | ||||
| watch = ["src", "Cargo.toml", "styles.css", "index.html"] | ||||
| ignore = ["backend/"] | ||||
|  | ||||
| [serve] | ||||
| address = "127.0.0.1" | ||||
| 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 | ||||
|     pub all_day: bool, | ||||
|      | ||||
|     /// Reminders/alarms for this event | ||||
|     pub reminders: Vec<EventReminder>, | ||||
|      | ||||
|     /// ETag from CalDAV server for conflict detection | ||||
|     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 | ||||
| pub struct CalDAVClient { | ||||
|     config: crate::config::CalDAVConfig, | ||||
| @@ -168,6 +192,17 @@ impl CalDAVClient { | ||||
|         Ok(events) | ||||
|     } | ||||
|  | ||||
|     /// Fetch a single calendar event by UID from the CalDAV server | ||||
|     pub async fn fetch_event_by_uid(&self, calendar_path: &str, uid: &str) -> Result<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 | ||||
|     fn extract_calendar_data(&self, xml_response: &str) -> Vec<CalendarDataSection> { | ||||
|         let mut sections = Vec::new(); | ||||
| @@ -244,8 +279,8 @@ impl CalDAVClient { | ||||
|         let mut properties: HashMap<String, String> = HashMap::new(); | ||||
|          | ||||
|         // Extract all properties from the event | ||||
|         for property in event.properties { | ||||
|             properties.insert(property.name.to_uppercase(), property.value.unwrap_or_default()); | ||||
|         for property in &event.properties { | ||||
|             properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default()); | ||||
|         } | ||||
|  | ||||
|         // Required UID field | ||||
| @@ -325,11 +360,82 @@ impl CalDAVClient { | ||||
|             last_modified, | ||||
|             recurrence_rule: properties.get("RRULE").cloned(), | ||||
|             all_day, | ||||
|             reminders: self.parse_alarms(&event)?, | ||||
|             etag: 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 | ||||
|     pub async fn discover_calendars(&self) -> Result<Vec<String>, CalDAVError> { | ||||
|         // 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::{ | ||||
|     extract::{State, Query}, | ||||
|     extract::{State, Query, Path}, | ||||
|     http::HeaderMap, | ||||
|     response::Json, | ||||
| }; | ||||
| @@ -73,6 +73,52 @@ pub async fn get_calendar_events( | ||||
|     Ok(Json(filtered_events)) | ||||
| } | ||||
|  | ||||
| pub async fn refresh_event( | ||||
|     State(_state): State<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( | ||||
|     State(state): State<Arc<AppState>>, | ||||
|     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/verify", get(handlers::verify_token)) | ||||
|         .route("/api/calendar/events", get(handlers::get_calendar_events)) | ||||
|         .route("/api/calendar/events/:uid", get(handlers::refresh_event)) | ||||
|         .layer( | ||||
|             CorsLayer::new() | ||||
|                 .allow_origin(Any) | ||||
|   | ||||
							
								
								
									
										431
									
								
								index.html
									
									
									
									
									
								
							
							
						
						
									
										431
									
								
								index.html
									
									
									
									
									
								
							| @@ -4,436 +4,7 @@ | ||||
|     <meta charset="utf-8" /> | ||||
|     <title>Calendar App</title> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|     <style> | ||||
|         * { | ||||
|             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> | ||||
|     <link rel="stylesheet" href="styles.css"> | ||||
| </head> | ||||
| <body></body> | ||||
| </html> | ||||
							
								
								
									
										61
									
								
								src/app.rs
									
									
									
									
									
								
							
							
						
						
									
										61
									
								
								src/app.rs
									
									
									
									
									
								
							| @@ -2,7 +2,7 @@ use yew::prelude::*; | ||||
| use yew_router::prelude::*; | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
| use crate::components::{Login, Register, Calendar}; | ||||
| use crate::services::CalendarService; | ||||
| use crate::services::{CalendarService, CalendarEvent}; | ||||
| use std::collections::HashMap; | ||||
| use chrono::{Local, NaiveDate, Datelike}; | ||||
|  | ||||
| @@ -107,9 +107,10 @@ pub fn App() -> Html { | ||||
|  | ||||
| #[function_component] | ||||
| 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 error = use_state(|| None::<String>); | ||||
|     let refreshing_event = use_state(|| None::<String>); | ||||
|      | ||||
|     // Get current auth token | ||||
|     let auth_token: Option<String> = LocalStorage::get("auth_token").ok(); | ||||
| @@ -118,6 +119,57 @@ fn CalendarView() -> Html { | ||||
|     let current_year = today.year(); | ||||
|     let current_month = today.month(); | ||||
|      | ||||
|     // Event refresh callback | ||||
|     let on_event_click = { | ||||
|         let events = events.clone(); | ||||
|         let refreshing_event = refreshing_event.clone(); | ||||
|         let auth_token = auth_token.clone(); | ||||
|          | ||||
|         Callback::from(move |event: CalendarEvent| { | ||||
|             if let Some(token) = auth_token.clone() { | ||||
|                 let events = events.clone(); | ||||
|                 let refreshing_event = refreshing_event.clone(); | ||||
|                 let uid = event.uid.clone(); | ||||
|                  | ||||
|                 refreshing_event.set(Some(uid.clone())); | ||||
|                  | ||||
|                 wasm_bindgen_futures::spawn_local(async move { | ||||
|                     let calendar_service = CalendarService::new(); | ||||
|                      | ||||
|                     match calendar_service.refresh_event(&token, &uid).await { | ||||
|                         Ok(Some(refreshed_event)) => { | ||||
|                             // Update the event in the existing events map | ||||
|                             let mut updated_events = (*events).clone(); | ||||
|                             for (_, day_events) in updated_events.iter_mut() { | ||||
|                                 for existing_event in day_events.iter_mut() { | ||||
|                                     if existing_event.uid == uid { | ||||
|                                         *existing_event = refreshed_event.clone(); | ||||
|                                         break; | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|                             events.set(updated_events); | ||||
|                         } | ||||
|                         Ok(None) => { | ||||
|                             // Event was deleted, remove it from the map | ||||
|                             let mut updated_events = (*events).clone(); | ||||
|                             for (_, day_events) in updated_events.iter_mut() { | ||||
|                                 day_events.retain(|e| e.uid != uid); | ||||
|                             } | ||||
|                             events.set(updated_events); | ||||
|                         } | ||||
|                         Err(_err) => { | ||||
|                             // Log error but don't show it to user - keep using cached event | ||||
|                             // Silently fall back to cached event data | ||||
|                         } | ||||
|                     } | ||||
|                      | ||||
|                     refreshing_event.set(None); | ||||
|                 }); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     // Fetch events when component mounts | ||||
|     { | ||||
|         let events = events.clone(); | ||||
| @@ -165,15 +217,16 @@ fn CalendarView() -> Html { | ||||
|                         </div> | ||||
|                     } | ||||
|                 } else if let Some(err) = (*error).clone() { | ||||
|                     let dummy_callback = Callback::from(|_: CalendarEvent| {}); | ||||
|                     html! { | ||||
|                         <div class="calendar-error"> | ||||
|                             <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> | ||||
|                     } | ||||
|                 } else { | ||||
|                     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 chrono::{Datelike, Local, NaiveDate, Duration, Weekday}; | ||||
| use std::collections::HashMap; | ||||
| use crate::services::calendar_service::CalendarEvent; | ||||
| use crate::components::EventModal; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct CalendarProps { | ||||
|     #[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] | ||||
| pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|     let today = Local::now().date_naive(); | ||||
|     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 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| { | ||||
|                         let date = current_month.with_day(day).unwrap(); | ||||
|                         let is_today = date == today; | ||||
|                         let is_selected = date == *selected_day; | ||||
|                         let events = props.events.get(&date).cloned().unwrap_or_default(); | ||||
|                          | ||||
|                         let mut classes = vec!["calendar-day", "current-month"]; | ||||
|                         if is_today { | ||||
|                             classes.push("today"); | ||||
|                         } | ||||
|                         if is_selected { | ||||
|                             classes.push("selected"); | ||||
|                         } | ||||
|                         if !events.is_empty() { | ||||
|                             classes.push("has-events"); | ||||
|                         } | ||||
|  | ||||
|                         let selected_day_clone = selected_day.clone(); | ||||
|                         let on_click = Callback::from(move |_| { | ||||
|                             selected_day_clone.set(date); | ||||
|                         }); | ||||
|                          | ||||
|                         html! { | ||||
|                             <div class={classes!(classes)}> | ||||
|                             <div class={classes!(classes)} onclick={on_click}> | ||||
|                                 <div class="day-number">{day}</div> | ||||
|                                 { | ||||
|                                     if !events.is_empty() { | ||||
| @@ -90,13 +106,29 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                                             <div class="event-indicators"> | ||||
|                                                 { | ||||
|                                                     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! {  | ||||
|                                                             <div class="event-box" title={event.clone()}> | ||||
|                                                             <div class={class_name}  | ||||
|                                                                  title={title.clone()}  | ||||
|                                                                  onclick={event_click}> | ||||
|                                                                 { | ||||
|                                                                     if event.len() > 15 { | ||||
|                                                                         format!("{}...", &event[..12]) | ||||
|                                                                     if is_refreshing { | ||||
|                                                                         "🔄 Refreshing...".to_string() | ||||
|                                                                     } else if title.len() > 15 { | ||||
|                                                                         format!("{}...", &title[..12]) | ||||
|                                                                     } else { | ||||
|                                                                         event.clone() | ||||
|                                                                         title | ||||
|                                                                     } | ||||
|                                                                 } | ||||
|                                                             </div>  | ||||
| @@ -123,6 +155,17 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                  | ||||
|                 { render_next_month_days(days_from_prev_month.len(), days_in_month) } | ||||
|             </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> | ||||
|     } | ||||
| } | ||||
| @@ -193,4 +236,5 @@ fn get_month_name(month: u32) -> &'static str { | ||||
|         12 => "December", | ||||
|         _ => "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 register; | ||||
| pub mod calendar; | ||||
| pub mod event_modal; | ||||
|  | ||||
| pub use login::Login; | ||||
| 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 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 uid: String, | ||||
|     pub summary: Option<String>, | ||||
| @@ -13,8 +33,45 @@ pub struct CalendarEvent { | ||||
|     pub start: DateTime<Utc>, | ||||
|     pub end: Option<DateTime<Utc>>, | ||||
|     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 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 { | ||||
| @@ -31,6 +88,38 @@ impl CalendarEvent { | ||||
|     pub fn get_title(&self) -> 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 { | ||||
| @@ -91,18 +180,56 @@ impl CalendarService { | ||||
|     } | ||||
|  | ||||
|     /// 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(); | ||||
|          | ||||
|         for event in events { | ||||
|             let date = event.get_date(); | ||||
|             let title = event.get_title(); | ||||
|              | ||||
|             grouped.entry(date) | ||||
|                 .or_insert_with(Vec::new) | ||||
|                 .push(title); | ||||
|                 .push(event); | ||||
|         } | ||||
|          | ||||
|         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 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