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:
316
frontend/src/components/calendar.rs
Normal file
316
frontend/src/components/calendar.rs
Normal file
@@ -0,0 +1,316 @@
|
||||
use yew::prelude::*;
|
||||
use chrono::{Datelike, Local, NaiveDate, Duration};
|
||||
use std::collections::HashMap;
|
||||
use web_sys::MouseEvent;
|
||||
use crate::services::calendar_service::UserInfo;
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::components::{EventModal, ViewMode, CalendarHeader, MonthView, WeekView, CreateEventModal, EventCreationData};
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CalendarProps {
|
||||
#[prop_or_default]
|
||||
pub events: HashMap<NaiveDate, Vec<VEvent>>,
|
||||
pub on_event_click: Callback<VEvent>,
|
||||
#[prop_or_default]
|
||||
pub refreshing_event_uid: Option<String>,
|
||||
#[prop_or_default]
|
||||
pub user_info: Option<UserInfo>,
|
||||
#[prop_or_default]
|
||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||
#[prop_or_default]
|
||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
||||
#[prop_or_default]
|
||||
pub view: ViewMode,
|
||||
#[prop_or_default]
|
||||
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
||||
#[prop_or_default]
|
||||
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)>>,
|
||||
#[prop_or_default]
|
||||
pub context_menus_open: bool,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
let today = Local::now().date_naive();
|
||||
// Track the currently selected date (the actual day the user has selected)
|
||||
let selected_date = use_state(|| {
|
||||
// Try to load saved selected date from localStorage
|
||||
if let Ok(saved_date_str) = LocalStorage::get::<String>("calendar_selected_date") {
|
||||
if let Ok(saved_date) = NaiveDate::parse_from_str(&saved_date_str, "%Y-%m-%d") {
|
||||
saved_date
|
||||
} else {
|
||||
today
|
||||
}
|
||||
} else {
|
||||
// Check for old key for backward compatibility
|
||||
if let Ok(saved_date_str) = LocalStorage::get::<String>("calendar_current_month") {
|
||||
if let Ok(saved_date) = NaiveDate::parse_from_str(&saved_date_str, "%Y-%m-%d") {
|
||||
saved_date
|
||||
} else {
|
||||
today
|
||||
}
|
||||
} else {
|
||||
today
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Track the display date (what to show in the view)
|
||||
let current_date = use_state(|| {
|
||||
match props.view {
|
||||
ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date),
|
||||
ViewMode::Week => *selected_date,
|
||||
}
|
||||
});
|
||||
let selected_event = use_state(|| None::<VEvent>);
|
||||
|
||||
// State for create event modal
|
||||
let show_create_modal = use_state(|| false);
|
||||
let create_event_data = use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>);
|
||||
|
||||
// State for time increment snapping (15 or 30 minutes)
|
||||
let time_increment = use_state(|| {
|
||||
// Try to load saved time increment from localStorage
|
||||
if let Ok(saved_increment) = LocalStorage::get::<u32>("calendar_time_increment") {
|
||||
if saved_increment == 15 || saved_increment == 30 {
|
||||
saved_increment
|
||||
} else {
|
||||
15
|
||||
}
|
||||
} else {
|
||||
15
|
||||
}
|
||||
});
|
||||
|
||||
// Handle view mode changes - adjust current_date format when switching between month/week
|
||||
{
|
||||
let current_date = current_date.clone();
|
||||
let selected_date = selected_date.clone();
|
||||
let view = props.view.clone();
|
||||
use_effect_with(view, move |view_mode| {
|
||||
let selected = *selected_date;
|
||||
let new_display_date = match view_mode {
|
||||
ViewMode::Month => selected.with_day(1).unwrap_or(selected),
|
||||
ViewMode::Week => selected, // Show the week containing the selected date
|
||||
};
|
||||
current_date.set(new_display_date);
|
||||
|| {}
|
||||
});
|
||||
}
|
||||
|
||||
let on_prev = {
|
||||
let current_date = current_date.clone();
|
||||
let selected_date = selected_date.clone();
|
||||
let view = props.view.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
let (new_selected, new_display) = match view {
|
||||
ViewMode::Month => {
|
||||
// Go to previous month, select the 1st day
|
||||
let prev_month = *current_date - Duration::days(1);
|
||||
let first_of_prev = prev_month.with_day(1).unwrap();
|
||||
(first_of_prev, first_of_prev)
|
||||
},
|
||||
ViewMode::Week => {
|
||||
// Go to previous week
|
||||
let prev_week = *selected_date - Duration::weeks(1);
|
||||
(prev_week, prev_week)
|
||||
},
|
||||
};
|
||||
selected_date.set(new_selected);
|
||||
current_date.set(new_display);
|
||||
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
|
||||
})
|
||||
};
|
||||
|
||||
let on_next = {
|
||||
let current_date = current_date.clone();
|
||||
let selected_date = selected_date.clone();
|
||||
let view = props.view.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
let (new_selected, new_display) = match view {
|
||||
ViewMode::Month => {
|
||||
// Go to next month, select the 1st day
|
||||
let next_month = if current_date.month() == 12 {
|
||||
NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap()
|
||||
} else {
|
||||
NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1).unwrap()
|
||||
};
|
||||
(next_month, next_month)
|
||||
},
|
||||
ViewMode::Week => {
|
||||
// Go to next week
|
||||
let next_week = *selected_date + Duration::weeks(1);
|
||||
(next_week, next_week)
|
||||
},
|
||||
};
|
||||
selected_date.set(new_selected);
|
||||
current_date.set(new_display);
|
||||
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
|
||||
})
|
||||
};
|
||||
|
||||
let on_today = {
|
||||
let current_date = current_date.clone();
|
||||
let selected_date = selected_date.clone();
|
||||
let view = props.view.clone();
|
||||
Callback::from(move |_| {
|
||||
let today = Local::now().date_naive();
|
||||
let (new_selected, new_display) = match view {
|
||||
ViewMode::Month => {
|
||||
let first_of_today = today.with_day(1).unwrap();
|
||||
(today, first_of_today) // Select today, but display the month
|
||||
},
|
||||
ViewMode::Week => (today, today), // Select and display today
|
||||
};
|
||||
selected_date.set(new_selected);
|
||||
current_date.set(new_display);
|
||||
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
|
||||
})
|
||||
};
|
||||
|
||||
// Handle time increment toggle
|
||||
let on_time_increment_toggle = {
|
||||
let time_increment = time_increment.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
let current = *time_increment;
|
||||
let next = if current == 15 { 30 } else { 15 };
|
||||
time_increment.set(next);
|
||||
let _ = LocalStorage::set("calendar_time_increment", next);
|
||||
})
|
||||
};
|
||||
|
||||
// Handle drag-to-create event
|
||||
let on_create_event = {
|
||||
let show_create_modal = show_create_modal.clone();
|
||||
let create_event_data = create_event_data.clone();
|
||||
Callback::from(move |(_date, start_datetime, end_datetime): (NaiveDate, chrono::NaiveDateTime, chrono::NaiveDateTime)| {
|
||||
// For drag-to-create, we don't need the temporary event approach
|
||||
// Instead, just pass the local times directly via initial_time props
|
||||
create_event_data.set(Some((start_datetime.date(), start_datetime.time(), end_datetime.time())));
|
||||
show_create_modal.set(true);
|
||||
})
|
||||
};
|
||||
|
||||
// Handle drag-to-move event
|
||||
let on_event_update = {
|
||||
let on_event_update_request = props.on_event_update_request.clone();
|
||||
Callback::from(move |(event, new_start, new_end, preserve_rrule, until_date): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)| {
|
||||
if let Some(callback) = &on_event_update_request {
|
||||
callback.emit((event, new_start, new_end, preserve_rrule, until_date));
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class={classes!("calendar", match props.view { ViewMode::Week => Some("week-view"), _ => None })}>
|
||||
<CalendarHeader
|
||||
current_date={*current_date}
|
||||
view_mode={props.view.clone()}
|
||||
on_prev={on_prev}
|
||||
on_next={on_next}
|
||||
on_today={on_today}
|
||||
time_increment={Some(*time_increment)}
|
||||
on_time_increment_toggle={Some(on_time_increment_toggle)}
|
||||
/>
|
||||
|
||||
{
|
||||
match props.view {
|
||||
ViewMode::Month => {
|
||||
let on_day_select = {
|
||||
let selected_date = selected_date.clone();
|
||||
Callback::from(move |date: NaiveDate| {
|
||||
selected_date.set(date);
|
||||
let _ = LocalStorage::set("calendar_selected_date", date.format("%Y-%m-%d").to_string());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<MonthView
|
||||
current_month={*current_date}
|
||||
today={today}
|
||||
events={props.events.clone()}
|
||||
on_event_click={props.on_event_click.clone()}
|
||||
refreshing_event_uid={props.refreshing_event_uid.clone()}
|
||||
user_info={props.user_info.clone()}
|
||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||
selected_date={Some(*selected_date)}
|
||||
on_day_select={Some(on_day_select)}
|
||||
/>
|
||||
}
|
||||
},
|
||||
ViewMode::Week => html! {
|
||||
<WeekView
|
||||
current_date={*current_date}
|
||||
today={today}
|
||||
events={props.events.clone()}
|
||||
on_event_click={props.on_event_click.clone()}
|
||||
refreshing_event_uid={props.refreshing_event_uid.clone()}
|
||||
user_info={props.user_info.clone()}
|
||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||
on_create_event={Some(on_create_event)}
|
||||
on_create_event_request={props.on_create_event_request.clone()}
|
||||
on_event_update={Some(on_event_update)}
|
||||
context_menus_open={props.context_menus_open}
|
||||
time_increment={*time_increment}
|
||||
/>
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Event details modal
|
||||
<EventModal
|
||||
event={(*selected_event).clone()}
|
||||
on_close={{
|
||||
let selected_event_clone = selected_event.clone();
|
||||
Callback::from(move |_| {
|
||||
selected_event_clone.set(None);
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
// Create event modal
|
||||
<CreateEventModal
|
||||
is_open={*show_create_modal}
|
||||
selected_date={create_event_data.as_ref().map(|(date, _, _)| *date)}
|
||||
event_to_edit={None}
|
||||
available_calendars={props.user_info.as_ref().map(|info| info.calendars.clone()).unwrap_or_default()}
|
||||
initial_start_time={create_event_data.as_ref().map(|(_, start_time, _)| *start_time)}
|
||||
initial_end_time={create_event_data.as_ref().map(|(_, _, end_time)| *end_time)}
|
||||
on_close={{
|
||||
let show_create_modal = show_create_modal.clone();
|
||||
let create_event_data = create_event_data.clone();
|
||||
Callback::from(move |_| {
|
||||
show_create_modal.set(false);
|
||||
create_event_data.set(None);
|
||||
})
|
||||
}}
|
||||
on_create={{
|
||||
let show_create_modal = show_create_modal.clone();
|
||||
let create_event_data = create_event_data.clone();
|
||||
let on_create_event_request = props.on_create_event_request.clone();
|
||||
Callback::from(move |event_data: EventCreationData| {
|
||||
show_create_modal.set(false);
|
||||
create_event_data.set(None);
|
||||
|
||||
// Emit the create event request to parent
|
||||
if let Some(callback) = &on_create_event_request {
|
||||
callback.emit(event_data);
|
||||
}
|
||||
})
|
||||
}}
|
||||
on_update={{
|
||||
let show_create_modal = show_create_modal.clone();
|
||||
let create_event_data = create_event_data.clone();
|
||||
Callback::from(move |(_original_event, _updated_data): (VEvent, EventCreationData)| {
|
||||
show_create_modal.set(false);
|
||||
create_event_data.set(None);
|
||||
// TODO: Handle actual event update
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
47
frontend/src/components/calendar_context_menu.rs
Normal file
47
frontend/src/components/calendar_context_menu.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::MouseEvent;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CalendarContextMenuProps {
|
||||
pub is_open: bool,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub on_close: Callback<()>,
|
||||
pub on_create_event: Callback<MouseEvent>,
|
||||
}
|
||||
|
||||
#[function_component(CalendarContextMenu)]
|
||||
pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
|
||||
let menu_ref = use_node_ref();
|
||||
|
||||
if !props.is_open {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
let style = format!(
|
||||
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
|
||||
props.x, props.y
|
||||
);
|
||||
|
||||
let on_create_event_click = {
|
||||
let on_create_event = props.on_create_event.clone();
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
on_create_event.emit(e);
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div
|
||||
ref={menu_ref}
|
||||
class="context-menu"
|
||||
style={style}
|
||||
>
|
||||
<div class="context-menu-item context-menu-create" onclick={on_create_event_click}>
|
||||
<span class="context-menu-icon">{"+"}</span>
|
||||
{"Create Event"}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
64
frontend/src/components/calendar_header.rs
Normal file
64
frontend/src/components/calendar_header.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use yew::prelude::*;
|
||||
use chrono::{NaiveDate, Datelike};
|
||||
use crate::components::ViewMode;
|
||||
use web_sys::MouseEvent;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CalendarHeaderProps {
|
||||
pub current_date: NaiveDate,
|
||||
pub view_mode: ViewMode,
|
||||
pub on_prev: Callback<MouseEvent>,
|
||||
pub on_next: Callback<MouseEvent>,
|
||||
pub on_today: Callback<MouseEvent>,
|
||||
#[prop_or_default]
|
||||
pub time_increment: Option<u32>,
|
||||
#[prop_or_default]
|
||||
pub on_time_increment_toggle: Option<Callback<MouseEvent>>,
|
||||
}
|
||||
|
||||
#[function_component(CalendarHeader)]
|
||||
pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
||||
let title = format!("{} {}", get_month_name(props.current_date.month()), props.current_date.year());
|
||||
|
||||
html! {
|
||||
<div class="calendar-header">
|
||||
<div class="header-left">
|
||||
<button class="nav-button" onclick={props.on_prev.clone()}>{"‹"}</button>
|
||||
{
|
||||
if let (Some(increment), Some(callback)) = (props.time_increment, &props.on_time_increment_toggle) {
|
||||
html! {
|
||||
<button class="time-increment-button" onclick={callback.clone()}>
|
||||
{format!("{}", increment)}
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<h2 class="month-year">{title}</h2>
|
||||
<div class="header-right">
|
||||
<button class="today-button" onclick={props.on_today.clone()}>{"Today"}</button>
|
||||
<button class="nav-button" onclick={props.on_next.clone()}>{"›"}</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn get_month_name(month: u32) -> &'static str {
|
||||
match month {
|
||||
1 => "January",
|
||||
2 => "February",
|
||||
3 => "March",
|
||||
4 => "April",
|
||||
5 => "May",
|
||||
6 => "June",
|
||||
7 => "July",
|
||||
8 => "August",
|
||||
9 => "September",
|
||||
10 => "October",
|
||||
11 => "November",
|
||||
12 => "December",
|
||||
_ => "Invalid"
|
||||
}
|
||||
}
|
||||
75
frontend/src/components/calendar_list_item.rs
Normal file
75
frontend/src/components/calendar_list_item.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::MouseEvent;
|
||||
use crate::services::calendar_service::CalendarInfo;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CalendarListItemProps {
|
||||
pub calendar: CalendarInfo,
|
||||
pub color_picker_open: bool,
|
||||
pub on_color_change: Callback<(String, String)>, // (calendar_path, color)
|
||||
pub on_color_picker_toggle: Callback<String>, // calendar_path
|
||||
pub available_colors: Vec<String>,
|
||||
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
|
||||
}
|
||||
|
||||
#[function_component(CalendarListItem)]
|
||||
pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
|
||||
let on_color_click = {
|
||||
let cal_path = props.calendar.path.clone();
|
||||
let on_color_picker_toggle = props.on_color_picker_toggle.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.stop_propagation();
|
||||
on_color_picker_toggle.emit(cal_path.clone());
|
||||
})
|
||||
};
|
||||
|
||||
let on_context_menu = {
|
||||
let cal_path = props.calendar.path.clone();
|
||||
let on_context_menu = props.on_context_menu.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
on_context_menu.emit((e, cal_path.clone()));
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}>
|
||||
<span class="calendar-color"
|
||||
style={format!("background-color: {}", props.calendar.color)}
|
||||
onclick={on_color_click}>
|
||||
{
|
||||
if props.color_picker_open {
|
||||
html! {
|
||||
<div class="color-picker">
|
||||
{
|
||||
props.available_colors.iter().map(|color| {
|
||||
let color_str = color.clone();
|
||||
let cal_path = props.calendar.path.clone();
|
||||
let on_color_change = props.on_color_change.clone();
|
||||
|
||||
let on_color_select = Callback::from(move |_: MouseEvent| {
|
||||
on_color_change.emit((cal_path.clone(), color_str.clone()));
|
||||
});
|
||||
|
||||
let is_selected = props.calendar.color == *color;
|
||||
let class_name = if is_selected { "color-option selected" } else { "color-option" };
|
||||
|
||||
html! {
|
||||
<div class={class_name}
|
||||
style={format!("background-color: {}", color)}
|
||||
onclick={on_color_select}>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</span>
|
||||
<span class="calendar-name">{&props.calendar.display_name}</span>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
49
frontend/src/components/context_menu.rs
Normal file
49
frontend/src/components/context_menu.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::MouseEvent;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ContextMenuProps {
|
||||
pub is_open: bool,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub on_delete: Callback<MouseEvent>,
|
||||
pub on_close: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(ContextMenu)]
|
||||
pub fn context_menu(props: &ContextMenuProps) -> Html {
|
||||
let menu_ref = use_node_ref();
|
||||
|
||||
// Close menu when clicking outside (handled by parent component)
|
||||
|
||||
if !props.is_open {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
let style = format!(
|
||||
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
|
||||
props.x, props.y
|
||||
);
|
||||
|
||||
let on_delete_click = {
|
||||
let on_delete = props.on_delete.clone();
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
on_delete.emit(e);
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div
|
||||
ref={menu_ref}
|
||||
class="context-menu"
|
||||
style={style}
|
||||
>
|
||||
<div class="context-menu-item context-menu-delete" onclick={on_delete_click}>
|
||||
<span class="context-menu-icon">{"🗑️"}</span>
|
||||
{"Delete Calendar"}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
196
frontend/src/components/create_calendar_modal.rs
Normal file
196
frontend/src/components/create_calendar_modal.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CreateCalendarModalProps {
|
||||
pub is_open: bool,
|
||||
pub on_close: Callback<()>,
|
||||
pub on_create: Callback<(String, Option<String>, Option<String>)>, // name, description, color
|
||||
pub available_colors: Vec<String>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
||||
let calendar_name = use_state(|| String::new());
|
||||
let description = use_state(|| String::new());
|
||||
let selected_color = use_state(|| None::<String>);
|
||||
let error_message = use_state(|| None::<String>);
|
||||
let is_creating = use_state(|| false);
|
||||
|
||||
let on_name_change = {
|
||||
let calendar_name = calendar_name.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
calendar_name.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_description_change = {
|
||||
let description = description.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: web_sys::HtmlTextAreaElement = e.target_unchecked_into();
|
||||
description.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_submit = {
|
||||
let calendar_name = calendar_name.clone();
|
||||
let description = description.clone();
|
||||
let selected_color = selected_color.clone();
|
||||
let error_message = error_message.clone();
|
||||
let is_creating = is_creating.clone();
|
||||
let on_create = props.on_create.clone();
|
||||
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
let name = (*calendar_name).trim();
|
||||
if name.is_empty() {
|
||||
error_message.set(Some("Calendar name is required".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
if name.len() > 100 {
|
||||
error_message.set(Some("Calendar name too long (max 100 characters)".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
error_message.set(None);
|
||||
is_creating.set(true);
|
||||
|
||||
let desc = if (*description).trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((*description).clone())
|
||||
};
|
||||
|
||||
on_create.emit((name.to_string(), desc, (*selected_color).clone()));
|
||||
})
|
||||
};
|
||||
|
||||
let on_backdrop_click = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
// Only close if clicking the backdrop, not the modal content
|
||||
if e.target() == e.current_target() {
|
||||
on_close.emit(());
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if !props.is_open {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
html! {
|
||||
<div class="modal-backdrop" onclick={on_backdrop_click}>
|
||||
<div class="create-calendar-modal">
|
||||
<div class="modal-header">
|
||||
<h2>{"Create New Calendar"}</h2>
|
||||
<button class="close-button" onclick={props.on_close.reform(|_| ())}>
|
||||
{"×"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="modal-body" onsubmit={on_submit}>
|
||||
{
|
||||
if let Some(ref error) = *error_message {
|
||||
html! {
|
||||
<div class="error-message">
|
||||
{error}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="calendar-name">{"Calendar Name *"}</label>
|
||||
<input
|
||||
id="calendar-name"
|
||||
type="text"
|
||||
value={(*calendar_name).clone()}
|
||||
oninput={on_name_change}
|
||||
placeholder="Enter calendar name"
|
||||
maxlength="100"
|
||||
disabled={*is_creating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="calendar-description">{"Description"}</label>
|
||||
<textarea
|
||||
id="calendar-description"
|
||||
value={(*description).clone()}
|
||||
oninput={on_description_change}
|
||||
placeholder="Optional calendar description"
|
||||
rows="3"
|
||||
disabled={*is_creating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{"Calendar Color"}</label>
|
||||
<div class="color-grid">
|
||||
{
|
||||
props.available_colors.iter().enumerate().map(|(index, color)| {
|
||||
let color = color.clone();
|
||||
let selected_color = selected_color.clone();
|
||||
let is_selected = selected_color.as_ref() == Some(&color);
|
||||
let on_color_select = {
|
||||
let color = color.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
selected_color.set(Some(color.clone()));
|
||||
})
|
||||
};
|
||||
|
||||
let class_name = if is_selected {
|
||||
"color-option selected"
|
||||
} else {
|
||||
"color-option"
|
||||
};
|
||||
|
||||
html! {
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
class={class_name}
|
||||
style={format!("background-color: {}", color)}
|
||||
onclick={on_color_select}
|
||||
disabled={*is_creating}
|
||||
/>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</div>
|
||||
<p class="color-help-text">{"Optional: Choose a color for your calendar"}</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cancel-button"
|
||||
onclick={props.on_close.reform(|_| ())}
|
||||
disabled={*is_creating}
|
||||
>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="create-button"
|
||||
disabled={*is_creating}
|
||||
>
|
||||
{
|
||||
if *is_creating {
|
||||
"Creating..."
|
||||
} else {
|
||||
"Create Calendar"
|
||||
}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
857
frontend/src/components/create_event_modal.rs
Normal file
857
frontend/src/components/create_event_modal.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
98
frontend/src/components/event_context_menu.rs
Normal file
98
frontend/src/components/event_context_menu.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::MouseEvent;
|
||||
use crate::models::ical::VEvent;
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum DeleteAction {
|
||||
DeleteThis,
|
||||
DeleteFollowing,
|
||||
DeleteSeries,
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct EventContextMenuProps {
|
||||
pub is_open: bool,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub event: Option<VEvent>,
|
||||
pub on_edit: Callback<()>,
|
||||
pub on_delete: Callback<DeleteAction>,
|
||||
pub on_close: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(EventContextMenu)]
|
||||
pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
||||
let menu_ref = use_node_ref();
|
||||
|
||||
if !props.is_open {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
let style = format!(
|
||||
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
|
||||
props.x, props.y
|
||||
);
|
||||
|
||||
// Check if the event is recurring
|
||||
let is_recurring = props.event.as_ref()
|
||||
.map(|event| event.rrule.is_some())
|
||||
.unwrap_or(false);
|
||||
|
||||
let on_edit_click = {
|
||||
let on_edit = props.on_edit.clone();
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_edit.emit(());
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
let create_delete_callback = |action: DeleteAction| {
|
||||
let on_delete = props.on_delete.clone();
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_delete.emit(action.clone());
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div
|
||||
ref={menu_ref}
|
||||
class="context-menu"
|
||||
style={style}
|
||||
>
|
||||
<div class="context-menu-item" onclick={on_edit_click}>
|
||||
<span class="context-menu-icon">{"✏️"}</span>
|
||||
{"Edit Event"}
|
||||
</div>
|
||||
{
|
||||
if is_recurring {
|
||||
html! {
|
||||
<>
|
||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
||||
<span class="context-menu-icon">{"🗑️"}</span>
|
||||
{"Delete This Event"}
|
||||
</div>
|
||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteFollowing)}>
|
||||
<span class="context-menu-icon">{"🗑️"}</span>
|
||||
{"Delete Following Events"}
|
||||
</div>
|
||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteSeries)}>
|
||||
<span class="context-menu-icon">{"🗑️"}</span>
|
||||
{"Delete Entire Series"}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
||||
<span class="context-menu-icon">{"🗑️"}</span>
|
||||
{"Delete Event"}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
239
frontend/src/components/event_modal.rs
Normal file
239
frontend/src/components/event_modal.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
use yew::prelude::*;
|
||||
use chrono::{DateTime, Utc};
|
||||
use crate::models::ical::VEvent;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct EventModalProps {
|
||||
pub event: Option<VEvent>,
|
||||
pub on_close: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn EventModal(props: &EventModalProps) -> Html {
|
||||
let close_modal = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |_| {
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
let backdrop_click = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
if e.target() == e.current_target() {
|
||||
on_close.emit(());
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if let Some(ref event) = props.event {
|
||||
html! {
|
||||
<div class="modal-backdrop" onclick={backdrop_click}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>{"Event Details"}</h3>
|
||||
<button class="modal-close" onclick={close_modal}>{"×"}</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="event-detail">
|
||||
<strong>{"Title:"}</strong>
|
||||
<span>{event.get_title()}</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
if let Some(ref description) = event.description {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Description:"}</strong>
|
||||
<span>{description}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="event-detail">
|
||||
<strong>{"Start:"}</strong>
|
||||
<span>{format_datetime(&event.dtstart, event.all_day)}</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
if let Some(ref end) = event.dtend {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"End:"}</strong>
|
||||
<span>{format_datetime(end, event.all_day)}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="event-detail">
|
||||
<strong>{"All Day:"}</strong>
|
||||
<span>{if event.all_day { "Yes" } else { "No" }}</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
if let Some(ref location) = event.location {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Location:"}</strong>
|
||||
<span>{location}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="event-detail">
|
||||
<strong>{"Status:"}</strong>
|
||||
<span>{event.get_status_display()}</span>
|
||||
</div>
|
||||
|
||||
<div class="event-detail">
|
||||
<strong>{"Privacy:"}</strong>
|
||||
<span>{event.get_class_display()}</span>
|
||||
</div>
|
||||
|
||||
<div class="event-detail">
|
||||
<strong>{"Priority:"}</strong>
|
||||
<span>{event.get_priority_display()}</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
if let Some(ref organizer) = event.organizer {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Organizer:"}</strong>
|
||||
<span>{organizer.cal_address.clone()}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if !event.attendees.is_empty() {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Attendees:"}</strong>
|
||||
<span>{event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(", ")}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if !event.categories.is_empty() {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Categories:"}</strong>
|
||||
<span>{event.categories.join(", ")}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if let Some(ref recurrence) = event.rrule {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Repeats:"}</strong>
|
||||
<span>{format_recurrence_rule(recurrence)}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Repeats:"}</strong>
|
||||
<span>{"No"}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if !event.alarms.is_empty() {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Reminders:"}</strong>
|
||||
<span>{"Alarms configured"}</span> /* TODO: Convert VAlarm to displayable format */
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Reminders:"}</strong>
|
||||
<span>{"None"}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if let Some(ref created) = event.created {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Created:"}</strong>
|
||||
<span>{format_datetime(created, false)}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if let Some(ref modified) = event.last_modified {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Last Modified:"}</strong>
|
||||
<span>{format_datetime(modified, false)}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
|
||||
if all_day {
|
||||
dt.format("%B %d, %Y").to_string()
|
||||
} else {
|
||||
dt.format("%B %d, %Y at %I:%M %p").to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_recurrence_rule(rrule: &str) -> String {
|
||||
// Basic parsing of RRULE to display user-friendly text
|
||||
if rrule.contains("FREQ=DAILY") {
|
||||
"Daily".to_string()
|
||||
} else if rrule.contains("FREQ=WEEKLY") {
|
||||
"Weekly".to_string()
|
||||
} else if rrule.contains("FREQ=MONTHLY") {
|
||||
"Monthly".to_string()
|
||||
} else if rrule.contains("FREQ=YEARLY") {
|
||||
"Yearly".to_string()
|
||||
} else {
|
||||
// Show the raw rule if we can't parse it
|
||||
format!("Custom ({})", rrule)
|
||||
}
|
||||
}
|
||||
|
||||
206
frontend/src/components/login.rs
Normal file
206
frontend/src/components/login.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlInputElement;
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct LoginProps {
|
||||
pub on_login: Callback<String>, // Callback with JWT token
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn Login(props: &LoginProps) -> Html {
|
||||
let server_url = use_state(String::new);
|
||||
let username = use_state(String::new);
|
||||
let password = use_state(String::new);
|
||||
let error_message = use_state(|| Option::<String>::None);
|
||||
let is_loading = use_state(|| false);
|
||||
|
||||
let server_url_ref = use_node_ref();
|
||||
let username_ref = use_node_ref();
|
||||
let password_ref = use_node_ref();
|
||||
|
||||
let on_server_url_change = {
|
||||
let server_url = server_url.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||
server_url.set(target.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_username_change = {
|
||||
let username = username.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||
username.set(target.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_password_change = {
|
||||
let password = password.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||
password.set(target.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_submit = {
|
||||
let server_url = server_url.clone();
|
||||
let username = username.clone();
|
||||
let password = password.clone();
|
||||
let error_message = error_message.clone();
|
||||
let is_loading = is_loading.clone();
|
||||
let on_login = props.on_login.clone();
|
||||
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
let server_url = (*server_url).clone();
|
||||
let username = (*username).clone();
|
||||
let password = (*password).clone();
|
||||
let error_message = error_message.clone();
|
||||
let is_loading = is_loading.clone();
|
||||
let on_login = on_login.clone();
|
||||
|
||||
// Basic client-side validation
|
||||
if server_url.trim().is_empty() || username.trim().is_empty() || password.is_empty() {
|
||||
error_message.set(Some("Please fill in all fields".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
is_loading.set(true);
|
||||
error_message.set(None);
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
web_sys::console::log_1(&"🚀 Starting login process...".into());
|
||||
match perform_login(server_url.clone(), username.clone(), password.clone()).await {
|
||||
Ok((token, credentials)) => {
|
||||
web_sys::console::log_1(&"✅ Login successful!".into());
|
||||
// Store token and credentials in local storage
|
||||
if let Err(_) = LocalStorage::set("auth_token", &token) {
|
||||
error_message.set(Some("Failed to store authentication token".to_string()));
|
||||
is_loading.set(false);
|
||||
return;
|
||||
}
|
||||
if let Err(_) = LocalStorage::set("caldav_credentials", &credentials) {
|
||||
error_message.set(Some("Failed to store credentials".to_string()));
|
||||
is_loading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
is_loading.set(false);
|
||||
on_login.emit(token);
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::log_1(&format!("❌ Login failed: {}", err).into());
|
||||
error_message.set(Some(err));
|
||||
is_loading.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="login-container">
|
||||
<div class="login-form">
|
||||
<h2>{"Sign In to CalDAV"}</h2>
|
||||
<form onsubmit={on_submit}>
|
||||
<div class="form-group">
|
||||
<label for="server_url">{"CalDAV Server URL"}</label>
|
||||
<input
|
||||
ref={server_url_ref}
|
||||
type="text"
|
||||
id="server_url"
|
||||
placeholder="https://your-caldav-server.com/dav/"
|
||||
value={(*server_url).clone()}
|
||||
onchange={on_server_url_change}
|
||||
disabled={*is_loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">{"Username"}</label>
|
||||
<input
|
||||
ref={username_ref}
|
||||
type="text"
|
||||
id="username"
|
||||
placeholder="Enter your username"
|
||||
value={(*username).clone()}
|
||||
onchange={on_username_change}
|
||||
disabled={*is_loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">{"Password"}</label>
|
||||
<input
|
||||
ref={password_ref}
|
||||
type="password"
|
||||
id="password"
|
||||
placeholder="Enter your password"
|
||||
value={(*password).clone()}
|
||||
onchange={on_password_change}
|
||||
disabled={*is_loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
if let Some(error) = (*error_message).clone() {
|
||||
html! { <div class="error-message">{error}</div> }
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
<button type="submit" disabled={*is_loading} class="login-button">
|
||||
{
|
||||
if *is_loading {
|
||||
"Signing in..."
|
||||
} else {
|
||||
"Sign In"
|
||||
}
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-links">
|
||||
<p>{"Enter your CalDAV server credentials to connect to your calendar"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform login using the CalDAV auth service
|
||||
async fn perform_login(server_url: String, username: String, password: String) -> Result<(String, String), String> {
|
||||
use crate::auth::{AuthService, CalDAVLoginRequest};
|
||||
use serde_json;
|
||||
|
||||
web_sys::console::log_1(&format!("📡 Creating auth service and request...").into());
|
||||
|
||||
let auth_service = AuthService::new();
|
||||
let request = CalDAVLoginRequest {
|
||||
server_url: server_url.clone(),
|
||||
username: username.clone(),
|
||||
password: password.clone()
|
||||
};
|
||||
|
||||
web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into());
|
||||
|
||||
match auth_service.login(request).await {
|
||||
Ok(response) => {
|
||||
web_sys::console::log_1(&format!("✅ Backend responded successfully").into());
|
||||
// Create credentials object to store
|
||||
let credentials = serde_json::json!({
|
||||
"server_url": server_url,
|
||||
"username": username,
|
||||
"password": password
|
||||
});
|
||||
Ok((response.token, credentials.to_string()))
|
||||
},
|
||||
Err(err) => {
|
||||
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
|
||||
Err(err)
|
||||
},
|
||||
}
|
||||
}
|
||||
31
frontend/src/components/mod.rs
Normal file
31
frontend/src/components/mod.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
pub mod login;
|
||||
pub mod calendar;
|
||||
pub mod calendar_header;
|
||||
pub mod month_view;
|
||||
pub mod week_view;
|
||||
pub mod event_modal;
|
||||
pub mod create_calendar_modal;
|
||||
pub mod context_menu;
|
||||
pub mod event_context_menu;
|
||||
pub mod calendar_context_menu;
|
||||
pub mod create_event_modal;
|
||||
pub mod sidebar;
|
||||
pub mod calendar_list_item;
|
||||
pub mod route_handler;
|
||||
pub mod recurring_edit_modal;
|
||||
|
||||
pub use login::Login;
|
||||
pub use calendar::Calendar;
|
||||
pub use calendar_header::CalendarHeader;
|
||||
pub use month_view::MonthView;
|
||||
pub use week_view::WeekView;
|
||||
pub use event_modal::EventModal;
|
||||
pub use create_calendar_modal::CreateCalendarModal;
|
||||
pub use context_menu::ContextMenu;
|
||||
pub use event_context_menu::{EventContextMenu, DeleteAction};
|
||||
pub use calendar_context_menu::CalendarContextMenu;
|
||||
pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType};
|
||||
pub use sidebar::{Sidebar, ViewMode, Theme};
|
||||
pub use calendar_list_item::CalendarListItem;
|
||||
pub use route_handler::RouteHandler;
|
||||
pub use recurring_edit_modal::{RecurringEditModal, RecurringEditAction};
|
||||
268
frontend/src/components/month_view.rs
Normal file
268
frontend/src/components/month_view.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
use yew::prelude::*;
|
||||
use chrono::{Datelike, NaiveDate, Weekday};
|
||||
use std::collections::HashMap;
|
||||
use web_sys::window;
|
||||
use wasm_bindgen::{prelude::*, JsCast};
|
||||
use crate::services::calendar_service::UserInfo;
|
||||
use crate::models::ical::VEvent;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct MonthViewProps {
|
||||
pub current_month: NaiveDate,
|
||||
pub today: NaiveDate,
|
||||
pub events: HashMap<NaiveDate, Vec<VEvent>>,
|
||||
pub on_event_click: Callback<VEvent>,
|
||||
#[prop_or_default]
|
||||
pub refreshing_event_uid: Option<String>,
|
||||
#[prop_or_default]
|
||||
pub user_info: Option<UserInfo>,
|
||||
#[prop_or_default]
|
||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||
#[prop_or_default]
|
||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
||||
#[prop_or_default]
|
||||
pub selected_date: Option<NaiveDate>,
|
||||
#[prop_or_default]
|
||||
pub on_day_select: Option<Callback<NaiveDate>>,
|
||||
}
|
||||
|
||||
#[function_component(MonthView)]
|
||||
pub fn month_view(props: &MonthViewProps) -> Html {
|
||||
let max_events_per_day = use_state(|| 4); // Default to 4 events max
|
||||
let first_day_of_month = props.current_month.with_day(1).unwrap();
|
||||
let days_in_month = get_days_in_month(props.current_month);
|
||||
let first_weekday = first_day_of_month.weekday();
|
||||
let days_from_prev_month = get_days_from_previous_month(props.current_month, first_weekday);
|
||||
|
||||
// Calculate maximum events that can fit based on available height
|
||||
let calculate_max_events = {
|
||||
let max_events_per_day = max_events_per_day.clone();
|
||||
move || {
|
||||
// Since we're using CSS Grid with equal row heights,
|
||||
// we can estimate based on typical calendar dimensions
|
||||
// Typical calendar height is around 600-800px for 6 rows
|
||||
// Each row gets ~100-133px, minus day number and padding leaves ~70-100px
|
||||
// Each event is ~18px, so we can fit ~3-4 events + "+n more" indicator
|
||||
max_events_per_day.set(3);
|
||||
}
|
||||
};
|
||||
|
||||
// Setup resize handler and initial calculation
|
||||
{
|
||||
let calculate_max_events = calculate_max_events.clone();
|
||||
use_effect_with((), move |_| {
|
||||
let calculate_max_events_clone = calculate_max_events.clone();
|
||||
|
||||
// Initial calculation with a slight delay to ensure DOM is ready
|
||||
if let Some(window) = window() {
|
||||
let timeout_closure = Closure::wrap(Box::new(move || {
|
||||
calculate_max_events_clone();
|
||||
}) as Box<dyn FnMut()>);
|
||||
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
timeout_closure.as_ref().unchecked_ref(),
|
||||
100,
|
||||
);
|
||||
timeout_closure.forget();
|
||||
}
|
||||
|
||||
// Setup resize listener
|
||||
let resize_closure = Closure::wrap(Box::new(move || {
|
||||
calculate_max_events();
|
||||
}) as Box<dyn Fn()>);
|
||||
|
||||
if let Some(window) = window() {
|
||||
let _ = window.add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref());
|
||||
resize_closure.forget(); // Keep the closure alive
|
||||
}
|
||||
|
||||
|| {}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to get calendar color for an event
|
||||
let get_event_color = |event: &VEvent| -> String {
|
||||
if let Some(user_info) = &props.user_info {
|
||||
if let Some(calendar_path) = &event.calendar_path {
|
||||
if let Some(calendar) = user_info.calendars.iter()
|
||||
.find(|cal| &cal.path == calendar_path) {
|
||||
return calendar.color.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
"#3B82F6".to_string()
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="calendar-grid">
|
||||
// Weekday headers
|
||||
<div class="weekday-header">{"Sun"}</div>
|
||||
<div class="weekday-header">{"Mon"}</div>
|
||||
<div class="weekday-header">{"Tue"}</div>
|
||||
<div class="weekday-header">{"Wed"}</div>
|
||||
<div class="weekday-header">{"Thu"}</div>
|
||||
<div class="weekday-header">{"Fri"}</div>
|
||||
<div class="weekday-header">{"Sat"}</div>
|
||||
|
||||
// Days from previous month (grayed out)
|
||||
{
|
||||
days_from_prev_month.iter().map(|day| {
|
||||
html! {
|
||||
<div class="calendar-day prev-month">{*day}</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
|
||||
// Days of the current month
|
||||
{
|
||||
(1..=days_in_month).map(|day| {
|
||||
let date = props.current_month.with_day(day).unwrap();
|
||||
let is_today = date == props.today;
|
||||
let is_selected = props.selected_date == Some(date);
|
||||
let day_events = props.events.get(&date).cloned().unwrap_or_default();
|
||||
|
||||
// Calculate visible events and overflow
|
||||
let max_events = *max_events_per_day as usize;
|
||||
let visible_events: Vec<_> = day_events.iter().take(max_events).collect();
|
||||
let hidden_count = day_events.len().saturating_sub(max_events);
|
||||
|
||||
html! {
|
||||
<div
|
||||
class={classes!(
|
||||
"calendar-day",
|
||||
if is_today { Some("today") } else { None },
|
||||
if is_selected { Some("selected") } else { None }
|
||||
)}
|
||||
onclick={
|
||||
if let Some(callback) = &props.on_day_select {
|
||||
let callback = callback.clone();
|
||||
Some(Callback::from(move |e: web_sys::MouseEvent| {
|
||||
e.stop_propagation(); // Prevent other handlers
|
||||
callback.emit(date);
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
oncontextmenu={
|
||||
if let Some(callback) = &props.on_calendar_context_menu {
|
||||
let callback = callback.clone();
|
||||
Some(Callback::from(move |e: web_sys::MouseEvent| {
|
||||
e.prevent_default();
|
||||
callback.emit((e, date));
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
>
|
||||
<div class="day-number">{day}</div>
|
||||
<div class="day-events">
|
||||
{
|
||||
visible_events.iter().map(|event| {
|
||||
let event_color = get_event_color(event);
|
||||
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
||||
|
||||
let onclick = {
|
||||
let on_event_click = props.on_event_click.clone();
|
||||
let event = (*event).clone();
|
||||
Callback::from(move |_: web_sys::MouseEvent| {
|
||||
on_event_click.emit(event.clone());
|
||||
})
|
||||
};
|
||||
|
||||
let oncontextmenu = {
|
||||
if let Some(callback) = &props.on_event_context_menu {
|
||||
let callback = callback.clone();
|
||||
let event = (*event).clone();
|
||||
Some(Callback::from(move |e: web_sys::MouseEvent| {
|
||||
e.prevent_default();
|
||||
callback.emit((e, event.clone()));
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
html! {
|
||||
<div
|
||||
class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })}
|
||||
style={format!("background-color: {}", event_color)}
|
||||
{onclick}
|
||||
{oncontextmenu}
|
||||
>
|
||||
{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
{
|
||||
if hidden_count > 0 {
|
||||
html! {
|
||||
<div class="more-events-indicator">
|
||||
{format!("+{} more", hidden_count)}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
|
||||
{ render_next_month_days(days_from_prev_month.len(), days_in_month) }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
|
||||
let total_slots = 42; // 6 rows x 7 days
|
||||
let used_slots = prev_days_count + current_days_count as usize;
|
||||
let remaining_slots = if used_slots < total_slots { total_slots - used_slots } else { 0 };
|
||||
|
||||
(1..=remaining_slots).map(|day| {
|
||||
html! {
|
||||
<div class="calendar-day next-month">{day}</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
|
||||
fn get_days_in_month(date: NaiveDate) -> u32 {
|
||||
NaiveDate::from_ymd_opt(
|
||||
if date.month() == 12 { date.year() + 1 } else { date.year() },
|
||||
if date.month() == 12 { 1 } else { date.month() + 1 },
|
||||
1
|
||||
)
|
||||
.unwrap()
|
||||
.pred_opt()
|
||||
.unwrap()
|
||||
.day()
|
||||
}
|
||||
|
||||
fn get_days_from_previous_month(current_month: NaiveDate, first_weekday: Weekday) -> Vec<u32> {
|
||||
let days_before = match first_weekday {
|
||||
Weekday::Sun => 0,
|
||||
Weekday::Mon => 1,
|
||||
Weekday::Tue => 2,
|
||||
Weekday::Wed => 3,
|
||||
Weekday::Thu => 4,
|
||||
Weekday::Fri => 5,
|
||||
Weekday::Sat => 6,
|
||||
};
|
||||
|
||||
if days_before == 0 {
|
||||
vec![]
|
||||
} else {
|
||||
let prev_month = if current_month.month() == 1 {
|
||||
NaiveDate::from_ymd_opt(current_month.year() - 1, 12, 1).unwrap()
|
||||
} else {
|
||||
NaiveDate::from_ymd_opt(current_month.year(), current_month.month() - 1, 1).unwrap()
|
||||
};
|
||||
|
||||
let prev_month_days = get_days_in_month(prev_month);
|
||||
((prev_month_days - days_before as u32 + 1)..=prev_month_days).collect()
|
||||
}
|
||||
}
|
||||
93
frontend/src/components/recurring_edit_modal.rs
Normal file
93
frontend/src/components/recurring_edit_modal.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use yew::prelude::*;
|
||||
use chrono::NaiveDateTime;
|
||||
use crate::models::ical::VEvent;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum RecurringEditAction {
|
||||
ThisEvent,
|
||||
FutureEvents,
|
||||
AllEvents,
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct RecurringEditModalProps {
|
||||
pub show: bool,
|
||||
pub event: VEvent,
|
||||
pub new_start: NaiveDateTime,
|
||||
pub new_end: NaiveDateTime,
|
||||
pub on_choice: Callback<RecurringEditAction>,
|
||||
pub on_cancel: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(RecurringEditModal)]
|
||||
pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html {
|
||||
if !props.show {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
let event_title = props.event.summary.as_ref().map(|s| s.as_str()).unwrap_or("Untitled Event");
|
||||
|
||||
let on_this_event = {
|
||||
let on_choice = props.on_choice.clone();
|
||||
Callback::from(move |_| {
|
||||
on_choice.emit(RecurringEditAction::ThisEvent);
|
||||
})
|
||||
};
|
||||
|
||||
let on_future_events = {
|
||||
let on_choice = props.on_choice.clone();
|
||||
Callback::from(move |_| {
|
||||
on_choice.emit(RecurringEditAction::FutureEvents);
|
||||
})
|
||||
};
|
||||
|
||||
let on_all_events = {
|
||||
let on_choice = props.on_choice.clone();
|
||||
Callback::from(move |_| {
|
||||
on_choice.emit(RecurringEditAction::AllEvents);
|
||||
})
|
||||
};
|
||||
|
||||
let on_cancel = {
|
||||
let on_cancel = props.on_cancel.clone();
|
||||
Callback::from(move |_| {
|
||||
on_cancel.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="modal-backdrop">
|
||||
<div class="modal-content recurring-edit-modal">
|
||||
<div class="modal-header">
|
||||
<h3>{"Edit Recurring Event"}</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{format!("You're modifying \"{}\" which is part of a recurring series.", event_title)}</p>
|
||||
<p>{"How would you like to apply this change?"}</p>
|
||||
|
||||
<div class="recurring-edit-options">
|
||||
<button class="btn btn-primary recurring-option" onclick={on_this_event}>
|
||||
<div class="option-title">{"This event only"}</div>
|
||||
<div class="option-description">{"Change only this occurrence"}</div>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-primary recurring-option" onclick={on_future_events}>
|
||||
<div class="option-title">{"This and future events"}</div>
|
||||
<div class="option-description">{"Change this occurrence and all future occurrences"}</div>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-primary recurring-option" onclick={on_all_events}>
|
||||
<div class="option-title">{"All events in series"}</div>
|
||||
<div class="option-description">{"Change all occurrences in the series"}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick={on_cancel}>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
297
frontend/src/components/route_handler.rs
Normal file
297
frontend/src/components/route_handler.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
use crate::components::{Login, ViewMode};
|
||||
use crate::services::calendar_service::UserInfo;
|
||||
use crate::models::ical::VEvent;
|
||||
|
||||
#[derive(Clone, Routable, PartialEq)]
|
||||
pub enum Route {
|
||||
#[at("/")]
|
||||
Home,
|
||||
#[at("/login")]
|
||||
Login,
|
||||
#[at("/calendar")]
|
||||
Calendar,
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct RouteHandlerProps {
|
||||
pub auth_token: Option<String>,
|
||||
pub user_info: Option<UserInfo>,
|
||||
pub on_login: Callback<String>,
|
||||
#[prop_or_default]
|
||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||
#[prop_or_default]
|
||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
||||
#[prop_or_default]
|
||||
pub view: ViewMode,
|
||||
#[prop_or_default]
|
||||
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
||||
#[prop_or_default]
|
||||
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)>>,
|
||||
#[prop_or_default]
|
||||
pub context_menus_open: bool,
|
||||
}
|
||||
|
||||
#[function_component(RouteHandler)]
|
||||
pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
||||
let auth_token = props.auth_token.clone();
|
||||
let user_info = props.user_info.clone();
|
||||
let on_login = props.on_login.clone();
|
||||
let on_event_context_menu = props.on_event_context_menu.clone();
|
||||
let on_calendar_context_menu = props.on_calendar_context_menu.clone();
|
||||
let view = props.view.clone();
|
||||
let on_create_event_request = props.on_create_event_request.clone();
|
||||
let on_event_update_request = props.on_event_update_request.clone();
|
||||
let context_menus_open = props.context_menus_open;
|
||||
|
||||
html! {
|
||||
<Switch<Route> render={move |route| {
|
||||
let auth_token = auth_token.clone();
|
||||
let user_info = user_info.clone();
|
||||
let on_login = on_login.clone();
|
||||
let on_event_context_menu = on_event_context_menu.clone();
|
||||
let on_calendar_context_menu = on_calendar_context_menu.clone();
|
||||
let view = view.clone();
|
||||
let on_create_event_request = on_create_event_request.clone();
|
||||
let on_event_update_request = on_event_update_request.clone();
|
||||
let context_menus_open = context_menus_open;
|
||||
|
||||
match route {
|
||||
Route::Home => {
|
||||
if auth_token.is_some() {
|
||||
html! { <Redirect<Route> to={Route::Calendar}/> }
|
||||
} else {
|
||||
html! { <Redirect<Route> to={Route::Login}/> }
|
||||
}
|
||||
}
|
||||
Route::Login => {
|
||||
if auth_token.is_some() {
|
||||
html! { <Redirect<Route> to={Route::Calendar}/> }
|
||||
} else {
|
||||
html! { <Login {on_login} /> }
|
||||
}
|
||||
}
|
||||
Route::Calendar => {
|
||||
if auth_token.is_some() {
|
||||
html! {
|
||||
<CalendarView
|
||||
user_info={user_info}
|
||||
on_event_context_menu={on_event_context_menu}
|
||||
on_calendar_context_menu={on_calendar_context_menu}
|
||||
view={view}
|
||||
on_create_event_request={on_create_event_request}
|
||||
on_event_update_request={on_event_update_request}
|
||||
context_menus_open={context_menus_open}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! { <Redirect<Route> to={Route::Login}/> }
|
||||
}
|
||||
}
|
||||
}
|
||||
}} />
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CalendarViewProps {
|
||||
pub user_info: Option<UserInfo>,
|
||||
#[prop_or_default]
|
||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||
#[prop_or_default]
|
||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
||||
#[prop_or_default]
|
||||
pub view: ViewMode,
|
||||
#[prop_or_default]
|
||||
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
||||
#[prop_or_default]
|
||||
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)>>,
|
||||
#[prop_or_default]
|
||||
pub context_menus_open: bool,
|
||||
}
|
||||
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use crate::services::CalendarService;
|
||||
use crate::components::Calendar;
|
||||
use std::collections::HashMap;
|
||||
use chrono::{Local, NaiveDate, Datelike};
|
||||
|
||||
#[function_component(CalendarView)]
|
||||
pub fn calendar_view(props: &CalendarViewProps) -> Html {
|
||||
let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new());
|
||||
let loading = use_state(|| true);
|
||||
let error = use_state(|| None::<String>);
|
||||
let refreshing_event = use_state(|| None::<String>);
|
||||
|
||||
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
||||
|
||||
|
||||
let today = Local::now().date_naive();
|
||||
let current_year = today.year();
|
||||
let current_month = today.month();
|
||||
|
||||
let on_event_click = {
|
||||
let events = events.clone();
|
||||
let refreshing_event = refreshing_event.clone();
|
||||
let auth_token = auth_token.clone();
|
||||
|
||||
Callback::from(move |event: VEvent| {
|
||||
if let Some(token) = auth_token.clone() {
|
||||
let events = events.clone();
|
||||
let refreshing_event = refreshing_event.clone();
|
||||
let uid = event.uid.clone();
|
||||
|
||||
refreshing_event.set(Some(uid.clone()));
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let calendar_service = CalendarService::new();
|
||||
|
||||
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
|
||||
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
|
||||
credentials["password"].as_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
match calendar_service.refresh_event(&token, &password, &uid).await {
|
||||
Ok(Some(refreshed_event)) => {
|
||||
let refreshed_vevent = VEvent::from_calendar_event(&refreshed_event);
|
||||
let mut updated_events = (*events).clone();
|
||||
|
||||
for (_, day_events) in updated_events.iter_mut() {
|
||||
day_events.retain(|e| e.uid != uid);
|
||||
}
|
||||
|
||||
if refreshed_vevent.rrule.is_some() {
|
||||
let new_occurrences = CalendarService::expand_recurring_events(vec![refreshed_vevent.clone()]);
|
||||
|
||||
for occurrence in new_occurrences {
|
||||
let date = occurrence.get_date();
|
||||
updated_events.entry(date)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(occurrence);
|
||||
}
|
||||
} else {
|
||||
let date = refreshed_vevent.get_date();
|
||||
updated_events.entry(date)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(refreshed_vevent);
|
||||
}
|
||||
|
||||
events.set(updated_events);
|
||||
}
|
||||
Ok(None) => {
|
||||
let mut updated_events = (*events).clone();
|
||||
for (_, day_events) in updated_events.iter_mut() {
|
||||
day_events.retain(|e| e.uid != uid);
|
||||
}
|
||||
events.set(updated_events);
|
||||
}
|
||||
Err(_err) => {
|
||||
}
|
||||
}
|
||||
|
||||
refreshing_event.set(None);
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
{
|
||||
let events = events.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let auth_token = auth_token.clone();
|
||||
|
||||
use_effect_with((), move |_| {
|
||||
if let Some(token) = auth_token {
|
||||
let events = events.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let calendar_service = CalendarService::new();
|
||||
|
||||
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
|
||||
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
|
||||
credentials["password"].as_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
match calendar_service.fetch_events_for_month_vevent(&token, &password, current_year, current_month).await {
|
||||
Ok(vevents) => {
|
||||
let grouped_events = CalendarService::group_events_by_date(vevents);
|
||||
events.set(grouped_events);
|
||||
loading.set(false);
|
||||
}
|
||||
Err(err) => {
|
||||
error.set(Some(format!("Failed to load events: {}", err)));
|
||||
loading.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
loading.set(false);
|
||||
error.set(Some("No authentication token found".to_string()));
|
||||
}
|
||||
|
||||
|| ()
|
||||
});
|
||||
}
|
||||
|
||||
html! {
|
||||
<div class="calendar-view">
|
||||
{
|
||||
if *loading {
|
||||
html! {
|
||||
<div class="calendar-loading">
|
||||
<p>{"Loading calendar events..."}</p>
|
||||
</div>
|
||||
}
|
||||
} else if let Some(err) = (*error).clone() {
|
||||
let dummy_callback = Callback::from(|_: VEvent| {});
|
||||
html! {
|
||||
<div class="calendar-error">
|
||||
<p>{format!("Error: {}", err)}</p>
|
||||
<Calendar
|
||||
events={HashMap::new()}
|
||||
on_event_click={dummy_callback}
|
||||
refreshing_event_uid={(*refreshing_event).clone()}
|
||||
user_info={props.user_info.clone()}
|
||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||
view={props.view.clone()}
|
||||
on_create_event_request={props.on_create_event_request.clone()}
|
||||
on_event_update_request={props.on_event_update_request.clone()}
|
||||
context_menus_open={props.context_menus_open}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<Calendar
|
||||
events={(*events).clone()}
|
||||
on_event_click={on_event_click}
|
||||
refreshing_event_uid={(*refreshing_event).clone()}
|
||||
user_info={props.user_info.clone()}
|
||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||
view={props.view.clone()}
|
||||
on_create_event_request={props.on_create_event_request.clone()}
|
||||
on_event_update_request={props.on_event_update_request.clone()}
|
||||
context_menus_open={props.context_menus_open}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
195
frontend/src/components/sidebar.rs
Normal file
195
frontend/src/components/sidebar.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
use web_sys::HtmlSelectElement;
|
||||
use crate::services::calendar_service::UserInfo;
|
||||
use crate::components::CalendarListItem;
|
||||
|
||||
#[derive(Clone, Routable, PartialEq)]
|
||||
pub enum Route {
|
||||
#[at("/")]
|
||||
Home,
|
||||
#[at("/login")]
|
||||
Login,
|
||||
#[at("/calendar")]
|
||||
Calendar,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum ViewMode {
|
||||
Month,
|
||||
Week,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum Theme {
|
||||
Default,
|
||||
Ocean,
|
||||
Forest,
|
||||
Sunset,
|
||||
Purple,
|
||||
Dark,
|
||||
Rose,
|
||||
Mint,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
|
||||
pub fn value(&self) -> &'static str {
|
||||
match self {
|
||||
Theme::Default => "default",
|
||||
Theme::Ocean => "ocean",
|
||||
Theme::Forest => "forest",
|
||||
Theme::Sunset => "sunset",
|
||||
Theme::Purple => "purple",
|
||||
Theme::Dark => "dark",
|
||||
Theme::Rose => "rose",
|
||||
Theme::Mint => "mint",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_value(value: &str) -> Self {
|
||||
match value {
|
||||
"ocean" => Theme::Ocean,
|
||||
"forest" => Theme::Forest,
|
||||
"sunset" => Theme::Sunset,
|
||||
"purple" => Theme::Purple,
|
||||
"dark" => Theme::Dark,
|
||||
"rose" => Theme::Rose,
|
||||
"mint" => Theme::Mint,
|
||||
_ => Theme::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ViewMode {
|
||||
fn default() -> Self {
|
||||
ViewMode::Month
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct SidebarProps {
|
||||
pub user_info: Option<UserInfo>,
|
||||
pub on_logout: Callback<()>,
|
||||
pub on_create_calendar: Callback<()>,
|
||||
pub color_picker_open: Option<String>,
|
||||
pub on_color_change: Callback<(String, String)>,
|
||||
pub on_color_picker_toggle: Callback<String>,
|
||||
pub available_colors: Vec<String>,
|
||||
pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
|
||||
pub current_view: ViewMode,
|
||||
pub on_view_change: Callback<ViewMode>,
|
||||
pub current_theme: Theme,
|
||||
pub on_theme_change: Callback<Theme>,
|
||||
}
|
||||
|
||||
#[function_component(Sidebar)]
|
||||
pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
let on_view_change = {
|
||||
let on_view_change = props.on_view_change.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_dyn_into::<HtmlSelectElement>();
|
||||
if let Some(select) = target {
|
||||
let value = select.value();
|
||||
let new_view = match value.as_str() {
|
||||
"week" => ViewMode::Week,
|
||||
_ => ViewMode::Month,
|
||||
};
|
||||
on_view_change.emit(new_view);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_theme_change = {
|
||||
let on_theme_change = props.on_theme_change.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_dyn_into::<HtmlSelectElement>();
|
||||
if let Some(select) = target {
|
||||
let value = select.value();
|
||||
let new_theme = Theme::from_value(&value);
|
||||
on_theme_change.emit(new_theme);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<aside class="app-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1>{"Calendar App"}</h1>
|
||||
{
|
||||
if let Some(ref info) = props.user_info {
|
||||
html! {
|
||||
<div class="user-info">
|
||||
<div class="username">{&info.username}</div>
|
||||
<div class="server-url">{&info.server_url}</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! { <div class="user-info loading">{"Loading..."}</div> }
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<Link<Route> to={Route::Calendar} classes="nav-link">{"Calendar"}</Link<Route>>
|
||||
</nav>
|
||||
{
|
||||
if let Some(ref info) = props.user_info {
|
||||
if !info.calendars.is_empty() {
|
||||
html! {
|
||||
<div class="calendar-list">
|
||||
<h3>{"My Calendars"}</h3>
|
||||
<ul>
|
||||
{
|
||||
info.calendars.iter().map(|cal| {
|
||||
html! {
|
||||
<CalendarListItem
|
||||
calendar={cal.clone()}
|
||||
color_picker_open={props.color_picker_open.as_ref() == Some(&cal.path)}
|
||||
on_color_change={props.on_color_change.clone()}
|
||||
on_color_picker_toggle={props.on_color_picker_toggle.clone()}
|
||||
available_colors={props.available_colors.clone()}
|
||||
on_context_menu={props.on_calendar_context_menu.clone()}
|
||||
/>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! { <div class="no-calendars">{"No calendars found"}</div> }
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
<div class="sidebar-footer">
|
||||
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
|
||||
{"+ Create Calendar"}
|
||||
</button>
|
||||
|
||||
<div class="view-selector">
|
||||
<select class="view-selector-dropdown" onchange={on_view_change}>
|
||||
<option value="month" selected={matches!(props.current_view, ViewMode::Month)}>{"Month"}</option>
|
||||
<option value="week" selected={matches!(props.current_view, ViewMode::Week)}>{"Week"}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="theme-selector">
|
||||
<select class="theme-selector-dropdown" onchange={on_theme_change}>
|
||||
<option value="default" selected={matches!(props.current_theme, Theme::Default)}>{"🎨 Default"}</option>
|
||||
<option value="ocean" selected={matches!(props.current_theme, Theme::Ocean)}>{"🌊 Ocean"}</option>
|
||||
<option value="forest" selected={matches!(props.current_theme, Theme::Forest)}>{"🌲 Forest"}</option>
|
||||
<option value="sunset" selected={matches!(props.current_theme, Theme::Sunset)}>{"🌅 Sunset"}</option>
|
||||
<option value="purple" selected={matches!(props.current_theme, Theme::Purple)}>{"💜 Purple"}</option>
|
||||
<option value="dark" selected={matches!(props.current_theme, Theme::Dark)}>{"🌙 Dark"}</option>
|
||||
<option value="rose" selected={matches!(props.current_theme, Theme::Rose)}>{"🌹 Rose"}</option>
|
||||
<option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"🍃 Mint"}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button>
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
}
|
||||
1047
frontend/src/components/week_view.rs
Normal file
1047
frontend/src/components/week_view.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user