Some checks failed
Build and Push Docker Image / docker (push) Failing after 1m7s
Moved event fetching logic from CalendarView to Calendar component to properly use the visible date range instead of hardcoded current month. The Calendar component already tracks the current visible date through navigation, so events now load correctly for August and other months when navigating. Changes: - Calendar component now manages its own events state and fetching - Event fetching responds to current_date changes from navigation - CalendarView simplified to just render Calendar component - Fixed cargo fmt/clippy formatting across codebase 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
2316 lines
125 KiB
Rust
2316 lines
125 KiB
Rust
use crate::components::EditAction;
|
||
use crate::models::ical::VEvent;
|
||
use crate::services::calendar_service::CalendarInfo;
|
||
use chrono::{Datelike, Local, NaiveDate, NaiveTime, TimeZone, Utc};
|
||
use wasm_bindgen::JsCast;
|
||
use web_sys::{HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement};
|
||
use yew::prelude::*;
|
||
|
||
#[derive(Properties, PartialEq)]
|
||
pub struct CreateEventModalProps {
|
||
pub is_open: bool,
|
||
pub selected_date: Option<NaiveDate>,
|
||
pub event_to_edit: Option<VEvent>,
|
||
pub on_close: Callback<()>,
|
||
pub on_create: Callback<EventCreationData>,
|
||
pub on_update: Callback<(VEvent, EventCreationData)>, // (original_event, updated_data)
|
||
pub available_calendars: Vec<CalendarInfo>,
|
||
#[prop_or_default]
|
||
pub initial_start_time: Option<NaiveTime>,
|
||
#[prop_or_default]
|
||
pub initial_end_time: Option<NaiveTime>,
|
||
#[prop_or_default]
|
||
pub edit_scope: Option<EditAction>,
|
||
}
|
||
|
||
#[derive(Clone, PartialEq, Debug)]
|
||
pub enum EventStatus {
|
||
Tentative,
|
||
Confirmed,
|
||
Cancelled,
|
||
}
|
||
|
||
impl Default for EventStatus {
|
||
fn default() -> Self {
|
||
EventStatus::Confirmed
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, PartialEq, Debug)]
|
||
pub enum EventClass {
|
||
Public,
|
||
Private,
|
||
Confidential,
|
||
}
|
||
|
||
impl Default for EventClass {
|
||
fn default() -> Self {
|
||
EventClass::Public
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, PartialEq, Debug)]
|
||
pub enum ReminderType {
|
||
None,
|
||
Minutes15,
|
||
Minutes30,
|
||
Hour1,
|
||
Hours2,
|
||
Day1,
|
||
Days2,
|
||
Week1,
|
||
}
|
||
|
||
impl Default for ReminderType {
|
||
fn default() -> Self {
|
||
ReminderType::None
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, PartialEq, Debug)]
|
||
pub enum RecurrenceType {
|
||
None,
|
||
Daily,
|
||
Weekly,
|
||
Monthly,
|
||
Yearly,
|
||
}
|
||
|
||
impl Default for RecurrenceType {
|
||
fn default() -> Self {
|
||
RecurrenceType::None
|
||
}
|
||
}
|
||
|
||
/// Parse RRULE string into recurrence components
|
||
/// Example RRULE: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;UNTIL=20231215T000000Z"
|
||
#[derive(Debug, Default, Clone)]
|
||
struct ParsedRrule {
|
||
pub freq: RecurrenceType,
|
||
pub interval: u32,
|
||
pub until: Option<NaiveDate>,
|
||
pub count: Option<u32>,
|
||
pub byday: Vec<String>,
|
||
pub bymonthday: Option<u8>,
|
||
pub bymonth: Vec<u8>,
|
||
}
|
||
|
||
fn parse_rrule(rrule: Option<&str>) -> ParsedRrule {
|
||
let mut parsed = ParsedRrule {
|
||
interval: 1, // Default interval is 1
|
||
..Default::default()
|
||
};
|
||
|
||
let Some(rrule_str) = rrule else {
|
||
return parsed;
|
||
};
|
||
|
||
// Split RRULE into parts: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE"
|
||
for part in rrule_str.split(';') {
|
||
let mut key_value = part.split('=');
|
||
let key = key_value.next().unwrap_or("");
|
||
let value = key_value.next().unwrap_or("");
|
||
|
||
match key {
|
||
"FREQ" => {
|
||
parsed.freq = match value {
|
||
"DAILY" => RecurrenceType::Daily,
|
||
"WEEKLY" => RecurrenceType::Weekly,
|
||
"MONTHLY" => RecurrenceType::Monthly,
|
||
"YEARLY" => RecurrenceType::Yearly,
|
||
_ => RecurrenceType::None,
|
||
};
|
||
}
|
||
"INTERVAL" => {
|
||
if let Ok(interval) = value.parse::<u32>() {
|
||
parsed.interval = interval.max(1);
|
||
}
|
||
}
|
||
"UNTIL" => {
|
||
// Parse UNTIL date: "20231215T000000Z" -> NaiveDate
|
||
if value.len() >= 8 {
|
||
let date_part = &value[..8]; // Extract YYYYMMDD
|
||
if let Ok(until_date) = NaiveDate::parse_from_str(date_part, "%Y%m%d") {
|
||
parsed.until = Some(until_date);
|
||
}
|
||
}
|
||
}
|
||
"COUNT" => {
|
||
if let Ok(count) = value.parse::<u32>() {
|
||
parsed.count = Some(count);
|
||
}
|
||
}
|
||
"BYDAY" => {
|
||
// Parse BYDAY: "MO,WE,FR" or "1MO,-1SU" (with position)
|
||
parsed.byday = value.split(',').map(|s| s.trim().to_uppercase()).collect();
|
||
}
|
||
"BYMONTHDAY" => {
|
||
// Parse BYMONTHDAY: "15" or "1,15,31"
|
||
if let Some(first_day) = value.split(',').next() {
|
||
if let Ok(day) = first_day.parse::<u8>() {
|
||
if day >= 1 && day <= 31 {
|
||
parsed.bymonthday = Some(day);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"BYMONTH" => {
|
||
// Parse BYMONTH: "1,3,5" (January, March, May)
|
||
parsed.bymonth = value
|
||
.split(',')
|
||
.filter_map(|m| m.trim().parse::<u8>().ok())
|
||
.filter(|&m| m >= 1 && m <= 12)
|
||
.collect();
|
||
}
|
||
_ => {} // Ignore unknown parameters
|
||
}
|
||
}
|
||
|
||
parsed
|
||
}
|
||
|
||
/// Convert BYDAY values to weekday boolean array
|
||
/// Maps RFC 5545 day codes to [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
|
||
fn byday_to_weekday_array(byday: &[String]) -> Vec<bool> {
|
||
let mut weekdays = vec![false; 7];
|
||
|
||
for day_spec in byday {
|
||
// Handle both simple days (MO, TU) and positioned days (1MO, -1SU)
|
||
let day_code = if day_spec.len() > 2 {
|
||
// Extract last 2 characters for positioned days like "1MO" -> "MO"
|
||
&day_spec[day_spec.len() - 2..]
|
||
} else {
|
||
day_spec
|
||
};
|
||
|
||
let index = match day_code {
|
||
"SU" => 0, // Sunday
|
||
"MO" => 1, // Monday
|
||
"TU" => 2, // Tuesday
|
||
"WE" => 3, // Wednesday
|
||
"TH" => 4, // Thursday
|
||
"FR" => 5, // Friday
|
||
"SA" => 6, // Saturday
|
||
_ => continue,
|
||
};
|
||
|
||
weekdays[index] = true;
|
||
}
|
||
|
||
weekdays
|
||
}
|
||
|
||
/// Convert BYMONTH values to monthly boolean array
|
||
/// Maps month numbers to [Jan, Feb, Mar, ..., Dec]
|
||
fn bymonth_to_monthly_array(bymonth: &[u8]) -> Vec<bool> {
|
||
let mut months = vec![false; 12];
|
||
|
||
for &month in bymonth {
|
||
if month >= 1 && month <= 12 {
|
||
months[(month - 1) as usize] = true;
|
||
}
|
||
}
|
||
|
||
months
|
||
}
|
||
|
||
/// Extract positioned weekday from BYDAY for monthly recurrence
|
||
/// Examples: "1MO" -> Some("1MO"), "2TU" -> Some("2TU"), "-1SU" -> Some("-1SU")
|
||
fn extract_monthly_byday(byday: &[String]) -> Option<String> {
|
||
byday
|
||
.iter()
|
||
.find(|day| day.len() > 2) // Positioned days have length > 2
|
||
.cloned()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod rrule_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_parse_simple_weekly() {
|
||
let parsed = parse_rrule(Some("FREQ=WEEKLY;BYDAY=MO,WE,FR"));
|
||
assert_eq!(parsed.freq, RecurrenceType::Weekly);
|
||
assert_eq!(parsed.interval, 1);
|
||
assert_eq!(parsed.byday, vec!["MO", "WE", "FR"]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_complex_monthly() {
|
||
let parsed = parse_rrule(Some(
|
||
"FREQ=MONTHLY;INTERVAL=2;BYDAY=1MO;UNTIL=20241231T000000Z",
|
||
));
|
||
assert_eq!(parsed.freq, RecurrenceType::Monthly);
|
||
assert_eq!(parsed.interval, 2);
|
||
assert_eq!(parsed.byday, vec!["1MO"]);
|
||
assert!(parsed.until.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn test_byday_to_weekday_array() {
|
||
let weekdays =
|
||
byday_to_weekday_array(&["MO".to_string(), "WE".to_string(), "FR".to_string()]);
|
||
// [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
|
||
assert_eq!(weekdays, vec![false, true, false, true, false, true, false]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_monthly_byday() {
|
||
let byday = vec!["1MO".to_string(), "WE".to_string()];
|
||
assert_eq!(extract_monthly_byday(&byday), Some("1MO".to_string()));
|
||
|
||
let byday_simple = vec!["MO".to_string(), "TU".to_string()];
|
||
assert_eq!(extract_monthly_byday(&byday_simple), None);
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_rrule_weekly() {
|
||
let mut data = EventCreationData::default();
|
||
data.recurrence = RecurrenceType::Weekly;
|
||
data.recurrence_interval = 2;
|
||
data.recurrence_days = vec![false, true, false, true, false, true, false]; // Mon, Wed, Fri
|
||
data.recurrence_count = Some(10);
|
||
|
||
let rrule = data.build_rrule();
|
||
assert!(rrule.contains("FREQ=WEEKLY"));
|
||
assert!(rrule.contains("INTERVAL=2"));
|
||
assert!(rrule.contains("BYDAY=MO,WE,FR"));
|
||
assert!(rrule.contains("COUNT=10"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_rrule_monthly_by_day() {
|
||
let mut data = EventCreationData::default();
|
||
data.recurrence = RecurrenceType::Monthly;
|
||
data.monthly_by_day = Some("1MO".to_string());
|
||
data.recurrence_until = Some(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap());
|
||
|
||
let rrule = data.build_rrule();
|
||
assert!(rrule.contains("FREQ=MONTHLY"));
|
||
assert!(rrule.contains("BYDAY=1MO"));
|
||
assert!(rrule.contains("UNTIL=20241231T000000Z"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_rrule_yearly() {
|
||
let mut data = EventCreationData::default();
|
||
data.recurrence = RecurrenceType::Yearly;
|
||
data.yearly_by_month = vec![
|
||
false, false, true, false, true, false, false, false, false, false, false, false,
|
||
]; // March, May
|
||
|
||
let rrule = data.build_rrule();
|
||
println!("YEARLY RRULE: {}", rrule);
|
||
assert!(rrule.contains("FREQ=YEARLY"));
|
||
assert!(rrule.contains("BYMONTH=3,5"));
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, PartialEq, Debug)]
|
||
pub struct EventCreationData {
|
||
pub title: String,
|
||
pub description: String,
|
||
pub start_date: NaiveDate,
|
||
pub start_time: NaiveTime,
|
||
pub end_date: NaiveDate,
|
||
pub end_time: NaiveTime,
|
||
pub location: String,
|
||
pub all_day: bool,
|
||
pub status: EventStatus,
|
||
pub class: EventClass,
|
||
pub priority: Option<u8>,
|
||
pub organizer: String,
|
||
pub attendees: String, // Comma-separated list
|
||
pub categories: String, // Comma-separated list
|
||
pub reminder: ReminderType,
|
||
pub recurrence: RecurrenceType,
|
||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||
pub selected_calendar: Option<String>, // Calendar path
|
||
|
||
// Advanced recurrence fields
|
||
pub recurrence_interval: u32, // INTERVAL - every N (days/weeks/months/years)
|
||
pub recurrence_until: Option<NaiveDate>, // UNTIL date
|
||
pub recurrence_count: Option<u32>, // COUNT - number of occurrences
|
||
pub monthly_by_day: Option<String>, // For monthly: "1MO" = first Monday, "2TU" = second Tuesday, etc.
|
||
pub monthly_by_monthday: Option<u8>, // For monthly: day of month (1-31)
|
||
pub yearly_by_month: Vec<bool>, // For yearly: [Jan, Feb, Mar, ..., Dec]
|
||
|
||
// Edit scope and tracking fields
|
||
pub edit_scope: Option<EditAction>,
|
||
pub changed_fields: Vec<String>, // List of field names that were changed
|
||
}
|
||
|
||
impl Default for EventCreationData {
|
||
fn default() -> Self {
|
||
let now = chrono::Local::now().naive_local();
|
||
let start_time = NaiveTime::from_hms_opt(9, 0, 0).unwrap_or_default();
|
||
let end_time = NaiveTime::from_hms_opt(10, 0, 0).unwrap_or_default();
|
||
|
||
Self {
|
||
title: String::new(),
|
||
description: String::new(),
|
||
start_date: now.date(),
|
||
start_time,
|
||
end_date: now.date(),
|
||
end_time,
|
||
location: String::new(),
|
||
all_day: false,
|
||
status: EventStatus::default(),
|
||
class: EventClass::default(),
|
||
priority: None,
|
||
organizer: String::new(),
|
||
attendees: String::new(),
|
||
categories: String::new(),
|
||
reminder: ReminderType::default(),
|
||
recurrence: RecurrenceType::default(),
|
||
recurrence_days: vec![false; 7], // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] - all false by default
|
||
selected_calendar: None,
|
||
|
||
// Advanced recurrence defaults
|
||
recurrence_interval: 1,
|
||
recurrence_until: None,
|
||
recurrence_count: None,
|
||
monthly_by_day: None,
|
||
monthly_by_monthday: None,
|
||
yearly_by_month: vec![false; 12], // [Jan, Feb, ..., Dec] - all false by default
|
||
|
||
// Edit scope and tracking defaults
|
||
edit_scope: None,
|
||
changed_fields: vec![],
|
||
}
|
||
}
|
||
}
|
||
|
||
impl EventCreationData {
|
||
/// Build a complete RRULE string from recurrence fields
|
||
fn build_rrule(&self) -> String {
|
||
if matches!(self.recurrence, RecurrenceType::None) {
|
||
return String::new();
|
||
}
|
||
|
||
let mut parts = Vec::new();
|
||
|
||
// Add frequency (required)
|
||
match self.recurrence {
|
||
RecurrenceType::Daily => parts.push("FREQ=DAILY".to_string()),
|
||
RecurrenceType::Weekly => parts.push("FREQ=WEEKLY".to_string()),
|
||
RecurrenceType::Monthly => parts.push("FREQ=MONTHLY".to_string()),
|
||
RecurrenceType::Yearly => parts.push("FREQ=YEARLY".to_string()),
|
||
RecurrenceType::None => return String::new(),
|
||
}
|
||
|
||
// Add interval if not 1
|
||
if self.recurrence_interval > 1 {
|
||
parts.push(format!("INTERVAL={}", self.recurrence_interval));
|
||
}
|
||
|
||
// Add frequency-specific rules
|
||
match self.recurrence {
|
||
RecurrenceType::Weekly => {
|
||
// Add BYDAY for weekly recurrence
|
||
let selected_days: Vec<&str> = self
|
||
.recurrence_days
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(i, &selected)| {
|
||
if selected {
|
||
Some(match i {
|
||
0 => "SU", // Sunday
|
||
1 => "MO", // Monday
|
||
2 => "TU", // Tuesday
|
||
3 => "WE", // Wednesday
|
||
4 => "TH", // Thursday
|
||
5 => "FR", // Friday
|
||
6 => "SA", // Saturday
|
||
_ => "",
|
||
})
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.filter(|s| !s.is_empty())
|
||
.collect();
|
||
|
||
if !selected_days.is_empty() {
|
||
parts.push(format!("BYDAY={}", selected_days.join(",")));
|
||
}
|
||
}
|
||
RecurrenceType::Monthly => {
|
||
// Add BYDAY or BYMONTHDAY for monthly recurrence
|
||
if let Some(ref by_day) = self.monthly_by_day {
|
||
parts.push(format!("BYDAY={}", by_day));
|
||
} else if let Some(by_monthday) = self.monthly_by_monthday {
|
||
parts.push(format!("BYMONTHDAY={}", by_monthday));
|
||
}
|
||
}
|
||
RecurrenceType::Yearly => {
|
||
// Add BYMONTH for yearly recurrence
|
||
let selected_months: Vec<String> = self
|
||
.yearly_by_month
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(i, &selected)| {
|
||
if selected {
|
||
Some((i + 1).to_string()) // Convert 0-based index to 1-based month
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
if !selected_months.is_empty() {
|
||
parts.push(format!("BYMONTH={}", selected_months.join(",")));
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
// Add end condition (UNTIL or COUNT)
|
||
if let Some(until_date) = self.recurrence_until {
|
||
// Format as UNTIL=YYYYMMDDTHHMMSSZ
|
||
parts.push(format!("UNTIL={}T000000Z", until_date.format("%Y%m%d")));
|
||
} else if let Some(count) = self.recurrence_count {
|
||
parts.push(format!("COUNT={}", count));
|
||
}
|
||
|
||
parts.join(";")
|
||
}
|
||
|
||
pub fn to_create_event_params(
|
||
&self,
|
||
) -> (
|
||
String,
|
||
String,
|
||
String,
|
||
String,
|
||
String,
|
||
String,
|
||
String,
|
||
bool,
|
||
String,
|
||
String,
|
||
Option<u8>,
|
||
String,
|
||
String,
|
||
String,
|
||
String,
|
||
String,
|
||
Vec<bool>,
|
||
Option<String>,
|
||
) {
|
||
// Convert local date/time to UTC
|
||
let start_local = Local
|
||
.from_local_datetime(&self.start_date.and_time(self.start_time))
|
||
.single()
|
||
.unwrap_or_else(|| Local::now());
|
||
let end_local = Local
|
||
.from_local_datetime(&self.end_date.and_time(self.end_time))
|
||
.single()
|
||
.unwrap_or_else(|| Local::now());
|
||
|
||
let start_utc = start_local.with_timezone(&Utc);
|
||
let end_utc = end_local.with_timezone(&Utc);
|
||
|
||
(
|
||
self.title.clone(),
|
||
self.description.clone(),
|
||
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(),
|
||
self.location.clone(),
|
||
self.all_day,
|
||
match self.status {
|
||
EventStatus::Tentative => "TENTATIVE".to_string(),
|
||
EventStatus::Confirmed => "CONFIRMED".to_string(),
|
||
EventStatus::Cancelled => "CANCELLED".to_string(),
|
||
},
|
||
match self.class {
|
||
EventClass::Public => "PUBLIC".to_string(),
|
||
EventClass::Private => "PRIVATE".to_string(),
|
||
EventClass::Confidential => "CONFIDENTIAL".to_string(),
|
||
},
|
||
self.priority,
|
||
self.organizer.clone(),
|
||
self.attendees.clone(),
|
||
self.categories.clone(),
|
||
match self.reminder {
|
||
ReminderType::None => "".to_string(),
|
||
ReminderType::Minutes15 => "15".to_string(),
|
||
ReminderType::Minutes30 => "30".to_string(),
|
||
ReminderType::Hour1 => "60".to_string(),
|
||
ReminderType::Hours2 => "120".to_string(),
|
||
ReminderType::Day1 => "1440".to_string(),
|
||
ReminderType::Days2 => "2880".to_string(),
|
||
ReminderType::Week1 => "10080".to_string(),
|
||
},
|
||
self.build_rrule(), // Use the comprehensive RRULE builder
|
||
self.recurrence_days.clone(),
|
||
self.selected_calendar.clone(),
|
||
)
|
||
}
|
||
}
|
||
|
||
impl EventCreationData {
|
||
pub fn from_calendar_event(event: &VEvent) -> Self {
|
||
// Convert VEvent to EventCreationData for editing
|
||
// All events (including temporary drag events) now have proper UTC times
|
||
// Convert to local time for display in the modal
|
||
|
||
// Parse RRULE once for efficiency
|
||
let parsed_rrule = parse_rrule(event.rrule.as_deref());
|
||
|
||
Self {
|
||
title: event.summary.clone().unwrap_or_default(),
|
||
description: event.description.clone().unwrap_or_default(),
|
||
start_date: event.dtstart.with_timezone(&chrono::Local).date_naive(),
|
||
start_time: event.dtstart.with_timezone(&chrono::Local).time(),
|
||
end_date: event
|
||
.dtend
|
||
.as_ref()
|
||
.map(|e| e.with_timezone(&chrono::Local).date_naive())
|
||
.unwrap_or(event.dtstart.with_timezone(&chrono::Local).date_naive()),
|
||
end_time: event
|
||
.dtend
|
||
.as_ref()
|
||
.map(|e| e.with_timezone(&chrono::Local).time())
|
||
.unwrap_or(event.dtstart.with_timezone(&chrono::Local).time()),
|
||
location: event.location.clone().unwrap_or_default(),
|
||
all_day: event.all_day,
|
||
status: event
|
||
.status
|
||
.as_ref()
|
||
.map(|s| match s {
|
||
crate::models::ical::EventStatus::Tentative => EventStatus::Tentative,
|
||
crate::models::ical::EventStatus::Confirmed => EventStatus::Confirmed,
|
||
crate::models::ical::EventStatus::Cancelled => EventStatus::Cancelled,
|
||
})
|
||
.unwrap_or(EventStatus::Confirmed),
|
||
class: event
|
||
.class
|
||
.as_ref()
|
||
.map(|c| match c {
|
||
crate::models::ical::EventClass::Public => EventClass::Public,
|
||
crate::models::ical::EventClass::Private => EventClass::Private,
|
||
crate::models::ical::EventClass::Confidential => EventClass::Confidential,
|
||
})
|
||
.unwrap_or(EventClass::Public),
|
||
priority: event.priority,
|
||
organizer: event
|
||
.organizer
|
||
.as_ref()
|
||
.map(|o| o.cal_address.clone())
|
||
.unwrap_or_default(),
|
||
attendees: event
|
||
.attendees
|
||
.iter()
|
||
.map(|a| a.cal_address.clone())
|
||
.collect::<Vec<_>>()
|
||
.join(", "),
|
||
categories: event.categories.join(", "),
|
||
reminder: ReminderType::default(), // TODO: Convert from event reminders
|
||
recurrence: parsed_rrule.freq.clone(),
|
||
recurrence_days: if parsed_rrule.freq == RecurrenceType::Weekly {
|
||
byday_to_weekday_array(&parsed_rrule.byday)
|
||
} else {
|
||
vec![false; 7]
|
||
},
|
||
selected_calendar: event.calendar_path.clone(),
|
||
|
||
// Advanced recurrence fields from parsed RRULE
|
||
recurrence_interval: parsed_rrule.interval,
|
||
recurrence_until: parsed_rrule.until,
|
||
recurrence_count: parsed_rrule.count,
|
||
monthly_by_day: if parsed_rrule.freq == RecurrenceType::Monthly {
|
||
extract_monthly_byday(&parsed_rrule.byday)
|
||
} else {
|
||
None
|
||
},
|
||
monthly_by_monthday: if parsed_rrule.freq == RecurrenceType::Monthly {
|
||
parsed_rrule.bymonthday
|
||
} else {
|
||
None
|
||
},
|
||
yearly_by_month: if parsed_rrule.freq == RecurrenceType::Yearly {
|
||
bymonth_to_monthly_array(&parsed_rrule.bymonth)
|
||
} else {
|
||
vec![false; 12]
|
||
},
|
||
|
||
// Edit scope and tracking defaults (will be set later if needed)
|
||
edit_scope: None,
|
||
changed_fields: vec![],
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, PartialEq)]
|
||
enum ModalTab {
|
||
BasicDetails,
|
||
Advanced,
|
||
People,
|
||
Categories,
|
||
Location,
|
||
Reminders,
|
||
}
|
||
|
||
impl Default for ModalTab {
|
||
fn default() -> Self {
|
||
ModalTab::BasicDetails
|
||
}
|
||
}
|
||
|
||
#[function_component(CreateEventModal)]
|
||
pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
|
||
let event_data = use_state(|| EventCreationData::default());
|
||
let active_tab = use_state(|| ModalTab::default());
|
||
|
||
// Initialize with selected date or event data if provided
|
||
use_effect_with(
|
||
(
|
||
props.selected_date,
|
||
props.event_to_edit.clone(),
|
||
props.is_open,
|
||
props.available_calendars.clone(),
|
||
props.initial_start_time,
|
||
props.initial_end_time,
|
||
props.edit_scope.clone(),
|
||
),
|
||
{
|
||
let event_data = event_data.clone();
|
||
move |(
|
||
selected_date,
|
||
event_to_edit,
|
||
is_open,
|
||
available_calendars,
|
||
initial_start_time,
|
||
initial_end_time,
|
||
edit_scope,
|
||
)| {
|
||
if *is_open {
|
||
let mut data = if let Some(event) = event_to_edit {
|
||
// Pre-populate with event data for editing
|
||
EventCreationData::from_calendar_event(event)
|
||
} else if let Some(date) = selected_date {
|
||
// Initialize with selected date for new event
|
||
let mut data = EventCreationData::default();
|
||
data.start_date = *date;
|
||
data.end_date = *date;
|
||
|
||
// Use initial times if provided (from drag-to-create)
|
||
if let Some(start_time) = initial_start_time {
|
||
data.start_time = *start_time;
|
||
}
|
||
if let Some(end_time) = initial_end_time {
|
||
data.end_time = *end_time;
|
||
}
|
||
|
||
data
|
||
} else {
|
||
// Default initialization
|
||
EventCreationData::default()
|
||
};
|
||
|
||
// Set default calendar to the first available one if none selected
|
||
if data.selected_calendar.is_none() && !available_calendars.is_empty() {
|
||
data.selected_calendar = Some(available_calendars[0].path.clone());
|
||
}
|
||
|
||
// Set edit scope if provided
|
||
if let Some(scope) = edit_scope {
|
||
data.edit_scope = Some(scope.clone());
|
||
}
|
||
|
||
event_data.set(data);
|
||
}
|
||
|| ()
|
||
}
|
||
},
|
||
);
|
||
|
||
if !props.is_open {
|
||
return html! {};
|
||
}
|
||
|
||
let on_backdrop_click = {
|
||
let on_close = props.on_close.clone();
|
||
Callback::from(move |e: MouseEvent| {
|
||
if e.target() == e.current_target() {
|
||
on_close.emit(());
|
||
}
|
||
})
|
||
};
|
||
|
||
// Helper function to track field changes
|
||
let _track_field_change = |data: &mut EventCreationData, field_name: &str| {
|
||
if !data.changed_fields.contains(&field_name.to_string()) {
|
||
data.changed_fields.push(field_name.to_string());
|
||
}
|
||
};
|
||
|
||
let on_title_input = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: InputEvent| {
|
||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||
let mut data = (*event_data).clone();
|
||
let new_value = input.value();
|
||
if data.title != new_value {
|
||
data.title = new_value;
|
||
if !data.changed_fields.contains(&"title".to_string()) {
|
||
data.changed_fields.push("title".to_string());
|
||
}
|
||
}
|
||
event_data.set(data);
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_calendar_change = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: Event| {
|
||
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
|
||
let mut data = (*event_data).clone();
|
||
let value = select.value();
|
||
let new_calendar = if value.is_empty() { None } else { Some(value) };
|
||
if data.selected_calendar != new_calendar {
|
||
data.selected_calendar = new_calendar;
|
||
if !data
|
||
.changed_fields
|
||
.contains(&"selected_calendar".to_string())
|
||
{
|
||
data.changed_fields.push("selected_calendar".to_string());
|
||
}
|
||
}
|
||
event_data.set(data);
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_description_input = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: InputEvent| {
|
||
if let Some(textarea) = e.target_dyn_into::<HtmlTextAreaElement>() {
|
||
let mut data = (*event_data).clone();
|
||
data.description = textarea.value();
|
||
event_data.set(data);
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_location_input = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: InputEvent| {
|
||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||
let mut data = (*event_data).clone();
|
||
data.location = input.value();
|
||
event_data.set(data);
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_organizer_input = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: InputEvent| {
|
||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||
let mut data = (*event_data).clone();
|
||
data.organizer = input.value();
|
||
event_data.set(data);
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_attendees_input = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: InputEvent| {
|
||
if let Some(textarea) = e.target_dyn_into::<HtmlTextAreaElement>() {
|
||
let mut data = (*event_data).clone();
|
||
data.attendees = textarea.value();
|
||
event_data.set(data);
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_categories_input = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: InputEvent| {
|
||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||
let mut data = (*event_data).clone();
|
||
data.categories = input.value();
|
||
event_data.set(data);
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_status_change = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: Event| {
|
||
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
|
||
let mut data = (*event_data).clone();
|
||
data.status = match select.value().as_str() {
|
||
"tentative" => EventStatus::Tentative,
|
||
"cancelled" => EventStatus::Cancelled,
|
||
_ => EventStatus::Confirmed,
|
||
};
|
||
event_data.set(data);
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_class_change = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: Event| {
|
||
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
|
||
let mut data = (*event_data).clone();
|
||
data.class = match select.value().as_str() {
|
||
"private" => EventClass::Private,
|
||
"confidential" => EventClass::Confidential,
|
||
_ => EventClass::Public,
|
||
};
|
||
event_data.set(data);
|
||
}
|
||
})
|
||
};
|
||
|
||
let _on_priority_input = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: InputEvent| {
|
||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||
let mut data = (*event_data).clone();
|
||
data.priority = input.value().parse::<u8>().ok().filter(|&p| p <= 9);
|
||
event_data.set(data);
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_reminder_change = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: Event| {
|
||
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
|
||
let mut data = (*event_data).clone();
|
||
data.reminder = match select.value().as_str() {
|
||
"15min" => ReminderType::Minutes15,
|
||
"30min" => ReminderType::Minutes30,
|
||
"1hour" => ReminderType::Hour1,
|
||
"2hours" => ReminderType::Hours2,
|
||
"1day" => ReminderType::Day1,
|
||
"2days" => ReminderType::Days2,
|
||
"1week" => ReminderType::Week1,
|
||
_ => ReminderType::None,
|
||
};
|
||
event_data.set(data);
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_recurrence_change = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: Event| {
|
||
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
|
||
let mut data = (*event_data).clone();
|
||
data.recurrence = match select.value().as_str() {
|
||
"daily" => RecurrenceType::Daily,
|
||
"weekly" => RecurrenceType::Weekly,
|
||
"monthly" => RecurrenceType::Monthly,
|
||
"yearly" => RecurrenceType::Yearly,
|
||
_ => RecurrenceType::None,
|
||
};
|
||
// Reset recurrence-related fields when changing recurrence type
|
||
data.recurrence_days = vec![false; 7];
|
||
data.recurrence_interval = 1;
|
||
data.recurrence_until = None;
|
||
data.recurrence_count = None;
|
||
data.monthly_by_day = None;
|
||
data.monthly_by_monthday = None;
|
||
data.yearly_by_month = vec![false; 12];
|
||
event_data.set(data);
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_recurrence_interval_change = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: Event| {
|
||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||
if let Ok(interval) = input.value().parse::<u32>() {
|
||
let mut data = (*event_data).clone();
|
||
data.recurrence_interval = interval.max(1);
|
||
event_data.set(data);
|
||
}
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_recurrence_until_change = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: Event| {
|
||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||
let mut data = (*event_data).clone();
|
||
if input.value().is_empty() {
|
||
data.recurrence_until = None;
|
||
} else if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
|
||
data.recurrence_until = Some(date);
|
||
}
|
||
event_data.set(data);
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_recurrence_count_change = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: Event| {
|
||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||
let mut data = (*event_data).clone();
|
||
if input.value().is_empty() {
|
||
data.recurrence_count = None;
|
||
} else if let Ok(count) = input.value().parse::<u32>() {
|
||
data.recurrence_count = Some(count.max(1));
|
||
}
|
||
event_data.set(data);
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_monthly_by_monthday_change = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: Event| {
|
||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||
let mut data = (*event_data).clone();
|
||
if input.value().is_empty() {
|
||
data.monthly_by_monthday = None;
|
||
} else if let Ok(day) = input.value().parse::<u8>() {
|
||
if day >= 1 && day <= 31 {
|
||
data.monthly_by_monthday = Some(day);
|
||
data.monthly_by_day = None; // Clear the other option
|
||
}
|
||
}
|
||
event_data.set(data);
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_monthly_by_day_change = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: Event| {
|
||
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
|
||
let mut data = (*event_data).clone();
|
||
if select.value().is_empty() || select.value() == "none" {
|
||
data.monthly_by_day = None;
|
||
} else {
|
||
data.monthly_by_day = Some(select.value());
|
||
data.monthly_by_monthday = None; // Clear the other option
|
||
}
|
||
event_data.set(data);
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_yearly_month_change = {
|
||
let event_data = event_data.clone();
|
||
move |month_index: usize| {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: Event| {
|
||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||
let mut data = (*event_data).clone();
|
||
if month_index < data.yearly_by_month.len() {
|
||
data.yearly_by_month[month_index] = input.checked();
|
||
event_data.set(data);
|
||
}
|
||
}
|
||
})
|
||
}
|
||
};
|
||
|
||
let on_weekday_change = {
|
||
let event_data = event_data.clone();
|
||
move |day_index: usize| {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: Event| {
|
||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||
let mut data = (*event_data).clone();
|
||
if day_index < data.recurrence_days.len() {
|
||
data.recurrence_days[day_index] = input.checked();
|
||
event_data.set(data);
|
||
}
|
||
}
|
||
})
|
||
}
|
||
};
|
||
|
||
let on_start_date_change = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: Event| {
|
||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||
if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
|
||
let mut data = (*event_data).clone();
|
||
data.start_date = date;
|
||
event_data.set(data);
|
||
}
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_start_time_change = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: Event| {
|
||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||
if let Ok(time) = NaiveTime::parse_from_str(&input.value(), "%H:%M") {
|
||
let mut data = (*event_data).clone();
|
||
data.start_time = time;
|
||
event_data.set(data);
|
||
}
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_end_date_change = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: Event| {
|
||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||
if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
|
||
let mut data = (*event_data).clone();
|
||
data.end_date = date;
|
||
event_data.set(data);
|
||
}
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_end_time_change = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: Event| {
|
||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||
if let Ok(time) = NaiveTime::parse_from_str(&input.value(), "%H:%M") {
|
||
let mut data = (*event_data).clone();
|
||
data.end_time = time;
|
||
event_data.set(data);
|
||
}
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_all_day_change = {
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: Event| {
|
||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||
let mut data = (*event_data).clone();
|
||
data.all_day = input.checked();
|
||
event_data.set(data);
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_submit_click = {
|
||
let event_data = event_data.clone();
|
||
let on_create = props.on_create.clone();
|
||
let on_update = props.on_update.clone();
|
||
let event_to_edit = props.event_to_edit.clone();
|
||
Callback::from(move |_: MouseEvent| {
|
||
if let Some(original_event) = &event_to_edit {
|
||
// We're editing - call on_update with original event and new data
|
||
on_update.emit((original_event.clone(), (*event_data).clone()));
|
||
} else {
|
||
// We're creating - call on_create with new data
|
||
on_create.emit((*event_data).clone());
|
||
}
|
||
})
|
||
};
|
||
|
||
let on_cancel_click = {
|
||
let on_close = props.on_close.clone();
|
||
Callback::from(move |_: MouseEvent| {
|
||
on_close.emit(());
|
||
})
|
||
};
|
||
|
||
// Tab switching callbacks
|
||
let switch_to_tab = {
|
||
let active_tab = active_tab.clone();
|
||
Callback::from(move |tab: ModalTab| {
|
||
active_tab.set(tab);
|
||
})
|
||
};
|
||
|
||
let data = &*event_data;
|
||
|
||
html! {
|
||
<div class="modal-backdrop" onclick={on_backdrop_click}>
|
||
<div class="modal-content create-event-modal" onclick={Callback::from(|e: MouseEvent| e.stop_propagation())}>
|
||
<div class="modal-header">
|
||
<h3>{if props.event_to_edit.is_some() { "Edit Event" } else { "Create New Event" }}</h3>
|
||
<button type="button" class="modal-close" onclick={Callback::from({
|
||
let on_close = props.on_close.clone();
|
||
move |_: MouseEvent| on_close.emit(())
|
||
})}>{"×"}</button>
|
||
</div>
|
||
|
||
<div class="modal-body">
|
||
// Tab navigation
|
||
<div class="tab-navigation">
|
||
<button
|
||
type="button"
|
||
class={if *active_tab == ModalTab::BasicDetails { "tab-button active" } else { "tab-button" }}
|
||
onclick={
|
||
let switch_to_tab = switch_to_tab.clone();
|
||
Callback::from(move |_| switch_to_tab.emit(ModalTab::BasicDetails))
|
||
}
|
||
>
|
||
{"Basic Details"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class={if *active_tab == ModalTab::Advanced { "tab-button active" } else { "tab-button" }}
|
||
onclick={
|
||
let switch_to_tab = switch_to_tab.clone();
|
||
Callback::from(move |_| switch_to_tab.emit(ModalTab::Advanced))
|
||
}
|
||
>
|
||
{"Advanced"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class={if *active_tab == ModalTab::People { "tab-button active" } else { "tab-button" }}
|
||
onclick={
|
||
let switch_to_tab = switch_to_tab.clone();
|
||
Callback::from(move |_| switch_to_tab.emit(ModalTab::People))
|
||
}
|
||
>
|
||
{"People"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class={if *active_tab == ModalTab::Categories { "tab-button active" } else { "tab-button" }}
|
||
onclick={
|
||
let switch_to_tab = switch_to_tab.clone();
|
||
Callback::from(move |_| switch_to_tab.emit(ModalTab::Categories))
|
||
}
|
||
>
|
||
{"Categories"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class={if *active_tab == ModalTab::Location { "tab-button active" } else { "tab-button" }}
|
||
onclick={
|
||
let switch_to_tab = switch_to_tab.clone();
|
||
Callback::from(move |_| switch_to_tab.emit(ModalTab::Location))
|
||
}
|
||
>
|
||
{"Location"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class={if *active_tab == ModalTab::Reminders { "tab-button active" } else { "tab-button" }}
|
||
onclick={
|
||
let switch_to_tab = switch_to_tab.clone();
|
||
Callback::from(move |_| switch_to_tab.emit(ModalTab::Reminders))
|
||
}
|
||
>
|
||
{"Reminders"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
// Tab Content
|
||
<div class="tab-content">
|
||
{
|
||
match *active_tab {
|
||
ModalTab::BasicDetails => html! {
|
||
<div class="tab-panel">
|
||
<div class="form-group">
|
||
<label for="event-title">{"Title *"}</label>
|
||
<input
|
||
type="text"
|
||
id="event-title"
|
||
class="form-input"
|
||
value={data.title.clone()}
|
||
oninput={on_title_input}
|
||
placeholder="Enter event title"
|
||
required=true
|
||
/>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="event-calendar">{"Calendar"}</label>
|
||
<select
|
||
id="event-calendar"
|
||
class="form-input"
|
||
onchange={on_calendar_change}
|
||
>
|
||
<option value="" selected={data.selected_calendar.is_none()}>{"Select calendar..."}</option>
|
||
{
|
||
props.available_calendars.iter().map(|calendar| {
|
||
let is_selected = data.selected_calendar.as_ref() == Some(&calendar.path);
|
||
html! {
|
||
<option
|
||
key={calendar.path.clone()}
|
||
value={calendar.path.clone()}
|
||
selected={is_selected}
|
||
>
|
||
{&calendar.display_name}
|
||
</option>
|
||
}
|
||
}).collect::<Html>()
|
||
}
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="event-description">{"Description"}</label>
|
||
<textarea
|
||
id="event-description"
|
||
class="form-input"
|
||
value={data.description.clone()}
|
||
oninput={on_description_input}
|
||
placeholder="Enter event description"
|
||
rows="3"
|
||
></textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
checked={data.all_day}
|
||
onchange={on_all_day_change}
|
||
/>
|
||
{" All Day"}
|
||
</label>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="event-recurrence-basic">{"Repeat"}</label>
|
||
<select
|
||
id="event-recurrence-basic"
|
||
class="form-input"
|
||
onchange={on_recurrence_change}
|
||
>
|
||
<option value="none" selected={matches!(data.recurrence, RecurrenceType::None)}>{"Does not repeat"}</option>
|
||
<option value="daily" selected={matches!(data.recurrence, RecurrenceType::Daily)}>{"Daily"}</option>
|
||
<option value="weekly" selected={matches!(data.recurrence, RecurrenceType::Weekly)}>{"Weekly"}</option>
|
||
<option value="monthly" selected={matches!(data.recurrence, RecurrenceType::Monthly)}>{"Monthly"}</option>
|
||
<option value="yearly" selected={matches!(data.recurrence, RecurrenceType::Yearly)}>{"Yearly"}</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="event-reminder-basic">{"Reminder"}</label>
|
||
<select
|
||
id="event-reminder-basic"
|
||
class="form-input"
|
||
onchange={on_reminder_change}
|
||
>
|
||
<option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"None"}</option>
|
||
<option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes before"}</option>
|
||
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option>
|
||
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option>
|
||
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="start-date">{"Start Date *"}</label>
|
||
<input
|
||
type="date"
|
||
id="start-date"
|
||
class="form-input"
|
||
value={data.start_date.format("%Y-%m-%d").to_string()}
|
||
onchange={on_start_date_change}
|
||
required=true
|
||
/>
|
||
</div>
|
||
|
||
if !data.all_day {
|
||
<div class="form-group">
|
||
<label for="start-time">{"Start Time"}</label>
|
||
<input
|
||
type="time"
|
||
id="start-time"
|
||
class="form-input"
|
||
value={data.start_time.format("%H:%M").to_string()}
|
||
onchange={on_start_time_change}
|
||
/>
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="end-date">{"End Date *"}</label>
|
||
<input
|
||
type="date"
|
||
id="end-date"
|
||
class="form-input"
|
||
value={data.end_date.format("%Y-%m-%d").to_string()}
|
||
onchange={on_end_date_change}
|
||
required=true
|
||
/>
|
||
</div>
|
||
|
||
if !data.all_day {
|
||
<div class="form-group">
|
||
<label for="end-time">{"End Time"}</label>
|
||
<input
|
||
type="time"
|
||
id="end-time"
|
||
class="form-input"
|
||
value={data.end_time.format("%H:%M").to_string()}
|
||
onchange={on_end_time_change}
|
||
/>
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="event-location">{"Location"}</label>
|
||
<input
|
||
type="text"
|
||
id="event-location"
|
||
class="form-input"
|
||
value={data.location.clone()}
|
||
oninput={on_location_input}
|
||
placeholder="Enter event location"
|
||
/>
|
||
</div>
|
||
|
||
// Show weekday selection only when weekly recurrence is selected
|
||
if matches!(data.recurrence, RecurrenceType::Weekly) {
|
||
<div class="form-group">
|
||
<label>{"Repeat on"}</label>
|
||
<div class="weekday-selection">
|
||
{
|
||
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, day)| {
|
||
let day_checked = data.recurrence_days.get(i).cloned().unwrap_or(false);
|
||
let on_change = on_weekday_change(i);
|
||
html! {
|
||
<label key={i} class="weekday-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={day_checked}
|
||
onchange={on_change}
|
||
/>
|
||
<span class="weekday-label">{day}</span>
|
||
</label>
|
||
}
|
||
})
|
||
.collect::<Html>()
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
// Show additional recurrence options for all recurring events
|
||
if !matches!(data.recurrence, RecurrenceType::None) {
|
||
<div class="recurrence-options">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="recurrence-interval">{"Every"}</label>
|
||
<div class="interval-input">
|
||
<input
|
||
id="recurrence-interval"
|
||
type="number"
|
||
class="form-input"
|
||
value={data.recurrence_interval.to_string()}
|
||
min="1"
|
||
max="999"
|
||
onchange={on_recurrence_interval_change}
|
||
/>
|
||
<span class="interval-unit">
|
||
{match data.recurrence {
|
||
RecurrenceType::Daily => if data.recurrence_interval == 1 { "day" } else { "days" },
|
||
RecurrenceType::Weekly => if data.recurrence_interval == 1 { "week" } else { "weeks" },
|
||
RecurrenceType::Monthly => if data.recurrence_interval == 1 { "month" } else { "months" },
|
||
RecurrenceType::Yearly => if data.recurrence_interval == 1 { "year" } else { "years" },
|
||
RecurrenceType::None => "",
|
||
}}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>{"Ends"}</label>
|
||
<div class="end-options">
|
||
<div class="end-option">
|
||
<label class="radio-label">
|
||
<input
|
||
type="radio"
|
||
name="recurrence-end"
|
||
value="never"
|
||
checked={data.recurrence_until.is_none() && data.recurrence_count.is_none()}
|
||
onchange={{
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut new_data = (*event_data).clone();
|
||
new_data.recurrence_until = None;
|
||
new_data.recurrence_count = None;
|
||
event_data.set(new_data);
|
||
})
|
||
}}
|
||
/>
|
||
{"Never"}
|
||
</label>
|
||
</div>
|
||
|
||
<div class="end-option">
|
||
<label class="radio-label">
|
||
<input
|
||
type="radio"
|
||
name="recurrence-end"
|
||
value="until"
|
||
checked={data.recurrence_until.is_some()}
|
||
onchange={{
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut new_data = (*event_data).clone();
|
||
new_data.recurrence_count = None;
|
||
new_data.recurrence_until = Some(new_data.start_date);
|
||
event_data.set(new_data);
|
||
})
|
||
}}
|
||
/>
|
||
{"Until"}
|
||
</label>
|
||
<input
|
||
type="date"
|
||
class="form-input"
|
||
value={data.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default()}
|
||
onchange={on_recurrence_until_change.clone()}
|
||
/>
|
||
</div>
|
||
|
||
<div class="end-option">
|
||
<label class="radio-label">
|
||
<input
|
||
type="radio"
|
||
name="recurrence-end"
|
||
value="count"
|
||
checked={data.recurrence_count.is_some()}
|
||
onchange={{
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut new_data = (*event_data).clone();
|
||
new_data.recurrence_until = None;
|
||
new_data.recurrence_count = Some(10); // Default count
|
||
event_data.set(new_data);
|
||
})
|
||
}}
|
||
/>
|
||
{"After"}
|
||
</label>
|
||
<input
|
||
type="number"
|
||
class="form-input count-input"
|
||
value={data.recurrence_count.map(|c| c.to_string()).unwrap_or_default()}
|
||
min="1"
|
||
max="999"
|
||
placeholder="1"
|
||
onchange={on_recurrence_count_change.clone()}
|
||
/>
|
||
<span class="count-unit">{"occurrences"}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
// Monthly-specific options
|
||
if matches!(data.recurrence, RecurrenceType::Monthly) {
|
||
<div class="form-group">
|
||
<label>{"Repeat by"}</label>
|
||
<div class="monthly-options">
|
||
<div class="monthly-option">
|
||
<label class="radio-label">
|
||
<input
|
||
type="radio"
|
||
name="monthly-type"
|
||
checked={data.monthly_by_monthday.is_some()}
|
||
onchange={{
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut new_data = (*event_data).clone();
|
||
new_data.monthly_by_day = None;
|
||
new_data.monthly_by_monthday = Some(new_data.start_date.day() as u8);
|
||
event_data.set(new_data);
|
||
})
|
||
}}
|
||
/>
|
||
{"Day of month:"}
|
||
</label>
|
||
<input
|
||
type="number"
|
||
class="form-input day-input"
|
||
value={data.monthly_by_monthday.map(|d| d.to_string()).unwrap_or_else(|| data.start_date.day().to_string())}
|
||
min="1"
|
||
max="31"
|
||
onchange={on_monthly_by_monthday_change.clone()}
|
||
/>
|
||
</div>
|
||
|
||
<div class="monthly-option">
|
||
<label class="radio-label">
|
||
<input
|
||
type="radio"
|
||
name="monthly-type"
|
||
checked={data.monthly_by_day.is_some()}
|
||
onchange={{
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut new_data = (*event_data).clone();
|
||
new_data.monthly_by_monthday = None;
|
||
new_data.monthly_by_day = Some("1MO".to_string()); // Default to first Monday
|
||
event_data.set(new_data);
|
||
})
|
||
}}
|
||
/>
|
||
{"Day of week:"}
|
||
</label>
|
||
<select
|
||
class="form-input"
|
||
value={data.monthly_by_day.clone().unwrap_or_default()}
|
||
onchange={on_monthly_by_day_change.clone()}
|
||
>
|
||
<option value="none">{"Select..."}</option>
|
||
<option value="1MO">{"First Monday"}</option>
|
||
<option value="1TU">{"First Tuesday"}</option>
|
||
<option value="1WE">{"First Wednesday"}</option>
|
||
<option value="1TH">{"First Thursday"}</option>
|
||
<option value="1FR">{"First Friday"}</option>
|
||
<option value="1SA">{"First Saturday"}</option>
|
||
<option value="1SU">{"First Sunday"}</option>
|
||
<option value="2MO">{"Second Monday"}</option>
|
||
<option value="2TU">{"Second Tuesday"}</option>
|
||
<option value="2WE">{"Second Wednesday"}</option>
|
||
<option value="2TH">{"Second Thursday"}</option>
|
||
<option value="2FR">{"Second Friday"}</option>
|
||
<option value="2SA">{"Second Saturday"}</option>
|
||
<option value="2SU">{"Second Sunday"}</option>
|
||
<option value="3MO">{"Third Monday"}</option>
|
||
<option value="3TU">{"Third Tuesday"}</option>
|
||
<option value="3WE">{"Third Wednesday"}</option>
|
||
<option value="3TH">{"Third Thursday"}</option>
|
||
<option value="3FR">{"Third Friday"}</option>
|
||
<option value="3SA">{"Third Saturday"}</option>
|
||
<option value="3SU">{"Third Sunday"}</option>
|
||
<option value="4MO">{"Fourth Monday"}</option>
|
||
<option value="4TU">{"Fourth Tuesday"}</option>
|
||
<option value="4WE">{"Fourth Wednesday"}</option>
|
||
<option value="4TH">{"Fourth Thursday"}</option>
|
||
<option value="4FR">{"Fourth Friday"}</option>
|
||
<option value="4SA">{"Fourth Saturday"}</option>
|
||
<option value="4SU">{"Fourth Sunday"}</option>
|
||
<option value="-1MO">{"Last Monday"}</option>
|
||
<option value="-1TU">{"Last Tuesday"}</option>
|
||
<option value="-1WE">{"Last Wednesday"}</option>
|
||
<option value="-1TH">{"Last Thursday"}</option>
|
||
<option value="-1FR">{"Last Friday"}</option>
|
||
<option value="-1SA">{"Last Saturday"}</option>
|
||
<option value="-1SU">{"Last Sunday"}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
// Yearly-specific options
|
||
if matches!(data.recurrence, RecurrenceType::Yearly) {
|
||
<div class="form-group">
|
||
<label>{"Repeat in months"}</label>
|
||
<div class="yearly-months">
|
||
{
|
||
["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, month)| {
|
||
let month_checked = data.yearly_by_month.get(i).cloned().unwrap_or(false);
|
||
let on_change = on_yearly_month_change(i);
|
||
html! {
|
||
<label key={i} class="month-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={month_checked}
|
||
onchange={on_change}
|
||
/>
|
||
<span class="month-label">{month}</span>
|
||
</label>
|
||
}
|
||
})
|
||
.collect::<Html>()
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
},
|
||
ModalTab::Advanced => html! {
|
||
<div class="tab-panel">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="event-status">{"Status"}</label>
|
||
<select
|
||
id="event-status"
|
||
class="form-input"
|
||
onchange={on_status_change}
|
||
>
|
||
<option value="confirmed" selected={matches!(data.status, EventStatus::Confirmed)}>{"Confirmed"}</option>
|
||
<option value="tentative" selected={matches!(data.status, EventStatus::Tentative)}>{"Tentative"}</option>
|
||
<option value="cancelled" selected={matches!(data.status, EventStatus::Cancelled)}>{"Cancelled"}</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="event-class">{"Privacy"}</label>
|
||
<select
|
||
id="event-class"
|
||
class="form-input"
|
||
onchange={on_class_change}
|
||
>
|
||
<option value="public" selected={matches!(data.class, EventClass::Public)}>{"Public"}</option>
|
||
<option value="private" selected={matches!(data.class, EventClass::Private)}>{"Private"}</option>
|
||
<option value="confidential" selected={matches!(data.class, EventClass::Confidential)}>{"Confidential"}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="event-priority">{"Priority"}</label>
|
||
<select
|
||
id="event-priority"
|
||
class="form-input"
|
||
onchange={
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |e: Event| {
|
||
if let Some(target) = e.target() {
|
||
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
|
||
let mut data = (*event_data).clone();
|
||
let value = select.value();
|
||
data.priority = if value.is_empty() {
|
||
None
|
||
} else {
|
||
value.parse::<u8>().ok().filter(|&p| p <= 9)
|
||
};
|
||
event_data.set(data);
|
||
}
|
||
}
|
||
})
|
||
}
|
||
>
|
||
<option value="" selected={data.priority.is_none()}>{"Not set"}</option>
|
||
<option value="1" selected={data.priority == Some(1)}>{"High (1)"}</option>
|
||
<option value="2" selected={data.priority == Some(2)}>{"High (2)"}</option>
|
||
<option value="3" selected={data.priority == Some(3)}>{"High (3)"}</option>
|
||
<option value="4" selected={data.priority == Some(4)}>{"High (4)"}</option>
|
||
<option value="5" selected={data.priority == Some(5)}>{"Medium (5)"}</option>
|
||
<option value="6" selected={data.priority == Some(6)}>{"Low (6)"}</option>
|
||
<option value="7" selected={data.priority == Some(7)}>{"Low (7)"}</option>
|
||
<option value="8" selected={data.priority == Some(8)}>{"Low (8)"}</option>
|
||
<option value="9" selected={data.priority == Some(9)}>{"Low (9)"}</option>
|
||
</select>
|
||
<p class="form-help-text">{"RFC 5545 priority scale: 1-4 = High, 5 = Medium, 6-9 = Low"}</p>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="advanced-reminder">{"Advanced Reminder Options"}</label>
|
||
<select
|
||
id="advanced-reminder"
|
||
class="form-input"
|
||
onchange={on_reminder_change}
|
||
>
|
||
<option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"None"}</option>
|
||
<option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes before"}</option>
|
||
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option>
|
||
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option>
|
||
<option value="2hours" selected={matches!(data.reminder, ReminderType::Hours2)}>{"2 hours before"}</option>
|
||
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option>
|
||
<option value="2days" selected={matches!(data.reminder, ReminderType::Days2)}>{"2 days before"}</option>
|
||
<option value="1week" selected={matches!(data.reminder, ReminderType::Week1)}>{"1 week before"}</option>
|
||
</select>
|
||
<p class="form-help-text">{"More reminder options available in the Reminders & Attachments tab"}</p>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="advanced-recurrence">{"Advanced Recurrence"}</label>
|
||
<select
|
||
id="advanced-recurrence"
|
||
class="form-input"
|
||
onchange={on_recurrence_change}
|
||
>
|
||
<option value="none" selected={matches!(data.recurrence, RecurrenceType::None)}>{"Does not repeat"}</option>
|
||
<option value="daily" selected={matches!(data.recurrence, RecurrenceType::Daily)}>{"Daily"}</option>
|
||
<option value="weekly" selected={matches!(data.recurrence, RecurrenceType::Weekly)}>{"Weekly"}</option>
|
||
<option value="monthly" selected={matches!(data.recurrence, RecurrenceType::Monthly)}>{"Monthly"}</option>
|
||
<option value="yearly" selected={matches!(data.recurrence, RecurrenceType::Yearly)}>{"Yearly"}</option>
|
||
</select>
|
||
<p class="form-help-text">{"Custom recurrence rules and exceptions can be configured after event creation"}</p>
|
||
</div>
|
||
|
||
// Show advanced weekday selection when weekly recurrence is selected
|
||
if matches!(data.recurrence, RecurrenceType::Weekly) {
|
||
<div class="form-group">
|
||
<label>{"Repeat on days"}</label>
|
||
<div class="weekday-selection">
|
||
{
|
||
["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, day)| {
|
||
let day_checked = data.recurrence_days.get(i).cloned().unwrap_or(false);
|
||
let on_change = on_weekday_change(i);
|
||
html! {
|
||
<label key={i} class="weekday-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={day_checked}
|
||
onchange={on_change}
|
||
/>
|
||
<span class="weekday-label">{&day[0..3]}</span>
|
||
</label>
|
||
}
|
||
})
|
||
.collect::<Html>()
|
||
}
|
||
</div>
|
||
<p class="form-help-text">{"Select which days of the week to repeat this event"}</p>
|
||
</div>
|
||
}
|
||
|
||
<div class="advanced-info">
|
||
<h5>{"Advanced Features"}</h5>
|
||
<ul>
|
||
<li>{"Time transparency and free/busy status"}</li>
|
||
<li>{"Complex recurrence rules with exceptions"}</li>
|
||
<li>{"Multiple alarm configurations"}</li>
|
||
<li>{"Custom properties and metadata"}</li>
|
||
</ul>
|
||
<p class="form-help-text">{"These features follow RFC 5545 iCalendar standards"}</p>
|
||
</div>
|
||
</div>
|
||
},
|
||
ModalTab::People => html! {
|
||
<div class="tab-panel">
|
||
<div class="form-group">
|
||
<label for="event-organizer">{"Organizer"}</label>
|
||
<input
|
||
type="email"
|
||
id="event-organizer"
|
||
class="form-input"
|
||
value={data.organizer.clone()}
|
||
oninput={on_organizer_input}
|
||
placeholder="organizer@example.com"
|
||
/>
|
||
<p class="form-help-text">{"Email address of the person organizing this event"}</p>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="event-attendees">{"Attendees"}</label>
|
||
<textarea
|
||
id="event-attendees"
|
||
class="form-input"
|
||
value={data.attendees.clone()}
|
||
oninput={on_attendees_input}
|
||
placeholder="attendee1@example.com, attendee2@example.com, attendee3@example.com"
|
||
rows="4"
|
||
></textarea>
|
||
<p class="form-help-text">{"Enter attendee email addresses separated by commas"}</p>
|
||
</div>
|
||
|
||
<div class="people-info">
|
||
<h5>{"Invitation & Response Management"}</h5>
|
||
<ul>
|
||
<li>{"Invitations are sent automatically when the event is saved"}</li>
|
||
<li>{"Attendees can respond with Accept, Decline, or Tentative"}</li>
|
||
<li>{"Response tracking follows RFC 5545 PARTSTAT standards"}</li>
|
||
<li>{"Delegation and role management available after event creation"}</li>
|
||
</ul>
|
||
|
||
<div class="people-validation">
|
||
<h6>{"Email Validation"}</h6>
|
||
<p>{"Email addresses will be validated when you save the event. Invalid emails will be highlighted and must be corrected before proceeding."}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="attendee-roles-preview">
|
||
<h5>{"Advanced Attendee Features"}</h5>
|
||
<div class="role-examples">
|
||
<div class="role-item">
|
||
<strong>{"Chair:"}</strong>
|
||
<span>{"Meeting leader or event host"}</span>
|
||
</div>
|
||
<div class="role-item">
|
||
<strong>{"Required Participant:"}</strong>
|
||
<span>{"Attendance is required"}</span>
|
||
</div>
|
||
<div class="role-item">
|
||
<strong>{"Optional Participant:"}</strong>
|
||
<span>{"Attendance is optional"}</span>
|
||
</div>
|
||
<div class="role-item">
|
||
<strong>{"Non-Participant:"}</strong>
|
||
<span>{"For information only"}</span>
|
||
</div>
|
||
</div>
|
||
<p class="form-help-text">{"Advanced role assignment and RSVP management will be available in future versions"}</p>
|
||
</div>
|
||
</div>
|
||
},
|
||
ModalTab::Categories => html! {
|
||
<div class="tab-panel">
|
||
<div class="form-group">
|
||
<label for="event-categories">{"Categories"}</label>
|
||
<input
|
||
type="text"
|
||
id="event-categories"
|
||
class="form-input"
|
||
value={data.categories.clone()}
|
||
oninput={on_categories_input}
|
||
placeholder="work, meeting, personal, project, urgent"
|
||
/>
|
||
<p class="form-help-text">{"Enter categories separated by commas to help organize and filter your events"}</p>
|
||
</div>
|
||
|
||
<div class="categories-suggestions">
|
||
<h5>{"Common Categories"}</h5>
|
||
<div class="category-tags">
|
||
<button type="button" class="category-tag" onclick={
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut data = (*event_data).clone();
|
||
if data.categories.is_empty() {
|
||
data.categories = "work".to_string();
|
||
} else {
|
||
data.categories = format!("{}, work", data.categories);
|
||
}
|
||
event_data.set(data);
|
||
})
|
||
}>{"work"}</button>
|
||
<button type="button" class="category-tag" onclick={
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut data = (*event_data).clone();
|
||
if data.categories.is_empty() {
|
||
data.categories = "meeting".to_string();
|
||
} else {
|
||
data.categories = format!("{}, meeting", data.categories);
|
||
}
|
||
event_data.set(data);
|
||
})
|
||
}>{"meeting"}</button>
|
||
<button type="button" class="category-tag" onclick={
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut data = (*event_data).clone();
|
||
if data.categories.is_empty() {
|
||
data.categories = "personal".to_string();
|
||
} else {
|
||
data.categories = format!("{}, personal", data.categories);
|
||
}
|
||
event_data.set(data);
|
||
})
|
||
}>{"personal"}</button>
|
||
<button type="button" class="category-tag" onclick={
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut data = (*event_data).clone();
|
||
if data.categories.is_empty() {
|
||
data.categories = "project".to_string();
|
||
} else {
|
||
data.categories = format!("{}, project", data.categories);
|
||
}
|
||
event_data.set(data);
|
||
})
|
||
}>{"project"}</button>
|
||
<button type="button" class="category-tag" onclick={
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut data = (*event_data).clone();
|
||
if data.categories.is_empty() {
|
||
data.categories = "urgent".to_string();
|
||
} else {
|
||
data.categories = format!("{}, urgent", data.categories);
|
||
}
|
||
event_data.set(data);
|
||
})
|
||
}>{"urgent"}</button>
|
||
<button type="button" class="category-tag" onclick={
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut data = (*event_data).clone();
|
||
if data.categories.is_empty() {
|
||
data.categories = "social".to_string();
|
||
} else {
|
||
data.categories = format!("{}, social", data.categories);
|
||
}
|
||
event_data.set(data);
|
||
})
|
||
}>{"social"}</button>
|
||
</div>
|
||
<p class="form-help-text">{"Click to add these common categories to your event"}</p>
|
||
</div>
|
||
|
||
<div class="categories-info">
|
||
<h5>{"Event Organization & Filtering"}</h5>
|
||
<ul>
|
||
<li>{"Categories help organize events in calendar views"}</li>
|
||
<li>{"Filter events by category to focus on specific types"}</li>
|
||
<li>{"Categories are searchable and can be used for reporting"}</li>
|
||
<li>{"Multiple categories per event are fully supported"}</li>
|
||
</ul>
|
||
|
||
<div class="resources-section">
|
||
<h6>{"Resources & Related Events"}</h6>
|
||
<p>{"Advanced resource management features will include:"}</p>
|
||
<div class="resource-features">
|
||
<div class="feature-item">
|
||
<strong>{"Equipment Resources:"}</strong>
|
||
<span>{"Projectors, rooms, vehicles"}</span>
|
||
</div>
|
||
<div class="feature-item">
|
||
<strong>{"Human Resources:"}</strong>
|
||
<span>{"Required staff, specialists"}</span>
|
||
</div>
|
||
<div class="feature-item">
|
||
<strong>{"Related Events:"}</strong>
|
||
<span>{"Link dependencies and sequences"}</span>
|
||
</div>
|
||
<div class="feature-item">
|
||
<strong>{"Comments & Notes:"}</strong>
|
||
<span>{"Internal notes and documentation"}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="quick-actions">
|
||
<h5>{"Quick Actions"}</h5>
|
||
<div class="action-buttons">
|
||
<button type="button" class="action-btn" onclick={
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut data = (*event_data).clone();
|
||
data.categories = String::new();
|
||
event_data.set(data);
|
||
})
|
||
}>{"Clear Categories"}</button>
|
||
</div>
|
||
<p class="form-help-text">{"Remove all categories from this event"}</p>
|
||
</div>
|
||
</div>
|
||
},
|
||
ModalTab::Location => html! {
|
||
<div class="tab-panel">
|
||
<div class="form-group">
|
||
<label for="event-location-detailed">{"Event Location"}</label>
|
||
<input
|
||
type="text"
|
||
id="event-location-detailed"
|
||
class="form-input"
|
||
value={data.location.clone()}
|
||
oninput={on_location_input}
|
||
placeholder="Conference Room A, 123 Main St, City, State 12345"
|
||
/>
|
||
<p class="form-help-text">{"Enter the full address or location description for the event"}</p>
|
||
</div>
|
||
|
||
<div class="location-suggestions">
|
||
<h5>{"Common Locations"}</h5>
|
||
<div class="location-tags">
|
||
<button type="button" class="location-tag" onclick={
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut data = (*event_data).clone();
|
||
data.location = "Conference Room".to_string();
|
||
event_data.set(data);
|
||
})
|
||
}>{"Conference Room"}</button>
|
||
<button type="button" class="location-tag" onclick={
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut data = (*event_data).clone();
|
||
data.location = "Online Meeting".to_string();
|
||
event_data.set(data);
|
||
})
|
||
}>{"Online Meeting"}</button>
|
||
<button type="button" class="location-tag" onclick={
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut data = (*event_data).clone();
|
||
data.location = "Main Office".to_string();
|
||
event_data.set(data);
|
||
})
|
||
}>{"Main Office"}</button>
|
||
<button type="button" class="location-tag" onclick={
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut data = (*event_data).clone();
|
||
data.location = "Client Site".to_string();
|
||
event_data.set(data);
|
||
})
|
||
}>{"Client Site"}</button>
|
||
<button type="button" class="location-tag" onclick={
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut data = (*event_data).clone();
|
||
data.location = "Home Office".to_string();
|
||
event_data.set(data);
|
||
})
|
||
}>{"Home Office"}</button>
|
||
</div>
|
||
<p class="form-help-text">{"Click to quickly set common location types"}</p>
|
||
</div>
|
||
|
||
<div class="location-info">
|
||
<h5>{"Location Features & Integration"}</h5>
|
||
<ul>
|
||
<li>{"Location information is included in calendar invitations"}</li>
|
||
<li>{"Supports both physical addresses and virtual meeting links"}</li>
|
||
<li>{"Compatible with mapping and navigation applications"}</li>
|
||
<li>{"Room booking integration available for enterprise setups"}</li>
|
||
</ul>
|
||
|
||
<div class="geo-section">
|
||
<h6>{"Geographic Coordinates (Advanced)"}</h6>
|
||
<p>{"Future versions will support:"}</p>
|
||
<div class="geo-features">
|
||
<div class="geo-item">
|
||
<strong>{"GPS Coordinates:"}</strong>
|
||
<span>{"Precise latitude/longitude positioning"}</span>
|
||
</div>
|
||
<div class="geo-item">
|
||
<strong>{"Map Integration:"}</strong>
|
||
<span>{"Embedded maps in event details"}</span>
|
||
</div>
|
||
<div class="geo-item">
|
||
<strong>{"Travel Time:"}</strong>
|
||
<span>{"Automatic travel time calculation"}</span>
|
||
</div>
|
||
<div class="geo-item">
|
||
<strong>{"Location History:"}</strong>
|
||
<span>{"Smart suggestions based on past events"}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="location-types">
|
||
<h5>{"Location Type Examples"}</h5>
|
||
<div class="type-examples">
|
||
<div class="type-category">
|
||
<strong>{"Physical Locations:"}</strong>
|
||
<ul>
|
||
<li>{"123 Business Ave, Suite 400, City, State 12345"}</li>
|
||
<li>{"Conference Room B, 2nd Floor, Main Building"}</li>
|
||
<li>{"Central Park, 5th Avenue entrance"}</li>
|
||
</ul>
|
||
</div>
|
||
<div class="type-category">
|
||
<strong>{"Virtual Locations:"}</strong>
|
||
<ul>
|
||
<li>{"Zoom Meeting ID: 123-456-7890"}</li>
|
||
<li>{"Microsoft Teams: team.microsoft.com/meeting/..."}</li>
|
||
<li>{"Google Meet: meet.google.com/abc-defg-hij"}</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<p class="form-help-text">{"Both physical addresses and virtual meeting information are fully supported"}</p>
|
||
</div>
|
||
|
||
<div class="quick-actions">
|
||
<h5>{"Quick Actions"}</h5>
|
||
<div class="action-buttons">
|
||
<button type="button" class="action-btn secondary" onclick={
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut data = (*event_data).clone();
|
||
data.location = String::new();
|
||
event_data.set(data);
|
||
})
|
||
}>{"Clear Location"}</button>
|
||
</div>
|
||
<p class="form-help-text">{"Remove location information from this event"}</p>
|
||
</div>
|
||
</div>
|
||
},
|
||
ModalTab::Reminders => html! {
|
||
<div class="tab-panel">
|
||
<div class="form-group">
|
||
<label for="event-reminder-main">{"Primary Reminder"}</label>
|
||
<select
|
||
id="event-reminder-main"
|
||
class="form-input"
|
||
onchange={on_reminder_change}
|
||
>
|
||
<option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"No reminder"}</option>
|
||
<option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes before"}</option>
|
||
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option>
|
||
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option>
|
||
<option value="2hours" selected={matches!(data.reminder, ReminderType::Hours2)}>{"2 hours before"}</option>
|
||
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option>
|
||
<option value="2days" selected={matches!(data.reminder, ReminderType::Days2)}>{"2 days before"}</option>
|
||
<option value="1week" selected={matches!(data.reminder, ReminderType::Week1)}>{"1 week before"}</option>
|
||
</select>
|
||
<p class="form-help-text">{"Choose when you'd like to be reminded about this event"}</p>
|
||
</div>
|
||
|
||
<div class="reminder-types">
|
||
<h5>{"Reminder & Alarm Types"}</h5>
|
||
<div class="alarm-examples">
|
||
<div class="alarm-type">
|
||
<strong>{"Display Alarm"}</strong>
|
||
<p>{"Pop-up notification on your device"}</p>
|
||
</div>
|
||
<div class="alarm-type">
|
||
<strong>{"Email Reminder"}</strong>
|
||
<p>{"Email notification sent to your address"}</p>
|
||
</div>
|
||
<div class="alarm-type">
|
||
<strong>{"Audio Alert"}</strong>
|
||
<p>{"Sound notification with custom audio"}</p>
|
||
</div>
|
||
<div class="alarm-type">
|
||
<strong>{"SMS/Text"}</strong>
|
||
<p>{"Text message reminder (enterprise feature)"}</p>
|
||
</div>
|
||
</div>
|
||
<p class="form-help-text">{"Multiple alarm types follow RFC 5545 VALARM standards"}</p>
|
||
</div>
|
||
|
||
<div class="reminder-info">
|
||
<h5>{"Advanced Reminder Features"}</h5>
|
||
<ul>
|
||
<li>{"Multiple reminders per event with different timing"}</li>
|
||
<li>{"Custom reminder messages and descriptions"}</li>
|
||
<li>{"Recurring reminders for recurring events"}</li>
|
||
<li>{"Snooze and dismiss functionality"}</li>
|
||
<li>{"Integration with system notifications"}</li>
|
||
</ul>
|
||
|
||
<div class="attachments-section">
|
||
<h6>{"File Attachments & Documents"}</h6>
|
||
<p>{"Future attachment features will include:"}</p>
|
||
<div class="attachment-features">
|
||
<div class="attachment-type">
|
||
<strong>{"File Uploads:"}</strong>
|
||
<span>{"Documents, images, presentations"}</span>
|
||
</div>
|
||
<div class="attachment-type">
|
||
<strong>{"URL Links:"}</strong>
|
||
<span>{"Web resources and reference materials"}</span>
|
||
</div>
|
||
<div class="attachment-type">
|
||
<strong>{"Cloud Storage:"}</strong>
|
||
<span>{"Google Drive, Dropbox, OneDrive integration"}</span>
|
||
</div>
|
||
<div class="attachment-type">
|
||
<strong>{"Meeting Notes:"}</strong>
|
||
<span>{"Collaborative note-taking and agenda items"}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="reminder-patterns">
|
||
<h5>{"Common Reminder Patterns"}</h5>
|
||
<div class="pattern-examples">
|
||
<div class="pattern-item">
|
||
<strong>{"Meetings:"}</strong>
|
||
<span>{"15 minutes before (preparation time)"}</span>
|
||
</div>
|
||
<div class="pattern-item">
|
||
<strong>{"Travel Events:"}</strong>
|
||
<span>{"2 hours before (traffic and check-in)"}</span>
|
||
</div>
|
||
<div class="pattern-item">
|
||
<strong>{"Personal Events:"}</strong>
|
||
<span>{"1 day before (preparation and gifts)"}</span>
|
||
</div>
|
||
<div class="pattern-item">
|
||
<strong>{"Deadlines:"}</strong>
|
||
<span>{"1 week before (completion buffer)"}</span>
|
||
</div>
|
||
</div>
|
||
<p class="form-help-text">{"Suggested timing based on common event types"}</p>
|
||
</div>
|
||
|
||
<div class="quick-actions">
|
||
<h5>{"Quick Actions"}</h5>
|
||
<div class="action-buttons">
|
||
<button type="button" class="action-btn tertiary" onclick={
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut data = (*event_data).clone();
|
||
data.reminder = ReminderType::Minutes15;
|
||
event_data.set(data);
|
||
})
|
||
}>{"Set 15min Reminder"}</button>
|
||
<button type="button" class="action-btn secondary" onclick={
|
||
let event_data = event_data.clone();
|
||
Callback::from(move |_| {
|
||
let mut data = (*event_data).clone();
|
||
data.reminder = ReminderType::None;
|
||
event_data.set(data);
|
||
})
|
||
}>{"No Reminder"}</button>
|
||
</div>
|
||
<p class="form-help-text">{"Quickly set or clear event reminders"}</p>
|
||
</div>
|
||
|
||
<div class="completion-status">
|
||
<h5>{"Modal Complete!"}</h5>
|
||
<p>{"You've reached the final tab of the comprehensive event creation interface. This modal now provides access to all major VEvent properties following RFC 5545 standards."}</p>
|
||
|
||
<div class="feature-summary">
|
||
<div class="summary-row">
|
||
<span class="tab-name">{"Basic Details"}</span>
|
||
<span class="tab-desc">{"Title, calendar, dates, location, basic recurrence"}</span>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span class="tab-name">{"Advanced"}</span>
|
||
<span class="tab-desc">{"Status, priority, classification, advanced options"}</span>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span class="tab-name">{"People"}</span>
|
||
<span class="tab-desc">{"Organizer, attendees, invitation management"}</span>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span class="tab-name">{"Categories"}</span>
|
||
<span class="tab-desc">{"Event tagging and organizational features"}</span>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span class="tab-name">{"Location"}</span>
|
||
<span class="tab-desc">{"Physical and virtual location management"}</span>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span class="tab-name">{"Reminders"}</span>
|
||
<span class="tab-desc">{"Alarm configuration and future attachments"}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
},
|
||
}
|
||
}
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" onclick={on_cancel_click}>
|
||
{"Cancel"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn btn-primary"
|
||
onclick={on_submit_click}
|
||
disabled={data.title.trim().is_empty()}
|
||
>
|
||
{if props.event_to_edit.is_some() { "Update Event" } else { "Create Event" }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
}
|