diff --git a/Cargo.toml b/Cargo.toml index 240a04e..1c0a05f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ web-sys = { version = "0.3", features = [ "RequestInit", "RequestMode", "Response", + "CssStyleDeclaration", ] } wasm-bindgen = "0.2" diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index 10ff1c2..cb11bb0 100644 --- a/backend/src/handlers.rs +++ b/backend/src/handlers.rs @@ -809,8 +809,10 @@ pub async fn update_event( let is_series_update = request.update_action.as_deref() == Some("update_series"); // Search for the event by UID across the specified calendars + // For recurring events, we might need to find by base UID pattern if exact match fails let mut found_event: Option<(CalendarEvent, String, String)> = None; // (event, calendar_path, href) for calendar_path in &calendar_paths { + // First try exact match match client.fetch_event_by_uid(calendar_path, &search_uid).await { Ok(Some(event)) => { if let Some(href) = event.href.clone() { @@ -818,7 +820,32 @@ pub async fn update_event( break; } }, - Ok(None) => continue, // Event not found in this calendar + Ok(None) => { + // If exact match fails, try to find by base UID pattern for recurring events + println!("🔍 Exact match failed for '{}', searching by base UID pattern", search_uid); + match client.fetch_events(calendar_path).await { + Ok(events) => { + // Look for any event whose UID starts with the search_uid + for event in events { + if let Some(href) = &event.href { + // Check if this event's UID starts with our search UID (base pattern) + if event.uid.starts_with(&search_uid) && event.uid != search_uid { + println!("🎯 Found recurring event by pattern: '{}' matches '{}'", event.uid, search_uid); + found_event = Some((event.clone(), calendar_path.clone(), href.clone())); + break; + } + } + } + if found_event.is_some() { + break; + } + }, + Err(e) => { + eprintln!("Error fetching events from {}: {:?}", calendar_path, e); + continue; + } + } + }, Err(e) => { eprintln!("Failed to fetch event from calendar {}: {}", calendar_path, e); continue; @@ -1021,6 +1048,56 @@ pub async fn update_event( // Keep existing recurrence rule (don't overwrite with recurrence_rule variable) // event.recurrence_rule stays as-is from the original event + + // However, allow exception_dates to be updated - this is needed for "This and Future" events + if let Some(exception_dates_str) = &request.exception_dates { + // Parse the ISO datetime strings into DateTime + let mut new_exception_dates = Vec::new(); + for date_str in exception_dates_str { + if let Ok(parsed_date) = chrono::DateTime::parse_from_rfc3339(date_str) { + new_exception_dates.push(parsed_date.with_timezone(&chrono::Utc)); + } else if let Ok(parsed_date) = chrono::DateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S UTC") { + new_exception_dates.push(parsed_date.with_timezone(&chrono::Utc)); + } else { + eprintln!("Failed to parse exception date: {}", date_str); + } + } + + // Merge with existing exception dates (avoid duplicates) + for new_date in new_exception_dates { + if !event.exception_dates.contains(&new_date) { + event.exception_dates.push(new_date); + } + } + + println!("🔄 Updated exception dates: {} total", event.exception_dates.len()); + } + + // Handle UNTIL date modification for "This and Future Events" + if let Some(until_date_str) = &request.until_date { + println!("🔄 Adding UNTIL clause to RRULE: {}", until_date_str); + + if let Some(ref rrule) = event.recurrence_rule { + // Remove existing UNTIL if present and add new one + let rrule_without_until = rrule.split(';') + .filter(|part| !part.starts_with("UNTIL=")) + .collect::>() + .join(";"); + + // Parse the until_date and format for RRULE + if let Ok(until_datetime) = chrono::DateTime::parse_from_rfc3339(until_date_str) { + let until_utc = until_datetime.with_timezone(&chrono::Utc); + let until_formatted = until_utc.format("%Y%m%dT%H%M%SZ").to_string(); + + event.recurrence_rule = Some(format!("{};UNTIL={}", rrule_without_until, until_formatted)); + println!("🔄 Modified RRULE: {}", event.recurrence_rule.as_ref().unwrap()); + + // Clear exception dates since we're using UNTIL instead + event.exception_dates.clear(); + println!("🔄 Cleared exception dates for UNTIL approach"); + } + } + } } else { // For regular updates, use the new recurrence rule event.recurrence_rule = recurrence_rule; diff --git a/backend/src/models.rs b/backend/src/models.rs index 146c13a..4cc365d 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -124,6 +124,9 @@ pub struct UpdateEventRequest { pub calendar_path: Option, // Optional - search all calendars if not specified pub update_action: Option, // "update_series" for recurring events pub occurrence_date: Option, // ISO date string for specific occurrence + pub exception_dates: Option>, // ISO datetime strings for EXDATE + #[serde(skip_serializing_if = "Option::is_none")] + pub until_date: Option, // ISO datetime string for RRULE UNTIL clause } #[derive(Debug, Serialize)] diff --git a/src/app.rs b/src/app.rs index 83ee606..57a7867 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,10 +2,36 @@ use yew::prelude::*; use yew_router::prelude::*; use gloo_storage::{LocalStorage, Storage}; use web_sys::MouseEvent; -use crate::components::{Sidebar, ViewMode, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction}; +use crate::components::{Sidebar, ViewMode, Theme, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction}; use crate::services::{CalendarService, calendar_service::{UserInfo, CalendarEvent}}; use chrono::NaiveDate; +fn get_theme_event_colors() -> Vec { + if let Some(window) = web_sys::window() { + if let Some(document) = window.document() { + if let Some(root) = document.document_element() { + if let Ok(Some(computed_style)) = window.get_computed_style(&root) { + if let Ok(colors_string) = computed_style.get_property_value("--event-colors") { + if !colors_string.is_empty() { + return colors_string + .split(',') + .map(|color| color.trim().to_string()) + .filter(|color| !color.is_empty()) + .collect(); + } + } + } + } + } + } + + vec![ + "#3B82F6".to_string(), "#10B981".to_string(), "#F59E0B".to_string(), "#EF4444".to_string(), + "#8B5CF6".to_string(), "#06B6D4".to_string(), "#84CC16".to_string(), "#F97316".to_string(), + "#EC4899".to_string(), "#6366F1".to_string(), "#14B8A6".to_string(), "#F3B806".to_string(), + "#8B5A2B".to_string(), "#6B7280".to_string(), "#DC2626".to_string(), "#7C3AED".to_string() + ] +} #[function_component] pub fn App() -> Html { @@ -41,12 +67,17 @@ pub fn App() -> Html { } }); - let available_colors = [ - "#3B82F6", "#10B981", "#F59E0B", "#EF4444", - "#8B5CF6", "#06B6D4", "#84CC16", "#F97316", - "#EC4899", "#6366F1", "#14B8A6", "#F3B806", - "#8B5A2B", "#6B7280", "#DC2626", "#7C3AED" - ]; + // Theme state - load from localStorage if available + let current_theme = use_state(|| { + // Try to load saved theme from localStorage + if let Ok(saved_theme) = LocalStorage::get::("calendar_theme") { + Theme::from_value(&saved_theme) + } else { + Theme::Default // Default theme + } + }); + + let available_colors = use_state(|| get_theme_event_colors()); let on_login = { let auth_token = auth_token.clone(); @@ -80,6 +111,41 @@ pub fn App() -> Html { }) }; + let on_theme_change = { + let current_theme = current_theme.clone(); + let available_colors = available_colors.clone(); + Callback::from(move |new_theme: Theme| { + // Save theme to localStorage + let _ = LocalStorage::set("calendar_theme", new_theme.value()); + + // Apply theme to document root + if let Some(document) = web_sys::window().and_then(|w| w.document()) { + if let Some(root) = document.document_element() { + let _ = root.set_attribute("data-theme", new_theme.value()); + } + } + + // Update state + current_theme.set(new_theme); + + // Update available colors after theme change + available_colors.set(get_theme_event_colors()); + }) + }; + + // Apply initial theme on mount + { + let current_theme = current_theme.clone(); + use_effect_with((), move |_| { + let theme = (*current_theme).clone(); + if let Some(document) = web_sys::window().and_then(|w| w.document()) { + if let Some(root) = document.document_element() { + let _ = root.set_attribute("data-theme", theme.value()); + } + } + }); + } + // Fetch user info when token is available { let user_info = user_info.clone(); @@ -347,7 +413,7 @@ pub fn App() -> Html { let on_event_update = { let auth_token = auth_token.clone(); - Callback::from(move |(original_event, new_start, new_end): (CalendarEvent, chrono::NaiveDateTime, chrono::NaiveDateTime)| { + Callback::from(move |(original_event, new_start, new_end, preserve_rrule, until_date): (CalendarEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option>)| { web_sys::console::log_1(&format!("Updating event: {} to new times: {} - {}", original_event.uid, new_start.format("%Y-%m-%d %H:%M"), @@ -428,7 +494,10 @@ pub fn App() -> Html { reminder_str, recurrence_str, recurrence_days, - original_event.calendar_path + original_event.calendar_path, + original_event.exception_dates.clone(), + if preserve_rrule { Some("update_series".to_string()) } else { None }, + until_date ).await { Ok(_) => { web_sys::console::log_1(&"Event updated successfully".into()); @@ -506,10 +575,12 @@ pub fn App() -> Html { color_picker_open={(*color_picker_open).clone()} on_color_change={on_color_change} on_color_picker_toggle={on_color_picker_toggle} - available_colors={available_colors.iter().map(|c| c.to_string()).collect::>()} + available_colors={(*available_colors).clone()} on_calendar_context_menu={on_calendar_context_menu} current_view={(*current_view).clone()} on_view_change={on_view_change} + current_theme={(*current_theme).clone()} + on_theme_change={on_theme_change} />
Html { reminder_str, recurrence_str, updated_data.recurrence_days, - updated_data.selected_calendar + updated_data.selected_calendar, + original_event.exception_dates.clone(), + Some("update_series".to_string()), // This is for event edit modal, preserve original RRULE + None // No until_date for edit modal ).await { Ok(_) => { web_sys::console::log_1(&"Event updated successfully".into()); diff --git a/src/components/calendar.rs b/src/components/calendar.rs index 7c3917c..f6ee003 100644 --- a/src/components/calendar.rs +++ b/src/components/calendar.rs @@ -24,7 +24,7 @@ pub struct CalendarProps { #[prop_or_default] pub on_create_event_request: Option>, #[prop_or_default] - pub on_event_update_request: Option>, + pub on_event_update_request: Option>)>>, #[prop_or_default] pub context_menus_open: bool, } @@ -194,9 +194,9 @@ pub fn Calendar(props: &CalendarProps) -> Html { // Handle drag-to-move event let on_event_update = { let on_event_update_request = props.on_event_update_request.clone(); - Callback::from(move |(event, new_start, new_end): (CalendarEvent, chrono::NaiveDateTime, chrono::NaiveDateTime)| { + Callback::from(move |(event, new_start, new_end, preserve_rrule, until_date): (CalendarEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option>)| { if let Some(callback) = &on_event_update_request { - callback.emit((event, new_start, new_end)); + callback.emit((event, new_start, new_end, preserve_rrule, until_date)); } }) }; diff --git a/src/components/mod.rs b/src/components/mod.rs index 8e7fee0..fdf1155 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -25,7 +25,7 @@ pub use context_menu::ContextMenu; pub use event_context_menu::{EventContextMenu, DeleteAction}; pub use calendar_context_menu::CalendarContextMenu; pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType}; -pub use sidebar::{Sidebar, ViewMode}; +pub use sidebar::{Sidebar, ViewMode, Theme}; pub use calendar_list_item::CalendarListItem; pub use route_handler::RouteHandler; pub use recurring_edit_modal::{RecurringEditModal, RecurringEditAction}; \ No newline at end of file diff --git a/src/components/route_handler.rs b/src/components/route_handler.rs index 5a7b14a..940792a 100644 --- a/src/components/route_handler.rs +++ b/src/components/route_handler.rs @@ -27,7 +27,7 @@ pub struct RouteHandlerProps { #[prop_or_default] pub on_create_event_request: Option>, #[prop_or_default] - pub on_event_update_request: Option>, + pub on_event_update_request: Option>)>>, #[prop_or_default] pub context_menus_open: bool, } @@ -105,7 +105,7 @@ pub struct CalendarViewProps { #[prop_or_default] pub on_create_event_request: Option>, #[prop_or_default] - pub on_event_update_request: Option>, + pub on_event_update_request: Option>)>>, #[prop_or_default] pub context_menus_open: bool, } diff --git a/src/components/sidebar.rs b/src/components/sidebar.rs index ab22b74..c88e0b5 100644 --- a/src/components/sidebar.rs +++ b/src/components/sidebar.rs @@ -20,6 +20,59 @@ pub enum ViewMode { Week, } +#[derive(Clone, PartialEq)] +pub enum Theme { + Default, + Ocean, + Forest, + Sunset, + Purple, + Dark, + Rose, + Mint, +} + +impl Theme { + pub fn name(&self) -> &'static str { + match self { + Theme::Default => "Default", + Theme::Ocean => "Ocean", + Theme::Forest => "Forest", + Theme::Sunset => "Sunset", + Theme::Purple => "Purple", + Theme::Dark => "Dark", + Theme::Rose => "Rose", + Theme::Mint => "Mint", + } + } + + pub fn value(&self) -> &'static str { + match self { + Theme::Default => "default", + Theme::Ocean => "ocean", + Theme::Forest => "forest", + Theme::Sunset => "sunset", + Theme::Purple => "purple", + Theme::Dark => "dark", + Theme::Rose => "rose", + Theme::Mint => "mint", + } + } + + pub fn from_value(value: &str) -> Self { + match value { + "ocean" => Theme::Ocean, + "forest" => Theme::Forest, + "sunset" => Theme::Sunset, + "purple" => Theme::Purple, + "dark" => Theme::Dark, + "rose" => Theme::Rose, + "mint" => Theme::Mint, + _ => Theme::Default, + } + } +} + impl Default for ViewMode { fn default() -> Self { ViewMode::Month @@ -38,6 +91,8 @@ pub struct SidebarProps { pub on_calendar_context_menu: Callback<(MouseEvent, String)>, pub current_view: ViewMode, pub on_view_change: Callback, + pub current_theme: Theme, + pub on_theme_change: Callback, } #[function_component(Sidebar)] @@ -57,6 +112,18 @@ pub fn sidebar(props: &SidebarProps) -> Html { }) }; + let on_theme_change = { + let on_theme_change = props.on_theme_change.clone(); + Callback::from(move |e: Event| { + let target = e.target_dyn_into::(); + if let Some(select) = target { + let value = select.value(); + let new_theme = Theme::from_value(&value); + on_theme_change.emit(new_theme); + } + }) + }; + html! { diff --git a/src/components/week_view.rs b/src/components/week_view.rs index 5870aab..accf4ff 100644 --- a/src/components/week_view.rs +++ b/src/components/week_view.rs @@ -24,7 +24,7 @@ pub struct WeekViewProps { #[prop_or_default] pub on_create_event_request: Option>, #[prop_or_default] - pub on_event_update: Option>, + pub on_event_update: Option>)>>, #[prop_or_default] pub context_menus_open: bool, #[prop_or_default] @@ -106,6 +106,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { let on_event_update = props.on_event_update.clone(); let on_create_event = props.on_create_event.clone(); let on_create_event_request = props.on_create_event_request.clone(); + let events = props.events.clone(); Callback::from(move |action: RecurringEditAction| { if let Some(edit) = (*pending_recurring_edit).clone() { match action { @@ -127,60 +128,142 @@ pub fn week_view(props: &WeekViewProps) -> Html { ).into()); // Update the original series with the exception (times unchanged) - update_callback.emit((updated_series, original_start, original_end)); + update_callback.emit((updated_series, original_start, original_end, true, None)); // preserve_rrule = true for EXDATE, no until_date } - // 2. Then create the new single event using the update callback (to avoid refresh) - if let Some(update_callback) = &on_event_update { - let mut new_event = edit.event.clone(); - new_event.uid = format!("{}-exception-{}", edit.event.uid, edit.event.start.timestamp()); - new_event.start = chrono::DateTime::from_naive_utc_and_offset(edit.new_start, chrono::Utc); - new_event.end = Some(chrono::DateTime::from_naive_utc_and_offset(edit.new_end, chrono::Utc)); - new_event.recurrence_rule = None; // This is now a single event - new_event.exception_dates.clear(); // No exception dates for single event + // 2. Then create the new single event using the create callback + if let Some(create_callback) = &on_create_event_request { + // Convert to EventCreationData for single event + let event_data = EventCreationData { + title: edit.event.summary.clone().unwrap_or_default(), + description: edit.event.description.clone().unwrap_or_default(), + start_date: edit.new_start.date(), + start_time: edit.new_start.time(), + end_date: edit.new_end.date(), + end_time: edit.new_end.time(), + location: edit.event.location.clone().unwrap_or_default(), + all_day: edit.event.all_day, + status: EventStatus::Confirmed, + class: EventClass::Public, + priority: edit.event.priority, + organizer: edit.event.organizer.clone().unwrap_or_default(), + attendees: edit.event.attendees.join(","), + categories: edit.event.categories.join(","), + reminder: ReminderType::None, + recurrence: RecurrenceType::None, // Single event, no recurrence + recurrence_days: vec![false; 7], + selected_calendar: edit.event.calendar_path.clone(), + }; - // Use update callback to create the new event (should work without refresh) - update_callback.emit((new_event, edit.new_start, edit.new_end)); + // Create the single event + create_callback.emit(event_data); } }, RecurringEditAction::FutureEvents => { // Split series and modify future events - web_sys::console::log_1(&format!("🔄 Splitting series and modifying future: {}", - edit.event.summary.as_deref().unwrap_or("Untitled")).into()); - // 1. Update original series to end before this occurrence + // 1. Update original series to set UNTIL to end before this occurrence if let Some(update_callback) = &on_event_update { - let mut original_series = edit.event.clone(); - - // Add UNTIL clause to end the series before this occurrence - let until_date = edit.event.start - chrono::Duration::days(1); - - // Parse existing RRULE and add UNTIL - if let Some(rrule) = &original_series.recurrence_rule { - let updated_rrule = if rrule.contains("UNTIL=") { - // Replace existing UNTIL - let re = regex::Regex::new(r"UNTIL=[^;]*").unwrap(); - re.replace(rrule, &format!("UNTIL={}", until_date.format("%Y%m%dT%H%M%SZ"))).to_string() + // Find the original series event (not the occurrence) + // UIDs like "uuid-timestamp" need to split on the last hyphen, not the first + let base_uid = if let Some(last_hyphen_pos) = edit.event.uid.rfind('-') { + let suffix = &edit.event.uid[last_hyphen_pos + 1..]; + // Check if suffix is numeric (timestamp), if so remove it + if suffix.chars().all(|c| c.is_numeric()) { + edit.event.uid[..last_hyphen_pos].to_string() } else { - // Add UNTIL to existing rule - format!("{};UNTIL={}", rrule, until_date.format("%Y%m%dT%H%M%SZ")) - }; - original_series.recurrence_rule = Some(updated_rrule); + edit.event.uid.clone() + } + } else { + edit.event.uid.clone() + }; + + web_sys::console::log_1(&format!("🔍 Looking for original series: '{}' from occurrence: '{}'", base_uid, edit.event.uid).into()); + + // Find the original series event by searching for the base UID + let mut original_series = None; + for events_list in events.values() { + for event in events_list { + if event.uid == base_uid { + original_series = Some(event.clone()); + break; + } + } + if original_series.is_some() { + break; + } } - // Update original series - update_callback.emit((original_series, edit.event.start.with_timezone(&chrono::Local).naive_local(), edit.event.end.unwrap_or(edit.event.start).with_timezone(&chrono::Local).naive_local())); + let mut original_series = match original_series { + Some(series) => { + web_sys::console::log_1(&format!("✅ Found original series: '{}'", series.uid).into()); + series + }, + None => { + web_sys::console::log_1(&format!("⚠️ Could not find original series '{}', using occurrence but fixing UID", base_uid).into()); + let mut fallback_event = edit.event.clone(); + // Ensure the UID is the base UID, not the occurrence UID + fallback_event.uid = base_uid.clone(); + fallback_event + } + }; + + // Calculate the day before this occurrence for UNTIL clause + let until_date = edit.event.start.date_naive() - chrono::Duration::days(1); + let until_datetime = until_date.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap()); + let until_utc = chrono::DateTime::::from_naive_utc_and_offset(until_datetime, chrono::Utc); + + web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}", + until_utc.format("%Y-%m-%d %H:%M:%S UTC"), + edit.event.start.format("%Y-%m-%d %H:%M:%S UTC")).into()); + + // Use the original series start time (not the dragged occurrence time) + let original_start = original_series.start.with_timezone(&chrono::Local).naive_local(); + let original_end = original_series.end.unwrap_or(original_series.start).with_timezone(&chrono::Local).naive_local(); + + // Send until_date to backend instead of modifying RRULE on frontend + update_callback.emit((original_series, original_start, original_end, true, Some(until_utc))); // preserve_rrule = true, backend will add UNTIL } // 2. Create new series starting from this occurrence with modified times - if let Some(update_callback) = &on_event_update { - let mut new_series = edit.event.clone(); - new_series.uid = format!("{}-future-{}", edit.event.uid, edit.event.start.timestamp()); - new_series.start = chrono::DateTime::from_naive_utc_and_offset(edit.new_start, chrono::Utc); - new_series.end = Some(chrono::DateTime::from_naive_utc_and_offset(edit.new_end, chrono::Utc)); - new_series.exception_dates.clear(); // New series has no exceptions + if let Some(create_callback) = &on_create_event_request { + // Convert the recurring event to EventCreationData for the create callback + let event_data = EventCreationData { + title: edit.event.summary.clone().unwrap_or_default(), + description: edit.event.description.clone().unwrap_or_default(), + start_date: edit.new_start.date(), + start_time: edit.new_start.time(), + end_date: edit.new_end.date(), + end_time: edit.new_end.time(), + location: edit.event.location.clone().unwrap_or_default(), + all_day: edit.event.all_day, + status: EventStatus::Confirmed, // Default status + class: EventClass::Public, // Default class + priority: edit.event.priority, + organizer: edit.event.organizer.clone().unwrap_or_default(), + attendees: edit.event.attendees.join(","), + categories: edit.event.categories.join(","), + reminder: ReminderType::None, // Default reminder + recurrence: if let Some(rrule) = &edit.event.recurrence_rule { + if rrule.contains("FREQ=DAILY") { + RecurrenceType::Daily + } else if rrule.contains("FREQ=WEEKLY") { + RecurrenceType::Weekly + } else if rrule.contains("FREQ=MONTHLY") { + RecurrenceType::Monthly + } else if rrule.contains("FREQ=YEARLY") { + RecurrenceType::Yearly + } else { + RecurrenceType::None + } + } else { + RecurrenceType::None + }, + recurrence_days: vec![false; 7], // Default days + selected_calendar: edit.event.calendar_path.clone(), + }; - // Use update callback to create the new series (should work without refresh) - update_callback.emit((new_series, edit.new_start, edit.new_end)); + // Create the new series + create_callback.emit(event_data); } }, RecurringEditAction::AllEvents => { @@ -188,7 +271,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { let series_event = edit.event.clone(); if let Some(callback) = &on_event_update { - callback.emit((series_event, edit.new_start, edit.new_end)); + callback.emit((series_event, edit.new_start, edit.new_end, true, None)); // Regular drag operation - preserve RRULE, no until_date } }, } @@ -382,7 +465,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { } else { // Regular event - proceed with update if let Some(callback) = &on_event_update { - callback.emit((event.clone(), new_start_datetime, new_end_datetime)); + callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None)); // Regular drag operation - preserve RRULE, no until_date } } }, @@ -418,7 +501,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { } else { // Regular event - proceed with update if let Some(callback) = &on_event_update { - callback.emit((event.clone(), new_start_datetime, new_end_datetime)); + callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None)); // Regular drag operation - preserve RRULE, no until_date } } }, @@ -449,7 +532,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { } else { // Regular event - proceed with update if let Some(callback) = &on_event_update { - callback.emit((event.clone(), new_start_datetime, new_end_datetime)); + callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None)); // Regular drag operation - preserve RRULE, no until_date } } } diff --git a/src/services/calendar_service.rs b/src/services/calendar_service.rs index 2b8d155..72c9383 100644 --- a/src/services/calendar_service.rs +++ b/src/services/calendar_service.rs @@ -718,7 +718,7 @@ impl CalendarService { let body_string = serde_json::to_string(&body) .map_err(|e| format!("JSON serialization failed: {}", e))?; - + let url = format!("{}/calendar/events/create", self.base_url); opts.set_body(&body_string.into()); let request = Request::new_with_str_and_init(&url, &opts) @@ -777,7 +777,10 @@ impl CalendarService { reminder: String, recurrence: String, recurrence_days: Vec, - calendar_path: Option + calendar_path: Option, + exception_dates: Vec>, + update_action: Option, + until_date: Option> ) -> Result<(), String> { let window = web_sys::window().ok_or("No global window exists")?; @@ -805,13 +808,15 @@ impl CalendarService { "recurrence": recurrence, "recurrence_days": recurrence_days, "calendar_path": calendar_path, - "update_action": "update_series", - "occurrence_date": null + "update_action": update_action, + "occurrence_date": null, + "exception_dates": exception_dates.iter().map(|dt| dt.to_rfc3339()).collect::>(), + "until_date": until_date.as_ref().map(|dt| dt.to_rfc3339()) }); let body_string = serde_json::to_string(&body) .map_err(|e| format!("JSON serialization failed: {}", e))?; - + let url = format!("{}/calendar/events/update", self.base_url); opts.set_body(&body_string.into()); let request = Request::new_with_str_and_init(&url, &opts) diff --git a/styles.css b/styles.css index 9814788..c9afe62 100644 --- a/styles.css +++ b/styles.css @@ -383,7 +383,7 @@ body { /* Calendar Component */ .calendar { - background: white; + background: var(--calendar-bg, white); border-radius: 12px; box-shadow: 0 4px 16px rgba(0,0,0,0.1); overflow: hidden; @@ -397,8 +397,8 @@ body { align-items: center; justify-content: space-between; padding: 1.5rem 2rem; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; + background: var(--header-bg, linear-gradient(135deg, #667eea 0%, #764ba2 100%)); + color: var(--header-text, white); } .month-year { @@ -486,7 +486,7 @@ body { grid-template-columns: repeat(7, 1fr); grid-template-rows: auto repeat(6, 1fr); flex: 1; - background: white; + background: var(--calendar-bg, white); gap: 0; } @@ -495,41 +495,42 @@ body { display: flex; flex-direction: column; height: 100%; - background: white; + background: var(--calendar-bg, white); } /* Week Header */ .week-header { display: grid; grid-template-columns: 80px repeat(7, 1fr); - background: #f8f9fa; - border-bottom: 2px solid #e9ecef; + background: var(--weekday-header-bg, #f8f9fa); + border-bottom: 2px solid var(--time-label-border, #e9ecef); position: sticky; top: 0; z-index: 10; } .time-gutter { - background: #f8f9fa; - border-right: 1px solid #e9ecef; + background: var(--time-label-bg, #f8f9fa); + border-right: 1px solid var(--time-label-border, #e9ecef); } .week-day-header { padding: 1rem; text-align: center; - border-right: 1px solid #e9ecef; - background: #f8f9fa; + border-right: 1px solid var(--time-label-border, #e9ecef); + background: var(--weekday-header-bg, #f8f9fa); + color: var(--weekday-header-text, inherit); } .week-day-header.today { - background: #e3f2fd; - color: #1976d2; + background: var(--calendar-today-bg, #e3f2fd); + color: var(--calendar-today-text, #1976d2); } .weekday-name { font-size: 0.9rem; font-weight: 600; - color: #666; + color: var(--weekday-header-text, #666); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 0.25rem; @@ -541,7 +542,7 @@ body { } .week-day-header.today .weekday-name { - color: #1976d2; + color: var(--calendar-today-text, #1976d2); } /* Week Content */ @@ -559,8 +560,8 @@ body { /* Time Labels */ .time-labels { - background: #f8f9fa; - border-right: 1px solid #e9ecef; + background: var(--time-label-bg, #f8f9fa); + border-right: 1px solid var(--time-label-border, #e9ecef); position: sticky; left: 0; z-index: 5; @@ -573,8 +574,8 @@ body { justify-content: center; padding-top: 0.5rem; font-size: 0.75rem; - color: #666; - border-bottom: 1px solid #f0f0f0; + color: var(--time-label-text, #666); + border-bottom: 1px solid var(--calendar-border, #f0f0f0); font-weight: 500; } @@ -593,7 +594,7 @@ body { .week-day-column { position: relative; - border-right: 1px solid #e9ecef; + border-right: 1px solid var(--time-label-border, #e9ecef); min-height: 1500px; /* 25 time labels × 60px = 1500px total */ } @@ -602,20 +603,20 @@ body { } .week-day-column.today { - background: #fafffe; + background: var(--calendar-day-hover, #fafffe); } /* Time Slots */ .time-slot { height: 60px; - border-bottom: 1px solid #f0f0f0; + border-bottom: 1px solid var(--calendar-border, #f0f0f0); position: relative; pointer-events: none; /* Don't capture mouse events */ } .time-slot-half { height: 30px; - border-bottom: 1px dotted #f5f5f5; + border-bottom: 1px dotted var(--calendar-border, #f5f5f5); pointer-events: none; /* Don't capture mouse events */ } @@ -839,7 +840,7 @@ body { grid-template-columns: repeat(7, 1fr); grid-template-rows: auto 1fr; flex: 1; - background: white; + background: var(--calendar-bg, white); } .week-view .calendar-day { @@ -859,7 +860,7 @@ body { } .calendar-day { - border: 1px solid #f0f0f0; + border: 1px solid var(--calendar-border, #f0f0f0); padding: 0.75rem; display: flex; flex-direction: column; @@ -867,52 +868,53 @@ body { transition: background-color 0.2s; position: relative; overflow: hidden; + background: var(--calendar-day-bg, white); } .calendar-day:hover { - background-color: #f8f9ff; + background-color: var(--calendar-day-hover, #f8f9ff); } .calendar-day.current-month { - background: white; + background: var(--calendar-day-bg, white); } .calendar-day.prev-month, .calendar-day.next-month { - background: #fafafa; - color: #ccc; + background: var(--calendar-day-prev-next, #fafafa); + color: var(--calendar-day-prev-next-text, #ccc); } .calendar-day.today { - background: #e3f2fd; - border: 2px solid #2196f3; + background: var(--calendar-today-bg, #e3f2fd); + border: 2px solid var(--calendar-today-border, #2196f3); } .calendar-day.has-events { - background: #fff3e0; + background: var(--calendar-has-events-bg, #fff3e0); } .calendar-day.today.has-events { - background: #e1f5fe; + background: var(--calendar-today-bg, #e1f5fe); } .calendar-day.selected { - background: #e8f5e8; - border: 2px solid #4caf50; + background: var(--calendar-selected-bg, #e8f5e8); + border: 2px solid var(--calendar-selected-border, #4caf50); box-shadow: 0 0 8px rgba(76, 175, 80, 0.3); } .calendar-day.selected.has-events { - background: #f1f8e9; + background: var(--calendar-selected-bg, #f1f8e9); } .calendar-day.selected.today { - background: #e0f2f1; - border: 2px solid #4caf50; + background: var(--calendar-selected-bg, #e0f2f1); + border: 2px solid var(--calendar-selected-border, #4caf50); } .calendar-day.selected .day-number { - color: #2e7d32; + color: var(--calendar-selected-text, #2e7d32); font-weight: 700; } @@ -923,7 +925,7 @@ body { } .calendar-day.today .day-number { - color: #1976d2; + color: var(--calendar-today-text, #1976d2); } .day-events { @@ -1896,4 +1898,376 @@ body { .recurring-option .option-description { font-size: 0.8rem; } +} + +/* Theme Selector Styles */ +.theme-selector { + margin-bottom: 1rem; +} + +.theme-selector-dropdown { + width: 100%; + padding: 0.5rem; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + color: white; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.theme-selector-dropdown:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); +} + +.theme-selector-dropdown:focus { + outline: none; + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.4); +} + +.theme-selector-dropdown option { + background: #333; + color: white; + padding: 0.5rem; +} + +/* Theme Definitions */ +:root { + /* Default Theme */ + --primary-gradient: linear-gradient(180deg, #667eea 0%, #764ba2 100%); + --primary-bg: #f8f9fa; + --primary-text: #333; + --sidebar-bg: linear-gradient(180deg, #667eea 0%, #764ba2 100%); + --sidebar-text: white; + --header-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + --header-text: white; + --card-bg: white; + --border-color: #e9ecef; + --accent-color: #667eea; + --calendar-bg: white; + --calendar-border: #f0f0f0; + --calendar-day-bg: white; + --calendar-day-hover: #f8f9ff; + --calendar-day-prev-next: #fafafa; + --calendar-day-prev-next-text: #ccc; + --calendar-today-bg: #e3f2fd; + --calendar-today-border: #2196f3; + --calendar-today-text: #1976d2; + --calendar-selected-bg: #e8f5e8; + --calendar-selected-border: #4caf50; + --calendar-selected-text: #2e7d32; + --calendar-has-events-bg: #fff3e0; + --weekday-header-bg: #f8f9fa; + --weekday-header-text: #666; + --time-label-bg: #f8f9fa; + --time-label-text: #666; + --time-label-border: #e9ecef; + --event-colors: #3B82F6, #10B981, #F59E0B, #EF4444, #8B5CF6, #06B6D4, #84CC16, #F97316, #EC4899, #6366F1, #14B8A6, #F3B806, #8B5A2B, #6B7280, #DC2626, #7C3AED; +} + +/* Ocean Theme */ +[data-theme="ocean"] { + --primary-gradient: linear-gradient(180deg, #2196F3 0%, #0277BD 100%); + --primary-bg: #e3f2fd; + --primary-text: #0d47a1; + --sidebar-bg: linear-gradient(180deg, #2196F3 0%, #0277BD 100%); + --sidebar-text: white; + --header-bg: linear-gradient(135deg, #2196F3 0%, #0277BD 100%); + --header-text: white; + --card-bg: #ffffff; + --border-color: #bbdefb; + --accent-color: #2196F3; + --calendar-bg: #ffffff; + --calendar-border: #bbdefb; + --calendar-day-bg: #ffffff; + --calendar-day-hover: #e1f5fe; + --calendar-day-prev-next: #f3f8ff; + --calendar-day-prev-next-text: #90caf9; + --calendar-today-bg: #b3e5fc; + --calendar-today-border: #0277BD; + --calendar-today-text: #01579b; + --calendar-selected-bg: #e0f7fa; + --calendar-selected-border: #00acc1; + --calendar-selected-text: #00695c; + --calendar-has-events-bg: #fff8e1; + --weekday-header-bg: #e3f2fd; + --weekday-header-text: #0d47a1; + --time-label-bg: #e3f2fd; + --time-label-text: #0d47a1; + --time-label-border: #bbdefb; + --event-colors: #2196F3, #03DAC6, #FF9800, #F44336, #9C27B0, #00BCD4, #8BC34A, #FF5722, #E91E63, #3F51B5, #009688, #FFC107, #607D8B, #795548, #E53935, #673AB7; +} + +[data-theme="ocean"] body { + background-color: var(--primary-bg); + color: var(--primary-text); +} + +[data-theme="ocean"] .app-sidebar { + background: var(--sidebar-bg); +} + +/* Forest Theme */ +[data-theme="forest"] { + --primary-gradient: linear-gradient(180deg, #4CAF50 0%, #2E7D32 100%); + --primary-bg: #e8f5e8; + --primary-text: #1b5e20; + --sidebar-bg: linear-gradient(180deg, #4CAF50 0%, #2E7D32 100%); + --sidebar-text: white; + --header-bg: linear-gradient(135deg, #4CAF50 0%, #2E7D32 100%); + --header-text: white; + --card-bg: #ffffff; + --border-color: #c8e6c9; + --accent-color: #4CAF50; + --calendar-bg: #ffffff; + --calendar-border: #c8e6c9; + --calendar-day-bg: #ffffff; + --calendar-day-hover: #f1f8e9; + --calendar-day-prev-next: #f9fbe7; + --calendar-day-prev-next-text: #a5d6a7; + --calendar-today-bg: #c8e6c9; + --calendar-today-border: #2E7D32; + --calendar-today-text: #1b5e20; + --calendar-selected-bg: #e8f5e8; + --calendar-selected-border: #388e3c; + --calendar-selected-text: #2e7d32; + --calendar-has-events-bg: #fff3e0; + --weekday-header-bg: #e8f5e8; + --weekday-header-text: #1b5e20; + --time-label-bg: #e8f5e8; + --time-label-text: #1b5e20; + --time-label-border: #c8e6c9; + --event-colors: #4CAF50, #8BC34A, #FF9800, #FF5722, #9C27B0, #03DAC6, #CDDC39, #FF6F00, #E91E63, #3F51B5, #009688, #FFC107, #795548, #607D8B, #F44336, #673AB7; +} + +[data-theme="forest"] body { + background-color: var(--primary-bg); + color: var(--primary-text); +} + +[data-theme="forest"] .app-sidebar { + background: var(--sidebar-bg); +} + +/* Sunset Theme */ +[data-theme="sunset"] { + --primary-gradient: linear-gradient(180deg, #FF9800 0%, #F57C00 100%); + --primary-bg: #fff3e0; + --primary-text: #e65100; + --sidebar-bg: linear-gradient(180deg, #FF9800 0%, #F57C00 100%); + --sidebar-text: white; + --header-bg: linear-gradient(135deg, #FF9800 0%, #F57C00 100%); + --header-text: white; + --card-bg: #ffffff; + --border-color: #ffcc02; + --accent-color: #FF9800; + --calendar-bg: #ffffff; + --calendar-border: #ffe0b2; + --calendar-day-bg: #ffffff; + --calendar-day-hover: #fff8e1; + --calendar-day-prev-next: #fffde7; + --calendar-day-prev-next-text: #ffcc02; + --calendar-today-bg: #ffe0b2; + --calendar-today-border: #F57C00; + --calendar-today-text: #e65100; + --calendar-selected-bg: #fff3e0; + --calendar-selected-border: #ff8f00; + --calendar-selected-text: #ff6f00; + --calendar-has-events-bg: #f3e5f5; + --weekday-header-bg: #fff3e0; + --weekday-header-text: #e65100; + --time-label-bg: #fff3e0; + --time-label-text: #e65100; + --time-label-border: #ffe0b2; + --event-colors: #FF9800, #FF5722, #F44336, #E91E63, #9C27B0, #673AB7, #3F51B5, #2196F3, #03DAC6, #009688, #4CAF50, #8BC34A, #CDDC39, #FFC107, #FF6F00, #795548; +} + +[data-theme="sunset"] body { + background-color: var(--primary-bg); + color: var(--primary-text); +} + +[data-theme="sunset"] .app-sidebar { + background: var(--sidebar-bg); +} + +/* Purple Theme */ +[data-theme="purple"] { + --primary-gradient: linear-gradient(180deg, #9C27B0 0%, #6A1B9A 100%); + --primary-bg: #f3e5f5; + --primary-text: #4a148c; + --sidebar-bg: linear-gradient(180deg, #9C27B0 0%, #6A1B9A 100%); + --sidebar-text: white; + --header-bg: linear-gradient(135deg, #9C27B0 0%, #6A1B9A 100%); + --header-text: white; + --card-bg: #ffffff; + --border-color: #ce93d8; + --accent-color: #9C27B0; + --calendar-bg: #ffffff; + --calendar-border: #ce93d8; + --calendar-day-bg: #ffffff; + --calendar-day-hover: #f8e9fc; + --calendar-day-prev-next: #fce4ec; + --calendar-day-prev-next-text: #ce93d8; + --calendar-today-bg: #e1bee7; + --calendar-today-border: #6A1B9A; + --calendar-today-text: #4a148c; + --calendar-selected-bg: #f3e5f5; + --calendar-selected-border: #8e24aa; + --calendar-selected-text: #6a1b9a; + --calendar-has-events-bg: #fff3e0; + --weekday-header-bg: #f3e5f5; + --weekday-header-text: #4a148c; + --time-label-bg: #f3e5f5; + --time-label-text: #4a148c; + --time-label-border: #ce93d8; + --event-colors: #9C27B0, #673AB7, #3F51B5, #2196F3, #03DAC6, #009688, #4CAF50, #8BC34A, #CDDC39, #FFC107, #FF9800, #FF5722, #F44336, #E91E63, #795548, #607D8B; +} + +[data-theme="purple"] body { + background-color: var(--primary-bg); + color: var(--primary-text); +} + +[data-theme="purple"] .app-sidebar { + background: var(--sidebar-bg); +} + +/* Dark Theme */ +[data-theme="dark"] { + --primary-gradient: linear-gradient(180deg, #424242 0%, #212121 100%); + --primary-bg: #121212; + --primary-text: #ffffff; + --sidebar-bg: linear-gradient(180deg, #424242 0%, #212121 100%); + --sidebar-text: white; + --header-bg: linear-gradient(135deg, #424242 0%, #212121 100%); + --header-text: white; + --card-bg: #1e1e1e; + --border-color: #333333; + --accent-color: #666666; + --calendar-bg: #1f1f1f; + --calendar-border: #333333; + --calendar-day-bg: #1f1f1f; + --calendar-day-hover: #2a2a2a; + --calendar-day-prev-next: #1a1a1a; + --calendar-day-prev-next-text: #555; + --calendar-today-bg: #2d2d2d; + --calendar-today-border: #bb86fc; + --calendar-today-text: #bb86fc; + --calendar-selected-bg: #2a2a2a; + --calendar-selected-border: #bb86fc; + --calendar-selected-text: #bb86fc; + --calendar-has-events-bg: #272727; + --weekday-header-bg: #1a1a1a; + --weekday-header-text: #e0e0e0; + --time-label-bg: #1a1a1a; + --time-label-text: #e0e0e0; + --time-label-border: #333333; + --event-colors: #bb86fc, #03dac6, #cf6679, #ff9800, #4caf50, #2196f3, #9c27b0, #f44336, #795548, #607d8b, #e91e63, #3f51b5, #009688, #8bc34a, #ffc107, #ff5722; +} + +[data-theme="dark"] body { + background-color: var(--primary-bg); + color: var(--primary-text); +} + +[data-theme="dark"] .app-sidebar { + background: var(--sidebar-bg); +} + +[data-theme="dark"] .app-main { + background-color: var(--primary-bg); +} + +[data-theme="dark"] .calendar-day { + background: var(--card-bg); + border-color: var(--border-color); + color: var(--primary-text); +} + +/* Rose Theme */ +[data-theme="rose"] { + --primary-gradient: linear-gradient(180deg, #E91E63 0%, #AD1457 100%); + --primary-bg: #fce4ec; + --primary-text: #880e4f; + --sidebar-bg: linear-gradient(180deg, #E91E63 0%, #AD1457 100%); + --sidebar-text: white; + --header-bg: linear-gradient(135deg, #E91E63 0%, #AD1457 100%); + --header-text: white; + --card-bg: #ffffff; + --border-color: #f8bbd9; + --accent-color: #E91E63; + --calendar-bg: #ffffff; + --calendar-border: #f8bbd9; + --calendar-day-bg: #ffffff; + --calendar-day-hover: #fdf2f8; + --calendar-day-prev-next: #fef7ff; + --calendar-day-prev-next-text: #f8bbd9; + --calendar-today-bg: #f48fb1; + --calendar-today-border: #AD1457; + --calendar-today-text: #880e4f; + --calendar-selected-bg: #fce4ec; + --calendar-selected-border: #c2185b; + --calendar-selected-text: #ad1457; + --calendar-has-events-bg: #fff3e0; + --weekday-header-bg: #fce4ec; + --weekday-header-text: #880e4f; + --time-label-bg: #fce4ec; + --time-label-text: #880e4f; + --time-label-border: #f8bbd9; + --event-colors: #E91E63, #9C27B0, #673AB7, #3F51B5, #2196F3, #03DAC6, #009688, #4CAF50, #8BC34A, #CDDC39, #FFC107, #FF9800, #FF5722, #F44336, #795548, #607D8B; +} + +[data-theme="rose"] body { + background-color: var(--primary-bg); + color: var(--primary-text); +} + +[data-theme="rose"] .app-sidebar { + background: var(--sidebar-bg); +} + +/* Mint Theme */ +[data-theme="mint"] { + --primary-gradient: linear-gradient(180deg, #26A69A 0%, #00695C 100%); + --primary-bg: #e0f2f1; + --primary-text: #004d40; + --sidebar-bg: linear-gradient(180deg, #26A69A 0%, #00695C 100%); + --sidebar-text: white; + --header-bg: linear-gradient(135deg, #26A69A 0%, #00695C 100%); + --header-text: white; + --card-bg: #ffffff; + --border-color: #b2dfdb; + --accent-color: #26A69A; + --calendar-bg: #ffffff; + --calendar-border: #b2dfdb; + --calendar-day-bg: #ffffff; + --calendar-day-hover: #f0fdfc; + --calendar-day-prev-next: #f7ffff; + --calendar-day-prev-next-text: #b2dfdb; + --calendar-today-bg: #b2dfdb; + --calendar-today-border: #00695C; + --calendar-today-text: #004d40; + --calendar-selected-bg: #e0f2f1; + --calendar-selected-border: #00897b; + --calendar-selected-text: #00695c; + --calendar-has-events-bg: #fff3e0; + --weekday-header-bg: #e0f2f1; + --weekday-header-text: #004d40; + --time-label-bg: #e0f2f1; + --time-label-text: #004d40; + --time-label-border: #b2dfdb; + --event-colors: #26A69A, #009688, #4CAF50, #8BC34A, #CDDC39, #FFC107, #FF9800, #FF5722, #F44336, #E91E63, #9C27B0, #673AB7, #3F51B5, #2196F3, #795548, #607D8B; +} + +[data-theme="mint"] body { + background-color: var(--primary-bg); + color: var(--primary-text); +} + +[data-theme="mint"] .app-sidebar { + background: var(--sidebar-bg); } \ No newline at end of file