Compare commits
	
		
			2 Commits
		
	
	
		
			f9c87369e5
			...
			b444ae710d
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | b444ae710d | ||
|   | c454104c69 | 
| @@ -658,6 +658,42 @@ impl CalDAVClient { | |||||||
|             Err(CalDAVError::ServerError(status.as_u16())) |             Err(CalDAVError::ServerError(status.as_u16())) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /// Delete a calendar from the CalDAV server | ||||||
|  |     pub async fn delete_calendar(&self, calendar_path: &str) -> Result<(), CalDAVError> { | ||||||
|  |         let full_url = if calendar_path.starts_with("http") { | ||||||
|  |             calendar_path.to_string() | ||||||
|  |         } else { | ||||||
|  |             // Handle case where calendar_path already contains /dav.php | ||||||
|  |             let clean_path = if calendar_path.starts_with("/dav.php") { | ||||||
|  |                 calendar_path.trim_start_matches("/dav.php") | ||||||
|  |             } else { | ||||||
|  |                 calendar_path | ||||||
|  |             }; | ||||||
|  |             format!("{}{}", self.config.server_url.trim_end_matches('/'), clean_path) | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         println!("Deleting calendar at: {}", full_url); | ||||||
|  |          | ||||||
|  |         let response = self.http_client | ||||||
|  |             .delete(&full_url) | ||||||
|  |             .header("Authorization", format!("Basic {}", self.config.get_basic_auth())) | ||||||
|  |             .send() | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| CalDAVError::ParseError(e.to_string()))?; | ||||||
|  |  | ||||||
|  |         println!("Calendar deletion response status: {}", response.status()); | ||||||
|  |          | ||||||
|  |         if response.status().is_success() || response.status().as_u16() == 204 { | ||||||
|  |             println!("✅ Calendar deleted successfully at {}", calendar_path); | ||||||
|  |             Ok(()) | ||||||
|  |         } else { | ||||||
|  |             let status = response.status(); | ||||||
|  |             let error_body = response.text().await.unwrap_or_default(); | ||||||
|  |             println!("❌ Calendar deletion failed: {} - {}", status, error_body); | ||||||
|  |             Err(CalDAVError::ServerError(status.as_u16())) | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| /// Helper struct for extracting calendar data from XML responses | /// Helper struct for extracting calendar data from XML responses | ||||||
|   | |||||||
| @@ -7,7 +7,7 @@ use serde::Deserialize; | |||||||
| use std::sync::Arc; | use std::sync::Arc; | ||||||
| use chrono::Datelike; | use chrono::Datelike; | ||||||
|  |  | ||||||
| use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo, CreateCalendarRequest, CreateCalendarResponse}}; | use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse}}; | ||||||
| use crate::calendar::{CalDAVClient, CalendarEvent}; | use crate::calendar::{CalDAVClient, CalendarEvent}; | ||||||
|  |  | ||||||
| #[derive(Deserialize)] | #[derive(Deserialize)] | ||||||
| @@ -293,3 +293,34 @@ pub async fn create_calendar( | |||||||
|         message: "Calendar created successfully".to_string(), |         message: "Calendar created successfully".to_string(), | ||||||
|     })) |     })) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | pub async fn delete_calendar( | ||||||
|  |     State(state): State<Arc<AppState>>, | ||||||
|  |     headers: HeaderMap, | ||||||
|  |     Json(request): Json<DeleteCalendarRequest>, | ||||||
|  | ) -> Result<Json<DeleteCalendarResponse>, ApiError> { | ||||||
|  |     println!("🗑️ Delete calendar request received: path='{}'", request.path); | ||||||
|  |      | ||||||
|  |     // Extract and verify token | ||||||
|  |     let token = extract_bearer_token(&headers)?; | ||||||
|  |     let password = extract_password_header(&headers)?; | ||||||
|  |  | ||||||
|  |     // Validate request | ||||||
|  |     if request.path.trim().is_empty() { | ||||||
|  |         return Err(ApiError::BadRequest("Calendar path is required".to_string())); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Create CalDAV config from token and password | ||||||
|  |     let config = state.auth_service.caldav_config_from_token(&token, &password)?; | ||||||
|  |     let client = CalDAVClient::new(config); | ||||||
|  |  | ||||||
|  |     // Delete the calendar | ||||||
|  |     client.delete_calendar(&request.path) | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| ApiError::Internal(format!("Failed to delete calendar: {}", e)))?; | ||||||
|  |  | ||||||
|  |     Ok(Json(DeleteCalendarResponse { | ||||||
|  |         success: true, | ||||||
|  |         message: "Calendar deleted successfully".to_string(), | ||||||
|  |     })) | ||||||
|  | } | ||||||
| @@ -39,6 +39,7 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> { | |||||||
|         .route("/api/auth/verify", get(handlers::verify_token)) |         .route("/api/auth/verify", get(handlers::verify_token)) | ||||||
|         .route("/api/user/info", get(handlers::get_user_info)) |         .route("/api/user/info", get(handlers::get_user_info)) | ||||||
|         .route("/api/calendar/create", post(handlers::create_calendar)) |         .route("/api/calendar/create", post(handlers::create_calendar)) | ||||||
|  |         .route("/api/calendar/delete", post(handlers::delete_calendar)) | ||||||
|         .route("/api/calendar/events", get(handlers::get_calendar_events)) |         .route("/api/calendar/events", get(handlers::get_calendar_events)) | ||||||
|         .route("/api/calendar/events/:uid", get(handlers::refresh_event)) |         .route("/api/calendar/events/:uid", get(handlers::refresh_event)) | ||||||
|         .layer( |         .layer( | ||||||
|   | |||||||
| @@ -47,6 +47,17 @@ pub struct CreateCalendarResponse { | |||||||
|     pub message: String, |     pub message: String, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | pub struct DeleteCalendarRequest { | ||||||
|  |     pub path: String, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize)] | ||||||
|  | pub struct DeleteCalendarResponse { | ||||||
|  |     pub success: bool, | ||||||
|  |     pub message: String, | ||||||
|  | } | ||||||
|  |  | ||||||
| // Error handling | // Error handling | ||||||
| #[derive(Debug)] | #[derive(Debug)] | ||||||
| pub enum ApiError { | pub enum ApiError { | ||||||
|   | |||||||
							
								
								
									
										565
									
								
								src/app.rs
									
									
									
									
									
								
							
							
						
						
									
										565
									
								
								src/app.rs
									
									
									
									
									
								
							| @@ -1,20 +1,10 @@ | |||||||
| use yew::prelude::*; | use yew::prelude::*; | ||||||
| use yew_router::prelude::*; | use yew_router::prelude::*; | ||||||
| use gloo_storage::{LocalStorage, Storage}; | use gloo_storage::{LocalStorage, Storage}; | ||||||
| use crate::components::{Login, Calendar, CreateCalendarModal}; | use web_sys::MouseEvent; | ||||||
| use crate::services::{CalendarService, CalendarEvent, UserInfo}; | use crate::components::{Sidebar, CreateCalendarModal, ContextMenu, RouteHandler}; | ||||||
| use std::collections::HashMap; | use crate::services::{CalendarService, calendar_service::UserInfo}; | ||||||
| use chrono::{Local, NaiveDate, Datelike}; |  | ||||||
|  |  | ||||||
| #[derive(Clone, Routable, PartialEq)] |  | ||||||
| enum Route { |  | ||||||
|     #[at("/")] |  | ||||||
|     Home, |  | ||||||
|     #[at("/login")] |  | ||||||
|     Login, |  | ||||||
|     #[at("/calendar")] |  | ||||||
|     Calendar, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[function_component] | #[function_component] | ||||||
| pub fn App() -> Html { | pub fn App() -> Html { | ||||||
| @@ -23,10 +13,12 @@ pub fn App() -> Html { | |||||||
|     }); |     }); | ||||||
|      |      | ||||||
|     let user_info = use_state(|| -> Option<UserInfo> { None }); |     let user_info = use_state(|| -> Option<UserInfo> { None }); | ||||||
|     let color_picker_open = use_state(|| -> Option<String> { None }); // Store calendar path of open picker |     let color_picker_open = use_state(|| -> Option<String> { None }); | ||||||
|     let create_modal_open = use_state(|| false); |     let create_modal_open = use_state(|| false); | ||||||
|  |     let context_menu_open = use_state(|| false); | ||||||
|  |     let context_menu_pos = use_state(|| (0i32, 0i32)); | ||||||
|  |     let context_menu_calendar_path = use_state(|| -> Option<String> { None }); | ||||||
|      |      | ||||||
|     // Available colors for calendar customization |  | ||||||
|     let available_colors = [ |     let available_colors = [ | ||||||
|         "#3B82F6", "#10B981", "#F59E0B", "#EF4444",  |         "#3B82F6", "#10B981", "#F59E0B", "#EF4444",  | ||||||
|         "#8B5CF6", "#06B6D4", "#84CC16", "#F97316", |         "#8B5CF6", "#06B6D4", "#84CC16", "#F97316", | ||||||
| @@ -64,7 +56,6 @@ pub fn App() -> Html { | |||||||
|                 wasm_bindgen_futures::spawn_local(async move { |                 wasm_bindgen_futures::spawn_local(async move { | ||||||
|                     let calendar_service = CalendarService::new(); |                     let calendar_service = CalendarService::new(); | ||||||
|                      |                      | ||||||
|                     // Get password from stored credentials |  | ||||||
|                     let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") { |                     let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") { | ||||||
|                         if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) { |                         if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) { | ||||||
|                             credentials["password"].as_str().unwrap_or("").to_string() |                             credentials["password"].as_str().unwrap_or("").to_string() | ||||||
| @@ -78,10 +69,8 @@ pub fn App() -> Html { | |||||||
|                     if !password.is_empty() { |                     if !password.is_empty() { | ||||||
|                         match calendar_service.fetch_user_info(&token, &password).await { |                         match calendar_service.fetch_user_info(&token, &password).await { | ||||||
|                             Ok(mut info) => { |                             Ok(mut info) => { | ||||||
|                                 // Load saved colors from local storage |  | ||||||
|                                 if let Ok(saved_colors_json) = LocalStorage::get::<String>("calendar_colors") { |                                 if let Ok(saved_colors_json) = LocalStorage::get::<String>("calendar_colors") { | ||||||
|                                     if let Ok(saved_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) { |                                     if let Ok(saved_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) { | ||||||
|                                         // Update colors with saved preferences |  | ||||||
|                                         for saved_cal in &saved_info.calendars { |                                         for saved_cal in &saved_info.calendars { | ||||||
|                                             for cal in &mut info.calendars { |                                             for cal in &mut info.calendars { | ||||||
|                                                 if cal.path == saved_cal.path { |                                                 if cal.path == saved_cal.path { | ||||||
| @@ -109,15 +98,99 @@ pub fn App() -> Html { | |||||||
|  |  | ||||||
|     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(); | ||||||
|         Callback::from(move |_: MouseEvent| { |         Callback::from(move |_: MouseEvent| { | ||||||
|             color_picker_open.set(None); |             color_picker_open.set(None); | ||||||
|  |             context_menu_open.set(false); | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_color_change = { | ||||||
|  |         let user_info = user_info.clone(); | ||||||
|  |         let color_picker_open = color_picker_open.clone(); | ||||||
|  |         Callback::from(move |(calendar_path, color): (String, String)| { | ||||||
|  |             if let Some(mut info) = (*user_info).clone() { | ||||||
|  |                 for calendar in &mut info.calendars { | ||||||
|  |                     if calendar.path == calendar_path { | ||||||
|  |                         calendar.color = color.clone(); | ||||||
|  |                         break; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 user_info.set(Some(info.clone())); | ||||||
|  |                  | ||||||
|  |                 if let Ok(json) = serde_json::to_string(&info) { | ||||||
|  |                     let _ = LocalStorage::set("calendar_colors", json); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             color_picker_open.set(None); | ||||||
|         }) |         }) | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Clone variables needed for the modal outside of the conditional blocks |     let on_color_picker_toggle = { | ||||||
|     let auth_token_for_modal = auth_token.clone(); |         let color_picker_open = color_picker_open.clone(); | ||||||
|     let user_info_for_modal = user_info.clone(); |         Callback::from(move |calendar_path: String| { | ||||||
|     let create_modal_open_for_modal = create_modal_open.clone(); |             if color_picker_open.as_ref() == Some(&calendar_path) { | ||||||
|  |                 color_picker_open.set(None); | ||||||
|  |             } else { | ||||||
|  |                 color_picker_open.set(Some(calendar_path)); | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_calendar_context_menu = { | ||||||
|  |         let context_menu_open = context_menu_open.clone(); | ||||||
|  |         let context_menu_pos = context_menu_pos.clone(); | ||||||
|  |         let context_menu_calendar_path = context_menu_calendar_path.clone(); | ||||||
|  |         Callback::from(move |(event, calendar_path): (MouseEvent, String)| { | ||||||
|  |             context_menu_open.set(true); | ||||||
|  |             context_menu_pos.set((event.client_x(), event.client_y())); | ||||||
|  |             context_menu_calendar_path.set(Some(calendar_path)); | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let refresh_calendars = { | ||||||
|  |         let auth_token = auth_token.clone(); | ||||||
|  |         let user_info = user_info.clone(); | ||||||
|  |         Callback::from(move |_| { | ||||||
|  |             if let Some(token) = (*auth_token).clone() { | ||||||
|  |                 let user_info = user_info.clone(); | ||||||
|  |                  | ||||||
|  |                 wasm_bindgen_futures::spawn_local(async move { | ||||||
|  |                     let calendar_service = CalendarService::new(); | ||||||
|  |                      | ||||||
|  |                     let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") { | ||||||
|  |                         if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) { | ||||||
|  |                             credentials["password"].as_str().unwrap_or("").to_string() | ||||||
|  |                         } else { | ||||||
|  |                             String::new() | ||||||
|  |                         } | ||||||
|  |                     } else { | ||||||
|  |                         String::new() | ||||||
|  |                     }; | ||||||
|  |                      | ||||||
|  |                     match calendar_service.fetch_user_info(&token, &password).await { | ||||||
|  |                         Ok(mut info) => { | ||||||
|  |                             if let Ok(saved_colors_json) = LocalStorage::get::<String>("calendar_colors") { | ||||||
|  |                                 if let Ok(saved_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) { | ||||||
|  |                                     for saved_cal in &saved_info.calendars { | ||||||
|  |                                         for cal in &mut info.calendars { | ||||||
|  |                                             if cal.path == saved_cal.path { | ||||||
|  |                                                 cal.color = saved_cal.color.clone(); | ||||||
|  |                                             } | ||||||
|  |                                         } | ||||||
|  |                                     } | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                             user_info.set(Some(info)); | ||||||
|  |                         } | ||||||
|  |                         Err(err) => { | ||||||
|  |                             web_sys::console::log_1(&format!("Failed to refresh calendars: {}", err).into()); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 }); | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|     html! { |     html! { | ||||||
|         <BrowserRouter> |         <BrowserRouter> | ||||||
| @@ -126,187 +199,36 @@ pub fn App() -> Html { | |||||||
|                     if auth_token.is_some() { |                     if auth_token.is_some() { | ||||||
|                         html! { |                         html! { | ||||||
|                             <> |                             <> | ||||||
|                                 <aside class="app-sidebar"> |                                 <Sidebar | ||||||
|                                     <div class="sidebar-header"> |                                     user_info={(*user_info).clone()} | ||||||
|                                         <h1>{"Calendar App"}</h1> |                                     on_logout={on_logout} | ||||||
|                                         { |                                     on_create_calendar={Callback::from({ | ||||||
|                                             if let Some(ref info) = *user_info { |                                         let create_modal_open = create_modal_open.clone(); | ||||||
|                                                 html! { |                                         move |_| create_modal_open.set(true) | ||||||
|                                                     <div class="user-info"> |                                     })} | ||||||
|                                                         <div class="username">{&info.username}</div> |                                     color_picker_open={(*color_picker_open).clone()} | ||||||
|                                                         <div class="server-url">{&info.server_url}</div> |                                     on_color_change={on_color_change} | ||||||
|                                                     </div> |                                     on_color_picker_toggle={on_color_picker_toggle} | ||||||
|                                                 } |                                     available_colors={available_colors.iter().map(|c| c.to_string()).collect::<Vec<_>>()} | ||||||
|                                             } else { |                                     on_calendar_context_menu={on_calendar_context_menu} | ||||||
|                                                 html! { <div class="user-info loading">{"Loading..."}</div> } |                                 /> | ||||||
|                                             } |  | ||||||
|                                         } |  | ||||||
|                                     </div> |  | ||||||
|                                     <nav class="sidebar-nav"> |  | ||||||
|                                         <Link<Route> to={Route::Calendar} classes="nav-link">{"Calendar"}</Link<Route>> |  | ||||||
|                                     </nav> |  | ||||||
|                                     { |  | ||||||
|                                         if let Some(ref info) = *user_info { |  | ||||||
|                                             if !info.calendars.is_empty() { |  | ||||||
|                                                 html! { |  | ||||||
|                                                     <div class="calendar-list"> |  | ||||||
|                                                         <h3>{"My Calendars"}</h3> |  | ||||||
|                                                         <ul> |  | ||||||
|                                                             { |  | ||||||
|                                                                 info.calendars.iter().map(|cal| { |  | ||||||
|                                                                     let _cal_clone = cal.clone(); |  | ||||||
|                                                                     let color_picker_open_clone = color_picker_open.clone(); |  | ||||||
|                                                                      |  | ||||||
|                                                                     let on_color_click = { |  | ||||||
|                                                                         let cal_path = cal.path.clone(); |  | ||||||
|                                                                         let color_picker_open = color_picker_open.clone(); |  | ||||||
|                                                                         Callback::from(move |e: MouseEvent| { |  | ||||||
|                                                                             e.stop_propagation(); |  | ||||||
|                                                                             color_picker_open.set(Some(cal_path.clone())); |  | ||||||
|                                                                         }) |  | ||||||
|                                                                     }; |  | ||||||
|                                                                      |  | ||||||
|                                                                     html! { |  | ||||||
|                                                                         <li key={cal.path.clone()}> |  | ||||||
|                                                                             <span class="calendar-color"  |  | ||||||
|                                                                                   style={format!("background-color: {}", cal.color)} |  | ||||||
|                                                                                   onclick={on_color_click}> |  | ||||||
|                                                                                 { |  | ||||||
|                                                                                     if color_picker_open_clone.as_ref() == Some(&cal.path) { |  | ||||||
|                                                                                         html! { |  | ||||||
|                                                                                             <div class="color-picker"> |  | ||||||
|                                                                                                 { |  | ||||||
|                                                                                                     available_colors.iter().map(|&color| { |  | ||||||
|                                                                                                         let color_str = color.to_string(); |  | ||||||
|                                                                                                         let cal_path = cal.path.clone(); |  | ||||||
|                                                                                                         let user_info_clone = user_info.clone(); |  | ||||||
|                                                                                                         let color_picker_open = color_picker_open.clone(); |  | ||||||
|                                                                                                          |  | ||||||
|                                                                                                         let on_color_select = Callback::from(move |_: MouseEvent| { |  | ||||||
|                                                                                                             // Update the calendar color locally |  | ||||||
|                                                                                                             if let Some(mut info) = (*user_info_clone).clone() { |  | ||||||
|                                                                                                                 for calendar in &mut info.calendars { |  | ||||||
|                                                                                                                     if calendar.path == cal_path { |  | ||||||
|                                                                                                                         calendar.color = color_str.clone(); |  | ||||||
|                                                                                                                         break; |  | ||||||
|                                                                                                                     } |  | ||||||
|                                                                                                                 } |  | ||||||
|                                                                                                                 user_info_clone.set(Some(info.clone())); |  | ||||||
|                                                                                                                  |  | ||||||
|                                                                                                                 // Save to local storage |  | ||||||
|                                                                                                                 if let Ok(json) = serde_json::to_string(&info) { |  | ||||||
|                                                                                                                     let _ = LocalStorage::set("calendar_colors", json); |  | ||||||
|                                                                                                                 } |  | ||||||
|                                                                                                             } |  | ||||||
|                                                                                                             color_picker_open.set(None); |  | ||||||
|                                                                                                         }); |  | ||||||
|                                                                                                          |  | ||||||
|                                                                                                         let is_selected = cal.color == color; |  | ||||||
|                                                                                                         let class_name = if is_selected { "color-option selected" } else { "color-option" }; |  | ||||||
|                                                                                                          |  | ||||||
|                                                                                                         html! { |  | ||||||
|                                                                                                             <div class={class_name} |  | ||||||
|                                                                                                                  style={format!("background-color: {}", color)} |  | ||||||
|                                                                                                                  onclick={on_color_select}> |  | ||||||
|                                                                                                             </div> |  | ||||||
|                                                                                                         } |  | ||||||
|                                                                                                     }).collect::<Html>() |  | ||||||
|                                                                                                 } |  | ||||||
|                                                                                             </div> |  | ||||||
|                                                                                         } |  | ||||||
|                                                                                     } else { |  | ||||||
|                                                                                         html! {} |  | ||||||
|                                                                                     } |  | ||||||
|                                                                                 } |  | ||||||
|                                                                             </span> |  | ||||||
|                                                                             <span class="calendar-name">{&cal.display_name}</span> |  | ||||||
|                                                                         </li> |  | ||||||
|                                                                     } |  | ||||||
|                                                                 }).collect::<Html>() |  | ||||||
|                                                             } |  | ||||||
|                                                         </ul> |  | ||||||
|                                                     </div> |  | ||||||
|                                                 } |  | ||||||
|                                             } else { |  | ||||||
|                                                 html! { <div class="no-calendars">{"No calendars found"}</div> } |  | ||||||
|                                             } |  | ||||||
|                                         } else { |  | ||||||
|                                             html! {} |  | ||||||
|                                         } |  | ||||||
|                                     } |  | ||||||
|                                     <div class="sidebar-footer"> |  | ||||||
|                                         <button onclick={Callback::from({ |  | ||||||
|                                             let create_modal_open = create_modal_open.clone(); |  | ||||||
|                                             move |_| create_modal_open.set(true) |  | ||||||
|                                         })} class="create-calendar-button"> |  | ||||||
|                                             {"+ Create Calendar"} |  | ||||||
|                                         </button> |  | ||||||
|                                         <button onclick={on_logout} class="logout-button">{"Logout"}</button> |  | ||||||
|                                     </div> |  | ||||||
|                                 </aside> |  | ||||||
|                                 <main class="app-main"> |                                 <main class="app-main"> | ||||||
|                                     <Switch<Route> render={move |route| { |                                     <RouteHandler  | ||||||
|                                         let auth_token = (*auth_token).clone(); |                                         auth_token={(*auth_token).clone()} | ||||||
|                                         let on_login = on_login.clone(); |                                         user_info={(*user_info).clone()} | ||||||
|                                          |                                         on_login={on_login.clone()} | ||||||
|                                         match route { |                                     /> | ||||||
|                                             Route::Home => { |  | ||||||
|                                                 if auth_token.is_some() { |  | ||||||
|                                                     html! { <Redirect<Route> to={Route::Calendar}/> } |  | ||||||
|                                                 } else { |  | ||||||
|                                                     html! { <Redirect<Route> to={Route::Login}/> } |  | ||||||
|                                                 } |  | ||||||
|                                             } |  | ||||||
|                                             Route::Login => { |  | ||||||
|                                                 if auth_token.is_some() { |  | ||||||
|                                                     html! { <Redirect<Route> to={Route::Calendar}/> } |  | ||||||
|                                                 } else { |  | ||||||
|                                                     html! { <Login {on_login} /> } |  | ||||||
|                                                 } |  | ||||||
|                                             } |  | ||||||
|                                             Route::Calendar => { |  | ||||||
|                                                 if auth_token.is_some() { |  | ||||||
|                                                     html! { <CalendarView user_info={(*user_info).clone()} /> } |  | ||||||
|                                                 } else { |  | ||||||
|                                                     html! { <Redirect<Route> to={Route::Login}/> } |  | ||||||
|                                                 } |  | ||||||
|                                             } |  | ||||||
|                                         } |  | ||||||
|                                     }} /> |  | ||||||
|                                 </main> |                                 </main> | ||||||
|                             </> |                             </> | ||||||
|                         } |                         } | ||||||
|                     } else { |                     } else { | ||||||
|                         html! { |                         html! { | ||||||
|                             <div class="login-layout"> |                             <div class="login-layout"> | ||||||
|                                 <Switch<Route> render={move |route| { |                                 <RouteHandler  | ||||||
|                                     let auth_token = (*auth_token).clone(); |                                     auth_token={(*auth_token).clone()} | ||||||
|                                     let on_login = on_login.clone(); |                                     user_info={(*user_info).clone()} | ||||||
|                                      |                                     on_login={on_login.clone()} | ||||||
|                                     match route { |                                 /> | ||||||
|                                         Route::Home => { |  | ||||||
|                                             if auth_token.is_some() { |  | ||||||
|                                                 html! { <Redirect<Route> to={Route::Calendar}/> } |  | ||||||
|                                             } else { |  | ||||||
|                                                 html! { <Redirect<Route> to={Route::Login}/> } |  | ||||||
|                                             } |  | ||||||
|                                         } |  | ||||||
|                                         Route::Login => { |  | ||||||
|                                             if auth_token.is_some() { |  | ||||||
|                                                 html! { <Redirect<Route> to={Route::Calendar}/> } |  | ||||||
|                                             } else { |  | ||||||
|                                                 html! { <Login {on_login} /> } |  | ||||||
|                                             } |  | ||||||
|                                         } |  | ||||||
|                                         Route::Calendar => { |  | ||||||
|                                             if auth_token.is_some() { |  | ||||||
|                                                 html! { <CalendarView user_info={(*user_info).clone()} /> } |  | ||||||
|                                             } else { |  | ||||||
|                                                 html! { <Redirect<Route> to={Route::Login}/> } |  | ||||||
|                                             } |  | ||||||
|                                         } |  | ||||||
|                                     } |  | ||||||
|                                 }} /> |  | ||||||
|                             </div> |                             </div> | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
| @@ -315,22 +237,21 @@ pub fn App() -> Html { | |||||||
|                 <CreateCalendarModal  |                 <CreateCalendarModal  | ||||||
|                     is_open={*create_modal_open} |                     is_open={*create_modal_open} | ||||||
|                     on_close={Callback::from({ |                     on_close={Callback::from({ | ||||||
|                         let create_modal_open = create_modal_open_for_modal.clone(); |                         let create_modal_open = create_modal_open.clone(); | ||||||
|                         move |_| create_modal_open.set(false) |                         move |_| create_modal_open.set(false) | ||||||
|                     })} |                     })} | ||||||
|                     on_create={Callback::from({ |                     on_create={Callback::from({ | ||||||
|                         let auth_token = auth_token_for_modal.clone(); |                         let auth_token = auth_token.clone(); | ||||||
|                         let user_info = user_info_for_modal.clone(); |                         let refresh_calendars = refresh_calendars.clone(); | ||||||
|                         let create_modal_open = create_modal_open_for_modal.clone(); |                         let create_modal_open = create_modal_open.clone(); | ||||||
|                         move |(name, description, color): (String, Option<String>, Option<String>)| { |                         move |(name, description, color): (String, Option<String>, Option<String>)| { | ||||||
|                             if let Some(token) = (*auth_token).clone() { |                             if let Some(token) = (*auth_token).clone() { | ||||||
|                                 let user_info = user_info.clone(); |                                 let refresh_calendars = refresh_calendars.clone(); | ||||||
|                                 let create_modal_open = create_modal_open.clone(); |                                 let create_modal_open = create_modal_open.clone(); | ||||||
|                                  |                                  | ||||||
|                                 wasm_bindgen_futures::spawn_local(async move { |                                 wasm_bindgen_futures::spawn_local(async move { | ||||||
|                                     let calendar_service = CalendarService::new(); |                                     let calendar_service = CalendarService::new(); | ||||||
|                                      |                                      | ||||||
|                                     // Get password from stored credentials |  | ||||||
|                                     let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") { |                                     let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") { | ||||||
|                                         if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) { |                                         if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) { | ||||||
|                                             credentials["password"].as_str().unwrap_or("").to_string() |                                             credentials["password"].as_str().unwrap_or("").to_string() | ||||||
| @@ -344,32 +265,11 @@ pub fn App() -> Html { | |||||||
|                                     match calendar_service.create_calendar(&token, &password, name, description, color).await { |                                     match calendar_service.create_calendar(&token, &password, name, description, color).await { | ||||||
|                                         Ok(_) => { |                                         Ok(_) => { | ||||||
|                                             web_sys::console::log_1(&"Calendar created successfully!".into()); |                                             web_sys::console::log_1(&"Calendar created successfully!".into()); | ||||||
|                                             // Refresh user info to show the new calendar |                                             refresh_calendars.emit(()); | ||||||
|                                             match calendar_service.fetch_user_info(&token, &password).await { |  | ||||||
|                                                 Ok(mut info) => { |  | ||||||
|                                                     // Load saved colors from local storage |  | ||||||
|                                                     if let Ok(saved_colors_json) = LocalStorage::get::<String>("calendar_colors") { |  | ||||||
|                                                         if let Ok(saved_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) { |  | ||||||
|                                                             for saved_cal in &saved_info.calendars { |  | ||||||
|                                                                 for cal in &mut info.calendars { |  | ||||||
|                                                                     if cal.path == saved_cal.path { |  | ||||||
|                                                                         cal.color = saved_cal.color.clone(); |  | ||||||
|                                                                     } |  | ||||||
|                                                                 } |  | ||||||
|                                                             } |  | ||||||
|                                                         } |  | ||||||
|                                                     } |  | ||||||
|                                                     user_info.set(Some(info)); |  | ||||||
|                                                 } |  | ||||||
|                                                 Err(err) => { |  | ||||||
|                                                     web_sys::console::log_1(&format!("Failed to refresh calendars: {}", err).into()); |  | ||||||
|                                                 } |  | ||||||
|                                             } |  | ||||||
|                                             create_modal_open.set(false); |                                             create_modal_open.set(false); | ||||||
|                                         } |                                         } | ||||||
|                                         Err(err) => { |                                         Err(err) => { | ||||||
|                                             web_sys::console::log_1(&format!("Failed to create calendar: {}", err).into()); |                                             web_sys::console::log_1(&format!("Failed to create calendar: {}", err).into()); | ||||||
|                                             // TODO: Show error to user |  | ||||||
|                                             create_modal_open.set(false); |                                             create_modal_open.set(false); | ||||||
|                                         } |                                         } | ||||||
|                                     } |                                     } | ||||||
| @@ -379,180 +279,51 @@ pub fn App() -> Html { | |||||||
|                     })} |                     })} | ||||||
|                     available_colors={available_colors.iter().map(|c| c.to_string()).collect::<Vec<_>>()} |                     available_colors={available_colors.iter().map(|c| c.to_string()).collect::<Vec<_>>()} | ||||||
|                 /> |                 /> | ||||||
|  |                  | ||||||
|  |                 <ContextMenu  | ||||||
|  |                     is_open={*context_menu_open} | ||||||
|  |                     x={context_menu_pos.0} | ||||||
|  |                     y={context_menu_pos.1} | ||||||
|  |                     on_close={Callback::from({ | ||||||
|  |                         let context_menu_open = context_menu_open.clone(); | ||||||
|  |                         move |_| context_menu_open.set(false) | ||||||
|  |                     })} | ||||||
|  |                     on_delete={Callback::from({ | ||||||
|  |                         let auth_token = auth_token.clone(); | ||||||
|  |                         let context_menu_calendar_path = context_menu_calendar_path.clone(); | ||||||
|  |                         let refresh_calendars = refresh_calendars.clone(); | ||||||
|  |                         move |_: MouseEvent| { | ||||||
|  |                             if let (Some(token), Some(calendar_path)) = ((*auth_token).clone(), (*context_menu_calendar_path).clone()) { | ||||||
|  |                                 let refresh_calendars = refresh_calendars.clone(); | ||||||
|  |                                  | ||||||
|  |                                 wasm_bindgen_futures::spawn_local(async move { | ||||||
|  |                                     let calendar_service = CalendarService::new(); | ||||||
|  |                                      | ||||||
|  |                                     let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") { | ||||||
|  |                                         if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) { | ||||||
|  |                                             credentials["password"].as_str().unwrap_or("").to_string() | ||||||
|  |                                         } else { | ||||||
|  |                                             String::new() | ||||||
|  |                                         } | ||||||
|  |                                     } else { | ||||||
|  |                                         String::new() | ||||||
|  |                                     }; | ||||||
|  |                                      | ||||||
|  |                                     match calendar_service.delete_calendar(&token, &password, calendar_path).await { | ||||||
|  |                                         Ok(_) => { | ||||||
|  |                                             web_sys::console::log_1(&"Calendar deleted successfully!".into()); | ||||||
|  |                                             refresh_calendars.emit(()); | ||||||
|  |                                         } | ||||||
|  |                                         Err(err) => { | ||||||
|  |                                             web_sys::console::log_1(&format!("Failed to delete calendar: {}", err).into()); | ||||||
|  |                                         } | ||||||
|  |                                     } | ||||||
|  |                                 }); | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     })} | ||||||
|  |                 /> | ||||||
|             </div> |             </div> | ||||||
|         </BrowserRouter> |         </BrowserRouter> | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Properties, PartialEq)] |  | ||||||
| pub struct CalendarViewProps { |  | ||||||
|     pub user_info: Option<UserInfo>, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[function_component] |  | ||||||
| fn CalendarView(props: &CalendarViewProps) -> Html { |  | ||||||
|     let events = use_state(|| HashMap::<NaiveDate, Vec<CalendarEvent>>::new()); |  | ||||||
|     let loading = use_state(|| true); |  | ||||||
|     let error = use_state(|| None::<String>); |  | ||||||
|     let refreshing_event = use_state(|| None::<String>); |  | ||||||
|      |  | ||||||
|     // Get current auth token |  | ||||||
|     let auth_token: Option<String> = LocalStorage::get("auth_token").ok(); |  | ||||||
|      |  | ||||||
|     let today = Local::now().date_naive(); |  | ||||||
|     let current_year = today.year(); |  | ||||||
|     let current_month = today.month(); |  | ||||||
|      |  | ||||||
|     // Event refresh callback |  | ||||||
|     let on_event_click = { |  | ||||||
|         let events = events.clone(); |  | ||||||
|         let refreshing_event = refreshing_event.clone(); |  | ||||||
|         let auth_token = auth_token.clone(); |  | ||||||
|          |  | ||||||
|         Callback::from(move |event: CalendarEvent| { |  | ||||||
|             if let Some(token) = auth_token.clone() { |  | ||||||
|                 let events = events.clone(); |  | ||||||
|                 let refreshing_event = refreshing_event.clone(); |  | ||||||
|                 let uid = event.uid.clone(); |  | ||||||
|                  |  | ||||||
|                 refreshing_event.set(Some(uid.clone())); |  | ||||||
|                  |  | ||||||
|                 wasm_bindgen_futures::spawn_local(async move { |  | ||||||
|                     let calendar_service = CalendarService::new(); |  | ||||||
|                      |  | ||||||
|                     // Get password from stored credentials |  | ||||||
|                     let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") { |  | ||||||
|                         if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) { |  | ||||||
|                             credentials["password"].as_str().unwrap_or("").to_string() |  | ||||||
|                         } else { |  | ||||||
|                             String::new() |  | ||||||
|                         } |  | ||||||
|                     } else { |  | ||||||
|                         String::new() |  | ||||||
|                     }; |  | ||||||
|                      |  | ||||||
|                     match calendar_service.refresh_event(&token, &password, &uid).await { |  | ||||||
|                         Ok(Some(refreshed_event)) => { |  | ||||||
|                             // If this is a recurring event, we need to regenerate all occurrences |  | ||||||
|                             let mut updated_events = (*events).clone(); |  | ||||||
|                              |  | ||||||
|                             // First, remove all existing occurrences of this event |  | ||||||
|                             for (_, day_events) in updated_events.iter_mut() { |  | ||||||
|                                 day_events.retain(|e| e.uid != uid); |  | ||||||
|                             } |  | ||||||
|                              |  | ||||||
|                             // Then, if it's a recurring event, generate new occurrences |  | ||||||
|                             if refreshed_event.recurrence_rule.is_some() { |  | ||||||
|                                 let new_occurrences = CalendarService::expand_recurring_events(vec![refreshed_event.clone()]); |  | ||||||
|                                  |  | ||||||
|                                 // Add all new occurrences to the appropriate dates |  | ||||||
|                                 for occurrence in new_occurrences { |  | ||||||
|                                     let date = occurrence.get_date(); |  | ||||||
|                                     updated_events.entry(date) |  | ||||||
|                                         .or_insert_with(Vec::new) |  | ||||||
|                                         .push(occurrence); |  | ||||||
|                                 } |  | ||||||
|                             } else { |  | ||||||
|                                 // Non-recurring event, just add it to the appropriate date |  | ||||||
|                                 let date = refreshed_event.get_date(); |  | ||||||
|                                 updated_events.entry(date) |  | ||||||
|                                     .or_insert_with(Vec::new) |  | ||||||
|                                     .push(refreshed_event); |  | ||||||
|                             } |  | ||||||
|                              |  | ||||||
|                             events.set(updated_events); |  | ||||||
|                         } |  | ||||||
|                         Ok(None) => { |  | ||||||
|                             // Event was deleted, remove it from the map |  | ||||||
|                             let mut updated_events = (*events).clone(); |  | ||||||
|                             for (_, day_events) in updated_events.iter_mut() { |  | ||||||
|                                 day_events.retain(|e| e.uid != uid); |  | ||||||
|                             } |  | ||||||
|                             events.set(updated_events); |  | ||||||
|                         } |  | ||||||
|                         Err(_err) => { |  | ||||||
|                             // Log error but don't show it to user - keep using cached event |  | ||||||
|                             // Silently fall back to cached event data |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                      |  | ||||||
|                     refreshing_event.set(None); |  | ||||||
|                 }); |  | ||||||
|             } |  | ||||||
|         }) |  | ||||||
|     }; |  | ||||||
|      |  | ||||||
|     // Fetch events when component mounts |  | ||||||
|     { |  | ||||||
|         let events = events.clone(); |  | ||||||
|         let loading = loading.clone(); |  | ||||||
|         let error = error.clone(); |  | ||||||
|         let auth_token = auth_token.clone(); |  | ||||||
|          |  | ||||||
|         use_effect_with((), move |_| { |  | ||||||
|             if let Some(token) = auth_token { |  | ||||||
|                 let events = events.clone(); |  | ||||||
|                 let loading = loading.clone(); |  | ||||||
|                 let error = error.clone(); |  | ||||||
|                  |  | ||||||
|                 wasm_bindgen_futures::spawn_local(async move { |  | ||||||
|                     let calendar_service = CalendarService::new(); |  | ||||||
|                      |  | ||||||
|                     // Get password from stored credentials |  | ||||||
|                     let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") { |  | ||||||
|                         if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) { |  | ||||||
|                             credentials["password"].as_str().unwrap_or("").to_string() |  | ||||||
|                         } else { |  | ||||||
|                             String::new() |  | ||||||
|                         } |  | ||||||
|                     } else { |  | ||||||
|                         String::new() |  | ||||||
|                     }; |  | ||||||
|                      |  | ||||||
|                     match calendar_service.fetch_events_for_month(&token, &password, current_year, current_month).await { |  | ||||||
|                         Ok(calendar_events) => { |  | ||||||
|                             let grouped_events = CalendarService::group_events_by_date(calendar_events); |  | ||||||
|                             events.set(grouped_events); |  | ||||||
|                             loading.set(false); |  | ||||||
|                         } |  | ||||||
|                         Err(err) => { |  | ||||||
|                             error.set(Some(format!("Failed to load events: {}", err))); |  | ||||||
|                             loading.set(false); |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 }); |  | ||||||
|             } else { |  | ||||||
|                 loading.set(false); |  | ||||||
|                 error.set(Some("No authentication token found".to_string())); |  | ||||||
|             } |  | ||||||
|              |  | ||||||
|             || () |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     html! { |  | ||||||
|         <div class="calendar-view"> |  | ||||||
|             { |  | ||||||
|                 if *loading { |  | ||||||
|                     html! { |  | ||||||
|                         <div class="calendar-loading"> |  | ||||||
|                             <p>{"Loading calendar events..."}</p> |  | ||||||
|                         </div> |  | ||||||
|                     } |  | ||||||
|                 } else if let Some(err) = (*error).clone() { |  | ||||||
|                     let dummy_callback = Callback::from(|_: CalendarEvent| {}); |  | ||||||
|                     html! { |  | ||||||
|                         <div class="calendar-error"> |  | ||||||
|                             <p>{format!("Error: {}", err)}</p> |  | ||||||
|                             <Calendar events={HashMap::new()} on_event_click={dummy_callback} refreshing_event_uid={(*refreshing_event).clone()} user_info={props.user_info.clone()} /> |  | ||||||
|                         </div> |  | ||||||
|                     } |  | ||||||
|                 } else { |  | ||||||
|                     html! { |  | ||||||
|                         <Calendar events={(*events).clone()} on_event_click={on_event_click} refreshing_event_uid={(*refreshing_event).clone()} user_info={props.user_info.clone()} /> |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         </div> |  | ||||||
|     } |  | ||||||
| } |  | ||||||
							
								
								
									
										75
									
								
								src/components/calendar_list_item.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/components/calendar_list_item.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | |||||||
|  | use yew::prelude::*; | ||||||
|  | use web_sys::MouseEvent; | ||||||
|  | use crate::services::calendar_service::CalendarInfo; | ||||||
|  |  | ||||||
|  | #[derive(Properties, PartialEq)] | ||||||
|  | pub struct CalendarListItemProps { | ||||||
|  |     pub calendar: CalendarInfo, | ||||||
|  |     pub color_picker_open: bool, | ||||||
|  |     pub on_color_change: Callback<(String, String)>, // (calendar_path, color) | ||||||
|  |     pub on_color_picker_toggle: Callback<String>, // calendar_path | ||||||
|  |     pub available_colors: Vec<String>, | ||||||
|  |     pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[function_component(CalendarListItem)] | ||||||
|  | pub fn calendar_list_item(props: &CalendarListItemProps) -> Html { | ||||||
|  |     let on_color_click = { | ||||||
|  |         let cal_path = props.calendar.path.clone(); | ||||||
|  |         let on_color_picker_toggle = props.on_color_picker_toggle.clone(); | ||||||
|  |         Callback::from(move |e: MouseEvent| { | ||||||
|  |             e.stop_propagation(); | ||||||
|  |             on_color_picker_toggle.emit(cal_path.clone()); | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_context_menu = { | ||||||
|  |         let cal_path = props.calendar.path.clone(); | ||||||
|  |         let on_context_menu = props.on_context_menu.clone(); | ||||||
|  |         Callback::from(move |e: MouseEvent| { | ||||||
|  |             e.prevent_default(); | ||||||
|  |             on_context_menu.emit((e, cal_path.clone())); | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     html! { | ||||||
|  |         <li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}> | ||||||
|  |             <span class="calendar-color"  | ||||||
|  |                   style={format!("background-color: {}", props.calendar.color)} | ||||||
|  |                   onclick={on_color_click}> | ||||||
|  |                 { | ||||||
|  |                     if props.color_picker_open { | ||||||
|  |                         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| { | ||||||
|  |                                             on_color_change.emit((cal_path.clone(), color_str.clone())); | ||||||
|  |                                         }); | ||||||
|  |                                          | ||||||
|  |                                         let is_selected = props.calendar.color == *color; | ||||||
|  |                                         let class_name = if is_selected { "color-option selected" } else { "color-option" }; | ||||||
|  |                                          | ||||||
|  |                                         html! { | ||||||
|  |                                             <div class={class_name} | ||||||
|  |                                                  style={format!("background-color: {}", color)} | ||||||
|  |                                                  onclick={on_color_select}> | ||||||
|  |                                             </div> | ||||||
|  |                                         } | ||||||
|  |                                     }).collect::<Html>() | ||||||
|  |                                 } | ||||||
|  |                             </div> | ||||||
|  |                         } | ||||||
|  |                     } else { | ||||||
|  |                         html! {} | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             </span> | ||||||
|  |             <span class="calendar-name">{&props.calendar.display_name}</span> | ||||||
|  |         </li> | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										49
									
								
								src/components/context_menu.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/components/context_menu.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | |||||||
|  | use yew::prelude::*; | ||||||
|  | use web_sys::MouseEvent; | ||||||
|  |  | ||||||
|  | #[derive(Properties, PartialEq)] | ||||||
|  | pub struct ContextMenuProps { | ||||||
|  |     pub is_open: bool, | ||||||
|  |     pub x: i32, | ||||||
|  |     pub y: i32, | ||||||
|  |     pub on_delete: Callback<MouseEvent>, | ||||||
|  |     pub on_close: Callback<()>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[function_component(ContextMenu)] | ||||||
|  | pub fn context_menu(props: &ContextMenuProps) -> Html { | ||||||
|  |     let menu_ref = use_node_ref(); | ||||||
|  |      | ||||||
|  |     // Close menu when clicking outside (handled by parent component) | ||||||
|  |  | ||||||
|  |     if !props.is_open { | ||||||
|  |         return html! {}; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let style = format!( | ||||||
|  |         "position: fixed; left: {}px; top: {}px; z-index: 1001;", | ||||||
|  |         props.x, props.y | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     let on_delete_click = { | ||||||
|  |         let on_delete = props.on_delete.clone(); | ||||||
|  |         let on_close = props.on_close.clone(); | ||||||
|  |         Callback::from(move |e: MouseEvent| { | ||||||
|  |             on_delete.emit(e); | ||||||
|  |             on_close.emit(()); | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     html! { | ||||||
|  |         <div  | ||||||
|  |             ref={menu_ref} | ||||||
|  |             class="context-menu"  | ||||||
|  |             style={style} | ||||||
|  |         > | ||||||
|  |             <div class="context-menu-item context-menu-delete" onclick={on_delete_click}> | ||||||
|  |                 <span class="context-menu-icon">{"🗑️"}</span> | ||||||
|  |                 {"Delete Calendar"} | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -2,8 +2,16 @@ pub mod login; | |||||||
| pub mod calendar; | pub mod calendar; | ||||||
| pub mod event_modal; | pub mod event_modal; | ||||||
| pub mod create_calendar_modal; | pub mod create_calendar_modal; | ||||||
|  | pub mod context_menu; | ||||||
|  | pub mod sidebar; | ||||||
|  | pub mod calendar_list_item; | ||||||
|  | pub mod route_handler; | ||||||
|  |  | ||||||
| pub use login::Login; | pub use login::Login; | ||||||
| pub use calendar::Calendar; | pub use calendar::Calendar; | ||||||
| pub use event_modal::EventModal; | pub use event_modal::EventModal; | ||||||
| pub use create_calendar_modal::CreateCalendarModal; | pub use create_calendar_modal::CreateCalendarModal; | ||||||
|  | pub use context_menu::ContextMenu; | ||||||
|  | pub use sidebar::Sidebar; | ||||||
|  | pub use calendar_list_item::CalendarListItem; | ||||||
|  | pub use route_handler::RouteHandler; | ||||||
							
								
								
									
										226
									
								
								src/components/route_handler.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										226
									
								
								src/components/route_handler.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,226 @@ | |||||||
|  | use yew::prelude::*; | ||||||
|  | use yew_router::prelude::*; | ||||||
|  | use crate::components::Login; | ||||||
|  | use crate::services::calendar_service::UserInfo; | ||||||
|  |  | ||||||
|  | #[derive(Clone, Routable, PartialEq)] | ||||||
|  | pub enum Route { | ||||||
|  |     #[at("/")] | ||||||
|  |     Home, | ||||||
|  |     #[at("/login")] | ||||||
|  |     Login, | ||||||
|  |     #[at("/calendar")] | ||||||
|  |     Calendar, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Properties, PartialEq)] | ||||||
|  | pub struct RouteHandlerProps { | ||||||
|  |     pub auth_token: Option<String>, | ||||||
|  |     pub user_info: Option<UserInfo>, | ||||||
|  |     pub on_login: Callback<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[function_component(RouteHandler)] | ||||||
|  | pub fn route_handler(props: &RouteHandlerProps) -> Html { | ||||||
|  |     let auth_token = props.auth_token.clone(); | ||||||
|  |     let user_info = props.user_info.clone(); | ||||||
|  |     let on_login = props.on_login.clone(); | ||||||
|  |      | ||||||
|  |     html! { | ||||||
|  |         <Switch<Route> render={move |route| { | ||||||
|  |             let auth_token = auth_token.clone(); | ||||||
|  |             let user_info = user_info.clone(); | ||||||
|  |             let on_login = on_login.clone(); | ||||||
|  |              | ||||||
|  |             match route { | ||||||
|  |                 Route::Home => { | ||||||
|  |                     if auth_token.is_some() { | ||||||
|  |                         html! { <Redirect<Route> to={Route::Calendar}/> } | ||||||
|  |                     } else { | ||||||
|  |                         html! { <Redirect<Route> to={Route::Login}/> } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 Route::Login => { | ||||||
|  |                     if auth_token.is_some() { | ||||||
|  |                         html! { <Redirect<Route> to={Route::Calendar}/> } | ||||||
|  |                     } else { | ||||||
|  |                         html! { <Login {on_login} /> } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 Route::Calendar => { | ||||||
|  |                     if auth_token.is_some() { | ||||||
|  |                         html! { <CalendarView user_info={user_info} /> } | ||||||
|  |                     } else { | ||||||
|  |                         html! { <Redirect<Route> to={Route::Login}/> } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }} /> | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Properties, PartialEq)] | ||||||
|  | pub struct CalendarViewProps { | ||||||
|  |     pub user_info: Option<UserInfo>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | use gloo_storage::{LocalStorage, Storage}; | ||||||
|  | use crate::services::{CalendarService, CalendarEvent}; | ||||||
|  | use crate::components::Calendar; | ||||||
|  | use std::collections::HashMap; | ||||||
|  | use chrono::{Local, NaiveDate, Datelike}; | ||||||
|  |  | ||||||
|  | #[function_component(CalendarView)] | ||||||
|  | pub fn calendar_view(props: &CalendarViewProps) -> Html { | ||||||
|  |     let events = use_state(|| HashMap::<NaiveDate, Vec<CalendarEvent>>::new()); | ||||||
|  |     let loading = use_state(|| true); | ||||||
|  |     let error = use_state(|| None::<String>); | ||||||
|  |     let refreshing_event = use_state(|| None::<String>); | ||||||
|  |      | ||||||
|  |     let auth_token: Option<String> = LocalStorage::get("auth_token").ok(); | ||||||
|  |      | ||||||
|  |     let today = Local::now().date_naive(); | ||||||
|  |     let current_year = today.year(); | ||||||
|  |     let current_month = today.month(); | ||||||
|  |      | ||||||
|  |     let on_event_click = { | ||||||
|  |         let events = events.clone(); | ||||||
|  |         let refreshing_event = refreshing_event.clone(); | ||||||
|  |         let auth_token = auth_token.clone(); | ||||||
|  |          | ||||||
|  |         Callback::from(move |event: CalendarEvent| { | ||||||
|  |             if let Some(token) = auth_token.clone() { | ||||||
|  |                 let events = events.clone(); | ||||||
|  |                 let refreshing_event = refreshing_event.clone(); | ||||||
|  |                 let uid = event.uid.clone(); | ||||||
|  |                  | ||||||
|  |                 refreshing_event.set(Some(uid.clone())); | ||||||
|  |                  | ||||||
|  |                 wasm_bindgen_futures::spawn_local(async move { | ||||||
|  |                     let calendar_service = CalendarService::new(); | ||||||
|  |                      | ||||||
|  |                     let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") { | ||||||
|  |                         if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) { | ||||||
|  |                             credentials["password"].as_str().unwrap_or("").to_string() | ||||||
|  |                         } else { | ||||||
|  |                             String::new() | ||||||
|  |                         } | ||||||
|  |                     } else { | ||||||
|  |                         String::new() | ||||||
|  |                     }; | ||||||
|  |                      | ||||||
|  |                     match calendar_service.refresh_event(&token, &password, &uid).await { | ||||||
|  |                         Ok(Some(refreshed_event)) => { | ||||||
|  |                             let mut updated_events = (*events).clone(); | ||||||
|  |                              | ||||||
|  |                             for (_, day_events) in updated_events.iter_mut() { | ||||||
|  |                                 day_events.retain(|e| e.uid != uid); | ||||||
|  |                             } | ||||||
|  |                              | ||||||
|  |                             if refreshed_event.recurrence_rule.is_some() { | ||||||
|  |                                 let new_occurrences = CalendarService::expand_recurring_events(vec![refreshed_event.clone()]); | ||||||
|  |                                  | ||||||
|  |                                 for occurrence in new_occurrences { | ||||||
|  |                                     let date = occurrence.get_date(); | ||||||
|  |                                     updated_events.entry(date) | ||||||
|  |                                         .or_insert_with(Vec::new) | ||||||
|  |                                         .push(occurrence); | ||||||
|  |                                 } | ||||||
|  |                             } else { | ||||||
|  |                                 let date = refreshed_event.get_date(); | ||||||
|  |                                 updated_events.entry(date) | ||||||
|  |                                     .or_insert_with(Vec::new) | ||||||
|  |                                     .push(refreshed_event); | ||||||
|  |                             } | ||||||
|  |                              | ||||||
|  |                             events.set(updated_events); | ||||||
|  |                         } | ||||||
|  |                         Ok(None) => { | ||||||
|  |                             let mut updated_events = (*events).clone(); | ||||||
|  |                             for (_, day_events) in updated_events.iter_mut() { | ||||||
|  |                                 day_events.retain(|e| e.uid != uid); | ||||||
|  |                             } | ||||||
|  |                             events.set(updated_events); | ||||||
|  |                         } | ||||||
|  |                         Err(_err) => { | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     refreshing_event.set(None); | ||||||
|  |                 }); | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |      | ||||||
|  |     { | ||||||
|  |         let events = events.clone(); | ||||||
|  |         let loading = loading.clone(); | ||||||
|  |         let error = error.clone(); | ||||||
|  |         let auth_token = auth_token.clone(); | ||||||
|  |          | ||||||
|  |         use_effect_with((), move |_| { | ||||||
|  |             if let Some(token) = auth_token { | ||||||
|  |                 let events = events.clone(); | ||||||
|  |                 let loading = loading.clone(); | ||||||
|  |                 let error = error.clone(); | ||||||
|  |                  | ||||||
|  |                 wasm_bindgen_futures::spawn_local(async move { | ||||||
|  |                     let calendar_service = CalendarService::new(); | ||||||
|  |                      | ||||||
|  |                     let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") { | ||||||
|  |                         if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) { | ||||||
|  |                             credentials["password"].as_str().unwrap_or("").to_string() | ||||||
|  |                         } else { | ||||||
|  |                             String::new() | ||||||
|  |                         } | ||||||
|  |                     } else { | ||||||
|  |                         String::new() | ||||||
|  |                     }; | ||||||
|  |                      | ||||||
|  |                     match calendar_service.fetch_events_for_month(&token, &password, current_year, current_month).await { | ||||||
|  |                         Ok(calendar_events) => { | ||||||
|  |                             let grouped_events = CalendarService::group_events_by_date(calendar_events); | ||||||
|  |                             events.set(grouped_events); | ||||||
|  |                             loading.set(false); | ||||||
|  |                         } | ||||||
|  |                         Err(err) => { | ||||||
|  |                             error.set(Some(format!("Failed to load events: {}", err))); | ||||||
|  |                             loading.set(false); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 }); | ||||||
|  |             } else { | ||||||
|  |                 loading.set(false); | ||||||
|  |                 error.set(Some("No authentication token found".to_string())); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             || () | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     html! { | ||||||
|  |         <div class="calendar-view"> | ||||||
|  |             { | ||||||
|  |                 if *loading { | ||||||
|  |                     html! { | ||||||
|  |                         <div class="calendar-loading"> | ||||||
|  |                             <p>{"Loading calendar events..."}</p> | ||||||
|  |                         </div> | ||||||
|  |                     } | ||||||
|  |                 } else if let Some(err) = (*error).clone() { | ||||||
|  |                     let dummy_callback = Callback::from(|_: CalendarEvent| {}); | ||||||
|  |                     html! { | ||||||
|  |                         <div class="calendar-error"> | ||||||
|  |                             <p>{format!("Error: {}", err)}</p> | ||||||
|  |                             <Calendar events={HashMap::new()} on_event_click={dummy_callback} refreshing_event_uid={(*refreshing_event).clone()} user_info={props.user_info.clone()} /> | ||||||
|  |                         </div> | ||||||
|  |                     } | ||||||
|  |                 } else { | ||||||
|  |                     html! { | ||||||
|  |                         <Calendar events={(*events).clone()} on_event_click={on_event_click} refreshing_event_uid={(*refreshing_event).clone()} user_info={props.user_info.clone()} /> | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         </div> | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										89
									
								
								src/components/sidebar.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								src/components/sidebar.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | |||||||
|  | use yew::prelude::*; | ||||||
|  | use yew_router::prelude::*; | ||||||
|  | use crate::services::calendar_service::UserInfo; | ||||||
|  | use crate::components::CalendarListItem; | ||||||
|  |  | ||||||
|  | #[derive(Clone, Routable, PartialEq)] | ||||||
|  | pub enum Route { | ||||||
|  |     #[at("/")] | ||||||
|  |     Home, | ||||||
|  |     #[at("/login")] | ||||||
|  |     Login, | ||||||
|  |     #[at("/calendar")] | ||||||
|  |     Calendar, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Properties, PartialEq)] | ||||||
|  | pub struct SidebarProps { | ||||||
|  |     pub user_info: Option<UserInfo>, | ||||||
|  |     pub on_logout: Callback<()>, | ||||||
|  |     pub on_create_calendar: Callback<()>, | ||||||
|  |     pub color_picker_open: Option<String>, | ||||||
|  |     pub on_color_change: Callback<(String, String)>, | ||||||
|  |     pub on_color_picker_toggle: Callback<String>, | ||||||
|  |     pub available_colors: Vec<String>, | ||||||
|  |     pub on_calendar_context_menu: Callback<(MouseEvent, String)>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[function_component(Sidebar)] | ||||||
|  | pub fn sidebar(props: &SidebarProps) -> Html { | ||||||
|  |     html! { | ||||||
|  |         <aside class="app-sidebar"> | ||||||
|  |             <div class="sidebar-header"> | ||||||
|  |                 <h1>{"Calendar App"}</h1> | ||||||
|  |                 { | ||||||
|  |                     if let Some(ref info) = props.user_info { | ||||||
|  |                         html! { | ||||||
|  |                             <div class="user-info"> | ||||||
|  |                                 <div class="username">{&info.username}</div> | ||||||
|  |                                 <div class="server-url">{&info.server_url}</div> | ||||||
|  |                             </div> | ||||||
|  |                         } | ||||||
|  |                     } else { | ||||||
|  |                         html! { <div class="user-info loading">{"Loading..."}</div> } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             </div> | ||||||
|  |             <nav class="sidebar-nav"> | ||||||
|  |                 <Link<Route> to={Route::Calendar} classes="nav-link">{"Calendar"}</Link<Route>> | ||||||
|  |             </nav> | ||||||
|  |             { | ||||||
|  |                 if let Some(ref info) = props.user_info { | ||||||
|  |                     if !info.calendars.is_empty() { | ||||||
|  |                         html! { | ||||||
|  |                             <div class="calendar-list"> | ||||||
|  |                                 <h3>{"My Calendars"}</h3> | ||||||
|  |                                 <ul> | ||||||
|  |                                     { | ||||||
|  |                                         info.calendars.iter().map(|cal| { | ||||||
|  |                                             html! { | ||||||
|  |                                                 <CalendarListItem | ||||||
|  |                                                     calendar={cal.clone()} | ||||||
|  |                                                     color_picker_open={props.color_picker_open.as_ref() == Some(&cal.path)} | ||||||
|  |                                                     on_color_change={props.on_color_change.clone()} | ||||||
|  |                                                     on_color_picker_toggle={props.on_color_picker_toggle.clone()} | ||||||
|  |                                                     available_colors={props.available_colors.clone()} | ||||||
|  |                                                     on_context_menu={props.on_calendar_context_menu.clone()} | ||||||
|  |                                                 /> | ||||||
|  |                                             } | ||||||
|  |                                         }).collect::<Html>() | ||||||
|  |                                     } | ||||||
|  |                                 </ul> | ||||||
|  |                             </div> | ||||||
|  |                         } | ||||||
|  |                     } else { | ||||||
|  |                         html! { <div class="no-calendars">{"No calendars found"}</div> } | ||||||
|  |                     } | ||||||
|  |                 } else { | ||||||
|  |                     html! {} | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             <div class="sidebar-footer"> | ||||||
|  |                 <button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button"> | ||||||
|  |                     {"+ Create Calendar"} | ||||||
|  |                 </button> | ||||||
|  |                 <button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button> | ||||||
|  |             </div> | ||||||
|  |         </aside> | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -528,6 +528,63 @@ impl CalendarService { | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /// Delete a calendar from the CalDAV server | ||||||
|  |     pub async fn delete_calendar( | ||||||
|  |         &self,  | ||||||
|  |         token: &str,  | ||||||
|  |         password: &str, | ||||||
|  |         path: String | ||||||
|  |     ) -> Result<(), String> { | ||||||
|  |         let window = web_sys::window().ok_or("No global window exists")?; | ||||||
|  |          | ||||||
|  |         let opts = RequestInit::new(); | ||||||
|  |         opts.set_method("POST"); | ||||||
|  |         opts.set_mode(RequestMode::Cors); | ||||||
|  |  | ||||||
|  |         let body = serde_json::json!({ | ||||||
|  |             "path": path | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         let body_string = serde_json::to_string(&body) | ||||||
|  |             .map_err(|e| format!("JSON serialization failed: {}", e))?; | ||||||
|  |  | ||||||
|  |         opts.set_body(&body_string.into()); | ||||||
|  |  | ||||||
|  |         let url = format!("{}/calendar/delete", self.base_url); | ||||||
|  |         let request = Request::new_with_str_and_init(&url, &opts) | ||||||
|  |             .map_err(|e| format!("Request creation failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         request.headers().set("Authorization", &format!("Bearer {}", token)) | ||||||
|  |             .map_err(|e| format!("Authorization header setting failed: {:?}", e))?; | ||||||
|  |          | ||||||
|  |         request.headers().set("X-CalDAV-Password", password) | ||||||
|  |             .map_err(|e| format!("Password header setting failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         request.headers().set("Content-Type", "application/json") | ||||||
|  |             .map_err(|e| format!("Content-Type header setting failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let resp_value = JsFuture::from(window.fetch_with_request(&request)) | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| format!("Network request failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let resp: Response = resp_value.dyn_into() | ||||||
|  |             .map_err(|e| format!("Response cast failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let text = JsFuture::from(resp.text() | ||||||
|  |             .map_err(|e| format!("Text extraction failed: {:?}", e))?) | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| format!("Text promise failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let text_string = text.as_string() | ||||||
|  |             .ok_or("Response text is not a string")?; | ||||||
|  |  | ||||||
|  |         if resp.ok() { | ||||||
|  |             Ok(()) | ||||||
|  |         } else { | ||||||
|  |             Err(format!("Request failed with status {}: {}", resp.status(), text_string)) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /// Refresh a single event by UID from the CalDAV server |     /// Refresh a single event by UID from the CalDAV server | ||||||
|     pub async fn refresh_event(&self, token: &str, password: &str, uid: &str) -> Result<Option<CalendarEvent>, String> { |     pub async fn refresh_event(&self, token: &str, password: &str, uid: &str) -> Result<Option<CalendarEvent>, String> { | ||||||
|         let window = web_sys::window().ok_or("No global window exists")?; |         let window = web_sys::window().ok_or("No global window exists")?; | ||||||
|   | |||||||
| @@ -1,3 +1,3 @@ | |||||||
| pub mod calendar_service; | pub mod calendar_service; | ||||||
|  |  | ||||||
| pub use calendar_service::{CalendarService, CalendarEvent, EventReminder, ReminderAction, UserInfo}; | pub use calendar_service::{CalendarService, CalendarEvent, EventReminder, ReminderAction}; | ||||||
							
								
								
									
										62
									
								
								styles.css
									
									
									
									
									
								
							
							
						
						
									
										62
									
								
								styles.css
									
									
									
									
									
								
							| @@ -1108,3 +1108,65 @@ body { | |||||||
|         text-align: center; |         text-align: center; | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /* Context Menu */ | ||||||
|  | .context-menu { | ||||||
|  |     background: white; | ||||||
|  |     border: 1px solid #e9ecef; | ||||||
|  |     border-radius: 8px; | ||||||
|  |     box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); | ||||||
|  |     min-width: 160px; | ||||||
|  |     overflow: hidden; | ||||||
|  |     animation: contextMenuSlideIn 0.15s ease; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes contextMenuSlideIn { | ||||||
|  |     from { | ||||||
|  |         opacity: 0; | ||||||
|  |         transform: scale(0.95) translateY(-5px); | ||||||
|  |     } | ||||||
|  |     to { | ||||||
|  |         opacity: 1; | ||||||
|  |         transform: scale(1) translateY(0); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .context-menu-item { | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     padding: 0.75rem 1rem; | ||||||
|  |     color: #495057; | ||||||
|  |     cursor: pointer; | ||||||
|  |     transition: background-color 0.2s ease; | ||||||
|  |     font-size: 0.9rem; | ||||||
|  |     border: none; | ||||||
|  |     background: none; | ||||||
|  |     width: 100%; | ||||||
|  |     text-align: left; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .context-menu-item:hover { | ||||||
|  |     background-color: #f8f9fa; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .context-menu-delete { | ||||||
|  |     color: #dc3545; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .context-menu-delete:hover { | ||||||
|  |     background-color: #f8f9fa; | ||||||
|  |     color: #dc3545; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .context-menu-icon { | ||||||
|  |     margin-right: 0.5rem; | ||||||
|  |     font-size: 1rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Prevent text selection on context menu items */ | ||||||
|  | .context-menu-item { | ||||||
|  |     user-select: none; | ||||||
|  |     -webkit-user-select: none; | ||||||
|  |     -moz-user-select: none; | ||||||
|  |     -ms-user-select: none; | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user