Compare commits
32 Commits
feature/ex
...
890940fe31
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
890940fe31 | ||
|
|
fdea5cd646 | ||
|
|
b307be7eb1 | ||
|
|
9d84c380d1 | ||
|
|
fad03f94f9 | ||
| a4476dcfae | |||
|
|
ca1ca0c3b1 | ||
|
|
64dbf65beb | ||
|
|
96585440d1 | ||
|
|
a297d38276 | ||
|
|
4fdaa9931d | ||
|
|
c6c7b38bef | ||
|
|
78db2cc00f | ||
|
|
73d191c5ca | ||
| d930468748 | |||
|
|
91be4436a9 | ||
|
|
4cbc495c48 | ||
|
|
927cd7d2bb | ||
|
|
38b22287c7 | ||
|
|
0de2eee626 | ||
|
|
aa7a15e6fa | ||
|
|
b0a8ef09a8 | ||
|
|
efbaea5ac1 | ||
|
|
bbad327ea2 | ||
|
|
72273a3f1c | ||
|
|
8329244c69 | ||
|
|
b16603b50b | ||
|
|
c6eea88002 | ||
|
|
5876553515 | ||
|
|
d73bc78af5 | ||
|
|
393bfecff2 | ||
| aab478202b |
@@ -4,7 +4,7 @@
|
||||

