Add user information and calendar list to sidebar

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 <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-28 19:58:02 -04:00
parent 8a0d2286dc
commit 7c83a4522c
7 changed files with 338 additions and 10 deletions

View File

@@ -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<String> {
LocalStorage::get("auth_token").ok()
});
let user_info = use_state(|| -> Option<UserInfo> { 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::<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! {
<BrowserRouter>
<div class="app">
@@ -47,11 +94,51 @@ pub fn App() -> Html {
<aside class="app-sidebar">
<div class="sidebar-header">
<h1>{"Calendar App"}</h1>
{
if let Some(ref info) = *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>>
<button onclick={on_logout} class="logout-button">{"Logout"}</button>
</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| {

View File

@@ -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)]
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<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
pub async fn fetch_events_for_month(
&self,

View File

@@ -1,3 +1,3 @@
pub mod calendar_service;
pub use calendar_service::{CalendarService, CalendarEvent, EventReminder, ReminderAction};
pub use calendar_service::{CalendarService, CalendarEvent, EventReminder, ReminderAction, UserInfo, CalendarInfo};