Compare commits
3 Commits
7c83a4522c
...
f9c87369e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9c87369e5 | ||
|
|
f94d057f81 | ||
|
|
5d519fd875 |
@@ -14,6 +14,3 @@ address = "127.0.0.1"
|
|||||||
port = 8080
|
port = 8080
|
||||||
open = false
|
open = false
|
||||||
|
|
||||||
[[copy]]
|
|
||||||
from = "styles.css"
|
|
||||||
to = "dist/"
|
|
||||||
@@ -61,6 +61,9 @@ pub struct CalendarEvent {
|
|||||||
|
|
||||||
/// URL/href of this event on the CalDAV server
|
/// URL/href of this event on the CalDAV server
|
||||||
pub href: Option<String>,
|
pub href: Option<String>,
|
||||||
|
|
||||||
|
/// Calendar path this event belongs to
|
||||||
|
pub calendar_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Event status enumeration
|
/// Event status enumeration
|
||||||
@@ -182,11 +185,11 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let body = response.text().await.map_err(CalDAVError::RequestError)?;
|
let body = response.text().await.map_err(CalDAVError::RequestError)?;
|
||||||
self.parse_calendar_response(&body)
|
self.parse_calendar_response(&body, calendar_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse CalDAV XML response containing calendar data
|
/// Parse CalDAV XML response containing calendar data
|
||||||
fn parse_calendar_response(&self, xml_response: &str) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
fn parse_calendar_response(&self, xml_response: &str, calendar_path: &str) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
|
||||||
// Extract calendar data from XML response
|
// Extract calendar data from XML response
|
||||||
@@ -198,6 +201,7 @@ impl CalDAVClient {
|
|||||||
for mut event in parsed_events {
|
for mut event in parsed_events {
|
||||||
event.etag = calendar_data.etag.clone();
|
event.etag = calendar_data.etag.clone();
|
||||||
event.href = calendar_data.href.clone();
|
event.href = calendar_data.href.clone();
|
||||||
|
event.calendar_path = Some(calendar_path.to_string());
|
||||||
events.push(event);
|
events.push(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,6 +381,7 @@ impl CalDAVClient {
|
|||||||
reminders: self.parse_alarms(&event)?,
|
reminders: self.parse_alarms(&event)?,
|
||||||
etag: None, // Set by caller
|
etag: None, // Set by caller
|
||||||
href: None, // Set by caller
|
href: None, // Set by caller
|
||||||
|
calendar_path: None, // Set by caller
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,6 +590,74 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
Err(CalDAVError::ParseError(format!("Unable to parse datetime: {}", datetime_str)))
|
Err(CalDAVError::ParseError(format!("Unable to parse datetime: {}", datetime_str)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new calendar on the CalDAV server using MKCALENDAR
|
||||||
|
pub async fn create_calendar(&self, name: &str, description: Option<&str>, color: Option<&str>) -> Result<(), CalDAVError> {
|
||||||
|
// Sanitize calendar name for URL path
|
||||||
|
let calendar_id = name
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||||
|
.collect::<String>()
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
|
let calendar_path = format!("/calendars/{}/{}/", self.config.username, calendar_id);
|
||||||
|
let full_url = format!("{}{}", self.config.server_url.trim_end_matches('/'), calendar_path);
|
||||||
|
|
||||||
|
// Build color property if provided
|
||||||
|
let color_property = if let Some(color) = color {
|
||||||
|
format!(r#"<ic:calendar-color xmlns:ic="http://apple.com/ns/ical/">{}</ic:calendar-color>"#, color)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let description_property = if let Some(desc) = description {
|
||||||
|
format!(r#"<c:calendar-description xmlns:c="urn:ietf:params:xml:ns:caldav">{}</c:calendar-description>"#, desc)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the MKCALENDAR request body
|
||||||
|
let mkcalendar_body = format!(
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:ic="http://apple.com/ns/ical/">
|
||||||
|
<d:set>
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname>{}</d:displayname>
|
||||||
|
<c:supported-calendar-component-set>
|
||||||
|
<c:comp name="VEVENT"/>
|
||||||
|
</c:supported-calendar-component-set>
|
||||||
|
{}
|
||||||
|
{}
|
||||||
|
</d:prop>
|
||||||
|
</d:set>
|
||||||
|
</c:mkcalendar>"#,
|
||||||
|
name, color_property, description_property
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("Creating calendar at: {}", full_url);
|
||||||
|
println!("MKCALENDAR body: {}", mkcalendar_body);
|
||||||
|
|
||||||
|
let response = self.http_client
|
||||||
|
.request(reqwest::Method::from_bytes(b"MKCALENDAR").unwrap(), &full_url)
|
||||||
|
.header("Content-Type", "application/xml; charset=utf-8")
|
||||||
|
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
||||||
|
.body(mkcalendar_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| CalDAVError::ParseError(e.to_string()))?;
|
||||||
|
|
||||||
|
println!("Calendar creation response status: {}", response.status());
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
println!("✅ Calendar created successfully at {}", calendar_path);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let status = response.status();
|
||||||
|
let error_body = response.text().await.unwrap_or_default();
|
||||||
|
println!("❌ Calendar creation 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
|
||||||
@@ -683,6 +756,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
/// Test parsing a sample iCal event
|
/// Test parsing a sample iCal event
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_ical_event() {
|
fn test_parse_ical_event() {
|
||||||
@@ -775,4 +850,3 @@ END:VCALENDAR"#;
|
|||||||
|
|
||||||
println!("✓ Event enum tests passed!");
|
println!("✓ Event enum tests passed!");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
@@ -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}};
|
use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo, CreateCalendarRequest, CreateCalendarResponse}};
|
||||||
use crate::calendar::{CalDAVClient, CalendarEvent};
|
use crate::calendar::{CalDAVClient, CalendarEvent};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -145,8 +145,9 @@ pub async fn get_user_info(
|
|||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(CalendarInfo {
|
Some(CalendarInfo {
|
||||||
path,
|
path: path.clone(),
|
||||||
display_name,
|
display_name,
|
||||||
|
color: generate_calendar_color(&path),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}).collect();
|
}).collect();
|
||||||
@@ -158,6 +159,39 @@ pub async fn get_user_info(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to generate a consistent color for a calendar based on its path
|
||||||
|
fn generate_calendar_color(path: &str) -> String {
|
||||||
|
// Predefined set of attractive, accessible colors for calendars
|
||||||
|
let colors = [
|
||||||
|
"#3B82F6", // Blue
|
||||||
|
"#10B981", // Emerald
|
||||||
|
"#F59E0B", // Amber
|
||||||
|
"#EF4444", // Red
|
||||||
|
"#8B5CF6", // Violet
|
||||||
|
"#06B6D4", // Cyan
|
||||||
|
"#84CC16", // Lime
|
||||||
|
"#F97316", // Orange
|
||||||
|
"#EC4899", // Pink
|
||||||
|
"#6366F1", // Indigo
|
||||||
|
"#14B8A6", // Teal
|
||||||
|
"#F3B806", // Yellow
|
||||||
|
"#8B5A2B", // Brown
|
||||||
|
"#6B7280", // Gray
|
||||||
|
"#DC2626", // Red-600
|
||||||
|
"#7C3AED", // Violet-600
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create a simple hash from the path to ensure consistent color assignment
|
||||||
|
let mut hash: u32 = 0;
|
||||||
|
for byte in path.bytes() {
|
||||||
|
hash = hash.wrapping_mul(31).wrapping_add(byte as u32);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the hash to select a color from our palette
|
||||||
|
let color_index = (hash as usize) % colors.len();
|
||||||
|
colors[color_index].to_string()
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to extract a readable calendar name from path
|
// Helper function to extract a readable calendar name from path
|
||||||
fn extract_calendar_name(path: &str) -> String {
|
fn extract_calendar_name(path: &str) -> String {
|
||||||
// Extract the last meaningful part of the path
|
// Extract the last meaningful part of the path
|
||||||
@@ -219,3 +253,43 @@ fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> {
|
|||||||
Err(ApiError::BadRequest("X-CalDAV-Password header required".to_string()))
|
Err(ApiError::BadRequest("X-CalDAV-Password header required".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn create_calendar(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(request): Json<CreateCalendarRequest>,
|
||||||
|
) -> Result<Json<CreateCalendarResponse>, ApiError> {
|
||||||
|
println!("📝 Create calendar request received: name='{}', description={:?}, color={:?}",
|
||||||
|
request.name, request.description, request.color);
|
||||||
|
|
||||||
|
// Extract and verify token
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if request.name.trim().is_empty() {
|
||||||
|
return Err(ApiError::BadRequest("Calendar name is required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.name.len() > 100 {
|
||||||
|
return Err(ApiError::BadRequest("Calendar name too long (max 100 characters)".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);
|
||||||
|
|
||||||
|
// Create the calendar
|
||||||
|
client.create_calendar(
|
||||||
|
&request.name,
|
||||||
|
request.description.as_deref(),
|
||||||
|
request.color.as_deref()
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to create calendar: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(CreateCalendarResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Calendar created successfully".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.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/user/info", get(handlers::get_user_info))
|
||||||
|
.route("/api/calendar/create", post(handlers::create_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(
|
||||||
|
|||||||
@@ -31,6 +31,20 @@ pub struct UserInfo {
|
|||||||
pub struct CalendarInfo {
|
pub struct CalendarInfo {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
|
pub color: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateCalendarRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateCalendarResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Calendar App</title>
|
<title>Calendar App</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link data-trunk rel="css" href="styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body></body>
|
<body></body>
|
||||||
</html>
|
</html>
|
||||||
195
src/app.rs
195
src/app.rs
@@ -1,8 +1,8 @@
|
|||||||
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};
|
use crate::components::{Login, Calendar, CreateCalendarModal};
|
||||||
use crate::services::{CalendarService, CalendarEvent, UserInfo, CalendarInfo};
|
use crate::services::{CalendarService, CalendarEvent, UserInfo};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use chrono::{Local, NaiveDate, Datelike};
|
use chrono::{Local, NaiveDate, Datelike};
|
||||||
|
|
||||||
@@ -23,6 +23,16 @@ 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 create_modal_open = use_state(|| false);
|
||||||
|
|
||||||
|
// Available colors for calendar customization
|
||||||
|
let available_colors = [
|
||||||
|
"#3B82F6", "#10B981", "#F59E0B", "#EF4444",
|
||||||
|
"#8B5CF6", "#06B6D4", "#84CC16", "#F97316",
|
||||||
|
"#EC4899", "#6366F1", "#14B8A6", "#F3B806",
|
||||||
|
"#8B5A2B", "#6B7280", "#DC2626", "#7C3AED"
|
||||||
|
];
|
||||||
|
|
||||||
let on_login = {
|
let on_login = {
|
||||||
let auth_token = auth_token.clone();
|
let auth_token = auth_token.clone();
|
||||||
@@ -67,7 +77,20 @@ 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(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_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) {
|
||||||
|
// Update colors with saved preferences
|
||||||
|
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));
|
user_info.set(Some(info));
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -84,9 +107,21 @@ pub fn App() -> Html {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let on_outside_click = {
|
||||||
|
let color_picker_open = color_picker_open.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
color_picker_open.set(None);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clone variables needed for the modal outside of the conditional blocks
|
||||||
|
let auth_token_for_modal = auth_token.clone();
|
||||||
|
let user_info_for_modal = user_info.clone();
|
||||||
|
let create_modal_open_for_modal = create_modal_open.clone();
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div class="app">
|
<div class="app" onclick={on_outside_click}>
|
||||||
{
|
{
|
||||||
if auth_token.is_some() {
|
if auth_token.is_some() {
|
||||||
html! {
|
html! {
|
||||||
@@ -119,8 +154,71 @@ pub fn App() -> Html {
|
|||||||
<ul>
|
<ul>
|
||||||
{
|
{
|
||||||
info.calendars.iter().map(|cal| {
|
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! {
|
html! {
|
||||||
<li key={cal.path.clone()}>
|
<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>
|
<span class="calendar-name">{&cal.display_name}</span>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -137,6 +235,12 @@ pub fn App() -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
<div class="sidebar-footer">
|
<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>
|
<button onclick={on_logout} class="logout-button">{"Logout"}</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -162,7 +266,7 @@ pub fn App() -> Html {
|
|||||||
}
|
}
|
||||||
Route::Calendar => {
|
Route::Calendar => {
|
||||||
if auth_token.is_some() {
|
if auth_token.is_some() {
|
||||||
html! { <CalendarView /> }
|
html! { <CalendarView user_info={(*user_info).clone()} /> }
|
||||||
} else {
|
} else {
|
||||||
html! { <Redirect<Route> to={Route::Login}/> }
|
html! { <Redirect<Route> to={Route::Login}/> }
|
||||||
}
|
}
|
||||||
@@ -196,7 +300,7 @@ pub fn App() -> Html {
|
|||||||
}
|
}
|
||||||
Route::Calendar => {
|
Route::Calendar => {
|
||||||
if auth_token.is_some() {
|
if auth_token.is_some() {
|
||||||
html! { <CalendarView /> }
|
html! { <CalendarView user_info={(*user_info).clone()} /> }
|
||||||
} else {
|
} else {
|
||||||
html! { <Redirect<Route> to={Route::Login}/> }
|
html! { <Redirect<Route> to={Route::Login}/> }
|
||||||
}
|
}
|
||||||
@@ -207,13 +311,86 @@ pub fn App() -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<CreateCalendarModal
|
||||||
|
is_open={*create_modal_open}
|
||||||
|
on_close={Callback::from({
|
||||||
|
let create_modal_open = create_modal_open_for_modal.clone();
|
||||||
|
move |_| create_modal_open.set(false)
|
||||||
|
})}
|
||||||
|
on_create={Callback::from({
|
||||||
|
let auth_token = auth_token_for_modal.clone();
|
||||||
|
let user_info = user_info_for_modal.clone();
|
||||||
|
let create_modal_open = create_modal_open_for_modal.clone();
|
||||||
|
move |(name, description, color): (String, Option<String>, Option<String>)| {
|
||||||
|
if let Some(token) = (*auth_token).clone() {
|
||||||
|
let user_info = user_info.clone();
|
||||||
|
let create_modal_open = create_modal_open.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.create_calendar(&token, &password, name, description, color).await {
|
||||||
|
Ok(_) => {
|
||||||
|
web_sys::console::log_1(&"Calendar created successfully!".into());
|
||||||
|
// Refresh user info to show the new calendar
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
web_sys::console::log_1(&format!("Failed to create calendar: {}", err).into());
|
||||||
|
// TODO: Show error to user
|
||||||
|
create_modal_open.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
available_colors={available_colors.iter().map(|c| c.to_string()).collect::<Vec<_>>()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct CalendarViewProps {
|
||||||
|
pub user_info: Option<UserInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
#[function_component]
|
#[function_component]
|
||||||
fn CalendarView() -> Html {
|
fn CalendarView(props: &CalendarViewProps) -> Html {
|
||||||
let events = use_state(|| HashMap::<NaiveDate, Vec<CalendarEvent>>::new());
|
let events = use_state(|| HashMap::<NaiveDate, Vec<CalendarEvent>>::new());
|
||||||
let loading = use_state(|| true);
|
let loading = use_state(|| true);
|
||||||
let error = use_state(|| None::<String>);
|
let error = use_state(|| None::<String>);
|
||||||
@@ -367,12 +544,12 @@ fn CalendarView() -> Html {
|
|||||||
html! {
|
html! {
|
||||||
<div class="calendar-error">
|
<div class="calendar-error">
|
||||||
<p>{format!("Error: {}", err)}</p>
|
<p>{format!("Error: {}", err)}</p>
|
||||||
<Calendar events={HashMap::new()} on_event_click={dummy_callback} refreshing_event_uid={(*refreshing_event).clone()} />
|
<Calendar events={HashMap::new()} on_event_click={dummy_callback} refreshing_event_uid={(*refreshing_event).clone()} user_info={props.user_info.clone()} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {
|
html! {
|
||||||
<Calendar events={(*events).clone()} on_event_click={on_event_click} refreshing_event_uid={(*refreshing_event).clone()} />
|
<Calendar events={(*events).clone()} on_event_click={on_event_click} refreshing_event_uid={(*refreshing_event).clone()} user_info={props.user_info.clone()} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use chrono::{Datelike, Local, NaiveDate, Duration, Weekday};
|
use chrono::{Datelike, Local, NaiveDate, Duration, Weekday};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use crate::services::calendar_service::CalendarEvent;
|
use crate::services::calendar_service::{CalendarEvent, UserInfo};
|
||||||
use crate::components::EventModal;
|
use crate::components::EventModal;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
@@ -11,6 +11,8 @@ pub struct CalendarProps {
|
|||||||
pub on_event_click: Callback<CalendarEvent>,
|
pub on_event_click: Callback<CalendarEvent>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub refreshing_event_uid: Option<String>,
|
pub refreshing_event_uid: Option<String>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub user_info: Option<UserInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component]
|
#[function_component]
|
||||||
@@ -20,6 +22,21 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let selected_day = use_state(|| today);
|
let selected_day = use_state(|| today);
|
||||||
let selected_event = use_state(|| None::<CalendarEvent>);
|
let selected_event = use_state(|| None::<CalendarEvent>);
|
||||||
|
|
||||||
|
// Helper function to get calendar color for an event
|
||||||
|
let get_event_color = |event: &CalendarEvent| -> String {
|
||||||
|
if let Some(user_info) = &props.user_info {
|
||||||
|
if let Some(calendar_path) = &event.calendar_path {
|
||||||
|
// Find the calendar that matches this event's path
|
||||||
|
if let Some(calendar) = user_info.calendars.iter()
|
||||||
|
.find(|cal| &cal.path == calendar_path) {
|
||||||
|
return calendar.color.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default color if no match found
|
||||||
|
"#3B82F6".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
let first_day_of_month = current_month.with_day(1).unwrap();
|
let first_day_of_month = current_month.with_day(1).unwrap();
|
||||||
let days_in_month = get_days_in_month(*current_month);
|
let days_in_month = get_days_in_month(*current_month);
|
||||||
let first_weekday = first_day_of_month.weekday();
|
let first_weekday = first_day_of_month.weekday();
|
||||||
@@ -118,10 +135,12 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let title = event.get_title();
|
let title = event.get_title();
|
||||||
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
||||||
let class_name = if is_refreshing { "event-box refreshing" } else { "event-box" };
|
let class_name = if is_refreshing { "event-box refreshing" } else { "event-box" };
|
||||||
|
let event_color = get_event_color(&event);
|
||||||
html! {
|
html! {
|
||||||
<div class={class_name}
|
<div class={class_name}
|
||||||
title={title.clone()}
|
title={title.clone()}
|
||||||
onclick={event_click}>
|
onclick={event_click}
|
||||||
|
style={format!("background-color: {}", event_color)}>
|
||||||
{
|
{
|
||||||
if is_refreshing {
|
if is_refreshing {
|
||||||
"🔄 Refreshing...".to_string()
|
"🔄 Refreshing...".to_string()
|
||||||
|
|||||||
196
src/components/create_calendar_modal.rs
Normal file
196
src/components/create_calendar_modal.rs
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct CreateCalendarModalProps {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
pub on_create: Callback<(String, Option<String>, Option<String>)>, // name, description, color
|
||||||
|
pub available_colors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
||||||
|
let calendar_name = use_state(|| String::new());
|
||||||
|
let description = use_state(|| String::new());
|
||||||
|
let selected_color = use_state(|| None::<String>);
|
||||||
|
let error_message = use_state(|| None::<String>);
|
||||||
|
let is_creating = use_state(|| false);
|
||||||
|
|
||||||
|
let on_name_change = {
|
||||||
|
let calendar_name = calendar_name.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||||
|
calendar_name.set(input.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_description_change = {
|
||||||
|
let description = description.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: web_sys::HtmlTextAreaElement = e.target_unchecked_into();
|
||||||
|
description.set(input.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_submit = {
|
||||||
|
let calendar_name = calendar_name.clone();
|
||||||
|
let description = description.clone();
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
let error_message = error_message.clone();
|
||||||
|
let is_creating = is_creating.clone();
|
||||||
|
let on_create = props.on_create.clone();
|
||||||
|
|
||||||
|
Callback::from(move |e: SubmitEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
|
||||||
|
let name = (*calendar_name).trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
error_message.set(Some("Calendar name is required".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if name.len() > 100 {
|
||||||
|
error_message.set(Some("Calendar name too long (max 100 characters)".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_message.set(None);
|
||||||
|
is_creating.set(true);
|
||||||
|
|
||||||
|
let desc = if (*description).trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((*description).clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
on_create.emit((name.to_string(), desc, (*selected_color).clone()));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_backdrop_click = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
// Only close if clicking the backdrop, not the modal content
|
||||||
|
if e.target() == e.current_target() {
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
if !props.is_open {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="modal-backdrop" onclick={on_backdrop_click}>
|
||||||
|
<div class="create-calendar-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{"Create New Calendar"}</h2>
|
||||||
|
<button class="close-button" onclick={props.on_close.reform(|_| ())}>
|
||||||
|
{"×"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="modal-body" onsubmit={on_submit}>
|
||||||
|
{
|
||||||
|
if let Some(ref error) = *error_message {
|
||||||
|
html! {
|
||||||
|
<div class="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="calendar-name">{"Calendar Name *"}</label>
|
||||||
|
<input
|
||||||
|
id="calendar-name"
|
||||||
|
type="text"
|
||||||
|
value={(*calendar_name).clone()}
|
||||||
|
oninput={on_name_change}
|
||||||
|
placeholder="Enter calendar name"
|
||||||
|
maxlength="100"
|
||||||
|
disabled={*is_creating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="calendar-description">{"Description"}</label>
|
||||||
|
<textarea
|
||||||
|
id="calendar-description"
|
||||||
|
value={(*description).clone()}
|
||||||
|
oninput={on_description_change}
|
||||||
|
placeholder="Optional calendar description"
|
||||||
|
rows="3"
|
||||||
|
disabled={*is_creating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{"Calendar Color"}</label>
|
||||||
|
<div class="color-grid">
|
||||||
|
{
|
||||||
|
props.available_colors.iter().enumerate().map(|(index, color)| {
|
||||||
|
let color = color.clone();
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
let is_selected = selected_color.as_ref() == Some(&color);
|
||||||
|
let on_color_select = {
|
||||||
|
let color = color.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
selected_color.set(Some(color.clone()));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let class_name = if is_selected {
|
||||||
|
"color-option selected"
|
||||||
|
} else {
|
||||||
|
"color-option"
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
class={class_name}
|
||||||
|
style={format!("background-color: {}", color)}
|
||||||
|
onclick={on_color_select}
|
||||||
|
disabled={*is_creating}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="color-help-text">{"Optional: Choose a color for your calendar"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cancel-button"
|
||||||
|
onclick={props.on_close.reform(|_| ())}
|
||||||
|
disabled={*is_creating}
|
||||||
|
>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="create-button"
|
||||||
|
disabled={*is_creating}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
if *is_creating {
|
||||||
|
"Creating..."
|
||||||
|
} else {
|
||||||
|
"Create Calendar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod event_modal;
|
pub mod event_modal;
|
||||||
|
pub mod create_calendar_modal;
|
||||||
|
|
||||||
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;
|
||||||
@@ -36,6 +36,7 @@ pub struct UserInfo {
|
|||||||
pub struct CalendarInfo {
|
pub struct CalendarInfo {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
|
pub color: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -59,6 +60,7 @@ pub struct CalendarEvent {
|
|||||||
pub reminders: Vec<EventReminder>,
|
pub reminders: Vec<EventReminder>,
|
||||||
pub etag: Option<String>,
|
pub etag: Option<String>,
|
||||||
pub href: Option<String>,
|
pub href: Option<String>,
|
||||||
|
pub calendar_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -465,6 +467,67 @@ impl CalendarService {
|
|||||||
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new calendar on the CalDAV server
|
||||||
|
pub async fn create_calendar(
|
||||||
|
&self,
|
||||||
|
token: &str,
|
||||||
|
password: &str,
|
||||||
|
name: String,
|
||||||
|
description: Option<String>,
|
||||||
|
color: Option<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!({
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"color": color
|
||||||
|
});
|
||||||
|
|
||||||
|
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/create", 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, CalendarInfo};
|
pub use calendar_service::{CalendarService, CalendarEvent, EventReminder, ReminderAction, UserInfo};
|
||||||
368
styles.css
368
styles.css
@@ -136,6 +136,7 @@ body {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-list li:hover {
|
.calendar-list li:hover {
|
||||||
@@ -143,10 +144,72 @@ body {
|
|||||||
transform: translateX(2px);
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-color {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 2px solid rgba(255,255,255,0.3);
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-list li:hover .calendar-color {
|
||||||
|
border-color: rgba(255,255,255,0.6);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
z-index: 1000;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 120px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(0,0,0,0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
border-color: rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option.selected {
|
||||||
|
border-color: #333;
|
||||||
|
border-width: 3px;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-name {
|
.calendar-name {
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-calendars {
|
.no-calendars {
|
||||||
@@ -459,7 +522,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.event-box {
|
.event-box {
|
||||||
background: #2196f3;
|
/* Background color will be set inline via style attribute */
|
||||||
color: white;
|
color: white;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -469,16 +532,34 @@ body {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s;
|
transition: all 0.2s ease;
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
text-shadow: 0 1px 1px rgba(0,0,0,0.3);
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-box:hover {
|
.event-box:hover {
|
||||||
background: #1976d2;
|
filter: brightness(1.15);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-box.refreshing {
|
.event-box.refreshing {
|
||||||
background: #ff9800;
|
|
||||||
animation: pulse 1.5s ease-in-out infinite alternate;
|
animation: pulse 1.5s ease-in-out infinite alternate;
|
||||||
|
border-color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-box.refreshing::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(255, 152, 0, 0.3);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
@@ -748,3 +829,282 @@ body {
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Create Calendar Button */
|
||||||
|
.create-calendar-button {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-calendar-button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-calendar-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Create Calendar Modal */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-calendar-modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: modalSlideIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9) translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-calendar-modal .modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 2rem 2rem 1rem;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-calendar-modal .modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #6c757d;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button:hover {
|
||||||
|
color: #495057;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-calendar-modal .modal-body {
|
||||||
|
padding: 1.5rem 2rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #495057;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:disabled,
|
||||||
|
.form-group textarea:disabled {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
color: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option.selected {
|
||||||
|
border-color: #495057;
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option.selected::after {
|
||||||
|
content: '✓';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-help-text {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button,
|
||||||
|
.create-button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button {
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #6c757d;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button:hover:not(:disabled) {
|
||||||
|
background: #e9ecef;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-button {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button:disabled,
|
||||||
|
.create-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile adjustments for create calendar modal */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.modal-backdrop {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-calendar-modal {
|
||||||
|
max-height: 95vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-calendar-modal .modal-header,
|
||||||
|
.create-calendar-modal .modal-body {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-grid {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button,
|
||||||
|
.create-button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user