Compare commits
	
		
			24 Commits
		
	
	
		
			419cb3d790
			...
			feature/ex
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 45e16313ba | ||
|   | 64c737c023 | ||
|   | 75d9149c76 | ||
|   | 28b3946e86 | ||
|   | 6a01a75cce | ||
|   | 189dd32f8c | ||
|   | 7461e8b123 | ||
|   | f88c238b0a | ||
|   | 8caa1f45ae | ||
|   | 289284a532 | ||
|   | 089f4ce105 | ||
|   | 235dcf8e1d | ||
|   | 8dd60a8ec1 | ||
|   | 20679b6b53 | ||
|   | 53c4a99697 | ||
|   | 5ea33b7d0a | ||
|   | 13a752a69c | ||
|   | 0609a99839 | ||
|   | dce82d5f7d | ||
|   | 1e8a8ce5f2 | ||
|   | c0bdd3d8c2 | ||
|   | 2b98c4d229 | ||
|   | ceae654a39 | ||
|   | fb28fa95c9 | 
| @@ -22,6 +22,7 @@ hyper = { version = "1.0", features = ["full"] } | ||||
| serde = { version = "1.0", features = ["derive"] } | ||||
| serde_json = "1.0" | ||||
| chrono = { version = "0.4", features = ["serde"] } | ||||
| chrono-tz = "0.8" | ||||
| uuid = { version = "1.0", features = ["v4", "serde"] } | ||||
| anyhow = "1.0" | ||||
|  | ||||
|   | ||||
							
								
								
									
										2
									
								
								backend/migrations/005_add_last_used_calendar.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								backend/migrations/005_add_last_used_calendar.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| -- Add last used calendar preference to user preferences | ||||
| ALTER TABLE user_preferences ADD COLUMN last_used_calendar TEXT; | ||||
							
								
								
									
										16
									
								
								backend/migrations/006_create_external_calendars_table.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								backend/migrations/006_create_external_calendars_table.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| -- Create external_calendars table | ||||
