Compare commits
26 Commits
feature/ex
...
print-prev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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,50 @@ 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
|
||||
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);
|
||||
(
|
||||
start_utc.format("%Y-%m-%d").to_string(),
|
||||
start_utc.format("%H:%M").to_string(),
|
||||
end_utc.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(),
|
||||
|
||||
@@ -145,6 +145,10 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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,7 +1226,7 @@ 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();
|
||||
@@ -1190,11 +1246,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 +1271,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 +1324,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)]
|
||||
|
||||
1244
frontend/styles.css
1244
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