Implement calendar context menu with event creation modal

- Add CalendarContextMenu component for right-click on calendar days
- Add CreateEventModal component with comprehensive event creation form
- Integrate context menu detection to avoid conflicts between event/calendar menus
- Add form validation and date/time selection with all-day toggle
- Connect modal through component hierarchy from app to calendar

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-28 22:20:22 -04:00
parent 7e62e3b7e3
commit 5c966b2571
6 changed files with 480 additions and 3 deletions

View File

@@ -2,8 +2,9 @@ use yew::prelude::*;
use yew_router::prelude::*;
use gloo_storage::{LocalStorage, Storage};
use web_sys::MouseEvent;
use crate::components::{Sidebar, CreateCalendarModal, ContextMenu, EventContextMenu, RouteHandler};
use crate::components::{Sidebar, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler};
use crate::services::{CalendarService, calendar_service::{UserInfo, CalendarEvent}};
use chrono::NaiveDate;
#[function_component]
@@ -21,6 +22,11 @@ pub fn App() -> Html {
let event_context_menu_open = use_state(|| false);
let event_context_menu_pos = use_state(|| (0i32, 0i32));
let event_context_menu_event = use_state(|| -> Option<CalendarEvent> { None });
let calendar_context_menu_open = use_state(|| false);
let calendar_context_menu_pos = use_state(|| (0i32, 0i32));
let calendar_context_menu_date = use_state(|| -> Option<NaiveDate> { None });
let create_event_modal_open = use_state(|| false);
let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None });
let available_colors = [
"#3B82F6", "#10B981", "#F59E0B", "#EF4444",
@@ -103,10 +109,12 @@ pub fn App() -> Html {
let color_picker_open = color_picker_open.clone();
let context_menu_open = context_menu_open.clone();
let event_context_menu_open = event_context_menu_open.clone();
let calendar_context_menu_open = calendar_context_menu_open.clone();
Callback::from(move |_: MouseEvent| {
color_picker_open.set(None);
context_menu_open.set(false);
event_context_menu_open.set(false);
calendar_context_menu_open.set(false);
})
};
@@ -164,6 +172,41 @@ pub fn App() -> Html {
})
};
let on_calendar_date_context_menu = {
let calendar_context_menu_open = calendar_context_menu_open.clone();
let calendar_context_menu_pos = calendar_context_menu_pos.clone();
let calendar_context_menu_date = calendar_context_menu_date.clone();
Callback::from(move |(event, date): (MouseEvent, NaiveDate)| {
calendar_context_menu_open.set(true);
calendar_context_menu_pos.set((event.client_x(), event.client_y()));
calendar_context_menu_date.set(Some(date));
})
};
let on_create_event_click = {
let create_event_modal_open = create_event_modal_open.clone();
let selected_date_for_event = selected_date_for_event.clone();
let calendar_context_menu_date = calendar_context_menu_date.clone();
Callback::from(move |_: MouseEvent| {
create_event_modal_open.set(true);
selected_date_for_event.set((*calendar_context_menu_date).clone());
})
};
let on_event_create = {
let create_event_modal_open = create_event_modal_open.clone();
let auth_token = auth_token.clone();
Callback::from(move |event_data: EventCreationData| {
web_sys::console::log_1(&format!("Creating event: {:?}", event_data).into());
create_event_modal_open.set(false);
// TODO: Implement actual event creation API call
// For now, just close the modal and refresh
if (*auth_token).is_some() {
web_sys::window().unwrap().location().reload().unwrap();
}
})
};
let refresh_calendars = {
let auth_token = auth_token.clone();
let user_info = user_info.clone();
@@ -234,6 +277,7 @@ pub fn App() -> Html {
user_info={(*user_info).clone()}
on_login={on_login.clone()}
on_event_context_menu={Some(on_event_context_menu.clone())}
on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())}
/>
</main>
</>
@@ -246,6 +290,7 @@ pub fn App() -> Html {
user_info={(*user_info).clone()}
on_login={on_login.clone()}
on_event_context_menu={Some(on_event_context_menu.clone())}
on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())}
/>
</div>
}
@@ -357,7 +402,7 @@ pub fn App() -> Html {
let refresh_calendars = refresh_calendars.clone();
move |_: MouseEvent| {
if let (Some(token), Some(event)) = ((*auth_token).clone(), (*event_context_menu_event).clone()) {
let refresh_calendars = refresh_calendars.clone();
let _refresh_calendars = refresh_calendars.clone();
let event_context_menu_open = event_context_menu_open.clone();
wasm_bindgen_futures::spawn_local(async move {
@@ -394,6 +439,27 @@ pub fn App() -> Html {
}
})}
/>
<CalendarContextMenu
is_open={*calendar_context_menu_open}
x={calendar_context_menu_pos.0}
y={calendar_context_menu_pos.1}
on_close={Callback::from({
let calendar_context_menu_open = calendar_context_menu_open.clone();
move |_| calendar_context_menu_open.set(false)
})}
on_create_event={on_create_event_click}
/>
<CreateEventModal
is_open={*create_event_modal_open}
selected_date={(*selected_date_for_event).clone()}
on_close={Callback::from({
let create_event_modal_open = create_event_modal_open.clone();
move |_| create_event_modal_open.set(false)
})}
on_create={on_event_create}
/>
</div>
</BrowserRouter>
}

View File

@@ -3,6 +3,7 @@ use chrono::{Datelike, Local, NaiveDate, Duration, Weekday};
use std::collections::HashMap;
use crate::services::calendar_service::{CalendarEvent, UserInfo};
use crate::components::EventModal;
use wasm_bindgen::JsCast;
#[derive(Properties, PartialEq)]
pub struct CalendarProps {
@@ -15,6 +16,8 @@ pub struct CalendarProps {
pub user_info: Option<UserInfo>,
#[prop_or_default]
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>,
#[prop_or_default]
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
}
#[function_component]
@@ -115,9 +118,34 @@ pub fn Calendar(props: &CalendarProps) -> Html {
let on_click = Callback::from(move |_| {
selected_day_clone.set(date);
});
let on_context_menu = {
let on_calendar_context_menu = props.on_calendar_context_menu.clone();
Callback::from(move |e: MouseEvent| {
// Only show context menu if we're not right-clicking on an event
if let Some(target) = e.target() {
if let Ok(element) = target.dyn_into::<web_sys::Element>() {
// Check if the click is on an event box or inside one
let mut current = Some(element);
while let Some(el) = current {
if el.class_name().contains("event-box") {
return; // Don't show calendar context menu on events
}
current = el.parent_element();
}
}
}
e.prevent_default();
e.stop_propagation();
if let Some(callback) = &on_calendar_context_menu {
callback.emit((e, date));
}
})
};
html! {
<div class={classes!(classes)} onclick={on_click}>
<div class={classes!(classes)} onclick={on_click} oncontextmenu={on_context_menu}>
<div class="day-number">{day}</div>
{
if !events.is_empty() {

View 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>
}
}

View File

@@ -0,0 +1,323 @@
use yew::prelude::*;
use web_sys::{HtmlInputElement, HtmlTextAreaElement};
use chrono::{NaiveDate, NaiveTime};
#[derive(Properties, PartialEq)]
pub struct CreateEventModalProps {
pub is_open: bool,
pub selected_date: Option<NaiveDate>,
pub on_close: Callback<()>,
pub on_create: Callback<EventCreationData>,
}
#[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,
}
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,
}
}
}
#[function_component(CreateEventModal)]
pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
let event_data = use_state(|| EventCreationData::default());
// Initialize with selected date if provided
use_effect_with((props.selected_date, props.is_open), {
let event_data = event_data.clone();
move |(selected_date, is_open)| {
if *is_open {
if let Some(date) = selected_date {
let mut data = (*event_data).clone();
data.start_date = *date;
data.end_date = *date;
event_data.set(data);
} else {
event_data.set(EventCreationData::default());
}
}
|| ()
}
});
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_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_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_create_click = {
let event_data = event_data.clone();
let on_create = props.on_create.clone();
Callback::from(move |_: MouseEvent| {
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>{"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-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>
<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_create_click}
disabled={data.title.trim().is_empty()}
>
{"Create Event"}
</button>
</div>
</div>
</div>
}
}

View File

@@ -4,6 +4,8 @@ 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;
@@ -14,6 +16,8 @@ pub use event_modal::EventModal;
pub use create_calendar_modal::CreateCalendarModal;
pub use context_menu::ContextMenu;
pub use event_context_menu::EventContextMenu;
pub use calendar_context_menu::CalendarContextMenu;
pub use create_event_modal::{CreateEventModal, EventCreationData};
pub use sidebar::Sidebar;
pub use calendar_list_item::CalendarListItem;
pub use route_handler::RouteHandler;

View File

@@ -20,6 +20,8 @@ pub struct RouteHandlerProps {
pub on_login: Callback<String>,
#[prop_or_default]
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>,
#[prop_or_default]
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
}
#[function_component(RouteHandler)]
@@ -28,6 +30,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
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();
html! {
<Switch<Route> render={move |route| {
@@ -35,6 +38,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
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();
match route {
Route::Home => {
@@ -57,6 +61,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
<CalendarView
user_info={user_info}
on_event_context_menu={on_event_context_menu}
on_calendar_context_menu={on_calendar_context_menu}
/>
}
} else {
@@ -73,6 +78,8 @@ pub struct CalendarViewProps {
pub user_info: Option<UserInfo>,
#[prop_or_default]
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>,
#[prop_or_default]
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
}
use gloo_storage::{LocalStorage, Storage};
@@ -230,6 +237,7 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html {
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()}
/>
</div>
}
@@ -241,6 +249,7 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html {
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()}
/>
}
}