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};
|
||||||
|
|
||||||
@@ -21,6 +21,8 @@ pub fn App() -> Html {
|
|||||||
let auth_token = use_state(|| -> Option<String> {
|
let auth_token = use_state(|| -> Option<String> {
|
||||||
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();
|
||||||
@@ -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