Implement shared RFC 5545 VEvent library with workspace restructuring

- Created calendar-models/ shared library with RFC 5545-compliant VEvent structures
- Migrated backend to use shared VEvent with proper field mappings (dtstart/dtend, rrule, exdate, etc.)
- Converted CalDAV client to parse into VEvent structures with structured types
- Updated all CRUD handlers to use VEvent with CalendarUser, Attendee, VAlarm types
- Restructured project as Cargo workspace with frontend/, backend/, calendar-models/
- Updated Trunk configuration for new directory structure
- Fixed all compilation errors and field references throughout codebase
- Updated documentation and build instructions for workspace structure

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-30 11:45:58 -04:00
parent 6887e0b389
commit 15f2d0c6d9
43 changed files with 1962 additions and 945 deletions

View File

@@ -0,0 +1,857 @@
use yew::prelude::*;
use web_sys::{HtmlInputElement, HtmlTextAreaElement, HtmlSelectElement};
use chrono::{NaiveDate, NaiveTime, Local, TimeZone, Utc};
use crate::services::calendar_service::CalendarInfo;
use crate::models::ical::VEvent;
#[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>,
}
#[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
}
}
impl RecurrenceType {
pub fn from_rrule(rrule: Option<&str>) -> Self {
match rrule {
Some(rule) if rule.contains("FREQ=DAILY") => RecurrenceType::Daily,
Some(rule) if rule.contains("FREQ=WEEKLY") => RecurrenceType::Weekly,
Some(rule) if rule.contains("FREQ=MONTHLY") => RecurrenceType::Monthly,
Some(rule) if rule.contains("FREQ=YEARLY") => RecurrenceType::Yearly,
_ => RecurrenceType::None,
}
}
}
#[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
}
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,
}
}
}
impl EventCreationData {
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(),
},
match self.recurrence {
RecurrenceType::None => "".to_string(),
RecurrenceType::Daily => "DAILY".to_string(),
RecurrenceType::Weekly => "WEEKLY".to_string(),
RecurrenceType::Monthly => "MONTHLY".to_string(),
RecurrenceType::Yearly => "YEARLY".to_string(),
},
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
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: RecurrenceType::from_rrule(event.rrule.as_deref()),
recurrence_days: vec![false; 7], // TODO: Parse from RRULE
selected_calendar: event.calendar_path.clone(),
}
}
}
#[function_component(CreateEventModal)]
pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
let event_data = use_state(|| EventCreationData::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), {
let event_data = event_data.clone();
move |(selected_date, event_to_edit, is_open, available_calendars, initial_start_time, initial_end_time)| {
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());
}
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(());
}
})
};
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();
data.title = input.value();
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();
data.selected_calendar = if value.is_empty() { None } else { Some(value) };
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 days when changing recurrence type
data.recurrence_days = vec![false; 7];
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(());
})
};
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">
<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="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>
<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 (0-9, optional)"}</label>
<input
type="number"
id="event-priority"
class="form-input"
value={data.priority.map(|p| p.to_string()).unwrap_or_default()}
oninput={on_priority_input}
placeholder="0-9 priority level"
min="0"
max="9"
/>
</div>
<div class="form-group">
<label for="event-organizer">{"Organizer Email"}</label>
<input
type="email"
id="event-organizer"
class="form-input"
value={data.organizer.clone()}
oninput={on_organizer_input}
placeholder="organizer@example.com"
/>
</div>
<div class="form-group">
<label for="event-attendees">{"Attendees (comma-separated emails)"}</label>
<textarea
id="event-attendees"
class="form-input"
value={data.attendees.clone()}
oninput={on_attendees_input}
placeholder="attendee1@example.com, attendee2@example.com"
rows="2"
></textarea>
</div>
<div class="form-group">
<label for="event-categories">{"Categories (comma-separated)"}</label>
<input
type="text"
id="event-categories"
class="form-input"
value={data.categories.clone()}
oninput={on_categories_input}
placeholder="work, meeting, personal"
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="event-reminder">{"Reminder"}</label>
<select
id="event-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"}</option>
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes"}</option>
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour"}</option>
<option value="2hours" selected={matches!(data.reminder, ReminderType::Hours2)}>{"2 hours"}</option>
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day"}</option>
<option value="2days" selected={matches!(data.reminder, ReminderType::Days2)}>{"2 days"}</option>
<option value="1week" selected={matches!(data.reminder, ReminderType::Week1)}>{"1 week"}</option>
</select>
</div>
<div class="form-group">
<label for="event-recurrence">{"Recurrence"}</label>
<select
id="event-recurrence"
class="form-input"
onchange={on_recurrence_change}
>
<option value="none" selected={matches!(data.recurrence, RecurrenceType::None)}>{"None"}</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>
// 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>
}
</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>
}
}