Compare commits
	
		
			11 Commits
		
	
	
		
			235dcf8e1d
			...
			feature/ex
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 45e16313ba | ||
|   | 64c737c023 | ||
|   | 75d9149c76 | ||
|   | 28b3946e86 | ||
|   | 6a01a75cce | ||
|   | 189dd32f8c | ||
|   | 7461e8b123 | ||
|   | f88c238b0a | ||
|   | 8caa1f45ae | ||
|   | 289284a532 | ||
|   | 089f4ce105 | 
| @@ -22,6 +22,7 @@ hyper = { version = "1.0", features = ["full"] } | |||||||
| serde = { version = "1.0", features = ["derive"] } | serde = { version = "1.0", features = ["derive"] } | ||||||
| serde_json = "1.0" | serde_json = "1.0" | ||||||
| chrono = { version = "0.4", features = ["serde"] } | chrono = { version = "0.4", features = ["serde"] } | ||||||
|  | chrono-tz = "0.8" | ||||||
| uuid = { version = "1.0", features = ["v4", "serde"] } | uuid = { version = "1.0", features = ["v4", "serde"] } | ||||||
| anyhow = "1.0" | anyhow = "1.0" | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										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); | ||||||
| @@ -112,6 +112,17 @@ impl AuthService { | |||||||
|         self.decode_token(token) |         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 |     /// Create CalDAV config from token | ||||||
|     pub fn caldav_config_from_token( |     pub fn caldav_config_from_token( | ||||||
|         &self, |         &self, | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| use chrono::{DateTime, Utc}; | use chrono::{DateTime, Duration, Utc}; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; | use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; | ||||||
| use sqlx::{FromRow, Result}; | use sqlx::{FromRow, Result}; | ||||||
| @@ -99,6 +99,38 @@ pub struct UserPreferences { | |||||||
|     pub updated_at: DateTime<Utc>, |     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 { | impl UserPreferences { | ||||||
|     /// Create default preferences for a new user |     /// Create default preferences for a new user | ||||||
|     pub fn default_for_user(user_id: String) -> Self { |     pub fn default_for_user(user_id: String) -> Self { | ||||||
| @@ -311,3 +343,146 @@ impl<'a> PreferencesRepository<'a> { | |||||||
|         Ok(()) |         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(), |             path: path.clone(), | ||||||
|             display_name: extract_calendar_name(path), |             display_name: extract_calendar_name(path), | ||||||
|             color: generate_calendar_color(path), |             color: generate_calendar_color(path), | ||||||
|  |             is_visible: true, // Default to visible | ||||||
|         }) |         }) | ||||||
|         .collect(); |         .collect(); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -852,10 +852,11 @@ fn parse_event_datetime( | |||||||
|         .map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?; |         .map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?; | ||||||
|  |  | ||||||
|     if all_day { |     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 |         let datetime = date | ||||||
|             .and_hms_opt(0, 0, 0) |             .and_hms_opt(12, 0, 0) | ||||||
|             .ok_or_else(|| "Failed to create midnight datetime".to_string())?; |             .ok_or_else(|| "Failed to create noon datetime".to_string())?; | ||||||
|         Ok(Utc.from_utc_datetime(&datetime)) |         Ok(Utc.from_utc_datetime(&datetime)) | ||||||
|     } else { |     } else { | ||||||
|         // Parse the time |         // Parse the time | ||||||
|   | |||||||
							
								
								
									
										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::*; | ||||||
| @@ -262,8 +262,8 @@ pub async fn update_event_series( | |||||||
|     Json(request): Json<UpdateEventSeriesRequest>, |     Json(request): Json<UpdateEventSeriesRequest>, | ||||||
| ) -> Result<Json<UpdateEventSeriesResponse>, ApiError> { | ) -> Result<Json<UpdateEventSeriesResponse>, ApiError> { | ||||||
|     println!( |     println!( | ||||||
|         "🔄 Update event series request received: series_uid='{}', update_scope='{}'", |         "🔄 Update event series request received: series_uid='{}', update_scope='{}', recurrence_count={:?}, recurrence_end_date={:?}", | ||||||
|         request.series_uid, request.update_scope |         request.series_uid, request.update_scope, request.recurrence_count, request.recurrence_end_date | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     // Extract and verify token |     // Extract and verify token | ||||||
| @@ -397,8 +397,9 @@ pub async fn update_event_series( | |||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     let (start_datetime, end_datetime) = if request.all_day { |     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 |         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()))?; |             .ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?; | ||||||
|  |  | ||||||
|         // For all-day events, also preserve the original date pattern |         // For all-day events, also preserve the original date pattern | ||||||
| @@ -414,20 +415,13 @@ pub async fn update_event_series( | |||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         let end_dt = end_date |         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()))?; |             .ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?; | ||||||
|  |  | ||||||
|         // Convert from local time to UTC |         // For all-day events, use UTC directly (no local conversion needed) | ||||||
|         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()))?; |  | ||||||
|          |  | ||||||
|         ( |         ( | ||||||
|             start_local.with_timezone(&chrono::Utc), |             chrono::Utc.from_utc_datetime(&start_dt), | ||||||
|             end_local.with_timezone(&chrono::Utc), |             chrono::Utc.from_utc_datetime(&end_dt), | ||||||
|         ) |         ) | ||||||
|     } else { |     } else { | ||||||
|         let start_time = if !request.start_time.is_empty() { |         let start_time = if !request.start_time.is_empty() { | ||||||
| @@ -765,9 +759,36 @@ fn update_entire_series( | |||||||
|     updated_event.last_modified = Some(now); |     updated_event.last_modified = Some(now); | ||||||
|     // Keep original created timestamp to preserve event history |     // Keep original created timestamp to preserve event history | ||||||
|  |  | ||||||
|     // For simple updates (like drag operations), preserve the existing RRULE |     // Update RRULE if recurrence parameters are provided | ||||||
|     // For more complex updates, we might need to regenerate it, but for now keep it simple |     if let Some(ref existing_rrule) = updated_event.rrule { | ||||||
|     // updated_event.rrule remains unchanged from the clone |         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 |     // Copy the updated event back to existing_event for the main handler | ||||||
|     *existing_event = updated_event.clone(); |     *existing_event = updated_event.clone(); | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| use axum::{ | use axum::{ | ||||||
|     response::Json, |     response::Json, | ||||||
|     routing::{get, post}, |     routing::{delete, get, post}, | ||||||
|     Router, |     Router, | ||||||
| }; | }; | ||||||
| use std::sync::Arc; | 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", get(handlers::get_preferences)) | ||||||
|         .route("/api/preferences", post(handlers::update_preferences)) |         .route("/api/preferences", post(handlers::update_preferences)) | ||||||
|         .route("/api/auth/logout", post(handlers::logout)) |         .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( |         .layer( | ||||||
|             CorsLayer::new() |             CorsLayer::new() | ||||||
|                 .allow_origin(Any) |                 .allow_origin(Any) | ||||||
|   | |||||||
| @@ -56,6 +56,7 @@ pub struct CalendarInfo { | |||||||
|     pub path: String, |     pub path: String, | ||||||
|     pub display_name: String, |     pub display_name: String, | ||||||
|     pub color: String, |     pub color: String, | ||||||
|  |     pub is_visible: bool, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
|   | |||||||
| @@ -37,6 +37,7 @@ reqwest = { version = "0.11", features = ["json"] } | |||||||
| ical = "0.7" | ical = "0.7" | ||||||
| serde = { version = "1.0", features = ["derive"] } | serde = { version = "1.0", features = ["derive"] } | ||||||
| serde_json = "1.0" | serde_json = "1.0" | ||||||
|  | serde-wasm-bindgen = "0.6" | ||||||
|  |  | ||||||
| # Date and time handling | # Date and time handling | ||||||
| chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] } | chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] } | ||||||
|   | |||||||
| @@ -1,12 +1,14 @@ | |||||||
| use crate::components::{ | use crate::components::{ | ||||||
|     CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction, |     CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction, | ||||||
|     EditAction, EventContextMenu, EventCreationData, RouteHandler, Sidebar, Theme, ViewMode, |     EditAction, EventContextMenu, EventCreationData, ExternalCalendarModal, RouteHandler,  | ||||||
|  |     Sidebar, Theme, ViewMode, | ||||||
| }; | }; | ||||||
| use crate::components::sidebar::{Style}; | use crate::components::sidebar::{Style}; | ||||||
| use crate::models::ical::VEvent; | use crate::models::ical::VEvent; | ||||||
| use crate::services::{calendar_service::UserInfo, CalendarService}; | use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService}; | ||||||
| use chrono::NaiveDate; | use chrono::NaiveDate; | ||||||
| use gloo_storage::{LocalStorage, Storage}; | use gloo_storage::{LocalStorage, Storage}; | ||||||
|  | use gloo_timers::callback::Interval; | ||||||
| use wasm_bindgen::JsCast; | use wasm_bindgen::JsCast; | ||||||
| use web_sys::MouseEvent; | use web_sys::MouseEvent; | ||||||
| use yew::prelude::*; | use yew::prelude::*; | ||||||
| @@ -74,6 +76,12 @@ pub fn App() -> Html { | |||||||
|     let _recurring_edit_event = use_state(|| -> Option<VEvent> { None }); |     let _recurring_edit_event = use_state(|| -> Option<VEvent> { None }); | ||||||
|     let _recurring_edit_data = use_state(|| -> Option<EventCreationData> { 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 |     // Calendar view state - load from localStorage if available | ||||||
|     let current_view = use_state(|| { |     let current_view = use_state(|| { | ||||||
|         // Try to load saved view mode from localStorage |         // Try to load saved view mode from localStorage | ||||||
| @@ -301,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 on_outside_click = { | ||||||
|         let color_picker_open = color_picker_open.clone(); |         let color_picker_open = color_picker_open.clone(); | ||||||
|         let context_menu_open = context_menu_open.clone(); |         let context_menu_open = context_menu_open.clone(); | ||||||
| @@ -478,7 +560,10 @@ pub fn App() -> Html { | |||||||
|                                     params.13, // categories |                                     params.13, // categories | ||||||
|                                     params.14, // reminder |                                     params.14, // reminder | ||||||
|                                     params.15, // recurrence |                                     params.15, // recurrence | ||||||
|                                     params.17, // calendar_path (skipping recurrence_days) |                                     params.16, // recurrence_days | ||||||
|  |                                     params.18, // recurrence_count | ||||||
|  |                                     params.19, // recurrence_until | ||||||
|  |                                     params.17, // calendar_path | ||||||
|                                     scope, |                                     scope, | ||||||
|                                     event_data_for_update.occurrence_date.map(|d| d.format("%Y-%m-%d").to_string()), // occurrence_date |                                     event_data_for_update.occurrence_date.map(|d| d.format("%Y-%m-%d").to_string()), // occurrence_date | ||||||
|                                 ) |                                 ) | ||||||
| @@ -759,6 +844,9 @@ pub fn App() -> Html { | |||||||
|                                             original_event.categories.join(","), |                                             original_event.categories.join(","), | ||||||
|                                             reminder_str.clone(), |                                             reminder_str.clone(), | ||||||
|                                             recurrence_str.clone(), |                                             recurrence_str.clone(), | ||||||
|  |                                             vec![false; 7], | ||||||
|  |                                             None, | ||||||
|  |                                             None, | ||||||
|                                             original_event.calendar_path.clone(), |                                             original_event.calendar_path.clone(), | ||||||
|                                             scope.clone(), |                                             scope.clone(), | ||||||
|                                             occurrence_date, |                                             occurrence_date, | ||||||
| @@ -918,11 +1006,146 @@ pub fn App() -> Html { | |||||||
|                                         let create_modal_open = create_modal_open.clone(); |                                         let create_modal_open = create_modal_open.clone(); | ||||||
|                                         move |_| create_modal_open.set(true) |                                         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()} |                                     color_picker_open={(*color_picker_open).clone()} | ||||||
|                                     on_color_change={on_color_change} |                                     on_color_change={on_color_change} | ||||||
|                                     on_color_picker_toggle={on_color_picker_toggle} |                                     on_color_picker_toggle={on_color_picker_toggle} | ||||||
|                                     available_colors={(*available_colors).clone()} |                                     available_colors={(*available_colors).clone()} | ||||||
|                                     on_calendar_context_menu={on_calendar_context_menu} |                                     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()} |                                     current_view={(*current_view).clone()} | ||||||
|                                     on_view_change={on_view_change} |                                     on_view_change={on_view_change} | ||||||
|                                     current_theme={(*current_theme).clone()} |                                     current_theme={(*current_theme).clone()} | ||||||
| @@ -935,6 +1158,8 @@ pub fn App() -> Html { | |||||||
|                                         auth_token={(*auth_token).clone()} |                                         auth_token={(*auth_token).clone()} | ||||||
|                                         user_info={(*user_info).clone()} |                                         user_info={(*user_info).clone()} | ||||||
|                                         on_login={on_login.clone()} |                                         on_login={on_login.clone()} | ||||||
|  |                                         external_calendar_events={(*external_calendar_events).clone()} | ||||||
|  |                                         external_calendars={(*external_calendars).clone()} | ||||||
|                                         on_event_context_menu={Some(on_event_context_menu.clone())} |                                         on_event_context_menu={Some(on_event_context_menu.clone())} | ||||||
|                                         on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())} |                                         on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())} | ||||||
|                                         view={(*current_view).clone()} |                                         view={(*current_view).clone()} | ||||||
| @@ -1187,6 +1412,59 @@ pub fn App() -> Html { | |||||||
|                     on_create={on_event_create} |                     on_create={on_event_create} | ||||||
|                     available_calendars={user_info.as_ref().map(|ui| ui.calendars.clone()).unwrap_or_default()} |                     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> |             </div> | ||||||
|         </BrowserRouter> |         </BrowserRouter> | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ use crate::components::{ | |||||||
|     CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView, |     CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView, | ||||||
| }; | }; | ||||||
| use crate::models::ical::VEvent; | 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 chrono::{Datelike, Duration, Local, NaiveDate}; | ||||||
| use gloo_storage::{LocalStorage, Storage}; | use gloo_storage::{LocalStorage, Storage}; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| @@ -14,6 +14,10 @@ pub struct CalendarProps { | |||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub user_info: Option<UserInfo>, |     pub user_info: Option<UserInfo>, | ||||||
|     #[prop_or_default] |     #[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)>>, |     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>, |     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 loading = loading.clone(); | ||||||
|         let error = error.clone(); |         let error = error.clone(); | ||||||
|         let current_date = current_date.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 auth_token: Option<String> = LocalStorage::get("auth_token").ok(); | ||||||
|             let date = *date; // Clone the date to avoid lifetime issues |             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 { |             if let Some(token) = auth_token { | ||||||
|                 let events = events.clone(); |                 let events = events.clone(); | ||||||
| @@ -141,7 +149,38 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                         .await |                         .await | ||||||
|                     { |                     { | ||||||
|                         Ok(vevents) => { |                         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); |                             events.set(grouped_events); | ||||||
|                             loading.set(false); |                             loading.set(false); | ||||||
|                         } |                         } | ||||||
| @@ -452,6 +491,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                                 on_event_click={on_event_click.clone()} |                                 on_event_click={on_event_click.clone()} | ||||||
|                                 refreshing_event_uid={(*refreshing_event_uid).clone()} |                                 refreshing_event_uid={(*refreshing_event_uid).clone()} | ||||||
|                                 user_info={props.user_info.clone()} |                                 user_info={props.user_info.clone()} | ||||||
|  |                                 external_calendars={props.external_calendars.clone()} | ||||||
|                                 on_event_context_menu={props.on_event_context_menu.clone()} |                                 on_event_context_menu={props.on_event_context_menu.clone()} | ||||||
|                                 on_calendar_context_menu={props.on_calendar_context_menu.clone()} |                                 on_calendar_context_menu={props.on_calendar_context_menu.clone()} | ||||||
|                                 selected_date={Some(*selected_date)} |                                 selected_date={Some(*selected_date)} | ||||||
| @@ -467,6 +507,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                             on_event_click={on_event_click.clone()} |                             on_event_click={on_event_click.clone()} | ||||||
|                             refreshing_event_uid={(*refreshing_event_uid).clone()} |                             refreshing_event_uid={(*refreshing_event_uid).clone()} | ||||||
|                             user_info={props.user_info.clone()} |                             user_info={props.user_info.clone()} | ||||||
|  |                             external_calendars={props.external_calendars.clone()} | ||||||
|                             on_event_context_menu={props.on_event_context_menu.clone()} |                             on_event_context_menu={props.on_event_context_menu.clone()} | ||||||
|                             on_calendar_context_menu={props.on_calendar_context_menu.clone()} |                             on_calendar_context_menu={props.on_calendar_context_menu.clone()} | ||||||
|                             on_create_event={Some(on_create_event)} |                             on_create_event={Some(on_create_event)} | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ pub struct CalendarListItemProps { | |||||||
|     pub on_color_picker_toggle: Callback<String>,    // calendar_path |     pub on_color_picker_toggle: Callback<String>,    // calendar_path | ||||||
|     pub available_colors: Vec<String>, |     pub available_colors: Vec<String>, | ||||||
|     pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path) |     pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path) | ||||||
|  |     pub on_visibility_toggle: Callback<String>,      // calendar_path | ||||||
| } | } | ||||||
|  |  | ||||||
| #[function_component(CalendarListItem)] | #[function_component(CalendarListItem)] | ||||||
| @@ -32,44 +33,59 @@ 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! { |     html! { | ||||||
|         <li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}> |         <li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}> | ||||||
|             <span class="calendar-color" |             <div class="calendar-info"> | ||||||
|                   style={format!("background-color: {}", props.calendar.color)} |                 <input | ||||||
|                   onclick={on_color_click}> |                     type="checkbox" | ||||||
|                 { |                     checked={props.calendar.is_visible} | ||||||
|                     if props.color_picker_open { |                     onchange={on_visibility_toggle} | ||||||
|                         html! { |                 /> | ||||||
|                             <div class="color-picker"> |                 <span class="calendar-color" | ||||||
|                                 { |                       style={format!("background-color: {}", props.calendar.color)} | ||||||
|                                     props.available_colors.iter().map(|color| { |                       onclick={on_color_click}> | ||||||
|                                         let color_str = color.clone(); |                     { | ||||||
|                                         let cal_path = props.calendar.path.clone(); |                         if props.color_picker_open { | ||||||
|                                         let on_color_change = props.on_color_change.clone(); |                             html! { | ||||||
|  |                                 <div class="color-picker"> | ||||||
|  |                                     { | ||||||
|  |                                         props.available_colors.iter().map(|color| { | ||||||
|  |                                             let color_str = color.clone(); | ||||||
|  |                                             let cal_path = props.calendar.path.clone(); | ||||||
|  |                                             let on_color_change = props.on_color_change.clone(); | ||||||
|  |  | ||||||
|                                         let on_color_select = Callback::from(move |_: MouseEvent| { |                                             let on_color_select = Callback::from(move |_: MouseEvent| { | ||||||
|                                             on_color_change.emit((cal_path.clone(), color_str.clone())); |                                                 on_color_change.emit((cal_path.clone(), color_str.clone())); | ||||||
|                                         }); |                                             }); | ||||||
|  |  | ||||||
|                                         let is_selected = props.calendar.color == *color; |                                             let is_selected = props.calendar.color == *color; | ||||||
|                                         let class_name = if is_selected { "color-option selected" } else { "color-option" }; |                                             let class_name = if is_selected { "color-option selected" } else { "color-option" }; | ||||||
|  |  | ||||||
|                                         html! { |                                             html! { | ||||||
|                                             <div class={class_name} |                                                 <div class={class_name} | ||||||
|                                                  style={format!("background-color: {}", color)} |                                                      style={format!("background-color: {}", color)} | ||||||
|                                                  onclick={on_color_select}> |                                                      onclick={on_color_select}> | ||||||
|                                             </div> |                                                 </div> | ||||||
|                                         } |                                             } | ||||||
|                                     }).collect::<Html>() |                                         }).collect::<Html>() | ||||||
|                                 } |                                     } | ||||||
|                             </div> |                                 </div> | ||||||
|  |                             } | ||||||
|  |                         } else { | ||||||
|  |                             html! {} | ||||||
|                         } |                         } | ||||||
|                     } else { |  | ||||||
|                         html! {} |  | ||||||
|                     } |                     } | ||||||
|                 } |                 </span> | ||||||
|             </span> |                 <span class="calendar-name">{&props.calendar.display_name}</span> | ||||||
|             <span class="calendar-name">{&props.calendar.display_name}</span> |             </div> | ||||||
|         </li> |         </li> | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -290,16 +290,32 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend | |||||||
|         // Reminders - TODO: Parse alarm from VEvent if needed |         // Reminders - TODO: Parse alarm from VEvent if needed | ||||||
|         reminder: ReminderType::None, |         reminder: ReminderType::None, | ||||||
|          |          | ||||||
|         // Recurrence - TODO: Parse RRULE if needed for advanced editing |         // Recurrence - Parse RRULE if present | ||||||
|         recurrence: if event.rrule.is_some() { |         recurrence: if let Some(ref rrule_str) = event.rrule { | ||||||
|             RecurrenceType::Daily // Default, could be enhanced to parse actual RRULE |             parse_rrule_frequency(rrule_str) | ||||||
|         } else { |         } else { | ||||||
|             RecurrenceType::None |             RecurrenceType::None | ||||||
|         }, |         }, | ||||||
|         recurrence_interval: 1, |         recurrence_interval: if let Some(ref rrule_str) = event.rrule { | ||||||
|         recurrence_until: None, |             parse_rrule_interval(rrule_str) | ||||||
|         recurrence_count: None, |         } else { | ||||||
|         recurrence_days: vec![false; 7], |             1 | ||||||
|  |         }, | ||||||
|  |         recurrence_until: if let Some(ref rrule_str) = event.rrule { | ||||||
|  |             parse_rrule_until(rrule_str) | ||||||
|  |         } else { | ||||||
|  |             None | ||||||
|  |         }, | ||||||
|  |         recurrence_count: if let Some(ref rrule_str) = event.rrule { | ||||||
|  |             parse_rrule_count(rrule_str) | ||||||
|  |         } else { | ||||||
|  |             None | ||||||
|  |         }, | ||||||
|  |         recurrence_days: if let Some(ref rrule_str) = event.rrule { | ||||||
|  |             parse_rrule_days(rrule_str) | ||||||
|  |         } else { | ||||||
|  |             vec![false; 7] | ||||||
|  |         }, | ||||||
|          |          | ||||||
|         // Advanced recurrence |         // Advanced recurrence | ||||||
|         monthly_by_day: None, |         monthly_by_day: None, | ||||||
| @@ -328,3 +344,112 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend | |||||||
|         occurrence_date: Some(start_local.date()), // The occurrence date being edited |         occurrence_date: Some(start_local.date()), // The occurrence date being edited | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // Parse RRULE frequency component | ||||||
|  | fn parse_rrule_frequency(rrule: &str) -> RecurrenceType { | ||||||
|  |     if rrule.contains("FREQ=DAILY") { | ||||||
|  |         RecurrenceType::Daily | ||||||
|  |     } else if rrule.contains("FREQ=WEEKLY") { | ||||||
|  |         RecurrenceType::Weekly | ||||||
|  |     } else if rrule.contains("FREQ=MONTHLY") { | ||||||
|  |         RecurrenceType::Monthly | ||||||
|  |     } else if rrule.contains("FREQ=YEARLY") { | ||||||
|  |         RecurrenceType::Yearly | ||||||
|  |     } else { | ||||||
|  |         RecurrenceType::None | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Parse RRULE interval component | ||||||
|  | fn parse_rrule_interval(rrule: &str) -> u32 { | ||||||
|  |     if let Some(start) = rrule.find("INTERVAL=") { | ||||||
|  |         let interval_part = &rrule[start + 9..]; | ||||||
|  |         if let Some(end) = interval_part.find(';') { | ||||||
|  |             interval_part[..end].parse().unwrap_or(1) | ||||||
|  |         } else { | ||||||
|  |             interval_part.parse().unwrap_or(1) | ||||||
|  |         } | ||||||
|  |     } else { | ||||||
|  |         1 | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Parse RRULE count component | ||||||
|  | fn parse_rrule_count(rrule: &str) -> Option<u32> { | ||||||
|  |     if let Some(start) = rrule.find("COUNT=") { | ||||||
|  |         let count_part = &rrule[start + 6..]; | ||||||
|  |         if let Some(end) = count_part.find(';') { | ||||||
|  |             count_part[..end].parse().ok() | ||||||
|  |         } else { | ||||||
|  |             count_part.parse().ok() | ||||||
|  |         } | ||||||
|  |     } else { | ||||||
|  |         None | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Parse RRULE until component | ||||||
|  | fn parse_rrule_until(rrule: &str) -> Option<chrono::NaiveDate> { | ||||||
|  |     if let Some(start) = rrule.find("UNTIL=") { | ||||||
|  |         let until_part = &rrule[start + 6..]; | ||||||
|  |         let until_str = if let Some(end) = until_part.find(';') { | ||||||
|  |             &until_part[..end] | ||||||
|  |         } else { | ||||||
|  |             until_part | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         // UNTIL can be in format YYYYMMDD or YYYYMMDDTHHMMSSZ | ||||||
|  |         let date_part = if until_str.len() >= 8 { | ||||||
|  |             &until_str[..8] | ||||||
|  |         } else { | ||||||
|  |             until_str | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         // Parse YYYYMMDD format | ||||||
|  |         if date_part.len() == 8 { | ||||||
|  |             if let (Ok(year), Ok(month), Ok(day)) = ( | ||||||
|  |                 date_part[0..4].parse::<i32>(), | ||||||
|  |                 date_part[4..6].parse::<u32>(), | ||||||
|  |                 date_part[6..8].parse::<u32>(), | ||||||
|  |             ) { | ||||||
|  |                 chrono::NaiveDate::from_ymd_opt(year, month, day) | ||||||
|  |             } else { | ||||||
|  |                 None | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             None | ||||||
|  |         } | ||||||
|  |     } else { | ||||||
|  |         None | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Parse RRULE BYDAY component for weekly recurrence | ||||||
|  | fn parse_rrule_days(rrule: &str) -> Vec<bool> { | ||||||
|  |     let mut days = vec![false; 7]; // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] | ||||||
|  |      | ||||||
|  |     if let Some(start) = rrule.find("BYDAY=") { | ||||||
|  |         let byday_part = &rrule[start + 6..]; | ||||||
|  |         let byday_str = if let Some(end) = byday_part.find(';') { | ||||||
|  |             &byday_part[..end] | ||||||
|  |         } else { | ||||||
|  |             byday_part | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         // Parse comma-separated day codes: SU,MO,TU,WE,TH,FR,SA | ||||||
|  |         for day_code in byday_str.split(',') { | ||||||
|  |             match day_code.trim() { | ||||||
|  |                 "SU" => days[0] = true, // Sunday | ||||||
|  |                 "MO" => days[1] = true, // Monday | ||||||
|  |                 "TU" => days[2] = true, // Tuesday | ||||||
|  |                 "WE" => days[3] = true, // Wednesday | ||||||
|  |                 "TH" => days[4] = true, // Thursday | ||||||
|  |                 "FR" => days[5] = true, // Friday | ||||||
|  |                 "SA" => days[6] = true, // Saturday | ||||||
|  |                 _ => {} // Ignore unknown day codes | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     days | ||||||
|  | } | ||||||
							
								
								
									
										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> | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -8,6 +8,7 @@ pub mod create_event_modal; | |||||||
| pub mod event_context_menu; | pub mod event_context_menu; | ||||||
| pub mod event_form; | pub mod event_form; | ||||||
| pub mod event_modal; | pub mod event_modal; | ||||||
|  | pub mod external_calendar_modal; | ||||||
| pub mod login; | pub mod login; | ||||||
| pub mod month_view; | pub mod month_view; | ||||||
| pub mod recurring_edit_modal; | pub mod recurring_edit_modal; | ||||||
| @@ -26,6 +27,7 @@ pub use create_event_modal::CreateEventModal; | |||||||
| pub use event_form::EventCreationData; | pub use event_form::EventCreationData; | ||||||
| pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu}; | pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu}; | ||||||
| pub use event_modal::EventModal; | pub use event_modal::EventModal; | ||||||
|  | pub use external_calendar_modal::ExternalCalendarModal; | ||||||
| pub use login::Login; | pub use login::Login; | ||||||
| pub use month_view::MonthView; | pub use month_view::MonthView; | ||||||
| pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal}; | pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal}; | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| use crate::models::ical::VEvent; | use crate::models::ical::VEvent; | ||||||
| use crate::services::calendar_service::UserInfo; | use crate::services::calendar_service::{UserInfo, ExternalCalendar}; | ||||||
| use chrono::{Datelike, NaiveDate, Weekday}; | use chrono::{Datelike, NaiveDate, Weekday}; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| use wasm_bindgen::{prelude::*, JsCast}; | use wasm_bindgen::{prelude::*, JsCast}; | ||||||
| @@ -17,6 +17,8 @@ pub struct MonthViewProps { | |||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub user_info: Option<UserInfo>, |     pub user_info: Option<UserInfo>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|  |     pub external_calendars: Vec<ExternalCalendar>, | ||||||
|  |     #[prop_or_default] | ||||||
|     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, |     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>, |     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 |     // Helper function to get calendar color for an event | ||||||
|     let get_event_color = |event: &VEvent| -> String { |     let get_event_color = |event: &VEvent| -> String { | ||||||
|         if let Some(user_info) = &props.user_info { |         if let Some(calendar_path) = &event.calendar_path { | ||||||
|             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 |                 if let Some(calendar) = user_info | ||||||
|                     .calendars |                     .calendars | ||||||
|                     .iter() |                     .iter() | ||||||
| @@ -194,6 +208,7 @@ pub fn month_view(props: &MonthViewProps) -> Html { | |||||||
|                                             <div |                                             <div | ||||||
|                                                 class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })} |                                                 class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })} | ||||||
|                                                 style={format!("background-color: {}", event_color)} |                                                 style={format!("background-color: {}", event_color)} | ||||||
|  |                                                 data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()} | ||||||
|                                                 {onclick} |                                                 {onclick} | ||||||
|                                                 {oncontextmenu} |                                                 {oncontextmenu} | ||||||
|                                             > |                                             > | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| use crate::components::{Login, ViewMode}; | use crate::components::{Login, ViewMode}; | ||||||
| use crate::models::ical::VEvent; | use crate::models::ical::VEvent; | ||||||
| use crate::services::calendar_service::UserInfo; | use crate::services::calendar_service::{UserInfo, ExternalCalendar}; | ||||||
| use yew::prelude::*; | use yew::prelude::*; | ||||||
| use yew_router::prelude::*; | use yew_router::prelude::*; | ||||||
|  |  | ||||||
| @@ -20,6 +20,10 @@ pub struct RouteHandlerProps { | |||||||
|     pub user_info: Option<UserInfo>, |     pub user_info: Option<UserInfo>, | ||||||
|     pub on_login: Callback<String>, |     pub on_login: Callback<String>, | ||||||
|     #[prop_or_default] |     #[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)>>, |     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>, |     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 auth_token = props.auth_token.clone(); | ||||||
|     let user_info = props.user_info.clone(); |     let user_info = props.user_info.clone(); | ||||||
|     let on_login = props.on_login.clone(); |     let on_login = props.on_login.clone(); | ||||||
|  |     let 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_event_context_menu = props.on_event_context_menu.clone(); | ||||||
|     let on_calendar_context_menu = props.on_calendar_context_menu.clone(); |     let on_calendar_context_menu = props.on_calendar_context_menu.clone(); | ||||||
|     let view = props.view.clone(); |     let view = props.view.clone(); | ||||||
| @@ -60,6 +66,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | |||||||
|             let auth_token = auth_token.clone(); |             let auth_token = auth_token.clone(); | ||||||
|             let user_info = user_info.clone(); |             let user_info = user_info.clone(); | ||||||
|             let on_login = on_login.clone(); |             let on_login = on_login.clone(); | ||||||
|  |             let 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_event_context_menu = on_event_context_menu.clone(); | ||||||
|             let on_calendar_context_menu = on_calendar_context_menu.clone(); |             let on_calendar_context_menu = on_calendar_context_menu.clone(); | ||||||
|             let view = view.clone(); |             let view = view.clone(); | ||||||
| @@ -87,6 +95,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | |||||||
|                         html! { |                         html! { | ||||||
|                             <CalendarView |                             <CalendarView | ||||||
|                                 user_info={user_info} |                                 user_info={user_info} | ||||||
|  |                                 external_calendar_events={external_calendar_events} | ||||||
|  |                                 external_calendars={external_calendars} | ||||||
|                                 on_event_context_menu={on_event_context_menu} |                                 on_event_context_menu={on_event_context_menu} | ||||||
|                                 on_calendar_context_menu={on_calendar_context_menu} |                                 on_calendar_context_menu={on_calendar_context_menu} | ||||||
|                                 view={view} |                                 view={view} | ||||||
| @@ -108,6 +118,10 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | |||||||
| pub struct CalendarViewProps { | pub struct CalendarViewProps { | ||||||
|     pub user_info: Option<UserInfo>, |     pub user_info: Option<UserInfo>, | ||||||
|     #[prop_or_default] |     #[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)>>, |     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>, |     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"> |         <div class="calendar-view"> | ||||||
|             <Calendar |             <Calendar | ||||||
|                 user_info={props.user_info.clone()} |                 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_event_context_menu={props.on_event_context_menu.clone()} | ||||||
|                 on_calendar_context_menu={props.on_calendar_context_menu.clone()} |                 on_calendar_context_menu={props.on_calendar_context_menu.clone()} | ||||||
|                 view={props.view.clone()} |                 view={props.view.clone()} | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| use crate::components::CalendarListItem; | use crate::components::CalendarListItem; | ||||||
| use crate::services::calendar_service::UserInfo; | use crate::services::calendar_service::{UserInfo, ExternalCalendar}; | ||||||
| use web_sys::HtmlSelectElement; | use web_sys::HtmlSelectElement; | ||||||
| use yew::prelude::*; | use yew::prelude::*; | ||||||
| use yew_router::prelude::*; | use yew_router::prelude::*; | ||||||
| @@ -101,11 +101,17 @@ pub struct SidebarProps { | |||||||
|     pub user_info: Option<UserInfo>, |     pub user_info: Option<UserInfo>, | ||||||
|     pub on_logout: Callback<()>, |     pub on_logout: Callback<()>, | ||||||
|     pub on_create_calendar: 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 color_picker_open: Option<String>, | ||||||
|     pub on_color_change: Callback<(String, String)>, |     pub on_color_change: Callback<(String, String)>, | ||||||
|     pub on_color_picker_toggle: Callback<String>, |     pub on_color_picker_toggle: Callback<String>, | ||||||
|     pub available_colors: Vec<String>, |     pub available_colors: Vec<String>, | ||||||
|     pub on_calendar_context_menu: Callback<(MouseEvent, String)>, |     pub on_calendar_context_menu: Callback<(MouseEvent, String)>, | ||||||
|  |     pub on_calendar_visibility_toggle: Callback<String>, | ||||||
|     pub current_view: ViewMode, |     pub current_view: ViewMode, | ||||||
|     pub on_view_change: Callback<ViewMode>, |     pub on_view_change: Callback<ViewMode>, | ||||||
|     pub current_theme: Theme, |     pub current_theme: Theme, | ||||||
| @@ -116,6 +122,7 @@ pub struct SidebarProps { | |||||||
|  |  | ||||||
| #[function_component(Sidebar)] | #[function_component(Sidebar)] | ||||||
| pub fn sidebar(props: &SidebarProps) -> Html { | pub fn sidebar(props: &SidebarProps) -> Html { | ||||||
|  |     let external_context_menu_open = use_state(|| None::<i32>); | ||||||
|     let on_view_change = { |     let on_view_change = { | ||||||
|         let on_view_change = props.on_view_change.clone(); |         let on_view_change = props.on_view_change.clone(); | ||||||
|         Callback::from(move |e: Event| { |         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! { |     html! { | ||||||
|         <aside class="app-sidebar"> |         <aside class="app-sidebar"> | ||||||
|             <div class="sidebar-header"> |             <div class="sidebar-header"> | ||||||
| @@ -192,6 +223,7 @@ pub fn sidebar(props: &SidebarProps) -> Html { | |||||||
|                                                     on_color_picker_toggle={props.on_color_picker_toggle.clone()} |                                                     on_color_picker_toggle={props.on_color_picker_toggle.clone()} | ||||||
|                                                     available_colors={props.available_colors.clone()} |                                                     available_colors={props.available_colors.clone()} | ||||||
|                                                     on_context_menu={props.on_calendar_context_menu.clone()} |                                                     on_context_menu={props.on_calendar_context_menu.clone()} | ||||||
|  |                                                     on_visibility_toggle={props.on_calendar_visibility_toggle.clone()} | ||||||
|                                                 /> |                                                 /> | ||||||
|                                             } |                                             } | ||||||
|                                         }).collect::<Html>() |                                         }).collect::<Html>() | ||||||
| @@ -206,11 +238,128 @@ pub fn sidebar(props: &SidebarProps) -> Html { | |||||||
|                     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"> |             <div class="sidebar-footer"> | ||||||
|                 <button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button"> |                 <button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button"> | ||||||
|                     {"+ Create Calendar"} |                     {"+ Create Calendar"} | ||||||
|                 </button> |                 </button> | ||||||
|                  |                  | ||||||
|  |                 <button onclick={props.on_create_external_calendar.reform(|_| ())} class="create-external-calendar-button"> | ||||||
|  |                     {"+ Add External Calendar"} | ||||||
|  |                 </button> | ||||||
|  |  | ||||||
|                 <div class="view-selector"> |                 <div class="view-selector"> | ||||||
|                     <select class="view-selector-dropdown" onchange={on_view_change}> |                     <select class="view-selector-dropdown" onchange={on_view_change}> | ||||||
|                         <option value="month" selected={matches!(props.current_view, ViewMode::Month)}>{"Month"}</option> |                         <option value="month" selected={matches!(props.current_view, ViewMode::Month)}>{"Month"}</option> | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| use crate::components::{EventCreationData, RecurringEditAction, RecurringEditModal}; | use crate::components::{EventCreationData, RecurringEditAction, RecurringEditModal}; | ||||||
| use crate::models::ical::VEvent; | 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 chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Weekday}; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| use web_sys::MouseEvent; | use web_sys::MouseEvent; | ||||||
| @@ -17,6 +17,8 @@ pub struct WeekViewProps { | |||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub user_info: Option<UserInfo>, |     pub user_info: Option<UserInfo>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|  |     pub external_calendars: Vec<ExternalCalendar>, | ||||||
|  |     #[prop_or_default] | ||||||
|     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, |     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>, |     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 |     // Helper function to get calendar color for an event | ||||||
|     let get_event_color = |event: &VEvent| -> String { |     let get_event_color = |event: &VEvent| -> String { | ||||||
|         if let Some(user_info) = &props.user_info { |         if let Some(calendar_path) = &event.calendar_path { | ||||||
|             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 |                 if let Some(calendar) = user_info | ||||||
|                     .calendars |                     .calendars | ||||||
|                     .iter() |                     .iter() | ||||||
| @@ -371,6 +385,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | |||||||
|                                                         <div  |                                                         <div  | ||||||
|                                                             class="all-day-event" |                                                             class="all-day-event" | ||||||
|                                                             style={format!("background-color: {}", event_color)} |                                                             style={format!("background-color: {}", event_color)} | ||||||
|  |                                                             data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()} | ||||||
|                                                             {onclick} |                                                             {onclick} | ||||||
|                                                             {oncontextmenu} |                                                             {oncontextmenu} | ||||||
|                                                         > |                                                         > | ||||||
| @@ -905,6 +920,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | |||||||
|                                                                         column_width |                                                                         column_width | ||||||
|                                                                     ) |                                                                     ) | ||||||
|                                                                 } |                                                                 } | ||||||
|  |                                                                 data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()} | ||||||
|                                                                 {onclick} |                                                                 {onclick} | ||||||
|                                                                 {oncontextmenu} |                                                                 {oncontextmenu} | ||||||
|                                                                 onmousedown={onmousedown_event} |                                                                 onmousedown={onmousedown_event} | ||||||
| @@ -992,6 +1008,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | |||||||
|                                                                 <div |                                                                 <div | ||||||
|                                                                     class="temp-event-box moving-event" |                                                                     class="temp-event-box moving-event" | ||||||
|                                                                     style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", preview_position, duration_pixels, event_color)} |                                                                     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-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div> | ||||||
|                                                                     {if duration_pixels > 30.0 { |                                                                     {if duration_pixels > 30.0 { | ||||||
| @@ -1025,6 +1042,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | |||||||
|                                                                 <div |                                                                 <div | ||||||
|                                                                     class="temp-event-box resizing-event" |                                                                     class="temp-event-box resizing-event" | ||||||
|                                                                     style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", new_start_pixels, new_height, event_color)} |                                                                     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-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div> | ||||||
|                                                                     {if new_height > 30.0 { |                                                                     {if new_height > 30.0 { | ||||||
| @@ -1052,6 +1070,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | |||||||
|                                                                 <div |                                                                 <div | ||||||
|                                                                     class="temp-event-box resizing-event" |                                                                     class="temp-event-box resizing-event" | ||||||
|                                                                     style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", original_start_pixels, new_height, event_color)} |                                                                     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-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div> | ||||||
|                                                                     {if new_height > 30.0 { |                                                                     {if new_height > 30.0 { | ||||||
| @@ -1320,11 +1339,23 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3 | |||||||
|  |  | ||||||
| // Check if an all-day event spans the given date | // Check if an all-day event spans the given date | ||||||
| fn event_spans_date(event: &VEvent, date: NaiveDate) -> bool { | fn event_spans_date(event: &VEvent, date: NaiveDate) -> bool { | ||||||
|     let start_date = event.dtstart.with_timezone(&Local).date_naive(); |     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 { |     let end_date = if let Some(dtend) = event.dtend { | ||||||
|         // For all-day events, dtend is often set to the day after the last day |         if event.all_day { | ||||||
|         // So we need to subtract a day to get the actual last day of the event |             // For all-day events, dtend is set to the day after the last day (RFC 5545) | ||||||
|         dtend.with_timezone(&Local).date_naive() - chrono::Duration::days(1) |             // 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 { |     } else { | ||||||
|         // Single day event |         // Single day event | ||||||
|         start_date |         start_date | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday}; | use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday}; | ||||||
|  | use gloo_storage::{LocalStorage, Storage}; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| use wasm_bindgen::JsCast; | use wasm_bindgen::JsCast; | ||||||
| @@ -43,6 +44,7 @@ pub struct CalendarInfo { | |||||||
|     pub path: String, |     pub path: String, | ||||||
|     pub display_name: String, |     pub display_name: String, | ||||||
|     pub color: String, |     pub color: String, | ||||||
|  |     pub is_visible: bool, | ||||||
| } | } | ||||||
|  |  | ||||||
| // CalendarEvent, EventStatus, and EventClass are now imported from shared library | // CalendarEvent, EventStatus, and EventClass are now imported from shared library | ||||||
| @@ -1678,6 +1680,9 @@ impl CalendarService { | |||||||
|         categories: String, |         categories: String, | ||||||
|         reminder: String, |         reminder: String, | ||||||
|         recurrence: String, |         recurrence: String, | ||||||
|  |         recurrence_days: Vec<bool>, | ||||||
|  |         recurrence_count: Option<u32>, | ||||||
|  |         recurrence_until: Option<String>, | ||||||
|         calendar_path: Option<String>, |         calendar_path: Option<String>, | ||||||
|         update_scope: String, |         update_scope: String, | ||||||
|         occurrence_date: Option<String>, |         occurrence_date: Option<String>, | ||||||
| @@ -1706,10 +1711,10 @@ impl CalendarService { | |||||||
|             "categories": categories, |             "categories": categories, | ||||||
|             "reminder": reminder, |             "reminder": reminder, | ||||||
|             "recurrence": recurrence, |             "recurrence": recurrence, | ||||||
|             "recurrence_days": vec![false; 7], // Default - could be enhanced |             "recurrence_days": recurrence_days, | ||||||
|             "recurrence_interval": 1_u32, // Default interval |             "recurrence_interval": 1_u32, // Default interval - could be enhanced to be a parameter | ||||||
|             "recurrence_end_date": None as Option<String>, // No end date by default |             "recurrence_end_date": recurrence_until, | ||||||
|             "recurrence_count": None as Option<u32>, // No count limit by default |             "recurrence_count": recurrence_count, | ||||||
|             "calendar_path": calendar_path, |             "calendar_path": calendar_path, | ||||||
|             "update_scope": update_scope, |             "update_scope": update_scope, | ||||||
|             "occurrence_date": occurrence_date |             "occurrence_date": occurrence_date | ||||||
| @@ -1851,4 +1856,257 @@ impl CalendarService { | |||||||
|  |  | ||||||
|         None |         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>>, | ||||||
| } | } | ||||||
|   | |||||||
| @@ -3745,3 +3745,383 @@ body { | |||||||
|         grid-template-columns: repeat(2, 1fr); |         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; | ||||||
|  |     } | ||||||
|  | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user