|
||||
|
||||
>[!WARNING]
|
||||
>This project was entirely vibe coded. It's my first attempt vibe coding anything, but I just sat down one day and realized this was something I've wanted to build for many years, but would just take way too long. With AI, I've been able to lay out the majority of the app in one long weekend. So proceed at your own risk, but I actually think the app is pretty solid.
|
||||
>This project was entirely vibe coded. It's my first attempt vibe coding anything, but I just sat down one day and realized this was something I've wanted to build for many years, but would just take way too long. With AI, I've been able to lay out the majority of the app in one long weekend. So proceed at your own risk, but I actually think the app is pretty decent. There are still a lot of places where the AI has implemented some really poor solutions to the problems that I didn't catch, but I've begun using it for my own general use.
|
||||
|
||||
A modern CalDAV web client built with Rust WebAssembly.
|
||||
|
||||
|
||||
@@ -330,13 +330,26 @@ impl CalDAVClient {
|
||||
event: ical::parser::ical::component::IcalEvent,
|
||||
) -> Result<CalendarEvent, CalDAVError> {
|
||||
let mut properties: HashMap<String, String> = HashMap::new();
|
||||
let mut full_properties: HashMap<String, String> = HashMap::new();
|
||||
|
||||
// Extract all properties from the event
|
||||
for property in &event.properties {
|
||||
properties.insert(
|
||||
property.name.to_uppercase(),
|
||||
property.value.clone().unwrap_or_default(),
|
||||
);
|
||||
let prop_name = property.name.to_uppercase();
|
||||
let prop_value = property.value.clone().unwrap_or_default();
|
||||
|
||||
properties.insert(prop_name.clone(), prop_value.clone());
|
||||
|
||||
// Build full property string with parameters for timezone parsing
|
||||
let mut full_prop = format!("{}", prop_name);
|
||||
if let Some(params) = &property.params {
|
||||
for (param_name, param_values) in params {
|
||||
if !param_values.is_empty() {
|
||||
full_prop.push_str(&format!(";{}={}", param_name, param_values.join(",")));
|
||||
}
|
||||
}
|
||||
}
|
||||
full_prop.push_str(&format!(":{}", prop_value));
|
||||
full_properties.insert(prop_name, full_prop);
|
||||
}
|
||||
|
||||
// Required UID field
|
||||
@@ -349,11 +362,11 @@ impl CalDAVClient {
|
||||
let start = properties
|
||||
.get("DTSTART")
|
||||
.ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?;
|
||||
let start = self.parse_datetime(start, properties.get("DTSTART"))?;
|
||||
let start = self.parse_datetime(start, full_properties.get("DTSTART"))?;
|
||||
|
||||
// Parse end time (optional - use start time if not present)
|
||||
let end = if let Some(dtend) = properties.get("DTEND") {
|
||||
Some(self.parse_datetime(dtend, properties.get("DTEND"))?)
|
||||
Some(self.parse_datetime(dtend, full_properties.get("DTEND"))?)
|
||||
} else if let Some(_duration) = properties.get("DURATION") {
|
||||
// TODO: Parse duration and add to start time
|
||||
Some(start)
|
||||
@@ -567,14 +580,34 @@ impl CalDAVClient {
|
||||
|
||||
let mut all_calendars = Vec::new();
|
||||
|
||||
let mut has_valid_caldav_response = false;
|
||||
|
||||
for path in discovery_paths {
|
||||
println!("Trying discovery path: {}", path);
|
||||
if let Ok(calendars) = self.discover_calendars_at_path(&path).await {
|
||||
println!("Found {} calendar(s) at {}", calendars.len(), path);
|
||||
all_calendars.extend(calendars);
|
||||
match self.discover_calendars_at_path(&path).await {
|
||||
Ok(calendars) => {
|
||||
println!("Found {} calendar(s) at {}", calendars.len(), path);
|
||||
has_valid_caldav_response = true;
|
||||
all_calendars.extend(calendars);
|
||||
}
|
||||
Err(CalDAVError::ServerError(status)) => {
|
||||
// HTTP error - this might be expected for some paths, continue trying
|
||||
println!("Discovery path {} returned HTTP {}, trying next path", path, status);
|
||||
}
|
||||
Err(e) => {
|
||||
// Network or other error - this suggests the server isn't reachable or isn't CalDAV
|
||||
println!("Discovery failed for path {}: {:?}", path, e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we never got a valid CalDAV response (e.g., all requests failed),
|
||||
// this is likely not a CalDAV server
|
||||
if !has_valid_caldav_response {
|
||||
return Err(CalDAVError::ServerError(404));
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
all_calendars.sort();
|
||||
all_calendars.dedup();
|
||||
@@ -671,16 +704,39 @@ impl CalDAVClient {
|
||||
Ok(calendar_paths)
|
||||
}
|
||||
|
||||
/// Parse iCal datetime format
|
||||
/// Parse iCal datetime format with timezone support
|
||||
fn parse_datetime(
|
||||
&self,
|
||||
datetime_str: &str,
|
||||
_original_property: Option<&String>,
|
||||
original_property: Option<&String>,
|
||||
) -> Result<DateTime<Utc>, CalDAVError> {
|
||||
use chrono::TimeZone;
|
||||
use chrono_tz::Tz;
|
||||
|
||||
// Handle different iCal datetime formats
|
||||
// Extract timezone information from the original property if available
|
||||
let mut timezone_id: Option<&str> = None;
|
||||
if let Some(prop) = original_property {
|
||||
// Look for TZID parameter in the property
|
||||
// Format: DTSTART;TZID=America/Denver:20231225T090000
|
||||
if let Some(tzid_start) = prop.find("TZID=") {
|
||||
let tzid_part = &prop[tzid_start + 5..];
|
||||
if let Some(tzid_end) = tzid_part.find(':') {
|
||||
timezone_id = Some(&tzid_part[..tzid_end]);
|
||||
} else if let Some(tzid_end) = tzid_part.find(';') {
|
||||
timezone_id = Some(&tzid_part[..tzid_end]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean the datetime string - remove any TZID prefix if present
|
||||
let cleaned = datetime_str.replace("TZID=", "").trim().to_string();
|
||||
|
||||
// Split on colon to separate TZID from datetime if format is "TZID=America/Denver:20231225T090000"
|
||||
let datetime_part = if let Some(colon_pos) = cleaned.find(':') {
|
||||
&cleaned[colon_pos + 1..]
|
||||
} else {
|
||||
&cleaned
|
||||
};
|
||||
|
||||
// Try different parsing formats
|
||||
let formats = [
|
||||
@@ -690,17 +746,145 @@ impl CalDAVClient {
|
||||
];
|
||||
|
||||
for format in &formats {
|
||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&cleaned, format) {
|
||||
return Ok(Utc.from_utc_datetime(&dt));
|
||||
// Try parsing as UTC first (if it has Z suffix)
|
||||
if datetime_part.ends_with('Z') {
|
||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&datetime_part[..datetime_part.len()-1], "%Y%m%dT%H%M%S") {
|
||||
return Ok(dt.and_utc());
|
||||
}
|
||||
}
|
||||
if let Ok(date) = chrono::NaiveDate::parse_from_str(&cleaned, format) {
|
||||
|
||||
// Try parsing with timezone offset (e.g., 20231225T120000-0500)
|
||||
if let Ok(dt) = DateTime::parse_from_str(datetime_part, "%Y%m%dT%H%M%S%z") {
|
||||
return Ok(dt.with_timezone(&Utc));
|
||||
}
|
||||
|
||||
// Try ISO format with timezone offset (e.g., 2023-12-25T12:00:00-05:00)
|
||||
if let Ok(dt) = DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%S%z") {
|
||||
return Ok(dt.with_timezone(&Utc));
|
||||
}
|
||||
|
||||
// Try ISO format with Z suffix (e.g., 2023-12-25T12:00:00Z)
|
||||
if let Ok(dt) = DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%SZ") {
|
||||
return Ok(dt.with_timezone(&Utc));
|
||||
}
|
||||
|
||||
// Try parsing as naive datetime
|
||||
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_part, format) {
|
||||
// If we have timezone information, convert accordingly
|
||||
if let Some(tz_id) = timezone_id {
|
||||
let tz_result = if tz_id.starts_with("/mozilla.org/") {
|
||||
// Mozilla/Thunderbird format: /mozilla.org/20070129_1/Europe/London
|
||||
tz_id.split('/').last().and_then(|tz_name| tz_name.parse::<Tz>().ok())
|
||||
} else if tz_id.contains('/') {
|
||||
// Standard timezone format: America/New_York, Europe/London
|
||||
tz_id.parse::<Tz>().ok()
|
||||
} else {
|
||||
// Try common abbreviations and Windows timezone names
|
||||
match tz_id {
|
||||
// Standard abbreviations
|
||||
"EST" => Some(Tz::America__New_York),
|
||||
"PST" => Some(Tz::America__Los_Angeles),
|
||||
"MST" => Some(Tz::America__Denver),
|
||||
"CST" => Some(Tz::America__Chicago),
|
||||
|
||||
// North America - Windows timezone names to IANA mapping
|
||||
"Mountain Standard Time" => Some(Tz::America__Denver),
|
||||
"Eastern Standard Time" => Some(Tz::America__New_York),
|
||||
"Central Standard Time" => Some(Tz::America__Chicago),
|
||||
"Pacific Standard Time" => Some(Tz::America__Los_Angeles),
|
||||
"Mountain Daylight Time" => Some(Tz::America__Denver),
|
||||
"Eastern Daylight Time" => Some(Tz::America__New_York),
|
||||
"Central Daylight Time" => Some(Tz::America__Chicago),
|
||||
"Pacific Daylight Time" => Some(Tz::America__Los_Angeles),
|
||||
"Hawaiian Standard Time" => Some(Tz::Pacific__Honolulu),
|
||||
"Alaskan Standard Time" => Some(Tz::America__Anchorage),
|
||||
"Alaskan Daylight Time" => Some(Tz::America__Anchorage),
|
||||
"Atlantic Standard Time" => Some(Tz::America__Halifax),
|
||||
"Newfoundland Standard Time" => Some(Tz::America__St_Johns),
|
||||
|
||||
// Europe
|
||||
"GMT Standard Time" => Some(Tz::Europe__London),
|
||||
"Greenwich Standard Time" => Some(Tz::UTC),
|
||||
"W. Europe Standard Time" => Some(Tz::Europe__Berlin),
|
||||
"Central Europe Standard Time" => Some(Tz::Europe__Warsaw),
|
||||
"Romance Standard Time" => Some(Tz::Europe__Paris),
|
||||
"Central European Standard Time" => Some(Tz::Europe__Belgrade),
|
||||
"E. Europe Standard Time" => Some(Tz::Europe__Bucharest),
|
||||
"FLE Standard Time" => Some(Tz::Europe__Helsinki),
|
||||
"GTB Standard Time" => Some(Tz::Europe__Athens),
|
||||
"Russian Standard Time" => Some(Tz::Europe__Moscow),
|
||||
"Turkey Standard Time" => Some(Tz::Europe__Istanbul),
|
||||
|
||||
// Asia
|
||||
"China Standard Time" => Some(Tz::Asia__Shanghai),
|
||||
"Tokyo Standard Time" => Some(Tz::Asia__Tokyo),
|
||||
"Korea Standard Time" => Some(Tz::Asia__Seoul),
|
||||
"Singapore Standard Time" => Some(Tz::Asia__Singapore),
|
||||
"India Standard Time" => Some(Tz::Asia__Kolkata),
|
||||
"Pakistan Standard Time" => Some(Tz::Asia__Karachi),
|
||||
"Bangladesh Standard Time" => Some(Tz::Asia__Dhaka),
|
||||
"Thailand Standard Time" => Some(Tz::Asia__Bangkok),
|
||||
"SE Asia Standard Time" => Some(Tz::Asia__Bangkok),
|
||||
"Myanmar Standard Time" => Some(Tz::Asia__Yangon),
|
||||
"Sri Lanka Standard Time" => Some(Tz::Asia__Colombo),
|
||||
"Nepal Standard Time" => Some(Tz::Asia__Kathmandu),
|
||||
"Central Asia Standard Time" => Some(Tz::Asia__Almaty),
|
||||
"West Asia Standard Time" => Some(Tz::Asia__Tashkent),
|
||||
"N. Central Asia Standard Time" => Some(Tz::Asia__Novosibirsk),
|
||||
"North Asia Standard Time" => Some(Tz::Asia__Krasnoyarsk),
|
||||
"North Asia East Standard Time" => Some(Tz::Asia__Irkutsk),
|
||||
"Yakutsk Standard Time" => Some(Tz::Asia__Yakutsk),
|
||||
"Vladivostok Standard Time" => Some(Tz::Asia__Vladivostok),
|
||||
"Magadan Standard Time" => Some(Tz::Asia__Magadan),
|
||||
|
||||
// Australia & Pacific
|
||||
"AUS Eastern Standard Time" => Some(Tz::Australia__Sydney),
|
||||
"AUS Central Standard Time" => Some(Tz::Australia__Adelaide),
|
||||
"W. Australia Standard Time" => Some(Tz::Australia__Perth),
|
||||
"Tasmania Standard Time" => Some(Tz::Australia__Hobart),
|
||||
"New Zealand Standard Time" => Some(Tz::Pacific__Auckland),
|
||||
"Fiji Standard Time" => Some(Tz::Pacific__Fiji),
|
||||
"Tonga Standard Time" => Some(Tz::Pacific__Tongatapu),
|
||||
|
||||
// Africa & Middle East
|
||||
"South Africa Standard Time" => Some(Tz::Africa__Johannesburg),
|
||||
"Egypt Standard Time" => Some(Tz::Africa__Cairo),
|
||||
"Israel Standard Time" => Some(Tz::Asia__Jerusalem),
|
||||
"Iran Standard Time" => Some(Tz::Asia__Tehran),
|
||||
"Arabic Standard Time" => Some(Tz::Asia__Baghdad),
|
||||
"Arab Standard Time" => Some(Tz::Asia__Riyadh),
|
||||
|
||||
// South America
|
||||
"SA Eastern Standard Time" => Some(Tz::America__Sao_Paulo),
|
||||
"Argentina Standard Time" => Some(Tz::America__Buenos_Aires),
|
||||
"SA Western Standard Time" => Some(Tz::America__La_Paz),
|
||||
"SA Pacific Standard Time" => Some(Tz::America__Bogota),
|
||||
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(tz) = tz_result {
|
||||
// Convert from the specified timezone to UTC
|
||||
if let Some(local_dt) = tz.from_local_datetime(&naive_dt).single() {
|
||||
return Ok(local_dt.with_timezone(&Utc));
|
||||
}
|
||||
}
|
||||
// If timezone parsing fails, fall back to UTC
|
||||
}
|
||||
// No timezone info or parsing failed - treat as UTC
|
||||
return Ok(Utc.from_utc_datetime(&naive_dt));
|
||||
}
|
||||
|
||||
// Try parsing as date only
|
||||
if let Ok(date) = chrono::NaiveDate::parse_from_str(datetime_part, format) {
|
||||
return Ok(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
Err(CalDAVError::ParseError(format!(
|
||||
"Unable to parse datetime: {}",
|
||||
datetime_str
|
||||
"Unable to parse datetime: {} (cleaned: {}, timezone: {:?})",
|
||||
datetime_str, datetime_part, timezone_id
|
||||
)))
|
||||
}
|
||||
|
||||
|
||||
@@ -845,7 +845,7 @@ fn parse_event_datetime(
|
||||
time_str: &str,
|
||||
all_day: bool,
|
||||
) -> Result<chrono::DateTime<chrono::Utc>, String> {
|
||||
use chrono::{Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
|
||||
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
|
||||
|
||||
// Parse the date
|
||||
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||
@@ -866,11 +866,7 @@ fn parse_event_datetime(
|
||||
// Combine date and time
|
||||
let datetime = NaiveDateTime::new(date, time);
|
||||
|
||||
// Treat the datetime as local time and convert to UTC
|
||||
let local_datetime = Local.from_local_datetime(&datetime)
|
||||
.single()
|
||||
.ok_or_else(|| "Ambiguous local datetime".to_string())?;
|
||||
|
||||
Ok(local_datetime.with_timezone(&Utc))
|
||||
// Frontend now sends UTC times, so treat as UTC directly
|
||||
Ok(Utc.from_utc_datetime(&datetime))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
response::Json,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono::{DateTime, Utc, Datelike};
|
||||
use ical::parser::ical::component::IcalEvent;
|
||||
use reqwest::Client;
|
||||
use serde::Serialize;
|
||||
@@ -78,17 +78,75 @@ pub async fn fetch_external_calendar_events(
|
||||
|
||||
// If not fetched from cache, get from external URL
|
||||
if !fetched_from_cache {
|
||||
let client = Client::new();
|
||||
let response = client
|
||||
.get(&calendar.url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch calendar: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(ApiError::Internal(format!("Calendar server returned: {}", response.status())));
|
||||
// Log the URL being fetched for debugging
|
||||
println!("🌍 Fetching calendar URL: {}", calendar.url);
|
||||
|
||||
let user_agents = vec![
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||
"Mozilla/5.0 (compatible; Runway Calendar/1.0)",
|
||||
"Outlook-iOS/709.2226530.prod.iphone (3.24.1)"
|
||||
];
|
||||
|
||||
let mut response = None;
|
||||
let mut last_error = None;
|
||||
|
||||
// Try different user agents
|
||||
for (i, ua) in user_agents.iter().enumerate() {
|
||||
println!("🔄 Attempt {} with User-Agent: {}", i + 1, ua);
|
||||
|
||||
let client = Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::limited(10))
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.user_agent(*ua)
|
||||
.build()
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to create HTTP client: {}", e)))?;
|
||||
|
||||
let result = client
|
||||
.get(&calendar.url)
|
||||
.header("Accept", "text/calendar,application/calendar+xml,text/plain,*/*")
|
||||
.header("Accept-Charset", "utf-8")
|
||||
.header("Cache-Control", "no-cache")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
println!("📡 Response status: {}", status);
|
||||
if status.is_success() {
|
||||
response = Some(resp);
|
||||
break;
|
||||
} else if status == 400 {
|
||||
// Check if this is an Outlook auth error
|
||||
let error_body = resp.text().await.unwrap_or_default();
|
||||
if error_body.contains("OwaPage") || error_body.contains("Outlook") {
|
||||
println!("🚫 Outlook authentication error detected, trying next approach...");
|
||||
last_error = Some(format!("Outlook auth error: {}", error_body.chars().take(100).collect::<String>()));
|
||||
continue;
|
||||
}
|
||||
last_error = Some(format!("Bad Request: {}", error_body.chars().take(100).collect::<String>()));
|
||||
} else {
|
||||
last_error = Some(format!("HTTP {}", status));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("❌ Request failed: {}", e);
|
||||
last_error = Some(format!("Request error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = response.ok_or_else(|| {
|
||||
ApiError::Internal(format!(
|
||||
"Failed to fetch calendar after trying {} different approaches. Last error: {}",
|
||||
user_agents.len(),
|
||||
last_error.unwrap_or("Unknown error".to_string())
|
||||
))
|
||||
})?;
|
||||
|
||||
// Response is guaranteed to be successful here since we checked in the loop
|
||||
println!("✅ Successfully fetched calendar data");
|
||||
|
||||
ics_content = response
|
||||
.text()
|
||||
.await
|
||||
@@ -138,6 +196,9 @@ fn parse_ics_content(ics_content: &str) -> Result<Vec<VEvent>, Box<dyn std::erro
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate events based on UID, start time, and summary
|
||||
// Outlook sometimes includes duplicate events (recurring exceptions may appear multiple times)
|
||||
events = deduplicate_events(events);
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
@@ -407,4 +468,439 @@ fn parse_datetime_with_tz(datetime_str: &str, tzid: Option<&str>) -> Option<Date
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Deduplicate events based on UID, start time, and summary
|
||||
/// Some calendar systems (like Outlook) may include duplicate events in ICS feeds
|
||||
/// This includes both exact duplicates and recurring event instances that would be
|
||||
/// generated by existing RRULE patterns, and events with same title but different
|
||||
/// RRULE patterns that should be consolidated
|
||||
fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let original_count = events.len();
|
||||
|
||||
// First pass: Group by UID and prefer recurring events over single events with same UID
|
||||
let mut uid_groups: HashMap<String, Vec<VEvent>> = HashMap::new();
|
||||
|
||||
for event in events.drain(..) {
|
||||
// Debug logging to understand what's happening
|
||||
println!("🔍 Event: '{}' at {} (RRULE: {}) - UID: {}",
|
||||
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
||||
event.dtstart.format("%Y-%m-%d %H:%M"),
|
||||
if event.rrule.is_some() { "Yes" } else { "No" },
|
||||
event.uid
|
||||
);
|
||||
|
||||
uid_groups.entry(event.uid.clone()).or_insert_with(Vec::new).push(event);
|
||||
}
|
||||
|
||||
let mut uid_deduplicated_events = Vec::new();
|
||||
|
||||
for (uid, mut events_with_uid) in uid_groups.drain() {
|
||||
if events_with_uid.len() == 1 {
|
||||
// Only one event with this UID, keep it
|
||||
uid_deduplicated_events.push(events_with_uid.into_iter().next().unwrap());
|
||||
} else {
|
||||
// Multiple events with same UID - prefer recurring over non-recurring
|
||||
println!("🔍 Found {} events with UID '{}'", events_with_uid.len(), uid);
|
||||
|
||||
// Sort by preference: recurring events first, then by completeness
|
||||
events_with_uid.sort_by(|a, b| {
|
||||
let a_has_rrule = a.rrule.is_some();
|
||||
let b_has_rrule = b.rrule.is_some();
|
||||
|
||||
match (a_has_rrule, b_has_rrule) {
|
||||
(true, false) => std::cmp::Ordering::Less, // a (recurring) comes first
|
||||
(false, true) => std::cmp::Ordering::Greater, // b (recurring) comes first
|
||||
_ => {
|
||||
// Both same type (both recurring or both single) - compare by completeness
|
||||
event_completeness_score(b).cmp(&event_completeness_score(a))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Keep the first (preferred) event
|
||||
let preferred_event = events_with_uid.into_iter().next().unwrap();
|
||||
println!("🔄 UID dedup: Keeping '{}' (RRULE: {})",
|
||||
preferred_event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
||||
if preferred_event.rrule.is_some() { "Yes" } else { "No" }
|
||||
);
|
||||
uid_deduplicated_events.push(preferred_event);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: separate recurring and single events from UID-deduplicated set
|
||||
let mut recurring_events = Vec::new();
|
||||
let mut single_events = Vec::new();
|
||||
|
||||
for event in uid_deduplicated_events.drain(..) {
|
||||
if event.rrule.is_some() {
|
||||
recurring_events.push(event);
|
||||
} else {
|
||||
single_events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Third pass: Group recurring events by normalized title and consolidate different RRULE patterns
|
||||
let mut title_groups: HashMap<String, Vec<VEvent>> = HashMap::new();
|
||||
|
||||
for event in recurring_events.drain(..) {
|
||||
let title = normalize_title(event.summary.as_ref().unwrap_or(&String::new()));
|
||||
title_groups.entry(title).or_insert_with(Vec::new).push(event);
|
||||
}
|
||||
|
||||
let mut deduplicated_recurring = Vec::new();
|
||||
|
||||
for (title, events_with_title) in title_groups.drain() {
|
||||
if events_with_title.len() == 1 {
|
||||
// Single event with this title, keep as-is
|
||||
deduplicated_recurring.push(events_with_title.into_iter().next().unwrap());
|
||||
} else {
|
||||
// Multiple events with same title - consolidate or deduplicate
|
||||
println!("🔍 Found {} events with title '{}'", events_with_title.len(), title);
|
||||
|
||||
// Check if these are actually different recurring patterns for the same logical event
|
||||
let consolidated = consolidate_same_title_events(events_with_title);
|
||||
deduplicated_recurring.extend(consolidated);
|
||||
}
|
||||
}
|
||||
|
||||
// Fourth pass: filter single events, removing those that would be generated by recurring events
|
||||
let mut deduplicated_single = Vec::new();
|
||||
let mut seen_single: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
for event in single_events.drain(..) {
|
||||
let normalized_title = normalize_title(event.summary.as_ref().unwrap_or(&String::new()));
|
||||
let dedup_key = format!(
|
||||
"{}|{}",
|
||||
event.dtstart.format("%Y%m%dT%H%M%S"),
|
||||
normalized_title
|
||||
);
|
||||
|
||||
// First check for exact duplicates among single events
|
||||
if let Some(&existing_index) = seen_single.get(&dedup_key) {
|
||||
let existing_event: &VEvent = &deduplicated_single[existing_index];
|
||||
let current_completeness = event_completeness_score(&event);
|
||||
let existing_completeness = event_completeness_score(existing_event);
|
||||
|
||||
if current_completeness > existing_completeness {
|
||||
println!("🔄 Replacing single event: Keeping '{}' over '{}'",
|
||||
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
||||
existing_event.summary.as_ref().unwrap_or(&"No Title".to_string())
|
||||
);
|
||||
deduplicated_single[existing_index] = event;
|
||||
} else {
|
||||
println!("🚫 Discarding duplicate single event: Keeping existing '{}'",
|
||||
existing_event.summary.as_ref().unwrap_or(&"No Title".to_string())
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this single event would be generated by any recurring event
|
||||
let is_rrule_generated = deduplicated_recurring.iter().any(|recurring_event| {
|
||||
// Check if this single event matches the recurring event's pattern (use normalized titles)
|
||||
let single_title = normalize_title(event.summary.as_ref().unwrap_or(&String::new()));
|
||||
let recurring_title = normalize_title(recurring_event.summary.as_ref().unwrap_or(&String::new()));
|
||||
|
||||
if single_title != recurring_title {
|
||||
return false; // Different events
|
||||
}
|
||||
|
||||
// Check if this single event would be generated by the recurring event
|
||||
would_event_be_generated_by_rrule(recurring_event, &event)
|
||||
});
|
||||
|
||||
if is_rrule_generated {
|
||||
println!("🚫 Discarding RRULE-generated instance: '{}' at {} would be generated by recurring event",
|
||||
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
||||
event.dtstart.format("%Y-%m-%d %H:%M")
|
||||
);
|
||||
} else {
|
||||
// This is a unique single event
|
||||
seen_single.insert(dedup_key, deduplicated_single.len());
|
||||
deduplicated_single.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Combine recurring and single events
|
||||
let mut result = deduplicated_recurring;
|
||||
result.extend(deduplicated_single);
|
||||
|
||||
println!("📊 Deduplication complete: {} -> {} events ({} recurring, {} single)",
|
||||
original_count, result.len(),
|
||||
result.iter().filter(|e| e.rrule.is_some()).count(),
|
||||
result.iter().filter(|e| e.rrule.is_none()).count()
|
||||
);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Normalize title for grouping similar events
|
||||
fn normalize_title(title: &str) -> String {
|
||||
title.trim()
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
|
||||
.collect::<String>()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<&str>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Consolidate events with the same title but potentially different RRULE patterns
|
||||
/// This handles cases where calendar systems provide multiple recurring definitions
|
||||
/// for the same logical meeting (e.g., one RRULE for Tuesdays, another for Thursdays)
|
||||
fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> {
|
||||
if events.is_empty() {
|
||||
return events;
|
||||
}
|
||||
|
||||
// Log the RRULEs we're working with
|
||||
for event in &events {
|
||||
if let Some(rrule) = &event.rrule {
|
||||
println!("🔍 RRULE for '{}': {}",
|
||||
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
||||
rrule
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if all events have similar time patterns and could be consolidated
|
||||
let first_event = &events[0];
|
||||
let base_time = first_event.dtstart.time();
|
||||
let base_duration = if let Some(end) = first_event.dtend {
|
||||
Some(end.signed_duration_since(first_event.dtstart))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Check if all events have the same time and duration
|
||||
let can_consolidate = events.iter().all(|event| {
|
||||
let same_time = event.dtstart.time() == base_time;
|
||||
let same_duration = match (event.dtend, base_duration) {
|
||||
(Some(end), Some(base_dur)) => end.signed_duration_since(event.dtstart) == base_dur,
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
same_time && same_duration
|
||||
});
|
||||
|
||||
if !can_consolidate {
|
||||
println!("🚫 Cannot consolidate events - different times or durations");
|
||||
// Just deduplicate exact duplicates
|
||||
return deduplicate_exact_recurring_events(events);
|
||||
}
|
||||
|
||||
// Try to detect if these are complementary weekly patterns
|
||||
let weekly_events: Vec<_> = events.iter()
|
||||
.filter(|e| e.rrule.as_ref().map_or(false, |r| r.contains("FREQ=WEEKLY")))
|
||||
.collect();
|
||||
|
||||
if weekly_events.len() >= 2 && weekly_events.len() == events.len() {
|
||||
// All events are weekly - try to consolidate into a single multi-day weekly pattern
|
||||
if let Some(consolidated) = consolidate_weekly_patterns(&events) {
|
||||
println!("✅ Successfully consolidated {} weekly patterns into one", events.len());
|
||||
return vec![consolidated];
|
||||
}
|
||||
}
|
||||
|
||||
// If we can't consolidate, just deduplicate exact matches and keep the most complete one
|
||||
println!("🚫 Cannot consolidate - keeping most complete event");
|
||||
let deduplicated = deduplicate_exact_recurring_events(events);
|
||||
|
||||
// If we still have multiple events, keep only the most complete one
|
||||
if deduplicated.len() > 1 {
|
||||
let best_event = deduplicated.into_iter()
|
||||
.max_by_key(|e| event_completeness_score(e))
|
||||
.unwrap();
|
||||
|
||||
println!("🎯 Kept most complete event: '{}'",
|
||||
best_event.summary.as_ref().unwrap_or(&"No Title".to_string())
|
||||
);
|
||||
vec![best_event]
|
||||
} else {
|
||||
deduplicated
|
||||
}
|
||||
}
|
||||
|
||||
/// Deduplicate exact recurring event matches
|
||||
fn deduplicate_exact_recurring_events(events: Vec<VEvent>) -> Vec<VEvent> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut seen: HashMap<String, usize> = HashMap::new();
|
||||
let mut deduplicated = Vec::new();
|
||||
|
||||
for event in events {
|
||||
let dedup_key = format!(
|
||||
"{}|{}|{}",
|
||||
event.dtstart.format("%Y%m%dT%H%M%S"),
|
||||
event.summary.as_ref().unwrap_or(&String::new()),
|
||||
event.rrule.as_ref().unwrap_or(&String::new())
|
||||
);
|
||||
|
||||
if let Some(&existing_index) = seen.get(&dedup_key) {
|
||||
let existing_event: &VEvent = &deduplicated[existing_index];
|
||||
let current_completeness = event_completeness_score(&event);
|
||||
let existing_completeness = event_completeness_score(existing_event);
|
||||
|
||||
if current_completeness > existing_completeness {
|
||||
println!("🔄 Replacing exact duplicate: Keeping more complete event");
|
||||
deduplicated[existing_index] = event;
|
||||
}
|
||||
} else {
|
||||
seen.insert(dedup_key, deduplicated.len());
|
||||
deduplicated.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
deduplicated
|
||||
}
|
||||
|
||||
/// Attempt to consolidate multiple weekly RRULE patterns into a single pattern
|
||||
fn consolidate_weekly_patterns(events: &[VEvent]) -> Option<VEvent> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
let mut all_days = HashSet::new();
|
||||
let mut base_event = None;
|
||||
|
||||
for event in events {
|
||||
let Some(rrule) = &event.rrule else { continue; };
|
||||
|
||||
if !rrule.contains("FREQ=WEEKLY") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract BYDAY if present
|
||||
if let Some(byday_part) = rrule.split(';').find(|part| part.starts_with("BYDAY=")) {
|
||||
let days_str = byday_part.strip_prefix("BYDAY=").unwrap_or("");
|
||||
for day in days_str.split(',') {
|
||||
all_days.insert(day.trim().to_string());
|
||||
}
|
||||
} else {
|
||||
// If no BYDAY specified, use the weekday from the start date
|
||||
let weekday = match event.dtstart.weekday() {
|
||||
chrono::Weekday::Mon => "MO",
|
||||
chrono::Weekday::Tue => "TU",
|
||||
chrono::Weekday::Wed => "WE",
|
||||
chrono::Weekday::Thu => "TH",
|
||||
chrono::Weekday::Fri => "FR",
|
||||
chrono::Weekday::Sat => "SA",
|
||||
chrono::Weekday::Sun => "SU",
|
||||
};
|
||||
all_days.insert(weekday.to_string());
|
||||
}
|
||||
|
||||
// Use the first event as the base (we already know they have same time/duration)
|
||||
if base_event.is_none() {
|
||||
base_event = Some(event.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if all_days.is_empty() || base_event.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Create consolidated RRULE
|
||||
let mut base = base_event.unwrap();
|
||||
let days_list: Vec<_> = all_days.into_iter().collect();
|
||||
let byday_str = days_list.join(",");
|
||||
|
||||
// Build new RRULE with consolidated BYDAY
|
||||
let new_rrule = if let Some(existing_rrule) = &base.rrule {
|
||||
// Remove existing BYDAY and add our consolidated one
|
||||
let parts: Vec<_> = existing_rrule.split(';')
|
||||
.filter(|part| !part.starts_with("BYDAY="))
|
||||
.collect();
|
||||
format!("{};BYDAY={}", parts.join(";"), byday_str)
|
||||
} else {
|
||||
format!("FREQ=WEEKLY;BYDAY={}", byday_str)
|
||||
};
|
||||
|
||||
base.rrule = Some(new_rrule);
|
||||
|
||||
println!("🔗 Consolidated weekly pattern: BYDAY={}", byday_str);
|
||||
Some(base)
|
||||
}
|
||||
|
||||
/// Check if a single event would be generated by a recurring event's RRULE
|
||||
fn would_event_be_generated_by_rrule(recurring_event: &VEvent, single_event: &VEvent) -> bool {
|
||||
let Some(rrule) = &recurring_event.rrule else {
|
||||
return false; // No RRULE to check against
|
||||
};
|
||||
|
||||
// Parse basic RRULE patterns
|
||||
if rrule.contains("FREQ=DAILY") {
|
||||
// Daily recurrence
|
||||
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
|
||||
let days_diff = (single_event.dtstart.date_naive() - recurring_event.dtstart.date_naive()).num_days();
|
||||
|
||||
if days_diff >= 0 && days_diff % interval as i64 == 0 {
|
||||
// Check if times match (allowing for timezone differences within same day)
|
||||
let recurring_time = recurring_event.dtstart.time();
|
||||
let single_time = single_event.dtstart.time();
|
||||
return recurring_time == single_time;
|
||||
}
|
||||
} else if rrule.contains("FREQ=WEEKLY") {
|
||||
// Weekly recurrence
|
||||
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
|
||||
let days_diff = (single_event.dtstart.date_naive() - recurring_event.dtstart.date_naive()).num_days();
|
||||
|
||||
// First check if it's the same day of week and time
|
||||
let recurring_weekday = recurring_event.dtstart.weekday();
|
||||
let single_weekday = single_event.dtstart.weekday();
|
||||
let recurring_time = recurring_event.dtstart.time();
|
||||
let single_time = single_event.dtstart.time();
|
||||
|
||||
if recurring_weekday == single_weekday && recurring_time == single_time && days_diff >= 0 {
|
||||
// Calculate how many weeks apart they are
|
||||
let weeks_diff = days_diff / 7;
|
||||
// Check if this falls on an interval boundary
|
||||
return weeks_diff % interval as i64 == 0;
|
||||
}
|
||||
} else if rrule.contains("FREQ=MONTHLY") {
|
||||
// Monthly recurrence - simplified check
|
||||
let months_diff = (single_event.dtstart.year() - recurring_event.dtstart.year()) * 12
|
||||
+ (single_event.dtstart.month() as i32 - recurring_event.dtstart.month() as i32);
|
||||
|
||||
if months_diff >= 0 {
|
||||
let interval = extract_interval_from_rrule(rrule).unwrap_or(1) as i32;
|
||||
if months_diff % interval == 0 {
|
||||
// Same day of month and time
|
||||
return recurring_event.dtstart.day() == single_event.dtstart.day()
|
||||
&& recurring_event.dtstart.time() == single_event.dtstart.time();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Extract INTERVAL value from RRULE string, defaulting to 1 if not found
|
||||
fn extract_interval_from_rrule(rrule: &str) -> Option<u32> {
|
||||
for part in rrule.split(';') {
|
||||
if part.starts_with("INTERVAL=") {
|
||||
return part.strip_prefix("INTERVAL=")
|
||||
.and_then(|s| s.parse().ok());
|
||||
}
|
||||
}
|
||||
Some(1) // Default interval is 1 if not specified
|
||||
}
|
||||
|
||||
/// Calculate a completeness score for an event based on how many optional fields are filled
|
||||
fn event_completeness_score(event: &VEvent) -> u32 {
|
||||
let mut score = 0;
|
||||
|
||||
if event.summary.is_some() { score += 1; }
|
||||
if event.description.is_some() { score += 1; }
|
||||
if event.location.is_some() { score += 1; }
|
||||
if event.dtend.is_some() { score += 1; }
|
||||
if event.rrule.is_some() { score += 1; }
|
||||
if !event.categories.is_empty() { score += 1; }
|
||||
if !event.alarms.is_empty() { score += 1; }
|
||||
if event.organizer.is_some() { score += 1; }
|
||||
if !event.attendees.is_empty() { score += 1; }
|
||||
|
||||
score
|
||||
}
|
||||
@@ -130,13 +130,9 @@ pub async fn create_event_series(
|
||||
.and_hms_opt(23, 59, 59)
|
||||
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
||||
|
||||
// Convert from local time to UTC
|
||||
let start_local = chrono::Local.from_local_datetime(&start_dt)
|
||||
.single()
|
||||
.ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?;
|
||||
let end_local = chrono::Local.from_local_datetime(&end_dt)
|
||||
.single()
|
||||
.ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?;
|
||||
// Frontend now sends UTC times, so treat as UTC directly
|
||||
let start_local = chrono::Utc.from_utc_datetime(&start_dt);
|
||||
let end_local = chrono::Utc.from_utc_datetime(&end_dt);
|
||||
|
||||
(
|
||||
start_local.with_timezone(&chrono::Utc),
|
||||
@@ -171,13 +167,9 @@ pub async fn create_event_series(
|
||||
start_date.and_time(end_time)
|
||||
};
|
||||
|
||||
// Convert from local time to UTC
|
||||
let start_local = chrono::Local.from_local_datetime(&start_dt)
|
||||
.single()
|
||||
.ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?;
|
||||
let end_local = chrono::Local.from_local_datetime(&end_dt)
|
||||
.single()
|
||||
.ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?;
|
||||
// Frontend now sends UTC times, so treat as UTC directly
|
||||
let start_local = chrono::Utc.from_utc_datetime(&start_dt);
|
||||
let end_local = chrono::Utc.from_utc_datetime(&end_dt);
|
||||
|
||||
(
|
||||
start_local.with_timezone(&chrono::Utc),
|
||||
@@ -456,13 +448,9 @@ pub async fn update_event_series(
|
||||
(chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc()
|
||||
};
|
||||
|
||||
// Convert from local time to UTC
|
||||
let start_local = chrono::Local.from_local_datetime(&start_dt)
|
||||
.single()
|
||||
.ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?;
|
||||
let end_local = chrono::Local.from_local_datetime(&end_dt)
|
||||
.single()
|
||||
.ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?;
|
||||
// Frontend now sends UTC times, so treat as UTC directly
|
||||
let start_local = chrono::Utc.from_utc_datetime(&start_dt);
|
||||
let end_local = chrono::Utc.from_utc_datetime(&end_dt);
|
||||
|
||||
(
|
||||
start_local.with_timezone(&chrono::Utc),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#!/bin/sh
|
||||
|
||||
export BACKEND_API_URL="https://runway.rcjohnstone.com/api"
|
||||
trunk build --release --config /home/connor/docs/projects/calendar/frontend/Trunk.toml
|
||||
sudo rsync -azX --delete --info=progress2 -e 'ssh -T -q' --rsync-path='sudo rsync' /home/connor/docs/projects/calendar/frontend/dist connor@server.rcjohnstone.com:/home/connor/data/runway/
|
||||
unset BACKEND_API_URL
|
||||
|
||||
@@ -22,14 +22,19 @@ web-sys = { version = "0.3", features = [
|
||||
"Document",
|
||||
"Window",
|
||||
"Location",
|
||||
"Navigator",
|
||||
"DomTokenList",
|
||||
"Headers",
|
||||
"Request",
|
||||
"RequestInit",
|
||||
"RequestMode",
|
||||
"Response",
|
||||
"CssStyleDeclaration",
|
||||
"MediaQueryList",
|
||||
"MediaQueryListEvent",
|
||||
] }
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
|
||||
# HTTP client for CalDAV requests
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
|
||||
@@ -6,7 +6,7 @@ dist = "dist"
|
||||
BACKEND_API_URL = "http://localhost:3000/api"
|
||||
|
||||
[watch]
|
||||
watch = ["src", "Cargo.toml", "../calendar-models/src", "styles.css", "index.html"]
|
||||
watch = ["src", "Cargo.toml", "../calendar-models/src", "styles.css", "print-preview.css", "index.html"]
|
||||
ignore = ["../backend/", "../target/"]
|
||||
|
||||
[serve]
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<base data-trunk-public-url />
|
||||
<link data-trunk rel="css" href="styles.css">
|
||||
<link data-trunk rel="css" href="print-preview.css">
|
||||
<link data-trunk rel="copy-file" href="styles/google.css">
|
||||
<link data-trunk rel="icon" href="favicon.ico">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
|
||||
1215
frontend/print-preview.css
Normal file
1215
frontend/print-preview.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
use crate::components::{
|
||||
CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction,
|
||||
EditAction, EventContextMenu, EventCreationData, ExternalCalendarModal, RouteHandler,
|
||||
Sidebar, Theme, ViewMode,
|
||||
CalendarContextMenu, CalendarManagementModal, ContextMenu, CreateEventModal, DeleteAction,
|
||||
EditAction, EventContextMenu, EventModal, EventCreationData,
|
||||
MobileWarningModal, RouteHandler, Sidebar, Theme, ViewMode,
|
||||
};
|
||||
use crate::components::mobile_warning_modal::is_mobile_device;
|
||||
use crate::components::sidebar::{Style};
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
|
||||
@@ -55,11 +56,46 @@ fn get_theme_event_colors() -> Vec<String> {
|
||||
|
||||
#[function_component]
|
||||
pub fn App() -> Html {
|
||||
let auth_token = use_state(|| -> Option<String> { LocalStorage::get("auth_token").ok() });
|
||||
let auth_token = use_state(|| -> Option<String> { None });
|
||||
|
||||
// Validate token on app startup
|
||||
{
|
||||
let auth_token = auth_token.clone();
|
||||
use_effect_with((), move |_| {
|
||||
let auth_token = auth_token.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// Check if there's a stored token
|
||||
if let Ok(stored_token) = LocalStorage::get::<String>("auth_token") {
|
||||
// Verify the stored token
|
||||
let auth_service = crate::auth::AuthService::new();
|
||||
match auth_service.verify_token(&stored_token).await {
|
||||
Ok(true) => {
|
||||
// Token is valid, set it
|
||||
web_sys::console::log_1(&"✅ Stored auth token is valid".into());
|
||||
auth_token.set(Some(stored_token));
|
||||
}
|
||||
_ => {
|
||||
// Token is invalid or verification failed, clear it
|
||||
web_sys::console::log_1(&"❌ Stored auth token is invalid, clearing".into());
|
||||
let _ = LocalStorage::delete("auth_token");
|
||||
let _ = LocalStorage::delete("session_token");
|
||||
let _ = LocalStorage::delete("caldav_credentials");
|
||||
auth_token.set(None);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No stored token
|
||||
web_sys::console::log_1(&"ℹ️ No stored auth token found".into());
|
||||
auth_token.set(None);
|
||||
}
|
||||
});
|
||||
|| ()
|
||||
});
|
||||
}
|
||||
|
||||
let user_info = use_state(|| -> Option<UserInfo> { None });
|
||||
let color_picker_open = use_state(|| -> Option<String> { None });
|
||||
let create_modal_open = use_state(|| false);
|
||||
let calendar_management_modal_open = use_state(|| false);
|
||||
let context_menu_open = use_state(|| false);
|
||||
let context_menu_pos = use_state(|| (0i32, 0i32));
|
||||
let context_menu_calendar_path = use_state(|| -> Option<String> { None });
|
||||
@@ -72,6 +108,9 @@ pub fn App() -> Html {
|
||||
let create_event_modal_open = use_state(|| false);
|
||||
let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None });
|
||||
let event_edit_scope = use_state(|| -> Option<EditAction> { None });
|
||||
let view_event_modal_open = use_state(|| false);
|
||||
let view_event_modal_event = use_state(|| -> Option<VEvent> { None });
|
||||
let refreshing_calendar_id = use_state(|| -> Option<i32> { None });
|
||||
let _recurring_edit_modal_open = use_state(|| false);
|
||||
let _recurring_edit_event = use_state(|| -> Option<VEvent> { None });
|
||||
let _recurring_edit_data = use_state(|| -> Option<EventCreationData> { None });
|
||||
@@ -79,7 +118,9 @@ pub fn App() -> Html {
|
||||
// External calendar state
|
||||
let external_calendars = use_state(|| -> Vec<ExternalCalendar> { Vec::new() });
|
||||
let external_calendar_events = use_state(|| -> Vec<VEvent> { Vec::new() });
|
||||
let external_calendar_modal_open = use_state(|| false);
|
||||
|
||||
// Mobile warning state
|
||||
let mobile_warning_open = use_state(|| is_mobile_device());
|
||||
let refresh_interval = use_state(|| -> Option<Interval> { None });
|
||||
|
||||
// Calendar view state - load from localStorage if available
|
||||
@@ -117,6 +158,108 @@ pub fn App() -> Html {
|
||||
|
||||
let available_colors = use_state(|| get_theme_event_colors());
|
||||
|
||||
// Function to refresh calendar data without full page reload
|
||||
let refresh_calendar_data = {
|
||||
let user_info = user_info.clone();
|
||||
let auth_token = auth_token.clone();
|
||||
let external_calendars = external_calendars.clone();
|
||||
let external_calendar_events = external_calendar_events.clone();
|
||||
|
||||
Callback::from(move |_| {
|
||||
let user_info = user_info.clone();
|
||||
let auth_token = auth_token.clone();
|
||||
let external_calendars = external_calendars.clone();
|
||||
let external_calendar_events = external_calendar_events.clone();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// Refresh main calendar data if authenticated
|
||||
if let Some(token) = (*auth_token).clone() {
|
||||
let calendar_service = CalendarService::new();
|
||||
|
||||
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(mut info) => {
|
||||
// Apply saved colors
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add timestamp to force re-render
|
||||
info.last_updated = (js_sys::Date::now() / 1000.0) as u64;
|
||||
user_info.set(Some(info));
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::log_1(
|
||||
&format!("Failed to refresh main calendar data: {}", err).into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh external calendars data
|
||||
match CalendarService::get_external_calendars().await {
|
||||
Ok(calendars) => {
|
||||
external_calendars.set(calendars.clone());
|
||||
|
||||
// Load events for visible external calendars
|
||||
let mut all_external_events = Vec::new();
|
||||
for calendar in calendars {
|
||||
if calendar.is_visible {
|
||||
match CalendarService::fetch_external_calendar_events(calendar.id).await {
|
||||
Ok(mut events) => {
|
||||
// Set calendar_path for color matching
|
||||
for event in &mut events {
|
||||
event.calendar_path = Some(format!("external_{}", calendar.id));
|
||||
}
|
||||
all_external_events.extend(events);
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::log_1(
|
||||
&format!("Failed to fetch events for external calendar {}: {}", calendar.id, e).into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
external_calendar_events.set(all_external_events);
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::log_1(
|
||||
&format!("Failed to refresh external calendars: {}", e).into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
let on_login = {
|
||||
let auth_token = auth_token.clone();
|
||||
Callback::from(move |token: String| {
|
||||
@@ -134,6 +277,13 @@ pub fn App() -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
let on_mobile_warning_close = {
|
||||
let mobile_warning_open = mobile_warning_open.clone();
|
||||
Callback::from(move |_| {
|
||||
mobile_warning_open.set(false);
|
||||
})
|
||||
};
|
||||
|
||||
let on_view_change = {
|
||||
let current_view = current_view.clone();
|
||||
Callback::from(move |new_view: ViewMode| {
|
||||
@@ -417,19 +567,60 @@ pub fn App() -> Html {
|
||||
|
||||
let on_color_change = {
|
||||
let user_info = user_info.clone();
|
||||
let external_calendars = external_calendars.clone();
|
||||
let color_picker_open = color_picker_open.clone();
|
||||
Callback::from(move |(calendar_path, color): (String, String)| {
|
||||
if let Some(mut info) = (*user_info).clone() {
|
||||
for calendar in &mut info.calendars {
|
||||
if calendar.path == calendar_path {
|
||||
calendar.color = color.clone();
|
||||
break;
|
||||
}
|
||||
if calendar_path.starts_with("external_") {
|
||||
// Handle external calendar color change
|
||||
if let Ok(id_str) = calendar_path.strip_prefix("external_").unwrap_or("").parse::<i32>() {
|
||||
let external_calendars = external_calendars.clone();
|
||||
let color = color.clone();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// Find the external calendar to get its current details
|
||||
if let Some(cal) = (*external_calendars).iter().find(|c| c.id == id_str) {
|
||||
match CalendarService::update_external_calendar(
|
||||
id_str,
|
||||
&cal.name,
|
||||
&cal.url,
|
||||
&color,
|
||||
cal.is_visible,
|
||||
).await {
|
||||
Ok(_) => {
|
||||
// Update the local state
|
||||
let mut updated_calendars = (*external_calendars).clone();
|
||||
for calendar in &mut updated_calendars {
|
||||
if calendar.id == id_str {
|
||||
calendar.color = color.clone();
|
||||
break;
|
||||
}
|
||||
}
|
||||
external_calendars.set(updated_calendars);
|
||||
|
||||
// No need to refresh events - they will automatically pick up the new color
|
||||
// from the calendar when rendered since they use the same calendar_path matching
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::error_1(&format!("Failed to update external calendar color: {}", e).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
user_info.set(Some(info.clone()));
|
||||
} else {
|
||||
// Handle CalDAV calendar color change (existing logic)
|
||||
if let Some(mut info) = (*user_info).clone() {
|
||||
for calendar in &mut info.calendars {
|
||||
if calendar.path == calendar_path {
|
||||
calendar.color = color.clone();
|
||||
break;
|
||||
}
|
||||
}
|
||||
user_info.set(Some(info.clone()));
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&info) {
|
||||
let _ = LocalStorage::set("calendar_colors", json);
|
||||
if let Ok(json) = serde_json::to_string(&info) {
|
||||
let _ = LocalStorage::set("calendar_colors", json);
|
||||
}
|
||||
}
|
||||
}
|
||||
color_picker_open.set(None);
|
||||
@@ -493,6 +684,7 @@ pub fn App() -> Html {
|
||||
let on_event_create = {
|
||||
let create_event_modal_open = create_event_modal_open.clone();
|
||||
let auth_token = auth_token.clone();
|
||||
let refresh_calendar_data = refresh_calendar_data.clone();
|
||||
Callback::from(move |event_data: EventCreationData| {
|
||||
// Check if this is an update operation (has original_uid) or a create operation
|
||||
if let Some(original_uid) = event_data.original_uid.clone() {
|
||||
@@ -503,6 +695,7 @@ pub fn App() -> Html {
|
||||
// Handle the update operation using the existing backend update logic
|
||||
if let Some(token) = (*auth_token).clone() {
|
||||
let event_data_for_update = event_data.clone();
|
||||
let refresh_callback = refresh_calendar_data.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let calendar_service = CalendarService::new();
|
||||
|
||||
@@ -603,10 +796,8 @@ pub fn App() -> Html {
|
||||
match update_result {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Event updated successfully via modal".into());
|
||||
// Trigger a page reload to refresh events from all calendars
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.location().reload();
|
||||
}
|
||||
// Refresh calendar data without page reload
|
||||
refresh_callback.emit(());
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(
|
||||
@@ -642,6 +833,7 @@ pub fn App() -> Html {
|
||||
create_event_modal_open.set(false);
|
||||
|
||||
if let Some(_token) = (*auth_token).clone() {
|
||||
let refresh_callback = refresh_calendar_data.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let _calendar_service = CalendarService::new();
|
||||
|
||||
@@ -688,9 +880,8 @@ pub fn App() -> Html {
|
||||
match create_result {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Event created successfully".into());
|
||||
// Trigger a page reload to refresh events from all calendars
|
||||
// TODO: This could be improved to do a more targeted refresh
|
||||
web_sys::window().unwrap().location().reload().unwrap();
|
||||
// Refresh calendar data without page reload
|
||||
refresh_callback.emit(());
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(
|
||||
@@ -709,6 +900,7 @@ pub fn App() -> Html {
|
||||
|
||||
let on_event_update = {
|
||||
let auth_token = auth_token.clone();
|
||||
let refresh_calendar_data = refresh_calendar_data.clone();
|
||||
Callback::from(
|
||||
move |(
|
||||
original_event,
|
||||
@@ -743,6 +935,7 @@ pub fn App() -> Html {
|
||||
if let Some(token) = (*auth_token).clone() {
|
||||
let original_event = original_event.clone();
|
||||
let backend_uid = backend_uid.clone();
|
||||
let refresh_callback = refresh_calendar_data.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let calendar_service = CalendarService::new();
|
||||
|
||||
@@ -761,11 +954,30 @@ pub fn App() -> Html {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Send local time directly to backend (backend will handle UTC conversion)
|
||||
let start_date = new_start.format("%Y-%m-%d").to_string();
|
||||
let start_time = new_start.format("%H:%M").to_string();
|
||||
let end_date = new_end.format("%Y-%m-%d").to_string();
|
||||
let end_time = new_end.format("%H:%M").to_string();
|
||||
// Convert local naive datetime to UTC before sending to backend
|
||||
use chrono::TimeZone;
|
||||
let local_tz = chrono::Local;
|
||||
|
||||
let start_utc = local_tz.from_local_datetime(&new_start)
|
||||
.single()
|
||||
.unwrap_or_else(|| {
|
||||
// Fallback for ambiguous times (DST transitions)
|
||||
local_tz.from_local_datetime(&new_start).earliest().unwrap()
|
||||
})
|
||||
.with_timezone(&chrono::Utc);
|
||||
|
||||
let end_utc = local_tz.from_local_datetime(&new_end)
|
||||
.single()
|
||||
.unwrap_or_else(|| {
|
||||
// Fallback for ambiguous times (DST transitions)
|
||||
local_tz.from_local_datetime(&new_end).earliest().unwrap()
|
||||
})
|
||||
.with_timezone(&chrono::Utc);
|
||||
|
||||
let start_date = start_utc.format("%Y-%m-%d").to_string();
|
||||
let start_time = start_utc.format("%H:%M").to_string();
|
||||
let end_date = end_utc.format("%Y-%m-%d").to_string();
|
||||
let end_time = end_utc.format("%H:%M").to_string();
|
||||
|
||||
// Convert existing event data to string formats for the API
|
||||
let status_str = match original_event.status {
|
||||
@@ -908,14 +1120,8 @@ pub fn App() -> Html {
|
||||
match result {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Event updated successfully".into());
|
||||
// Add small delay before reload to let any pending requests complete
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
gloo_timers::future::sleep(std::time::Duration::from_millis(
|
||||
100,
|
||||
))
|
||||
.await;
|
||||
web_sys::window().unwrap().location().reload().unwrap();
|
||||
});
|
||||
// Refresh calendar data without page reload
|
||||
refresh_callback.emit(());
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(
|
||||
@@ -1002,13 +1208,9 @@ pub fn App() -> Html {
|
||||
<Sidebar
|
||||
user_info={(*user_info).clone()}
|
||||
on_logout={on_logout}
|
||||
on_create_calendar={Callback::from({
|
||||
let create_modal_open = create_modal_open.clone();
|
||||
move |_| create_modal_open.set(true)
|
||||
})}
|
||||
on_create_external_calendar={Callback::from({
|
||||
let external_calendar_modal_open = external_calendar_modal_open.clone();
|
||||
move |_| external_calendar_modal_open.set(true)
|
||||
on_add_calendar={Callback::from({
|
||||
let calendar_management_modal_open = calendar_management_modal_open.clone();
|
||||
move |_| calendar_management_modal_open.set(true)
|
||||
})}
|
||||
external_calendars={(*external_calendars).clone()}
|
||||
on_external_calendar_toggle={Callback::from({
|
||||
@@ -1093,34 +1295,65 @@ pub fn App() -> Html {
|
||||
on_external_calendar_refresh={Callback::from({
|
||||
let external_calendar_events = external_calendar_events.clone();
|
||||
let external_calendars = external_calendars.clone();
|
||||
let refreshing_calendar_id = refreshing_calendar_id.clone();
|
||||
move |id: i32| {
|
||||
let external_calendar_events = external_calendar_events.clone();
|
||||
let external_calendars = external_calendars.clone();
|
||||
let refreshing_calendar_id = refreshing_calendar_id.clone();
|
||||
|
||||
// Set loading state
|
||||
refreshing_calendar_id.set(Some(id));
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
web_sys::console::log_1(&format!("🔄 Refreshing external calendar {}", id).into());
|
||||
|
||||
// Force refresh of this specific calendar
|
||||
if let Ok(mut events) = CalendarService::fetch_external_calendar_events(id).await {
|
||||
// Set calendar_path for color matching
|
||||
for event in &mut events {
|
||||
event.calendar_path = Some(format!("external_{}", id));
|
||||
}
|
||||
|
||||
// Update events for this calendar
|
||||
let mut all_events = (*external_calendar_events).clone();
|
||||
// Remove old events from this calendar
|
||||
all_events.retain(|e| {
|
||||
if let Some(ref calendar_path) = e.calendar_path {
|
||||
calendar_path != &format!("external_{}", id)
|
||||
} else {
|
||||
true
|
||||
match CalendarService::fetch_external_calendar_events(id).await {
|
||||
Ok(mut events) => {
|
||||
web_sys::console::log_1(&format!("✅ Successfully refreshed calendar {} with {} events", id, events.len()).into());
|
||||
|
||||
// Set calendar_path for color matching
|
||||
for event in &mut events {
|
||||
event.calendar_path = Some(format!("external_{}", id));
|
||||
}
|
||||
});
|
||||
// Add new events
|
||||
all_events.extend(events);
|
||||
external_calendar_events.set(all_events);
|
||||
|
||||
// Update the last_fetched timestamp in calendars list
|
||||
if let Ok(calendars) = CalendarService::get_external_calendars().await {
|
||||
external_calendars.set(calendars);
|
||||
|
||||
// Update events for this calendar
|
||||
let mut all_events = (*external_calendar_events).clone();
|
||||
// Remove old events from this calendar
|
||||
all_events.retain(|e| {
|
||||
if let Some(ref calendar_path) = e.calendar_path {
|
||||
calendar_path != &format!("external_{}", id)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
// Add new events
|
||||
all_events.extend(events);
|
||||
external_calendar_events.set(all_events);
|
||||
|
||||
// Update the last_fetched timestamp in calendars list
|
||||
match CalendarService::get_external_calendars().await {
|
||||
Ok(calendars) => {
|
||||
external_calendars.set(calendars);
|
||||
web_sys::console::log_1(&"✅ Calendar list updated with new timestamps".into());
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(&format!("⚠️ Failed to update calendar list: {}", err).into());
|
||||
}
|
||||
}
|
||||
|
||||
// Clear loading state on success
|
||||
refreshing_calendar_id.set(None);
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(&format!("❌ Failed to refresh calendar {}: {}", id, err).into());
|
||||
// Show error to user
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.alert_with_message(&format!("Failed to refresh calendar: {}", err));
|
||||
}
|
||||
|
||||
// Clear loading state on error
|
||||
refreshing_calendar_id.set(None);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1130,6 +1363,7 @@ pub fn App() -> Html {
|
||||
on_color_change={on_color_change}
|
||||
on_color_picker_toggle={on_color_picker_toggle}
|
||||
available_colors={(*available_colors).clone()}
|
||||
refreshing_calendar_id={(*refreshing_calendar_id).clone()}
|
||||
on_calendar_context_menu={on_calendar_context_menu}
|
||||
on_calendar_visibility_toggle={Callback::from({
|
||||
let user_info = user_info.clone();
|
||||
@@ -1188,20 +1422,20 @@ pub fn App() -> Html {
|
||||
}
|
||||
}
|
||||
|
||||
<CreateCalendarModal
|
||||
is_open={*create_modal_open}
|
||||
<CalendarManagementModal
|
||||
is_open={*calendar_management_modal_open}
|
||||
on_close={Callback::from({
|
||||
let create_modal_open = create_modal_open.clone();
|
||||
move |_| create_modal_open.set(false)
|
||||
let calendar_management_modal_open = calendar_management_modal_open.clone();
|
||||
move |_| calendar_management_modal_open.set(false)
|
||||
})}
|
||||
on_create={Callback::from({
|
||||
on_create_calendar={Callback::from({
|
||||
let auth_token = auth_token.clone();
|
||||
let refresh_calendars = refresh_calendars.clone();
|
||||
let create_modal_open = create_modal_open.clone();
|
||||
let calendar_management_modal_open = calendar_management_modal_open.clone();
|
||||
move |(name, description, color): (String, Option<String>, Option<String>)| {
|
||||
if let Some(token) = (*auth_token).clone() {
|
||||
let refresh_calendars = refresh_calendars.clone();
|
||||
let create_modal_open = create_modal_open.clone();
|
||||
let calendar_management_modal_open = calendar_management_modal_open.clone();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let calendar_service = CalendarService::new();
|
||||
@@ -1220,17 +1454,41 @@ pub fn App() -> Html {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Calendar created successfully!".into());
|
||||
refresh_calendars.emit(());
|
||||
create_modal_open.set(false);
|
||||
calendar_management_modal_open.set(false);
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::log_1(&format!("Failed to create calendar: {}", err).into());
|
||||
create_modal_open.set(false);
|
||||
calendar_management_modal_open.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
})}
|
||||
on_external_success={Callback::from({
|
||||
let external_calendars = external_calendars.clone();
|
||||
let calendar_management_modal_open = calendar_management_modal_open.clone();
|
||||
move |new_id: i32| {
|
||||
// Refresh external calendars list
|
||||
let external_calendars = external_calendars.clone();
|
||||
let calendar_management_modal_open = calendar_management_modal_open.clone();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let calendar_service = CalendarService::new();
|
||||
match CalendarService::get_external_calendars().await {
|
||||
Ok(calendars) => {
|
||||
external_calendars.set(calendars);
|
||||
calendar_management_modal_open.set(false);
|
||||
web_sys::console::log_1(&format!("External calendar {} added successfully!", new_id).into());
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(&format!("Failed to refresh external calendars: {}", err).into());
|
||||
calendar_management_modal_open.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})}
|
||||
available_colors={available_colors.iter().map(|c| c.to_string()).collect::<Vec<_>>()}
|
||||
/>
|
||||
|
||||
@@ -1303,10 +1561,10 @@ pub fn App() -> Html {
|
||||
let auth_token = auth_token.clone();
|
||||
let event_context_menu_event = event_context_menu_event.clone();
|
||||
let event_context_menu_open = event_context_menu_open.clone();
|
||||
let refresh_calendars = refresh_calendars.clone();
|
||||
let refresh_calendar_data = refresh_calendar_data.clone();
|
||||
move |delete_action: DeleteAction| {
|
||||
if let (Some(token), Some(event)) = ((*auth_token).clone(), (*event_context_menu_event).clone()) {
|
||||
let _refresh_calendars = refresh_calendars.clone();
|
||||
let refresh_calendar_data = refresh_calendar_data.clone();
|
||||
let event_context_menu_open = event_context_menu_open.clone();
|
||||
|
||||
// Log the delete action for now - we'll implement different behaviors later
|
||||
@@ -1316,6 +1574,7 @@ pub fn App() -> Html {
|
||||
DeleteAction::DeleteSeries => web_sys::console::log_1(&"Delete entire series".into()),
|
||||
}
|
||||
|
||||
let refresh_callback = refresh_calendar_data.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let calendar_service = CalendarService::new();
|
||||
|
||||
@@ -1363,8 +1622,8 @@ pub fn App() -> Html {
|
||||
|
||||
// Close the context menu
|
||||
event_context_menu_open.set(false);
|
||||
// Force a page reload to refresh the calendar events
|
||||
web_sys::window().unwrap().location().reload().unwrap();
|
||||
// Refresh calendar data without page reload
|
||||
refresh_callback.emit(());
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::log_1(&format!("Failed to delete event: {}", err).into());
|
||||
@@ -1378,6 +1637,17 @@ pub fn App() -> Html {
|
||||
}
|
||||
}
|
||||
})}
|
||||
on_view_details={Callback::from({
|
||||
let event_context_menu_open = event_context_menu_open.clone();
|
||||
let view_event_modal_open = view_event_modal_open.clone();
|
||||
let view_event_modal_event = view_event_modal_event.clone();
|
||||
move |event: VEvent| {
|
||||
// Set the event for viewing (read-only mode)
|
||||
view_event_modal_event.set(Some(event));
|
||||
event_context_menu_open.set(false);
|
||||
view_event_modal_open.set(true);
|
||||
}
|
||||
})}
|
||||
/>
|
||||
|
||||
<CalendarContextMenu
|
||||
@@ -1413,59 +1683,28 @@ pub fn App() -> Html {
|
||||
available_calendars={user_info.as_ref().map(|ui| ui.calendars.clone()).unwrap_or_default()}
|
||||
/>
|
||||
|
||||
<ExternalCalendarModal
|
||||
is_open={*external_calendar_modal_open}
|
||||
|
||||
<EventModal
|
||||
event={if *view_event_modal_open { (*view_event_modal_event).clone() } else { None }}
|
||||
on_close={Callback::from({
|
||||
let external_calendar_modal_open = external_calendar_modal_open.clone();
|
||||
move |_| external_calendar_modal_open.set(false)
|
||||
})}
|
||||
on_success={Callback::from({
|
||||
let external_calendars = external_calendars.clone();
|
||||
let external_calendar_events = external_calendar_events.clone();
|
||||
move |new_calendar_id: i32| {
|
||||
let external_calendars = external_calendars.clone();
|
||||
let external_calendar_events = external_calendar_events.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// First, refresh the calendar list to get the new calendar
|
||||
match CalendarService::get_external_calendars().await {
|
||||
Ok(calendars) => {
|
||||
external_calendars.set(calendars.clone());
|
||||
|
||||
// Then immediately fetch events for the new calendar if it's visible
|
||||
if let Some(new_calendar) = calendars.iter().find(|c| c.id == new_calendar_id) {
|
||||
if new_calendar.is_visible {
|
||||
match CalendarService::fetch_external_calendar_events(new_calendar_id).await {
|
||||
Ok(mut events) => {
|
||||
// Set calendar_path for color matching
|
||||
for event in &mut events {
|
||||
event.calendar_path = Some(format!("external_{}", new_calendar_id));
|
||||
}
|
||||
|
||||
// Add the new calendar's events to existing events
|
||||
let mut all_events = (*external_calendar_events).clone();
|
||||
all_events.extend(events);
|
||||
external_calendar_events.set(all_events);
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::log_1(
|
||||
&format!("Failed to fetch events for new calendar {}: {}", new_calendar_id, e).into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::log_1(
|
||||
&format!("Failed to refresh calendars after creation: {}", err).into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
let view_event_modal_open = view_event_modal_open.clone();
|
||||
let view_event_modal_event = view_event_modal_event.clone();
|
||||
move |_| {
|
||||
view_event_modal_open.set(false);
|
||||
view_event_modal_event.set(None);
|
||||
}
|
||||
})}
|
||||
/>
|
||||
|
||||
// Mobile warning modal
|
||||
<MobileWarningModal
|
||||
is_open={*mobile_warning_open}
|
||||
on_close={on_mobile_warning_close}
|
||||
/>
|
||||
</div>
|
||||
|
||||
// Hidden print copy that gets shown only during printing
|
||||
<div id="print-preview-copy" class="print-preview-paper" style="display: none;"></div>
|
||||
</BrowserRouter>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,50 @@ impl AuthService {
|
||||
self.post_json("/auth/login", &request).await
|
||||
}
|
||||
|
||||
pub async fn verify_token(&self, token: &str) -> Result<bool, 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!("{}/auth/verify", 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!("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))?;
|
||||
|
||||
if resp.ok() {
|
||||
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")?;
|
||||
|
||||
// Parse the response to get the "valid" field
|
||||
let response: serde_json::Value = serde_json::from_str(&text_string)
|
||||
.map_err(|e| format!("JSON parsing failed: {}", e))?;
|
||||
|
||||
Ok(response.get("valid").and_then(|v| v.as_bool()).unwrap_or(false))
|
||||
} else {
|
||||
Ok(false) // Invalid token
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method for POST requests with JSON body
|
||||
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::components::{
|
||||
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
|
||||
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, PrintPreviewModal, ViewMode, WeekView,
|
||||
};
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
|
||||
@@ -389,6 +389,15 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
// Handle print calendar preview
|
||||
let show_print_preview = use_state(|| false);
|
||||
let on_print = {
|
||||
let show_print_preview = show_print_preview.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
show_print_preview.set(true);
|
||||
})
|
||||
};
|
||||
|
||||
// Handle drag-to-create event
|
||||
let on_create_event = {
|
||||
let show_create_modal = show_create_modal.clone();
|
||||
@@ -457,6 +466,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
on_today={on_today}
|
||||
time_increment={Some(*time_increment)}
|
||||
on_time_increment_toggle={Some(on_time_increment_toggle)}
|
||||
on_print={Some(on_print)}
|
||||
/>
|
||||
|
||||
{
|
||||
@@ -563,6 +573,32 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
// Print preview modal
|
||||
{
|
||||
if *show_print_preview {
|
||||
html! {
|
||||
<PrintPreviewModal
|
||||
on_close={{
|
||||
let show_print_preview = show_print_preview.clone();
|
||||
Callback::from(move |_| {
|
||||
show_print_preview.set(false);
|
||||
})
|
||||
}}
|
||||
view_mode={props.view.clone()}
|
||||
current_date={*current_date}
|
||||
selected_date={*selected_date}
|
||||
events={(*events).clone()}
|
||||
user_info={props.user_info.clone()}
|
||||
external_calendars={props.external_calendars.clone()}
|
||||
time_increment={*time_increment}
|
||||
today={today}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ pub struct CalendarHeaderProps {
|
||||
pub time_increment: Option<u32>,
|
||||
#[prop_or_default]
|
||||
pub on_time_increment_toggle: Option<Callback<MouseEvent>>,
|
||||
#[prop_or_default]
|
||||
pub on_print: Option<Callback<MouseEvent>>,
|
||||
}
|
||||
|
||||
#[function_component(CalendarHeader)]
|
||||
@@ -39,6 +41,17 @@ pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
{
|
||||
if let Some(print_callback) = &props.on_print {
|
||||
html! {
|
||||
<button class="print-button" onclick={print_callback.clone()} title="Print Calendar">
|
||||
<i class="fas fa-print"></i>
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<h2 class="month-year">{title}</h2>
|
||||
<div class="header-right">
|
||||
|
||||
@@ -55,7 +55,7 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
|
||||
{
|
||||
if props.color_picker_open {
|
||||
html! {
|
||||
<div class="color-picker">
|
||||
<div class="color-picker-dropdown">
|
||||
{
|
||||
props.available_colors.iter().map(|color| {
|
||||
let color_str = color.clone();
|
||||
|
||||
449
frontend/src/components/calendar_management_modal.rs
Normal file
449
frontend/src/components/calendar_management_modal.rs
Normal file
@@ -0,0 +1,449 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlInputElement;
|
||||
use wasm_bindgen::JsCast;
|
||||
use crate::services::calendar_service::CalendarService;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum CalendarTab {
|
||||
Create,
|
||||
External,
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CalendarManagementModalProps {
|
||||
pub is_open: bool,
|
||||
pub on_close: Callback<()>,
|
||||
pub on_create_calendar: Callback<(String, Option<String>, Option<String>)>, // name, description, color
|
||||
pub on_external_success: Callback<i32>, // Pass the newly created external calendar ID
|
||||
pub available_colors: Vec<String>,
|
||||
}
|
||||
|
||||
#[function_component(CalendarManagementModal)]
|
||||
pub fn calendar_management_modal(props: &CalendarManagementModalProps) -> Html {
|
||||
let active_tab = use_state(|| CalendarTab::Create);
|
||||
|
||||
// Create Calendar state
|
||||
let calendar_name = use_state(|| String::new());
|
||||
let description = use_state(|| String::new());
|
||||
let selected_color = use_state(|| None::<String>);
|
||||
let create_error_message = use_state(|| None::<String>);
|
||||
let is_creating = use_state(|| false);
|
||||
|
||||
// External Calendar state
|
||||
let external_name = use_state(|| String::new());
|
||||
let external_url = use_state(|| String::new());
|
||||
let external_selected_color = use_state(|| Some("#4285f4".to_string()));
|
||||
let external_is_loading = use_state(|| false);
|
||||
let external_error_message = use_state(|| None::<String>);
|
||||
|
||||
// Reset state when modal opens
|
||||
use_effect_with(props.is_open, {
|
||||
let calendar_name = calendar_name.clone();
|
||||
let description = description.clone();
|
||||
let selected_color = selected_color.clone();
|
||||
let create_error_message = create_error_message.clone();
|
||||
let is_creating = is_creating.clone();
|
||||
let external_name = external_name.clone();
|
||||
let external_url = external_url.clone();
|
||||
let external_is_loading = external_is_loading.clone();
|
||||
let external_error_message = external_error_message.clone();
|
||||
let external_selected_color = external_selected_color.clone();
|
||||
let active_tab = active_tab.clone();
|
||||
|
||||
move |is_open| {
|
||||
if *is_open {
|
||||
// Reset all state when modal opens
|
||||
calendar_name.set(String::new());
|
||||
description.set(String::new());
|
||||
selected_color.set(None);
|
||||
create_error_message.set(None);
|
||||
is_creating.set(false);
|
||||
external_name.set(String::new());
|
||||
external_url.set(String::new());
|
||||
external_is_loading.set(false);
|
||||
external_error_message.set(None);
|
||||
external_selected_color.set(Some("#4285f4".to_string()));
|
||||
active_tab.set(CalendarTab::Create);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let on_tab_click = {
|
||||
let active_tab = active_tab.clone();
|
||||
Callback::from(move |tab: CalendarTab| {
|
||||
active_tab.set(tab);
|
||||
})
|
||||
};
|
||||
|
||||
let on_backdrop_click = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
if let Some(target) = e.target() {
|
||||
let element = target.dyn_into::<web_sys::Element>().unwrap();
|
||||
if element.class_list().contains("modal-backdrop") {
|
||||
on_close.emit(());
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Create Calendar handlers
|
||||
let on_name_change = {
|
||||
let calendar_name = calendar_name.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: 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_color_select = {
|
||||
let selected_color = selected_color.clone();
|
||||
Callback::from(move |color: String| {
|
||||
selected_color.set(Some(color));
|
||||
})
|
||||
};
|
||||
|
||||
let on_external_color_select = {
|
||||
let external_selected_color = external_selected_color.clone();
|
||||
Callback::from(move |color: String| {
|
||||
external_selected_color.set(Some(color));
|
||||
})
|
||||
};
|
||||
|
||||
let on_create_submit = {
|
||||
let calendar_name = calendar_name.clone();
|
||||
let description = description.clone();
|
||||
let selected_color = selected_color.clone();
|
||||
let create_error_message = create_error_message.clone();
|
||||
let is_creating = is_creating.clone();
|
||||
let on_create_calendar = props.on_create_calendar.clone();
|
||||
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
let name = (*calendar_name).trim();
|
||||
if name.is_empty() {
|
||||
create_error_message.set(Some("Calendar name is required".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
is_creating.set(true);
|
||||
create_error_message.set(None);
|
||||
|
||||
let desc = if description.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((*description).clone())
|
||||
};
|
||||
|
||||
on_create_calendar.emit((name.to_string(), desc, (*selected_color).clone()));
|
||||
})
|
||||
};
|
||||
|
||||
// External Calendar handlers
|
||||
let on_external_submit = {
|
||||
let external_name = external_name.clone();
|
||||
let external_url = external_url.clone();
|
||||
let external_selected_color = external_selected_color.clone();
|
||||
let external_is_loading = external_is_loading.clone();
|
||||
let external_error_message = external_error_message.clone();
|
||||
let on_close = props.on_close.clone();
|
||||
let on_external_success = props.on_external_success.clone();
|
||||
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
let name = (*external_name).trim().to_string();
|
||||
let url = (*external_url).trim().to_string();
|
||||
let color = (*external_selected_color).clone().unwrap_or_else(|| "#4285f4".to_string());
|
||||
|
||||
// Debug logging to understand the issue
|
||||
web_sys::console::log_1(&format!("External calendar form submission - Name: '{}', URL: '{}'", name, url).into());
|
||||
|
||||
if name.is_empty() {
|
||||
external_error_message.set(Some("Calendar name is required".to_string()));
|
||||
web_sys::console::log_1(&"Validation failed: empty name".into());
|
||||
return;
|
||||
}
|
||||
|
||||
if url.is_empty() {
|
||||
external_error_message.set(Some("Calendar URL is required".to_string()));
|
||||
web_sys::console::log_1(&"Validation failed: empty URL".into());
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic URL validation
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
external_error_message.set(Some("Please enter a valid HTTP or HTTPS URL".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
external_is_loading.set(true);
|
||||
external_error_message.set(None);
|
||||
|
||||
let external_is_loading = external_is_loading.clone();
|
||||
let external_error_message = external_error_message.clone();
|
||||
let on_close = on_close.clone();
|
||||
let on_external_success = on_external_success.clone();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let calendar_service = CalendarService::new();
|
||||
|
||||
match CalendarService::create_external_calendar(&name, &url, &color).await {
|
||||
Ok(calendar) => {
|
||||
external_is_loading.set(false);
|
||||
on_close.emit(());
|
||||
on_external_success.emit(calendar.id);
|
||||
}
|
||||
Err(e) => {
|
||||
external_is_loading.set(false);
|
||||
external_error_message.set(Some(format!("Failed to add calendar: {}", e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
// External input change handlers
|
||||
let on_external_name_change = {
|
||||
let external_name = external_name.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
external_name.set(input.value());
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_external_url_change = {
|
||||
let external_url = external_url.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||
external_url.set(input.value());
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if !props.is_open {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
html! {
|
||||
<div class="modal-backdrop" onclick={on_backdrop_click}>
|
||||
<div class="modal-content calendar-management-modal">
|
||||
<div class="modal-header">
|
||||
<h2>{"Add Calendar"}</h2>
|
||||
<button class="modal-close" onclick={
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||
}>{"×"}</button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-management-tabs">
|
||||
<button
|
||||
class={if *active_tab == CalendarTab::Create { "tab-button active" } else { "tab-button" }}
|
||||
onclick={
|
||||
let on_tab_click = on_tab_click.clone();
|
||||
Callback::from(move |_: MouseEvent| on_tab_click.emit(CalendarTab::Create))
|
||||
}
|
||||
>
|
||||
{"Create Calendar"}
|
||||
</button>
|
||||
<button
|
||||
class={if *active_tab == CalendarTab::External { "tab-button active" } else { "tab-button" }}
|
||||
onclick={
|
||||
let on_tab_click = on_tab_click.clone();
|
||||
Callback::from(move |_: MouseEvent| on_tab_click.emit(CalendarTab::External))
|
||||
}
|
||||
>
|
||||
{"Add External"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
{
|
||||
match *active_tab {
|
||||
CalendarTab::Create => html! {
|
||||
<form onsubmit={on_create_submit}>
|
||||
<div class="form-group">
|
||||
<label for="calendar-name">{"Calendar Name"}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="calendar-name"
|
||||
value={(*calendar_name).clone()}
|
||||
oninput={on_name_change}
|
||||
placeholder="Enter calendar name"
|
||||
disabled={*is_creating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="calendar-description">{"Description (optional)"}</label>
|
||||
<textarea
|
||||
id="calendar-description"
|
||||
value={(*description).clone()}
|
||||
oninput={on_description_change}
|
||||
placeholder="Enter calendar description"
|
||||
disabled={*is_creating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{"Calendar Color"}</label>
|
||||
<div class="color-picker">
|
||||
{
|
||||
props.available_colors.iter().map(|color| {
|
||||
let is_selected = selected_color.as_ref() == Some(color);
|
||||
let color_clone = color.clone();
|
||||
let on_color_select = on_color_select.clone();
|
||||
|
||||
html! {
|
||||
<div
|
||||
key={color.clone()}
|
||||
class={if is_selected { "color-option selected" } else { "color-option" }}
|
||||
style={format!("background-color: {}", color)}
|
||||
onclick={Callback::from(move |_: MouseEvent| {
|
||||
on_color_select.emit(color_clone.clone());
|
||||
})}
|
||||
/>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
if let Some(ref error) = *create_error_message {
|
||||
html! {
|
||||
<div class="error-message">
|
||||
{error}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="cancel-button"
|
||||
onclick={
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||
}
|
||||
disabled={*is_creating}
|
||||
>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="create-button"
|
||||
disabled={*is_creating}
|
||||
>
|
||||
{if *is_creating { "Creating..." } else { "Create Calendar" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
},
|
||||
CalendarTab::External => html! {
|
||||
<form onsubmit={on_external_submit}>
|
||||
<div class="form-group">
|
||||
<label for="external-name">{"Calendar Name"}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="external-name"
|
||||
value={(*external_name).clone()}
|
||||
onchange={on_external_name_change}
|
||||
placeholder="Enter calendar name"
|
||||
disabled={*external_is_loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="external-url">{"Calendar URL"}</label>
|
||||
<input
|
||||
type="url"
|
||||
id="external-url"
|
||||
value={(*external_url).clone()}
|
||||
onchange={on_external_url_change}
|
||||
placeholder="https://example.com/calendar.ics"
|
||||
disabled={*external_is_loading}
|
||||
/>
|
||||
<small class="help-text">
|
||||
{"Enter the ICS/CalDAV URL for your external calendar"}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{"Calendar Color"}</label>
|
||||
<div class="color-picker">
|
||||
{
|
||||
props.available_colors.iter().map(|color| {
|
||||
let is_selected = external_selected_color.as_ref() == Some(color);
|
||||
let color_clone = color.clone();
|
||||
let on_external_color_select = on_external_color_select.clone();
|
||||
|
||||
html! {
|
||||
<div
|
||||
key={color.clone()}
|
||||
class={if is_selected { "color-option selected" } else { "color-option" }}
|
||||
style={format!("background-color: {}", color)}
|
||||
onclick={Callback::from(move |_: MouseEvent| {
|
||||
on_external_color_select.emit(color_clone.clone());
|
||||
})}
|
||||
/>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
if let Some(ref error) = *external_error_message {
|
||||
html! {
|
||||
<div class="error-message">
|
||||
{error}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="cancel-button"
|
||||
onclick={
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||
}
|
||||
disabled={*external_is_loading}
|
||||
>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="create-button"
|
||||
disabled={*external_is_loading}
|
||||
>
|
||||
{if *external_is_loading { "Adding..." } else { "Add Calendar" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ pub struct EventContextMenuProps {
|
||||
pub event: Option<VEvent>,
|
||||
pub on_edit: Callback<EditAction>,
|
||||
pub on_delete: Callback<DeleteAction>,
|
||||
pub on_view_details: Callback<VEvent>,
|
||||
pub on_close: Callback<()>,
|
||||
}
|
||||
|
||||
@@ -90,6 +91,14 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
||||
.as_ref()
|
||||
.map(|event| event.rrule.is_some())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Check if the event is from an external calendar (read-only)
|
||||
let is_external = props
|
||||
.event
|
||||
.as_ref()
|
||||
.and_then(|event| event.calendar_path.as_ref())
|
||||
.map(|path| path.starts_with("external_"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let create_edit_callback = |action: EditAction| {
|
||||
let on_edit = props.on_edit.clone();
|
||||
@@ -109,6 +118,18 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
let create_view_details_callback = {
|
||||
let on_view_details = props.on_view_details.clone();
|
||||
let on_close = props.on_close.clone();
|
||||
let event = props.event.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
if let Some(event) = &event {
|
||||
on_view_details.emit(event.clone());
|
||||
}
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div
|
||||
ref={menu_ref}
|
||||
@@ -116,7 +137,15 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
||||
style={style}
|
||||
>
|
||||
{
|
||||
if is_recurring {
|
||||
if is_external {
|
||||
// External calendar events are read-only - only show "View Details"
|
||||
html! {
|
||||
<div class="context-menu-item" onclick={create_view_details_callback}>
|
||||
{"View Event Details"}
|
||||
</div>
|
||||
}
|
||||
} else if is_recurring {
|
||||
// Regular recurring events - show edit options
|
||||
html! {
|
||||
<>
|
||||
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
|
||||
@@ -131,6 +160,7 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
// Regular single events - show edit option
|
||||
html! {
|
||||
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
|
||||
{"Edit Event"}
|
||||
@@ -139,26 +169,32 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
||||
}
|
||||
}
|
||||
{
|
||||
if is_recurring {
|
||||
html! {
|
||||
<>
|
||||
if !is_external {
|
||||
// Only show delete options for non-external events
|
||||
if is_recurring {
|
||||
html! {
|
||||
<>
|
||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
||||
{"Delete This Event"}
|
||||
</div>
|
||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteFollowing)}>
|
||||
{"Delete Following Events"}
|
||||
</div>
|
||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteSeries)}>
|
||||
{"Delete Entire Series"}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
||||
{"Delete This Event"}
|
||||
{"Delete Event"}
|
||||
</div>
|
||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteFollowing)}>
|
||||
{"Delete Following Events"}
|
||||
</div>
|
||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteSeries)}>
|
||||
{"Delete Entire Series"}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
||||
{"Delete Event"}
|
||||
</div>
|
||||
}
|
||||
// No delete options for external events
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -152,13 +152,53 @@ impl EventCreationData {
|
||||
Option<u32>, // recurrence_count
|
||||
Option<String>, // recurrence_until
|
||||
) {
|
||||
use chrono::{Local, TimeZone};
|
||||
|
||||
// Convert local date/time to UTC for backend
|
||||
let (utc_start_date, utc_start_time, utc_end_date, utc_end_time) = if self.all_day {
|
||||
// For all-day events, just use the dates as-is (no time conversion needed)
|
||||
(
|
||||
self.start_date.format("%Y-%m-%d").to_string(),
|
||||
self.start_time.format("%H:%M").to_string(),
|
||||
self.end_date.format("%Y-%m-%d").to_string(),
|
||||
self.end_time.format("%H:%M").to_string(),
|
||||
)
|
||||
} else {
|
||||
// Convert local date/time to UTC, but preserve original local dates
|
||||
let start_local = Local.from_local_datetime(&self.start_date.and_time(self.start_time)).single();
|
||||
let end_local = Local.from_local_datetime(&self.end_date.and_time(self.end_time)).single();
|
||||
|
||||
if let (Some(start_dt), Some(end_dt)) = (start_local, end_local) {
|
||||
let start_utc = start_dt.with_timezone(&chrono::Utc);
|
||||
let end_utc = end_dt.with_timezone(&chrono::Utc);
|
||||
|
||||
// IMPORTANT: Use original local dates, not UTC dates!
|
||||
// This ensures events display on the correct day regardless of timezone conversion
|
||||
(
|
||||
self.start_date.format("%Y-%m-%d").to_string(),
|
||||
start_utc.format("%H:%M").to_string(),
|
||||
self.end_date.format("%Y-%m-%d").to_string(),
|
||||
end_utc.format("%H:%M").to_string(),
|
||||
)
|
||||
} else {
|
||||
// Fallback if timezone conversion fails - use local time as-is
|
||||
web_sys::console::warn_1(&"⚠️ Failed to convert local time to UTC, using local time".into());
|
||||
(
|
||||
self.start_date.format("%Y-%m-%d").to_string(),
|
||||
self.start_time.format("%H:%M").to_string(),
|
||||
self.end_date.format("%Y-%m-%d").to_string(),
|
||||
self.end_time.format("%H:%M").to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
(
|
||||
self.title.clone(),
|
||||
self.description.clone(),
|
||||
self.start_date.format("%Y-%m-%d").to_string(),
|
||||
self.start_time.format("%H:%M").to_string(),
|
||||
self.end_date.format("%Y-%m-%d").to_string(),
|
||||
self.end_time.format("%H:%M").to_string(),
|
||||
utc_start_date,
|
||||
utc_start_time,
|
||||
utc_end_date,
|
||||
utc_end_time,
|
||||
self.location.clone(),
|
||||
self.all_day,
|
||||
format!("{:?}", self.status).to_uppercase(),
|
||||
|
||||
@@ -23,6 +23,9 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
// Remember checkboxes state - default to checked
|
||||
let remember_server = use_state(|| true);
|
||||
let remember_username = use_state(|| true);
|
||||
|
||||
// Password visibility toggle
|
||||
let show_password = use_state(|| false);
|
||||
|
||||
let server_url_ref = use_node_ref();
|
||||
let username_ref = use_node_ref();
|
||||
@@ -30,17 +33,31 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
|
||||
let on_server_url_change = {
|
||||
let server_url = server_url.clone();
|
||||
let remember_server = remember_server.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||
server_url.set(target.value());
|
||||
let new_value = target.value();
|
||||
server_url.set(new_value.clone());
|
||||
|
||||
// Save to localStorage immediately if remember is checked
|
||||
if *remember_server {
|
||||
let _ = LocalStorage::set("remembered_server_url", new_value);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_username_change = {
|
||||
let username = username.clone();
|
||||
let remember_username = remember_username.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||
username.set(target.value());
|
||||
let new_value = target.value();
|
||||
username.set(new_value.clone());
|
||||
|
||||
// Save to localStorage immediately if remember is checked
|
||||
if *remember_username {
|
||||
let _ = LocalStorage::set("remembered_username", new_value);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
@@ -83,6 +100,13 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_toggle_password_visibility = {
|
||||
let show_password = show_password.clone();
|
||||
Callback::from(move |_| {
|
||||
show_password.set(!*show_password);
|
||||
})
|
||||
};
|
||||
|
||||
let on_submit = {
|
||||
let server_url = server_url.clone();
|
||||
@@ -90,6 +114,8 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
let password = password.clone();
|
||||
let error_message = error_message.clone();
|
||||
let is_loading = is_loading.clone();
|
||||
let remember_server = remember_server.clone();
|
||||
let remember_username = remember_username.clone();
|
||||
let on_login = props.on_login.clone();
|
||||
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
@@ -100,6 +126,8 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
let password = (*password).clone();
|
||||
let error_message = error_message.clone();
|
||||
let is_loading = is_loading.clone();
|
||||
let remember_server_value = *remember_server;
|
||||
let remember_username_value = *remember_username;
|
||||
let on_login = on_login.clone();
|
||||
|
||||
// Basic client-side validation
|
||||
@@ -140,11 +168,23 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
let _ = LocalStorage::set("user_preferences", &prefs_json);
|
||||
}
|
||||
|
||||
// Save server URL and username to LocalStorage if remember checkboxes are checked
|
||||
if remember_server_value {
|
||||
let _ = LocalStorage::set("remembered_server_url", server_url.clone());
|
||||
}
|
||||
if remember_username_value {
|
||||
let _ = LocalStorage::set("remembered_username", username.clone());
|
||||
}
|
||||
|
||||
is_loading.set(false);
|
||||
on_login.emit(token);
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::log_1(&format!("❌ Login failed: {}", err).into());
|
||||
// Clear any existing invalid tokens
|
||||
let _ = LocalStorage::delete("auth_token");
|
||||
let _ = LocalStorage::delete("session_token");
|
||||
let _ = LocalStorage::delete("caldav_credentials");
|
||||
error_message.set(Some(err));
|
||||
is_loading.set(false);
|
||||
}
|
||||
@@ -160,59 +200,79 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
<form onsubmit={on_submit}>
|
||||
<div class="form-group">
|
||||
<label for="server_url">{"CalDAV Server URL"}</label>
|
||||
<input
|
||||
ref={server_url_ref}
|
||||
type="text"
|
||||
id="server_url"
|
||||
placeholder="https://your-caldav-server.com/dav/"
|
||||
value={(*server_url).clone()}
|
||||
onchange={on_server_url_change}
|
||||
disabled={*is_loading}
|
||||
/>
|
||||
<div class="remember-checkbox">
|
||||
<div class="input-with-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember_server"
|
||||
checked={*remember_server}
|
||||
onchange={on_remember_server_change}
|
||||
ref={server_url_ref}
|
||||
type="text"
|
||||
id="server_url"
|
||||
placeholder="https://your-caldav-server.com/dav/"
|
||||
value={(*server_url).clone()}
|
||||
onchange={on_server_url_change}
|
||||
disabled={*is_loading}
|
||||
tabindex="1"
|
||||
/>
|
||||
<label for="remember_server">{"Remember server"}</label>
|
||||
<div class="remember-checkbox">
|
||||
<label for="remember_server">{"Remember"}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember_server"
|
||||
checked={*remember_server}
|
||||
onchange={on_remember_server_change}
|
||||
tabindex="4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">{"Username"}</label>
|
||||
<input
|
||||
ref={username_ref}
|
||||
type="text"
|
||||
id="username"
|
||||
placeholder="Enter your username"
|
||||
value={(*username).clone()}
|
||||
onchange={on_username_change}
|
||||
disabled={*is_loading}
|
||||
/>
|
||||
<div class="remember-checkbox">
|
||||
<div class="input-with-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember_username"
|
||||
checked={*remember_username}
|
||||
onchange={on_remember_username_change}
|
||||
ref={username_ref}
|
||||
type="text"
|
||||
id="username"
|
||||
placeholder="Enter your username"
|
||||
value={(*username).clone()}
|
||||
onchange={on_username_change}
|
||||
disabled={*is_loading}
|
||||
tabindex="2"
|
||||
/>
|
||||
<label for="remember_username">{"Remember username"}</label>
|
||||
<div class="remember-checkbox">
|
||||
<label for="remember_username">{"Remember"}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember_username"
|
||||
checked={*remember_username}
|
||||
onchange={on_remember_username_change}
|
||||
tabindex="5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">{"Password"}</label>
|
||||
<input
|
||||
ref={password_ref}
|
||||
type="password"
|
||||
id="password"
|
||||
placeholder="Enter your password"
|
||||
value={(*password).clone()}
|
||||
onchange={on_password_change}
|
||||
disabled={*is_loading}
|
||||
/>
|
||||
<div class="password-input-container">
|
||||
<input
|
||||
ref={password_ref}
|
||||
type={if *show_password { "text" } else { "password" }}
|
||||
id="password"
|
||||
placeholder="Enter your password"
|
||||
value={(*password).clone()}
|
||||
onchange={on_password_change}
|
||||
disabled={*is_loading}
|
||||
tabindex="3"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="password-toggle-btn"
|
||||
onclick={on_toggle_password_visibility}
|
||||
tabindex="6"
|
||||
title={if *show_password { "Hide password" } else { "Show password" }}
|
||||
>
|
||||
<i class={if *show_password { "fas fa-eye-slash" } else { "fas fa-eye" }}></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
|
||||
96
frontend/src/components/mobile_warning_modal.rs
Normal file
96
frontend/src/components/mobile_warning_modal.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::window;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct MobileWarningModalProps {
|
||||
pub is_open: bool,
|
||||
pub on_close: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(MobileWarningModal)]
|
||||
pub fn mobile_warning_modal(props: &MobileWarningModalProps) -> Html {
|
||||
let on_backdrop_click = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
if let Some(target) = e.target() {
|
||||
let element = target.dyn_into::<web_sys::Element>().unwrap();
|
||||
if element.class_list().contains("modal-overlay") {
|
||||
on_close.emit(());
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if !props.is_open {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
html! {
|
||||
<div class="modal-overlay mobile-warning-overlay" onclick={on_backdrop_click}>
|
||||
<div class="modal-content mobile-warning-modal">
|
||||
<div class="modal-header">
|
||||
<h2>{"Desktop Application"}</h2>
|
||||
<button class="modal-close" onclick={
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||
}>{"×"}</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mobile-warning-icon">
|
||||
{"💻"}
|
||||
</div>
|
||||
<p class="mobile-warning-title">
|
||||
{"This calendar application is designed for desktop usage"}
|
||||
</p>
|
||||
<p class="mobile-warning-description">
|
||||
{"For the best mobile calendar experience, we recommend using dedicated CalDAV apps available on your device's app store:"}
|
||||
</p>
|
||||
<ul class="mobile-warning-apps">
|
||||
<li>
|
||||
<strong>{"iOS:"}</strong>
|
||||
{" Calendar (built-in), Calendars 5, Fantastical"}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{"Android:"}</strong>
|
||||
{" Google Calendar, DAVx5, CalDAV Sync"}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mobile-warning-note">
|
||||
{"These apps can sync with the same CalDAV server you're using here."}
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="continue-anyway-button" onclick={
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||
}>
|
||||
{"Continue Anyway"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to detect mobile devices
|
||||
pub fn is_mobile_device() -> bool {
|
||||
if let Some(window) = window() {
|
||||
let navigator = window.navigator();
|
||||
let user_agent = navigator.user_agent().unwrap_or_default();
|
||||
let user_agent = user_agent.to_lowercase();
|
||||
|
||||
// Check for mobile device indicators
|
||||
user_agent.contains("mobile")
|
||||
|| user_agent.contains("android")
|
||||
|| user_agent.contains("iphone")
|
||||
|| user_agent.contains("ipad")
|
||||
|| user_agent.contains("ipod")
|
||||
|| user_agent.contains("blackberry")
|
||||
|| user_agent.contains("webos")
|
||||
|| user_agent.contains("opera mini")
|
||||
|| user_agent.contains("windows phone")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod calendar;
|
||||
pub mod calendar_context_menu;
|
||||
pub mod calendar_management_modal;
|
||||
pub mod calendar_header;
|
||||
pub mod calendar_list_item;
|
||||
pub mod context_menu;
|
||||
@@ -10,7 +11,9 @@ pub mod event_form;
|
||||
pub mod event_modal;
|
||||
pub mod external_calendar_modal;
|
||||
pub mod login;
|
||||
pub mod mobile_warning_modal;
|
||||
pub mod month_view;
|
||||
pub mod print_preview_modal;
|
||||
pub mod recurring_edit_modal;
|
||||
pub mod route_handler;
|
||||
pub mod sidebar;
|
||||
@@ -18,18 +21,19 @@ pub mod week_view;
|
||||
|
||||
pub use calendar::Calendar;
|
||||
pub use calendar_context_menu::CalendarContextMenu;
|
||||
pub use calendar_management_modal::CalendarManagementModal;
|
||||
pub use calendar_header::CalendarHeader;
|
||||
pub use calendar_list_item::CalendarListItem;
|
||||
pub use context_menu::ContextMenu;
|
||||
pub use create_calendar_modal::CreateCalendarModal;
|
||||
pub use create_event_modal::CreateEventModal;
|
||||
// Re-export event form types for backwards compatibility
|
||||
pub use event_form::EventCreationData;
|
||||
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
|
||||
pub use event_modal::EventModal;
|
||||
pub use external_calendar_modal::ExternalCalendarModal;
|
||||
pub use login::Login;
|
||||
pub use mobile_warning_modal::MobileWarningModal;
|
||||
pub use month_view::MonthView;
|
||||
pub use print_preview_modal::PrintPreviewModal;
|
||||
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
|
||||
pub use route_handler::RouteHandler;
|
||||
pub use sidebar::{Sidebar, Theme, ViewMode};
|
||||
|
||||
362
frontend/src/components/print_preview_modal.rs
Normal file
362
frontend/src/components/print_preview_modal.rs
Normal file
@@ -0,0 +1,362 @@
|
||||
use crate::components::{ViewMode, WeekView, MonthView};
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||
use chrono::NaiveDate;
|
||||
use std::collections::HashMap;
|
||||
use wasm_bindgen::{closure::Closure, JsCast};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct PrintPreviewModalProps {
|
||||
pub on_close: Callback<()>,
|
||||
pub view_mode: ViewMode,
|
||||
pub current_date: NaiveDate,
|
||||
pub selected_date: NaiveDate,
|
||||
pub events: HashMap<NaiveDate, Vec<VEvent>>,
|
||||
pub user_info: Option<UserInfo>,
|
||||
pub external_calendars: Vec<ExternalCalendar>,
|
||||
pub time_increment: u32,
|
||||
pub today: NaiveDate,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html {
|
||||
let start_hour = use_state(|| 6u32);
|
||||
let end_hour = use_state(|| 22u32);
|
||||
let zoom_level = use_state(|| 0.4f64); // Default 40% zoom
|
||||
|
||||
let close_modal = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |_| {
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
let backdrop_click = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
if e.target() == e.current_target() {
|
||||
on_close.emit(());
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_start_hour_change = {
|
||||
let start_hour = start_hour.clone();
|
||||
let end_hour = end_hour.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_dyn_into::<web_sys::HtmlSelectElement>();
|
||||
if let Some(select) = target {
|
||||
if let Ok(hour) = select.value().parse::<u32>() {
|
||||
if hour < *end_hour {
|
||||
start_hour.set(hour);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_end_hour_change = {
|
||||
let start_hour = start_hour.clone();
|
||||
let end_hour = end_hour.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_dyn_into::<web_sys::HtmlSelectElement>();
|
||||
if let Some(select) = target {
|
||||
if let Ok(hour) = select.value().parse::<u32>() {
|
||||
if hour > *start_hour && hour <= 24 {
|
||||
end_hour.set(hour);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
let format_hour = |hour: u32| -> String {
|
||||
if hour == 0 {
|
||||
"12 AM".to_string()
|
||||
} else if hour < 12 {
|
||||
format!("{} AM", hour)
|
||||
} else if hour == 12 {
|
||||
"12 PM".to_string()
|
||||
} else {
|
||||
format!("{} PM", hour - 12)
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate dynamic base unit for print preview
|
||||
let calculate_print_dimensions = |start_hour: u32, end_hour: u32, time_increment: u32| -> (f64, f64, f64) {
|
||||
let visible_hours = (end_hour - start_hour) as f64;
|
||||
let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 };
|
||||
let header_height = 50.0; // Fixed week header height in print preview
|
||||
let header_border = 2.0; // Week header bottom border (2px solid)
|
||||
let container_spacing = 8.0; // Additional container spacing/margins
|
||||
let total_overhead = header_height + header_border + container_spacing;
|
||||
let available_height = 720.0 - total_overhead; // Available for time content
|
||||
let base_unit = available_height / (visible_hours * slots_per_hour);
|
||||
let pixels_per_hour = base_unit * slots_per_hour;
|
||||
|
||||
(base_unit, pixels_per_hour, available_height)
|
||||
};
|
||||
|
||||
// Calculate print dimensions for the current hour range
|
||||
let (base_unit, pixels_per_hour, _available_height) = calculate_print_dimensions(*start_hour, *end_hour, props.time_increment);
|
||||
|
||||
// Effect to update print copy whenever modal renders or content changes
|
||||
{
|
||||
let start_hour = *start_hour;
|
||||
let end_hour = *end_hour;
|
||||
let time_increment = props.time_increment;
|
||||
let original_base_unit = base_unit;
|
||||
use_effect(move || {
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(document) = window.document() {
|
||||
// Set CSS variables on document root
|
||||
if let Some(document_element) = document.document_element() {
|
||||
if let Some(html_element) = document_element.dyn_ref::<web_sys::HtmlElement>() {
|
||||
let style = html_element.style();
|
||||
let _ = style.set_property("--print-start-hour", &start_hour.to_string());
|
||||
let _ = style.set_property("--print-end-hour", &end_hour.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Copy content from print-preview-content to the hidden print-preview-copy div
|
||||
let copy_content = move || {
|
||||
if let Some(preview_content) = document.query_selector(".print-preview-content").ok().flatten() {
|
||||
if let Some(print_copy) = document.get_element_by_id("print-preview-copy") {
|
||||
// Clone the preview content
|
||||
if let Some(content_clone) = preview_content.clone_node_with_deep(true).ok() {
|
||||
// Clear the print copy div and add the cloned content
|
||||
print_copy.set_inner_html("");
|
||||
let _ = print_copy.append_child(&content_clone);
|
||||
|
||||
// Get the actual rendered height of the print copy div and recalculate base-unit
|
||||
if let Some(print_copy_html) = print_copy.dyn_ref::<web_sys::HtmlElement>() {
|
||||
// Temporarily make visible to measure height, then hide again
|
||||
let original_display = print_copy_html.style().get_property_value("display").unwrap_or_default();
|
||||
let _ = print_copy_html.style().set_property("display", "block");
|
||||
let _ = print_copy_html.style().set_property("visibility", "hidden");
|
||||
let _ = print_copy_html.style().set_property("position", "absolute");
|
||||
let _ = print_copy_html.style().set_property("top", "-9999px");
|
||||
|
||||
// Now measure the height
|
||||
let actual_height = print_copy_html.client_height() as f64;
|
||||
|
||||
// Restore original display
|
||||
let _ = print_copy_html.style().set_property("display", &original_display);
|
||||
let _ = print_copy_html.style().remove_property("visibility");
|
||||
let _ = print_copy_html.style().remove_property("position");
|
||||
let _ = print_copy_html.style().remove_property("top");
|
||||
|
||||
// Recalculate base-unit and pixels-per-hour based on actual height
|
||||
let visible_hours = (end_hour - start_hour) as f64;
|
||||
let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 };
|
||||
let header_height = 50.0;
|
||||
let header_border = 2.0;
|
||||
let container_spacing = 8.0;
|
||||
let total_overhead = header_height + header_border + container_spacing;
|
||||
let available_height = actual_height - total_overhead;
|
||||
let actual_base_unit = available_height / (visible_hours * slots_per_hour);
|
||||
let actual_pixels_per_hour = actual_base_unit * slots_per_hour;
|
||||
|
||||
|
||||
// Set CSS variables with recalculated values
|
||||
let style = print_copy_html.style();
|
||||
let _ = style.set_property("--print-base-unit", &format!("{:.2}", actual_base_unit));
|
||||
let _ = style.set_property("--print-pixels-per-hour", &format!("{:.2}", actual_pixels_per_hour));
|
||||
let _ = style.set_property("--print-start-hour", &start_hour.to_string());
|
||||
let _ = style.set_property("--print-end-hour", &end_hour.to_string());
|
||||
|
||||
// Copy data attributes
|
||||
let _ = print_copy.set_attribute("data-start-hour", &start_hour.to_string());
|
||||
let _ = print_copy.set_attribute("data-end-hour", &end_hour.to_string());
|
||||
|
||||
// Recalculate event positions using the new base-unit
|
||||
let events = print_copy.query_selector_all(".week-event").unwrap();
|
||||
let scale_factor = actual_base_unit / original_base_unit;
|
||||
|
||||
for i in 0..events.length() {
|
||||
if let Some(event_element) = events.get(i) {
|
||||
if let Some(event_html) = event_element.dyn_ref::<web_sys::HtmlElement>() {
|
||||
let event_style = event_html.style();
|
||||
|
||||
// Get current positioning values and recalculate
|
||||
if let Ok(current_top) = event_style.get_property_value("top") {
|
||||
if current_top.ends_with("px") {
|
||||
if let Ok(top_px) = current_top[..current_top.len()-2].parse::<f64>() {
|
||||
let new_top = top_px * scale_factor;
|
||||
let _ = event_style.set_property("top", &format!("{:.2}px", new_top));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(current_height) = event_style.get_property_value("height") {
|
||||
if current_height.ends_with("px") {
|
||||
if let Ok(height_px) = current_height[..current_height.len()-2].parse::<f64>() {
|
||||
let new_height = height_px * scale_factor;
|
||||
let _ = event_style.set_property("height", &format!("{:.2}px", new_height));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
web_sys::console::log_1(&format!("Height: {:.2}, Original base-unit: {:.2}, New base-unit: {:.2}, Scale factor: {:.2}",
|
||||
actual_height, original_base_unit, actual_base_unit, scale_factor).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Copy content immediately
|
||||
copy_content();
|
||||
|
||||
// Also set up a small delay to catch any async rendering
|
||||
let copy_callback = Closure::wrap(Box::new(copy_content) as Box<dyn FnMut()>);
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
copy_callback.as_ref().unchecked_ref(),
|
||||
100
|
||||
);
|
||||
copy_callback.forget();
|
||||
}
|
||||
}
|
||||
|| ()
|
||||
});
|
||||
}
|
||||
|
||||
let on_print = {
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
// Print copy is already updated by the use_effect, just trigger print
|
||||
let _ = window.print();
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="modal-backdrop print-preview-modal-backdrop" onclick={backdrop_click}>
|
||||
<div class="modal-content print-preview-modal">
|
||||
<div class="modal-header">
|
||||
<h3>{"Print Preview"}</h3>
|
||||
<button class="modal-close" onclick={close_modal.clone()}>{"×"}</button>
|
||||
</div>
|
||||
<div class="modal-body print-preview-body">
|
||||
<div class="print-preview-controls">
|
||||
{
|
||||
if props.view_mode == ViewMode::Week {
|
||||
html! {
|
||||
<>
|
||||
<div class="control-group">
|
||||
<label for="start-hour">{"Start Hour:"}</label>
|
||||
<select id="start-hour" onchange={on_start_hour_change}>
|
||||
{
|
||||
(0..24).map(|hour| {
|
||||
html! {
|
||||
<option value={hour.to_string()} selected={hour == *start_hour}>
|
||||
{format_hour(hour)}
|
||||
</option>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label for="end-hour">{"End Hour:"}</label>
|
||||
<select id="end-hour" onchange={on_end_hour_change}>
|
||||
{
|
||||
(1..=24).map(|hour| {
|
||||
html! {
|
||||
<option value={hour.to_string()} selected={hour == *end_hour}>
|
||||
{if hour == 24 { "12 AM".to_string() } else { format_hour(hour) }}
|
||||
</option>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="hour-range-info">
|
||||
{format!("Will print from {} to {}",
|
||||
format_hour(*start_hour),
|
||||
if *end_hour == 24 { "12 AM".to_string() } else { format_hour(*end_hour) }
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="month-info">
|
||||
{"Will print entire month view"}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
<div class="zoom-display-info">
|
||||
<label>{"Zoom: "}</label>
|
||||
<span>{format!("{}%", (*zoom_level * 100.0) as i32)}</span>
|
||||
<span class="zoom-hint">{"(scroll to zoom)"}</span>
|
||||
</div>
|
||||
<div class="preview-actions">
|
||||
<button class="btn-primary" onclick={on_print}>{"Print"}</button>
|
||||
<button class="btn-secondary" onclick={close_modal}>{"Cancel"}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="print-preview-display" onwheel={{
|
||||
let zoom_level = zoom_level.clone();
|
||||
Callback::from(move |e: WheelEvent| {
|
||||
e.prevent_default(); // Prevent page scroll
|
||||
let delta_y = e.delta_y();
|
||||
let zoom_change = if delta_y < 0.0 { 1.1 } else { 1.0 / 1.1 };
|
||||
let new_zoom = (*zoom_level * zoom_change).clamp(0.2, 1.5);
|
||||
zoom_level.set(new_zoom);
|
||||
})
|
||||
}}>
|
||||
<div class="print-preview-paper"
|
||||
data-start-hour={start_hour.to_string()}
|
||||
data-end-hour={end_hour.to_string()}
|
||||
style={format!(
|
||||
"--print-start-hour: {}; --print-end-hour: {}; --print-base-unit: {:.2}; --print-pixels-per-hour: {:.2}; transform: scale({}); transform-origin: top center;",
|
||||
*start_hour, *end_hour, base_unit, pixels_per_hour, *zoom_level
|
||||
)}>
|
||||
<div class="print-preview-content">
|
||||
{
|
||||
match props.view_mode {
|
||||
ViewMode::Week => html! {
|
||||
<WeekView
|
||||
key={format!("week-preview-{}-{}", *start_hour, *end_hour)}
|
||||
current_date={props.current_date}
|
||||
today={props.today}
|
||||
events={props.events.clone()}
|
||||
on_event_click={Callback::noop()}
|
||||
user_info={props.user_info.clone()}
|
||||
external_calendars={props.external_calendars.clone()}
|
||||
time_increment={props.time_increment}
|
||||
print_mode={true}
|
||||
print_pixels_per_hour={Some(pixels_per_hour)}
|
||||
print_start_hour={Some(*start_hour)}
|
||||
/>
|
||||
},
|
||||
ViewMode::Month => html! {
|
||||
<MonthView
|
||||
key={format!("month-preview-{}-{}", *start_hour, *end_hour)}
|
||||
current_month={props.current_date}
|
||||
selected_date={Some(props.selected_date)}
|
||||
today={props.today}
|
||||
events={props.events.clone()}
|
||||
on_day_select={None::<Callback<NaiveDate>>}
|
||||
on_event_click={Callback::noop()}
|
||||
user_info={props.user_info.clone()}
|
||||
external_calendars={props.external_calendars.clone()}
|
||||
/>
|
||||
},
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -100,8 +100,7 @@ impl Default for ViewMode {
|
||||
pub struct SidebarProps {
|
||||
pub user_info: Option<UserInfo>,
|
||||
pub on_logout: Callback<()>,
|
||||
pub on_create_calendar: Callback<()>,
|
||||
pub on_create_external_calendar: Callback<()>,
|
||||
pub on_add_calendar: Callback<()>,
|
||||
pub external_calendars: Vec<ExternalCalendar>,
|
||||
pub on_external_calendar_toggle: Callback<i32>,
|
||||
pub on_external_calendar_delete: Callback<i32>,
|
||||
@@ -110,6 +109,7 @@ pub struct SidebarProps {
|
||||
pub on_color_change: Callback<(String, String)>,
|
||||
pub on_color_picker_toggle: Callback<String>,
|
||||
pub available_colors: Vec<String>,
|
||||
pub refreshing_calendar_id: Option<i32>,
|
||||
pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
|
||||
pub on_calendar_visibility_toggle: Callback<String>,
|
||||
pub current_view: ViewMode,
|
||||
@@ -203,9 +203,6 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<Link<Route> to={Route::Calendar} classes="nav-link">{"Calendar"}</Link<Route>>
|
||||
</nav>
|
||||
{
|
||||
if let Some(ref info) = props.user_info {
|
||||
if !info.calendars.is_empty() {
|
||||
@@ -259,7 +256,11 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
html! {
|
||||
<li class="external-calendar-item" style="position: relative;">
|
||||
<div
|
||||
class="external-calendar-info"
|
||||
class={if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {
|
||||
"external-calendar-info color-picker-active"
|
||||
} else {
|
||||
"external-calendar-info"
|
||||
}}
|
||||
oncontextmenu={{
|
||||
let on_context_menu = on_external_calendar_context_menu.clone();
|
||||
let cal_id = cal.id;
|
||||
@@ -276,7 +277,48 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
<span
|
||||
class="external-calendar-color"
|
||||
style={format!("background-color: {}", cal.color)}
|
||||
/>
|
||||
onclick={{
|
||||
let on_color_picker_toggle = props.on_color_picker_toggle.clone();
|
||||
let external_id = format!("external_{}", cal.id);
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.stop_propagation();
|
||||
on_color_picker_toggle.emit(external_id.clone());
|
||||
})
|
||||
}}
|
||||
>
|
||||
{
|
||||
if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {
|
||||
html! {
|
||||
<div class="color-picker-dropdown">
|
||||
{
|
||||
props.available_colors.iter().map(|color| {
|
||||
let color_str = color.clone();
|
||||
let external_id = format!("external_{}", cal.id);
|
||||
let on_color_change = props.on_color_change.clone();
|
||||
|
||||
let on_color_select = Callback::from(move |_: MouseEvent| {
|
||||
on_color_change.emit((external_id.clone(), color_str.clone()));
|
||||
});
|
||||
|
||||
let is_selected = cal.color == *color;
|
||||
|
||||
html! {
|
||||
<div
|
||||
key={color.clone()}
|
||||
class={if is_selected { "color-option selected" } else { "color-option" }}
|
||||
style={format!("background-color: {}", color)}
|
||||
onclick={on_color_select}
|
||||
/>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</span>
|
||||
<span class="external-calendar-name">{&cal.name}</span>
|
||||
<div class="external-calendar-actions">
|
||||
{
|
||||
@@ -304,8 +346,15 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
on_refresh.emit(cal_id);
|
||||
})
|
||||
}}
|
||||
disabled={props.refreshing_calendar_id == Some(cal.id)}
|
||||
>
|
||||
{"🔄"}
|
||||
{
|
||||
if props.refreshing_calendar_id == Some(cal.id) {
|
||||
html! { <i class="fas fa-spinner fa-spin"></i> }
|
||||
} else {
|
||||
html! { <i class="fas fa-sync-alt"></i> }
|
||||
}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -352,12 +401,8 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
|
||||
{"+ Create Calendar"}
|
||||
</button>
|
||||
|
||||
<button onclick={props.on_create_external_calendar.reform(|_| ())} class="create-external-calendar-button">
|
||||
{"+ Add External Calendar"}
|
||||
<button onclick={props.on_add_calendar.reform(|_| ())} class="add-calendar-button">
|
||||
{"+ Add Calendar"}
|
||||
</button>
|
||||
|
||||
<div class="view-selector">
|
||||
|
||||
@@ -42,6 +42,12 @@ pub struct WeekViewProps {
|
||||
pub context_menus_open: bool,
|
||||
#[prop_or_default]
|
||||
pub time_increment: u32,
|
||||
#[prop_or_default]
|
||||
pub print_mode: bool,
|
||||
#[prop_or_default]
|
||||
pub print_pixels_per_hour: Option<f64>,
|
||||
#[prop_or_default]
|
||||
pub print_start_hour: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
@@ -81,6 +87,31 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
|
||||
let pending_recurring_edit = use_state(|| None::<PendingRecurringEdit>);
|
||||
|
||||
// Current time state for time indicator
|
||||
let current_time = use_state(|| Local::now());
|
||||
|
||||
// Update current time every 5 seconds
|
||||
{
|
||||
let current_time = current_time.clone();
|
||||
use_effect_with((), move |_| {
|
||||
let interval = gloo_timers::callback::Interval::new(5_000, move || {
|
||||
current_time.set(Local::now());
|
||||
});
|
||||
|
||||
// Return the interval to keep it alive
|
||||
move || drop(interval)
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to calculate current time indicator position
|
||||
let calculate_current_time_position = |time_increment: u32| -> f64 {
|
||||
let now = current_time.time();
|
||||
let hour = now.hour() as f64;
|
||||
let minute = now.minute() as f64;
|
||||
let pixels_per_hour = if time_increment == 15 { 120.0 } else { 60.0 };
|
||||
(hour + minute / 60.0) * pixels_per_hour
|
||||
};
|
||||
|
||||
// Helper function to get calendar color for an event
|
||||
let get_event_color = |event: &VEvent| -> String {
|
||||
if let Some(calendar_path) = &event.calendar_path {
|
||||
@@ -413,13 +444,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
// Time labels
|
||||
<div class={classes!("time-labels", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
|
||||
{
|
||||
time_labels.iter().map(|time| {
|
||||
time_labels.iter().enumerate().map(|(hour, time)| {
|
||||
let is_quarter_mode = props.time_increment == 15;
|
||||
html! {
|
||||
<div class={classes!(
|
||||
"time-label",
|
||||
if is_quarter_mode { Some("quarter-mode") } else { None }
|
||||
)}>
|
||||
)} data-hour={hour.to_string()}>
|
||||
{time}
|
||||
</div>
|
||||
}
|
||||
@@ -676,10 +707,10 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
>
|
||||
// Time slot backgrounds - 24 hour slots to represent full day
|
||||
{
|
||||
(0..24).map(|_hour| {
|
||||
(0..24).map(|hour| {
|
||||
let slots_per_hour = 60 / props.time_increment;
|
||||
html! {
|
||||
<div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
|
||||
<div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })} data-hour={hour.to_string()}>
|
||||
{
|
||||
(0..slots_per_hour).map(|_slot| {
|
||||
let slot_class = if props.time_increment == 15 {
|
||||
@@ -701,7 +732,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
<div class="events-container">
|
||||
{
|
||||
day_events.iter().enumerate().filter_map(|(event_idx, event)| {
|
||||
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date, props.time_increment);
|
||||
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
|
||||
|
||||
// Skip all-day events (they're rendered in the header)
|
||||
if is_all_day {
|
||||
@@ -730,6 +761,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
let event_for_drag = event.clone();
|
||||
let date_for_drag = *date;
|
||||
let time_increment = props.time_increment;
|
||||
let print_pixels_per_hour = props.print_pixels_per_hour;
|
||||
let print_start_hour = props.print_start_hour;
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
|
||||
|
||||
@@ -743,7 +776,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 };
|
||||
|
||||
// Get event's current position in day column coordinates
|
||||
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag, time_increment);
|
||||
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag, time_increment, print_pixels_per_hour, print_start_hour);
|
||||
let event_start_pixels = event_start_pixels as f64;
|
||||
|
||||
// Convert click position to day column coordinates
|
||||
@@ -1029,7 +1062,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
};
|
||||
|
||||
// Calculate positions for the preview
|
||||
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment);
|
||||
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
|
||||
let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local());
|
||||
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
|
||||
|
||||
@@ -1059,7 +1092,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
|
||||
|
||||
// Calculate positions for the preview
|
||||
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment);
|
||||
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
|
||||
|
||||
let new_end_pixels = drag.current_y;
|
||||
let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0);
|
||||
@@ -1089,6 +1122,29 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
// Current time indicator - only show on today
|
||||
{
|
||||
if *date == props.today {
|
||||
let current_time_position = calculate_current_time_position(props.time_increment);
|
||||
let current_time_str = current_time.time().format("%I:%M %p").to_string();
|
||||
|
||||
html! {
|
||||
<div class="current-time-indicator-container">
|
||||
<div
|
||||
class="current-time-indicator"
|
||||
style={format!("top: {}px;", current_time_position)}
|
||||
>
|
||||
<div class="current-time-dot"></div>
|
||||
<div class="current-time-line"></div>
|
||||
<div class="current-time-label">{current_time_str}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
@@ -1170,18 +1226,15 @@ fn pixels_to_time(pixels: f64, time_increment: u32) -> NaiveTime {
|
||||
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
|
||||
}
|
||||
|
||||
fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32) -> (f32, f32, bool) {
|
||||
fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32, print_pixels_per_hour: Option<f64>, print_start_hour: Option<u32>) -> (f32, f32, bool) {
|
||||
// Convert UTC times to local time for display
|
||||
let local_start = event.dtstart.with_timezone(&Local);
|
||||
let event_date = local_start.date_naive();
|
||||
|
||||
// Position events based on when they appear in local time, not their original date
|
||||
// For timezone issues: an event created at 10 PM Sunday might be stored as Monday UTC
|
||||
// but should still display on Sunday's column since that's when the user sees it
|
||||
let should_display_here = event_date == date ||
|
||||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20);
|
||||
|
||||
if !should_display_here {
|
||||
// Events should display based on their stored date (which now preserves the original local date)
|
||||
// not the calculated local date from UTC conversion, since we fixed the creation logic
|
||||
let event_date = event.dtstart.date_naive(); // Use the stored date, not the converted local date
|
||||
|
||||
if event_date != date {
|
||||
return (0.0, 0.0, false); // Event not on this date
|
||||
}
|
||||
|
||||
@@ -1190,11 +1243,23 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32
|
||||
return (0.0, 30.0, true); // Position at top, 30px height, is_all_day = true
|
||||
}
|
||||
|
||||
// Calculate start position in pixels from midnight
|
||||
// Calculate start position in pixels
|
||||
let start_hour = local_start.hour() as f32;
|
||||
let start_minute = local_start.minute() as f32;
|
||||
let pixels_per_hour = if time_increment == 15 { 120.0 } else { 60.0 };
|
||||
let start_pixels = (start_hour + start_minute / 60.0) * pixels_per_hour;
|
||||
let pixels_per_hour = if let Some(print_pph) = print_pixels_per_hour {
|
||||
print_pph as f32 // Use the dynamic print mode calculation
|
||||
} else {
|
||||
if time_increment == 15 { 120.0 } else { 60.0 } // Default values
|
||||
};
|
||||
|
||||
// In print mode, offset by the start hour to show relative position within visible range
|
||||
let hour_offset = if let Some(print_start) = print_start_hour {
|
||||
print_start as f32
|
||||
} else {
|
||||
0.0 // No offset for normal view (starts at midnight)
|
||||
};
|
||||
|
||||
let start_pixels = ((start_hour + start_minute / 60.0) - hour_offset) * pixels_per_hour;
|
||||
|
||||
// Calculate duration and height
|
||||
let duration_pixels = if let Some(end) = event.dtend {
|
||||
@@ -1203,19 +1268,19 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32
|
||||
|
||||
// Handle events that span multiple days by capping at midnight
|
||||
if end_date > date {
|
||||
// Event continues past midnight, cap at 24:00
|
||||
let max_pixels = 24.0 * pixels_per_hour;
|
||||
max_pixels - start_pixels
|
||||
// Event continues past midnight, cap at end of visible range
|
||||
let max_hour = if let Some(_print_start) = print_start_hour { 24.0 } else { 24.0 };
|
||||
let max_pixels = (max_hour - hour_offset) * pixels_per_hour;
|
||||
(max_pixels - start_pixels).max(20.0)
|
||||
} else {
|
||||
let end_hour = local_end.hour() as f32;
|
||||
let end_minute = local_end.minute() as f32;
|
||||
let end_pixels = (end_hour + end_minute / 60.0) * pixels_per_hour;
|
||||
let end_pixels = ((end_hour + end_minute / 60.0) - hour_offset) * pixels_per_hour;
|
||||
(end_pixels - start_pixels).max(20.0) // Minimum 20px height
|
||||
}
|
||||
} else {
|
||||
pixels_per_hour // Default 1 hour if no end time
|
||||
};
|
||||
|
||||
(start_pixels, duration_pixels, false) // is_all_day = false
|
||||
}
|
||||
|
||||
@@ -1256,7 +1321,7 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
|
||||
return None;
|
||||
}
|
||||
|
||||
let (_, _, _) = calculate_event_position(event, date, time_increment);
|
||||
let (_, _, _) = calculate_event_position(event, date, time_increment, None, None);
|
||||
let local_start = event.dtstart.with_timezone(&Local);
|
||||
let event_date = local_start.date_naive();
|
||||
if event_date == date ||
|
||||
|
||||
@@ -37,6 +37,12 @@ pub struct UserInfo {
|
||||
pub username: String,
|
||||
pub server_url: String,
|
||||
pub calendars: Vec<CalendarInfo>,
|
||||
#[serde(default = "default_timestamp")]
|
||||
pub last_updated: u64,
|
||||
}
|
||||
|
||||
fn default_timestamp() -> u64 {
|
||||
0
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
|
||||
1309
frontend/styles.css
1309
frontend/styles.css
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,51 +0,0 @@
|
||||
/* Base Styles - Always Loaded */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.login-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Base Layout */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 280px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Basic Form Elements */
|
||||
input, select, textarea, button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.loading {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #28a745;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user