From 7c83a4522ca7e3846d1707986b853853bbed411e Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Thu, 28 Aug 2025 19:58:02 -0400 Subject: [PATCH] Add user information and calendar list to sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend changes: - Add /api/user/info endpoint to fetch user details and calendar list - Create UserInfo and CalendarInfo models for API responses - Filter out generic calendar collections from sidebar display - Extract readable calendar names with proper title case formatting Frontend changes: - Fetch and display user info (username, server URL) in sidebar header - Show list of user's calendars with hover effects and styling - Add loading states and error handling for user info - Reorganize sidebar layout: header, navigation, calendar list, logout Styling: - Enhanced sidebar with user info section and calendar list - Responsive design hides user info and calendar list on mobile - Improved logout button positioning in sidebar footer - Professional styling with proper spacing and visual hierarchy 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- backend/src/handlers.rs | 78 +++++++++++++++++++++- backend/src/lib.rs | 1 + backend/src/models.rs | 13 ++++ src/app.rs | 91 +++++++++++++++++++++++++- src/services/calendar_service.rs | 55 ++++++++++++++++ src/services/mod.rs | 2 +- styles.css | 108 +++++++++++++++++++++++++++++-- 7 files changed, 338 insertions(+), 10 deletions(-) diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index b802101..6777af3 100644 --- a/backend/src/handlers.rs +++ b/backend/src/handlers.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use std::sync::Arc; use chrono::Datelike; -use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError}}; +use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo}}; use crate::calendar::{CalDAVClient, CalendarEvent}; #[derive(Deserialize)] @@ -116,6 +116,82 @@ pub async fn verify_token( }))) } +pub async fn get_user_info( + State(state): State>, + headers: HeaderMap, +) -> Result, 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 = 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::() + chars.as_str(), + } + }) + .collect::>() + .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 fn extract_bearer_token(headers: &HeaderMap) -> Result { if let Some(auth_header) = headers.get("authorization") { diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 06e9b83..3a5b337 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -37,6 +37,7 @@ pub async fn run_server() -> Result<(), Box> { .route("/api/health", get(health_check)) .route("/api/auth/login", post(handlers::login)) .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/:uid", get(handlers::refresh_event)) .layer( diff --git a/backend/src/models.rs b/backend/src/models.rs index 5ef83a0..64b3354 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -20,6 +20,19 @@ pub struct AuthResponse { pub server_url: String, } +#[derive(Debug, Serialize)] +pub struct UserInfo { + pub username: String, + pub server_url: String, + pub calendars: Vec, +} + +#[derive(Debug, Serialize)] +pub struct CalendarInfo { + pub path: String, + pub display_name: String, +} + // Error handling #[derive(Debug)] pub enum ApiError { diff --git a/src/app.rs b/src/app.rs index e31f739..c098b19 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,7 +2,7 @@ use yew::prelude::*; use yew_router::prelude::*; use gloo_storage::{LocalStorage, Storage}; use crate::components::{Login, Calendar}; -use crate::services::{CalendarService, CalendarEvent}; +use crate::services::{CalendarService, CalendarEvent, UserInfo, CalendarInfo}; use std::collections::HashMap; use chrono::{Local, NaiveDate, Datelike}; @@ -21,6 +21,8 @@ pub fn App() -> Html { let auth_token = use_state(|| -> Option { LocalStorage::get("auth_token").ok() }); + + let user_info = use_state(|| -> Option { None }); let on_login = { let auth_token = auth_token.clone(); @@ -31,12 +33,57 @@ pub fn App() -> Html { let on_logout = { let auth_token = auth_token.clone(); + let user_info = user_info.clone(); Callback::from(move |_| { let _ = LocalStorage::delete("auth_token"); 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::("caldav_credentials") { + if let Ok(credentials) = serde_json::from_str::(&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! {
@@ -47,11 +94,51 @@ pub fn App() -> Html {
render={move |route| { diff --git a/src/services/calendar_service.rs b/src/services/calendar_service.rs index 70aab13..20b9ee9 100644 --- a/src/services/calendar_service.rs +++ b/src/services/calendar_service.rs @@ -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, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct CalendarInfo { + pub path: String, + pub display_name: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct CalendarEvent { pub uid: String, @@ -135,6 +148,48 @@ impl CalendarService { Self { base_url } } + /// Fetch user info including available calendars + pub async fn fetch_user_info(&self, token: &str, password: &str) -> Result { + 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 pub async fn fetch_events_for_month( &self, diff --git a/src/services/mod.rs b/src/services/mod.rs index b98a1b3..e8b465a 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,3 +1,3 @@ pub mod calendar_service; -pub use calendar_service::{CalendarService, CalendarEvent, EventReminder, ReminderAction}; \ No newline at end of file +pub use calendar_service::{CalendarService, CalendarEvent, EventReminder, ReminderAction, UserInfo, CalendarInfo}; \ No newline at end of file diff --git a/styles.css b/styles.css index 6c31969..da9b2fc 100644 --- a/styles.css +++ b/styles.css @@ -45,15 +45,39 @@ body { } .sidebar-header h1 { - margin: 0; + 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 { - flex: 1; - padding: 1.5rem 1rem; + padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem; @@ -80,6 +104,65 @@ body { box-shadow: 0 2px 4px rgba(0,0,0,0.1); } +.calendar-list { + flex: 1; + padding: 1rem; + border-top: 1px solid rgba(255,255,255,0.1); +} + +.calendar-list h3 { + color: white; + font-size: 1rem; + font-weight: 600; + margin: 0 0 1rem 0; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.calendar-list ul { + 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 { flex: 1; margin-left: 280px; @@ -200,13 +283,12 @@ body { cursor: pointer; transition: all 0.2s; font-weight: 500; - margin-top: auto; - margin-bottom: 1rem; + width: 100%; } .logout-button:hover { background: rgba(255,255,255,0.2); - transform: translateX(4px); + transform: translateY(-1px); } /* Calendar View */ @@ -549,6 +631,10 @@ body { margin: 0; } + .user-info { + display: none; /* Hide user info on mobile to save space */ + } + .sidebar-nav { flex-direction: row; padding: 0; @@ -566,6 +652,16 @@ body { 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 {