Compare commits
	
		
			2 Commits
		
	
	
		
			d85898cae7
			...
			7c83a4522c
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 7c83a4522c | ||
|   | 8a0d2286dc | 
| @@ -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}}; | use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo}}; | ||||||
| use crate::calendar::{CalDAVClient, CalendarEvent}; | use crate::calendar::{CalDAVClient, CalendarEvent}; | ||||||
|  |  | ||||||
| #[derive(Deserialize)] | #[derive(Deserialize)] | ||||||
| @@ -116,6 +116,82 @@ pub async fn verify_token( | |||||||
|     }))) |     }))) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | pub async fn get_user_info( | ||||||
|  |     State(state): State<Arc<AppState>>, | ||||||
|  |     headers: HeaderMap, | ||||||
|  | ) -> Result<Json<UserInfo>, ApiError> { | ||||||
|  |     // Extract and verify token | ||||||
|  |     let token = extract_bearer_token(&headers)?; | ||||||
|  |     let password = extract_password_header(&headers)?; | ||||||
|  |     let claims = state.auth_service.verify_token(&token)?; | ||||||
|  |      | ||||||
|  |     // Create CalDAV config from token and password | ||||||
|  |     let config = state.auth_service.caldav_config_from_token(&token, &password)?; | ||||||
|  |     let client = CalDAVClient::new(config); | ||||||
|  |      | ||||||
|  |     // Discover calendars | ||||||
|  |     let calendar_paths = client.discover_calendars() | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; | ||||||
|  |      | ||||||
|  |     // Convert paths to CalendarInfo structs with display names, filtering out generic collections | ||||||
|  |     let calendars: Vec<CalendarInfo> = calendar_paths.into_iter() | ||||||
|  |         .filter_map(|path| { | ||||||
|  |             let display_name = extract_calendar_name(&path); | ||||||
|  |             // Skip generic collection names | ||||||
|  |             if display_name.eq_ignore_ascii_case("calendar") ||  | ||||||
|  |                display_name.eq_ignore_ascii_case("calendars") || | ||||||
|  |                display_name.eq_ignore_ascii_case("collection") { | ||||||
|  |                 None | ||||||
|  |             } else { | ||||||
|  |                 Some(CalendarInfo { | ||||||
|  |                     path, | ||||||
|  |                     display_name, | ||||||
|  |                 }) | ||||||
|  |             } | ||||||
|  |         }).collect(); | ||||||
|  |      | ||||||
|  |     Ok(Json(UserInfo { | ||||||
|  |         username: claims.username, | ||||||
|  |         server_url: claims.server_url, | ||||||
|  |         calendars, | ||||||
|  |     })) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Helper function to extract a readable calendar name from path | ||||||
|  | fn extract_calendar_name(path: &str) -> String { | ||||||
|  |     // Extract the last meaningful part of the path | ||||||
|  |     // e.g., "/calendars/user/personal/" -> "personal" | ||||||
|  |     // or "/calendars/user/work-calendar/" -> "work-calendar" | ||||||
|  |     let parts: Vec<&str> = path.trim_end_matches('/').split('/').collect(); | ||||||
|  |      | ||||||
|  |     if let Some(last_part) = parts.last() { | ||||||
|  |         if !last_part.is_empty() && *last_part != "calendars" { | ||||||
|  |             // Convert kebab-case or snake_case to title case | ||||||
|  |             last_part | ||||||
|  |                 .replace('-', " ") | ||||||
|  |                 .replace('_', " ") | ||||||
|  |                 .split_whitespace() | ||||||
|  |                 .map(|word| { | ||||||
|  |                     let mut chars = word.chars(); | ||||||
|  |                     match chars.next() { | ||||||
|  |                         None => String::new(), | ||||||
|  |                         Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(), | ||||||
|  |                     } | ||||||
|  |                 }) | ||||||
|  |                 .collect::<Vec<_>>() | ||||||
|  |                 .join(" ") | ||||||
|  |         } else if parts.len() > 1 { | ||||||
|  |             // If the last part is empty or "calendars", try the second to last | ||||||
|  |             extract_calendar_name(&parts[..parts.len()-1].join("/")) | ||||||
|  |         } else { | ||||||
|  |             "Calendar".to_string() | ||||||
|  |         } | ||||||
|  |     } else { | ||||||
|  |         "Calendar".to_string() | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
| // Helper functions | // Helper functions | ||||||
| fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> { | fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> { | ||||||
|     if let Some(auth_header) = headers.get("authorization") { |     if let Some(auth_header) = headers.get("authorization") { | ||||||
|   | |||||||
| @@ -37,6 +37,7 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> { | |||||||
|         .route("/api/health", get(health_check)) |         .route("/api/health", get(health_check)) | ||||||
|         .route("/api/auth/login", post(handlers::login)) |         .route("/api/auth/login", post(handlers::login)) | ||||||
|         .route("/api/auth/verify", get(handlers::verify_token)) |         .route("/api/auth/verify", get(handlers::verify_token)) | ||||||
|  |         .route("/api/user/info", get(handlers::get_user_info)) | ||||||
|         .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( | ||||||
|   | |||||||
| @@ -20,6 +20,19 @@ pub struct AuthResponse { | |||||||
|     pub server_url: String, |     pub server_url: String, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize)] | ||||||
|  | pub struct UserInfo { | ||||||
|  |     pub username: String, | ||||||
|  |     pub server_url: String, | ||||||
|  |     pub calendars: Vec<CalendarInfo>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize)] | ||||||
|  | pub struct CalendarInfo { | ||||||
|  |     pub path: String, | ||||||
|  |     pub display_name: String, | ||||||
|  | } | ||||||
|  |  | ||||||
| // Error handling | // Error handling | ||||||
| #[derive(Debug)] | #[derive(Debug)] | ||||||
| pub enum ApiError { | pub enum ApiError { | ||||||
|   | |||||||
							
								
								
									
										215
									
								
								src/app.rs
									
									
									
									
									
								
							
							
						
						
									
										215
									
								
								src/app.rs
									
									
									
									
									
								
							| @@ -2,7 +2,7 @@ use yew::prelude::*; | |||||||
| use yew_router::prelude::*; | use yew_router::prelude::*; | ||||||
| use gloo_storage::{LocalStorage, Storage}; | use gloo_storage::{LocalStorage, Storage}; | ||||||
| use crate::components::{Login, Calendar}; | use crate::components::{Login, Calendar}; | ||||||
| use crate::services::{CalendarService, CalendarEvent}; | use crate::services::{CalendarService, CalendarEvent, UserInfo, CalendarInfo}; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| use chrono::{Local, NaiveDate, Datelike}; | use chrono::{Local, NaiveDate, Datelike}; | ||||||
|  |  | ||||||
| @@ -22,6 +22,8 @@ pub fn App() -> Html { | |||||||
|         LocalStorage::get("auth_token").ok() |         LocalStorage::get("auth_token").ok() | ||||||
|     }); |     }); | ||||||
|      |      | ||||||
|  |     let user_info = use_state(|| -> Option<UserInfo> { None }); | ||||||
|  |  | ||||||
|     let on_login = { |     let on_login = { | ||||||
|         let auth_token = auth_token.clone(); |         let auth_token = auth_token.clone(); | ||||||
|         Callback::from(move |token: String| { |         Callback::from(move |token: String| { | ||||||
| @@ -31,65 +33,180 @@ pub fn App() -> Html { | |||||||
|  |  | ||||||
|     let on_logout = { |     let on_logout = { | ||||||
|         let auth_token = auth_token.clone(); |         let auth_token = auth_token.clone(); | ||||||
|  |         let user_info = user_info.clone(); | ||||||
|         Callback::from(move |_| { |         Callback::from(move |_| { | ||||||
|             let _ = LocalStorage::delete("auth_token"); |             let _ = LocalStorage::delete("auth_token"); | ||||||
|             auth_token.set(None); |             auth_token.set(None); | ||||||
|  |             user_info.set(None); | ||||||
|         }) |         }) | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|  |     // Fetch user info when token is available | ||||||
|  |     { | ||||||
|  |         let user_info = user_info.clone(); | ||||||
|  |         let auth_token = auth_token.clone(); | ||||||
|  |          | ||||||
|  |         use_effect_with((*auth_token).clone(), move |token| { | ||||||
|  |             if let Some(token) = token { | ||||||
|  |                 let user_info = user_info.clone(); | ||||||
|  |                 let token = token.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() | ||||||
|  |                     }; | ||||||
|  |                      | ||||||
|  |                     if !password.is_empty() { | ||||||
|  |                         match calendar_service.fetch_user_info(&token, &password).await { | ||||||
|  |                             Ok(info) => { | ||||||
|  |                                 user_info.set(Some(info)); | ||||||
|  |                             } | ||||||
|  |                             Err(err) => { | ||||||
|  |                                 web_sys::console::log_1(&format!("Failed to fetch user info: {}", err).into()); | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 }); | ||||||
|  |             } else { | ||||||
|  |                 user_info.set(None); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             || () | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     html! { |     html! { | ||||||
|         <BrowserRouter> |         <BrowserRouter> | ||||||
|             <div class="app"> |             <div class="app"> | ||||||
|                 <header class="app-header"> |                 { | ||||||
|                     <h1>{"Calendar App"}</h1> |                     if auth_token.is_some() { | ||||||
|                     { |                         html! { | ||||||
|                         if auth_token.is_some() { |                             <> | ||||||
|                             html! { |                                 <aside class="app-sidebar"> | ||||||
|                                 <nav> |                                     <div class="sidebar-header"> | ||||||
|                                     <Link<Route> to={Route::Calendar}>{"Calendar"}</Link<Route>> |                                         <h1>{"Calendar App"}</h1> | ||||||
|                                     <button onclick={on_logout} class="logout-button">{"Logout"}</button> |                                         { | ||||||
|                                 </nav> |                                             if let Some(ref info) = *user_info { | ||||||
|                             } |                                                 html! { | ||||||
|                         } else { |                                                     <div class="user-info"> | ||||||
|                             html! { |                                                         <div class="username">{&info.username}</div> | ||||||
|                                 <nav> |                                                         <div class="server-url">{&info.server_url}</div> | ||||||
|                                     <Link<Route> to={Route::Login}>{"Login"}</Link<Route>> |                                                     </div> | ||||||
|                                 </nav> |                                                 } | ||||||
|                             } |                                             } 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) = *user_info { | ||||||
|  |                                             if !info.calendars.is_empty() { | ||||||
|  |                                                 html! { | ||||||
|  |                                                     <div class="calendar-list"> | ||||||
|  |                                                         <h3>{"My Calendars"}</h3> | ||||||
|  |                                                         <ul> | ||||||
|  |                                                             { | ||||||
|  |                                                                 info.calendars.iter().map(|cal| { | ||||||
|  |                                                                     html! { | ||||||
|  |                                                                         <li key={cal.path.clone()}> | ||||||
|  |                                                                             <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={on_logout} class="logout-button">{"Logout"}</button> | ||||||
|  |                                     </div> | ||||||
|  |                                 </aside> | ||||||
|  |                                 <main class="app-main"> | ||||||
|  |                                     <Switch<Route> render={move |route| { | ||||||
|  |                                         let auth_token = (*auth_token).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 /> } | ||||||
|  |                                                 } else { | ||||||
|  |                                                     html! { <Redirect<Route> to={Route::Login}/> } | ||||||
|  |                                                 } | ||||||
|  |                                             } | ||||||
|  |                                         } | ||||||
|  |                                     }} /> | ||||||
|  |                                 </main> | ||||||
|  |                             </> | ||||||
|  |                         } | ||||||
|  |                     } else { | ||||||
|  |                         html! { | ||||||
|  |                             <div class="login-layout"> | ||||||
|  |                                 <Switch<Route> render={move |route| { | ||||||
|  |                                     let auth_token = (*auth_token).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 /> } | ||||||
|  |                                             } else { | ||||||
|  |                                                 html! { <Redirect<Route> to={Route::Login}/> } | ||||||
|  |                                             } | ||||||
|  |                                         } | ||||||
|  |                                     } | ||||||
|  |                                 }} /> | ||||||
|  |                             </div> | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 </header> |                 } | ||||||
|  |  | ||||||
|                 <main class="app-main"> |  | ||||||
|                     <Switch<Route> render={move |route| { |  | ||||||
|                         let auth_token = (*auth_token).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 /> } |  | ||||||
|                                 } else { |  | ||||||
|                                     html! { <Redirect<Route> to={Route::Login}/> } |  | ||||||
|                                 } |  | ||||||
|                             } |  | ||||||
|                         } |  | ||||||
|                     }} /> |  | ||||||
|                 </main> |  | ||||||
|             </div> |             </div> | ||||||
|         </BrowserRouter> |         </BrowserRouter> | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -25,6 +25,19 @@ impl Default for ReminderAction { | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||||
|  | pub struct UserInfo { | ||||||
|  |     pub username: String, | ||||||
|  |     pub server_url: String, | ||||||
|  |     pub calendars: Vec<CalendarInfo>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||||
|  | pub struct CalendarInfo { | ||||||
|  |     pub path: String, | ||||||
|  |     pub display_name: String, | ||||||
|  | } | ||||||
|  |  | ||||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||||
| pub struct CalendarEvent { | pub struct CalendarEvent { | ||||||
|     pub uid: String, |     pub uid: String, | ||||||
| @@ -135,6 +148,48 @@ impl CalendarService { | |||||||
|         Self { base_url } |         Self { base_url } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /// Fetch user info including available calendars | ||||||
|  |     pub async fn fetch_user_info(&self, token: &str, password: &str) -> Result<UserInfo, String> { | ||||||
|  |         let window = web_sys::window().ok_or("No global window exists")?; | ||||||
|  |          | ||||||
|  |         let opts = RequestInit::new(); | ||||||
|  |         opts.set_method("GET"); | ||||||
|  |         opts.set_mode(RequestMode::Cors); | ||||||
|  |  | ||||||
|  |         let url = format!("{}/user/info", 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))?; | ||||||
|  |  | ||||||
|  |         let resp_value = JsFuture::from(window.fetch_with_request(&request)) | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| format!("Network request failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let resp: Response = resp_value.dyn_into() | ||||||
|  |             .map_err(|e| format!("Response cast failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let text = JsFuture::from(resp.text() | ||||||
|  |             .map_err(|e| format!("Text extraction failed: {:?}", e))?) | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| format!("Text promise failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let text_string = text.as_string() | ||||||
|  |             .ok_or("Response text is not a string")?; | ||||||
|  |  | ||||||
|  |         if resp.ok() { | ||||||
|  |             let user_info: UserInfo = serde_json::from_str(&text_string) | ||||||
|  |                 .map_err(|e| format!("JSON parsing failed: {}", e))?; | ||||||
|  |             Ok(user_info) | ||||||
|  |         } else { | ||||||
|  |             Err(format!("Request failed with status {}: {}", resp.status(), text_string)) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /// Fetch calendar events for a specific month |     /// Fetch calendar events for a specific month | ||||||
|     pub async fn fetch_events_for_month( |     pub async fn fetch_events_for_month( | ||||||
|         &self,  |         &self,  | ||||||
|   | |||||||
| @@ -1,3 +1,3 @@ | |||||||
| pub mod calendar_service; | pub mod calendar_service; | ||||||
|  |  | ||||||
| pub use calendar_service::{CalendarService, CalendarEvent, EventReminder, ReminderAction}; | pub use calendar_service::{CalendarService, CalendarEvent, EventReminder, ReminderAction, UserInfo, CalendarInfo}; | ||||||
							
								
								
									
										263
									
								
								styles.css
									
									
									
									
									
								
							
							
						
						
									
										263
									
								
								styles.css
									
									
									
									
									
								
							| @@ -14,48 +14,162 @@ body { | |||||||
| .app { | .app { | ||||||
|     min-height: 100vh; |     min-height: 100vh; | ||||||
|     display: flex; |     display: flex; | ||||||
|     flex-direction: column; |     flex-direction: row; | ||||||
| } | } | ||||||
|  |  | ||||||
| .app-header { | .login-layout { | ||||||
|     background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |     min-height: 100vh; | ||||||
|     color: white; |     display: flex; | ||||||
|     padding: 1rem 2rem; |     flex-direction: column; | ||||||
|  |     width: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Sidebar Styles */ | ||||||
|  | .app-sidebar { | ||||||
|  |     width: 280px; | ||||||
|  |     min-height: 100vh; | ||||||
|  |     background: linear-gradient(180deg, #667eea 0%, #764ba2 100%); | ||||||
|  |     color: white; | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  |     box-shadow: 2px 0 8px rgba(0,0,0,0.1); | ||||||
|  |     position: fixed; | ||||||
|  |     left: 0; | ||||||
|  |     top: 0; | ||||||
|  |     z-index: 100; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .sidebar-header { | ||||||
|  |     padding: 2rem 1.5rem 1.5rem; | ||||||
|  |     border-bottom: 1px solid rgba(255,255,255,0.2); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .sidebar-header h1 { | ||||||
|  |     margin: 0 0 1rem 0; | ||||||
|  |     font-size: 1.8rem; | ||||||
|  |     font-weight: 600; | ||||||
|  |     text-align: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .user-info { | ||||||
|  |     text-align: center; | ||||||
|  |     margin-bottom: 0.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .user-info .username { | ||||||
|  |     font-size: 1.1rem; | ||||||
|  |     font-weight: 600; | ||||||
|  |     color: white; | ||||||
|  |     margin-bottom: 0.25rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .user-info .server-url { | ||||||
|  |     font-size: 0.8rem; | ||||||
|  |     color: rgba(255,255,255,0.7); | ||||||
|  |     word-break: break-all; | ||||||
|  |     line-height: 1.2; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .user-info.loading { | ||||||
|  |     font-size: 0.9rem; | ||||||
|  |     color: rgba(255,255,255,0.6); | ||||||
|  |     font-style: italic; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .sidebar-nav { | ||||||
|  |     padding: 1rem; | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  |     gap: 0.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .sidebar-nav .nav-link { | ||||||
|  |     color: white; | ||||||
|  |     text-decoration: none; | ||||||
|  |     padding: 0.75rem 1rem; | ||||||
|  |     border-radius: 8px; | ||||||
|  |     transition: all 0.2s; | ||||||
|  |     font-weight: 500; | ||||||
|     display: flex; |     display: flex; | ||||||
|     justify-content: space-between; |  | ||||||
|     align-items: center; |     align-items: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .sidebar-nav .nav-link:hover { | ||||||
|  |     background-color: rgba(255,255,255,0.15); | ||||||
|  |     transform: translateX(4px); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .sidebar-nav .nav-link.active { | ||||||
|  |     background-color: rgba(255,255,255,0.2); | ||||||
|     box-shadow: 0 2px 4px rgba(0,0,0,0.1); |     box-shadow: 0 2px 4px rgba(0,0,0,0.1); | ||||||
| } | } | ||||||
|  |  | ||||||
| .app-header h1 { | .calendar-list { | ||||||
|     margin: 0; |     flex: 1; | ||||||
|     font-size: 1.8rem; |     padding: 1rem; | ||||||
|  |     border-top: 1px solid rgba(255,255,255,0.1); | ||||||
| } | } | ||||||
|  |  | ||||||
| .app-header nav { | .calendar-list h3 { | ||||||
|     display: flex; |  | ||||||
|     gap: 1rem; |  | ||||||
|     align-items: center; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .app-header nav a { |  | ||||||
|     color: white; |     color: white; | ||||||
|     text-decoration: none; |     font-size: 1rem; | ||||||
|     padding: 0.5rem 1rem; |     font-weight: 600; | ||||||
|     border-radius: 4px; |     margin: 0 0 1rem 0; | ||||||
|     transition: background-color 0.2s; |     text-transform: uppercase; | ||||||
|  |     letter-spacing: 0.5px; | ||||||
| } | } | ||||||
|  |  | ||||||
| .app-header nav a:hover { | .calendar-list ul { | ||||||
|     background-color: rgba(255,255,255,0.2); |     list-style: none; | ||||||
|  |     margin: 0; | ||||||
|  |     padding: 0; | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  |     gap: 0.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-list li { | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     padding: 0.5rem 0.75rem; | ||||||
|  |     background: rgba(255,255,255,0.1); | ||||||
|  |     border-radius: 6px; | ||||||
|  |     transition: all 0.2s; | ||||||
|  |     cursor: pointer; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-list li:hover { | ||||||
|  |     background: rgba(255,255,255,0.15); | ||||||
|  |     transform: translateX(2px); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar-name { | ||||||
|  |     color: white; | ||||||
|  |     font-size: 0.9rem; | ||||||
|  |     font-weight: 500; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .no-calendars { | ||||||
|  |     padding: 1rem; | ||||||
|  |     text-align: center; | ||||||
|  |     color: rgba(255,255,255,0.6); | ||||||
|  |     font-size: 0.9rem; | ||||||
|  |     font-style: italic; | ||||||
|  |     border-top: 1px solid rgba(255,255,255,0.1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .sidebar-footer { | ||||||
|  |     padding: 1rem; | ||||||
|  |     border-top: 1px solid rgba(255,255,255,0.1); | ||||||
| } | } | ||||||
|  |  | ||||||
| .app-main { | .app-main { | ||||||
|     flex: 1; |     flex: 1; | ||||||
|  |     margin-left: 280px; | ||||||
|     padding: 2rem; |     padding: 2rem; | ||||||
|     max-width: 1200px; |     max-width: calc(100% - 280px); | ||||||
|     margin: 0 auto; |     width: calc(100% - 280px); | ||||||
|     width: 100%; |     box-sizing: border-box; | ||||||
| } | } | ||||||
|  |  | ||||||
| /* Authentication Forms */ | /* Authentication Forms */ | ||||||
| @@ -161,22 +275,25 @@ body { | |||||||
| } | } | ||||||
|  |  | ||||||
| .logout-button { | .logout-button { | ||||||
|     background: rgba(255,255,255,0.2); |     background: rgba(255,255,255,0.1); | ||||||
|     border: 1px solid rgba(255,255,255,0.3); |     border: 1px solid rgba(255,255,255,0.2); | ||||||
|     color: white; |     color: white; | ||||||
|     padding: 0.5rem 1rem; |     padding: 0.75rem 1rem; | ||||||
|     border-radius: 4px; |     border-radius: 8px; | ||||||
|     cursor: pointer; |     cursor: pointer; | ||||||
|     transition: background-color 0.2s; |     transition: all 0.2s; | ||||||
|  |     font-weight: 500; | ||||||
|  |     width: 100%; | ||||||
| } | } | ||||||
|  |  | ||||||
| .logout-button:hover { | .logout-button:hover { | ||||||
|     background: rgba(255,255,255,0.3); |     background: rgba(255,255,255,0.2); | ||||||
|  |     transform: translateY(-1px); | ||||||
| } | } | ||||||
|  |  | ||||||
| /* Calendar View */ | /* Calendar View */ | ||||||
| .calendar-view { | .calendar-view { | ||||||
|     height: calc(100vh - 140px); /* Full height minus header and padding */ |     height: calc(100vh - 4rem); /* Full height minus main padding */ | ||||||
|     display: flex; |     display: flex; | ||||||
|     flex-direction: column; |     flex-direction: column; | ||||||
| } | } | ||||||
| @@ -492,6 +609,68 @@ body { | |||||||
|  |  | ||||||
| /* Responsive Design */ | /* Responsive Design */ | ||||||
| @media (max-width: 768px) { | @media (max-width: 768px) { | ||||||
|  |     .app-sidebar { | ||||||
|  |         width: 100%; | ||||||
|  |         height: auto; | ||||||
|  |         min-height: unset; | ||||||
|  |         position: relative; | ||||||
|  |         flex-direction: row; | ||||||
|  |         padding: 1rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .sidebar-header { | ||||||
|  |         padding: 0; | ||||||
|  |         border-bottom: none; | ||||||
|  |         border-right: 1px solid rgba(255,255,255,0.2); | ||||||
|  |         margin-right: 1rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .sidebar-header h1 { | ||||||
|  |         font-size: 1.4rem; | ||||||
|  |         text-align: left; | ||||||
|  |         margin: 0; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .user-info { | ||||||
|  |         display: none; /* Hide user info on mobile to save space */ | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .sidebar-nav { | ||||||
|  |         flex-direction: row; | ||||||
|  |         padding: 0; | ||||||
|  |         align-items: center; | ||||||
|  |         gap: 1rem; | ||||||
|  |         flex: 1; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .sidebar-nav .nav-link { | ||||||
|  |         padding: 0.5rem 0.75rem; | ||||||
|  |         font-size: 0.9rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .logout-button { | ||||||
|  |         margin: 0; | ||||||
|  |         padding: 0.5rem 0.75rem; | ||||||
|  |         font-size: 0.9rem; | ||||||
|  |         width: auto; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .calendar-list { | ||||||
|  |         display: none; /* Hide calendar list on mobile */ | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .sidebar-footer { | ||||||
|  |         padding: 0; | ||||||
|  |         border-top: none; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .app-main { | ||||||
|  |         margin-left: 0; | ||||||
|  |         max-width: 100%; | ||||||
|  |         width: 100%; | ||||||
|  |         padding: 1rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     .calendar-header { |     .calendar-header { | ||||||
|         padding: 1rem; |         padding: 1rem; | ||||||
|     } |     } | ||||||
| @@ -520,12 +699,8 @@ body { | |||||||
|         font-size: 1rem; |         font-size: 1rem; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     .app-main { |  | ||||||
|         padding: 1rem; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     .calendar-view { |     .calendar-view { | ||||||
|         height: calc(100vh - 120px); |         height: calc(100vh - 8rem); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -569,20 +744,6 @@ body { | |||||||
|         font-size: 0.9rem; |         font-size: 0.9rem; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     .app-header { |  | ||||||
|         flex-direction: column; |  | ||||||
|         text-align: center; |  | ||||||
|         padding: 1rem; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     .app-header nav { |  | ||||||
|         margin-top: 1rem; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     .app-main { |  | ||||||
|         padding: 1rem; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     .login-form, .register-form { |     .login-form, .register-form { | ||||||
|         padding: 1.5rem; |         padding: 1.5rem; | ||||||
|     } |     } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user