| CREATE TABLE external_calendars ( | ||||
|     id INTEGER PRIMARY KEY AUTOINCREMENT, | ||||
|     user_id INTEGER NOT NULL, | ||||
|     name TEXT NOT NULL, | ||||
|     url TEXT NOT NULL, | ||||
|     color TEXT NOT NULL DEFAULT '#4285f4', | ||||
|     is_visible BOOLEAN NOT NULL DEFAULT 1, | ||||
|     created_at DATETIME DEFAULT CURRENT_TIMESTAMP, | ||||
|     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, | ||||
|     last_fetched DATETIME, | ||||
|     FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE | ||||
| ); | ||||
|  | ||||
| -- Create index for performance | ||||
| CREATE INDEX idx_external_calendars_user_id ON external_calendars(user_id); | ||||
| @@ -0,0 +1,14 @@ | ||||
| -- Create external calendar cache table for storing ICS data | ||||
| CREATE TABLE external_calendar_cache ( | ||||
|     id INTEGER PRIMARY KEY AUTOINCREMENT, | ||||
|     external_calendar_id INTEGER NOT NULL, | ||||
|     ics_data TEXT NOT NULL, | ||||
|     cached_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||||
|     etag TEXT, | ||||
|     FOREIGN KEY (external_calendar_id) REFERENCES external_calendars(id) ON DELETE CASCADE, | ||||
|     UNIQUE(external_calendar_id) | ||||
| ); | ||||
|  | ||||
| -- Index for faster lookups | ||||
| CREATE INDEX idx_external_calendar_cache_calendar_id ON external_calendar_cache(external_calendar_id); | ||||
| CREATE INDEX idx_external_calendar_cache_cached_at ON external_calendar_cache(cached_at); | ||||
| @@ -93,6 +93,7 @@ impl AuthService { | ||||
|                         calendar_theme: preferences.calendar_theme, | ||||
|                         calendar_style: preferences.calendar_style, | ||||
|                         calendar_colors: preferences.calendar_colors, | ||||
|                         last_used_calendar: preferences.last_used_calendar, | ||||
|                     }, | ||||
|                 }) | ||||
|             } | ||||
| @@ -111,6 +112,17 @@ impl AuthService { | ||||
|         self.decode_token(token) | ||||
|     } | ||||
|  | ||||
|     /// Get user from token | ||||
|     pub async fn get_user_from_token(&self, token: &str) -> Result<crate::db::User, ApiError> { | ||||
|         let claims = self.verify_token(token)?; | ||||
|          | ||||
|         let user_repo = UserRepository::new(&self.db); | ||||
|         user_repo | ||||
|             .find_or_create(&claims.username, &claims.server_url) | ||||
|             .await | ||||
|             .map_err(|e| ApiError::Database(format!("Failed to get user: {}", e))) | ||||
|     } | ||||
|  | ||||
|     /// Create CalDAV config from token | ||||
|     pub fn caldav_config_from_token( | ||||
|         &self, | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| use chrono::{DateTime, Utc}; | ||||
| use chrono::{DateTime, Duration, Utc}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; | ||||
| use sqlx::{FromRow, Result}; | ||||
| @@ -95,9 +95,42 @@ pub struct UserPreferences { | ||||
|     pub calendar_theme: Option<String>, | ||||
|     pub calendar_style: Option<String>, | ||||
|     pub calendar_colors: Option<String>, // JSON string | ||||
|     pub last_used_calendar: Option<String>, | ||||
|     pub updated_at: DateTime<Utc>, | ||||
| } | ||||
|  | ||||
| /// External calendar model | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] | ||||
| pub struct ExternalCalendar { | ||||
|     pub id: i32, | ||||
|     pub user_id: String, | ||||
|     pub name: String, | ||||
|     pub url: String, | ||||
|     pub color: String, | ||||
|     pub is_visible: bool, | ||||
|     pub created_at: DateTime<Utc>, | ||||
|     pub updated_at: DateTime<Utc>, | ||||
|     pub last_fetched: Option<DateTime<Utc>>, | ||||
| } | ||||
|  | ||||
| impl ExternalCalendar { | ||||
|     /// Create a new external calendar | ||||
|     pub fn new(user_id: String, name: String, url: String, color: String) -> Self { | ||||
|         let now = Utc::now(); | ||||
|         Self { | ||||
|             id: 0, // Will be set by database | ||||
|             user_id, | ||||
|             name, | ||||
|             url, | ||||
|             color, | ||||
|             is_visible: true, | ||||
|             created_at: now, | ||||
|             updated_at: now, | ||||
|             last_fetched: None, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl UserPreferences { | ||||
|     /// Create default preferences for a new user | ||||
|     pub fn default_for_user(user_id: String) -> Self { | ||||
| @@ -109,6 +142,7 @@ impl UserPreferences { | ||||
|             calendar_theme: Some("light".to_string()), | ||||
|             calendar_style: Some("default".to_string()), | ||||
|             calendar_colors: None, | ||||
|             last_used_calendar: None, | ||||
|             updated_at: Utc::now(), | ||||
|         } | ||||
|     } | ||||
| @@ -266,8 +300,8 @@ impl<'a> PreferencesRepository<'a> { | ||||
|             sqlx::query( | ||||
|                 "INSERT INTO user_preferences  | ||||
|                  (user_id, calendar_selected_date, calendar_time_increment,  | ||||
|                   calendar_view_mode, calendar_theme, calendar_style, calendar_colors, updated_at)  | ||||
|                  VALUES (?, ?, ?, ?, ?, ?, ?, ?)", | ||||
|                   calendar_view_mode, calendar_theme, calendar_style, calendar_colors, last_used_calendar, updated_at)  | ||||
|                  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", | ||||
|             ) | ||||
|             .bind(&prefs.user_id) | ||||
|             .bind(&prefs.calendar_selected_date) | ||||
| @@ -276,6 +310,7 @@ impl<'a> PreferencesRepository<'a> { | ||||
|             .bind(&prefs.calendar_theme) | ||||
|             .bind(&prefs.calendar_style) | ||||
|             .bind(&prefs.calendar_colors) | ||||
|             .bind(&prefs.last_used_calendar) | ||||
|             .bind(&prefs.updated_at) | ||||
|             .execute(self.db.pool()) | ||||
|             .await?; | ||||
| @@ -290,7 +325,7 @@ impl<'a> PreferencesRepository<'a> { | ||||
|             "UPDATE user_preferences  | ||||
|              SET calendar_selected_date = ?, calendar_time_increment = ?,  | ||||
|                  calendar_view_mode = ?, calendar_theme = ?, calendar_style = ?, | ||||
|                  calendar_colors = ?, updated_at = ? | ||||
|                  calendar_colors = ?, last_used_calendar = ?, updated_at = ? | ||||
|              WHERE user_id = ?", | ||||
|         ) | ||||
|         .bind(&prefs.calendar_selected_date) | ||||
| @@ -299,6 +334,7 @@ impl<'a> PreferencesRepository<'a> { | ||||
|         .bind(&prefs.calendar_theme) | ||||
|         .bind(&prefs.calendar_style) | ||||
|         .bind(&prefs.calendar_colors) | ||||
|         .bind(&prefs.last_used_calendar) | ||||
|         .bind(Utc::now()) | ||||
|         .bind(&prefs.user_id) | ||||
|         .execute(self.db.pool()) | ||||
| @@ -307,3 +343,146 @@ impl<'a> PreferencesRepository<'a> { | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Repository for ExternalCalendar operations | ||||
| pub struct ExternalCalendarRepository<'a> { | ||||
|     db: &'a Database, | ||||
| } | ||||
|  | ||||
| impl<'a> ExternalCalendarRepository<'a> { | ||||
|     pub fn new(db: &'a Database) -> Self { | ||||
|         Self { db } | ||||
|     } | ||||
|  | ||||
|     /// Get all external calendars for a user | ||||
|     pub async fn get_by_user(&self, user_id: &str) -> Result<Vec<ExternalCalendar>> { | ||||
|         sqlx::query_as::<_, ExternalCalendar>( | ||||
|             "SELECT * FROM external_calendars WHERE user_id = ? ORDER BY created_at ASC", | ||||
|         ) | ||||
|         .bind(user_id) | ||||
|         .fetch_all(self.db.pool()) | ||||
|         .await | ||||
|     } | ||||
|  | ||||
|     /// Create a new external calendar | ||||
|     pub async fn create(&self, calendar: &ExternalCalendar) -> Result<i32> { | ||||
|         let result = sqlx::query( | ||||
|             "INSERT INTO external_calendars (user_id, name, url, color, is_visible, created_at, updated_at)  | ||||
|              VALUES (?, ?, ?, ?, ?, ?, ?)", | ||||
|         ) | ||||
|         .bind(&calendar.user_id) | ||||
|         .bind(&calendar.name) | ||||
|         .bind(&calendar.url) | ||||
|         .bind(&calendar.color) | ||||
|         .bind(&calendar.is_visible) | ||||
|         .bind(&calendar.created_at) | ||||
|         .bind(&calendar.updated_at) | ||||
|         .execute(self.db.pool()) | ||||
|         .await?; | ||||
|  | ||||
|         Ok(result.last_insert_rowid() as i32) | ||||
|     } | ||||
|  | ||||
|     /// Update an external calendar | ||||
|     pub async fn update(&self, id: i32, calendar: &ExternalCalendar) -> Result<()> { | ||||
|         sqlx::query( | ||||
|             "UPDATE external_calendars  | ||||
|              SET name = ?, url = ?, color = ?, is_visible = ?, updated_at = ? | ||||
|              WHERE id = ? AND user_id = ?", | ||||
|         ) | ||||
|         .bind(&calendar.name) | ||||
|         .bind(&calendar.url) | ||||
|         .bind(&calendar.color) | ||||
|         .bind(&calendar.is_visible) | ||||
|         .bind(Utc::now()) | ||||
|         .bind(id) | ||||
|         .bind(&calendar.user_id) | ||||
|         .execute(self.db.pool()) | ||||
|         .await?; | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Delete an external calendar | ||||
|     pub async fn delete(&self, id: i32, user_id: &str) -> Result<()> { | ||||
|         sqlx::query("DELETE FROM external_calendars WHERE id = ? AND user_id = ?") | ||||
|             .bind(id) | ||||
|             .bind(user_id) | ||||
|             .execute(self.db.pool()) | ||||
|             .await?; | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Update last_fetched timestamp | ||||
|     pub async fn update_last_fetched(&self, id: i32, user_id: &str) -> Result<()> { | ||||
|         sqlx::query( | ||||
|             "UPDATE external_calendars SET last_fetched = ? WHERE id = ? AND user_id = ?", | ||||
|         ) | ||||
|         .bind(Utc::now()) | ||||
|         .bind(id) | ||||
|         .bind(user_id) | ||||
|         .execute(self.db.pool()) | ||||
|         .await?; | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Get cached ICS data for an external calendar | ||||
|     pub async fn get_cached_data(&self, external_calendar_id: i32) -> Result<Option<(String, DateTime<Utc>)>> { | ||||
|         let result = sqlx::query_as::<_, (String, DateTime<Utc>)>( | ||||
|             "SELECT ics_data, cached_at FROM external_calendar_cache WHERE external_calendar_id = ?", | ||||
|         ) | ||||
|         .bind(external_calendar_id) | ||||
|         .fetch_optional(self.db.pool()) | ||||
|         .await?; | ||||
|  | ||||
|         Ok(result) | ||||
|     } | ||||
|  | ||||
|     /// Update cache with new ICS data | ||||
|     pub async fn update_cache(&self, external_calendar_id: i32, ics_data: &str, etag: Option<&str>) -> Result<()> { | ||||
|         sqlx::query( | ||||
|             "INSERT INTO external_calendar_cache (external_calendar_id, ics_data, etag, cached_at) | ||||
|              VALUES (?, ?, ?, ?) | ||||
|              ON CONFLICT(external_calendar_id) DO UPDATE SET | ||||
|              ics_data = excluded.ics_data, | ||||
|              etag = excluded.etag, | ||||
|              cached_at = excluded.cached_at", | ||||
|         ) | ||||
|         .bind(external_calendar_id) | ||||
|         .bind(ics_data) | ||||
|         .bind(etag) | ||||
|         .bind(Utc::now()) | ||||
|         .execute(self.db.pool()) | ||||
|         .await?; | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Check if cache is stale (older than max_age_minutes) | ||||
|     pub async fn is_cache_stale(&self, external_calendar_id: i32, max_age_minutes: i64) -> Result<bool> { | ||||
|         let cutoff_time = Utc::now() - Duration::minutes(max_age_minutes); | ||||
|          | ||||
|         let result = sqlx::query_scalar::<_, i64>( | ||||
|             "SELECT COUNT(*) FROM external_calendar_cache  | ||||
|              WHERE external_calendar_id = ? AND cached_at > ?", | ||||
|         ) | ||||
|         .bind(external_calendar_id) | ||||
|         .bind(cutoff_time) | ||||
|         .fetch_one(self.db.pool()) | ||||
|         .await?; | ||||
|  | ||||
|         Ok(result == 0) | ||||
|     } | ||||
|  | ||||
|     /// Clear cache for an external calendar | ||||
|     pub async fn clear_cache(&self, external_calendar_id: i32) -> Result<()> { | ||||
|         sqlx::query("DELETE FROM external_calendar_cache WHERE external_calendar_id = ?") | ||||
|             .bind(external_calendar_id) | ||||
|             .execute(self.db.pool()) | ||||
|             .await?; | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
| @@ -1,12 +0,0 @@ | ||||
| // Re-export all handlers from the modular structure | ||||
| mod auth; | ||||
| mod calendar; | ||||
| mod events; | ||||
| mod preferences; | ||||
| mod series; | ||||
|  | ||||
| pub use auth::{get_user_info, login, verify_token}; | ||||
| pub use calendar::{create_calendar, delete_calendar}; | ||||
| pub use events::{create_event, delete_event, get_calendar_events, refresh_event, update_event}; | ||||
| pub use preferences::{get_preferences, logout, update_preferences}; | ||||
| pub use series::{create_event_series, delete_event_series, update_event_series}; | ||||
| @@ -93,6 +93,7 @@ pub async fn get_user_info( | ||||
|             path: path.clone(), | ||||
|             display_name: extract_calendar_name(path), | ||||
|             color: generate_calendar_color(path), | ||||
|             is_visible: true, // Default to visible | ||||
|         }) | ||||
|         .collect(); | ||||
|  | ||||
|   | ||||
| @@ -845,17 +845,18 @@ fn parse_event_datetime( | ||||
|     time_str: &str, | ||||
|     all_day: bool, | ||||
| ) -> Result<chrono::DateTime<chrono::Utc>, String> { | ||||
|     use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; | ||||
|     use chrono::{Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; | ||||
|  | ||||
|     // Parse the date | ||||
|     let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") | ||||
|         .map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?; | ||||
|  | ||||
|     if all_day { | ||||
|         // For all-day events, use midnight UTC | ||||
|         // For all-day events, use noon UTC to avoid timezone boundary issues | ||||
|         // This ensures the date remains correct when converted to any local timezone | ||||
|         let datetime = date | ||||
|             .and_hms_opt(0, 0, 0) | ||||
|             .ok_or_else(|| "Failed to create midnight datetime".to_string())?; | ||||
|             .and_hms_opt(12, 0, 0) | ||||
|             .ok_or_else(|| "Failed to create noon datetime".to_string())?; | ||||
|         Ok(Utc.from_utc_datetime(&datetime)) | ||||
|     } else { | ||||
|         // Parse the time | ||||
| @@ -865,7 +866,11 @@ fn parse_event_datetime( | ||||
|         // Combine date and time | ||||
|         let datetime = NaiveDateTime::new(date, time); | ||||
|  | ||||
|         // Assume local time and convert to UTC (in a real app, you'd want timezone support) | ||||
|         Ok(Utc.from_utc_datetime(&datetime)) | ||||
|         // Treat the datetime as local time and convert to UTC | ||||
|         let local_datetime = Local.from_local_datetime(&datetime) | ||||
|             .single() | ||||
|             .ok_or_else(|| "Ambiguous local datetime".to_string())?; | ||||
|          | ||||
|         Ok(local_datetime.with_timezone(&Utc)) | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										142
									
								
								backend/src/handlers/external_calendars.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								backend/src/handlers/external_calendars.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,142 @@ | ||||
| use axum::{ | ||||
|     extract::{Path, State}, | ||||
|     response::Json, | ||||
| }; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use std::sync::Arc; | ||||
|  | ||||
| use crate::{ | ||||
|     db::{ExternalCalendar, ExternalCalendarRepository}, | ||||
|     models::ApiError, | ||||
|     AppState, | ||||
| }; | ||||
|  | ||||
| use super::auth::{extract_bearer_token}; | ||||
|  | ||||
| #[derive(Debug, Deserialize)] | ||||
| pub struct CreateExternalCalendarRequest { | ||||
|     pub name: String, | ||||
|     pub url: String, | ||||
|     pub color: String, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Deserialize)] | ||||
| pub struct UpdateExternalCalendarRequest { | ||||
|     pub name: String, | ||||
|     pub url: String, | ||||
|     pub color: String, | ||||
|     pub is_visible: bool, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Serialize)] | ||||
| pub struct ExternalCalendarResponse { | ||||
|     pub id: i32, | ||||
|     pub name: String, | ||||
|     pub url: String, | ||||
|     pub color: String, | ||||
|     pub is_visible: bool, | ||||
|     pub created_at: chrono::DateTime<chrono::Utc>, | ||||
|     pub updated_at: chrono::DateTime<chrono::Utc>, | ||||
|     pub last_fetched: Option<chrono::DateTime<chrono::Utc>>, | ||||
| } | ||||
|  | ||||
| impl From<ExternalCalendar> for ExternalCalendarResponse { | ||||
|     fn from(calendar: ExternalCalendar) -> Self { | ||||
|         Self { | ||||
|             id: calendar.id, | ||||
|             name: calendar.name, | ||||
|             url: calendar.url, | ||||
|             color: calendar.color, | ||||
|             is_visible: calendar.is_visible, | ||||
|             created_at: calendar.created_at, | ||||
|             updated_at: calendar.updated_at, | ||||
|             last_fetched: calendar.last_fetched, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub async fn get_external_calendars( | ||||
|     headers: axum::http::HeaderMap, | ||||
|     State(app_state): State<Arc<AppState>>, | ||||
| ) -> Result<Json<Vec<ExternalCalendarResponse>>, ApiError> { | ||||
|     // Extract and verify token, get user | ||||
|     let token = extract_bearer_token(&headers)?; | ||||
|     let user = app_state.auth_service.get_user_from_token(&token).await?; | ||||
|  | ||||
|     let repo = ExternalCalendarRepository::new(&app_state.db); | ||||
|     let calendars = repo | ||||
|         .get_by_user(&user.id) | ||||
|         .await | ||||
|         .map_err(|e| ApiError::Database(format!("Failed to get external calendars: {}", e)))?; | ||||
|  | ||||
|     let response: Vec<ExternalCalendarResponse> = calendars.into_iter().map(Into::into).collect(); | ||||
|     Ok(Json(response)) | ||||
| } | ||||
|  | ||||
| pub async fn create_external_calendar( | ||||
|     headers: axum::http::HeaderMap, | ||||
|     State(app_state): State<Arc<AppState>>, | ||||
|     Json(request): Json<CreateExternalCalendarRequest>, | ||||
| ) -> Result<Json<ExternalCalendarResponse>, ApiError> { | ||||
|     let token = extract_bearer_token(&headers)?; | ||||
|     let user = app_state.auth_service.get_user_from_token(&token).await?; | ||||
|  | ||||
|     let calendar = ExternalCalendar::new( | ||||
|         user.id, | ||||
|         request.name, | ||||
|         request.url, | ||||
|         request.color, | ||||
|     ); | ||||
|  | ||||
|     let repo = ExternalCalendarRepository::new(&app_state.db); | ||||
|     let id = repo | ||||
|         .create(&calendar) | ||||
|         .await | ||||
|         .map_err(|e| ApiError::Database(format!("Failed to create external calendar: {}", e)))?; | ||||
|  | ||||
|     let mut created_calendar = calendar; | ||||
|     created_calendar.id = id; | ||||
|  | ||||
|     Ok(Json(created_calendar.into())) | ||||
| } | ||||
|  | ||||
| pub async fn update_external_calendar( | ||||
|     headers: axum::http::HeaderMap, | ||||
|     State(app_state): State<Arc<AppState>>, | ||||
|     Path(id): Path<i32>, | ||||
|     Json(request): Json<UpdateExternalCalendarRequest>, | ||||
| ) -> Result<Json<()>, ApiError> { | ||||
|     let token = extract_bearer_token(&headers)?; | ||||
|     let user = app_state.auth_service.get_user_from_token(&token).await?; | ||||
|  | ||||
|     let mut calendar = ExternalCalendar::new( | ||||
|         user.id, | ||||
|         request.name, | ||||
|         request.url, | ||||
|         request.color, | ||||
|     ); | ||||
|     calendar.is_visible = request.is_visible; | ||||
|  | ||||
|     let repo = ExternalCalendarRepository::new(&app_state.db); | ||||
|     repo.update(id, &calendar) | ||||
|         .await | ||||
|         .map_err(|e| ApiError::Database(format!("Failed to update external calendar: {}", e)))?; | ||||
|  | ||||
|     Ok(Json(())) | ||||
| } | ||||
|  | ||||
| pub async fn delete_external_calendar( | ||||
|     headers: axum::http::HeaderMap, | ||||
|     State(app_state): State<Arc<AppState>>, | ||||
|     Path(id): Path<i32>, | ||||
| ) -> Result<Json<()>, ApiError> { | ||||
|     let token = extract_bearer_token(&headers)?; | ||||
|     let user = app_state.auth_service.get_user_from_token(&token).await?; | ||||
|  | ||||
|     let repo = ExternalCalendarRepository::new(&app_state.db); | ||||
|     repo.delete(id, &user.id) | ||||
|         .await | ||||
|         .map_err(|e| ApiError::Database(format!("Failed to delete external calendar: {}", e)))?; | ||||
|  | ||||
|     Ok(Json(())) | ||||
| } | ||||
							
								
								
									
										410
									
								
								backend/src/handlers/ics_fetcher.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										410
									
								
								backend/src/handlers/ics_fetcher.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,410 @@ | ||||
| use axum::{ | ||||
|     extract::{Path, State}, | ||||
|     response::Json, | ||||
| }; | ||||
| use chrono::{DateTime, Utc}; | ||||
| use ical::parser::ical::component::IcalEvent; | ||||
| use reqwest::Client; | ||||
| use serde::Serialize; | ||||
| use std::sync::Arc; | ||||
|  | ||||
| use crate::{ | ||||
|     db::ExternalCalendarRepository, | ||||
|     models::ApiError, | ||||
|     AppState, | ||||
| }; | ||||
|  | ||||
| // Import VEvent from calendar-models shared crate | ||||
| use calendar_models::VEvent; | ||||
|  | ||||
| use super::auth::{extract_bearer_token}; | ||||
|  | ||||
| #[derive(Debug, Serialize)] | ||||
| pub struct ExternalCalendarEventsResponse { | ||||
|     pub events: Vec<VEvent>, | ||||
|     pub last_fetched: DateTime<Utc>, | ||||
| } | ||||
|  | ||||
| pub async fn fetch_external_calendar_events( | ||||
|     headers: axum::http::HeaderMap, | ||||
|     State(app_state): State<Arc<AppState>>, | ||||
|     Path(id): Path<i32>, | ||||
| ) -> Result<Json<ExternalCalendarEventsResponse>, ApiError> { | ||||
|     let token = extract_bearer_token(&headers)?; | ||||
|     let user = app_state.auth_service.get_user_from_token(&token).await?; | ||||
|  | ||||
|     let repo = ExternalCalendarRepository::new(&app_state.db); | ||||
|      | ||||
|     // Get user's external calendars to verify ownership and get URL | ||||
|     let calendars = repo | ||||
|         .get_by_user(&user.id) | ||||
|         .await | ||||
|         .map_err(|e| ApiError::Database(format!("Failed to get external calendars: {}", e)))?; | ||||
|  | ||||
|     let calendar = calendars | ||||
|         .into_iter() | ||||
|         .find(|c| c.id == id) | ||||
|         .ok_or_else(|| ApiError::NotFound("External calendar not found".to_string()))?; | ||||
|  | ||||
|     if !calendar.is_visible { | ||||
|         return Ok(Json(ExternalCalendarEventsResponse { | ||||
|             events: vec![], | ||||
|             last_fetched: Utc::now(), | ||||
|         })); | ||||
|     } | ||||
|  | ||||
|     // Check cache first | ||||
|     let cache_max_age_minutes = 5; | ||||
|     let mut ics_content = String::new(); | ||||
|     let mut last_fetched = Utc::now(); | ||||
|     let mut fetched_from_cache = false; | ||||
|  | ||||
|     // Try to get from cache if not stale | ||||
|     match repo.is_cache_stale(id, cache_max_age_minutes).await { | ||||
|         Ok(is_stale) => { | ||||
|             if !is_stale { | ||||
|                 // Cache is fresh, use it | ||||
|                 if let Ok(Some((cached_data, cached_at))) = repo.get_cached_data(id).await { | ||||
|                     ics_content = cached_data; | ||||
|                     last_fetched = cached_at; | ||||
|                     fetched_from_cache = true; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         Err(_) => { | ||||
|             // If cache check fails, proceed to fetch from URL | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // If not fetched from cache, get from external URL | ||||
|     if !fetched_from_cache { | ||||
|         let client = Client::new(); | ||||
|         let response = client | ||||
|             .get(&calendar.url) | ||||
|             .send() | ||||
|             .await | ||||
|             .map_err(|e| ApiError::Internal(format!("Failed to fetch calendar: {}", e)))?; | ||||
|  | ||||
|         if !response.status().is_success() { | ||||
|             return Err(ApiError::Internal(format!("Calendar server returned: {}", response.status()))); | ||||
|         } | ||||
|  | ||||
|         ics_content = response | ||||
|             .text() | ||||
|             .await | ||||
|             .map_err(|e| ApiError::Internal(format!("Failed to read calendar content: {}", e)))?; | ||||
|  | ||||
|         // Store in cache for future requests | ||||
|         let etag = None; // TODO: Extract ETag from response headers if available | ||||
|         if let Err(_) = repo.update_cache(id, &ics_content, etag).await { | ||||
|             // Log error but don't fail the request | ||||
|         } | ||||
|  | ||||
|         // Update last_fetched timestamp | ||||
|         if let Err(_) = repo.update_last_fetched(id, &user.id).await { | ||||
|         } | ||||
|          | ||||
|         last_fetched = Utc::now(); | ||||
|     } | ||||
|  | ||||
|     // Parse ICS content | ||||
|     let events = parse_ics_content(&ics_content) | ||||
|         .map_err(|e| ApiError::BadRequest(format!("Failed to parse calendar: {}", e)))?; | ||||
|  | ||||
|     Ok(Json(ExternalCalendarEventsResponse { | ||||
|         events, | ||||
|         last_fetched, | ||||
|     })) | ||||
| } | ||||
|  | ||||
| fn parse_ics_content(ics_content: &str) -> Result<Vec<VEvent>, Box<dyn std::error::Error>> { | ||||
|     let reader = ical::IcalParser::new(ics_content.as_bytes()); | ||||
|     let mut events = Vec::new(); | ||||
|     let mut _total_components = 0; | ||||
|     let mut _failed_conversions = 0; | ||||
|  | ||||
|     for calendar in reader { | ||||
|         let calendar = calendar?; | ||||
|         for component in calendar.events { | ||||
|             _total_components += 1; | ||||
|             match convert_ical_to_vevent(component) { | ||||
|                 Ok(vevent) => { | ||||
|                     events.push(vevent); | ||||
|                 } | ||||
|                 Err(_) => { | ||||
|                     _failed_conversions += 1; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     Ok(events) | ||||
| } | ||||
|  | ||||
| fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::error::Error>> { | ||||
|     use uuid::Uuid; | ||||
|  | ||||
|     let mut summary = None; | ||||
|     let mut description = None; | ||||
|     let mut location = None; | ||||
|     let mut dtstart = None; | ||||
|     let mut dtend = None; | ||||
|     let mut uid = None; | ||||
|     let mut all_day = false; | ||||
|     let mut rrule = None; | ||||
|  | ||||
|  | ||||
|     // Extract properties | ||||
|     for property in ical_event.properties { | ||||
|         match property.name.as_str() { | ||||
|             "SUMMARY" => { | ||||
|                 summary = property.value; | ||||
|             } | ||||
|             "DESCRIPTION" => { | ||||
|                 description = property.value; | ||||
|             } | ||||
|             "LOCATION" => { | ||||
|                 location = property.value; | ||||
|             } | ||||
|             "DTSTART" => { | ||||
|                 if let Some(value) = property.value { | ||||
|                     // Check if it's a date-only value (all-day event) | ||||
|                     if value.len() == 8 && !value.contains('T') { | ||||
|                         all_day = true; | ||||
|                         // Parse YYYYMMDD format | ||||
|                         if let Ok(date) = chrono::NaiveDate::parse_from_str(&value, "%Y%m%d") { | ||||
|                             dtstart = Some(chrono::TimeZone::from_utc_datetime(&Utc, &date.and_hms_opt(12, 0, 0).unwrap())); | ||||
|                         } | ||||
|                     } else { | ||||
|                         // Extract timezone info from parameters | ||||
|                         let tzid = property.params.as_ref() | ||||
|                             .and_then(|params| params.iter().find(|(k, _)| k == "TZID")) | ||||
|                             .and_then(|(_, v)| v.first().cloned()); | ||||
|                          | ||||
|                         // Parse datetime with timezone information | ||||
|                         if let Some(dt) = parse_datetime_with_tz(&value, tzid.as_deref()) { | ||||
|                             dtstart = Some(dt); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             "DTEND" => { | ||||
|                 if let Some(value) = property.value { | ||||
|                     if all_day && value.len() == 8 && !value.contains('T') { | ||||
|                         // For all-day events, DTEND is exclusive so use the date as-is at noon | ||||
|                         if let Ok(date) = chrono::NaiveDate::parse_from_str(&value, "%Y%m%d") { | ||||
|                             dtend = Some(chrono::TimeZone::from_utc_datetime(&Utc, &date.and_hms_opt(12, 0, 0).unwrap())); | ||||
|                         } | ||||
|                     } else { | ||||
|                         // Extract timezone info from parameters | ||||
|                         let tzid = property.params.as_ref() | ||||
|                             .and_then(|params| params.iter().find(|(k, _)| k == "TZID")) | ||||
|                             .and_then(|(_, v)| v.first().cloned()); | ||||
|                          | ||||
|                         // Parse datetime with timezone information | ||||
|                         if let Some(dt) = parse_datetime_with_tz(&value, tzid.as_deref()) { | ||||
|                             dtend = Some(dt); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             "UID" => { | ||||
|                 uid = property.value; | ||||
|             } | ||||
|             "RRULE" => { | ||||
|                 rrule = property.value; | ||||
|             } | ||||
|             _ => {} // Ignore other properties for now | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     let dtstart = dtstart.ok_or("Missing DTSTART")?; | ||||
|  | ||||
|     let vevent = VEvent { | ||||
|         uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()), | ||||
|         dtstart, | ||||
|         dtend, | ||||
|         summary, | ||||
|         description, | ||||
|         location, | ||||
|         all_day, | ||||
|         rrule, | ||||
|         exdate: Vec::new(), // External calendars don't need exception handling | ||||
|         recurrence_id: None, | ||||
|         created: None, | ||||
|         last_modified: None, | ||||
|         dtstamp: Utc::now(), | ||||
|         sequence: Some(0), | ||||
|         status: None, | ||||
|         transp: None, | ||||
|         organizer: None, | ||||
|         attendees: Vec::new(), | ||||
|         url: None, | ||||
|         attachments: Vec::new(), | ||||
|         categories: Vec::new(), | ||||
|         priority: None, | ||||
|         resources: Vec::new(), | ||||
|         related_to: None, | ||||
|         geo: None, | ||||
|         duration: None, | ||||
|         class: None, | ||||
|         contact: None, | ||||
|         comment: None, | ||||
|         rdate: Vec::new(), | ||||
|         alarms: Vec::new(), | ||||
|         etag: None, | ||||
|         href: None, | ||||
|         calendar_path: None, | ||||
|     }; | ||||
|  | ||||
|     Ok(vevent) | ||||
| } | ||||
|  | ||||
| fn parse_datetime_with_tz(datetime_str: &str, tzid: Option<&str>) -> Option<DateTime<Utc>> { | ||||
|     use chrono::TimeZone; | ||||
|     use chrono_tz::Tz; | ||||
|      | ||||
|      | ||||
|     // Try various datetime formats commonly found in ICS files | ||||
|      | ||||
|     // Format: 20231201T103000Z (UTC) - handle as naive datetime first | ||||
|     if datetime_str.ends_with('Z') { | ||||
|         let datetime_without_z = &datetime_str[..datetime_str.len()-1]; | ||||
|         if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_without_z, "%Y%m%dT%H%M%S") { | ||||
|             return Some(naive_dt.and_utc()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Format: 20231201T103000-0500 (with timezone offset) | ||||
|     if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S%z") { | ||||
|         return Some(dt.with_timezone(&Utc)); | ||||
|     } | ||||
|  | ||||
|     // Format: 2023-12-01T10:30:00Z (ISO format) | ||||
|     if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%SZ") { | ||||
|         return Some(dt.with_timezone(&Utc)); | ||||
|     } | ||||
|  | ||||
|     // Handle naive datetime with timezone parameter | ||||
|     let naive_dt = if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S") { | ||||
|         Some(dt) | ||||
|     } else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%S") { | ||||
|         Some(dt) | ||||
|     } else { | ||||
|         None | ||||
|     }; | ||||
|  | ||||
|     if let Some(naive_dt) = naive_dt { | ||||
|         // If TZID is provided, try to parse it | ||||
|         if let Some(tzid_str) = tzid { | ||||
|             // Handle common timezone formats | ||||
|             let tz_result = if tzid_str.starts_with("/mozilla.org/") { | ||||
|                 // Mozilla/Thunderbird format: /mozilla.org/20070129_1/Europe/London | ||||
|                 tzid_str.split('/').last().and_then(|tz_name| tz_name.parse::<Tz>().ok()) | ||||
|             } else if tzid_str.contains('/') { | ||||
|                 // Standard timezone format: America/New_York, Europe/London | ||||
|                 tzid_str.parse::<Tz>().ok() | ||||
|             } else { | ||||
|                 // Try common abbreviations and Windows timezone names | ||||
|                 match tzid_str { | ||||
|                     // Standard abbreviations | ||||
|                     "EST" => Some(Tz::America__New_York), | ||||
|                     "PST" => Some(Tz::America__Los_Angeles), | ||||
|                     "MST" => Some(Tz::America__Denver),  | ||||
|                     "CST" => Some(Tz::America__Chicago), | ||||
|                      | ||||
|                     // North America - Windows timezone names to IANA mapping | ||||
|                     "Mountain Standard Time" => Some(Tz::America__Denver), | ||||
|                     "Eastern Standard Time" => Some(Tz::America__New_York), | ||||
|                     "Central Standard Time" => Some(Tz::America__Chicago), | ||||
|                     "Pacific Standard Time" => Some(Tz::America__Los_Angeles), | ||||
|                     "Mountain Daylight Time" => Some(Tz::America__Denver), | ||||
|                     "Eastern Daylight Time" => Some(Tz::America__New_York), | ||||
|                     "Central Daylight Time" => Some(Tz::America__Chicago), | ||||
|                     "Pacific Daylight Time" => Some(Tz::America__Los_Angeles), | ||||
|                     "Hawaiian Standard Time" => Some(Tz::Pacific__Honolulu), | ||||
|                     "Alaskan Standard Time" => Some(Tz::America__Anchorage), | ||||
|                     "Alaskan Daylight Time" => Some(Tz::America__Anchorage), | ||||
|                     "Atlantic Standard Time" => Some(Tz::America__Halifax), | ||||
|                     "Newfoundland Standard Time" => Some(Tz::America__St_Johns), | ||||
|                      | ||||
|                     // Europe | ||||
|                     "GMT Standard Time" => Some(Tz::Europe__London), | ||||
|                     "Greenwich Standard Time" => Some(Tz::UTC), | ||||
|                     "W. Europe Standard Time" => Some(Tz::Europe__Berlin), | ||||
|                     "Central Europe Standard Time" => Some(Tz::Europe__Warsaw), | ||||
|                     "Romance Standard Time" => Some(Tz::Europe__Paris), | ||||
|                     "Central European Standard Time" => Some(Tz::Europe__Belgrade), | ||||
|                     "E. Europe Standard Time" => Some(Tz::Europe__Bucharest), | ||||
|                     "FLE Standard Time" => Some(Tz::Europe__Helsinki), | ||||
|                     "GTB Standard Time" => Some(Tz::Europe__Athens), | ||||
|                     "Russian Standard Time" => Some(Tz::Europe__Moscow), | ||||
|                     "Turkey Standard Time" => Some(Tz::Europe__Istanbul), | ||||
|                      | ||||
|                     // Asia | ||||
|                     "China Standard Time" => Some(Tz::Asia__Shanghai), | ||||
|                     "Tokyo Standard Time" => Some(Tz::Asia__Tokyo), | ||||
|                     "Korea Standard Time" => Some(Tz::Asia__Seoul), | ||||
|                     "Singapore Standard Time" => Some(Tz::Asia__Singapore), | ||||
|                     "India Standard Time" => Some(Tz::Asia__Kolkata), | ||||
|                     "Pakistan Standard Time" => Some(Tz::Asia__Karachi), | ||||
|                     "Bangladesh Standard Time" => Some(Tz::Asia__Dhaka), | ||||
|                     "Thailand Standard Time" => Some(Tz::Asia__Bangkok), | ||||
|                     "SE Asia Standard Time" => Some(Tz::Asia__Bangkok), | ||||
|                     "Myanmar Standard Time" => Some(Tz::Asia__Yangon), | ||||
|                     "Sri Lanka Standard Time" => Some(Tz::Asia__Colombo), | ||||
|                     "Nepal Standard Time" => Some(Tz::Asia__Kathmandu), | ||||
|                     "Central Asia Standard Time" => Some(Tz::Asia__Almaty), | ||||
|                     "West Asia Standard Time" => Some(Tz::Asia__Tashkent), | ||||
|                     "Afghanistan Standard Time" => Some(Tz::Asia__Kabul), | ||||
|                     "Iran Standard Time" => Some(Tz::Asia__Tehran), | ||||
|                     "Arabian Standard Time" => Some(Tz::Asia__Dubai), | ||||
|                     "Arab Standard Time" => Some(Tz::Asia__Riyadh), | ||||
|                     "Israel Standard Time" => Some(Tz::Asia__Jerusalem), | ||||
|                     "Jordan Standard Time" => Some(Tz::Asia__Amman), | ||||
|                     "Syria Standard Time" => Some(Tz::Asia__Damascus), | ||||
|                     "Middle East Standard Time" => Some(Tz::Asia__Beirut), | ||||
|                     "Egypt Standard Time" => Some(Tz::Africa__Cairo), | ||||
|                     "South Africa Standard Time" => Some(Tz::Africa__Johannesburg), | ||||
|                     "E. Africa Standard Time" => Some(Tz::Africa__Nairobi), | ||||
|                     "W. Central Africa Standard Time" => Some(Tz::Africa__Lagos), | ||||
|                      | ||||
|                     // Asia Pacific | ||||
|                     "AUS Eastern Standard Time" => Some(Tz::Australia__Sydney), | ||||
|                     "AUS Central Standard Time" => Some(Tz::Australia__Darwin), | ||||
|                     "W. Australia Standard Time" => Some(Tz::Australia__Perth), | ||||
|                     "Tasmania Standard Time" => Some(Tz::Australia__Hobart), | ||||
|                     "New Zealand Standard Time" => Some(Tz::Pacific__Auckland), | ||||
|                     "Fiji Standard Time" => Some(Tz::Pacific__Fiji), | ||||
|                     "Tonga Standard Time" => Some(Tz::Pacific__Tongatapu), | ||||
|                      | ||||
|                     // South America | ||||
|                     "Argentina Standard Time" => Some(Tz::America__Buenos_Aires), | ||||
|                     "E. South America Standard Time" => Some(Tz::America__Sao_Paulo), | ||||
|                     "SA Eastern Standard Time" => Some(Tz::America__Cayenne), | ||||
|                     "SA Pacific Standard Time" => Some(Tz::America__Bogota), | ||||
|                     "SA Western Standard Time" => Some(Tz::America__La_Paz), | ||||
|                     "Pacific SA Standard Time" => Some(Tz::America__Santiago), | ||||
|                     "Venezuela Standard Time" => Some(Tz::America__Caracas), | ||||
|                     "Montevideo Standard Time" => Some(Tz::America__Montevideo), | ||||
|                      | ||||
|                     // Try parsing as IANA name | ||||
|                     _ => tzid_str.parse::<Tz>().ok() | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             if let Some(tz) = tz_result { | ||||
|                 if let Some(dt_with_tz) = tz.from_local_datetime(&naive_dt).single() { | ||||
|                     return Some(dt_with_tz.with_timezone(&Utc)); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         // If no timezone info or parsing failed, treat as UTC (safer than local time assumptions) | ||||
|         return Some(chrono::TimeZone::from_utc_datetime(&Utc, &naive_dt)); | ||||
|     } | ||||
|  | ||||
|     None | ||||
| } | ||||
							
								
								
									
										15
									
								
								backend/src/handlers/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								backend/src/handlers/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| pub mod auth; | ||||
| pub mod calendar; | ||||
| pub mod events; | ||||
| pub mod external_calendars; | ||||
| pub mod ics_fetcher; | ||||
| pub mod preferences; | ||||
| pub mod series; | ||||
|  | ||||
| pub use auth::*; | ||||
| pub use calendar::*; | ||||
| pub use events::*; | ||||
| pub use external_calendars::*; | ||||
| pub use ics_fetcher::*; | ||||
| pub use preferences::*; | ||||
| pub use series::*; | ||||
| @@ -40,6 +40,7 @@ pub async fn get_preferences( | ||||
|         calendar_theme: preferences.calendar_theme, | ||||
|         calendar_style: preferences.calendar_style, | ||||
|         calendar_colors: preferences.calendar_colors, | ||||
|         last_used_calendar: preferences.last_used_calendar, | ||||
|     })) | ||||
| } | ||||
|  | ||||
| @@ -85,6 +86,9 @@ pub async fn update_preferences( | ||||
|     if request.calendar_colors.is_some() { | ||||
|         preferences.calendar_colors = request.calendar_colors; | ||||
|     } | ||||
|     if request.last_used_calendar.is_some() { | ||||
|         preferences.last_used_calendar = request.last_used_calendar; | ||||
|     } | ||||
|  | ||||
|     prefs_repo | ||||
|         .update(&preferences) | ||||
| @@ -100,6 +104,7 @@ pub async fn update_preferences( | ||||
|             calendar_theme: preferences.calendar_theme, | ||||
|             calendar_style: preferences.calendar_style, | ||||
|             calendar_colors: preferences.calendar_colors, | ||||
|             last_used_calendar: preferences.last_used_calendar, | ||||
|         }), | ||||
|     )) | ||||
| } | ||||
|   | ||||
| @@ -130,9 +130,17 @@ pub async fn create_event_series( | ||||
|             .and_hms_opt(23, 59, 59) | ||||
|             .ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?; | ||||
|  | ||||
|         // Convert from local time to UTC | ||||
|         let start_local = chrono::Local.from_local_datetime(&start_dt) | ||||
|             .single() | ||||
|             .ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?; | ||||
|         let end_local = chrono::Local.from_local_datetime(&end_dt) | ||||
|             .single() | ||||
|             .ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?; | ||||
|          | ||||
|         ( | ||||
|             chrono::Utc.from_utc_datetime(&start_dt), | ||||
|             chrono::Utc.from_utc_datetime(&end_dt), | ||||
|             start_local.with_timezone(&chrono::Utc), | ||||
|             end_local.with_timezone(&chrono::Utc), | ||||
|         ) | ||||
|     } else { | ||||
|         // Parse times for timed events | ||||
| @@ -163,9 +171,17 @@ pub async fn create_event_series( | ||||
|             start_date.and_time(end_time) | ||||
|         }; | ||||
|  | ||||
|         // Convert from local time to UTC | ||||
|         let start_local = chrono::Local.from_local_datetime(&start_dt) | ||||
|             .single() | ||||
|             .ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?; | ||||
|         let end_local = chrono::Local.from_local_datetime(&end_dt) | ||||
|             .single() | ||||
|             .ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?; | ||||
|          | ||||
|         ( | ||||
|             chrono::Utc.from_utc_datetime(&start_dt), | ||||
|             chrono::Utc.from_utc_datetime(&end_dt), | ||||
|             start_local.with_timezone(&chrono::Utc), | ||||
|             end_local.with_timezone(&chrono::Utc), | ||||
|         ) | ||||
|     }; | ||||
|  | ||||
| @@ -246,8 +262,8 @@ pub async fn update_event_series( | ||||
|     Json(request): Json<UpdateEventSeriesRequest>, | ||||
| ) -> Result<Json<UpdateEventSeriesResponse>, ApiError> { | ||||
|     println!( | ||||
|         "🔄 Update event series request received: series_uid='{}', update_scope='{}'", | ||||
|         request.series_uid, request.update_scope | ||||
|         "🔄 Update event series request received: series_uid='{}', update_scope='{}', recurrence_count={:?}, recurrence_end_date={:?}", | ||||
|         request.series_uid, request.update_scope, request.recurrence_count, request.recurrence_end_date | ||||
|     ); | ||||
|  | ||||
|     // Extract and verify token | ||||
| @@ -381,8 +397,9 @@ pub async fn update_event_series( | ||||
|     }; | ||||
|  | ||||
|     let (start_datetime, end_datetime) = if request.all_day { | ||||
|         // For all-day events, use noon UTC to avoid timezone boundary issues | ||||
|         let start_dt = start_date | ||||
|             .and_hms_opt(0, 0, 0) | ||||
|             .and_hms_opt(12, 0, 0) | ||||
|             .ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?; | ||||
|  | ||||
|         // For all-day events, also preserve the original date pattern | ||||
| @@ -398,9 +415,10 @@ pub async fn update_event_series( | ||||
|         }; | ||||
|  | ||||
|         let end_dt = end_date | ||||
|             .and_hms_opt(23, 59, 59) | ||||
|             .and_hms_opt(12, 0, 0) | ||||
|             .ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?; | ||||
|  | ||||
|         // For all-day events, use UTC directly (no local conversion needed) | ||||
|         ( | ||||
|             chrono::Utc.from_utc_datetime(&start_dt), | ||||
|             chrono::Utc.from_utc_datetime(&end_dt), | ||||
| @@ -438,9 +456,17 @@ pub async fn update_event_series( | ||||
|             (chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc() | ||||
|         }; | ||||
|  | ||||
|         // Convert from local time to UTC | ||||
|         let start_local = chrono::Local.from_local_datetime(&start_dt) | ||||
|             .single() | ||||
|             .ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?; | ||||
|         let end_local = chrono::Local.from_local_datetime(&end_dt) | ||||
|             .single() | ||||
|             .ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?; | ||||
|          | ||||
|         ( | ||||
|             chrono::Utc.from_utc_datetime(&start_dt), | ||||
|             chrono::Utc.from_utc_datetime(&end_dt), | ||||
|             start_local.with_timezone(&chrono::Utc), | ||||
|             end_local.with_timezone(&chrono::Utc), | ||||
|         ) | ||||
|     }; | ||||
|  | ||||
| @@ -733,9 +759,36 @@ fn update_entire_series( | ||||
|     updated_event.last_modified = Some(now); | ||||
|     // Keep original created timestamp to preserve event history | ||||
|  | ||||
|     // For simple updates (like drag operations), preserve the existing RRULE | ||||
|     // For more complex updates, we might need to regenerate it, but for now keep it simple | ||||
|     // updated_event.rrule remains unchanged from the clone | ||||
|     // Update RRULE if recurrence parameters are provided | ||||
|     if let Some(ref existing_rrule) = updated_event.rrule { | ||||
|         let mut new_rrule = existing_rrule.clone(); | ||||
|         println!("🔄 Original RRULE: {}", existing_rrule); | ||||
|          | ||||
|         // Update COUNT if provided | ||||
|         if let Some(count) = request.recurrence_count { | ||||
|             println!("🔄 Updating RRULE with new COUNT: {}", count); | ||||
|             // Remove old COUNT or UNTIL parameters | ||||
|             new_rrule = new_rrule.split(';') | ||||
|                 .filter(|part| !part.starts_with("COUNT=") && !part.starts_with("UNTIL=")) | ||||
|                 .collect::<Vec<_>>() | ||||
|                 .join(";"); | ||||
|             // Add new COUNT | ||||
|             new_rrule = format!("{};COUNT={}", new_rrule, count); | ||||
|         } else if let Some(ref end_date) = request.recurrence_end_date { | ||||
|             println!("🔄 Updating RRULE with new UNTIL: {}", end_date); | ||||
|             // Remove old COUNT or UNTIL parameters | ||||
|             new_rrule = new_rrule.split(';') | ||||
|                 .filter(|part| !part.starts_with("COUNT=") && !part.starts_with("UNTIL=")) | ||||
|                 .collect::<Vec<_>>() | ||||
|                 .join(";"); | ||||
|             // Add new UNTIL (convert YYYY-MM-DD to YYYYMMDD format) | ||||
|             let until_date = end_date.replace("-", ""); | ||||
|             new_rrule = format!("{};UNTIL={}", new_rrule, until_date); | ||||
|         } | ||||
|          | ||||
|         println!("🔄 Updated RRULE: {}", new_rrule); | ||||
|         updated_event.rrule = Some(new_rrule); | ||||
|     } | ||||
|  | ||||
|     // Copy the updated event back to existing_event for the main handler | ||||
|     *existing_event = updated_event.clone(); | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| use axum::{ | ||||
|     response::Json, | ||||
|     routing::{get, post}, | ||||
|     routing::{delete, get, post}, | ||||
|     Router, | ||||
| }; | ||||
| use std::sync::Arc; | ||||
| @@ -72,6 +72,12 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> { | ||||
|         .route("/api/preferences", get(handlers::get_preferences)) | ||||
|         .route("/api/preferences", post(handlers::update_preferences)) | ||||
|         .route("/api/auth/logout", post(handlers::logout)) | ||||
|         // External calendars endpoints | ||||
|         .route("/api/external-calendars", get(handlers::get_external_calendars)) | ||||
|         .route("/api/external-calendars", post(handlers::create_external_calendar)) | ||||
|         .route("/api/external-calendars/:id", post(handlers::update_external_calendar)) | ||||
|         .route("/api/external-calendars/:id", delete(handlers::delete_external_calendar)) | ||||
|         .route("/api/external-calendars/:id/events", get(handlers::fetch_external_calendar_events)) | ||||
|         .layer( | ||||
|             CorsLayer::new() | ||||
|                 .allow_origin(Any) | ||||
|   | ||||
| @@ -30,6 +30,7 @@ pub struct UserPreferencesResponse { | ||||
|     pub calendar_theme: Option<String>, | ||||
|     pub calendar_style: Option<String>, | ||||
|     pub calendar_colors: Option<String>, | ||||
|     pub last_used_calendar: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Deserialize)] | ||||
| @@ -40,6 +41,7 @@ pub struct UpdatePreferencesRequest { | ||||
|     pub calendar_theme: Option<String>, | ||||
|     pub calendar_style: Option<String>, | ||||
|     pub calendar_colors: Option<String>, | ||||
|     pub last_used_calendar: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Serialize)] | ||||
| @@ -54,6 +56,7 @@ pub struct CalendarInfo { | ||||
|     pub path: String, | ||||
|     pub display_name: String, | ||||
|     pub color: String, | ||||
|     pub is_visible: bool, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Deserialize)] | ||||
|   | ||||
| @@ -37,6 +37,7 @@ reqwest = { version = "0.11", features = ["json"] } | ||||
| ical = "0.7" | ||||
| serde = { version = "1.0", features = ["derive"] } | ||||
| serde_json = "1.0" | ||||
| serde-wasm-bindgen = "0.6" | ||||
|  | ||||
| # Date and time handling | ||||
| chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] } | ||||
|   | ||||
| @@ -1,13 +1,14 @@ | ||||
| use crate::components::{ | ||||
|     CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModalV2, DeleteAction, | ||||
|     EditAction, EventClass, EventContextMenu, EventCreationData, EventStatus, RecurrenceType, | ||||
|     ReminderType, RouteHandler, Sidebar, Theme, ViewMode, | ||||
|     CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction, | ||||
|     EditAction, EventContextMenu, EventCreationData, ExternalCalendarModal, RouteHandler,  | ||||
|     Sidebar, Theme, ViewMode, | ||||
| }; | ||||
| use crate::components::sidebar::{Style}; | ||||
| use crate::models::ical::VEvent; | ||||
| use crate::services::{calendar_service::UserInfo, CalendarService}; | ||||
| use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService}; | ||||
| use chrono::NaiveDate; | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
| use gloo_timers::callback::Interval; | ||||
| use wasm_bindgen::JsCast; | ||||
| use web_sys::MouseEvent; | ||||
| use yew::prelude::*; | ||||
| @@ -75,6 +76,12 @@ pub fn App() -> Html { | ||||
|     let _recurring_edit_event = use_state(|| -> Option<VEvent> { None }); | ||||
|     let _recurring_edit_data = use_state(|| -> Option<EventCreationData> { None }); | ||||
|      | ||||
|     // External calendar state | ||||
|     let external_calendars = use_state(|| -> Vec<ExternalCalendar> { Vec::new() }); | ||||
|     let external_calendar_events = use_state(|| -> Vec<VEvent> { Vec::new() }); | ||||
|     let external_calendar_modal_open = use_state(|| false); | ||||
|     let refresh_interval = use_state(|| -> Option<Interval> { None }); | ||||
|  | ||||
|     // Calendar view state - load from localStorage if available | ||||
|     let current_view = use_state(|| { | ||||
|         // Try to load saved view mode from localStorage | ||||
| @@ -302,6 +309,80 @@ pub fn App() -> Html { | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     // Function to refresh external calendars | ||||
|     let refresh_external_calendars = { | ||||
|         let external_calendars = external_calendars.clone(); | ||||
|         let external_calendar_events = external_calendar_events.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             let external_calendars = external_calendars.clone(); | ||||
|             let external_calendar_events = external_calendar_events.clone(); | ||||
|  | ||||
|             wasm_bindgen_futures::spawn_local(async move { | ||||
|                 // Load external calendars | ||||
|                 match CalendarService::get_external_calendars().await { | ||||
|                     Ok(calendars) => { | ||||
|                         external_calendars.set(calendars.clone()); | ||||
|                          | ||||
|                         // Load events for visible external calendars | ||||
|                         let mut all_events = Vec::new(); | ||||
|                         for calendar in calendars { | ||||
|                             if calendar.is_visible { | ||||
|                                 if let Ok(mut events) = CalendarService::fetch_external_calendar_events(calendar.id).await { | ||||
|                                     // Set calendar_path for color matching | ||||
|                                     for event in &mut events { | ||||
|                                         event.calendar_path = Some(format!("external_{}", calendar.id)); | ||||
|                                     } | ||||
|                                     all_events.extend(events); | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                         external_calendar_events.set(all_events); | ||||
|                     } | ||||
|                     Err(err) => { | ||||
|                         web_sys::console::log_1( | ||||
|                             &format!("Failed to load external calendars: {}", err).into(), | ||||
|                         ); | ||||
|                     } | ||||
|                 } | ||||
|             }); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     // Load external calendars when auth token is available and set up auto-refresh | ||||
|     { | ||||
|         let auth_token = auth_token.clone(); | ||||
|         let refresh_external_calendars = refresh_external_calendars.clone(); | ||||
|         let refresh_interval = refresh_interval.clone(); | ||||
|         let external_calendars = external_calendars.clone(); | ||||
|         let external_calendar_events = external_calendar_events.clone(); | ||||
|  | ||||
|         use_effect_with((*auth_token).clone(), move |token| { | ||||
|             if let Some(_) = token { | ||||
|                 // Initial load | ||||
|                 refresh_external_calendars.emit(()); | ||||
|                  | ||||
|                 // Set up 5-minute refresh interval | ||||
|                 let refresh_external_calendars = refresh_external_calendars.clone(); | ||||
|                 let interval = Interval::new(5 * 60 * 1000, move || { | ||||
|                     refresh_external_calendars.emit(()); | ||||
|                 }); | ||||
|                 refresh_interval.set(Some(interval)); | ||||
|             } else { | ||||
|                 // Clear data and interval when logged out | ||||
|                 external_calendars.set(Vec::new()); | ||||
|                 external_calendar_events.set(Vec::new()); | ||||
|                 refresh_interval.set(None); | ||||
|             } | ||||
|  | ||||
|             // Cleanup function | ||||
|             let refresh_interval = refresh_interval.clone(); | ||||
|             move || { | ||||
|                 // Clear interval on cleanup | ||||
|                 refresh_interval.set(None); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     let on_outside_click = { | ||||
|         let color_picker_open = color_picker_open.clone(); | ||||
|         let context_menu_open = context_menu_open.clone(); | ||||
| @@ -413,7 +494,151 @@ pub fn App() -> Html { | ||||
|         let create_event_modal_open = create_event_modal_open.clone(); | ||||
|         let auth_token = auth_token.clone(); | ||||
|         Callback::from(move |event_data: EventCreationData| { | ||||
|             // Check if this is an update operation (has original_uid) or a create operation | ||||
|             if let Some(original_uid) = event_data.original_uid.clone() { | ||||
|                 web_sys::console::log_1(&format!("Updating event via modal: {:?}", event_data).into()); | ||||
|                  | ||||
|                 create_event_modal_open.set(false); | ||||
|                  | ||||
|                 // Handle the update operation using the existing backend update logic | ||||
|                 if let Some(token) = (*auth_token).clone() { | ||||
|                     let event_data_for_update = event_data.clone(); | ||||
|                     wasm_bindgen_futures::spawn_local(async move { | ||||
|                         let calendar_service = CalendarService::new(); | ||||
|  | ||||
|                         // Get CalDAV password from storage | ||||
|                         let password = if let Ok(credentials_str) = | ||||
|                             LocalStorage::get::<String>("caldav_credentials") | ||||
|                         { | ||||
|                             if let Ok(credentials) = | ||||
|                                 serde_json::from_str::<serde_json::Value>(&credentials_str) | ||||
|                             { | ||||
|                                 credentials["password"].as_str().unwrap_or("").to_string() | ||||
|                             } else { | ||||
|                                 String::new() | ||||
|                             } | ||||
|                         } else { | ||||
|                             String::new() | ||||
|                         }; | ||||
|  | ||||
|                         // Convert EventCreationData to update parameters | ||||
|                         let params = event_data_for_update.to_create_event_params(); | ||||
|                          | ||||
|                         // Determine if this is a recurring event update | ||||
|                         let is_recurring = matches!(event_data_for_update.recurrence, crate::components::event_form::RecurrenceType::Daily |  | ||||
|                                                    crate::components::event_form::RecurrenceType::Weekly |  | ||||
|                                                    crate::components::event_form::RecurrenceType::Monthly |  | ||||
|                                                    crate::components::event_form::RecurrenceType::Yearly); | ||||
|                          | ||||
|                         let update_result = if is_recurring && event_data_for_update.edit_scope.is_some() { | ||||
|                             // Use series update endpoint for recurring events | ||||
|                             let edit_action = event_data_for_update.edit_scope.unwrap(); | ||||
|                             let scope = match edit_action { | ||||
|                                 crate::components::EditAction::EditAll => "all_in_series".to_string(), | ||||
|                                 crate::components::EditAction::EditFuture => "this_and_future".to_string(), | ||||
|                                 crate::components::EditAction::EditThis => "this_only".to_string(), | ||||
|                             }; | ||||
|                              | ||||
|                             calendar_service | ||||
|                                 .update_series( | ||||
|                                     &token, | ||||
|                                     &password, | ||||
|                                     original_uid.clone(), | ||||
|                                     params.0,  // title | ||||
|                                     params.1,  // description | ||||
|                                     params.2,  // start_date | ||||
|                                     params.3,  // start_time | ||||
|                                     params.4,  // end_date | ||||
|                                     params.5,  // end_time | ||||
|                                     params.6,  // location | ||||
|                                     params.7,  // all_day | ||||
|                                     params.8,  // status | ||||
|                                     params.9,  // class | ||||
|                                     params.10, // priority | ||||
|                                     params.11, // organizer | ||||
|                                     params.12, // attendees | ||||
|                                     params.13, // categories | ||||
|                                     params.14, // reminder | ||||
|                                     params.15, // recurrence | ||||
|                                     params.16, // recurrence_days | ||||
|                                     params.18, // recurrence_count | ||||
|                                     params.19, // recurrence_until | ||||
|                                     params.17, // calendar_path | ||||
|                                     scope, | ||||
|                                     event_data_for_update.occurrence_date.map(|d| d.format("%Y-%m-%d").to_string()), // occurrence_date | ||||
|                                 ) | ||||
|                                 .await | ||||
|                         } else { | ||||
|                             // Use regular update endpoint for single events | ||||
|                             calendar_service | ||||
|                                 .update_event( | ||||
|                                     &token, | ||||
|                                     &password, | ||||
|                                     original_uid.clone(), | ||||
|                                     params.0,  // title | ||||
|                                     params.1,  // description | ||||
|                                     params.2,  // start_date | ||||
|                                     params.3,  // start_time | ||||
|                                     params.4,  // end_date | ||||
|                                     params.5,  // end_time | ||||
|                                     params.6,  // location | ||||
|                                     params.7,  // all_day | ||||
|                                     params.8,  // status | ||||
|                                     params.9,  // class | ||||
|                                     params.10, // priority | ||||
|                                     params.11, // organizer | ||||
|                                     params.12, // attendees | ||||
|                                     params.13, // categories | ||||
|                                     params.14, // reminder | ||||
|                                     params.15, // recurrence | ||||
|                                     params.16, // recurrence_days | ||||
|                                     params.17, // calendar_path | ||||
|                                     vec![], // exception_dates - empty for simple updates | ||||
|                                     None,   // update_action - None for regular updates | ||||
|                                     None,   // until_date - None for regular updates | ||||
|                                 ) | ||||
|                                 .await | ||||
|                         }; | ||||
|  | ||||
|                         match update_result { | ||||
|                             Ok(_) => { | ||||
|                                 web_sys::console::log_1(&"Event updated successfully via modal".into()); | ||||
|                                 // Trigger a page reload to refresh events from all calendars | ||||
|                                 if let Some(window) = web_sys::window() { | ||||
|                                     let _ = window.location().reload(); | ||||
|                                 } | ||||
|                             } | ||||
|                             Err(err) => { | ||||
|                                 web_sys::console::error_1( | ||||
|                                     &format!("Failed to update event: {}", err).into(), | ||||
|                                 ); | ||||
|                                 web_sys::window() | ||||
|                                     .unwrap() | ||||
|                                     .alert_with_message(&format!("Failed to update event: {}", err)) | ||||
|                                     .unwrap(); | ||||
|                             } | ||||
|                         } | ||||
|                     }); | ||||
|                 } | ||||
|                 return; | ||||
|             } | ||||
|              | ||||
|             web_sys::console::log_1(&format!("Creating event: {:?}", event_data).into()); | ||||
|              | ||||
|             // Save the selected calendar as the last used calendar | ||||
|             if let Some(ref calendar_path) = event_data.selected_calendar { | ||||
|                 let _ = LocalStorage::set("last_used_calendar", calendar_path); | ||||
|                  | ||||
|                 // Also sync to backend asynchronously | ||||
|                 let calendar_path_for_sync = calendar_path.clone(); | ||||
|                 wasm_bindgen_futures::spawn_local(async move { | ||||
|                     let preferences_service = crate::services::preferences::PreferencesService::new(); | ||||
|                     if let Err(e) = preferences_service.update_last_used_calendar(&calendar_path_for_sync).await { | ||||
|                         web_sys::console::warn_1(&format!("Failed to sync last used calendar to backend: {}", e).into()); | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|              | ||||
|             create_event_modal_open.set(false); | ||||
|  | ||||
|             if let Some(_token) = (*auth_token).clone() { | ||||
| @@ -455,6 +680,8 @@ pub fn App() -> Html { | ||||
|                             params.14, // reminder | ||||
|                             params.15, // recurrence | ||||
|                             params.16, // recurrence_days | ||||
|                             params.18, // recurrence_count | ||||
|                             params.19, // recurrence_until | ||||
|                             params.17, // calendar_path | ||||
|                         ) | ||||
|                         .await; | ||||
| @@ -534,18 +761,11 @@ pub fn App() -> Html { | ||||
|                             String::new() | ||||
|                         }; | ||||
|  | ||||
|                         // Convert local times to UTC for backend storage | ||||
|                         let start_utc = new_start | ||||
|                             .and_local_timezone(chrono::Local) | ||||
|                             .unwrap() | ||||
|                             .to_utc(); | ||||
|                         let end_utc = new_end.and_local_timezone(chrono::Local).unwrap().to_utc(); | ||||
|  | ||||
|                         // Format UTC date and time strings for backend | ||||
|                         let start_date = start_utc.format("%Y-%m-%d").to_string(); | ||||
|                         let start_time = start_utc.format("%H:%M").to_string(); | ||||
|                         let end_date = end_utc.format("%Y-%m-%d").to_string(); | ||||
|                         let end_time = end_utc.format("%H:%M").to_string(); | ||||
|                         // Send local time directly to backend (backend will handle UTC conversion) | ||||
|                         let start_date = new_start.format("%Y-%m-%d").to_string(); | ||||
|                         let start_time = new_start.format("%H:%M").to_string(); | ||||
|                         let end_date = new_end.format("%Y-%m-%d").to_string(); | ||||
|                         let end_time = new_end.format("%H:%M").to_string(); | ||||
|  | ||||
|                         // Convert existing event data to string formats for the API | ||||
|                         let status_str = match original_event.status { | ||||
| @@ -624,6 +844,9 @@ pub fn App() -> Html { | ||||
|                                             original_event.categories.join(","), | ||||
|                                             reminder_str.clone(), | ||||
|                                             recurrence_str.clone(), | ||||
|                                             vec![false; 7], | ||||
|                                             None, | ||||
|                                             None, | ||||
|                                             original_event.calendar_path.clone(), | ||||
|                                             scope.clone(), | ||||
|                                             occurrence_date, | ||||
| @@ -783,11 +1006,146 @@ pub fn App() -> Html { | ||||
|                                         let create_modal_open = create_modal_open.clone(); | ||||
|                                         move |_| create_modal_open.set(true) | ||||
|                                     })} | ||||
|                                     on_create_external_calendar={Callback::from({ | ||||
|                                         let external_calendar_modal_open = external_calendar_modal_open.clone(); | ||||
|                                         move |_| external_calendar_modal_open.set(true) | ||||
|                                     })} | ||||
|                                     external_calendars={(*external_calendars).clone()} | ||||
|                                     on_external_calendar_toggle={Callback::from({ | ||||
|                                         let external_calendars = external_calendars.clone(); | ||||
|                                         let external_calendar_events = external_calendar_events.clone(); | ||||
|                                         move |id: i32| { | ||||
|                                             let external_calendars = external_calendars.clone(); | ||||
|                                             let external_calendar_events = external_calendar_events.clone(); | ||||
|                                             wasm_bindgen_futures::spawn_local(async move { | ||||
|                                                 // Find the calendar and toggle its visibility | ||||
|                                                 let mut calendars = (*external_calendars).clone(); | ||||
|                                                 if let Some(calendar) = calendars.iter_mut().find(|c| c.id == id) { | ||||
|                                                     calendar.is_visible = !calendar.is_visible; | ||||
|                                                      | ||||
|                                                     // Update on server | ||||
|                                                     if let Err(err) = CalendarService::update_external_calendar( | ||||
|                                                         calendar.id, | ||||
|                                                         &calendar.name, | ||||
|                                                         &calendar.url, | ||||
|                                                         &calendar.color, | ||||
|                                                         calendar.is_visible, | ||||
|                                                     ).await { | ||||
|                                                         web_sys::console::log_1( | ||||
|                                                             &format!("Failed to update external calendar: {}", err).into(), | ||||
|                                                         ); | ||||
|                                                         return; | ||||
|                                                     } | ||||
|                                                      | ||||
|                                                     external_calendars.set(calendars.clone()); | ||||
|                                                      | ||||
|                                                     // Reload events for all visible external calendars   | ||||
|                                                     let mut all_events = Vec::new(); | ||||
|                                                     for cal in calendars { | ||||
|                                                         if cal.is_visible { | ||||
|                                                             if let Ok(mut events) = CalendarService::fetch_external_calendar_events(cal.id).await { | ||||
|                                                                 // Set calendar_path for color matching | ||||
|                                                                 for event in &mut events { | ||||
|                                                                     event.calendar_path = Some(format!("external_{}", cal.id)); | ||||
|                                                                 } | ||||
|                                                                 all_events.extend(events); | ||||
|                                                             } | ||||
|                                                         } | ||||
|                                                     } | ||||
|                                                     external_calendar_events.set(all_events); | ||||
|                                                 } | ||||
|                                             }); | ||||
|                                         } | ||||
|                                     })} | ||||
|                                     on_external_calendar_delete={Callback::from({ | ||||
|                                         let external_calendars = external_calendars.clone(); | ||||
|                                         let external_calendar_events = external_calendar_events.clone(); | ||||
|                                         move |id: i32| { | ||||
|                                             let external_calendars = external_calendars.clone(); | ||||
|                                             let external_calendar_events = external_calendar_events.clone(); | ||||
|                                             wasm_bindgen_futures::spawn_local(async move { | ||||
|                                                 // Delete the external calendar from the server | ||||
|                                                 if let Err(err) = CalendarService::delete_external_calendar(id).await { | ||||
|                                                     web_sys::console::log_1( | ||||
|                                                         &format!("Failed to delete external calendar: {}", err).into(), | ||||
|                                                     ); | ||||
|                                                     return; | ||||
|                                                 } | ||||
|                                                  | ||||
|                                                 // Remove calendar from local state | ||||
|                                                 let mut calendars = (*external_calendars).clone(); | ||||
|                                                 calendars.retain(|c| c.id != id); | ||||
|                                                 external_calendars.set(calendars.clone()); | ||||
|                                                  | ||||
|                                                 // Remove events from this calendar | ||||
|                                                 let mut events = (*external_calendar_events).clone(); | ||||
|                                                 events.retain(|e| { | ||||
|                                                     if let Some(ref calendar_path) = e.calendar_path { | ||||
|                                                         calendar_path != &format!("external_{}", id) | ||||
|                                                     } else { | ||||
|                                                         true | ||||
|                                                     } | ||||
|                                                 }); | ||||
|                                                 external_calendar_events.set(events); | ||||
|                                             }); | ||||
|                                         } | ||||
|                                     })} | ||||
|                                     on_external_calendar_refresh={Callback::from({ | ||||
|                                         let external_calendar_events = external_calendar_events.clone(); | ||||
|                                         let external_calendars = external_calendars.clone(); | ||||
|                                         move |id: i32| { | ||||
|                                             let external_calendar_events = external_calendar_events.clone(); | ||||
|                                             let external_calendars = external_calendars.clone(); | ||||
|                                             wasm_bindgen_futures::spawn_local(async move { | ||||
|                                                 // Force refresh of this specific calendar | ||||
|                                                 if let Ok(mut events) = CalendarService::fetch_external_calendar_events(id).await { | ||||
|                                                     // Set calendar_path for color matching | ||||
|                                                     for event in &mut events { | ||||
|                                                         event.calendar_path = Some(format!("external_{}", id)); | ||||
|                                                     } | ||||
|                                                      | ||||
|                                                     // Update events for this calendar | ||||
|                                                     let mut all_events = (*external_calendar_events).clone(); | ||||
|                                                     // Remove old events from this calendar | ||||
|                                                     all_events.retain(|e| { | ||||
|                                                         if let Some(ref calendar_path) = e.calendar_path { | ||||
|                                                             calendar_path != &format!("external_{}", id) | ||||
|                                                         } else { | ||||
|                                                             true | ||||
|                                                         } | ||||
|                                                     }); | ||||
|                                                     // Add new events | ||||
|                                                     all_events.extend(events); | ||||
|                                                     external_calendar_events.set(all_events); | ||||
|                                                      | ||||
|                                                     // Update the last_fetched timestamp in calendars list | ||||
|                                                     if let Ok(calendars) = CalendarService::get_external_calendars().await { | ||||
|                                                         external_calendars.set(calendars); | ||||
|                                                     } | ||||
|                                                 } | ||||
|                                             }); | ||||
|                                         } | ||||
|                                     })} | ||||
|                                     color_picker_open={(*color_picker_open).clone()} | ||||
|                                     on_color_change={on_color_change} | ||||
|                                     on_color_picker_toggle={on_color_picker_toggle} | ||||
|                                     available_colors={(*available_colors).clone()} | ||||
|                                     on_calendar_context_menu={on_calendar_context_menu} | ||||
|                                     on_calendar_visibility_toggle={Callback::from({ | ||||
|                                         let user_info = user_info.clone(); | ||||
|                                         move |calendar_path: String| { | ||||
|                                             let user_info = user_info.clone(); | ||||
|                                             wasm_bindgen_futures::spawn_local(async move { | ||||
|                                                 if let Some(mut info) = (*user_info).clone() { | ||||
|                                                     // Toggle the visibility | ||||
|                                                     if let Some(calendar) = info.calendars.iter_mut().find(|c| c.path == calendar_path) { | ||||
|                                                         calendar.is_visible = !calendar.is_visible; | ||||
|                                                         user_info.set(Some(info)); | ||||
|                                                     } | ||||
|                                                 } | ||||
|                                             }); | ||||
|                                         } | ||||
|                                     })} | ||||
|                                     current_view={(*current_view).clone()} | ||||
|                                     on_view_change={on_view_change} | ||||
|                                     current_theme={(*current_theme).clone()} | ||||
| @@ -800,6 +1158,8 @@ pub fn App() -> Html { | ||||
|                                         auth_token={(*auth_token).clone()} | ||||
|                                         user_info={(*user_info).clone()} | ||||
|                                         on_login={on_login.clone()} | ||||
|                                         external_calendar_events={(*external_calendar_events).clone()} | ||||
|                                         external_calendars={(*external_calendars).clone()} | ||||
|                                         on_event_context_menu={Some(on_event_context_menu.clone())} | ||||
|                                         on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())} | ||||
|                                         view={(*current_view).clone()} | ||||
| @@ -1031,7 +1391,7 @@ pub fn App() -> Html { | ||||
|                     on_create_event={on_create_event_click} | ||||
|                 /> | ||||
|  | ||||
|                 <CreateEventModalV2 | ||||
|                 <CreateEventModal | ||||
|                     is_open={*create_event_modal_open} | ||||
|                     selected_date={(*selected_date_for_event).clone()} | ||||
|                     initial_start_time={None} | ||||
| @@ -1052,6 +1412,59 @@ pub fn App() -> Html { | ||||
|                     on_create={on_event_create} | ||||
|                     available_calendars={user_info.as_ref().map(|ui| ui.calendars.clone()).unwrap_or_default()} | ||||
|                 /> | ||||
|  | ||||
|                 <ExternalCalendarModal | ||||
|                     is_open={*external_calendar_modal_open} | ||||
|                     on_close={Callback::from({ | ||||
|                         let external_calendar_modal_open = external_calendar_modal_open.clone(); | ||||
|                         move |_| external_calendar_modal_open.set(false) | ||||
|                     })} | ||||
|                     on_success={Callback::from({ | ||||
|                         let external_calendars = external_calendars.clone(); | ||||
|                         let external_calendar_events = external_calendar_events.clone(); | ||||
|                         move |new_calendar_id: i32| { | ||||
|                             let external_calendars = external_calendars.clone(); | ||||
|                             let external_calendar_events = external_calendar_events.clone(); | ||||
|                             wasm_bindgen_futures::spawn_local(async move { | ||||
|                                 // First, refresh the calendar list to get the new calendar | ||||
|                                 match CalendarService::get_external_calendars().await { | ||||
|                                     Ok(calendars) => { | ||||
|                                         external_calendars.set(calendars.clone()); | ||||
|                                          | ||||
|                                         // Then immediately fetch events for the new calendar if it's visible | ||||
|                                         if let Some(new_calendar) = calendars.iter().find(|c| c.id == new_calendar_id) { | ||||
|                                             if new_calendar.is_visible { | ||||
|                                                 match CalendarService::fetch_external_calendar_events(new_calendar_id).await { | ||||
|                                                     Ok(mut events) => { | ||||
|                                                         // Set calendar_path for color matching | ||||
|                                                         for event in &mut events { | ||||
|                                                             event.calendar_path = Some(format!("external_{}", new_calendar_id)); | ||||
|                                                         } | ||||
|                                                          | ||||
|                                                         // Add the new calendar's events to existing events | ||||
|                                                         let mut all_events = (*external_calendar_events).clone(); | ||||
|                                                         all_events.extend(events); | ||||
|                                                         external_calendar_events.set(all_events); | ||||
|                                                     } | ||||
|                                                     Err(e) => { | ||||
|                                                         web_sys::console::log_1( | ||||
|                                                             &format!("Failed to fetch events for new calendar {}: {}", new_calendar_id, e).into(), | ||||
|                                                         ); | ||||
|                                                     } | ||||
|                                                 } | ||||
|                                             } | ||||
|                                         } | ||||
|                                     } | ||||
|                                     Err(err) => { | ||||
|                                         web_sys::console::log_1( | ||||
|                                             &format!("Failed to refresh calendars after creation: {}", err).into(), | ||||
|                                         ); | ||||
|                                     } | ||||
|                                 } | ||||
|                             }); | ||||
|                         } | ||||
|                     })} | ||||
|                 /> | ||||
|             </div> | ||||
|         </BrowserRouter> | ||||
|     } | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| use crate::components::{ | ||||
|     CalendarHeader, CreateEventModalV2, EventCreationData, EventModal, MonthView, ViewMode, WeekView, | ||||
|     CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView, | ||||
| }; | ||||
| use crate::models::ical::VEvent; | ||||
| use crate::services::{calendar_service::UserInfo, CalendarService}; | ||||
| use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService}; | ||||
| use chrono::{Datelike, Duration, Local, NaiveDate}; | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
| use std::collections::HashMap; | ||||
| @@ -14,6 +14,10 @@ pub struct CalendarProps { | ||||
|     #[prop_or_default] | ||||
|     pub user_info: Option<UserInfo>, | ||||
|     #[prop_or_default] | ||||
|     pub external_calendar_events: Vec<VEvent>, | ||||
|     #[prop_or_default] | ||||
|     pub external_calendars: Vec<ExternalCalendar>, | ||||
|     #[prop_or_default] | ||||
|     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, | ||||
|     #[prop_or_default] | ||||
|     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>, | ||||
| @@ -101,10 +105,14 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|         let loading = loading.clone(); | ||||
|         let error = error.clone(); | ||||
|         let current_date = current_date.clone(); | ||||
|         let external_events = props.external_calendar_events.clone(); // Clone before the effect | ||||
|         let view = props.view.clone(); // Clone before the effect | ||||
|          | ||||
|         use_effect_with((*current_date, props.view.clone()), move |(date, _view)| { | ||||
|         use_effect_with((*current_date, view.clone(), external_events.len(), props.user_info.clone()), move |(date, _view, _external_len, user_info)| { | ||||
|             let auth_token: Option<String> = LocalStorage::get("auth_token").ok(); | ||||
|             let date = *date; // Clone the date to avoid lifetime issues | ||||
|             let external_events = external_events.clone(); // Clone external events to avoid lifetime issues | ||||
|             let user_info = user_info.clone(); // Clone user_info to avoid lifetime issues | ||||
|              | ||||
|             if let Some(token) = auth_token { | ||||
|                 let events = events.clone(); | ||||
| @@ -141,7 +149,38 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                         .await | ||||
|                     { | ||||
|                         Ok(vevents) => { | ||||
|                             let grouped_events = CalendarService::group_events_by_date(vevents); | ||||
|                             // Filter CalDAV events based on calendar visibility | ||||
|                             let mut filtered_events = if let Some(user_info) = user_info.as_ref() { | ||||
|                                 vevents.into_iter() | ||||
|                                     .filter(|event| { | ||||
|                                         if let Some(calendar_path) = event.calendar_path.as_ref() { | ||||
|                                             // Find the calendar info for this event | ||||
|                                             user_info.calendars.iter() | ||||
|                                                 .find(|cal| &cal.path == calendar_path) | ||||
|                                                 .map(|cal| cal.is_visible) | ||||
|                                                 .unwrap_or(true) // Default to visible if not found | ||||
|                                         } else { | ||||
|                                             true // Show events without calendar path | ||||
|                                         } | ||||
|                                     }) | ||||
|                                     .collect() | ||||
|                             } else { | ||||
|                                 vevents // Show all events if no user info | ||||
|                             }; | ||||
|                              | ||||
|                             // Mark external events as external by adding a special category | ||||
|                             let marked_external_events: Vec<VEvent> = external_events | ||||
|                                 .into_iter() | ||||
|                                 .map(|mut event| { | ||||
|                                     // Add a special category to identify external events | ||||
|                                     event.categories.push("__EXTERNAL_CALENDAR__".to_string()); | ||||
|                                     event | ||||
|                                 }) | ||||
|                                 .collect(); | ||||
|                              | ||||
|                             filtered_events.extend(marked_external_events); | ||||
|                              | ||||
|                             let grouped_events = CalendarService::group_events_by_date(filtered_events); | ||||
|                             events.set(grouped_events); | ||||
|                             loading.set(false); | ||||
|                         } | ||||
| @@ -452,6 +491,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                                 on_event_click={on_event_click.clone()} | ||||
|                                 refreshing_event_uid={(*refreshing_event_uid).clone()} | ||||
|                                 user_info={props.user_info.clone()} | ||||
|                                 external_calendars={props.external_calendars.clone()} | ||||
|                                 on_event_context_menu={props.on_event_context_menu.clone()} | ||||
|                                 on_calendar_context_menu={props.on_calendar_context_menu.clone()} | ||||
|                                 selected_date={Some(*selected_date)} | ||||
| @@ -467,6 +507,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                             on_event_click={on_event_click.clone()} | ||||
|                             refreshing_event_uid={(*refreshing_event_uid).clone()} | ||||
|                             user_info={props.user_info.clone()} | ||||
|                             external_calendars={props.external_calendars.clone()} | ||||
|                             on_event_context_menu={props.on_event_context_menu.clone()} | ||||
|                             on_calendar_context_menu={props.on_calendar_context_menu.clone()} | ||||
|                             on_create_event={Some(on_create_event)} | ||||
| @@ -492,7 +533,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|             /> | ||||
|  | ||||
|             // Create event modal | ||||
|             <CreateEventModalV2 | ||||
|             <CreateEventModal | ||||
|                 is_open={*show_create_modal} | ||||
|                 selected_date={create_event_data.as_ref().map(|(date, _, _)| *date)} | ||||
|                 event_to_edit={None} | ||||
|   | ||||
| @@ -10,6 +10,7 @@ pub struct CalendarListItemProps { | ||||
|     pub on_color_picker_toggle: Callback<String>,    // calendar_path | ||||
|     pub available_colors: Vec<String>, | ||||
|     pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path) | ||||
|     pub on_visibility_toggle: Callback<String>,      // calendar_path | ||||
| } | ||||
|  | ||||
| #[function_component(CalendarListItem)] | ||||
| @@ -32,8 +33,22 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html { | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_visibility_toggle = { | ||||
|         let cal_path = props.calendar.path.clone(); | ||||
|         let on_visibility_toggle = props.on_visibility_toggle.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             on_visibility_toggle.emit(cal_path.clone()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}> | ||||
|             <div class="calendar-info"> | ||||
|                 <input | ||||
|                     type="checkbox" | ||||
|                     checked={props.calendar.is_visible} | ||||
|                     onchange={on_visibility_toggle} | ||||
|                 /> | ||||
|                 <span class="calendar-color" | ||||
|                       style={format!("background-color: {}", props.calendar.color)} | ||||
|                       onclick={on_color_click}> | ||||
| @@ -70,6 +85,7 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html { | ||||
|                     } | ||||
|                 </span> | ||||
|                 <span class="calendar-name">{&props.calendar.display_name}</span> | ||||
|             </div> | ||||
|         </li> | ||||
|     } | ||||
| } | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,210 +0,0 @@ | ||||
| use crate::components::event_form::*; | ||||
| use crate::components::create_event_modal::{EventCreationData}; // Use the existing types | ||||
| use crate::components::{EditAction}; | ||||
| use crate::models::ical::VEvent; | ||||
| use crate::services::calendar_service::CalendarInfo; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct CreateEventModalProps { | ||||
|     pub is_open: bool, | ||||
|     pub on_close: Callback<()>, | ||||
|     pub on_create: Callback<EventCreationData>, | ||||
|     pub available_calendars: Vec<CalendarInfo>, | ||||
|     pub selected_date: Option<chrono::NaiveDate>, | ||||
|     pub initial_start_time: Option<chrono::NaiveTime>, | ||||
|     pub initial_end_time: Option<chrono::NaiveTime>, | ||||
|     #[prop_or_default] | ||||
|     pub event_to_edit: Option<VEvent>, | ||||
|     #[prop_or_default] | ||||
|     pub edit_scope: Option<EditAction>, | ||||
| } | ||||
|  | ||||
| #[function_component(CreateEventModalV2)] | ||||
| pub fn create_event_modal_v2(props: &CreateEventModalProps) -> Html { | ||||
|     let active_tab = use_state(|| ModalTab::default()); | ||||
|     let event_data = use_state(|| EventCreationData::default()); | ||||
|  | ||||
|     // Initialize data when modal opens | ||||
|     { | ||||
|         let event_data = event_data.clone(); | ||||
|         let is_open = props.is_open; | ||||
|         let event_to_edit = props.event_to_edit.clone(); | ||||
|         let selected_date = props.selected_date; | ||||
|         let initial_start_time = props.initial_start_time; | ||||
|         let initial_end_time = props.initial_end_time; | ||||
|         let edit_scope = props.edit_scope.clone(); | ||||
|         let available_calendars = props.available_calendars.clone(); | ||||
|          | ||||
|         use_effect_with(is_open, move |&is_open| { | ||||
|             if is_open { | ||||
|                 let mut data = if let Some(_event) = &event_to_edit { | ||||
|                     // TODO: Convert VEvent to EventCreationData | ||||
|                     EventCreationData::default() | ||||
|                 } else if let Some(date) = selected_date { | ||||
|                     let mut data = EventCreationData::default(); | ||||
|                     data.start_date = date; | ||||
|                     data.end_date = date; | ||||
|                     if let Some(start_time) = initial_start_time { | ||||
|                         data.start_time = start_time; | ||||
|                     } | ||||
|                     if let Some(end_time) = initial_end_time { | ||||
|                         data.end_time = end_time; | ||||
|                     } | ||||
|                     data | ||||
|                 } else { | ||||
|                     EventCreationData::default() | ||||
|                 }; | ||||
|  | ||||
|                 // Set default calendar | ||||
|                 if data.selected_calendar.is_none() && !available_calendars.is_empty() { | ||||
|                     data.selected_calendar = Some(available_calendars[0].path.clone()); | ||||
|                 } | ||||
|  | ||||
|                 // Set edit scope if provided | ||||
|                 if let Some(scope) = &edit_scope { | ||||
|                     data.edit_scope = Some(scope.clone()); | ||||
|                 } | ||||
|  | ||||
|                 event_data.set(data); | ||||
|             } | ||||
|             || () | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     if !props.is_open { | ||||
|         return html! {}; | ||||
|     } | ||||
|  | ||||
|     let on_backdrop_click = { | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |e: MouseEvent| { | ||||
|             if e.target() == e.current_target() { | ||||
|                 on_close.emit(()); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let switch_to_tab = { | ||||
|         let active_tab = active_tab.clone(); | ||||
|         Callback::from(move |tab: ModalTab| { | ||||
|             active_tab.set(tab); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_save = { | ||||
|         let event_data = event_data.clone(); | ||||
|         let on_create = props.on_create.clone(); | ||||
|         Callback::from(move |_: MouseEvent| { | ||||
|             on_create.emit((*event_data).clone()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_close = props.on_close.clone(); | ||||
|     let on_close_header = on_close.clone(); | ||||
|  | ||||
|     let tab_props = TabProps { | ||||
|         data: event_data.clone(), | ||||
|         available_calendars: props.available_calendars.clone(), | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <div class="modal-backdrop" onclick={on_backdrop_click}> | ||||
|             <div class="modal-content create-event-modal"> | ||||
|                 <div class="modal-header"> | ||||
|                     <h3> | ||||
|                         {if props.event_to_edit.is_some() { "Edit Event" } else { "Create Event" }} | ||||
|                     </h3> | ||||
|                     <button class="modal-close" onclick={Callback::from(move |_| on_close_header.emit(()))}> | ||||
|                         {"×"} | ||||
|                     </button> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="modal-tabs"> | ||||
|                     <div class="tab-navigation"> | ||||
|                         <button | ||||
|                             class={if *active_tab == ModalTab::BasicDetails { "tab-button active" } else { "tab-button" }} | ||||
|                             onclick={{ | ||||
|                                 let switch_to_tab = switch_to_tab.clone(); | ||||
|                                 Callback::from(move |_| switch_to_tab.emit(ModalTab::BasicDetails)) | ||||
|                             }} | ||||
|                         > | ||||
|                             {"Basic"} | ||||
|                         </button> | ||||
|                         <button | ||||
|                             class={if *active_tab == ModalTab::Advanced { "tab-button active" } else { "tab-button" }} | ||||
|                             onclick={{ | ||||
|                                 let switch_to_tab = switch_to_tab.clone(); | ||||
|                                 Callback::from(move |_| switch_to_tab.emit(ModalTab::Advanced)) | ||||
|                             }} | ||||
|                         > | ||||
|                             {"Advanced"} | ||||
|                         </button> | ||||
|                         <button | ||||
|                             class={if *active_tab == ModalTab::People { "tab-button active" } else { "tab-button" }} | ||||
|                             onclick={{ | ||||
|                                 let switch_to_tab = switch_to_tab.clone(); | ||||
|                                 Callback::from(move |_| switch_to_tab.emit(ModalTab::People)) | ||||
|                             }} | ||||
|                         > | ||||
|                             {"People"} | ||||
|                         </button> | ||||
|                         <button | ||||
|                             class={if *active_tab == ModalTab::Categories { "tab-button active" } else { "tab-button" }} | ||||
|                             onclick={{ | ||||
|                                 let switch_to_tab = switch_to_tab.clone(); | ||||
|                                 Callback::from(move |_| switch_to_tab.emit(ModalTab::Categories)) | ||||
|                             }} | ||||
|                         > | ||||
|                             {"Categories"} | ||||
|                         </button> | ||||
|                         <button | ||||
|                             class={if *active_tab == ModalTab::Location { "tab-button active" } else { "tab-button" }} | ||||
|                             onclick={{ | ||||
|                                 let switch_to_tab = switch_to_tab.clone(); | ||||
|                                 Callback::from(move |_| switch_to_tab.emit(ModalTab::Location)) | ||||
|                             }} | ||||
|                         > | ||||
|                             {"Location"} | ||||
|                         </button> | ||||
|                         <button | ||||
|                             class={if *active_tab == ModalTab::Reminders { "tab-button active" } else { "tab-button" }} | ||||
|                             onclick={{ | ||||
|                                 let switch_to_tab = switch_to_tab.clone(); | ||||
|                                 Callback::from(move |_| switch_to_tab.emit(ModalTab::Reminders)) | ||||
|                             }} | ||||
|                         > | ||||
|                             {"Reminders"} | ||||
|                         </button> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="modal-body"> | ||||
|                         <div class="tab-content"> | ||||
|                             { | ||||
|                                 match *active_tab { | ||||
|                                     ModalTab::BasicDetails => html! { <BasicDetailsTab ..tab_props /> }, | ||||
|                                     ModalTab::Advanced => html! { <AdvancedTab ..tab_props /> }, | ||||
|                                     ModalTab::People => html! { <PeopleTab ..tab_props /> }, | ||||
|                                     ModalTab::Categories => html! { <CategoriesTab ..tab_props /> }, | ||||
|                                     ModalTab::Location => html! { <LocationTab ..tab_props /> }, | ||||
|                                     ModalTab::Reminders => html! { <RemindersTab ..tab_props /> }, | ||||
|                                 } | ||||
|                             } | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="modal-footer"> | ||||
|                     <div class="modal-actions"> | ||||
|                         <button class="btn btn-secondary" onclick={Callback::from(move |_| on_close.emit(()))}> | ||||
|                             {"Cancel"} | ||||
|                         </button> | ||||
|                         <button class="btn btn-primary" onclick={on_save}> | ||||
|                             {if props.event_to_edit.is_some() { "Update Event" } else { "Create Event" }} | ||||
|                         </button> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| @@ -1,7 +1,7 @@ | ||||
| use super::types::*; | ||||
| use crate::components::create_event_modal::{EventStatus, EventClass}; | ||||
| // Types are already imported from super::types::* | ||||
| use wasm_bindgen::JsCast; | ||||
| use web_sys::{HtmlInputElement, HtmlSelectElement}; | ||||
| use web_sys::HtmlSelectElement; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[function_component(AdvancedTab)] | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| use super::types::*; | ||||
| use crate::components::create_event_modal::{EventStatus, EventClass, RecurrenceType, ReminderType}; | ||||
| // Types are already imported from super::types::* | ||||
| use chrono::{Datelike, NaiveDate}; | ||||
| use wasm_bindgen::JsCast; | ||||
| use web_sys::{HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement}; | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| use super::types::*; | ||||
| use crate::components::create_event_modal::ReminderType; | ||||
| // Types are already imported from super::types::* | ||||
| use wasm_bindgen::JsCast; | ||||
| use web_sys::HtmlSelectElement; | ||||
| use yew::prelude::*; | ||||
|   | ||||
| @@ -1,7 +1,5 @@ | ||||
| use crate::models::ical::VEvent; | ||||
| use crate::services::calendar_service::CalendarInfo; | ||||
| use chrono::{Datelike, Local, NaiveDate, NaiveTime, TimeZone, Utc}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use chrono::{Local, NaiveDate, NaiveTime}; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| @@ -78,12 +76,7 @@ impl Default for ModalTab { | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub enum EditAction { | ||||
|     ThisOnly, | ||||
|     ThisAndFuture, | ||||
|     AllInSeries, | ||||
| } | ||||
| // EditAction is now imported from event_context_menu - this duplicate removed | ||||
|  | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub struct EventCreationData { | ||||
| @@ -130,8 +123,58 @@ pub struct EventCreationData { | ||||
|     pub selected_calendar: Option<String>, | ||||
|      | ||||
|     // Edit tracking (for recurring events) | ||||
|     pub edit_scope: Option<EditAction>, | ||||
|     pub edit_scope: Option<crate::components::EditAction>, | ||||
|     pub changed_fields: Vec<String>, | ||||
|     pub original_uid: Option<String>, // Set when editing existing events | ||||
|     pub occurrence_date: Option<NaiveDate>, // The specific occurrence date being edited | ||||
| } | ||||
|  | ||||
| impl EventCreationData { | ||||
|     pub fn to_create_event_params(&self) -> ( | ||||
|         String, // title | ||||
|         String, // description | ||||
|         String, // start_date | ||||
|         String, // start_time | ||||
|         String, // end_date | ||||
|         String, // end_time | ||||
|         String, // location | ||||
|         bool,   // all_day | ||||
|         String, // status | ||||
|         String, // class | ||||
|         Option<u8>, // priority | ||||
|         String, // organizer | ||||
|         String, // attendees | ||||
|         String, // categories | ||||
|         String, // reminder | ||||
|         String, // recurrence | ||||
|         Vec<bool>, // recurrence_days | ||||
|         Option<String>, // calendar_path | ||||
|         Option<u32>, // recurrence_count | ||||
|         Option<String>, // recurrence_until | ||||
|     ) { | ||||
|         ( | ||||
|             self.title.clone(), | ||||
|             self.description.clone(), | ||||
|             self.start_date.format("%Y-%m-%d").to_string(), | ||||
|             self.start_time.format("%H:%M").to_string(), | ||||
|             self.end_date.format("%Y-%m-%d").to_string(), | ||||
|             self.end_time.format("%H:%M").to_string(), | ||||
|             self.location.clone(), | ||||
|             self.all_day, | ||||
|             format!("{:?}", self.status).to_uppercase(), | ||||
|             format!("{:?}", self.class).to_uppercase(), | ||||
|             self.priority, | ||||
|             self.organizer.clone(), | ||||
|             self.attendees.clone(), | ||||
|             self.categories.clone(), | ||||
|             format!("{:?}", self.reminder), | ||||
|             format!("{:?}", self.recurrence), | ||||
|             self.recurrence_days.clone(), | ||||
|             self.selected_calendar.clone(), | ||||
|             self.recurrence_count, | ||||
|             self.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Default for EventCreationData { | ||||
| @@ -168,6 +211,8 @@ impl Default for EventCreationData { | ||||
|             selected_calendar: None, | ||||
|             edit_scope: None, | ||||
|             changed_fields: vec![], | ||||
|             original_uid: None, | ||||
|             occurrence_date: None, | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -175,6 +220,6 @@ impl Default for EventCreationData { | ||||
| // Common props for all tab components | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct TabProps { | ||||
|     pub data: UseStateHandle<crate::components::create_event_modal::EventCreationData>, | ||||
|     pub data: UseStateHandle<EventCreationData>, | ||||
|     pub available_calendars: Vec<CalendarInfo>, | ||||
| } | ||||
							
								
								
									
										222
									
								
								frontend/src/components/external_calendar_modal.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										222
									
								
								frontend/src/components/external_calendar_modal.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,222 @@ | ||||
| use web_sys::HtmlInputElement; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| use crate::services::calendar_service::CalendarService; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct ExternalCalendarModalProps { | ||||
|     pub is_open: bool, | ||||
|     pub on_close: Callback<()>, | ||||
|     pub on_success: Callback<i32>, // Pass the newly created calendar ID | ||||
| } | ||||
|  | ||||
| #[function_component(ExternalCalendarModal)] | ||||
| pub fn external_calendar_modal(props: &ExternalCalendarModalProps) -> Html { | ||||
|     let name_ref = use_node_ref(); | ||||
|     let url_ref = use_node_ref(); | ||||
|     let color_ref = use_node_ref(); | ||||
|     let is_loading = use_state(|| false); | ||||
|     let error_message = use_state(|| None::<String>); | ||||
|  | ||||
|     let on_submit = { | ||||
|         let name_ref = name_ref.clone(); | ||||
|         let url_ref = url_ref.clone(); | ||||
|         let color_ref = color_ref.clone(); | ||||
|         let is_loading = is_loading.clone(); | ||||
|         let error_message = error_message.clone(); | ||||
|         let on_close = props.on_close.clone(); | ||||
|         let on_success = props.on_success.clone(); | ||||
|  | ||||
|         Callback::from(move |e: SubmitEvent| { | ||||
|             e.prevent_default(); | ||||
|              | ||||
|             let name = name_ref | ||||
|                 .cast::<HtmlInputElement>() | ||||
|                 .map(|input| input.value()) | ||||
|                 .unwrap_or_default() | ||||
|                 .trim() | ||||
|                 .to_string(); | ||||
|              | ||||
|             let url = url_ref | ||||
|                 .cast::<HtmlInputElement>() | ||||
|                 .map(|input| input.value()) | ||||
|                 .unwrap_or_default() | ||||
|                 .trim() | ||||
|                 .to_string(); | ||||
|                  | ||||
|             let color = color_ref | ||||
|                 .cast::<HtmlInputElement>() | ||||
|                 .map(|input| input.value()) | ||||
|                 .unwrap_or_else(|| "#4285f4".to_string()); | ||||
|  | ||||
|             if name.is_empty() { | ||||
|                 error_message.set(Some("Calendar name is required".to_string())); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if url.is_empty() { | ||||
|                 error_message.set(Some("Calendar URL is required".to_string())); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             // Basic URL validation | ||||
|             if !url.starts_with("http://") && !url.starts_with("https://") { | ||||
|                 error_message.set(Some("Please enter a valid HTTP or HTTPS URL".to_string())); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             error_message.set(None); | ||||
|             is_loading.set(true); | ||||
|  | ||||
|             let is_loading = is_loading.clone(); | ||||
|             let error_message = error_message.clone(); | ||||
|             let on_close = on_close.clone(); | ||||
|             let on_success = on_success.clone(); | ||||
|  | ||||
|             wasm_bindgen_futures::spawn_local(async move { | ||||
|                 match CalendarService::create_external_calendar(&name, &url, &color).await { | ||||
|                     Ok(new_calendar) => { | ||||
|                         is_loading.set(false); | ||||
|                         on_success.emit(new_calendar.id); | ||||
|                         on_close.emit(()); | ||||
|                     } | ||||
|                     Err(e) => { | ||||
|                         is_loading.set(false); | ||||
|                         error_message.set(Some(format!("Failed to add calendar: {}", e))); | ||||
|                     } | ||||
|                 } | ||||
|             }); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_cancel = { | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             on_close.emit(()); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     let on_cancel_clone = on_cancel.clone(); | ||||
|  | ||||
|     if !props.is_open { | ||||
|         return html! {}; | ||||
|     } | ||||
|  | ||||
|     html! { | ||||
|         <div class="modal-backdrop" onclick={on_cancel_clone}> | ||||
|             <div class="external-calendar-modal" onclick={Callback::from(|e: MouseEvent| e.stop_propagation())}> | ||||
|                 <div class="modal-header"> | ||||
|                     <h3>{"Add External Calendar"}</h3> | ||||
|                     <button class="modal-close" onclick={on_cancel.clone()}>{"×"}</button> | ||||
|                 </div> | ||||
|                  | ||||
|                 <form onsubmit={on_submit}> | ||||
|                     <div class="modal-body"> | ||||
|                         { | ||||
|                             if let Some(error) = (*error_message).as_ref() { | ||||
|                                 html! { | ||||
|                                     <div class="error-message"> | ||||
|                                         {error} | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         <div class="form-help" style="margin-bottom: 1.5rem; padding: 1rem; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #007bff;"> | ||||
|                             <h4 style="margin: 0 0 0.5rem 0; font-size: 0.9rem; color: #495057;">{"Setting up External Calendars"}</h4> | ||||
|                             <p style="margin: 0 0 0.5rem 0; font-size: 0.8rem; line-height: 1.4;"> | ||||
|                                 {"Currently tested with Outlook 365 and Google Calendar. To get your calendar link:"} | ||||
|                             </p> | ||||
|                              | ||||
|                             <div style="margin-bottom: 1rem;"> | ||||
|                                 <strong style="font-size: 0.8rem; color: #495057;">{"Outlook 365:"}</strong> | ||||
|                                 <ol style="margin: 0.3rem 0 0 0; padding-left: 1.2rem; font-size: 0.8rem; line-height: 1.3;"> | ||||
|                                     <li>{"Go to Outlook Settings"}</li> | ||||
|                                     <li>{"Navigate to Calendar → Shared Calendars"}</li> | ||||
|                                     <li>{"Click \"Publish a calendar\""}</li> | ||||
|                                     <li>{"Select your calendar and choose \"Can view all details\""}</li> | ||||
|                                     <li>{"Copy the ICS link and paste it below"}</li> | ||||
|                                 </ol> | ||||
|                             </div> | ||||
|                              | ||||
|                             <div> | ||||
|                                 <strong style="font-size: 0.8rem; color: #495057;">{"Google Calendar:"}</strong> | ||||
|                                 <ol style="margin: 0.3rem 0 0 0; padding-left: 1.2rem; font-size: 0.8rem; line-height: 1.3;"> | ||||
|                                     <li>{"Hover over your calendar name in the left sidebar"}</li> | ||||
|                                     <li>{"Click the three dots that appear"}</li> | ||||
|                                     <li>{"Select \"Settings and sharing\""}</li> | ||||
|                                     <li>{"Scroll to \"Integrate calendar\""}</li> | ||||
|                                     <li>{"Copy the \"Public address in iCal format\" link"}</li> | ||||
|                                 </ol> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                          | ||||
|                         <div class="form-group"> | ||||
|                             <label for="external-calendar-name">{"Calendar Name"}</label> | ||||
|                             <input | ||||
|                                 ref={name_ref} | ||||
|                                 id="external-calendar-name" | ||||
|                                 type="text" | ||||
|                                 placeholder="My External Calendar" | ||||
|                                 disabled={*is_loading} | ||||
|                                 required={true} | ||||
|                             /> | ||||
|                         </div> | ||||
|  | ||||
|                         <div class="form-group"> | ||||
|                             <label for="external-calendar-url">{"ICS URL"}</label> | ||||
|                             <input | ||||
|                                 ref={url_ref} | ||||
|                                 id="external-calendar-url" | ||||
|                                 type="url" | ||||
|                                 placeholder="https://example.com/calendar.ics" | ||||
|                                 disabled={*is_loading} | ||||
|                                 required={true} | ||||
|                             /> | ||||
|                             <small class="form-help"> | ||||
|                                 {"Enter the public ICS URL for the calendar you want to add"} | ||||
|                             </small> | ||||
|                         </div> | ||||
|  | ||||
|                         <div class="form-group"> | ||||
|                             <label for="external-calendar-color">{"Color"}</label> | ||||
|                             <input | ||||
|                                 ref={color_ref} | ||||
|                                 id="external-calendar-color" | ||||
|                                 type="color" | ||||
|                                 value="#4285f4" | ||||
|                                 disabled={*is_loading} | ||||
|                             /> | ||||
|                         </div> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="modal-actions"> | ||||
|                         <button | ||||
|                             type="button" | ||||
|                             class="btn btn-secondary" | ||||
|                             onclick={on_cancel} | ||||
|                             disabled={*is_loading} | ||||
|                         > | ||||
|                             {"Cancel"} | ||||
|                         </button> | ||||
|                         <button | ||||
|                             type="submit" | ||||
|                             class="btn btn-primary" | ||||
|                             disabled={*is_loading} | ||||
|                         > | ||||
|                             { | ||||
|                                 if *is_loading { | ||||
|                                     "Adding..." | ||||
|                                 } else { | ||||
|                                     "Add Calendar" | ||||
|                                 } | ||||
|                             } | ||||
|                         </button> | ||||
|                     </div> | ||||
|                 </form> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| @@ -5,10 +5,10 @@ pub mod calendar_list_item; | ||||
| pub mod context_menu; | ||||
| pub mod create_calendar_modal; | ||||
| pub mod create_event_modal; | ||||
| pub mod create_event_modal_v2; | ||||
| pub mod event_context_menu; | ||||
| pub mod event_form; | ||||
| pub mod event_modal; | ||||
| pub mod external_calendar_modal; | ||||
| pub mod login; | ||||
| pub mod month_view; | ||||
| pub mod recurring_edit_modal; | ||||
| @@ -22,16 +22,12 @@ pub use calendar_header::CalendarHeader; | ||||
| pub use calendar_list_item::CalendarListItem; | ||||
| pub use context_menu::ContextMenu; | ||||
| pub use create_calendar_modal::CreateCalendarModal; | ||||
| pub use create_event_modal::{ | ||||
|     CreateEventModal, EventClass, EventCreationData, EventStatus, RecurrenceType, ReminderType, | ||||
| }; | ||||
| pub use create_event_modal_v2::CreateEventModalV2; | ||||
| pub use event_form::{ | ||||
|     EventClass as EventFormClass, EventCreationData as EventFormData, EventStatus as EventFormStatus,  | ||||
|     RecurrenceType as EventFormRecurrenceType, ReminderType as EventFormReminderType, | ||||
| }; | ||||
| pub use create_event_modal::CreateEventModal; | ||||
| // Re-export event form types for backwards compatibility | ||||
| pub use event_form::EventCreationData; | ||||
| pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu}; | ||||
| pub use event_modal::EventModal; | ||||
| pub use external_calendar_modal::ExternalCalendarModal; | ||||
| pub use login::Login; | ||||
| pub use month_view::MonthView; | ||||
| pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal}; | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| use crate::models::ical::VEvent; | ||||
| use crate::services::calendar_service::UserInfo; | ||||
| use crate::services::calendar_service::{UserInfo, ExternalCalendar}; | ||||
| use chrono::{Datelike, NaiveDate, Weekday}; | ||||
| use std::collections::HashMap; | ||||
| use wasm_bindgen::{prelude::*, JsCast}; | ||||
| @@ -17,6 +17,8 @@ pub struct MonthViewProps { | ||||
|     #[prop_or_default] | ||||
|     pub user_info: Option<UserInfo>, | ||||
|     #[prop_or_default] | ||||
|     pub external_calendars: Vec<ExternalCalendar>, | ||||
|     #[prop_or_default] | ||||
|     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, | ||||
|     #[prop_or_default] | ||||
|     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>, | ||||
| @@ -85,8 +87,20 @@ pub fn month_view(props: &MonthViewProps) -> Html { | ||||
|  | ||||
|     // Helper function to get calendar color for an event | ||||
|     let get_event_color = |event: &VEvent| -> String { | ||||
|         if let Some(user_info) = &props.user_info { | ||||
|         if let Some(calendar_path) = &event.calendar_path { | ||||
|             // Check external calendars first (path format: "external_{id}") | ||||
|             if calendar_path.starts_with("external_") { | ||||
|                 if let Ok(id_str) = calendar_path.strip_prefix("external_").unwrap_or("").parse::<i32>() { | ||||
|                     if let Some(external_calendar) = props.external_calendars | ||||
|                         .iter() | ||||
|                         .find(|cal| cal.id == id_str) | ||||
|                     { | ||||
|                         return external_calendar.color.clone(); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             // Check regular calendars | ||||
|             else if let Some(user_info) = &props.user_info { | ||||
|                 if let Some(calendar) = user_info | ||||
|                     .calendars | ||||
|                     .iter() | ||||
| @@ -194,6 +208,7 @@ pub fn month_view(props: &MonthViewProps) -> Html { | ||||
|                                             <div | ||||
|                                                 class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })} | ||||
|                                                 style={format!("background-color: {}", event_color)} | ||||
|                                                 data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()} | ||||
|                                                 {onclick} | ||||
|                                                 {oncontextmenu} | ||||
|                                             > | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| use crate::components::{Login, ViewMode}; | ||||
| use crate::models::ical::VEvent; | ||||
| use crate::services::calendar_service::UserInfo; | ||||
| use crate::services::calendar_service::{UserInfo, ExternalCalendar}; | ||||
| use yew::prelude::*; | ||||
| use yew_router::prelude::*; | ||||
|  | ||||
| @@ -20,6 +20,10 @@ pub struct RouteHandlerProps { | ||||
|     pub user_info: Option<UserInfo>, | ||||
|     pub on_login: Callback<String>, | ||||
|     #[prop_or_default] | ||||
|     pub external_calendar_events: Vec<VEvent>, | ||||
|     #[prop_or_default] | ||||
|     pub external_calendars: Vec<ExternalCalendar>, | ||||
|     #[prop_or_default] | ||||
|     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, | ||||
|     #[prop_or_default] | ||||
|     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>, | ||||
| @@ -48,6 +52,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | ||||
|     let auth_token = props.auth_token.clone(); | ||||
|     let user_info = props.user_info.clone(); | ||||
|     let on_login = props.on_login.clone(); | ||||
|     let external_calendar_events = props.external_calendar_events.clone(); | ||||
|     let external_calendars = props.external_calendars.clone(); | ||||
|     let on_event_context_menu = props.on_event_context_menu.clone(); | ||||
|     let on_calendar_context_menu = props.on_calendar_context_menu.clone(); | ||||
|     let view = props.view.clone(); | ||||
| @@ -60,6 +66,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | ||||
|             let auth_token = auth_token.clone(); | ||||
|             let user_info = user_info.clone(); | ||||
|             let on_login = on_login.clone(); | ||||
|             let external_calendar_events = external_calendar_events.clone(); | ||||
|             let external_calendars = external_calendars.clone(); | ||||
|             let on_event_context_menu = on_event_context_menu.clone(); | ||||
|             let on_calendar_context_menu = on_calendar_context_menu.clone(); | ||||
|             let view = view.clone(); | ||||
| @@ -87,6 +95,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | ||||
|                         html! { | ||||
|                             <CalendarView | ||||
|                                 user_info={user_info} | ||||
|                                 external_calendar_events={external_calendar_events} | ||||
|                                 external_calendars={external_calendars} | ||||
|                                 on_event_context_menu={on_event_context_menu} | ||||
|                                 on_calendar_context_menu={on_calendar_context_menu} | ||||
|                                 view={view} | ||||
| @@ -108,6 +118,10 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | ||||
| pub struct CalendarViewProps { | ||||
|     pub user_info: Option<UserInfo>, | ||||
|     #[prop_or_default] | ||||
|     pub external_calendar_events: Vec<VEvent>, | ||||
|     #[prop_or_default] | ||||
|     pub external_calendars: Vec<ExternalCalendar>, | ||||
|     #[prop_or_default] | ||||
|     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, | ||||
|     #[prop_or_default] | ||||
|     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>, | ||||
| @@ -139,6 +153,8 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html { | ||||
|         <div class="calendar-view"> | ||||
|             <Calendar | ||||
|                 user_info={props.user_info.clone()} | ||||
|                 external_calendar_events={props.external_calendar_events.clone()} | ||||
|                 external_calendars={props.external_calendars.clone()} | ||||
|                 on_event_context_menu={props.on_event_context_menu.clone()} | ||||
|                 on_calendar_context_menu={props.on_calendar_context_menu.clone()} | ||||
|                 view={props.view.clone()} | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| use crate::components::CalendarListItem; | ||||
| use crate::services::calendar_service::UserInfo; | ||||
| use crate::services::calendar_service::{UserInfo, ExternalCalendar}; | ||||
| use web_sys::HtmlSelectElement; | ||||
| use yew::prelude::*; | ||||
| use yew_router::prelude::*; | ||||
| @@ -101,11 +101,17 @@ pub struct SidebarProps { | ||||
|     pub user_info: Option<UserInfo>, | ||||
|     pub on_logout: Callback<()>, | ||||
|     pub on_create_calendar: Callback<()>, | ||||
|     pub on_create_external_calendar: Callback<()>, | ||||
|     pub external_calendars: Vec<ExternalCalendar>, | ||||
|     pub on_external_calendar_toggle: Callback<i32>, | ||||
|     pub on_external_calendar_delete: Callback<i32>, | ||||
|     pub on_external_calendar_refresh: Callback<i32>, | ||||
|     pub color_picker_open: Option<String>, | ||||
|     pub on_color_change: Callback<(String, String)>, | ||||
|     pub on_color_picker_toggle: Callback<String>, | ||||
|     pub available_colors: Vec<String>, | ||||
|     pub on_calendar_context_menu: Callback<(MouseEvent, String)>, | ||||
|     pub on_calendar_visibility_toggle: Callback<String>, | ||||
|     pub current_view: ViewMode, | ||||
|     pub on_view_change: Callback<ViewMode>, | ||||
|     pub current_theme: Theme, | ||||
| @@ -116,6 +122,7 @@ pub struct SidebarProps { | ||||
|  | ||||
| #[function_component(Sidebar)] | ||||
| pub fn sidebar(props: &SidebarProps) -> Html { | ||||
|     let external_context_menu_open = use_state(|| None::<i32>); | ||||
|     let on_view_change = { | ||||
|         let on_view_change = props.on_view_change.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
| @@ -155,6 +162,30 @@ pub fn sidebar(props: &SidebarProps) -> Html { | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_external_calendar_context_menu = { | ||||
|         let external_context_menu_open = external_context_menu_open.clone(); | ||||
|         Callback::from(move |(e, cal_id): (MouseEvent, i32)| { | ||||
|             e.prevent_default(); | ||||
|             external_context_menu_open.set(Some(cal_id)); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_external_calendar_delete = { | ||||
|         let on_external_calendar_delete = props.on_external_calendar_delete.clone(); | ||||
|         let external_context_menu_open = external_context_menu_open.clone(); | ||||
|         Callback::from(move |cal_id: i32| { | ||||
|             on_external_calendar_delete.emit(cal_id); | ||||
|             external_context_menu_open.set(None); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let close_external_context_menu = { | ||||
|         let external_context_menu_open = external_context_menu_open.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             external_context_menu_open.set(None); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <aside class="app-sidebar"> | ||||
|             <div class="sidebar-header"> | ||||
| @@ -192,6 +223,7 @@ pub fn sidebar(props: &SidebarProps) -> Html { | ||||
|                                                     on_color_picker_toggle={props.on_color_picker_toggle.clone()} | ||||
|                                                     available_colors={props.available_colors.clone()} | ||||
|                                                     on_context_menu={props.on_calendar_context_menu.clone()} | ||||
|                                                     on_visibility_toggle={props.on_calendar_visibility_toggle.clone()} | ||||
|                                                 /> | ||||
|                                             } | ||||
|                                         }).collect::<Html>() | ||||
| @@ -206,11 +238,128 @@ pub fn sidebar(props: &SidebarProps) -> Html { | ||||
|                     html! {} | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             // External calendars section | ||||
|             <div class="external-calendar-list"> | ||||
|                 <h3>{"External Calendars"}</h3> | ||||
|                 { | ||||
|                     if !props.external_calendars.is_empty() { | ||||
|                         html! { | ||||
|                             <ul class="external-calendar-items"> | ||||
|                                 { | ||||
|                                     props.external_calendars.iter().map(|cal| { | ||||
|                                         let on_toggle = { | ||||
|                                             let on_external_calendar_toggle = props.on_external_calendar_toggle.clone(); | ||||
|                                             let cal_id = cal.id; | ||||
|                                             Callback::from(move |_| { | ||||
|                                                 on_external_calendar_toggle.emit(cal_id); | ||||
|                                             }) | ||||
|                                         }; | ||||
|                                          | ||||
|                                         html! { | ||||
|                                             <li class="external-calendar-item" style="position: relative;"> | ||||
|                                                 <div  | ||||
|                                                     class="external-calendar-info" | ||||
|                                                     oncontextmenu={{ | ||||
|                                                         let on_context_menu = on_external_calendar_context_menu.clone(); | ||||
|                                                         let cal_id = cal.id; | ||||
|                                                         Callback::from(move |e: MouseEvent| { | ||||
|                                                             on_context_menu.emit((e, cal_id)); | ||||
|                                                         }) | ||||
|                                                     }} | ||||
|                                                 > | ||||
|                                                     <input | ||||
|                                                         type="checkbox" | ||||
|                                                         checked={cal.is_visible} | ||||
|                                                         onchange={on_toggle} | ||||
|                                                     /> | ||||
|                                                     <span  | ||||
|                                                         class="external-calendar-color"  | ||||
|                                                         style={format!("background-color: {}", cal.color)} | ||||
|                                                     /> | ||||
|                                                     <span class="external-calendar-name">{&cal.name}</span> | ||||
|                                                     <div class="external-calendar-actions"> | ||||
|                                                         { | ||||
|                                                             if let Some(last_fetched) = cal.last_fetched { | ||||
|                                                                 let local_time = last_fetched.with_timezone(&chrono::Local); | ||||
|                                                                 html! { | ||||
|                                                                     <span class="last-updated" title={format!("Last updated: {}", local_time.format("%Y-%m-%d %H:%M"))}> | ||||
|                                                                         {format!("{}", local_time.format("%H:%M"))} | ||||
|                                                                     </span> | ||||
|                                                                 } | ||||
|                                                             } else { | ||||
|                                                                 html! { | ||||
|                                                                     <span class="last-updated">{"Never"}</span> | ||||
|                                                                 } | ||||
|                                                             } | ||||
|                                                         } | ||||
|                                                         <button  | ||||
|                                                             class="external-calendar-refresh-btn"  | ||||
|                                                             title="Refresh calendar" | ||||
|                                                             onclick={{ | ||||
|                                                                 let on_refresh = props.on_external_calendar_refresh.clone(); | ||||
|                                                                 let cal_id = cal.id; | ||||
|                                                                 Callback::from(move |e: MouseEvent| { | ||||
|                                                                     e.stop_propagation(); | ||||
|                                                                     on_refresh.emit(cal_id); | ||||
|                                                                 }) | ||||
|                                                             }} | ||||
|                                                         > | ||||
|                                                             {"🔄"} | ||||
|                                                         </button> | ||||
|                                                     </div> | ||||
|                                                 </div> | ||||
|                                                 { | ||||
|                                                     if *external_context_menu_open == Some(cal.id) { | ||||
|                                                         html! { | ||||
|                                                             <> | ||||
|                                                                 <div  | ||||
|                                                                     class="context-menu-overlay"  | ||||
|                                                                     onclick={close_external_context_menu.clone()} | ||||
|                                                                     style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 999;" | ||||
|                                                                 /> | ||||
|                                                                 <div class="context-menu" style="position: absolute; top: 0; right: 0; background: white; border: 1px solid #ccc; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 1000; min-width: 120px;"> | ||||
|                                                                     <div  | ||||
|                                                                         class="context-menu-item"  | ||||
|                                                                         style="padding: 8px 12px; cursor: pointer; color: #d73a49;" | ||||
|                                                                         onclick={{ | ||||
|                                                                             let on_delete = on_external_calendar_delete.clone(); | ||||
|                                                                             let cal_id = cal.id; | ||||
|                                                                             Callback::from(move |_| { | ||||
|                                                                                 on_delete.emit(cal_id); | ||||
|                                                                             }) | ||||
|                                                                         }} | ||||
|                                                                     > | ||||
|                                                                         {"Delete Calendar"} | ||||
|                                                                     </div> | ||||
|                                                                 </div> | ||||
|                                                             </> | ||||
|                                                         } | ||||
|                                                     } else { | ||||
|                                                         html! {} | ||||
|                                                     } | ||||
|                                                 } | ||||
|                                             </li> | ||||
|                                         } | ||||
|                                     }).collect::<Html>() | ||||
|                                 } | ||||
|                             </ul> | ||||
|                         } | ||||
|                     } else { | ||||
|                         html! {} | ||||
|                     } | ||||
|                 } | ||||
|             </div> | ||||
|              | ||||
|             <div class="sidebar-footer"> | ||||
|                 <button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button"> | ||||
|                     {"+ Create Calendar"} | ||||
|                 </button> | ||||
|                  | ||||
|                 <button onclick={props.on_create_external_calendar.reform(|_| ())} class="create-external-calendar-button"> | ||||
|                     {"+ Add External Calendar"} | ||||
|                 </button> | ||||
|  | ||||
|                 <div class="view-selector"> | ||||
|                     <select class="view-selector-dropdown" onchange={on_view_change}> | ||||
|                         <option value="month" selected={matches!(props.current_view, ViewMode::Month)}>{"Month"}</option> | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| use crate::components::{EventCreationData, RecurringEditAction, RecurringEditModal}; | ||||
| use crate::models::ical::VEvent; | ||||
| use crate::services::calendar_service::UserInfo; | ||||
| use crate::services::calendar_service::{UserInfo, ExternalCalendar}; | ||||
| use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Weekday}; | ||||
| use std::collections::HashMap; | ||||
| use web_sys::MouseEvent; | ||||
| @@ -17,6 +17,8 @@ pub struct WeekViewProps { | ||||
|     #[prop_or_default] | ||||
|     pub user_info: Option<UserInfo>, | ||||
|     #[prop_or_default] | ||||
|     pub external_calendars: Vec<ExternalCalendar>, | ||||
|     #[prop_or_default] | ||||
|     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, | ||||
|     #[prop_or_default] | ||||
|     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>, | ||||
| @@ -81,8 +83,20 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|  | ||||
|     // Helper function to get calendar color for an event | ||||
|     let get_event_color = |event: &VEvent| -> String { | ||||
|         if let Some(user_info) = &props.user_info { | ||||
|         if let Some(calendar_path) = &event.calendar_path { | ||||
|             // Check external calendars first (path format: "external_{id}") | ||||
|             if calendar_path.starts_with("external_") { | ||||
|                 if let Ok(id_str) = calendar_path.strip_prefix("external_").unwrap_or("").parse::<i32>() { | ||||
|                     if let Some(external_calendar) = props.external_calendars | ||||
|                         .iter() | ||||
|                         .find(|cal| cal.id == id_str) | ||||
|                     { | ||||
|                         return external_calendar.color.clone(); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             // Check regular calendars | ||||
|             else if let Some(user_info) = &props.user_info { | ||||
|                 if let Some(calendar) = user_info | ||||
|                     .calendars | ||||
|                     .iter() | ||||
| @@ -95,8 +109,8 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|         "#3B82F6".to_string() | ||||
|     }; | ||||
|  | ||||
|     // Generate time labels - 24 hours plus the final midnight boundary | ||||
|     let mut time_labels: Vec<String> = (0..24) | ||||
|     // Generate time labels - 24 hours | ||||
|     let time_labels: Vec<String> = (0..24) | ||||
|         .map(|hour| { | ||||
|             if hour == 0 { | ||||
|                 "12 AM".to_string() | ||||
| @@ -110,9 +124,6 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|         }) | ||||
|         .collect(); | ||||
|  | ||||
|     // Add the final midnight boundary to show where the day ends | ||||
|     time_labels.push("12 AM".to_string()); | ||||
|  | ||||
|     // Handlers for recurring event modification modal | ||||
|     let on_recurring_choice = { | ||||
|         let pending_recurring_edit = pending_recurring_edit.clone(); | ||||
| @@ -319,10 +330,19 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                     week_days.iter().map(|date| { | ||||
|                         let is_today = *date == props.today; | ||||
|                         let weekday_name = get_weekday_name(date.weekday()); | ||||
|                         let day_events = props.events.get(date).cloned().unwrap_or_default(); | ||||
|                          | ||||
|                         // Filter for all-day events only | ||||
|                         let all_day_events: Vec<_> = day_events.iter().filter(|event| event.all_day).collect(); | ||||
|                         // Collect all-day events that span this date (from any day in the week) | ||||
|                         let mut all_day_events: Vec<&VEvent> = Vec::new(); | ||||
|                         for events_list in props.events.values() { | ||||
|                             for event in events_list { | ||||
|                                 if event.all_day && event_spans_date(event, *date) { | ||||
|                                     all_day_events.push(event); | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                         // Remove duplicates (same event might appear in multiple day buckets) | ||||
|                         all_day_events.sort_by_key(|e| &e.uid); | ||||
|                         all_day_events.dedup_by_key(|e| &e.uid); | ||||
|  | ||||
|                         html! { | ||||
|                             <div class={classes!("week-day-header", if is_today { Some("today") } else { None })}> | ||||
| @@ -365,6 +385,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                         <div  | ||||
|                                                             class="all-day-event" | ||||
|                                                             style={format!("background-color: {}", event_color)} | ||||
|                                                             data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()} | ||||
|                                                             {onclick} | ||||
|                                                             {oncontextmenu} | ||||
|                                                         > | ||||
| @@ -388,14 +409,17 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|  | ||||
|             // Scrollable content area with time grid | ||||
|             <div class="week-content"> | ||||
|                 <div class="time-grid"> | ||||
|                 <div class={classes!("time-grid", if props.time_increment == 15 { Some("quarter-mode") } else { None })}> | ||||
|                     // Time labels | ||||
|                     <div class="time-labels"> | ||||
|                     <div class={classes!("time-labels", if props.time_increment == 15 { Some("quarter-mode") } else { None })}> | ||||
|                         { | ||||
|                             time_labels.iter().enumerate().map(|(index, time)| { | ||||
|                                 let is_final = index == time_labels.len() - 1; | ||||
|                             time_labels.iter().map(|time| { | ||||
|                                 let is_quarter_mode = props.time_increment == 15; | ||||
|                                 html! { | ||||
|                                     <div class={classes!("time-label", if is_final { Some("final-boundary") } else { None })}> | ||||
|                                     <div class={classes!( | ||||
|                                         "time-label", | ||||
|                                         if is_quarter_mode { Some("quarter-mode") } else { None } | ||||
|                                     )}> | ||||
|                                         {time} | ||||
|                                     </div> | ||||
|                                 } | ||||
| @@ -404,12 +428,12 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                     </div> | ||||
|  | ||||
|                     // Day columns | ||||
|                     <div class="week-days-grid"> | ||||
|                     <div class={classes!("week-days-grid", if props.time_increment == 15 { Some("quarter-mode") } else { None })}> | ||||
|                         { | ||||
|                             week_days.iter().enumerate().map(|(_column_index, date)| { | ||||
|                                 let is_today = *date == props.today; | ||||
|                                 let day_events = props.events.get(date).cloned().unwrap_or_default(); | ||||
|                                 let event_layouts = calculate_event_layout(&day_events, *date); | ||||
|                                 let event_layouts = calculate_event_layout(&day_events, *date, props.time_increment); | ||||
|  | ||||
|                                 // Drag event handlers | ||||
|                                 let drag_state_clone = drag_state.clone(); | ||||
| @@ -500,8 +524,8 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                 match ¤t_drag.drag_type { | ||||
|                                                     DragType::CreateEvent => { | ||||
|                                                         // Calculate start and end times | ||||
|                                                         let start_time = pixels_to_time(current_drag.start_y); | ||||
|                                                         let end_time = pixels_to_time(current_drag.current_y); | ||||
|                                                         let start_time = pixels_to_time(current_drag.start_y, time_increment); | ||||
|                                                         let end_time = pixels_to_time(current_drag.current_y, time_increment); | ||||
|  | ||||
|                                                         // Ensure start is before end | ||||
|                                                         let (actual_start, actual_end) = if start_time <= end_time { | ||||
| @@ -529,7 +553,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                         let unsnapped_position = current_drag.current_y - current_drag.offset_y; | ||||
|                                                         // Snap the final position to maintain time increment alignment | ||||
|                                                         let event_top_position = snap_to_increment(unsnapped_position, time_increment); | ||||
|                                                         let new_start_time = pixels_to_time(event_top_position); | ||||
|                                                         let new_start_time = pixels_to_time(event_top_position, time_increment); | ||||
|  | ||||
|                                                         // Calculate duration from original event | ||||
|                                                         let original_duration = if let Some(end) = event.dtend { | ||||
| @@ -558,7 +582,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                     }, | ||||
|                                                     DragType::ResizeEventStart(event) => { | ||||
|                                                         // Calculate new start time based on drag position | ||||
|                                                         let new_start_time = pixels_to_time(current_drag.current_y); | ||||
|                                                         let new_start_time = pixels_to_time(current_drag.current_y, time_increment); | ||||
|  | ||||
|                                                         // Keep the original end time | ||||
|                                                         let original_end = if let Some(end) = event.dtend { | ||||
| @@ -594,7 +618,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                     }, | ||||
|                                                     DragType::ResizeEventEnd(event) => { | ||||
|                                                         // Calculate new end time based on drag position | ||||
|                                                         let new_end_time = pixels_to_time(current_drag.current_y); | ||||
|                                                         let new_end_time = pixels_to_time(current_drag.current_y, time_increment); | ||||
|  | ||||
|                                                         // Keep the original start time | ||||
|                                                         let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local(); | ||||
| @@ -643,7 +667,8 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                         class={classes!( | ||||
|                                             "week-day-column", | ||||
|                                             if is_today { Some("today") } else { None }, | ||||
|                                             if is_creating_event { Some("creating-event") } else { None } | ||||
|                                             if is_creating_event { Some("creating-event") } else { None }, | ||||
|                                             if props.time_increment == 15 { Some("quarter-mode") } else { None } | ||||
|                                         )} | ||||
|                                         {onmousedown} | ||||
|                                         {onmousemove} | ||||
| @@ -652,10 +677,21 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                         // Time slot backgrounds - 24 hour slots to represent full day | ||||
|                                         { | ||||
|                                             (0..24).map(|_hour| { | ||||
|                                                 let slots_per_hour = 60 / props.time_increment; | ||||
|                                                 html! { | ||||
|                                                     <div class="time-slot"> | ||||
|                                                         <div class="time-slot-half"></div> | ||||
|                                                         <div class="time-slot-half"></div> | ||||
|                                                     <div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })}> | ||||
|                                                         { | ||||
|                                                             (0..slots_per_hour).map(|_slot| { | ||||
|                                                                 let slot_class = if props.time_increment == 15 { | ||||
|                                                                     "time-slot-quarter" | ||||
|                                                                 } else { | ||||
|                                                                     "time-slot-half" | ||||
|                                                                 }; | ||||
|                                                                 html! { | ||||
|                                                                     <div class={slot_class}></div> | ||||
|                                                                 } | ||||
|                                                             }).collect::<Html>() | ||||
|                                                         } | ||||
|                                                     </div> | ||||
|                                                 } | ||||
|                                             }).collect::<Html>() | ||||
| @@ -665,7 +701,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                         <div class="events-container"> | ||||
|                                             { | ||||
|                                                 day_events.iter().enumerate().filter_map(|(event_idx, event)| { | ||||
|                                                     let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date); | ||||
|                                                     let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date, props.time_increment); | ||||
|  | ||||
|                                                     // Skip all-day events (they're rendered in the header) | ||||
|                                                     if is_all_day { | ||||
| @@ -693,7 +729,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                         let drag_state = drag_state.clone(); | ||||
|                                                         let event_for_drag = event.clone(); | ||||
|                                                         let date_for_drag = *date; | ||||
|                                                         let _time_increment = props.time_increment; | ||||
|                                                         let time_increment = props.time_increment; | ||||
|                                                         Callback::from(move |e: MouseEvent| { | ||||
|                                                             e.stop_propagation(); // Prevent drag-to-create from starting on event clicks | ||||
|  | ||||
| @@ -707,7 +743,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                             let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 }; | ||||
|  | ||||
|                                                             // Get event's current position in day column coordinates | ||||
|                                                             let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag); | ||||
|                                                             let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag, time_increment); | ||||
|                                                             let event_start_pixels = event_start_pixels as f64; | ||||
|  | ||||
|                                                             // Convert click position to day column coordinates | ||||
| @@ -884,6 +920,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                                         column_width | ||||
|                                                                     ) | ||||
|                                                                 } | ||||
|                                                                 data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()} | ||||
|                                                                 {onclick} | ||||
|                                                                 {oncontextmenu} | ||||
|                                                                 onmousedown={onmousedown_event} | ||||
| @@ -903,7 +940,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                                 // Event content | ||||
|                                                                 <div class="event-content"> | ||||
|                                                                     <div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div> | ||||
|                                                                     {if !is_all_day { | ||||
|                                                                     {if !is_all_day && duration_pixels > 30.0 { | ||||
|                                                                         html! { <div class="event-time">{time_display}</div> } | ||||
|                                                                     } else { | ||||
|                                                                         html! {} | ||||
| @@ -939,8 +976,8 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                             let height = (drag.current_y - drag.start_y).abs().max(20.0); | ||||
|  | ||||
|                                                             // Convert pixels to times for display | ||||
|                                                             let start_time = pixels_to_time(start_y); | ||||
|                                                             let end_time = pixels_to_time(end_y); | ||||
|                                                             let start_time = pixels_to_time(start_y, props.time_increment); | ||||
|                                                             let end_time = pixels_to_time(end_y, props.time_increment); | ||||
|  | ||||
|                                                             html! { | ||||
|                                                                 <div | ||||
| @@ -956,7 +993,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                             let unsnapped_position = drag.current_y - drag.offset_y; | ||||
|                                                             // Snap the final position to maintain time increment alignment | ||||
|                                                             let preview_position = snap_to_increment(unsnapped_position, props.time_increment); | ||||
|                                                             let new_start_time = pixels_to_time(preview_position); | ||||
|                                                             let new_start_time = pixels_to_time(preview_position, props.time_increment); | ||||
|                                                             let original_duration = if let Some(end) = event.dtend { | ||||
|                                                                 end.signed_duration_since(event.dtstart) | ||||
|                                                             } else { | ||||
| @@ -971,15 +1008,20 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                                 <div | ||||
|                                                                     class="temp-event-box moving-event" | ||||
|                                                                     style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", preview_position, duration_pixels, event_color)} | ||||
|                                                                     data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()} | ||||
|                                                                 > | ||||
|                                                                     <div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div> | ||||
|                                                                     <div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div> | ||||
|                                                                     {if duration_pixels > 30.0 { | ||||
|                                                                         html! { <div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div> } | ||||
|                                                                     } else { | ||||
|                                                                         html! {} | ||||
|                                                                     }} | ||||
|                                                                 </div> | ||||
|                                                             } | ||||
|                                                         }, | ||||
|                                                         DragType::ResizeEventStart(event) => { | ||||
|                                                             // Show the event being resized from the start | ||||
|                                                             let new_start_time = pixels_to_time(drag.current_y); | ||||
|                                                             let new_start_time = pixels_to_time(drag.current_y, props.time_increment); | ||||
|                                                             let original_end = if let Some(end) = event.dtend { | ||||
|                                                                 end.with_timezone(&chrono::Local).naive_local() | ||||
|                                                             } else { | ||||
| @@ -987,7 +1029,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                             }; | ||||
|  | ||||
|                                                             // Calculate positions for the preview | ||||
|                                                             let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date); | ||||
|                                                             let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment); | ||||
|                                                             let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local()); | ||||
|                                                             let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32); | ||||
|  | ||||
| @@ -1000,19 +1042,24 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                                 <div | ||||
|                                                                     class="temp-event-box resizing-event" | ||||
|                                                                     style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", new_start_pixels, new_height, event_color)} | ||||
|                                                                     data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()} | ||||
|                                                                 > | ||||
|                                                                     <div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div> | ||||
|                                                                     <div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), original_end.time().format("%I:%M %p"))}</div> | ||||
|                                                                     {if new_height > 30.0 { | ||||
|                                                                         html! { <div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), original_end.time().format("%I:%M %p"))}</div> } | ||||
|                                                                     } else { | ||||
|                                                                         html! {} | ||||
|                                                                     }} | ||||
|                                                                 </div> | ||||
|                                                             } | ||||
|                                                         }, | ||||
|                                                         DragType::ResizeEventEnd(event) => { | ||||
|                                                             // Show the event being resized from the end | ||||
|                                                             let new_end_time = pixels_to_time(drag.current_y); | ||||
|                                                             let new_end_time = pixels_to_time(drag.current_y, props.time_increment); | ||||
|                                                             let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local(); | ||||
|  | ||||
|                                                             // Calculate positions for the preview | ||||
|                                                             let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date); | ||||
|                                                             let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment); | ||||
|  | ||||
|                                                             let new_end_pixels = drag.current_y; | ||||
|                                                             let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0); | ||||
| @@ -1023,9 +1070,14 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                                 <div | ||||
|                                                                     class="temp-event-box resizing-event" | ||||
|                                                                     style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", original_start_pixels, new_height, event_color)} | ||||
|                                                                     data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()} | ||||
|                                                                 > | ||||
|                                                                     <div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div> | ||||
|                                                                     <div class="event-time">{format!("{} - {}", original_start.time().format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div> | ||||
|                                                                     {if new_height > 30.0 { | ||||
|                                                                         html! { <div class="event-time">{format!("{} - {}", original_start.time().format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div> } | ||||
|                                                                     } else { | ||||
|                                                                         html! {} | ||||
|                                                                     }} | ||||
|                                                                 </div> | ||||
|                                                             } | ||||
|                                                         } | ||||
| @@ -1089,22 +1141,25 @@ fn get_weekday_name(weekday: Weekday) -> &'static str { | ||||
| } | ||||
|  | ||||
| // Calculate the pixel position of an event based on its time | ||||
| // Each hour is 60px, so we convert time to pixels | ||||
| // Snap pixel position to 15-minute increments (15px = 15 minutes since 60px = 60 minutes) | ||||
| // Snap pixel position based on time increment and grid scaling | ||||
| // In 30-minute mode: 60px per hour (1px = 1 minute) | ||||
| // In 15-minute mode: 120px per hour (2px = 1 minute) | ||||
| fn snap_to_increment(pixels: f64, increment: u32) -> f64 { | ||||
|     let increment_px = increment as f64; // Convert to pixels (1px = 1 minute) | ||||
|     let pixels_per_minute = if increment == 15 { 2.0 } else { 1.0 }; | ||||
|     let increment_px = increment as f64 * pixels_per_minute; | ||||
|     (pixels / increment_px).round() * increment_px | ||||
| } | ||||
|  | ||||
| // Convert pixel position to time (inverse of time to pixels) | ||||
| fn pixels_to_time(pixels: f64) -> NaiveTime { | ||||
|     // Since 60px = 1 hour, pixels directly represent minutes | ||||
|     let total_minutes = pixels; // 1px = 1 minute | ||||
| fn pixels_to_time(pixels: f64, time_increment: u32) -> NaiveTime { | ||||
|     let pixels_per_minute = if time_increment == 15 { 2.0 } else { 1.0 }; | ||||
|     let total_minutes = pixels / pixels_per_minute; | ||||
|     let hours = (total_minutes / 60.0) as u32; | ||||
|     let minutes = (total_minutes % 60.0) as u32; | ||||
|  | ||||
|     // Handle midnight boundary - if we're at exactly 1440 pixels (24:00), return midnight | ||||
|     if total_minutes >= 1440.0 { | ||||
|     // Handle midnight boundary - check against scaled boundary | ||||
|     let max_pixels = 1440.0 * pixels_per_minute; // 24 hours in pixels | ||||
|     if pixels >= max_pixels { | ||||
|         return NaiveTime::from_hms_opt(0, 0, 0).unwrap(); | ||||
|     } | ||||
|  | ||||
| @@ -1115,7 +1170,7 @@ fn pixels_to_time(pixels: f64) -> NaiveTime { | ||||
|     NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap()) | ||||
| } | ||||
|  | ||||
| fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) { | ||||
| fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32) -> (f32, f32, bool) { | ||||
|     // Convert UTC times to local time for display | ||||
|     let local_start = event.dtstart.with_timezone(&Local); | ||||
|     let event_date = local_start.date_naive(); | ||||
| @@ -1138,7 +1193,8 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) | ||||
|     // Calculate start position in pixels from midnight | ||||
|     let start_hour = local_start.hour() as f32; | ||||
|     let start_minute = local_start.minute() as f32; | ||||
|     let start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px per hour | ||||
|     let pixels_per_hour = if time_increment == 15 { 120.0 } else { 60.0 }; | ||||
|     let start_pixels = (start_hour + start_minute / 60.0) * pixels_per_hour; | ||||
|  | ||||
|     // Calculate duration and height | ||||
|     let duration_pixels = if let Some(end) = event.dtend { | ||||
| @@ -1147,16 +1203,17 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) | ||||
|  | ||||
|         // Handle events that span multiple days by capping at midnight | ||||
|         if end_date > date { | ||||
|             // Event continues past midnight, cap at 24:00 (1440px) | ||||
|             1440.0 - start_pixels | ||||
|             // Event continues past midnight, cap at 24:00  | ||||
|             let max_pixels = 24.0 * pixels_per_hour; | ||||
|             max_pixels - start_pixels | ||||
|         } else { | ||||
|             let end_hour = local_end.hour() as f32; | ||||
|             let end_minute = local_end.minute() as f32; | ||||
|             let end_pixels = (end_hour + end_minute / 60.0) * 60.0; | ||||
|             let end_pixels = (end_hour + end_minute / 60.0) * pixels_per_hour; | ||||
|             (end_pixels - start_pixels).max(20.0) // Minimum 20px height | ||||
|         } | ||||
|     } else { | ||||
|         60.0 // Default 1 hour if no end time | ||||
|         pixels_per_hour // Default 1 hour if no end time | ||||
|     }; | ||||
|  | ||||
|     (start_pixels, duration_pixels, false) // is_all_day = false | ||||
| @@ -1164,6 +1221,11 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) | ||||
|  | ||||
| // Check if two events overlap in time | ||||
| fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool { | ||||
|     // All-day events don't overlap with timed events for width calculation purposes | ||||
|     if event1.all_day || event2.all_day { | ||||
|         return false; | ||||
|     } | ||||
|      | ||||
|     let start1 = event1.dtstart.with_timezone(&Local).naive_local(); | ||||
|     let end1 = if let Some(end) = event1.dtend { | ||||
|         end.with_timezone(&Local).naive_local() | ||||
| @@ -1183,13 +1245,18 @@ fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool { | ||||
| } | ||||
|  | ||||
| // Calculate layout columns for overlapping events | ||||
| fn calculate_event_layout(events: &[VEvent], date: NaiveDate) -> Vec<(usize, usize)> { | ||||
| fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u32) -> Vec<(usize, usize)> { | ||||
|      | ||||
|     // Filter and sort events that should appear on this date | ||||
|     // Filter and sort events that should appear on this date (excluding all-day events) | ||||
|     let mut day_events: Vec<_> = events.iter() | ||||
|         .enumerate() | ||||
|         .filter_map(|(idx, event)| { | ||||
|             let (_, _, _) = calculate_event_position(event, date); | ||||
|             // Skip all-day events as they don't participate in timed event overlap calculations | ||||
|             if event.all_day { | ||||
|                 return None; | ||||
|             } | ||||
|              | ||||
|             let (_, _, _) = calculate_event_position(event, date, time_increment); | ||||
|             let local_start = event.dtstart.with_timezone(&Local); | ||||
|             let event_date = local_start.date_naive(); | ||||
|             if event_date == date ||  | ||||
| @@ -1269,3 +1336,31 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate) -> Vec<(usize, usi | ||||
|      | ||||
|     event_columns | ||||
| } | ||||
|  | ||||
| // Check if an all-day event spans the given date | ||||
| fn event_spans_date(event: &VEvent, date: NaiveDate) -> bool { | ||||
|     let start_date = if event.all_day { | ||||
|         // For all-day events, extract date directly from UTC without timezone conversion | ||||
|         // since all-day events are stored at noon UTC to avoid timezone boundary issues | ||||
|         event.dtstart.date_naive() | ||||
|     } else { | ||||
|         event.dtstart.with_timezone(&Local).date_naive() | ||||
|     }; | ||||
|      | ||||
|     let end_date = if let Some(dtend) = event.dtend { | ||||
|         if event.all_day { | ||||
|             // For all-day events, dtend is set to the day after the last day (RFC 5545) | ||||
|             // Extract date directly from UTC and subtract a day to get actual last day | ||||
|             dtend.date_naive() - chrono::Duration::days(1) | ||||
|         } else { | ||||
|             // For timed events, use timezone conversion | ||||
|             dtend.with_timezone(&Local).date_naive() | ||||
|         } | ||||
|     } else { | ||||
|         // Single day event | ||||
|         start_date | ||||
|     }; | ||||
|      | ||||
|     // Check if the given date falls within the event's date range | ||||
|     date >= start_date && date <= end_date | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday}; | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use std::collections::HashMap; | ||||
| use wasm_bindgen::JsCast; | ||||
| @@ -43,6 +44,7 @@ pub struct CalendarInfo { | ||||
|     pub path: String, | ||||
|     pub display_name: String, | ||||
|     pub color: String, | ||||
|     pub is_visible: bool, | ||||
| } | ||||
|  | ||||
| // CalendarEvent, EventStatus, and EventClass are now imported from shared library | ||||
| @@ -271,8 +273,8 @@ impl CalendarService { | ||||
|     pub fn expand_recurring_events(events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|         let mut expanded_events = Vec::new(); | ||||
|         let today = chrono::Utc::now().date_naive(); | ||||
|         let start_range = today - Duration::days(30); // Show past 30 days | ||||
|         let end_range = today + Duration::days(365); // Show next 365 days | ||||
|         let start_range = today - Duration::days(36500); // Show past 100 years (to catch any historical yearly events) | ||||
|         let end_range = today + Duration::days(36500); // Show next 100 years | ||||
|  | ||||
|         for event in events { | ||||
|             if let Some(ref rrule) = event.rrule { | ||||
| @@ -1249,6 +1251,8 @@ impl CalendarService { | ||||
|         reminder: String, | ||||
|         recurrence: String, | ||||
|         recurrence_days: Vec<bool>, | ||||
|         recurrence_count: Option<u32>, | ||||
|         recurrence_until: Option<String>, | ||||
|         calendar_path: Option<String>, | ||||
|     ) -> Result<(), String> { | ||||
|         let window = web_sys::window().ok_or("No global window exists")?; | ||||
| @@ -1281,8 +1285,8 @@ impl CalendarService { | ||||
|                 "recurrence": recurrence, | ||||
|                 "recurrence_days": recurrence_days, | ||||
|                 "recurrence_interval": 1_u32, // Default interval | ||||
|                 "recurrence_end_date": None as Option<String>, // No end date by default | ||||
|                 "recurrence_count": None as Option<u32>, // No count limit by default | ||||
|                 "recurrence_end_date": recurrence_until, | ||||
|                 "recurrence_count": recurrence_count, | ||||
|                 "calendar_path": calendar_path | ||||
|             }); | ||||
|             let url = format!("{}/calendar/events/series/create", self.base_url); | ||||
| @@ -1676,6 +1680,9 @@ impl CalendarService { | ||||
|         categories: String, | ||||
|         reminder: String, | ||||
|         recurrence: String, | ||||
|         recurrence_days: Vec<bool>, | ||||
|         recurrence_count: Option<u32>, | ||||
|         recurrence_until: Option<String>, | ||||
|         calendar_path: Option<String>, | ||||
|         update_scope: String, | ||||
|         occurrence_date: Option<String>, | ||||
| @@ -1704,10 +1711,10 @@ impl CalendarService { | ||||
|             "categories": categories, | ||||
|             "reminder": reminder, | ||||
|             "recurrence": recurrence, | ||||
|             "recurrence_days": vec![false; 7], // Default - could be enhanced | ||||
|             "recurrence_interval": 1_u32, // Default interval | ||||
|             "recurrence_end_date": None as Option<String>, // No end date by default | ||||
|             "recurrence_count": None as Option<u32>, // No count limit by default | ||||
|             "recurrence_days": recurrence_days, | ||||
|             "recurrence_interval": 1_u32, // Default interval - could be enhanced to be a parameter | ||||
|             "recurrence_end_date": recurrence_until, | ||||
|             "recurrence_count": recurrence_count, | ||||
|             "calendar_path": calendar_path, | ||||
|             "update_scope": update_scope, | ||||
|             "occurrence_date": occurrence_date | ||||
| @@ -1849,4 +1856,257 @@ impl CalendarService { | ||||
|  | ||||
|         None | ||||
|     } | ||||
|  | ||||
|     // ==================== EXTERNAL CALENDAR METHODS ==================== | ||||
|  | ||||
|     pub async fn get_external_calendars() -> Result<Vec<ExternalCalendar>, String> { | ||||
|         let token = LocalStorage::get::<String>("auth_token") | ||||
|             .map_err(|_| "No authentication token found".to_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 service = Self::new(); | ||||
|         let url = format!("{}/external-calendars", service.base_url); | ||||
|         let request = Request::new_with_str_and_init(&url, &opts) | ||||
|             .map_err(|e| format!("Request creation failed: {:?}", e))?; | ||||
|  | ||||
|         request | ||||
|             .headers() | ||||
|             .set("Authorization", &format!("Bearer {}", token)) | ||||
|             .map_err(|e| format!("Authorization header setting failed: {:?}", e))?; | ||||
|  | ||||
|         let resp_value = JsFuture::from(window.fetch_with_request(&request)) | ||||
|             .await | ||||
|             .map_err(|e| format!("Request failed: {:?}", e))?; | ||||
|  | ||||
|         let resp: Response = resp_value | ||||
|             .dyn_into() | ||||
|             .map_err(|e| format!("Response casting failed: {:?}", e))?; | ||||
|  | ||||
|         if !resp.ok() { | ||||
|             return Err(format!("HTTP error: {}", resp.status())); | ||||
|         } | ||||
|  | ||||
|         let json = JsFuture::from(resp.json().unwrap()) | ||||
|             .await | ||||
|             .map_err(|e| format!("JSON parsing failed: {:?}", e))?; | ||||
|  | ||||
|         let external_calendars: Vec<ExternalCalendar> = serde_wasm_bindgen::from_value(json) | ||||
|             .map_err(|e| format!("Deserialization failed: {:?}", e))?; | ||||
|  | ||||
|         Ok(external_calendars) | ||||
|     } | ||||
|  | ||||
|     pub async fn create_external_calendar(name: &str, url: &str, color: &str) -> Result<ExternalCalendar, String> { | ||||
|         let token = LocalStorage::get::<String>("auth_token") | ||||
|             .map_err(|_| "No authentication token found".to_string())?; | ||||
|  | ||||
|         let window = web_sys::window().ok_or("No global window exists")?; | ||||
|  | ||||
|         let opts = RequestInit::new(); | ||||
|         opts.set_method("POST"); | ||||
|         opts.set_mode(RequestMode::Cors); | ||||
|  | ||||
|         let body = serde_json::json!({ | ||||
|             "name": name, | ||||
|             "url": url, | ||||
|             "color": color | ||||
|         }); | ||||
|  | ||||
|         let service = Self::new(); | ||||
|         let body_string = serde_json::to_string(&body) | ||||
|             .map_err(|e| format!("JSON serialization failed: {}", e))?; | ||||
|         opts.set_body(&body_string.into()); | ||||
|  | ||||
|         let url = format!("{}/external-calendars", service.base_url); | ||||
|         let request = Request::new_with_str_and_init(&url, &opts) | ||||
|             .map_err(|e| format!("Request creation failed: {:?}", e))?; | ||||
|  | ||||
|         request | ||||
|             .headers() | ||||
|             .set("Authorization", &format!("Bearer {}", token)) | ||||
|             .map_err(|e| format!("Authorization header setting failed: {:?}", e))?; | ||||
|  | ||||
|         request | ||||
|             .headers() | ||||
|             .set("Content-Type", "application/json") | ||||
|             .map_err(|e| format!("Content-Type header setting failed: {:?}", e))?; | ||||
|  | ||||
|         let resp_value = JsFuture::from(window.fetch_with_request(&request)) | ||||
|             .await | ||||
|             .map_err(|e| format!("Request failed: {:?}", e))?; | ||||
|  | ||||
|         let resp: Response = resp_value | ||||
|             .dyn_into() | ||||
|             .map_err(|e| format!("Response casting failed: {:?}", e))?; | ||||
|  | ||||
|         if !resp.ok() { | ||||
|             return Err(format!("HTTP error: {}", resp.status())); | ||||
|         } | ||||
|  | ||||
|         let json = JsFuture::from(resp.json().unwrap()) | ||||
|             .await | ||||
|             .map_err(|e| format!("JSON parsing failed: {:?}", e))?; | ||||
|  | ||||
|         let external_calendar: ExternalCalendar = serde_wasm_bindgen::from_value(json) | ||||
|             .map_err(|e| format!("Deserialization failed: {:?}", e))?; | ||||
|  | ||||
|         Ok(external_calendar) | ||||
|     } | ||||
|  | ||||
|     pub async fn update_external_calendar( | ||||
|         id: i32, | ||||
|         name: &str, | ||||
|         url: &str, | ||||
|         color: &str, | ||||
|         is_visible: bool, | ||||
|     ) -> Result<(), String> { | ||||
|         let token = LocalStorage::get::<String>("auth_token") | ||||
|             .map_err(|_| "No authentication token found".to_string())?; | ||||
|  | ||||
|         let window = web_sys::window().ok_or("No global window exists")?; | ||||
|  | ||||
|         let opts = RequestInit::new(); | ||||
|         opts.set_method("POST"); | ||||
|         opts.set_mode(RequestMode::Cors); | ||||
|  | ||||
|         let body = serde_json::json!({ | ||||
|             "name": name, | ||||
|             "url": url, | ||||
|             "color": color, | ||||
|             "is_visible": is_visible | ||||
|         }); | ||||
|  | ||||
|         let service = Self::new(); | ||||
|         let body_string = serde_json::to_string(&body) | ||||
|             .map_err(|e| format!("JSON serialization failed: {}", e))?; | ||||
|         opts.set_body(&body_string.into()); | ||||
|  | ||||
|         let url = format!("{}/external-calendars/{}", service.base_url, id); | ||||
|         let request = Request::new_with_str_and_init(&url, &opts) | ||||
|             .map_err(|e| format!("Request creation failed: {:?}", e))?; | ||||
|  | ||||
|         request | ||||
|             .headers() | ||||
|             .set("Authorization", &format!("Bearer {}", token)) | ||||
|             .map_err(|e| format!("Authorization header setting failed: {:?}", e))?; | ||||
|  | ||||
|         request | ||||
|             .headers() | ||||
|             .set("Content-Type", "application/json") | ||||
|             .map_err(|e| format!("Content-Type header setting failed: {:?}", e))?; | ||||
|  | ||||
|         let resp_value = JsFuture::from(window.fetch_with_request(&request)) | ||||
|             .await | ||||
|             .map_err(|e| format!("Request failed: {:?}", e))?; | ||||
|  | ||||
|         let resp: Response = resp_value | ||||
|             .dyn_into() | ||||
|             .map_err(|e| format!("Response casting failed: {:?}", e))?; | ||||
|  | ||||
|         if !resp.ok() { | ||||
|             return Err(format!("HTTP error: {}", resp.status())); | ||||
|         } | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     pub async fn delete_external_calendar(id: i32) -> Result<(), String> { | ||||
|         let token = LocalStorage::get::<String>("auth_token") | ||||
|             .map_err(|_| "No authentication token found".to_string())?; | ||||
|  | ||||
|         let window = web_sys::window().ok_or("No global window exists")?; | ||||
|  | ||||
|         let opts = RequestInit::new(); | ||||
|         opts.set_method("DELETE"); | ||||
|         opts.set_mode(RequestMode::Cors); | ||||
|  | ||||
|         let service = Self::new(); | ||||
|         let url = format!("{}/external-calendars/{}", service.base_url, id); | ||||
|         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!("Request failed: {:?}", e))?; | ||||
|  | ||||
|         let resp: Response = resp_value | ||||
|             .dyn_into() | ||||
|             .map_err(|e| format!("Response casting failed: {:?}", e))?; | ||||
|  | ||||
|         if !resp.ok() { | ||||
|             return Err(format!("HTTP error: {}", resp.status())); | ||||
|         } | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     pub async fn fetch_external_calendar_events(id: i32) -> Result<Vec<VEvent>, String> { | ||||
|         let token = LocalStorage::get::<String>("auth_token") | ||||
|             .map_err(|_| "No authentication token found".to_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 service = Self::new(); | ||||
|         let url = format!("{}/external-calendars/{}/events", service.base_url, id); | ||||
|         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!("Request failed: {:?}", e))?; | ||||
|  | ||||
|         let resp: Response = resp_value | ||||
|             .dyn_into() | ||||
|             .map_err(|e| format!("Response casting failed: {:?}", e))?; | ||||
|  | ||||
|         if !resp.ok() { | ||||
|             return Err(format!("HTTP error: {}", resp.status())); | ||||
|         } | ||||
|  | ||||
|         let json = JsFuture::from(resp.json().unwrap()) | ||||
|             .await | ||||
|             .map_err(|e| format!("JSON parsing failed: {:?}", e))?; | ||||
|  | ||||
|         #[derive(Deserialize)] | ||||
|         struct ExternalCalendarEventsResponse { | ||||
|             events: Vec<VEvent>, | ||||
|             last_fetched: chrono::DateTime<chrono::Utc>, | ||||
|         } | ||||
|  | ||||
|         let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json) | ||||
|             .map_err(|e| format!("Deserialization failed: {:?}", e))?; | ||||
|  | ||||
|         Ok(response.events) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub struct ExternalCalendar { | ||||
|     pub id: i32, | ||||
|     pub name: String, | ||||
|     pub url: String, | ||||
|     pub color: String, | ||||
|     pub is_visible: bool, | ||||
|     pub created_at: chrono::DateTime<chrono::Utc>, | ||||
|     pub updated_at: chrono::DateTime<chrono::Utc>, | ||||
|     pub last_fetched: Option<chrono::DateTime<chrono::Utc>>, | ||||
| } | ||||
|   | ||||
| @@ -12,6 +12,7 @@ pub struct UserPreferences { | ||||
|     pub calendar_view_mode: Option<String>, | ||||
|     pub calendar_theme: Option<String>, | ||||
|     pub calendar_colors: Option<String>, | ||||
|     pub last_used_calendar: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Serialize)] | ||||
| @@ -22,6 +23,7 @@ pub struct UpdatePreferencesRequest { | ||||
|     pub calendar_view_mode: Option<String>, | ||||
|     pub calendar_theme: Option<String>, | ||||
|     pub calendar_colors: Option<String>, | ||||
|     pub last_used_calendar: Option<String>, | ||||
| } | ||||
|  | ||||
| #[allow(dead_code)] | ||||
| @@ -61,6 +63,7 @@ impl PreferencesService { | ||||
|             calendar_view_mode: None, | ||||
|             calendar_theme: None, | ||||
|             calendar_colors: None, | ||||
|             last_used_calendar: None, | ||||
|         }); | ||||
|          | ||||
|         // Update the specific field | ||||
| @@ -95,6 +98,7 @@ impl PreferencesService { | ||||
|             calendar_view_mode: preferences.calendar_view_mode.clone(), | ||||
|             calendar_theme: preferences.calendar_theme.clone(), | ||||
|             calendar_colors: preferences.calendar_colors.clone(), | ||||
|             last_used_calendar: preferences.last_used_calendar.clone(), | ||||
|         }; | ||||
|          | ||||
|         self.sync_preferences(&session_token, &request).await | ||||
| @@ -156,6 +160,7 @@ impl PreferencesService { | ||||
|             calendar_view_mode: LocalStorage::get::<String>("calendar_view_mode").ok(), | ||||
|             calendar_theme: LocalStorage::get::<String>("calendar_theme").ok(), | ||||
|             calendar_colors: LocalStorage::get::<String>("calendar_colors").ok(), | ||||
|             last_used_calendar: LocalStorage::get::<String>("last_used_calendar").ok(), | ||||
|         }; | ||||
|          | ||||
|         // Only migrate if we have some preferences to migrate | ||||
| @@ -164,6 +169,7 @@ impl PreferencesService { | ||||
|             || request.calendar_view_mode.is_some() | ||||
|             || request.calendar_theme.is_some() | ||||
|             || request.calendar_colors.is_some() | ||||
|             || request.last_used_calendar.is_some() | ||||
|         { | ||||
|             self.sync_preferences(&session_token, &request).await?; | ||||
|              | ||||
| @@ -177,4 +183,24 @@ impl PreferencesService { | ||||
|          | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Update the last used calendar and sync with backend | ||||
|     pub async fn update_last_used_calendar(&self, calendar_path: &str) -> Result<(), String> { | ||||
|         // Get session token | ||||
|         let session_token = LocalStorage::get::<String>("session_token") | ||||
|             .map_err(|_| "No session token found".to_string())?; | ||||
|          | ||||
|         // Create minimal update request with only the last used calendar | ||||
|         let request = UpdatePreferencesRequest { | ||||
|             calendar_selected_date: None, | ||||
|             calendar_time_increment: None, | ||||
|             calendar_view_mode: None, | ||||
|             calendar_theme: None, | ||||
|             calendar_colors: None, | ||||
|             last_used_calendar: Some(calendar_path.to_string()), | ||||
|         }; | ||||
|          | ||||
|         // Sync to backend | ||||
|         self.sync_preferences(&session_token, &request).await | ||||
|     } | ||||
| } | ||||
| @@ -733,7 +733,11 @@ body { | ||||
| .time-grid { | ||||
|     display: grid; | ||||
|     grid-template-columns: 80px 1fr; | ||||
|     min-height: 1530px; | ||||
|     min-height: 1530px; /* 30-minute mode */ | ||||
| } | ||||
|  | ||||
| .time-grid.quarter-mode { | ||||
|     min-height: 2970px; /* 15-minute mode */ | ||||
| } | ||||
|  | ||||
| /* Time Labels */ | ||||
| @@ -743,9 +747,15 @@ body { | ||||
|     position: sticky; | ||||
|     left: 0; | ||||
|     z-index: 5; | ||||
|     min-height: 1440px; /* Match the time slots height */ | ||||
|     min-height: 1530px; /* 30-minute mode */ | ||||
| } | ||||
|  | ||||
| /* Scale time labels container for 15-minute mode */ | ||||
| .time-labels.quarter-mode { | ||||
|     min-height: 2970px; /* 15-minute mode */ | ||||
| } | ||||
|  | ||||
| /* Default time label height for 30-minute mode */ | ||||
| .time-label { | ||||
|     height: 60px; | ||||
|     display: flex; | ||||
| @@ -758,24 +768,31 @@ body { | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .time-label.final-boundary { | ||||
|     height: 60px; /* Keep same height but this marks the end boundary */ | ||||
|     border-bottom: 2px solid #e9ecef; /* Stronger border to show day end */ | ||||
|     color: #999; /* Lighter color to indicate it's the boundary */ | ||||
|     font-size: 0.7rem; | ||||
| /* Time label height for 15-minute mode - double height */ | ||||
| .time-label.quarter-mode { | ||||
|     height: 120px; | ||||
| } | ||||
|  | ||||
|  | ||||
| /* Week Days Grid */ | ||||
| .week-days-grid { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(7, 1fr); | ||||
|     min-height: 1440px; /* Ensure grid is tall enough for 24 time slots */ | ||||
|     min-height: 1530px; /* 30-minute mode */ | ||||
| } | ||||
|  | ||||
| .week-days-grid.quarter-mode { | ||||
|     min-height: 2970px; /* 15-minute mode */ | ||||
| } | ||||
|  | ||||
| .week-day-column { | ||||
|     position: relative; | ||||
|     border-right: 1px solid var(--time-label-border, #e9ecef); | ||||
|     min-height: 1440px; /* 24 time slots × 60px = 1440px total */ | ||||
|     min-height: 1530px; /* 30-minute mode */ | ||||
| } | ||||
|  | ||||
| .week-day-column.quarter-mode { | ||||
|     min-height: 2970px; /* 15-minute mode */ | ||||
| } | ||||
|  | ||||
| .week-day-column:last-child { | ||||
| @@ -788,12 +805,16 @@ body { | ||||
|  | ||||
| /* Time Slots */ | ||||
| .time-slot { | ||||
|     height: 60px; | ||||
|     height: 60px; /* 30-minute mode: 2 slots × 30px = 60px */ | ||||
|     border-bottom: 1px solid var(--calendar-border, #f0f0f0); | ||||
|     position: relative; | ||||
|     pointer-events: none; /* Don't capture mouse events */ | ||||
| } | ||||
|  | ||||
| .time-slot.quarter-mode { | ||||
|     height: 120px; /* 15-minute mode: 4 slots × 30px = 120px */ | ||||
| } | ||||
|  | ||||
| .time-slot-half { | ||||
|     height: 30px; | ||||
|     border-bottom: 1px dotted var(--calendar-border, #f5f5f5); | ||||
| @@ -804,13 +825,17 @@ body { | ||||
|     border-bottom: none; | ||||
| } | ||||
|  | ||||
| .time-slot.boundary-slot { | ||||
|     height: 60px; /* Match the final time label height */ | ||||
|     border-bottom: 2px solid #e9ecef; /* Strong border to match final boundary */ | ||||
|     background: rgba(0,0,0,0.02); /* Slightly different background to indicate boundary */ | ||||
| .time-slot-quarter { | ||||
|     height: 30px; | ||||
|     border-bottom: 1px dotted var(--calendar-border-light, #f8f8f8); | ||||
|     pointer-events: none; /* Don't capture mouse events */ | ||||
| } | ||||
|  | ||||
| .time-slot-quarter:last-child { | ||||
|     border-bottom: none; | ||||
| } | ||||
|  | ||||
|  | ||||
| /* Events Container */ | ||||
| .events-container { | ||||
|     position: absolute; | ||||
| @@ -3242,6 +3267,7 @@ body { | ||||
|     --accent-color: #667eea; | ||||
|     --calendar-bg: white; | ||||
|     --calendar-border: #f0f0f0; | ||||
|     --calendar-border-light: #f8f8f8; | ||||
|     --calendar-day-bg: white; | ||||
|     --calendar-day-hover: #f8f9ff; | ||||
|     --calendar-day-prev-next: #fafafa; | ||||
| @@ -3275,6 +3301,7 @@ body { | ||||
|     --accent-color: #2196F3; | ||||
|     --calendar-bg: #ffffff; | ||||
|     --calendar-border: #bbdefb; | ||||
|     --calendar-border-light: #e3f2fd; | ||||
|     --calendar-day-bg: #ffffff; | ||||
|     --calendar-day-hover: #e1f5fe; | ||||
|     --calendar-day-prev-next: #f3f8ff; | ||||
| @@ -3317,6 +3344,7 @@ body { | ||||
|     --accent-color: #4CAF50; | ||||
|     --calendar-bg: #ffffff; | ||||
|     --calendar-border: #c8e6c9; | ||||
|     --calendar-border-light: #e8f5e8; | ||||
|     --calendar-day-bg: #ffffff; | ||||
|     --calendar-day-hover: #f1f8e9; | ||||
|     --calendar-day-prev-next: #f9fbe7; | ||||
| @@ -3359,6 +3387,7 @@ body { | ||||
|     --accent-color: #FF9800; | ||||
|     --calendar-bg: #ffffff; | ||||
|     --calendar-border: #ffe0b2; | ||||
|     --calendar-border-light: #fff3e0; | ||||
|     --calendar-day-bg: #ffffff; | ||||
|     --calendar-day-hover: #fff8e1; | ||||
|     --calendar-day-prev-next: #fffde7; | ||||
| @@ -3401,6 +3430,7 @@ body { | ||||
|     --accent-color: #9C27B0; | ||||
|     --calendar-bg: #ffffff; | ||||
|     --calendar-border: #ce93d8; | ||||
|     --calendar-border-light: #f3e5f5; | ||||
|     --calendar-day-bg: #ffffff; | ||||
|     --calendar-day-hover: #f8e9fc; | ||||
|     --calendar-day-prev-next: #fce4ec; | ||||
| @@ -3443,6 +3473,7 @@ body { | ||||
|     --accent-color: #666666; | ||||
|     --calendar-bg: #1f1f1f; | ||||
|     --calendar-border: #333333; | ||||
|     --calendar-border-light: #2a2a2a; | ||||
|     --calendar-day-bg: #1f1f1f; | ||||
|     --calendar-day-hover: #2a2a2a; | ||||
|     --calendar-day-prev-next: #1a1a1a; | ||||
| @@ -3495,6 +3526,7 @@ body { | ||||
|     --accent-color: #E91E63; | ||||
|     --calendar-bg: #ffffff; | ||||
|     --calendar-border: #f8bbd9; | ||||
|     --calendar-border-light: #fce4ec; | ||||
|     --calendar-day-bg: #ffffff; | ||||
|     --calendar-day-hover: #fdf2f8; | ||||
|     --calendar-day-prev-next: #fef7ff; | ||||
| @@ -3537,6 +3569,7 @@ body { | ||||
|     --accent-color: #26A69A; | ||||
|     --calendar-bg: #ffffff; | ||||
|     --calendar-border: #b2dfdb; | ||||
|     --calendar-border-light: #e0f2f1; | ||||
|     --calendar-day-bg: #ffffff; | ||||
|     --calendar-day-hover: #f0fdfc; | ||||
|     --calendar-day-prev-next: #f7ffff; | ||||
| @@ -3712,3 +3745,383 @@ body { | ||||
|         grid-template-columns: repeat(2, 1fr); | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* ==================== EXTERNAL CALENDARS STYLES ==================== */ | ||||
|  | ||||
| /* External Calendar Section in Sidebar */ | ||||
| .external-calendar-list { | ||||
|     margin-bottom: 2rem; | ||||
| } | ||||
|  | ||||
| .external-calendar-list h3 { | ||||
|     color: rgba(255, 255, 255, 0.9); | ||||
|     font-size: 0.9rem; | ||||
|     font-weight: 600; | ||||
|     margin-bottom: 1rem; | ||||
|     text-transform: uppercase; | ||||
|     letter-spacing: 0.5px; | ||||
|     padding-left: 0.5rem; | ||||
| } | ||||
|  | ||||
| .external-calendar-items { | ||||
|     list-style: none; | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
| } | ||||
|  | ||||
| .external-calendar-item { | ||||
|     margin-bottom: 0.5rem; | ||||
| } | ||||
|  | ||||
| .external-calendar-info { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 0.75rem; | ||||
|     padding: 0.75rem; | ||||
|     border-radius: 8px; | ||||
|     transition: all 0.2s ease; | ||||
|     cursor: pointer; | ||||
|     background: rgba(255, 255, 255, 0.05); | ||||
|     border: 1px solid rgba(255, 255, 255, 0.1); | ||||
| } | ||||
|  | ||||
| .external-calendar-info:hover { | ||||
|     background: rgba(255, 255, 255, 0.1); | ||||
|     border-color: rgba(255, 255, 255, 0.2); | ||||
|     transform: translateX(2px); | ||||
| } | ||||
|  | ||||
| .external-calendar-info input[type="checkbox"] { | ||||
|     width: 16px; | ||||
|     height: 16px; | ||||
|     accent-color: rgba(255, 255, 255, 0.8); | ||||
|     cursor: pointer; | ||||
| } | ||||
|  | ||||
| .external-calendar-color { | ||||
|     width: 12px; | ||||
|     height: 12px; | ||||
|     border-radius: 50%; | ||||
|     border: 1px solid rgba(255, 255, 255, 0.3); | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .external-calendar-name { | ||||
|     color: rgba(255, 255, 255, 0.9); | ||||
|     font-size: 0.85rem; | ||||
|     font-weight: 500; | ||||
|     flex: 1; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
|     white-space: nowrap; | ||||
| } | ||||
|  | ||||
| .external-calendar-actions { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 0.5rem; | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .last-updated { | ||||
|     font-size: 0.7rem; | ||||
|     color: rgba(255, 255, 255, 0.6); | ||||
|     opacity: 0.8; | ||||
| } | ||||
|  | ||||
| .external-calendar-refresh-btn { | ||||
|     background: none; | ||||
|     border: none; | ||||
|     color: rgba(255, 255, 255, 0.7); | ||||
|     cursor: pointer; | ||||
|     font-size: 0.8rem; | ||||
|     padding: 2px 4px; | ||||
|     border-radius: 3px; | ||||
|     transition: all 0.2s ease; | ||||
|     line-height: 1; | ||||
| } | ||||
|  | ||||
| .external-calendar-refresh-btn:hover { | ||||
|     color: rgba(255, 255, 255, 0.9); | ||||
|     background: rgba(255, 255, 255, 0.1); | ||||
|     transform: rotate(180deg); | ||||
| } | ||||
|  | ||||
| .external-calendar-indicator { | ||||
|     font-size: 0.8rem; | ||||
|     opacity: 0.7; | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| /* CalDAV Calendar Styles */ | ||||
| .calendar-info { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 0.75rem; | ||||
|     padding: 0.75rem; | ||||
|     border-radius: 8px; | ||||
|     transition: all 0.2s ease; | ||||
|     cursor: pointer; | ||||
| } | ||||
|  | ||||
| .calendar-info:hover { | ||||
|     background: rgba(255, 255, 255, 0.1); | ||||
|     transform: translateX(2px); | ||||
| } | ||||
|  | ||||
| .calendar-info input[type="checkbox"] { | ||||
|     width: 16px; | ||||
|     height: 16px; | ||||
|     accent-color: rgba(255, 255, 255, 0.8); | ||||
|     cursor: pointer; | ||||
| } | ||||
|  | ||||
| /* Create External Calendar Button */ | ||||
| .create-external-calendar-button { | ||||
|     background: rgba(255, 255, 255, 0.15); | ||||
|     border: 1px solid rgba(255, 255, 255, 0.25); | ||||
|     color: rgba(255, 255, 255, 0.9); | ||||
|     padding: 0.75rem 1rem; | ||||
|     border-radius: 8px; | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     margin-bottom: 1rem; | ||||
|     font-size: 0.85rem; | ||||
|     font-weight: 500; | ||||
|     backdrop-filter: blur(10px); | ||||
|     width: 100%; | ||||
|     position: relative; | ||||
| } | ||||
|  | ||||
| .create-external-calendar-button::before { | ||||
|     content: ""; | ||||
|     position: absolute; | ||||
|     left: 1rem; | ||||
|     font-size: 0.8rem; | ||||
|     opacity: 0.8; | ||||
| } | ||||
|  | ||||
| .create-external-calendar-button { | ||||
|     padding-left: 2.5rem; | ||||
| } | ||||
|  | ||||
| .create-external-calendar-button:hover { | ||||
|     background: rgba(255, 255, 255, 0.25); | ||||
|     border-color: rgba(255, 255, 255, 0.4); | ||||
|     transform: translateY(-1px); | ||||
| } | ||||
|  | ||||
| .create-external-calendar-button:active { | ||||
|     transform: translateY(0); | ||||
| } | ||||
|  | ||||
| /* External Calendar Modal */ | ||||
| .external-calendar-modal { | ||||
|     background: white; | ||||
|     border-radius: 12px; | ||||
|     box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); | ||||
|     max-width: 500px; | ||||
|     width: 100%; | ||||
|     max-height: 90vh; | ||||
|     overflow-y: auto; | ||||
|     animation: modalSlideIn 0.3s ease; | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .modal-header { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: space-between; | ||||
|     padding: 2rem 2rem 1rem; | ||||
|     border-bottom: 1px solid #e9ecef; | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .modal-header h3 { | ||||
|     margin: 0; | ||||
|     color: #495057; | ||||
|     font-size: 1.5rem; | ||||
|     font-weight: 600; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 0.5rem; | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .modal-header h3::before { | ||||
|     content: "📅"; | ||||
|     font-size: 1.2rem; | ||||
|     opacity: 0.8; | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .modal-close { | ||||
|     background: none; | ||||
|     border: none; | ||||
|     font-size: 1.5rem; | ||||
|     color: #6c757d; | ||||
|     cursor: pointer; | ||||
|     padding: 0.5rem; | ||||
|     border-radius: 50%; | ||||
|     width: 2.5rem; | ||||
|     height: 2.5rem; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     transition: all 0.2s ease; | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .modal-close:hover { | ||||
|     background: #f8f9fa; | ||||
|     color: #495057; | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .modal-body { | ||||
|     padding: 1.5rem 2rem 2rem; | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .form-group { | ||||
|     margin-bottom: 1.5rem; | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .form-group label { | ||||
|     display: block; | ||||
|     margin-bottom: 0.5rem; | ||||
|     font-weight: 600; | ||||
|     color: #495057; | ||||
|     font-size: 0.9rem; | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .form-group input[type="text"], | ||||
| .external-calendar-modal .form-group input[type="url"] { | ||||
|     width: 100%; | ||||
|     padding: 0.75rem; | ||||
|     border: 1px solid #ced4da; | ||||
|     border-radius: 8px; | ||||
|     font-size: 0.9rem; | ||||
|     transition: all 0.2s ease; | ||||
|     background: white; | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .form-group input[type="text"]:focus, | ||||
| .external-calendar-modal .form-group input[type="url"]:focus { | ||||
|     outline: none; | ||||
|     border-color: var(--primary-color, #667eea); | ||||
|     box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1); | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .form-group input[type="color"] { | ||||
|     width: 80px; | ||||
|     height: 40px; | ||||
|     padding: 0; | ||||
|     border: 1px solid #ced4da; | ||||
|     border-radius: 8px; | ||||
|     cursor: pointer; | ||||
|     background: none; | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .form-help { | ||||
|     display: block; | ||||
|     margin-top: 0.5rem; | ||||
|     font-size: 0.8rem; | ||||
|     color: #6c757d; | ||||
|     font-style: italic; | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .modal-actions { | ||||
|     display: flex; | ||||
|     gap: 1rem; | ||||
|     justify-content: flex-end; | ||||
|     padding: 1.5rem 2rem; | ||||
|     border-top: 1px solid #e9ecef; | ||||
|     background: #f8f9fa; | ||||
|     border-radius: 0 0 12px 12px; | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .btn { | ||||
|     padding: 0.75rem 1.5rem; | ||||
|     border-radius: 8px; | ||||
|     font-size: 0.9rem; | ||||
|     font-weight: 600; | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     border: none; | ||||
|     min-width: 100px; | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .btn-secondary { | ||||
|     background: #6c757d; | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .btn-secondary:hover:not(:disabled) { | ||||
|     background: #5a6268; | ||||
|     transform: translateY(-1px); | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .btn-primary { | ||||
|     background: var(--primary-gradient, linear-gradient(135deg, #667eea 0%, #764ba2 100%)); | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .btn-primary:hover:not(:disabled) { | ||||
|     filter: brightness(1.1); | ||||
|     transform: translateY(-1px); | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .btn:disabled { | ||||
|     opacity: 0.6; | ||||
|     cursor: not-allowed; | ||||
| } | ||||
|  | ||||
| .external-calendar-modal .error-message { | ||||
|     background: #f8d7da; | ||||
|     color: #721c24; | ||||
|     padding: 0.75rem 1rem; | ||||
|     border-radius: 8px; | ||||
|     margin-bottom: 1rem; | ||||
|     font-size: 0.9rem; | ||||
|     border: 1px solid #f5c6cb; | ||||
| } | ||||
|  | ||||
| /* External Calendar Events (Visual Distinction) */ | ||||
| .event[data-external="true"] { | ||||
|     position: relative; | ||||
|     border-style: dashed !important; | ||||
|     opacity: 0.85; | ||||
| } | ||||
|  | ||||
| .event[data-external="true"]::before { | ||||
|     content: "📅"; | ||||
|     position: absolute; | ||||
|     top: 2px; | ||||
|     right: 2px; | ||||
|     font-size: 0.7rem; | ||||
|     opacity: 0.7; | ||||
|     z-index: 1; | ||||
| } | ||||
|  | ||||
| /* Mobile Responsive */ | ||||
| @media (max-width: 768px) { | ||||
|     .external-calendar-modal { | ||||
|         max-height: 95vh; | ||||
|         margin: 1rem; | ||||
|         width: calc(100% - 2rem); | ||||
|     } | ||||
|      | ||||
|     .external-calendar-modal .modal-header, | ||||
|     .external-calendar-modal .modal-body, | ||||
|     .external-calendar-modal .modal-actions { | ||||
|         padding-left: 1.5rem; | ||||
|         padding-right: 1.5rem; | ||||
|     } | ||||
|      | ||||
|     .external-calendar-info { | ||||
|         padding: 0.5rem; | ||||
|     } | ||||
|      | ||||
|     .external-calendar-name { | ||||
|         font-size: 0.8rem; | ||||
|     } | ||||
|      | ||||
|     .create-external-calendar-button { | ||||
|         font-size: 0.8rem; | ||||
|         padding: 0.5rem 1rem 0.5rem 2rem; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -648,6 +648,16 @@ body { | ||||
|     border-bottom: none; | ||||
| } | ||||
|  | ||||
| .time-slot-quarter { | ||||
|     height: 30px; | ||||
|     border-bottom: 1px dotted var(--calendar-border-light, #f8f8f8); | ||||
|     pointer-events: none; /* Don't capture mouse events */ | ||||
| } | ||||
|  | ||||
| .time-slot-quarter:last-child { | ||||
|     border-bottom: none; | ||||
| } | ||||
|  | ||||
| .time-slot.boundary-slot { | ||||
|     height: 60px; /* Match the final time label height */ | ||||
|     border-bottom: 2px solid #e9ecef; /* Strong border to match final boundary */ | ||||
|   | ||||
		Reference in New Issue
	
	Block a user